From af371bbe1ba247087d7f3fc897a2ca9fa56d968b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 8 Oct 2026 07:32:24 +0000 Subject: [PATCH] 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 --- web/src/app/app/api-keys/page.tsx | 4 +- web/src/app/app/audit/page.test.tsx | 42 ++++++++++ web/src/app/app/audit/page.tsx | 28 ++----- web/src/app/app/forms/browseSchemas.test.tsx | 58 +++++++++++++ web/src/app/app/forms/page.test.tsx | 80 ++++++++++++++++++ web/src/app/app/forms/page.tsx | 13 +-- .../_components/ConnectionDetail.tsx | 4 +- .../_components/store/SearchBox.test.tsx | 73 ++++++++++++++++ .../_components/store/SearchBox.tsx | 6 +- web/src/app/app/integrations/page.tsx | 1 + .../app/app/settings/billing/AIUsageCard.tsx | 4 +- web/src/app/app/settings/billing/page.tsx | 4 +- .../app/app/settings/inbox-tagging/page.tsx | 5 +- web/src/app/app/settings/oauth-apps/page.tsx | 9 +- .../app/app/settings/webhooks/page.test.tsx | 84 +++++++++++++++++++ web/src/app/app/settings/webhooks/page.tsx | 9 +- web/src/app/app/templates/page.test.tsx | 59 +++++++++++++ web/src/app/app/templates/page.tsx | 14 +++- .../app/automations/AutomationFlow.tsx | 19 +++-- .../automations/useAutomationPanel.test.tsx | 61 ++++++++++++++ .../app/automations/useAutomationPanel.ts | 26 ++++++ web/src/components/app/forms/AnalyticsTab.tsx | 4 +- .../components/app/forms/ShareTab.test.tsx | 59 +++++++++++++ web/src/components/app/forms/ShareTab.tsx | 10 ++- .../app/forms/SubmissionsTab.test.tsx | 49 +++++++++++ .../components/app/forms/SubmissionsTab.tsx | 8 +- web/src/lib/browse-other-lists.ts | 28 +++++++ 27 files changed, 706 insertions(+), 55 deletions(-) create mode 100644 web/src/app/app/audit/page.test.tsx create mode 100644 web/src/app/app/forms/browseSchemas.test.tsx create mode 100644 web/src/app/app/forms/page.test.tsx create mode 100644 web/src/app/app/integrations/_components/store/SearchBox.test.tsx create mode 100644 web/src/app/app/settings/webhooks/page.test.tsx create mode 100644 web/src/app/app/templates/page.test.tsx create mode 100644 web/src/components/app/automations/useAutomationPanel.test.tsx create mode 100644 web/src/components/app/automations/useAutomationPanel.ts create mode 100644 web/src/components/app/forms/ShareTab.test.tsx create mode 100644 web/src/components/app/forms/SubmissionsTab.test.tsx create mode 100644 web/src/lib/browse-other-lists.ts diff --git a/web/src/app/app/api-keys/page.tsx b/web/src/app/app/api-keys/page.tsx index 6a3028d4c..ccd472f14 100644 --- a/web/src/app/app/api-keys/page.tsx +++ b/web/src/app/app/api-keys/page.tsx @@ -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(null); - const [search, setSearch] = React.useState(""); + const [search, setSearch] = useBrowseState("api-keys.list.search", "", browseSearchSchema); const all = keys.data?.data ?? []; const filtered = search.trim() diff --git a/web/src/app/app/audit/page.test.tsx b/web/src/app/app/audit/page.test.tsx new file mode 100644 index 000000000..327fcbbb3 --- /dev/null +++ b/web/src/app/app/audit/page.test.tsx @@ -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["value"]; + return {children}; +} +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(, { 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(, { 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(, { wrapper: Owner }); + expect(screen.getAllByRole("textbox")[0]).toHaveValue("Alice"); + expect(query).toHaveBeenCalledWith({ action: undefined, entity_type: undefined, date: undefined, cursor: undefined, limit: 50 }); + }); +}); diff --git a/web/src/app/app/audit/page.tsx b/web/src/app/app/audit/page.tsx index c860ae53e..723c0952a 100644 --- a/web/src/app/app/audit/page.tsx +++ b/web/src/app/app/audit/page.tsx @@ -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 = { create: { dot: "bg-emerald-500", text: "text-emerald-700" }, @@ -86,10 +74,10 @@ const ACTION_TONE: Record = { export default function AuditPage() { const access = useFeatureAccess(); - const [action, setAction] = React.useState(); - const [entityType, setEntityType] = React.useState(); - const [date, setDate] = React.useState(""); - const [search, setSearch] = React.useState(""); + const [action, setAction] = useBrowseState("audit.list.action", undefined, auditActionSchema.optional()); + const [entityType, setEntityType] = useBrowseState("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([]); const params = React.useMemo( diff --git a/web/src/app/app/forms/browseSchemas.test.tsx b/web/src/app/app/forms/browseSchemas.test.tsx new file mode 100644 index 000000000..0487c6624 --- /dev/null +++ b/web/src/app/app/forms/browseSchemas.test.tsx @@ -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["value"]; + return {children}; +} + +const cases: { name: string; schema: z.ZodType; 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); + }); +}); diff --git a/web/src/app/app/forms/page.test.tsx b/web/src/app/app/forms/page.test.tsx new file mode 100644 index 000000000..9d37109c4 --- /dev/null +++ b/web/src/app/app/forms/page.test.tsx @@ -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 }) => + , +})); + +function Owner({ children, categories = [{ id: "label", title: "Leads" }] }: { children: ReactNode; categories?: { id: string; title: string }[] }) { + const value = { user: { id: "user", categories } } as ComponentProps["value"]; + return {children}; +} +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(, { 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(, { 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(, { 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(); + expect(screen.getByLabelText("Filter by label")).toHaveValue("label"); + expect(screen.getByRole("option", { name: "Unavailable label" })).toBeInTheDocument(); + page.rerender(); + expect(screen.getByLabelText("Filter by label")).toHaveValue("label"); + expect(screen.getByRole("option", { name: "Leads" })).toBeInTheDocument(); + page.rerender(); + 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(, { 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 } }); + }); +}); diff --git a/web/src/app/app/forms/page.tsx b/web/src/app/app/forms/page.tsx index 770143cba..9bae94b1d 100644 --- a/web/src/app/app/forms/page.tsx +++ b/web/src/app/app/forms/page.tsx @@ -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("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("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([]); const [bulkBusy, setBulkBusy] = React.useState(false); @@ -277,12 +279,13 @@ function FormsList() { ))} - {categories.length > 0 && ( + {(categories.length > 0 || category) && ( ({ value: c.id, label: c.title })), ]} aria-label="Filter by label" diff --git a/web/src/app/app/integrations/_components/ConnectionDetail.tsx b/web/src/app/app/integrations/_components/ConnectionDetail.tsx index 0d49ce0f0..c73cacb63 100644 --- a/web/src/app/app/integrations/_components/ConnectionDetail.tsx +++ b/web/src/app/app/integrations/_components/ConnectionDetail.tsx @@ -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("overview"); + const [slackTab, setSlackTab] = useBrowseState(`integrations.${connection.id}.slack.tab`, "overview", slackTabSchema); const conn = detail.data?.connection ?? connection; const runs = detail.data?.runs ?? []; diff --git a/web/src/app/app/integrations/_components/store/SearchBox.test.tsx b/web/src/app/app/integrations/_components/store/SearchBox.test.tsx new file mode 100644 index 000000000..64d494c8e --- /dev/null +++ b/web/src/app/app/integrations/_components/store/SearchBox.test.tsx @@ -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["value"]; + return {children}; +} + +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(, { wrapper: Owner }); + fireEvent.change(screen.getByPlaceholderText("Search apps"), { target: { value: "Slack" } }); + page.unmount(); + const refreshed = render(, { wrapper: Owner }); + expect(screen.getByPlaceholderText("Search apps")).toHaveValue("Slack"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + refreshed.rerender(); + expect(screen.getByPlaceholderText("Search apps")).toHaveValue(""); + fireEvent.change(screen.getByPlaceholderText("Search apps"), { target: { value: "calendar" } }); + refreshed.rerender(); + expect(screen.getByPlaceholderText("Search apps")).toHaveValue("Slack"); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + refreshed.unmount(); + render(, { 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(, { 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(, { 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 <> + setFilters((old) => ({ ...old, q })) }} /> + {url.toString()} + ; + } + const linked = render(, { 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(, { wrapper: Owner }); + expect(screen.getByPlaceholderText("Search apps")).toHaveValue(""); + }); +}); diff --git a/web/src/app/app/integrations/_components/store/SearchBox.tsx b/web/src/app/app/integrations/_components/store/SearchBox.tsx index 4cd4a7174..48c830457 100644 --- a/web/src/app/app/integrations/_components/store/SearchBox.tsx +++ b/web/src/app/app/integrations/_components/store/SearchBox.tsx @@ -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); diff --git a/web/src/app/app/integrations/page.tsx b/web/src/app/app/integrations/page.tsx index 8d1b86661..72a85002d 100644 --- a/web/src/app/app/integrations/page.tsx +++ b/web/src/app/app/integrations/page.tsx @@ -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 = ( go(`${STORE_BASE}/category/${c}`)} diff --git a/web/src/app/app/settings/billing/AIUsageCard.tsx b/web/src/app/app/settings/billing/AIUsageCard.tsx index b197ac013..5fee2fefb 100644 --- a/web/src/app/app/settings/billing/AIUsageCard.tsx +++ b/web/src/app/app/settings/billing/AIUsageCard.tsx @@ -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(30); + const [range, setRange] = useBrowseState("settings.billing.ai-usage.range", 30, aiUsageRangeSchema); const usage = useCreditUsage(range); const settings = useCreditSettings(); const credits = useCredits(); diff --git a/web/src/app/app/settings/billing/page.tsx b/web/src/app/app/settings/billing/page.tsx index ad4937089..197a7b9dd 100644 --- a/web/src/app/app/settings/billing/page.tsx +++ b/web/src/app/app/settings/billing/page.tsx @@ -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(null); const [billingInterval, setBillingInterval] = - React.useState("annual"); + useBrowseState("settings.billing.interval", "annual", billingIntervalSchema); const [salesOpen, setSalesOpen] = React.useState(false); // ?pool=1 is where a linked instance's "Premium" button lands (and what diff --git a/web/src/app/app/settings/inbox-tagging/page.tsx b/web/src/app/app/settings/inbox-tagging/page.tsx index be09227da..b95ece356 100644 --- a/web/src/app/app/settings/inbox-tagging/page.tsx +++ b/web/src/app/app/settings/inbox-tagging/page.tsx @@ -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]; diff --git a/web/src/app/app/settings/oauth-apps/page.tsx b/web/src/app/app/settings/oauth-apps/page.tsx index 5a7928fd9..a6ebb2313 100644 --- a/web/src/app/app/settings/oauth-apps/page.tsx +++ b/web/src/app/app/settings/oauth-apps/page.tsx @@ -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(""); - const [eventType, setEventType] = React.useState(""); + const [status, setStatus] = useBrowseState(`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([]); @@ -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]" > + {eventType && !catalog.some((d) => d.type === eventType) && } {catalog.map((d) => ( ))} @@ -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 ; diff --git a/web/src/app/app/settings/webhooks/page.test.tsx b/web/src/app/app/settings/webhooks/page.test.tsx new file mode 100644 index 000000000..40667184f --- /dev/null +++ b/web/src/app/app/settings/webhooks/page.test.tsx @@ -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["value"]; + return {children}; +} +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(, { 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(, { 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(, { 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(, { 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(); + 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(""); + }); +}); diff --git a/web/src/app/app/settings/webhooks/page.tsx b/web/src/app/app/settings/webhooks/page.tsx index 53710f503..4adc550d5 100644 --- a/web/src/app/app/settings/webhooks/page.tsx +++ b/web/src/app/app/settings/webhooks/page.tsx @@ -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(`settings.webhooks.${endpoint.id}.tab`, "overview", webhookTabSchema); return ( (""); - const [eventType, setEventType] = React.useState(""); + const [status, setStatus] = useBrowseState(`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]" > + {eventType && !catalog.some((d) => d.type === eventType) && } {catalog.map((d) => ( ))} diff --git a/web/src/app/app/templates/page.test.tsx b/web/src/app/app/templates/page.test.tsx new file mode 100644 index 000000000..d267adfa8 --- /dev/null +++ b/web/src/app/app/templates/page.test.tsx @@ -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["value"]; + return {children}; +} +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(, { wrapper: Owner }); + fireEvent.change(search(), { target: { value: "Welcome" } }); + act(() => vi.advanceTimersByTime(200)); + page.unmount(); + query.mockClear(); + const refreshed = render(, { wrapper: Owner }); + expect(search()).toHaveValue("Welcome"); + expect(query).toHaveBeenNthCalledWith(1, "Welcome"); + fireEvent.click(screen.getByLabelText("Clear search")); + refreshed.unmount(); + render(, { 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(, { 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); + }); +}); diff --git a/web/src/app/app/templates/page.tsx b/web/src/app/app/templates/page.tsx index 5affa738b..92d7d314c 100644 --- a/web/src/app/app/templates/page.tsx +++ b/web/src/app/app/templates/page.tsx @@ -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 ; +} + +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); diff --git a/web/src/components/app/automations/AutomationFlow.tsx b/web/src/components/app/automations/AutomationFlow.tsx index a2c88afcc..3eb96ab61 100644 --- a/web/src/components/app/automations/AutomationFlow.tsx +++ b/web/src/components/app/automations/AutomationFlow.tsx @@ -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(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(null); const [remoteUpdate, setRemoteUpdate] = React.useState(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()); @@ -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} /> )} diff --git a/web/src/components/app/automations/useAutomationPanel.test.tsx b/web/src/components/app/automations/useAutomationPanel.test.tsx new file mode 100644 index 000000000..6022e2ac1 --- /dev/null +++ b/web/src/components/app/automations/useAutomationPanel.test.tsx @@ -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["value"]; + return {children}; +} + +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"); + }); +}); diff --git a/web/src/components/app/automations/useAutomationPanel.ts b/web/src/components/app/automations/useAutomationPanel.ts new file mode 100644 index 000000000..924fcade4 --- /dev/null +++ b/web/src/components/app/automations/useAutomationPanel.ts @@ -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 }; +} diff --git a/web/src/components/app/forms/AnalyticsTab.tsx b/web/src/components/app/forms/AnalyticsTab.tsx index b181eba9b..fdd6f857a 100644 --- a/web/src/components/app/forms/AnalyticsTab.tsx +++ b/web/src/components/app/forms/AnalyticsTab.tsx @@ -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("30d"); + const [range, setRange] = useBrowseState(`forms.${form.id}.analytics.range`, "30d", formAnalyticsRangeSchema); const stats = useFormStats(form.id, range); const totalPages = React.useMemo(() => splitPages(form.fields).length, [form.fields]); diff --git a/web/src/components/app/forms/ShareTab.test.tsx b/web/src/components/app/forms/ShareTab.test.tsx new file mode 100644 index 000000000..fd8691b6d --- /dev/null +++ b/web/src/components/app/forms/ShareTab.test.tsx @@ -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["value"]; + return {children}; +} +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(, { wrapper: Owner }); + fireEvent.change(search(), { target: { value: "Alice" } }); + act(() => vi.advanceTimersByTime(300)); + page.unmount(); + contacts.mockClear(); + const refreshed = render(, { 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(, { wrapper: Owner }); + expect(search()).toHaveValue(""); + }); + + it("does not issue the previous resource or workspace's debounced search after switching", () => { + const page = render(, { wrapper: Owner }); + fireEvent.change(search(), { target: { value: "Alice" } }); + act(() => vi.advanceTimersByTime(300)); + contacts.mockClear(); + page.rerender(); + expect(search()).toHaveValue(""); + expect(contacts).not.toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ query: "Alice" }) })); + page.rerender(); + 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" }) })); + }); +}); diff --git a/web/src/components/app/forms/ShareTab.tsx b/web/src/components/app/forms/ShareTab.tsx index fae477385..74aa6f46d 100644 --- a/web/src/components/app/forms/ShareTab.tsx +++ b/web/src/components/app/forms/ShareTab.tsx @@ -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." >
- +
diff --git a/web/src/components/app/forms/SubmissionsTab.test.tsx b/web/src/components/app/forms/SubmissionsTab.test.tsx new file mode 100644 index 000000000..8504bf4d8 --- /dev/null +++ b/web/src/components/app/forms/SubmissionsTab.test.tsx @@ -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["value"]; + return {children}; +} +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(, { wrapper: Owner }); + fireEvent.change(screen.getByPlaceholderText("Search responses…"), { target: { value: "Alice" } }); + fireEvent.click(screen.getByRole("button", { name: /Junk/ })); + page.unmount(); + const restored = render(, { wrapper: Owner }); + expect(screen.getByPlaceholderText("Search responses…")).toHaveValue("Alice"); + expect(screen.getByRole("button", { name: /Junk/ })).toHaveClass("bg-white"); + restored.rerender(); + expect(screen.getByPlaceholderText("Search responses…")).toHaveValue(""); + expect(screen.queryByRole("button", { name: /Junk/ })).not.toBeInTheDocument(); + restored.rerender(); + 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(, { wrapper: Owner }); + expect(screen.getByPlaceholderText("Search responses…")).toHaveValue(""); + expect(screen.queryByRole("button", { name: /Junk/ })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/app/forms/SubmissionsTab.tsx b/web/src/components/app/forms/SubmissionsTab.tsx index 93fb72421..947f61a25 100644 --- a/web/src/components/app/forms/SubmissionsTab.tsx +++ b/web/src/components/app/forms/SubmissionsTab.tsx @@ -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(null); - const [query, setQuery] = React.useState(""); - const [status, setStatus] = React.useState("all"); + const [query, setQuery] = useBrowseState(`forms.${form.id}.submissions.query`, "", browseSearchSchema); + const [status, setStatus] = useBrowseState(`forms.${form.id}.submissions.status`, "all", submissionsStatusSchema); const [checked, setChecked] = React.useState([]); 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 ( diff --git a/web/src/lib/browse-other-lists.ts b/web/src/lib/browse-other-lists.ts new file mode 100644 index 000000000..a30053ea2 --- /dev/null +++ b/web/src/lib/browse-other-lists.ts @@ -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"]);