mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-19 16:03:14 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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,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("");
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"]);
|
||||
Reference in New Issue
Block a user