feat: persist user and workspace scoped contacts and campaign browsing filters, resource-specific activity and import views, contact drawer tabs and analytics periods while preserving URL intent, saved sorting and intentional resets with regression coverage

This commit is contained in:
Matthew Meszaros
2026-10-08 07:34:59 +00:00
parent acc3d62ba3
commit e3a128bdea
27 changed files with 646 additions and 60 deletions
+4 -2
View File
@@ -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<CampaignPeriod>(loadCampaignPeriod);
const [period, setPeriod] = useBrowseState<CampaignPeriod>(`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<Metric[]>([]);
const [hiddenMetrics, setHiddenMetrics] = useBrowseState<Metric[]>(`campaigns:${id}:analytics:hidden-metrics`, [], hiddenMetricsSchema);
const toggleMetric = (k: Metric) =>
setHiddenMetrics((cur) => {
if (cur.includes(k)) return cur.filter((x) => x !== k);
+7 -5
View File
@@ -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<string>("");
const [query, setQuery] = useState<string>("");
const [status, setStatus] = useState<StatusFilter>("all");
const [sort, setSort] = useState<SortMode>("newest");
const [folder, setFolder] = useBrowseState("campaigns:list:folder", "", browseText);
const [query, setQuery] = useBrowseState("campaigns:list:query", "", browseText);
const [status, setStatus] = useBrowseState<StatusFilter>("campaigns:list:status", "all", campaignStatusSchema);
const [sort, setSort] = useBrowseState<SortMode>("campaigns:list:sort", "newest", campaignSortSchema);
const [newOpen, setNewOpen] = useState<boolean>(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() {
<PopoverMenuTrigger asChild>
<SelectButton
icon={<FolderIcon className="w-3.5 h-3.5" />}
label={activeFolder?.title ?? "All folders"}
label={activeFolder?.title ?? (folder ? "Selected folder" : "All folders")}
/>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={200}>
+3 -1
View File
@@ -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("");
+3 -1
View File
@@ -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<Segment | null>(null);
const [campaignFor, setCampaignFor] = React.useState<Segment | null>(null);
@@ -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);
@@ -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)}
@@ -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 }) =>
<button onClick={() => onChange(value.length ? [] : ["label"])}>{value.join(",") || "Choose labels"}</button> }));
function Owner({ children }: { children: ReactNode }) {
const value = { user: { id: "member" } } as ComponentProps<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
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(<AddFromContactsDialog open campaign={{ id: "a", name: "A" }} onClose={onClose} />, { wrapper: Owner });
fireEvent.change(query(), { target: { value: "alice" } });
fireEvent.click(screen.getByText("Choose labels"));
page.rerender(<AddFromContactsDialog open={false} campaign={{ id: "a", name: "A" }} onClose={onClose} />);
page.unmount();
searchContacts.mockClear();
const refreshed = render(<AddFromContactsDialog open={false} campaign={{ id: "a", name: "A" }} onClose={onClose} />, { wrapper: Owner });
expect(searchContacts.mock.calls[0][0]).toMatchObject({ options: { query: "alice", category_ids: ["label"] } });
refreshed.rerender(<AddFromContactsDialog open campaign={{ id: "a", name: "A" }} onClose={onClose} />);
expect(query()).toHaveValue("alice");
expect(screen.getByText("label")).toBeInTheDocument();
refreshed.rerender(<AddFromContactsDialog open campaign={{ id: "b", name: "B" }} onClose={onClose} />);
expect(query()).toHaveValue("");
expect(screen.getByText("Choose labels")).toBeInTheDocument();
refreshed.rerender(<AddFromContactsDialog open target={{ kind: "segment", segment: { id: "a", name: "A" } }} onClose={onClose} />);
expect(query()).toHaveValue("");
refreshed.rerender(<AddFromContactsDialog open campaign={{ id: "a", name: "A" }} onClose={onClose} />);
expect(query()).toHaveValue("alice");
});
});
@@ -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<string[]>([]);
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<string[]>(`${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>(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<SearchContacts>(
() => ({
@@ -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
/>
<div className="flex-1 min-w-0">
<CategoryPicker
showMissing
value={categoryIds}
onChange={setCategoryIds}
placeholder="Filter by label…"
@@ -0,0 +1,31 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { type ComponentProps, useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { UserContext } from "@/hooks/context/user";
import CategoryPicker from "./CategoryPicker";
vi.mock("@/lib/api/hooks/app/categories/useCreateCategory", () => ({ default: () => ({ isPending: false }) }));
function Browse({ showMissing = true }: { showMissing?: boolean }) {
const [labels, setLabels] = useState(["missing"]);
const value = { user: { id: "member", categories: [] } } as unknown as ComponentProps<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={value}>
<CategoryPicker showMissing={showMissing} value={labels} onChange={setLabels} allowCreate={false} />
<output>{labels.join(",")}</output>
</UserContext.Provider>;
}
describe("unavailable label metadata", () => {
it("keeps browse labels visibly removable while labels are unavailable", () => {
render(<Browse />);
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(<Browse showMissing={false} />);
expect(screen.queryByText("Selected label")).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("missing");
});
});
@@ -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<string, Category>();
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();
@@ -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<ContactSlideTab>(initialTab ?? "overview");
const [tab, setTab] = useBrowseState<ContactSlideTab>(`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);
@@ -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<SearchContacts>(() => {
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}
/>
<FilterBar
browseName={browseName}
filters={searchProps}
setFilters={setSearchProps}
activeCampaign={current_campaign}
@@ -1131,6 +1136,7 @@ export default function ContactsTable({
</SectionBar>
<FilterBar
browseName={browseName}
filters={searchProps}
setFilters={setSearchProps}
hideSegments={!!segment}
@@ -480,6 +480,7 @@ export default function ImportWizard({ open, onClose, lockedCampaign, lockedSegm
)}
{!resuming && step === "map" && draft?.preview && (
<MapStep
importId={draft.id}
preview={draft.preview}
mapping={mapping}
setMapping={setMapping}
@@ -324,6 +324,7 @@ export default function SheetSyncWizard({ open, onClose, lockedCampaign, lockedS
)}
{step === "map" && preview && (
<MapStep
importId={`sheet:${connectionId}:${sheetId}:${tabTitle}`}
preview={preview}
mapping={mapping}
setMapping={setMapping}
@@ -0,0 +1,51 @@
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 { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
import ActivityTab from "./ActivityTab";
const timeline = vi.hoisted(() => ({ 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 }) =>
<input placeholder={placeholder} value={value} onChange={(e) => onChange(e.target.value)} /> }));
function Owner({ children }: { children: ReactNode }) {
const value = { user: { id: "member" } } as ComponentProps<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
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(<ActivityTab contactId="a" contactName="Alice" />, { 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(<ActivityTab contactId="a" contactName="Alice" />, { wrapper: Owner });
expect(query()).toHaveValue("proposal");
expect(screen.queryByRole("button", { name: "Any date" })).not.toBeInTheDocument();
refreshed.rerender(<ActivityTab contactId="b" contactName="Bob" />);
expect(query()).toHaveValue("");
expect(screen.getByRole("button", { name: "Any date" })).toBeInTheDocument();
refreshed.rerender(<ActivityTab contactId="a" contactName="Alice" />);
expect(query()).toHaveValue("proposal");
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
expect(saved()).toEqual({ query: "", type: "all", from: "", to: "" });
refreshed.unmount();
render(<ActivityTab contactId="a" contactName="Alice" />, { wrapper: Owner });
expect(query()).toHaveValue("");
expect(screen.queryByRole("button", { name: "Reset" })).not.toBeInTheDocument();
});
});
@@ -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<FilterId>("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 }),
@@ -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<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={value}>
<QueryClientProvider client={client}>
<ContactEdit contacts={[contact(), contact({ id: "contact-2" })]} active={active} setActive={() => {}} initialTab={initialTab} />
</QueryClientProvider>
</UserContext.Provider>;
}
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(<BrowsePanel />);
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
expect(screen.getByText("activity")).toBeInTheDocument();
page.unmount();
const refreshed = render(<BrowsePanel />);
expect(screen.getByText("activity")).toBeInTheDocument();
refreshed.rerender(<BrowsePanel active="contact-2" />);
expect(screen.getByText("overview")).toBeInTheDocument();
refreshed.unmount();
render(<BrowsePanel initialTab="details" />);
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(<Panel contacts={[contact()]} />);
expect(screen.getByText("Unsubscribed")).toBeTruthy();
@@ -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<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
function List({ resource = "contacts:all" }: { resource?: string }) {
const [filters, setFilters] = useContactBrowseState({ initialSort: () => ({ sort_by: "created_at", reverse: false }) });
const [resetToken, setResetToken] = useState(0);
return <>
<button onClick={() => setResetToken((v) => v + 1)}>Reset view</button>
<FilterBar browseName={resource} filters={filters} setFilters={setFilters} total={0} loading={false} resetToken={resetToken} />
</>;
}
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(<List />, { 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(<List />, { wrapper: Owner });
expect(screen.getByRole("button", { name: "Remove Verification filter" })).toBeInTheDocument();
refreshed.rerender(<List resource="contacts:segments:s:members" />);
expect(screen.queryByRole("button", { name: "Remove Verification filter" })).not.toBeInTheDocument();
refreshed.rerender(<List />);
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(<List />, { wrapper: Owner });
expect(screen.queryByRole("button", { name: "Remove Verification filter" })).not.toBeInTheDocument();
});
});
@@ -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<ExtraKey[]>([]);
const [extras, setExtras] = useBrowseState<ExtraKey[]>(`${browseName}:extra-filters`, [], campaignCtx ? contactExtrasSchema : contactGeneralExtrasSchema);
const [openKey, setOpenKey] = React.useState<string | null>(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 && <CheckIcon className="w-2 h-2 text-white" />}
</button>
<span className="text-[12px] text-slate-700 w-16">{label}</span>
<NumberInput value={value ?? 0} onChange={(v) => onChange(Math.max(0, v))} min={0} disabled={!set} suffix="campaigns" className="flex-1" />
<NumberInput value={value ?? 0} onChange={(v) => onChange(Math.max(0, Math.floor(v)))} min={0} disabled={!set} suffix="campaigns" className="flex-1" />
</div>
);
}
@@ -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<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
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);
});
});
@@ -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;
}
@@ -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<typeof UserContext.Provider>["value"];
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
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");
});
});
@@ -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<SearchContacts, "sort_by" | "reverse">;
clearCategoryIntent?: () => void;
}): [SearchContacts, Dispatch<SetStateAction<SearchContacts>>] {
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<ContactBrowseFilters>(`${name}:filters`, {
query: "", custom_field_filters: [], campaign_ids: [],
}, contactFiltersSchema);
const linkedCategory = !campaignId && !segmentId ? category : undefined;
const [categories, setCategories] = useBrowseState<string[]>(`${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<Dispatch<SetStateAction<SearchContacts>>>((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];
}
@@ -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]);
@@ -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<React.SetStateAction<ImportColumnMapping[]>>;
@@ -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<View>("columns");
const [query, setQuery] = React.useState("");
const [unmappedOnly, setUnmappedOnly] = React.useState(false);
const [view, setView] = useBrowseState<View>(`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 {
@@ -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() {
+44
View File
@@ -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 = <T>(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<typeof contactFiltersSchema>;
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"]);