mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-25 16:03:31 +00:00
feat: persist user and workspace scoped browsing filters, searches, sorting and resource history views across forms, templates, automations, audit, integrations, API keys and settings while preserving URL intent and transient editor state
This commit is contained in:
@@ -20,6 +20,8 @@
|
||||
import { NoAccess } from "@/components/layout/NoAccess";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseSearchSchema } from "@/lib/browse-other-lists";
|
||||
import {
|
||||
ActivityIcon,
|
||||
AlertCircleIcon,
|
||||
@@ -62,7 +64,7 @@ export default function APIKeysPage() {
|
||||
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
const [selected, setSelected] = React.useState<APIKey | null>(null);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [search, setSearch] = useBrowseState("api-keys.list.search", "", browseSearchSchema);
|
||||
|
||||
const all = keys.data?.data ?? [];
|
||||
const filtered = search.trim()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import AuditPage from "./page";
|
||||
|
||||
const query = vi.hoisted(() => vi.fn(() => ({ data: { data: [], pagination: {} } })));
|
||||
vi.mock("@/hooks/useFeatureAccess", () => ({ default: () => ({ loading: false, canManage: true }) }));
|
||||
vi.mock("@/lib/api/hooks/app/audit/useAuditLogs", () => ({ default: query }));
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const value = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
const key = (field: string) => `warmbly:browse:v1:user:workspace:audit.list.${field}`;
|
||||
|
||||
describe("audit browsing reset", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores search and preserves the intentional reset that clears server filters but not search", () => {
|
||||
const page = render(<AuditPage />, { wrapper: Owner });
|
||||
fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: "Alice" } });
|
||||
page.unmount();
|
||||
sessionStorage.setItem(key("action"), JSON.stringify({ value: "create" }));
|
||||
sessionStorage.setItem(key("entity-type"), JSON.stringify({ value: "contact" }));
|
||||
sessionStorage.setItem(key("date"), JSON.stringify({ value: "2026-10-08" }));
|
||||
const restored = render(<AuditPage />, { wrapper: Owner });
|
||||
expect(screen.getAllByRole("textbox")[0]).toHaveValue("Alice");
|
||||
expect(query).toHaveBeenCalledWith({ action: "create", entity_type: "contact", date: "2026-10-08", cursor: undefined, limit: 50 });
|
||||
fireEvent.click(screen.getAllByTitle("Clear filters")[0]);
|
||||
restored.unmount();
|
||||
query.mockClear();
|
||||
render(<AuditPage />, { wrapper: Owner });
|
||||
expect(screen.getAllByRole("textbox")[0]).toHaveValue("Alice");
|
||||
expect(query).toHaveBeenCalledWith({ action: undefined, entity_type: undefined, date: undefined, cursor: undefined, limit: 50 });
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@
|
||||
// changes payload, and filters in the topbar.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { auditActionSchema, auditDateSchema, auditEntitySchema, browseSearchSchema } from "@/lib/browse-other-lists";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ChevronDownIcon,
|
||||
@@ -45,22 +47,8 @@ import {
|
||||
import type AuditLog from "@/lib/api/models/app/audit/AuditLog";
|
||||
import type { AuditAction, AuditEntityType } from "@/lib/api/models/app/audit/AuditLog";
|
||||
|
||||
const ACTIONS: AuditAction[] = [
|
||||
"create", "update", "delete",
|
||||
"invite", "remove", "transfer",
|
||||
"start", "stop", "pause", "resume", "send",
|
||||
"connect", "disconnect", "rotate", "revoke",
|
||||
"duplicate", "export", "import", "api_call",
|
||||
];
|
||||
|
||||
const ENTITY_TYPES: AuditEntityType[] = [
|
||||
"campaign", "campaign_lead", "contact", "email_account", "step", "template", "email_image",
|
||||
"api_key", "webhook", "integration", "warmup_routing_rule",
|
||||
"organization", "organization_member", "invitation",
|
||||
"folder", "tag", "category", "subscription", "settings",
|
||||
"crm_pipeline", "crm_stage", "crm_deal", "crm_task", "crm_note",
|
||||
"unibox", "user",
|
||||
];
|
||||
const ACTIONS = auditActionSchema.options;
|
||||
const ENTITY_TYPES = auditEntitySchema.options;
|
||||
|
||||
const ACTION_TONE: Record<string, { dot: string; text: string }> = {
|
||||
create: { dot: "bg-emerald-500", text: "text-emerald-700" },
|
||||
@@ -86,10 +74,10 @@ const ACTION_TONE: Record<string, { dot: string; text: string }> = {
|
||||
|
||||
export default function AuditPage() {
|
||||
const access = useFeatureAccess();
|
||||
const [action, setAction] = React.useState<AuditAction | undefined>();
|
||||
const [entityType, setEntityType] = React.useState<AuditEntityType | undefined>();
|
||||
const [date, setDate] = React.useState<string>("");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [action, setAction] = useBrowseState<AuditAction | undefined>("audit.list.action", undefined, auditActionSchema.optional());
|
||||
const [entityType, setEntityType] = useBrowseState<AuditEntityType | undefined>("audit.list.entity-type", undefined, auditEntitySchema.optional());
|
||||
const [date, setDate] = useBrowseState("audit.list.date", "", auditDateSchema);
|
||||
const [search, setSearch] = useBrowseState("audit.list.search", "", browseSearchSchema);
|
||||
const [cursors, setCursors] = React.useState<string[]>([]);
|
||||
|
||||
const params = React.useMemo(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import {
|
||||
aiUsageRangeSchema, auditActionSchema, auditDateSchema, auditEntitySchema, billingIntervalSchema,
|
||||
browseFlagSchema, browseSearchSchema, formAnalyticsRangeSchema, formsSortSchema, formsStatusSchema,
|
||||
oauthAppsTabSchema, slackTabSchema, submissionsStatusSchema, webhookStatusSchema, webhookTabSchema,
|
||||
} from "@/lib/browse-other-lists";
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const value = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
|
||||
const cases: { name: string; schema: z.ZodType<unknown>; initial: unknown; changed: unknown; invalid: unknown[] }[] = [
|
||||
{ name: "search", schema: browseSearchSchema, initial: "", changed: "Alice", invalid: [null, 12, {}] },
|
||||
{ name: "review", schema: browseFlagSchema, initial: false, changed: true, invalid: [null, "true", 1] },
|
||||
{ name: "forms.status", schema: formsStatusSchema, initial: "all", changed: "published", invalid: [null, "live"] },
|
||||
{ name: "forms.sort", schema: formsSortSchema, initial: { key: "created", dir: -1 }, changed: { key: "name", dir: 1 }, invalid: [null, [], { key: "__proto__", dir: 1 }, { key: "name", dir: 0 }, { key: "name", dir: "1" }, { key: "name", dir: Infinity }, { key: "name", dir: 1, stale: true }] },
|
||||
{ name: "form.submissions.status", schema: submissionsStatusSchema, initial: "all", changed: "junk", invalid: [null, "draft"] },
|
||||
{ name: "form.analytics.range", schema: formAnalyticsRangeSchema, initial: "30d", changed: "90d", invalid: [30, "365d"] },
|
||||
{ name: "audit.action", schema: auditActionSchema.optional(), initial: undefined, changed: "export", invalid: [null, "unknown", "__proto__"] },
|
||||
{ name: "audit.entity", schema: auditEntitySchema.optional(), initial: undefined, changed: "crm_deal", invalid: [null, "unknown"] },
|
||||
{ name: "audit.date", schema: auditDateSchema, initial: "", changed: "2024-02-29", invalid: [null, 0, "2025-02-29", "2026-04-31", "2026-10-8", "2026-10-08T00:00:00Z"] },
|
||||
{ name: "billing.interval", schema: billingIntervalSchema, initial: "annual", changed: "monthly", invalid: [null, "weekly"] },
|
||||
{ name: "billing.usage.range", schema: aiUsageRangeSchema, initial: 30, changed: 90, invalid: [null, "30", 31, NaN, Infinity] },
|
||||
{ name: "oauth.tab", schema: oauthAppsTabSchema, initial: "apps", changed: "authorized", invalid: [null, "history"] },
|
||||
{ name: "webhook.tab", schema: webhookTabSchema, initial: "overview", changed: "deliveries", invalid: [null, "history"] },
|
||||
{ name: "webhook.status", schema: webhookStatusSchema, initial: "", changed: "failed", invalid: [null, "success"] },
|
||||
{ name: "slack.tab", schema: slackTabSchema, initial: "overview", changed: "members", invalid: [null, "settings"] },
|
||||
];
|
||||
|
||||
describe("other-list browsing schemas", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it.each(cases)("restores and explicitly clears $name on remount", ({ name, schema, initial, changed }) => {
|
||||
const mounted = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
|
||||
act(() => mounted.result.current[1](changed));
|
||||
mounted.unmount();
|
||||
const refreshed = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
|
||||
expect(refreshed.result.current[0]).toEqual(changed);
|
||||
act(() => refreshed.result.current[1](initial));
|
||||
refreshed.unmount();
|
||||
const cleared = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
|
||||
expect(cleared.result.current[0]).toEqual(initial);
|
||||
});
|
||||
|
||||
it.each(cases)("rejects stale and malformed $name values without coercion", ({ schema, invalid }) => {
|
||||
for (const value of invalid) expect(schema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import FormsPage from "./page";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
|
||||
vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => vi.fn() }));
|
||||
vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true, useWriteGuard: () => ({ guard: (fn: () => void) => fn }) }));
|
||||
vi.mock("@/lib/api/hooks/app/forms", () => ({
|
||||
useForms: () => ({ data: [
|
||||
{ id: "one", name: "Signup", status: "published", category_ids: ["label"], created_at: new Date(), updated_at: new Date(), views_count: 1, starts_count: 1, submissions_count: 1 },
|
||||
{ id: "two", name: "Contact", status: "draft", category_ids: [], created_at: new Date(), updated_at: new Date(), views_count: 0, starts_count: 0, submissions_count: 0 },
|
||||
] }),
|
||||
useCreateForm: () => ({}), useUpdateForm: () => ({}), useDeleteForm: () => ({}),
|
||||
}));
|
||||
vi.mock("@/components/ui/select-menu", () => ({
|
||||
SelectMenu: ({ value, onChange, options, "aria-label": label }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[]; "aria-label": string }) =>
|
||||
<select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)}>{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}</select>,
|
||||
}));
|
||||
|
||||
function Owner({ children, categories = [{ id: "label", title: "Leads" }] }: { children: ReactNode; categories?: { id: string; title: string }[] }) {
|
||||
const value = { user: { id: "user", categories } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
const key = (field: string) => `warmbly:browse:v1:user:workspace:forms.list.${field}`;
|
||||
|
||||
describe("forms list browsing", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores search, status, label and sorting but not row selections, and remembers clearing", () => {
|
||||
const page = render(<FormsPage />, { wrapper: Owner });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Name" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Search forms…"), { target: { value: "Signup" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Published/ }));
|
||||
fireEvent.change(screen.getByLabelText("Filter by label"), { target: { value: "label" } });
|
||||
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
||||
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
page.unmount();
|
||||
const refreshed = render(<FormsPage />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search forms…")).toHaveValue("Signup");
|
||||
expect(screen.getByLabelText("Filter by label")).toHaveValue("label");
|
||||
expect(screen.getByRole("button", { name: /Published/ })).toHaveClass("bg-white");
|
||||
expect(JSON.parse(sessionStorage.getItem(key("sort"))!)).toEqual({ value: { key: "name", dir: 1 } });
|
||||
expect(screen.queryByText(/selected$/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^All/ }));
|
||||
fireEvent.change(screen.getByLabelText("Filter by label"), { target: { value: "" } });
|
||||
refreshed.unmount();
|
||||
render(<FormsPage />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search forms…")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Filter by label")).toHaveValue("");
|
||||
expect(JSON.parse(sessionStorage.getItem(key("status"))!)).toEqual({ value: "all" });
|
||||
});
|
||||
|
||||
it("retains a removable label when profile metadata is absent instead of resetting it", () => {
|
||||
sessionStorage.setItem(key("category"), JSON.stringify({ value: "label" }));
|
||||
const page = render(<Owner categories={[]}><FormsPage /></Owner>);
|
||||
expect(screen.getByLabelText("Filter by label")).toHaveValue("label");
|
||||
expect(screen.getByRole("option", { name: "Unavailable label" })).toBeInTheDocument();
|
||||
page.rerender(<Owner><FormsPage /></Owner>);
|
||||
expect(screen.getByLabelText("Filter by label")).toHaveValue("label");
|
||||
expect(screen.getByRole("option", { name: "Leads" })).toBeInTheDocument();
|
||||
page.rerender(<Owner categories={[]}><FormsPage /></Owner>);
|
||||
fireEvent.change(screen.getByLabelText("Filter by label"), { target: { value: "" } });
|
||||
expect(JSON.parse(sessionStorage.getItem(key("category"))!)).toEqual({ value: "" });
|
||||
});
|
||||
|
||||
it("rejects a stale sort key before sorting live rows", () => {
|
||||
sessionStorage.setItem(key("sort"), JSON.stringify({ value: { key: "__proto__", dir: 1 } }));
|
||||
render(<FormsPage />, { wrapper: Owner });
|
||||
expect(screen.getByText("Signup")).toBeInTheDocument();
|
||||
expect(screen.getByText("Contact")).toBeInTheDocument();
|
||||
expect(JSON.parse(sessionStorage.getItem(key("sort"))!)).toEqual({ value: { key: "created", dir: -1 } });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
// row opens the builder.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseSearchSchema, formsSortSchema, formsStatusSchema } from "@/lib/browse-other-lists";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, ClipboardListIcon, LinkIcon, Loader2Icon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast/headless";
|
||||
@@ -86,10 +88,10 @@ function FormsList() {
|
||||
const categories = React.useMemo(() => user?.categories ?? [], [user?.categories]);
|
||||
const categoryById = React.useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [status, setStatusFilter] = React.useState<StatusFilter>("all");
|
||||
const [category, setCategory] = React.useState("");
|
||||
const [sort, setSort] = React.useState<{ key: SortKey; dir: 1 | -1 }>({ key: "created", dir: -1 });
|
||||
const [query, setQuery] = useBrowseState("forms.list.query", "", browseSearchSchema);
|
||||
const [status, setStatusFilter] = useBrowseState<StatusFilter>("forms.list.status", "all", formsStatusSchema);
|
||||
const [category, setCategory] = useBrowseState("forms.list.category", "", browseSearchSchema);
|
||||
const [sort, setSort] = useBrowseState<{ key: SortKey; dir: 1 | -1 }>("forms.list.sort", { key: "created", dir: -1 }, formsSortSchema);
|
||||
const [selected, setSelected] = React.useState<string[]>([]);
|
||||
const [bulkBusy, setBulkBusy] = React.useState(false);
|
||||
|
||||
@@ -277,12 +279,13 @@ function FormsList() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
{(categories.length > 0 || category) && (
|
||||
<SelectMenu
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
options={[
|
||||
{ value: "", label: "All labels" },
|
||||
...(category && !categoryById.has(category) ? [{ value: category, label: "Unavailable label" }] : []),
|
||||
...categories.map((c) => ({ value: c.id, label: c.title })),
|
||||
]}
|
||||
aria-label="Filter by label"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { slackTabSchema } from "@/lib/browse-other-lists";
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
@@ -81,7 +83,7 @@ export default function ConnectionDetail({
|
||||
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const confirm = useConfirm();
|
||||
const [slackTab, setSlackTab] = React.useState<SlackTab>("overview");
|
||||
const [slackTab, setSlackTab] = useBrowseState<SlackTab>(`integrations.${connection.id}.slack.tab`, "overview", slackTabSchema);
|
||||
|
||||
const conn = detail.data?.connection ?? connection;
|
||||
const runs = detail.data?.runs ?? [];
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import type { IntegrationCatalogEntry } from "@/lib/api/models/app/integrations/Integration";
|
||||
import SearchBox from "./SearchBox";
|
||||
import { builtinItem, readFilters, writeFilters } from "./model";
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const value = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
|
||||
const props = { items: [], onOpenItem: vi.fn(), onOpenCategory: vi.fn(), onSearchAll: vi.fn() };
|
||||
|
||||
describe("integration search persistence", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores suggestion search, isolates routes and persists an explicit clear", () => {
|
||||
const page = render(<SearchBox {...props} browseKey="home" />, { wrapper: Owner });
|
||||
fireEvent.change(screen.getByPlaceholderText("Search apps"), { target: { value: "Slack" } });
|
||||
page.unmount();
|
||||
const refreshed = render(<SearchBox {...props} browseKey="home" />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search apps")).toHaveValue("Slack");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
refreshed.rerender(<SearchBox {...props} browseKey="app/slack" />);
|
||||
expect(screen.getByPlaceholderText("Search apps")).toHaveValue("");
|
||||
fireEvent.change(screen.getByPlaceholderText("Search apps"), { target: { value: "calendar" } });
|
||||
refreshed.rerender(<SearchBox {...props} browseKey="home" />);
|
||||
expect(screen.getByPlaceholderText("Search apps")).toHaveValue("Slack");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
refreshed.unmount();
|
||||
render(<SearchBox {...props} browseKey="home" />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search apps")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("clears the stored suggestion text on selection without persisting its dropdown", () => {
|
||||
const slack = builtinItem({ provider: "slack", name: "Slack", tagline: "Team chat", category: "notifications", auth_method: "oauth", configured: true } as IntegrationCatalogEntry);
|
||||
const page = render(<SearchBox {...props} items={[slack]} browseKey="home" />, { wrapper: Owner });
|
||||
fireEvent.change(screen.getByPlaceholderText("Search apps"), { target: { value: "Slack" } });
|
||||
fireEvent.click(screen.getByRole("option", { name: /Team chat/ }));
|
||||
expect(props.onOpenItem).toHaveBeenCalledWith(slack);
|
||||
page.unmount();
|
||||
render(<SearchBox {...props} items={[slack]} browseKey="home" />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search apps")).toHaveValue("");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves URL filters authoritative and removes q on explicit clear", () => {
|
||||
sessionStorage.setItem("warmbly:browse:v1:user:workspace:integrations.store.all.search", JSON.stringify({ value: "stale session" }));
|
||||
function URLSearch({ query }: { query: string }) {
|
||||
const [filters, setFilters] = useState(() => readFilters(new URLSearchParams(query)));
|
||||
const url = writeFilters(filters);
|
||||
return <>
|
||||
<SearchBox {...props} browseKey="all" filter={{ value: filters.q, onChange: (q) => setFilters((old) => ({ ...old, q })) }} />
|
||||
<output data-testid="url">{url.toString()}</output>
|
||||
</>;
|
||||
}
|
||||
const linked = render(<URLSearch query="q=deep+link&view=list" />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search apps")).toHaveValue("deep link");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
expect(screen.getByTestId("url")).toHaveTextContent("view=list");
|
||||
expect(screen.getByTestId("url").textContent).not.toContain("q=");
|
||||
linked.unmount();
|
||||
render(<URLSearch query="view=list" />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search apps")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
// type, arrow keys move, and Enter opens the highlighted one or every result.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseSearchSchema } from "@/lib/browse-other-lists";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowRightIcon, CornerDownLeftIcon } from "lucide-react";
|
||||
|
||||
@@ -26,6 +28,7 @@ export default function SearchBox({
|
||||
onOpenCategory,
|
||||
onSearchAll,
|
||||
filter,
|
||||
browseKey,
|
||||
placeholder = "Search apps",
|
||||
}: {
|
||||
items: StoreItem[];
|
||||
@@ -34,9 +37,10 @@ export default function SearchBox({
|
||||
onSearchAll: (q: string) => void;
|
||||
/** On a browse page: the live query and its setter, instead of suggestions. */
|
||||
filter?: { value: string; onChange: (q: string) => void };
|
||||
browseKey: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [draft, setDraft] = useBrowseState(`integrations.store.${browseKey}.search`, "", browseSearchSchema);
|
||||
const value = filter ? filter.value : draft;
|
||||
const setValue = filter ? filter.onChange : setDraft;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
@@ -409,6 +409,7 @@ function IntegrationsStore({ route, routeKey }: { route: Route; routeKey: string
|
||||
const browsing = route.kind === "all" || route.kind === "connected" || route.kind === "community" || route.kind === "category";
|
||||
const search = (
|
||||
<SearchBox
|
||||
browseKey={routeKey || "home"}
|
||||
items={allItems}
|
||||
onOpenItem={openItem}
|
||||
onOpenCategory={(c) => go(`${STORE_BASE}/category/${c}`)}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// until you commit.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { aiUsageRangeSchema } from "@/lib/browse-other-lists";
|
||||
import toast from "react-hot-toast/headless";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Loader2Icon, RotateCcwIcon } from "lucide-react";
|
||||
@@ -86,7 +88,7 @@ function toForm(s: AISpendSettings): SpendForm {
|
||||
type RangeDays = 7 | 30 | 90;
|
||||
|
||||
export default function AIUsageCard() {
|
||||
const [range, setRange] = React.useState<RangeDays>(30);
|
||||
const [range, setRange] = useBrowseState<RangeDays>("settings.billing.ai-usage.range", 30, aiUsageRangeSchema);
|
||||
const usage = useCreditUsage(range);
|
||||
const settings = useCreditSettings();
|
||||
const credits = useCredits();
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
// from the marketing site's pricing.astro.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { billingIntervalSchema } from "@/lib/browse-other-lists";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
CheckIcon,
|
||||
@@ -88,7 +90,7 @@ export default function BillingSettingsPage() {
|
||||
const [codeInput, setCodeInput] = React.useState("");
|
||||
const [applied, setApplied] = React.useState<DiscountPreview | null>(null);
|
||||
const [billingInterval, setBillingInterval] =
|
||||
React.useState<BillingInterval>("annual");
|
||||
useBrowseState<BillingInterval>("settings.billing.interval", "annual", billingIntervalSchema);
|
||||
const [salesOpen, setSalesOpen] = React.useState(false);
|
||||
|
||||
// ?pool=1 is where a linked instance's "Premium" button lands (and what
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// from, and what it did, so a person can judge the reversible actions that
|
||||
// run from day one and decide when to trust the suppression.
|
||||
|
||||
import { useState } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseFlagSchema } from "@/lib/browse-other-lists";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { CheckIcon, FilterIcon, InfoIcon, SparklesIcon } from "lucide-react";
|
||||
|
||||
@@ -175,7 +176,7 @@ function Row({ r }: { r: InboxTagRow }) {
|
||||
|
||||
export default function InboxTaggingPage() {
|
||||
const canView = usePermission("VIEW_ANALYTICS");
|
||||
const [needsReviewOnly, setNeedsReviewOnly] = useState(false);
|
||||
const [needsReviewOnly, setNeedsReviewOnly] = useBrowseState("settings.inbox-tagging.needs-review", false, browseFlagSchema);
|
||||
const q = useInboxTagReview(needsReviewOnly);
|
||||
const d = q.data?.pages[0];
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// (consent + token exchange) lives on the standalone /oauth/authorize page + API.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseSearchSchema, oauthAppsTabSchema, webhookStatusSchema } from "@/lib/browse-other-lists";
|
||||
import { createPortal } from "react-dom";
|
||||
import toast from "react-hot-toast/headless";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -415,8 +417,8 @@ const APP_DELIVERY_STATUSES: WebhookDeliveryStatus[] = ["pending", "in_flight",
|
||||
|
||||
// AppWebhookDeliveries — the cross-org delivery log (read-only, no redeliver).
|
||||
function AppWebhookDeliveries({ app, catalog }: { app: OAuthApplication; catalog: WebhookEventDescriptor[] }) {
|
||||
const [status, setStatus] = React.useState<WebhookDeliveryStatus | "">("");
|
||||
const [eventType, setEventType] = React.useState("");
|
||||
const [status, setStatus] = useBrowseState<WebhookDeliveryStatus | "">(`settings.oauth-apps.${app.id}.deliveries.status`, "", webhookStatusSchema);
|
||||
const [eventType, setEventType] = useBrowseState(`settings.oauth-apps.${app.id}.deliveries.event-type`, "", browseSearchSchema);
|
||||
|
||||
const first = useOAuthAppWebhookDeliveries(app.id, { status, eventType, limit: 25 });
|
||||
const [extra, setExtra] = React.useState<WebhookDelivery[]>([]);
|
||||
@@ -467,6 +469,7 @@ function AppWebhookDeliveries({ app, catalog }: { app: OAuthApplication; catalog
|
||||
className="h-7 rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-700 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100 max-w-[180px]"
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{eventType && !catalog.some((d) => d.type === eventType) && <option value={eventType}>{eventType} (unavailable)</option>}
|
||||
{catalog.map((d) => (
|
||||
<option key={d.type} value={d.type}>{d.type}</option>
|
||||
))}
|
||||
@@ -810,7 +813,7 @@ function AuthorizedTab() {
|
||||
export default function OAuthAppsPage() {
|
||||
const canManage = usePermission("MANAGE_API_KEYS");
|
||||
const apps = useOAuthApps();
|
||||
const [tab, setTab] = React.useState<"apps" | "authorized">("apps");
|
||||
const [tab, setTab] = useBrowseState("settings.oauth-apps.tab", "apps", oauthAppsTabSchema);
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
|
||||
if (!canManage) return <NoAccess feature="OAuth apps" permissionLabel="Manage API keys" />;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import WebhooksPage from "./page";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
catalog: [{ type: "contact.created", category: "contacts", description: "Contact created", firehose: false }],
|
||||
endpoints: ["one", "two"].map((id) => ({ id, url: `https://${id}.example/webhook`, description: "", event_types: [], enabled: true, consecutive_failures: 0, ownership_confirmed: true, created_at: new Date(), updated_at: new Date() })),
|
||||
deliveries: { data: { data: [], pagination: { next_cursor: null, has_more: false } } },
|
||||
query: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true }));
|
||||
vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: vi.fn() }) }));
|
||||
vi.mock("@/lib/api/hooks/app/webhooks/useWebhooks", () => ({
|
||||
useWebhooks: () => ({ data: { endpoints: api.endpoints, event_types: api.catalog } }),
|
||||
useWebhookDrops: () => ({ data: { drops: [] } }),
|
||||
useWebhookDeliveries: (query: unknown) => { api.query(query); return api.deliveries; },
|
||||
useCreateWebhook: () => ({}), useUpdateWebhook: () => ({}), useDeleteWebhook: () => ({}),
|
||||
useRotateWebhookSecret: () => ({}), useVerifyWebhook: () => ({}), useRedeliverDelivery: () => ({}),
|
||||
}));
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const value = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
const open = (id: string) => fireEvent.click(screen.getByRole("button", { name: new RegExp(`https://${id}.example/webhook`) }));
|
||||
const select = (index: number, value: string) => fireEvent.change(screen.getAllByRole("combobox")[index], { target: { value } });
|
||||
|
||||
describe("webhook delivery browsing", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
api.catalog = [{ type: "contact.created", category: "contacts", description: "Contact created", firehose: false }];
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores the tab and delivery filters only after reopening the drawer, and remembers clearing", () => {
|
||||
const page = render(<WebhooksPage />, { wrapper: Owner });
|
||||
open("one");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deliveries" }));
|
||||
select(0, "failed");
|
||||
select(1, "contact.created");
|
||||
page.unmount();
|
||||
api.query.mockClear();
|
||||
const refreshed = render(<WebhooksPage />, { wrapper: Owner });
|
||||
expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument();
|
||||
expect(api.query).not.toHaveBeenCalled();
|
||||
open("one");
|
||||
expect(screen.getAllByRole("combobox")[0]).toHaveValue("failed");
|
||||
expect(screen.getAllByRole("combobox")[1]).toHaveValue("contact.created");
|
||||
expect(api.query).toHaveBeenCalledWith({ endpointId: "one", status: "failed", eventType: "contact.created", limit: 25 });
|
||||
select(0, "");
|
||||
select(1, "");
|
||||
refreshed.unmount();
|
||||
render(<WebhooksPage />, { wrapper: Owner });
|
||||
open("one");
|
||||
expect(screen.getAllByRole("combobox")[0]).toHaveValue("");
|
||||
expect(screen.getAllByRole("combobox")[1]).toHaveValue("");
|
||||
});
|
||||
|
||||
it("isolates endpoint histories and keeps a removable event filter while the catalog is empty", () => {
|
||||
const page = render(<WebhooksPage />, { wrapper: Owner });
|
||||
open("one");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deliveries" }));
|
||||
select(0, "failed");
|
||||
select(1, "contact.created");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
open("two");
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deliveries" }));
|
||||
expect(screen.getAllByRole("combobox")[0]).toHaveValue("");
|
||||
expect(screen.getAllByRole("combobox")[1]).toHaveValue("");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
api.catalog = [];
|
||||
page.rerender(<WebhooksPage />);
|
||||
open("one");
|
||||
expect(screen.getAllByRole("combobox")[1]).toHaveValue("contact.created");
|
||||
expect(screen.getByRole("option", { name: "contact.created (unavailable)" })).toBeInTheDocument();
|
||||
select(1, "");
|
||||
expect(screen.getAllByRole("combobox")[1]).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
// sent when explicitly selected, and throttled drops surface as an info strip.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseSearchSchema, webhookStatusSchema, webhookTabSchema } from "@/lib/browse-other-lists";
|
||||
import { createPortal } from "react-dom";
|
||||
import toast from "react-hot-toast/headless";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -611,7 +613,7 @@ function EndpointDrawer({
|
||||
catalog: WebhookEventDescriptor[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = React.useState("overview");
|
||||
const [tab, setTab] = useBrowseState<string>(`settings.webhooks.${endpoint.id}.tab`, "overview", webhookTabSchema);
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
@@ -786,8 +788,8 @@ function OverviewTab({ endpoint }: { endpoint: WebhookEndpoint }) {
|
||||
const DELIVERY_STATUSES: WebhookDeliveryStatus[] = ["pending", "in_flight", "delivered", "failed", "abandoned"];
|
||||
|
||||
function DeliveriesTab({ endpoint, catalog }: { endpoint: WebhookEndpoint; catalog: WebhookEventDescriptor[] }) {
|
||||
const [status, setStatus] = React.useState<WebhookDeliveryStatus | "">("");
|
||||
const [eventType, setEventType] = React.useState("");
|
||||
const [status, setStatus] = useBrowseState<WebhookDeliveryStatus | "">(`settings.webhooks.${endpoint.id}.deliveries.status`, "", webhookStatusSchema);
|
||||
const [eventType, setEventType] = useBrowseState(`settings.webhooks.${endpoint.id}.deliveries.event-type`, "", browseSearchSchema);
|
||||
|
||||
// Live first page from the hook; extra pages are appended locally as the
|
||||
// user loads more (and the live page replaces the head on invalidation).
|
||||
@@ -846,6 +848,7 @@ function DeliveriesTab({ endpoint, catalog }: { endpoint: WebhookEndpoint; catal
|
||||
className="h-7 rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-700 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100 max-w-[180px]"
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{eventType && !catalog.some((d) => d.type === eventType) && <option value={eventType}>{eventType} (unavailable)</option>}
|
||||
{catalog.map((d) => (
|
||||
<option key={d.type} value={d.type}>{d.type}</option>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import TemplatesPage from "./page";
|
||||
|
||||
const query = vi.hoisted(() => vi.fn(() => ({ data: [] })));
|
||||
vi.mock("@/lib/api/hooks/app/templates/useTemplates", () => ({ default: query }));
|
||||
vi.mock("@/lib/api/hooks/app/templates/useCreateTemplate", () => ({ default: () => ({}) }));
|
||||
vi.mock("@/lib/api/hooks/app/templates/useUpdateTemplate", () => ({ default: () => ({}) }));
|
||||
vi.mock("@/lib/api/hooks/app/templates/useReorderTemplates", () => ({ default: () => ({}) }));
|
||||
vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => vi.fn() }));
|
||||
vi.mock("@/hooks/PresenceProvider", () => ({ usePresenceResource: () => {} }));
|
||||
vi.mock("@/components/app/presence/GlobalCursors", () => ({ useSuppressGlobalCursors: () => {} }));
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const value = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
const search = () => screen.getByPlaceholderText("Search by name or subject…");
|
||||
|
||||
describe("template search browsing", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("restores the initial debounced API query and remembers explicit search clearing", () => {
|
||||
const page = render(<TemplatesPage />, { wrapper: Owner });
|
||||
fireEvent.change(search(), { target: { value: "Welcome" } });
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
page.unmount();
|
||||
query.mockClear();
|
||||
const refreshed = render(<TemplatesPage />, { wrapper: Owner });
|
||||
expect(search()).toHaveValue("Welcome");
|
||||
expect(query).toHaveBeenNthCalledWith(1, "Welcome");
|
||||
fireEvent.click(screen.getByLabelText("Clear search"));
|
||||
refreshed.unmount();
|
||||
render(<TemplatesPage />, { wrapper: Owner });
|
||||
expect(search()).toHaveValue("");
|
||||
});
|
||||
|
||||
it("does not query the previous workspace's debounced search on a scope change", () => {
|
||||
sessionStorage.setItem("warmbly:browse:v1:user:workspace:templates.list.search", JSON.stringify({ value: "Welcome" }));
|
||||
render(<TemplatesPage />, { wrapper: Owner });
|
||||
query.mockClear();
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "another", name: "Another", role: "owner" } }));
|
||||
expect(search()).toHaveValue("");
|
||||
expect(query).not.toHaveBeenCalledWith("Welcome");
|
||||
query.mockClear();
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }));
|
||||
expect(search()).toHaveValue("Welcome");
|
||||
expect(query).not.toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@
|
||||
// page stays accessible without a dnd lib.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseSearchSchema } from "@/lib/browse-other-lists";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import {
|
||||
CopyIcon,
|
||||
FileTextIcon,
|
||||
@@ -62,8 +66,14 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
const VARIABLE_HINTS = VARIABLES;
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
const userID = React.useContext(UserContext)?.user.id;
|
||||
const organizationID = useAppStore((state) => state.currentOrganization?.id ?? "personal");
|
||||
return <TemplatesList key={`${userID}:${organizationID}`} />;
|
||||
}
|
||||
|
||||
function TemplatesList() {
|
||||
const [search, setSearch] = useBrowseState("templates.list.search", "", browseSearchSchema);
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState(search);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(t);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import useAutomationPanel from "./useAutomationPanel";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
@@ -133,7 +134,7 @@ export default function AutomationFlow({
|
||||
const { graph, trigger } = doc;
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [picker, setPicker] = React.useState<{ at: InsertPoint; anchor: DOMRect } | null>(null);
|
||||
const [panel, setPanel] = React.useState<"test" | "history" | null>(null);
|
||||
const { panel, closePanel, showTest, toggleHistory, toggleTest } = useAutomationPanel(automation.id);
|
||||
const [testResult, setTestResult] = React.useState<DryRunResponse | null>(null);
|
||||
const [remoteUpdate, setRemoteUpdate] = React.useState<Automation | null>(null);
|
||||
|
||||
@@ -275,8 +276,8 @@ export default function AutomationFlow({
|
||||
// ── Editing ──────────────────────────────────────────────────────────────
|
||||
const select = React.useCallback((id: string | null) => {
|
||||
setSelectedId(id);
|
||||
if (id) setPanel(null);
|
||||
}, []);
|
||||
if (id) closePanel();
|
||||
}, [closePanel]);
|
||||
|
||||
// Each switch's last non-empty case name per row (see renameCases).
|
||||
const caseMemory = React.useRef(new Map<string, string[]>());
|
||||
@@ -397,7 +398,7 @@ export default function AutomationFlow({
|
||||
try {
|
||||
const res = await test.mutateAsync({ id: automation.id, data, skipNodeIds });
|
||||
setTestResult(res);
|
||||
setPanel("test");
|
||||
showTest();
|
||||
setSelectedId(null);
|
||||
} catch {
|
||||
toast.error("Could not run the test");
|
||||
@@ -452,12 +453,12 @@ export default function AutomationFlow({
|
||||
removeStep(selectedId);
|
||||
} else if (e.key === "Escape" && (selectedId || panel)) {
|
||||
setSelectedId(null);
|
||||
setPanel(null);
|
||||
closePanel();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [canEdit, dirty, undo, redo, removeStep, selectedId, panel]);
|
||||
}, [canEdit, dirty, undo, redo, removeStep, selectedId, panel, closePanel]);
|
||||
|
||||
// ── Live collaboration: cursors and selections ───────────────────────────
|
||||
const hasPeers = useResourceViewers(resource).length > 0;
|
||||
@@ -602,7 +603,7 @@ export default function AutomationFlow({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedId(null);
|
||||
setPanel((p) => (p === "history" ? null : "history"));
|
||||
toggleHistory();
|
||||
}}
|
||||
aria-label="Run history"
|
||||
className={cn(
|
||||
@@ -617,7 +618,7 @@ export default function AutomationFlow({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedId(null);
|
||||
setPanel((p) => (p === "test" ? null : "test"));
|
||||
toggleTest();
|
||||
}}
|
||||
aria-label="Test"
|
||||
className={cn(
|
||||
@@ -726,7 +727,7 @@ export default function AutomationFlow({
|
||||
testResult={testResult}
|
||||
testing={test.isPending}
|
||||
onRun={runTest}
|
||||
onClose={() => setPanel(null)}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
</FlowPanel>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import useAutomationPanel from "./useAutomationPanel";
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const value = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
|
||||
describe("automation history browsing", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores history after remount but remembers closing or selecting an editor node", () => {
|
||||
const first = renderHook(() => useAutomationPanel("one"), { wrapper: Owner });
|
||||
act(() => first.result.current.toggleHistory());
|
||||
expect(first.result.current.panel).toBe("history");
|
||||
first.unmount();
|
||||
const restored = renderHook(() => useAutomationPanel("one"), { wrapper: Owner });
|
||||
expect(restored.result.current.panel).toBe("history");
|
||||
act(() => restored.result.current.closePanel());
|
||||
restored.unmount();
|
||||
const closed = renderHook(() => useAutomationPanel("one"), { wrapper: Owner });
|
||||
expect(closed.result.current.panel).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps test panels transient and closes remembered history when testing", () => {
|
||||
const first = renderHook(() => useAutomationPanel("one"), { wrapper: Owner });
|
||||
act(() => first.result.current.toggleHistory());
|
||||
act(() => first.result.current.showTest());
|
||||
expect(first.result.current.panel).toBe("test");
|
||||
expect([...Array(sessionStorage.length)].map((_, i) => sessionStorage.getItem(sessionStorage.key(i)!))).toEqual([JSON.stringify({ value: false })]);
|
||||
first.unmount();
|
||||
const restored = renderHook(() => useAutomationPanel("one"), { wrapper: Owner });
|
||||
expect(restored.result.current.panel).toBeNull();
|
||||
act(() => restored.result.current.toggleTest());
|
||||
expect(restored.result.current.panel).toBe("test");
|
||||
act(() => restored.result.current.toggleHistory());
|
||||
expect(restored.result.current.panel).toBe("history");
|
||||
act(() => restored.result.current.toggleHistory());
|
||||
expect(restored.result.current.panel).toBeNull();
|
||||
});
|
||||
|
||||
it("isolates history across resource and workspace switches", () => {
|
||||
const page = renderHook(({ id }) => useAutomationPanel(id), { wrapper: Owner, initialProps: { id: "one" } });
|
||||
act(() => page.result.current.toggleHistory());
|
||||
page.rerender({ id: "two" });
|
||||
expect(page.result.current.panel).toBeNull();
|
||||
page.rerender({ id: "one" });
|
||||
expect(page.result.current.panel).toBe("history");
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "another", name: "Another", role: "owner" } }));
|
||||
expect(page.result.current.panel).toBeNull();
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }));
|
||||
expect(page.result.current.panel).toBe("history");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseFlagSchema } from "@/lib/browse-other-lists";
|
||||
|
||||
export default function useAutomationPanel(automationID: string) {
|
||||
const [historyOpen, setHistoryOpen] = useBrowseState(`automations.${automationID}.history.open`, false, browseFlagSchema);
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const closePanel = useCallback(() => {
|
||||
setHistoryOpen(false);
|
||||
setTestOpen(false);
|
||||
}, [setHistoryOpen]);
|
||||
const showTest = useCallback(() => {
|
||||
setHistoryOpen(false);
|
||||
setTestOpen(true);
|
||||
}, [setHistoryOpen]);
|
||||
const toggleHistory = useCallback(() => {
|
||||
setTestOpen(false);
|
||||
setHistoryOpen((open) => !open);
|
||||
}, [setHistoryOpen]);
|
||||
const toggleTest = useCallback(() => {
|
||||
setHistoryOpen(false);
|
||||
setTestOpen((open) => !open);
|
||||
}, [setHistoryOpen]);
|
||||
|
||||
return { panel: testOpen ? "test" as const : historyOpen ? "history" as const : null, closePanel, showTest, toggleHistory, toggleTest };
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
// spine.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { formAnalyticsRangeSchema } from "@/lib/browse-other-lists";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
@@ -33,7 +35,7 @@ function flagEmoji(code: string): string {
|
||||
|
||||
export default function AnalyticsTab({ form }: { form: Form }) {
|
||||
const navigate = useNavigate();
|
||||
const [range, setRange] = React.useState<FormStatsRange>("30d");
|
||||
const [range, setRange] = useBrowseState<FormStatsRange>(`forms.${form.id}.analytics.range`, "30d", formAnalyticsRangeSchema);
|
||||
const stats = useFormStats(form.id, range);
|
||||
const totalPages = React.useMemo(() => splitPages(form.fields).length, [form.fields]);
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import type Form from "@/lib/api/models/app/forms/Form";
|
||||
import ShareTab from "./ShareTab";
|
||||
|
||||
const contacts = vi.hoisted(() => vi.fn(() => ({ contacts: [], isFetching: false })));
|
||||
vi.mock("@/lib/api/hooks/app/contacts/useSearchContacts", () => ({ default: contacts }));
|
||||
vi.mock("./FormsDomainCard", () => ({ default: () => null }));
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const value = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
const form = (id: string) => ({ id, public_id: id, name: id, status: "published", allowed_domains: [] }) as unknown as Form;
|
||||
const search = () => screen.getByPlaceholderText("Search contacts by name or email…");
|
||||
|
||||
describe("personalized form-link search", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("restores the canonical search and initializes the first API query from it", () => {
|
||||
const page = render(<ShareTab form={form("one")} baseUrl="https://forms.example" />, { wrapper: Owner });
|
||||
fireEvent.change(search(), { target: { value: "Alice" } });
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
page.unmount();
|
||||
contacts.mockClear();
|
||||
const refreshed = render(<ShareTab form={form("one")} baseUrl="https://forms.example" />, { wrapper: Owner });
|
||||
expect(search()).toHaveValue("Alice");
|
||||
expect(contacts).toHaveBeenCalledWith(expect.objectContaining({ enabled: true, options: expect.objectContaining({ query: "Alice" }) }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
refreshed.unmount();
|
||||
render(<ShareTab form={form("one")} baseUrl="https://forms.example" />, { wrapper: Owner });
|
||||
expect(search()).toHaveValue("");
|
||||
});
|
||||
|
||||
it("does not issue the previous resource or workspace's debounced search after switching", () => {
|
||||
const page = render(<ShareTab form={form("one")} baseUrl="https://forms.example" />, { wrapper: Owner });
|
||||
fireEvent.change(search(), { target: { value: "Alice" } });
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
contacts.mockClear();
|
||||
page.rerender(<ShareTab form={form("two")} baseUrl="https://forms.example" />);
|
||||
expect(search()).toHaveValue("");
|
||||
expect(contacts).not.toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ query: "Alice" }) }));
|
||||
page.rerender(<ShareTab form={form("one")} baseUrl="https://forms.example" />);
|
||||
expect(search()).toHaveValue("Alice");
|
||||
contacts.mockClear();
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "another", name: "Another", role: "owner" } }));
|
||||
expect(search()).toHaveValue("");
|
||||
expect(contacts).not.toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ query: "Alice" }) }));
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
// and per-contact personalized links on the right.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseSearchSchema } from "@/lib/browse-other-lists";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast/headless";
|
||||
|
||||
@@ -91,7 +95,7 @@ function ContactLinkRow({ form, contact }: { form: Form; contact: Contact }) {
|
||||
}
|
||||
|
||||
function PersonalizedLinksCard({ form }: { form: Form }) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [query, setQuery] = useBrowseState(`forms.${form.id}.share.contact-search`, "", browseSearchSchema);
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const active = debouncedQuery.trim().length >= 2;
|
||||
|
||||
@@ -148,6 +152,8 @@ function PersonalizedLinksCard({ form }: { form: Form }) {
|
||||
}
|
||||
|
||||
export default function ShareTab({ form, baseUrl }: { form: Form; baseUrl: string }) {
|
||||
const userID = React.useContext(UserContext)?.user.id;
|
||||
const organizationID = useAppStore((state) => state.currentOrganization?.id ?? "personal");
|
||||
const pageUrl = form.share_url || (baseUrl ? `${baseUrl}/f/${form.public_id}` : "");
|
||||
// The embed loads its iframe from its own origin, so the snippet has to
|
||||
// come from the same host as the page: an organization on a verified
|
||||
@@ -215,7 +221,7 @@ export default function ShareTab({ form, baseUrl }: { form: Form; baseUrl: strin
|
||||
description="Each contact gets their own link. Opening it prefills the form and ties the submission and every page view back to that contact and the campaign that sent it."
|
||||
>
|
||||
<div className="col-span-full">
|
||||
<PersonalizedLinksCard form={form} />
|
||||
<PersonalizedLinksCard key={`${userID}:${organizationID}:${form.id}`} form={form} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import type Form from "@/lib/api/models/app/forms/Form";
|
||||
import SubmissionsTab from "./SubmissionsTab";
|
||||
|
||||
vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: vi.fn() }) }));
|
||||
vi.mock("@/hooks/usePermission", () => ({ useWriteGuard: () => ({ guard: (fn: () => void) => fn }) }));
|
||||
vi.mock("@/lib/api/hooks/app/forms", () => ({
|
||||
useFormSubmissions: () => ({ data: { data: [{ id: "response", contact_name: "Alice", data: {}, created_at: new Date(), triage: "good" }] } }),
|
||||
useFormStats: () => ({ data: { identified: [] } }),
|
||||
useDeleteFormSubmission: () => ({}),
|
||||
}));
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const value = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
}
|
||||
const form = (id: string, triage = true) => ({ id, fields: [], status: "published", triage_enabled: triage }) as unknown as Form;
|
||||
|
||||
describe("form submission browsing", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores resource-scoped search and keeps a removable Junk filter when triage is disabled", () => {
|
||||
const page = render(<SubmissionsTab form={form("one")} />, { wrapper: Owner });
|
||||
fireEvent.change(screen.getByPlaceholderText("Search responses…"), { target: { value: "Alice" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Junk/ }));
|
||||
page.unmount();
|
||||
const restored = render(<SubmissionsTab form={form("one", false)} />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search responses…")).toHaveValue("Alice");
|
||||
expect(screen.getByRole("button", { name: /Junk/ })).toHaveClass("bg-white");
|
||||
restored.rerender(<SubmissionsTab form={form("two", false)} />);
|
||||
expect(screen.getByPlaceholderText("Search responses…")).toHaveValue("");
|
||||
expect(screen.queryByRole("button", { name: /Junk/ })).not.toBeInTheDocument();
|
||||
restored.rerender(<SubmissionsTab form={form("one", false)} />);
|
||||
expect(screen.getByPlaceholderText("Search responses…")).toHaveValue("Alice");
|
||||
fireEvent.click(screen.getByRole("button", { name: /^All/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
restored.unmount();
|
||||
render(<SubmissionsTab form={form("one", false)} />, { wrapper: Owner });
|
||||
expect(screen.getByPlaceholderText("Search responses…")).toHaveValue("");
|
||||
expect(screen.queryByRole("button", { name: /Junk/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
// pages are appended on demand.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseSearchSchema, submissionsStatusSchema } from "@/lib/browse-other-lists";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckIcon, Loader2Icon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast/headless";
|
||||
@@ -83,8 +85,8 @@ export default function SubmissionsTab({ form }: { form: Form }) {
|
||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||
const [selected, setSelected] = React.useState<FormSubmission | null>(null);
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [status, setStatus] = React.useState<StatusFilter>("all");
|
||||
const [query, setQuery] = useBrowseState(`forms.${form.id}.submissions.query`, "", browseSearchSchema);
|
||||
const [status, setStatus] = useBrowseState<StatusFilter>(`forms.${form.id}.submissions.status`, "all", submissionsStatusSchema);
|
||||
const [checked, setChecked] = React.useState<string[]>([]);
|
||||
const [bulkBusy, setBulkBusy] = React.useState(false);
|
||||
|
||||
@@ -242,7 +244,7 @@ export default function SubmissionsTab({ form }: { form: Form }) {
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "in_progress", label: "In progress" },
|
||||
...(form.triage_enabled || counts.junk > 0 ? [{ value: "junk" as const, label: "Junk" }] : []),
|
||||
...(form.triage_enabled || counts.junk > 0 || status === "junk" ? [{ value: "junk" as const, label: "Junk" }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const browseSearchSchema = z.string();
|
||||
export const browseFlagSchema = z.boolean();
|
||||
export const formsStatusSchema = z.enum(["all", "draft", "published", "archived"]);
|
||||
export const formsSortSchema = z.strictObject({
|
||||
key: z.enum(["name", "views", "starts", "submissions", "conversion", "identified", "created"]),
|
||||
dir: z.union([z.literal(1), z.literal(-1)]),
|
||||
});
|
||||
export const submissionsStatusSchema = z.enum(["all", "completed", "in_progress", "junk"]);
|
||||
export const formAnalyticsRangeSchema = z.enum(["7d", "30d", "90d"]);
|
||||
export const auditActionSchema = z.enum([
|
||||
"create", "update", "delete", "invite", "remove", "transfer", "start", "stop", "pause", "resume", "send",
|
||||
"connect", "disconnect", "rotate", "revoke", "duplicate", "export", "import", "api_call",
|
||||
]);
|
||||
export const auditEntitySchema = z.enum([
|
||||
"campaign", "campaign_lead", "contact", "email_account", "step", "template", "email_image",
|
||||
"api_key", "webhook", "integration", "warmup_routing_rule", "organization", "organization_member", "invitation",
|
||||
"folder", "tag", "category", "subscription", "settings", "crm_pipeline", "crm_stage", "crm_deal", "crm_task", "crm_note",
|
||||
"unibox", "user",
|
||||
]);
|
||||
export const auditDateSchema = z.union([z.literal(""), z.iso.date()]);
|
||||
export const billingIntervalSchema = z.enum(["monthly", "annual"]);
|
||||
export const aiUsageRangeSchema = z.union([z.literal(7), z.literal(30), z.literal(90)]);
|
||||
export const oauthAppsTabSchema = z.enum(["apps", "authorized"]);
|
||||
export const webhookTabSchema = z.enum(["overview", "deliveries", "settings"]);
|
||||
export const webhookStatusSchema = z.enum(["", "pending", "in_flight", "delivered", "failed", "abandoned"]);
|
||||
export const slackTabSchema = z.enum(["overview", "assistant", "inbox", "notifications", "members"]);
|
||||
Reference in New Issue
Block a user