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"]);