diff --git a/web/src/app/app/campaigns/[id]/page.tsx b/web/src/app/app/campaigns/[id]/page.tsx index d9cfbc627..9c1ca06b6 100644 --- a/web/src/app/app/campaigns/[id]/page.tsx +++ b/web/src/app/app/campaigns/[id]/page.tsx @@ -1,4 +1,6 @@ import { useEffect, useMemo, useState } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { campaignPeriodSchema, hiddenMetricsSchema } from "@/lib/browse-contacts-campaigns"; import { MailCheckIcon, MousePointerClickIcon, @@ -51,7 +53,7 @@ export default function CampaignOverview() { // One period drives every figure on the page: summary, steps, engagement // and the chart all read the same sends. - const [period, setPeriod] = useState(loadCampaignPeriod); + const [period, setPeriod] = useBrowseState(`campaigns:${id}:analytics:period`, loadCampaignPeriod, campaignPeriodSchema); // A preset ends today, so a page left open rolls over at UTC midnight. const [today, setToday] = useState(utcToday); useEffect(() => { @@ -74,7 +76,7 @@ export default function CampaignOverview() { }, [asked, analytics.isPlaceholderData, analytics.data?.date_range]); // Legend toggles: every metric charts together; hidden ones drop out. - const [hiddenMetrics, setHiddenMetrics] = useState([]); + const [hiddenMetrics, setHiddenMetrics] = useBrowseState(`campaigns:${id}:analytics:hidden-metrics`, [], hiddenMetricsSchema); const toggleMetric = (k: Metric) => setHiddenMetrics((cur) => { if (cur.includes(k)) return cur.filter((x) => x !== k); diff --git a/web/src/app/app/campaigns/page.tsx b/web/src/app/app/campaigns/page.tsx index 956591c7e..c09a9210f 100644 --- a/web/src/app/app/campaigns/page.tsx +++ b/web/src/app/app/campaigns/page.tsx @@ -1,6 +1,8 @@ import { NoAccess } from "@/components/layout/NoAccess"; import { usePermission } from "@/hooks/usePermission"; import { useUserProfile } from "@/hooks/context/user"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseText, campaignStatusSchema, campaignSortSchema } from "@/lib/browse-contacts-campaigns"; import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; import useStartCampaign from "@/lib/api/hooks/app/campaigns/useStartCampaign"; import useStopCampaign from "@/lib/api/hooks/app/campaigns/useStopCampaign"; @@ -245,10 +247,10 @@ export default function CampaignsPage() { const canView = usePermission("VIEW_CAMPAIGNS"); const startCampaign = useStartCampaign(); const stopCampaign = useStopCampaign(); - const [folder, setFolder] = useState(""); - const [query, setQuery] = useState(""); - const [status, setStatus] = useState("all"); - const [sort, setSort] = useState("newest"); + const [folder, setFolder] = useBrowseState("campaigns:list:folder", "", browseText); + const [query, setQuery] = useBrowseState("campaigns:list:query", "", browseText); + const [status, setStatus] = useBrowseState("campaigns:list:status", "all", campaignStatusSchema); + const [sort, setSort] = useBrowseState("campaigns:list:sort", "newest", campaignSortSchema); const [newOpen, setNewOpen] = useState(false); // A draft clicked in the list reopens in the new-campaign flow, for a // member who may edit it; anyone else gets the campaign page. @@ -395,7 +397,7 @@ export default function CampaignsPage() { } - label={activeFolder?.title ?? "All folders"} + label={activeFolder?.title ?? (folder ? "Selected folder" : "All folders")} /> diff --git a/web/src/app/app/contacts/labels/page.tsx b/web/src/app/app/contacts/labels/page.tsx index 9048ed94d..da7c7e472 100644 --- a/web/src/app/app/contacts/labels/page.tsx +++ b/web/src/app/app/contacts/labels/page.tsx @@ -3,6 +3,8 @@ // list filtered to that category. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseText } from "@/lib/browse-contacts-campaigns"; import { useNavigate } from "@tanstack/react-router"; import { useQueries } from "@tanstack/react-query"; import { CheckIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react"; @@ -36,7 +38,7 @@ export default function LabelsPage() { const write = useWriteGuard("MANAGE_CONTACTS"); const guarded = (fn: () => void) => () => write.guard(fn)({}); const create = useCreateCategory(); - const [query, setQuery] = React.useState(""); + const [query, setQuery] = useBrowseState("contacts:labels:query", "", browseText); const [creating, setCreating] = React.useState(false); const [newTitle, setNewTitle] = React.useState(""); diff --git a/web/src/app/app/contacts/segments/page.tsx b/web/src/app/app/contacts/segments/page.tsx index 85b095361..6361595b1 100644 --- a/web/src/app/app/contacts/segments/page.tsx +++ b/web/src/app/app/contacts/segments/page.tsx @@ -2,6 +2,8 @@ // with its live member count; clicking opens the segment's contact list. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseText } from "@/lib/browse-contacts-campaigns"; import { useNavigate } from "@tanstack/react-router"; import { MoreHorizontalIcon, PlusIcon } from "lucide-react"; import toast from "react-hot-toast/headless"; @@ -60,7 +62,7 @@ function SegmentsList() { } } - const [query, setQuery] = React.useState(""); + const [query, setQuery] = useBrowseState("contacts:segments:query", "", browseText); const [editorOpen, setEditorOpen] = React.useState(false); const [editing, setEditing] = React.useState(null); const [campaignFor, setCampaignFor] = React.useState(null); diff --git a/web/src/app/app/contacts/suppressions/page.tsx b/web/src/app/app/contacts/suppressions/page.tsx index 65691c07a..0885ec731 100644 --- a/web/src/app/app/contacts/suppressions/page.tsx +++ b/web/src/app/app/contacts/suppressions/page.tsx @@ -4,6 +4,9 @@ // entry the recipient made themselves is confirmed with a stronger warning. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseText } from "@/lib/browse-contacts-campaigns"; +import useBrowseDebouncedValue from "@/components/app/contacts/filters/useBrowseDebouncedValue"; import { GlobeIcon, MailIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react"; import toast from "react-hot-toast/headless"; @@ -22,13 +25,9 @@ import AddSuppressionsDialog from "@/components/app/contacts/AddSuppressionsDial export default function SuppressionsPage() { const write = useWriteGuard("MANAGE_CONTACTS"); const guarded = (fn: () => void) => () => write.guard(fn)({}); - const [query, setQuery] = React.useState(""); - const [debounced, setDebounced] = React.useState(""); + const [query, setQuery] = useBrowseState("contacts:suppressions:query", "", browseText); + const debounced = useBrowseDebouncedValue(query.trim(), "contacts:suppressions"); const [adding, setAdding] = React.useState(false); - React.useEffect(() => { - const t = setTimeout(() => setDebounced(query.trim()), 200); - return () => clearTimeout(t); - }, [query]); const list = useSuppressions(debounced); diff --git a/web/src/components/app/campaigns/CampaignPeriodPicker.tsx b/web/src/components/app/campaigns/CampaignPeriodPicker.tsx index cbd5ce90f..4474816ca 100644 --- a/web/src/components/app/campaigns/CampaignPeriodPicker.tsx +++ b/web/src/components/app/campaigns/CampaignPeriodPicker.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { CalendarIcon } from "lucide-react"; import { DatePicker } from "@/components/ui/DatePicker"; import { PopoverMenu, PopoverMenuContent, PopoverMenuTrigger } from "@/components/ui/popover-menu"; -import { COHORT_TIP, PRESETS, formatWindow, savePreset, type CampaignPeriod, type DayWindow } from "@/lib/campaignPeriod"; +import { COHORT_TIP, PRESETS, formatWindow, type CampaignPeriod, type DayWindow } from "@/lib/campaignPeriod"; export default function CampaignPeriodPicker({ value, @@ -42,7 +42,6 @@ export default function CampaignPeriodPicker({ key={p.key} type="button" onClick={() => { - savePreset(p.key); onChange({ key: p.key }); }} className={tab(value.key === p.key)} diff --git a/web/src/components/app/contacts/AddFromContactsDialog.test.tsx b/web/src/components/app/contacts/AddFromContactsDialog.test.tsx new file mode 100644 index 000000000..59e9ff3da --- /dev/null +++ b/web/src/components/app/contacts/AddFromContactsDialog.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { 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 AddFromContactsDialog from "./AddFromContactsDialog"; + +const searchContacts = vi.hoisted(() => vi.fn((_options: unknown) => ({ data: { pages: [] }, isLoading: false }))); +vi.mock("@/lib/api/hooks/app/contacts/useSearchContacts", () => ({ default: searchContacts })); +vi.mock("@/lib/api/hooks/app/contacts/useUpdateContactsBulk", () => ({ default: () => ({ isPending: false }) })); +vi.mock("@/lib/api/hooks/app/segments", () => ({ useSetSegmentMembers: () => ({ isPending: false }) })); +vi.mock("@/hooks/useAlert", () => ({ default: () => ({ error: vi.fn() }) })); +vi.mock("@/components/app/contacts/CategoryPicker", () => ({ default: ({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) => + })); + +function Owner({ children }: { children: ReactNode }) { + const value = { user: { id: "member" } } as ComponentProps["value"]; + return {children}; +} +const onClose = vi.fn(); +const query = () => screen.getByPlaceholderText("Search name, email, company…"); + +describe("add-from contacts browsing filters", () => { + beforeEach(() => { + sessionStorage.clear(); + searchContacts.mockClear(); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + it("preserves filters through closing, initial closed mount, and refresh, with the restored first API query", () => { + const page = render(, { wrapper: Owner }); + fireEvent.change(query(), { target: { value: "alice" } }); + fireEvent.click(screen.getByText("Choose labels")); + page.rerender(); + page.unmount(); + searchContacts.mockClear(); + const refreshed = render(, { wrapper: Owner }); + expect(searchContacts.mock.calls[0][0]).toMatchObject({ options: { query: "alice", category_ids: ["label"] } }); + refreshed.rerender(); + expect(query()).toHaveValue("alice"); + expect(screen.getByText("label")).toBeInTheDocument(); + refreshed.rerender(); + expect(query()).toHaveValue(""); + expect(screen.getByText("Choose labels")).toBeInTheDocument(); + refreshed.rerender(); + expect(query()).toHaveValue(""); + refreshed.rerender(); + expect(query()).toHaveValue("alice"); + }); +}); diff --git a/web/src/components/app/contacts/AddFromContactsDialog.tsx b/web/src/components/app/contacts/AddFromContactsDialog.tsx index 647223c14..88f0781f8 100644 --- a/web/src/components/app/contacts/AddFromContactsDialog.tsx +++ b/web/src/components/app/contacts/AddFromContactsDialog.tsx @@ -8,6 +8,9 @@ // search itself, so a whole category is one request. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseText, browseIds } from "@/lib/browse-contacts-campaigns"; +import useBrowseDebouncedValue from "./filters/useBrowseDebouncedValue"; import { AnimatePresence, motion } from "framer-motion"; import { AlertCircleIcon, @@ -65,18 +68,15 @@ export default function AddFromContactsDialog({ open, onClose, campaign: campaig ); const targetName = target.kind === "campaign" ? target.campaign.name : target.segment.name; - const [query, setQuery] = React.useState(""); - const [categoryIds, setCategoryIds] = React.useState([]); + const browseName = `contacts:add-from:${target.kind}:${target.kind === "campaign" ? target.campaign.id : target.segment.id}`; + const [query, setQuery] = useBrowseState(`${browseName}:query`, "", browseText); + const [categoryIds, setCategoryIds] = useBrowseState(`${browseName}:labels`, [], browseIds); // Ticked rows, or the search itself minus what was unticked after it; the // same two shapes the contacts table uses (./selection). const [rowSel, setRowSel] = React.useState(rowSelection.emptySelection); // Debounce the query so a fast typist does not fire a search per keystroke. - const [debounced, setDebounced] = React.useState(""); - React.useEffect(() => { - const t = setTimeout(() => setDebounced(query.trim()), 200); - return () => clearTimeout(t); - }, [query]); + const debounced = useBrowseDebouncedValue(query.trim(), browseName); const options = React.useMemo( () => ({ @@ -95,9 +95,6 @@ export default function AddFromContactsDialog({ open, onClose, campaign: campaig React.useEffect(() => { if (!open) { - setQuery(""); - setDebounced(""); - setCategoryIds([]); setRowSel(rowSelection.emptySelection); } }, [open]); @@ -232,6 +229,7 @@ export default function AddFromContactsDialog({ open, onClose, campaign: campaig />
({ default: () => ({ isPending: false }) })); + +function Browse({ showMissing = true }: { showMissing?: boolean }) { + const [labels, setLabels] = useState(["missing"]); + const value = { user: { id: "member", categories: [] } } as unknown as ComponentProps["value"]; + return + + {labels.join(",")} + ; +} + +describe("unavailable label metadata", () => { + it("keeps browse labels visibly removable while labels are unavailable", () => { + render(); + expect(screen.getByText("Selected label")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Remove Selected label" })); + expect(screen.getByRole("status")).toHaveTextContent(""); + expect(screen.queryByText("Selected label")).not.toBeInTheDocument(); + }); + it("does not change editor picker behavior unless opted in", () => { + render(); + expect(screen.queryByText("Selected label")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("missing"); + }); +}); diff --git a/web/src/components/app/contacts/CategoryPicker.tsx b/web/src/components/app/contacts/CategoryPicker.tsx index b2f6c8ed5..d85b34760 100644 --- a/web/src/components/app/contacts/CategoryPicker.tsx +++ b/web/src/components/app/contacts/CategoryPicker.tsx @@ -40,6 +40,7 @@ interface Props { // Disable inline category creation. Useful in contexts where the // user shouldn't be inventing tags (e.g. legacy import flows). allowCreate?: boolean; + showMissing?: boolean; } export default function CategoryPicker({ @@ -48,6 +49,7 @@ export default function CategoryPicker({ placeholder = "Click to add labels…", className, allowCreate = true, + showMissing = false, }: Props) { const { user } = useUserProfile(); const categories = user.categories ?? []; @@ -61,9 +63,7 @@ export default function CategoryPicker({ // ~270px: 33px search input + 56 max-h list (224px) + borders. const placement = useFlipPlacement(triggerRef, open, 270); - // Map id → category for chip rendering. We don't render orphaned - // ids — if the underlying category was deleted, the chip just - // disappears on next render, which is the right behaviour. + // Browse filters retain removable chips while workspace metadata is unavailable. const byId = React.useMemo(() => { const m = new Map(); for (const c of categories) m.set(c.id, c); @@ -71,8 +71,8 @@ export default function CategoryPicker({ }, [categories]); const selectedChips = value - .map((id) => byId.get(id)) - .filter((c): c is Category => !!c); + .map((id) => byId.get(id) ?? (showMissing ? { id, title: "Selected label", color: "#64748b" } : undefined)) + .filter((c) => c !== undefined); const filtered = React.useMemo(() => { const q = query.trim().toLowerCase(); diff --git a/web/src/components/app/contacts/ContactEdit.tsx b/web/src/components/app/contacts/ContactEdit.tsx index 80c1e49a4..a0b8af47b 100644 --- a/web/src/components/app/contacts/ContactEdit.tsx +++ b/web/src/components/app/contacts/ContactEdit.tsx @@ -17,6 +17,8 @@ // - Details → identity / categories / campaigns / custom fields import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { contactTabSchema } from "@/lib/browse-contacts-campaigns"; import { AnimatePresence, motion } from "framer-motion"; import { CalendarPlusIcon, CheckIcon, CopyIcon, Loader2Icon, MailIcon, XIcon } from "lucide-react"; import toast from "react-hot-toast/headless"; @@ -105,7 +107,8 @@ function ContactEditPanel({ // teammate editing the same person sees the live-viewer pill up top. usePresenceResource(`contact:${contact.id}`, "editing"); - const [tab, setTab] = React.useState(initialTab ?? "overview"); + const [tab, setTab] = useBrowseState(`contacts:${contact.id}:panel:tab`, "overview", contactTabSchema, + initialTab ? { initialOverride: initialTab } : undefined); const [firstName, setFirstName] = React.useState(contact.first_name); const [lastName, setLastName] = React.useState(contact.last_name); diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 81fea6ca6..4d88c2c02 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -60,6 +60,7 @@ import toast from "react-hot-toast/headless"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import FilterBar from "./filters/FilterBar"; +import useContactBrowseState, { contactBrowseName } from "./filters/useContactBrowseState"; import { hasNarrowingFilters, isCompleteCustomFilter, scopeSearch } from "./filters/helpers"; import ContactEdit from "./ContactEdit"; import type { ContactSlideTab } from "./contact-edit/tabs"; @@ -243,16 +244,19 @@ export default function ContactsTable({ const viewName: ColumnViewName = current_campaign ? "campaign_leads" : "contacts"; const view = useContactView(viewName); - const [searchProps, setSearchProps] = React.useState(() => { - const category = params.get("category"); - // The browser's copy of the saved sort seeds the first request, so the - // list does not load in one order and then reload in another. - const cached = readCachedView(view.scope, viewName)?.sort; - return { - ...scopeSearch({ campaignId: current_campaign?.id, segmentId: segment?.id }), - category_ids: category && !segment && !current_campaign ? [category] : undefined, - ...(cached ? { sort_by: cached.by as SearchContactsSortBy, reverse: cached.reverse } : {}), - }; + const browseName = contactBrowseName(current_campaign?.id, segment?.id); + const category = !segment && !current_campaign ? params.get("category") ?? undefined : undefined; + const [searchProps, setSearchProps] = useContactBrowseState({ + campaignId: current_campaign?.id, segmentId: segment?.id, category, + initialSort: () => { + const cached = readCachedView(view.scope, viewName)?.sort; + return { sort_by: (cached?.by ?? "created_at") as SearchContactsSortBy, reverse: cached?.reverse ?? false }; + }, + clearCategoryIntent: () => setParams((prev) => { + const next = new URLSearchParams(prev); + next.delete("category"); + return next; + }, { replace: true }), }); // The saved sort applies once the server's copy is in hand, and only when @@ -262,7 +266,7 @@ export default function ContactsTable({ React.useEffect(() => { if (!view.loaded) return; const saved = view.savedSort; - const sig = saved ? `${saved.by}:${saved.reverse}` : "default"; + const sig = `${view.scope.userId}:${view.scope.orgId}:${viewName}:${saved ? `${saved.by}:${saved.reverse}` : "default"}`; if (appliedSortRef.current === sig) return; appliedSortRef.current = sig; setSearchProps((prev) => ({ @@ -270,10 +274,10 @@ export default function ContactsTable({ sort_by: (saved?.by ?? "created_at") as SearchContactsSortBy, reverse: saved?.reverse ?? false, })); - }, [view.loaded, view.savedSort]); + }, [view.loaded, view.savedSort, view.scope.userId, view.scope.orgId, viewName, setSearchProps]); const sortState: ViewSortState = { by: searchProps.sort_by, reverse: searchProps.reverse }; function changeSort(next: ViewSortState) { - appliedSortRef.current = `${next.by}:${next.reverse}`; + appliedSortRef.current = `${view.scope.userId}:${view.scope.orgId}:${viewName}:${next.by}:${next.reverse}`; setSearchProps((s) => ({ ...s, sort_by: next.by, reverse: next.reverse })); view.setSort({ by: next.by, reverse: next.reverse }); } @@ -875,6 +879,7 @@ export default function ContactsTable({ reenrolling={reenrolling} /> ({ events: [], isLoading: false, hasNextPage: false, fetchNextPage: vi.fn() })); +vi.mock("@/lib/api/hooks/app/contacts/useContactTimeline", () => ({ default: () => timeline })); +vi.mock("@/lib/api/hooks/app/contacts/useContactCampaignStates", () => ({ default: () => ({ data: { data: [] } }) })); +vi.mock("@/components/ui/DatePicker", () => ({ DatePicker: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) => + onChange(e.target.value)} /> })); + +function Owner({ children }: { children: ReactNode }) { + const value = { user: { id: "member" } } as ComponentProps["value"]; + return {children}; +} +const query = () => screen.getByPlaceholderText("Search subject, content, campaign…"); +const saved = () => JSON.parse(sessionStorage.getItem(`${BROWSE_STORAGE_PREFIX}member:workspace:contacts:a:activity:filters`)!).value; + +describe("contact activity browsing", () => { + beforeEach(() => { + sessionStorage.clear(); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + it("restores query, type and date filters, isolates contacts, and retains intentional reset", () => { + const page = render(, { wrapper: Owner }); + fireEvent.change(query(), { target: { value: "proposal" } }); + fireEvent.click(screen.getByRole("button", { name: "Replies" })); + fireEvent.click(screen.getByRole("button", { name: "Any date" })); + fireEvent.change(screen.getByPlaceholderText("From"), { target: { value: "2026-10-01" } }); + fireEvent.change(screen.getByPlaceholderText("To"), { target: { value: "2026-10-07" } }); + expect(saved()).toEqual({ query: "proposal", type: "replies", from: "2026-10-01", to: "2026-10-07" }); + page.unmount(); + const refreshed = render(, { wrapper: Owner }); + expect(query()).toHaveValue("proposal"); + expect(screen.queryByRole("button", { name: "Any date" })).not.toBeInTheDocument(); + refreshed.rerender(); + expect(query()).toHaveValue(""); + expect(screen.getByRole("button", { name: "Any date" })).toBeInTheDocument(); + refreshed.rerender(); + expect(query()).toHaveValue("proposal"); + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + expect(saved()).toEqual({ query: "", type: "all", from: "", to: "" }); + refreshed.unmount(); + render(, { wrapper: Owner }); + expect(query()).toHaveValue(""); + expect(screen.queryByRole("button", { name: "Reset" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx index ed8876b67..818062bf8 100644 --- a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx +++ b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx @@ -17,6 +17,8 @@ // the audit spine's contact invalidation keeps them live. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { activityFiltersSchema } from "@/lib/browse-contacts-campaigns"; import { AnimatePresence, motion } from "framer-motion"; import { DatePicker } from "@/components/ui/DatePicker"; import { @@ -132,10 +134,12 @@ export default function ActivityTab({ contactId, contactName }: { contactId: str fetchNextPage, } = useContactTimeline(contactId); - const [type, setType] = React.useState("all"); - const [query, setQuery] = React.useState(""); - const [from, setFrom] = React.useState(""); - const [to, setTo] = React.useState(""); + const [{ type, query, from, to }, setBrowse] = useBrowseState(`contacts:${contactId}:activity:filters`, + { type: "all" as FilterId, query: "", from: "", to: "" }, activityFiltersSchema); + const setType = (type: FilterId) => setBrowse((s) => ({ ...s, type })); + const setQuery = (query: string) => setBrowse((s) => ({ ...s, query })); + const setFrom = (from: string) => setBrowse((s) => ({ ...s, from })); + const setTo = (to: string) => setBrowse((s) => ({ ...s, to })); const visible = React.useMemo( () => applyFilters(events, { type, query, from, to }), diff --git a/web/src/components/app/contacts/contactPanelRebase.test.tsx b/web/src/components/app/contacts/contactPanelRebase.test.tsx index 7ba07cda5..c0de0f781 100644 --- a/web/src/components/app/contacts/contactPanelRebase.test.tsx +++ b/web/src/components/app/contacts/contactPanelRebase.test.tsx @@ -11,6 +11,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type Contact from "@/lib/api/models/app/contacts/Contact"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import type { ContactSlideTab } from "./contact-edit/tabs"; const requested: { url?: string; data?: unknown }[] = []; vi.mock("@/lib/api/client/Request", () => ({ @@ -115,16 +118,43 @@ function Panel({ contacts }: { contacts: Contact[] }) { ); } +function BrowsePanel({ initialTab, active = "contact-1" }: { initialTab?: ContactSlideTab; active?: string }) { + const client = React.useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }), []); + const value = { user: { id: "member" } } as React.ComponentProps["value"]; + return + + {}} initialTab={initialTab} /> + + ; +} + function clickBackdrop(container: HTMLElement) { fireEvent.mouseDown(container.querySelector(".fixed.inset-0") as Element); } describe("the contact 360 panel", () => { beforeEach(() => { + sessionStorage.clear(); confirmShow.mockClear(); requested.length = 0; }); + it("restores the contact tab, isolates other contacts, and lets explicit navigation beat the saved tab", () => { + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + const page = render(); + fireEvent.click(screen.getByRole("button", { name: "Activity" })); + expect(screen.getByText("activity")).toBeInTheDocument(); + page.unmount(); + const refreshed = render(); + expect(screen.getByText("activity")).toBeInTheDocument(); + refreshed.rerender(); + expect(screen.getByText("overview")).toBeInTheDocument(); + refreshed.unmount(); + render(); + expect(screen.getByRole("button", { name: "rename" })).toBeInTheDocument(); + expect(screen.queryByText("activity")).not.toBeInTheDocument(); + }); + it("closes without asking when the contact was re-subscribed elsewhere", () => { const { rerender, container } = render(); expect(screen.getByText("Unsubscribed")).toBeTruthy(); diff --git a/web/src/components/app/contacts/filters/FilterBar.test.tsx b/web/src/components/app/contacts/filters/FilterBar.test.tsx new file mode 100644 index 000000000..5594f66a6 --- /dev/null +++ b/web/src/components/app/contacts/filters/FilterBar.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { type ComponentProps, type ReactNode, useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import useContactBrowseState from "./useContactBrowseState"; +import FilterBar from "./FilterBar"; + +vi.mock("@/lib/api/hooks/app/campaigns/useCampaigns", () => ({ default: () => ({ campaigns: [], data: { pages: [] } }) })); +vi.mock("@/lib/api/hooks/app/contacts/useCustomFieldKeys", () => ({ default: () => ({ data: [] }) })); +vi.mock("@/lib/api/hooks/app/segments", () => ({ useSegments: () => ({ data: [] }) })); + +function Owner({ children }: { children: ReactNode }) { + const value = { user: { id: "member", categories: [] } } as unknown as ComponentProps["value"]; + return {children}; +} +function List({ resource = "contacts:all" }: { resource?: string }) { + const [filters, setFilters] = useContactBrowseState({ initialSort: () => ({ sort_by: "created_at", reverse: false }) }); + const [resetToken, setResetToken] = useState(0); + return <> + + + ; +} + +describe("blank filter pill persistence", () => { + beforeEach(() => { + sessionStorage.clear(); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + it("keeps blank pills after remount, only clears on an actual reset, and isolates list resources", () => { + const page = render(, { wrapper: Owner }); + fireEvent.click(screen.getByRole("button", { name: "Add filter" })); + fireEvent.click(screen.getByText("Address verification")); + expect(screen.getByRole("button", { name: "Remove Verification filter" })).toBeInTheDocument(); + page.unmount(); + const refreshed = render(, { wrapper: Owner }); + expect(screen.getByRole("button", { name: "Remove Verification filter" })).toBeInTheDocument(); + refreshed.rerender(); + expect(screen.queryByRole("button", { name: "Remove Verification filter" })).not.toBeInTheDocument(); + refreshed.rerender(); + expect(screen.getByRole("button", { name: "Remove Verification filter" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Reset view" })); + expect(screen.queryByRole("button", { name: "Remove Verification filter" })).not.toBeInTheDocument(); + refreshed.unmount(); + render(, { wrapper: Owner }); + expect(screen.queryByRole("button", { name: "Remove Verification filter" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/app/contacts/filters/FilterBar.tsx b/web/src/components/app/contacts/filters/FilterBar.tsx index 3e8801d4e..7f278f08c 100644 --- a/web/src/components/app/contacts/filters/FilterBar.tsx +++ b/web/src/components/app/contacts/filters/FilterBar.tsx @@ -3,6 +3,8 @@ // change applied on the spot. Pills open a popover under themselves. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { contactExtrasSchema, contactGeneralExtrasSchema } from "@/lib/browse-contacts-campaigns"; import { AnimatePresence, motion } from "framer-motion"; import { CheckIcon, ChevronDownIcon, LayersIcon, Loader2Icon, PlusIcon, XIcon } from "lucide-react"; @@ -90,6 +92,7 @@ function fromIso(s: string): Date | undefined { } export default function FilterBar({ + browseName, filters, setFilters, activeCampaign, @@ -99,6 +102,7 @@ export default function FilterBar({ loading, onSaveAsSegment, }: { + browseName: string; filters: SearchContacts; setFilters: Setter; activeCampaign?: MiniCampaign; @@ -116,7 +120,7 @@ export default function FilterBar({ const campaignCtx = !!activeCampaign; // Pills added from the menu stay visible while empty so the user can fill them. - const [extras, setExtras] = React.useState([]); + const [extras, setExtras] = useBrowseState(`${browseName}:extra-filters`, [], campaignCtx ? contactExtrasSchema : contactGeneralExtrasSchema); const [openKey, setOpenKey] = React.useState(null); const shown = (k: ExtraKey) => { @@ -177,8 +181,10 @@ export default function FilterBar({ setOpenKey(null); } + const previousReset = React.useRef(resetToken); React.useEffect(() => { - if (resetToken === undefined) return; + if (previousReset.current === resetToken) return; + previousReset.current = resetToken; setExtras([]); setOpenKey(null); }, [resetToken]); @@ -868,7 +874,7 @@ function Bound({ label, value, onChange }: { label: string; value?: number; onCh {set && } {label} - onChange(Math.max(0, v))} min={0} disabled={!set} suffix="campaigns" className="flex-1" /> + onChange(Math.max(0, Math.floor(v)))} min={0} disabled={!set} suffix="campaigns" className="flex-1" />
); } diff --git a/web/src/components/app/contacts/filters/browseSchemas.test.tsx b/web/src/components/app/contacts/filters/browseSchemas.test.tsx new file mode 100644 index 000000000..6a7aea075 --- /dev/null +++ b/web/src/components/app/contacts/filters/browseSchemas.test.tsx @@ -0,0 +1,92 @@ +import { act, renderHook } from "@testing-library/react"; +import { type ComponentProps, type ReactNode } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; +import { UserContext } from "@/hooks/context/user"; +import useBrowseState from "@/hooks/useBrowseState"; +import { useAppStore } from "@/stores/useAppStore"; +import { + activityFiltersSchema, browseIds, browseText, campaignPeriodSchema, campaignSortSchema, campaignStatusSchema, + contactExtrasSchema, contactFiltersSchema, contactGeneralExtrasSchema, contactTabSchema, hiddenMetricsSchema, importViewSchema, +} from "@/lib/browse-contacts-campaigns"; + +function Owner({ children }: { children: ReactNode }) { + const value = { user: { id: "member" } } as ComponentProps["value"]; + return {children}; +} +const filters = { query: "", custom_field_filters: [], campaign_ids: [] }; + +describe("contacts and campaigns browse schemas", () => { + beforeEach(() => { + sessionStorage.clear(); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it.each([null, "2026-02-30T00:00:00Z", "not-a-date", 0, new Date(NaN)])("rejects unsafe persisted dates: %s", (date) => { + expect(contactFiltersSchema.safeParse({ ...filters, created_after: date }).success).toBe(false); + }); + it.each([NaN, Infinity, -1, 1.5, "2", null])("rejects invalid campaign counts: %s", (count) => { + expect(contactFiltersSchema.safeParse({ ...filters, min_campaigns: count }).success).toBe(false); + expect(contactFiltersSchema.safeParse({ ...filters, max_campaigns: count }).success).toBe(false); + }); + it("validates required arrays, booleans, free custom keys and the full chip enums", () => { + expect(contactFiltersSchema.safeParse({ ...filters, campaign_ids: null }).success).toBe(false); + expect(contactFiltersSchema.safeParse({ ...filters, subscribed: "false" }).success).toBe(false); + expect(contactFiltersSchema.safeParse({ ...filters, subscribed: undefined }).success).toBe(true); + expect(contactFiltersSchema.safeParse({ ...filters, verification_status: "stale", mail_hosts: ["stale"] }).success).toBe(false); + expect(contactFiltersSchema.safeParse({ ...filters, lead_status: "stale", engagement: "stale" }).success).toBe(false); + const custom_field_filters = [{ name: "", value: "", type: "equal" }, { name: "no longer suggested", value: "v", type: "contains" }]; + for (const engagement of ["opened", "not_opened", "clicked", "not_clicked", "replied", "not_replied", "bounced"]) { + expect(contactFiltersSchema.safeParse({ ...filters, custom_field_filters, lead_status: "unsubscribed", engagement }).success).toBe(true); + } + }); + it("validates real ordered calendar days for activity and campaign reporting", () => { + for (const from of ["2026-02-30", "2026-01-01T00:00:00Z", null]) { + expect(activityFiltersSchema.safeParse({ type: "all", query: "", from, to: "" }).success).toBe(false); + expect(campaignPeriodSchema.safeParse({ key: "custom", from, to: "2026-03-01" }).success).toBe(false); + } + expect(activityFiltersSchema.safeParse({ type: "website", query: "", from: "2026-10-02", to: "2026-10-01" }).success).toBe(false); + expect(campaignPeriodSchema.safeParse({ key: "custom", from: "2026-10-02", to: "2026-10-01" }).success).toBe(false); + expect(campaignPeriodSchema.safeParse({ key: "custom", from: "2024-02-29", to: "2024-02-29" }).success).toBe(true); + }); + it("rejects stale tabs, views, list enums, duplicate extras and hiding every chart metric", () => { + for (const schema of [campaignStatusSchema, campaignSortSchema, contactTabSchema, importViewSchema]) { + expect(schema.safeParse("obsolete").success).toBe(false); + } + expect(contactExtrasSchema.safeParse(["provider", "provider"]).success).toBe(false); + expect(contactGeneralExtrasSchema.safeParse(["lead_status"]).success).toBe(false); + expect(contactGeneralExtrasSchema.safeParse(["engagement"]).success).toBe(false); + expect(hiddenMetricsSchema.safeParse(["sent", "opens", "clicks", "replies"]).success).toBe(false); + expect(hiddenMetricsSchema.safeParse(["opens", "opens"]).success).toBe(false); + }); + + const cases: { name: string; schema: z.ZodType; initial: unknown; changed: unknown }[] = [ + { name: "campaigns:list:folder", schema: browseText, initial: "", changed: "folder-id" }, + { name: "campaigns:list:query", schema: browseText, initial: "", changed: "renewal" }, + { name: "campaigns:list:status", schema: campaignStatusSchema, initial: "all", changed: "paused" }, + { name: "campaigns:list:sort", schema: campaignSortSchema, initial: "newest", changed: "name" }, + { name: "contacts:labels:query", schema: browseText, initial: "", changed: "vip" }, + { name: "contacts:segments:query", schema: browseText, initial: "", changed: "europe" }, + { name: "contacts:suppressions:query", schema: browseText, initial: "", changed: "example.com" }, + { name: "campaigns:a:analytics:period", schema: campaignPeriodSchema, initial: { key: "all" }, changed: { key: "custom", from: "2026-10-01", to: "2026-10-07" } }, + { name: "campaigns:a:analytics:hidden-metrics", schema: hiddenMetricsSchema, initial: [], changed: ["opens", "clicks", "replies"] }, + { name: "contacts:a:panel:tab", schema: contactTabSchema, initial: "overview", changed: "research" }, + { name: "contacts:a:activity:filters", schema: activityFiltersSchema, initial: { type: "all", query: "", from: "", to: "" }, changed: { type: "replies", query: "subject", from: "2026-10-01", to: "2026-10-07" } }, + { name: "contacts:crm-import:a:lists:query", schema: browseText, initial: "", changed: "accounts" }, + { name: "contacts:imports:a:failures:query", schema: browseText, initial: "", changed: "invalid" }, + { name: "contacts:imports:a:mapping:view", schema: importViewSchema, initial: "columns", changed: "preview" }, + { name: "contacts:imports:a:mapping:query", schema: browseText, initial: "", changed: "email" }, + { name: "contacts:imports:a:mapping:unmapped-only", schema: z.boolean(), initial: false, changed: true }, + { name: "contacts:add-from:segment:a:labels", schema: browseIds, initial: [], changed: ["unlisted-label"] }, + ]; + it.each(cases)("restores $name after changing and remounting, without leaking to another resource", ({ name, schema, initial, changed }) => { + const page = renderHook(({ resource }) => useBrowseState(resource, initial, schema), { wrapper: Owner, initialProps: { resource: name } }); + expect(page.result.current[0]).toEqual(initial); + act(() => page.result.current[1](changed)); + page.unmount(); + const refreshed = renderHook(({ resource }) => useBrowseState(resource, initial, schema), { wrapper: Owner, initialProps: { resource: name } }); + expect(refreshed.result.current[0]).toEqual(changed); + refreshed.rerender({ resource: `${name}:other` }); + expect(refreshed.result.current[0]).toEqual(initial); + }); +}); diff --git a/web/src/components/app/contacts/filters/useBrowseDebouncedValue.ts b/web/src/components/app/contacts/filters/useBrowseDebouncedValue.ts new file mode 100644 index 000000000..344acb652 --- /dev/null +++ b/web/src/components/app/contacts/filters/useBrowseDebouncedValue.ts @@ -0,0 +1,16 @@ +import { useContext, useEffect, useState } from "react"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; + +export default function useBrowseDebouncedValue(value: string, resource: string, delay = 200) { + const userId = useContext(UserContext)?.user.id; + const orgId = useAppStore((s) => s.currentOrganization?.id); + const scope = `${userId}:${orgId}:${resource}`; + const [memory, setMemory] = useState({ scope, value }); + if (memory.scope !== scope) setMemory({ scope, value }); + useEffect(() => { + const timer = setTimeout(() => setMemory({ scope, value }), delay); + return () => clearTimeout(timer); + }, [scope, value, delay]); + return memory.scope === scope ? memory.value : value; +} diff --git a/web/src/components/app/contacts/filters/useContactBrowseState.test.tsx b/web/src/components/app/contacts/filters/useContactBrowseState.test.tsx new file mode 100644 index 000000000..1504e0382 --- /dev/null +++ b/web/src/components/app/contacts/filters/useContactBrowseState.test.tsx @@ -0,0 +1,122 @@ +import { act, renderHook } from "@testing-library/react"; +import { type ComponentProps, type ReactNode, useState } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState"; +import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; +import useContactBrowseState from "./useContactBrowseState"; +import useBrowseDebouncedValue from "./useBrowseDebouncedValue"; +import { scopeSearch } from "./helpers"; + +let userId = "member"; +function Owner({ children }: { children: ReactNode }) { + const value = { user: { id: userId } } as ComponentProps["value"]; + return {children}; +} +const initialSort = () => ({ sort_by: "created_at" as const, reverse: false }); +const key = (name: string) => `${BROWSE_STORAGE_PREFIX}member:workspace:${name}`; + +describe("contact list browsing persistence", () => { + beforeEach(() => { + sessionStorage.clear(); + userId = "member"; + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it("restores all canonical filters, blank custom pills and actual Dates on the first remount", () => { + const page = renderHook(() => useContactBrowseState({ initialSort }), { wrapper: Owner }); + const changed: SearchContacts = { + ...page.result.current[0], query: "alice", campaign_ids: ["selected-campaign"], category_ids: ["label"], segment_ids: ["selected-segment"], + custom_field_filters: [{ name: "", value: "", type: "contains" }, { name: "unlisted-key", value: "yes", type: "equal" }], + subscribed: false, verification_status: "risky", mail_hosts: ["", "gmail"], min_campaigns: 0, max_campaigns: 12, + created_after: new Date("2026-10-01T00:00:00Z"), created_before: new Date("2026-10-02T00:00:00Z"), + updated_after: new Date("2026-10-03T00:00:00Z"), updated_before: new Date("2026-10-04T00:00:00Z"), + }; + act(() => page.result.current[1](changed)); + page.unmount(); + const refreshed = renderHook(() => useContactBrowseState({ initialSort }), { wrapper: Owner }); + expect(refreshed.result.current[0]).toEqual({ ...changed, lead_status: undefined, engagement: undefined }); + expect(refreshed.result.current[0].created_after?.toISOString()).toBe("2026-10-01T00:00:00.000Z"); + }); + + it("keeps consecutive functional updates and distinguishes full reset from filter-bar clearing", () => { + const page = renderHook(() => useContactBrowseState({ campaignId: "a", initialSort }), { wrapper: Owner }); + act(() => { + page.result.current[1]((s) => ({ ...s, query: "alice" })); + page.result.current[1]((s) => ({ ...s, lead_status: "unsubscribed", engagement: "bounced" })); + }); + page.unmount(); + const refreshed = renderHook(() => useContactBrowseState({ campaignId: "a", initialSort }), { wrapper: Owner }); + expect(refreshed.result.current[0]).toMatchObject({ query: "alice", lead_status: "unsubscribed", engagement: "bounced", campaign_ids: ["a"] }); + act(() => refreshed.result.current[1]((s) => ({ ...scopeSearch({ campaignId: "a" }), query: s.query }))); + expect(refreshed.result.current[0].query).toBe("alice"); + act(() => refreshed.result.current[1](scopeSearch({ campaignId: "a" }))); + refreshed.unmount(); + const cleared = renderHook(() => useContactBrowseState({ campaignId: "a", initialSort }), { wrapper: Owner }); + expect(cleared.result.current[0]).toMatchObject({ query: "", custom_field_filters: [], campaign_ids: ["a"] }); + expect(cleared.result.current[0].lead_status).toBeUndefined(); + }); + + it("isolates campaign and segment resources while mounted and always recomposes route IDs", () => { + const page = renderHook(({ campaignId, segmentId }: { campaignId?: string; segmentId?: string }) => useContactBrowseState({ campaignId, segmentId, initialSort }), + { wrapper: Owner, initialProps: { campaignId: "a", segmentId: undefined } as { campaignId?: string; segmentId?: string } }); + act(() => page.result.current[1]((s) => ({ ...s, query: "campaign-a", campaign_ids: ["wrong"], segment_ids: ["linked"], lead_status: "paused" }))); + expect(page.result.current[0].campaign_ids).toEqual(["a"]); + page.rerender({ campaignId: "b", segmentId: undefined }); + expect(page.result.current[0]).toMatchObject({ query: "", campaign_ids: ["b"] }); + page.rerender({ campaignId: undefined, segmentId: "s" }); + act(() => page.result.current[1]((s) => ({ ...s, query: "segment", segment_ids: ["wrong"] }))); + expect(page.result.current[0].segment_ids).toEqual(["s"]); + page.rerender({ campaignId: "a", segmentId: undefined }); + expect(page.result.current[0]).toMatchObject({ query: "campaign-a", campaign_ids: ["a"], segment_ids: ["linked"], lead_status: "paused" }); + }); + + it("lets initial and mounted URL intents win without erasing unrelated filters and removes cleared parameters", () => { + sessionStorage.setItem(key("contacts:all:filters"), JSON.stringify({ value: { query: "saved", custom_field_filters: [], campaign_ids: [] } })); + sessionStorage.setItem(key("contacts:all:labels"), JSON.stringify({ value: ["saved-label"] })); + const page = renderHook(({ navigationCategory }) => { + const [params, setParams] = useState(new URLSearchParams("category=linked&contact=deep-contact")); + const browse = useContactBrowseState({ initialSort, category: navigationCategory ?? params.get("category") ?? undefined, + clearCategoryIntent: () => setParams((prev) => { const next = new URLSearchParams(prev); next.delete("category"); return next; }) }); + return { browse, params }; + }, { wrapper: Owner, initialProps: { navigationCategory: undefined as string | undefined } }); + expect(page.result.current.browse[0]).toMatchObject({ query: "saved", category_ids: ["linked"] }); + page.rerender({ navigationCategory: "another-link" }); + expect(page.result.current.browse[0].category_ids).toEqual(["another-link"]); + page.rerender({ navigationCategory: undefined }); + act(() => page.result.current.browse[1]((s) => ({ ...s, category_ids: undefined }))); + expect(page.result.current.params.has("category")).toBe(false); + expect(page.result.current.params.get("contact")).toBe("deep-contact"); + page.unmount(); + const refreshed = renderHook(() => useContactBrowseState({ initialSort }), { wrapper: Owner }); + expect(refreshed.result.current[0].category_ids).toBeUndefined(); + }); + + it("does not store sort in browse filters or override the existing saved-sort authority", () => { + const page = renderHook(() => useContactBrowseState({ initialSort }), { wrapper: Owner }); + act(() => page.result.current[1]((s) => ({ ...s, query: "saved", sort_by: "email", reverse: true }))); + expect(JSON.parse(sessionStorage.getItem(key("contacts:all:filters"))!).value).not.toHaveProperty("sort_by"); + page.unmount(); + const refreshed = renderHook(() => useContactBrowseState({ initialSort: () => ({ sort_by: "company", reverse: false }) }), { wrapper: Owner }); + expect(refreshed.result.current[0]).toMatchObject({ query: "saved", sort_by: "company", reverse: false }); + }); + + it("restores ownership synchronously, including debounce mirrors, on workspace and user changes", () => { + const page = renderHook(() => { + const browse = useContactBrowseState({ initialSort }); + return { browse, debounced: useBrowseDebouncedValue(browse[0].query, "contacts:all") }; + }, { wrapper: Owner }); + act(() => page.result.current.browse[1]((s) => ({ ...s, query: "workspace-one" }))); + act(() => useAppStore.setState({ currentOrganization: { id: "two", name: "Two", role: "owner" } })); + expect(page.result.current.browse[0].query).toBe(""); + expect(page.result.current.debounced).toBe(""); + act(() => page.result.current.browse[1]((s) => ({ ...s, query: "workspace-two" }))); + userId = "other-member"; + page.rerender(); + expect(page.result.current.browse[0].query).toBe(""); + userId = "member"; + page.rerender(); + expect(page.result.current.debounced).toBe("workspace-two"); + }); +}); diff --git a/web/src/components/app/contacts/filters/useContactBrowseState.ts b/web/src/components/app/contacts/filters/useContactBrowseState.ts new file mode 100644 index 000000000..1658ed3fe --- /dev/null +++ b/web/src/components/app/contacts/filters/useContactBrowseState.ts @@ -0,0 +1,68 @@ +import { useCallback, useContext, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import { browseIds, contactFiltersSchema, type ContactBrowseFilters } from "@/lib/browse-contacts-campaigns"; +import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; + +export function contactBrowseName(campaignId?: string, segmentId?: string) { + return campaignId ? `campaigns:${campaignId}:leads` : segmentId ? `contacts:segments:${segmentId}:members` : "contacts:all"; +} + +export default function useContactBrowseState({ campaignId, segmentId, category, initialSort, clearCategoryIntent }: { + campaignId?: string; + segmentId?: string; + category?: string; + initialSort: () => Pick; + clearCategoryIntent?: () => void; +}): [SearchContacts, Dispatch>] { + const name = contactBrowseName(campaignId, segmentId); + const userId = useContext(UserContext)?.user.id; + const orgId = useAppStore((s) => s.currentOrganization?.id); + const owner = `${userId}:${orgId}:${campaignId ? "campaign_leads" : "contacts"}`; + const [filters, setFilters] = useBrowseState(`${name}:filters`, { + query: "", custom_field_filters: [], campaign_ids: [], + }, contactFiltersSchema); + const linkedCategory = !campaignId && !segmentId ? category : undefined; + const [categories, setCategories] = useBrowseState(`${name}:labels`, [], browseIds, + linkedCategory ? { initialOverride: [linkedCategory] } : undefined); + const [intent, setIntent] = useState(linkedCategory); + if (intent !== linkedCategory) { + setIntent(linkedCategory); + if (linkedCategory) setCategories([linkedCategory]); + } + // Sort remains owned by the existing server/localStorage view preferences. + const [sortMemory, setSortMemory] = useState(() => ({ owner, value: initialSort() })); + let sort = sortMemory.value; + if (sortMemory.owner !== owner) { + sort = initialSort(); + setSortMemory({ owner, value: sort }); + } + const search: SearchContacts = { + ...filters, ...sort, + category_ids: intent !== linkedCategory && linkedCategory ? [linkedCategory] : categories.length ? categories : undefined, + campaign_ids: campaignId ? [campaignId] : filters.campaign_ids, + segment_ids: segmentId ? [segmentId] : filters.segment_ids, + lead_status: campaignId ? filters.lead_status : undefined, + engagement: campaignId ? filters.engagement : undefined, + }; + const latest = useRef(search); + latest.current = search; + useEffect(() => { + if (linkedCategory && !search.category_ids?.includes(linkedCategory)) clearCategoryIntent?.(); + }, [linkedCategory, search.category_ids, clearCategoryIntent]); + const setSearch = useCallback>>((update) => { + const next = typeof update === "function" ? update(latest.current) : update; + latest.current = next; + setSortMemory((previous) => previous.owner === owner ? { owner, value: { sort_by: next.sort_by, reverse: next.reverse } } : previous); + setCategories(next.category_ids ?? []); + const { category_ids: _labels, sort_by: _sortBy, reverse: _reverse, ...browseFilters } = next; + setFilters({ + ...browseFilters, campaign_ids: campaignId ? [] : next.campaign_ids, + segment_ids: segmentId ? undefined : next.segment_ids, + lead_status: campaignId ? next.lead_status : undefined, + engagement: campaignId ? next.engagement : undefined, + }); + }, [owner, campaignId, segmentId, setFilters, setCategories]); + return [search, setSearch]; +} diff --git a/web/src/components/app/contacts/import/CrmImportDialog.tsx b/web/src/components/app/contacts/import/CrmImportDialog.tsx index 6f1374060..345f5a529 100644 --- a/web/src/components/app/contacts/import/CrmImportDialog.tsx +++ b/web/src/components/app/contacts/import/CrmImportDialog.tsx @@ -21,7 +21,9 @@ import { Toggle } from "@/components/app/campaigns/preferences/components/Campai import { type CrmInfo, CrmMark } from "@/components/app/crm/crmProviders"; import { crmErrorMessage } from "@/components/app/crm/crmModeUtils"; import useCrmProvider from "@/hooks/useCrmProvider"; -import useDebouncedValue from "@/hooks/useDebouncedValue"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseText } from "@/lib/browse-contacts-campaigns"; +import useBrowseDebouncedValue from "../filters/useBrowseDebouncedValue"; import { useCrmLists, useImportCrmList, usePreviewCrmImport } from "@/lib/api/hooks/app/crm/provider/useCrmLists"; import useCrmMetadata from "@/lib/api/hooks/app/crm/provider/useCrmMetadata"; import { getContactImport } from "@/lib/api/client/app/contacts/contactImports"; @@ -330,8 +332,9 @@ function ListStep({ onPick: (l: CRMList) => void; }) { const { crm } = useCrmProvider(); - const [q, setQ] = React.useState(""); - const query = useDebouncedValue(q.trim(), 300); + const browseName = `contacts:crm-import:${crm.id}:lists`; + const [q, setQ] = useBrowseState(`${browseName}:query`, "", browseText); + const query = useBrowseDebouncedValue(q.trim(), browseName, 300); // Each "Load more" adds the next page's cursor; every page is its own query. const [cursors, setCursors] = React.useState<(string | undefined)[]>([undefined]); React.useEffect(() => setCursors([undefined]), [query]); diff --git a/web/src/components/app/contacts/import/MapStep.tsx b/web/src/components/app/contacts/import/MapStep.tsx index f51e3f6ae..2308a7417 100644 --- a/web/src/components/app/contacts/import/MapStep.tsx +++ b/web/src/components/app/contacts/import/MapStep.tsx @@ -3,6 +3,8 @@ // contacts the mapping makes. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseText, browseBoolean, importViewSchema } from "@/lib/browse-contacts-campaigns"; import { AnimatePresence, motion } from "framer-motion"; import { AlertTriangleIcon, @@ -53,12 +55,14 @@ import TargetIcon from "./TargetIcon"; type View = "columns" | "preview"; export default function MapStep({ + importId, preview: detected, mapping, setMapping, hasHeader, setHasHeader, }: { + importId: string; preview: ImportPreview; mapping: ImportColumnMapping[]; setMapping: React.Dispatch>; @@ -67,9 +71,9 @@ export default function MapStep({ }) { // The header choice moves the first row between header and data for real. const preview = React.useMemo(() => derivePreview(detected, hasHeader), [detected, hasHeader]); - const [view, setView] = React.useState("columns"); - const [query, setQuery] = React.useState(""); - const [unmappedOnly, setUnmappedOnly] = React.useState(false); + const [view, setView] = useBrowseState(`contacts:imports:${importId}:mapping:view`, "columns", importViewSchema); + const [query, setQuery] = useBrowseState(`contacts:imports:${importId}:mapping:query`, "", browseText); + const [unmappedOnly, setUnmappedOnly] = useBrowseState(`contacts:imports:${importId}:mapping:unmapped-only`, false, browseBoolean); const { data: existingKeys = [] } = useCustomFieldKeys(); function getMapping(idx: number): ImportColumnMapping { diff --git a/web/src/components/app/contacts/import/RunStep.tsx b/web/src/components/app/contacts/import/RunStep.tsx index c06ffa3ad..fb56530c8 100644 --- a/web/src/components/app/contacts/import/RunStep.tsx +++ b/web/src/components/app/contacts/import/RunStep.tsx @@ -2,6 +2,8 @@ // read from the import itself, so it is the same view for whoever opens it. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseText } from "@/lib/browse-contacts-campaigns"; import { AnimatePresence, motion } from "framer-motion"; import { AlertTriangleIcon, @@ -142,7 +144,7 @@ function Counts({ imp }: { imp: ContactImport }) { } function Finished({ imp, pinned }: { imp: ContactImport; pinned: string[] }) { - const [query, setQuery] = React.useState(""); + const [query, setQuery] = useBrowseState(`contacts:imports:${imp.id}:failures:query`, "", browseText); const [downloading, setDownloading] = React.useState(false); async function download() { diff --git a/web/src/lib/browse-contacts-campaigns.ts b/web/src/lib/browse-contacts-campaigns.ts new file mode 100644 index 000000000..137114867 --- /dev/null +++ b/web/src/lib/browse-contacts-campaigns.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import type { MailHost } from "@/lib/api/models/app/emails/MailboxImport"; +import { MAIL_HOST_LABELS } from "@/lib/mailHost"; + +export const browseText = z.string(); +export const browseBoolean = z.boolean(); +export const browseIds = z.array(z.string()); +const unique = (values: T[]) => new Set(values).size === values.length; +export const campaignStatusSchema = z.enum(["all", "active", "paused", "draft", "completed"]); +export const campaignSortSchema = z.enum(["newest", "oldest", "name"]); +const isoDate = z.union([z.date(), z.iso.datetime({ offset: true }).transform((value) => new Date(value))]); +const count = z.number().finite().int().nonnegative(); +export const contactFiltersSchema = z.object({ + query: browseText, + custom_field_filters: z.array(z.object({ + name: z.string(), value: z.string(), type: z.enum(["equal", "starts_with", "ends_with", "contains"]), + })), + campaign_ids: browseIds, + segment_ids: browseIds.optional(), + subscribed: z.boolean().optional(), + verification_status: z.enum(["valid", "risky", "invalid", "unknown"]).optional(), + mail_hosts: z.array(z.enum(["", ...Object.keys(MAIL_HOST_LABELS)] as [MailHost, ...MailHost[]])).optional(), + min_campaigns: count.optional(), max_campaigns: count.optional(), + created_after: isoDate.optional(), created_before: isoDate.optional(), + updated_after: isoDate.optional(), updated_before: isoDate.optional(), + lead_status: z.enum(["pending", "active", "completed", "replied", "bounced", "failed", "unsubscribed", "paused", "undeliverable"]).optional(), + engagement: z.enum(["opened", "not_opened", "clicked", "not_clicked", "replied", "not_replied", "bounced"]).optional(), +}); +export type ContactBrowseFilters = z.infer; +export const contactTabSchema = z.enum(["overview", "activity", "deals", "notes", "details", "research"]); +export const contactExtrasSchema = z.array(z.enum(["created", "updated", "campaign_count", "lead_status", "engagement", "verification", "provider"])).refine(unique); +export const contactGeneralExtrasSchema = z.array(z.enum(["created", "updated", "campaign_count", "verification", "provider"])).refine(unique); +export const daySchema = z.iso.date(); +const optionalDay = z.union([z.literal(""), daySchema]); +export const activityFiltersSchema = z.object({ + type: z.enum(["all", "emails", "replies", "deliv", "notes", "meetings", "campaigns", "lifecycle", "website"]), + query: browseText, from: optionalDay, to: optionalDay, +}).refine(({ from, to }) => !from || !to || from <= to); +export const campaignPeriodSchema = z.union([ + z.object({ key: z.enum(["7d", "30d", "90d", "all"]) }), + z.object({ key: z.literal("custom"), from: daySchema, to: daySchema }).refine(({ from, to }) => from <= to), +]); +export const hiddenMetricsSchema = z.array(z.enum(["sent", "opens", "clicks", "replies"])).max(3).refine(unique); +export const importViewSchema = z.enum(["columns", "preview"]);