mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-16 16:03:01 +00:00
feat: persist CRM task filters and views, independent deal board and table browsing choices, and meeting searches with workspace-safe pipeline fallback and remount regression coverage
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
// pipeline. Cards click-open into the same dialog for editing.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { crmBrowseSearchSchema, dealBoardPipelineSchema, dealBrowseViewSchema } from "@/lib/browse-crm";
|
||||
import {
|
||||
CircleDollarSignIcon,
|
||||
LayoutGridIcon,
|
||||
@@ -40,7 +42,7 @@ import {
|
||||
PopoverMenuContent,
|
||||
PopoverMenuItem,
|
||||
} from "@/components/ui/popover-menu";
|
||||
import usePipelines from "@/lib/api/hooks/app/crm/pipelines/usePipelines";
|
||||
import useBrowsePipelines from "@/components/app/crm/useBrowsePipelines";
|
||||
import useSearchDeals from "@/lib/api/hooks/app/crm/deals/useSearchDeals";
|
||||
import useDealsSummary from "@/lib/api/hooks/app/crm/deals/useDealsSummary";
|
||||
import { EMPTY_DEAL_SEARCH } from "@/lib/api/models/app/crm/SearchDeals";
|
||||
@@ -84,7 +86,7 @@ const STATUS_LABEL = {
|
||||
} as const;
|
||||
|
||||
export default function DealsPage() {
|
||||
const pipelines = usePipelines();
|
||||
const pipelines = useBrowsePipelines();
|
||||
const { isExternal, crm } = useCrmProvider();
|
||||
const { data: members } = useMembers();
|
||||
const crmCtx = React.useMemo(() => {
|
||||
@@ -96,21 +98,24 @@ export default function DealsPage() {
|
||||
// (a fresh `?? []` each render would re-fire them every time).
|
||||
const list = React.useMemo(() => pipelines.data ?? [], [pipelines.data]);
|
||||
|
||||
const [pipelineId, setPipelineId] = React.useState<string | undefined>(undefined);
|
||||
const [pipelineId, setPipelineId] = useBrowseState("crm.deals.board.pipeline", undefined, dealBoardPipelineSchema);
|
||||
React.useEffect(() => {
|
||||
if (!pipelineId && list.length > 0) setPipelineId(list[0].id);
|
||||
}, [list, pipelineId]);
|
||||
if (!pipelines.isSuccess || pipelines.isPlaceholderData) return;
|
||||
if (!list.some((pipeline) => pipeline.id === pipelineId) && pipelineId !== list[0]?.id) {
|
||||
setPipelineId(list[0]?.id);
|
||||
}
|
||||
}, [list, pipelineId, pipelines.isSuccess, pipelines.isPlaceholderData, setPipelineId]);
|
||||
|
||||
const currentPipeline = list.find((p) => p.id === pipelineId);
|
||||
const stages = [...(currentPipeline?.stages ?? [])].sort((a, b) => a.position - b.position);
|
||||
const updateDeal = useUpdateDeal();
|
||||
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [search, setSearch] = useBrowseState("crm.deals.board.search", "", crmBrowseSearchSchema);
|
||||
const [newOpen, setNewOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<Deal | null>(null);
|
||||
// Table = the cross-pipeline, server-driven "see everything" view (default).
|
||||
// Board = the focused single-pipeline kanban for moving deals through stages.
|
||||
const [view, setView] = React.useState<"table" | "board">("table");
|
||||
const [view, setView] = useBrowseState("crm.deals.view", "table", dealBrowseViewSchema);
|
||||
|
||||
// When editing a deal opened from the cross-pipeline table, scope the stage
|
||||
// picker to that deal's OWN pipeline, not whichever pipeline the board has
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
// the whole set via /meetings/summary, not a reduce over the loaded page.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { crmBrowseSearchSchema, meetingBrowseTimeframeSchema } from "@/lib/browse-crm";
|
||||
import {
|
||||
CalendarClockIcon,
|
||||
CalendarPlusIcon,
|
||||
@@ -137,9 +141,15 @@ function downloadICS(m: MeetingBooking) {
|
||||
}
|
||||
|
||||
export default function MeetingsPage() {
|
||||
const [timeframe, setTimeframe] = React.useState<Timeframe>("upcoming");
|
||||
const [searchRaw, setSearchRaw] = React.useState("");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const userId = React.useContext(UserContext)?.user.id;
|
||||
const workspaceId = useAppStore((state) => state.currentOrganization?.id ?? "personal");
|
||||
return <MeetingsBrowser key={`${userId}:${workspaceId}`} />;
|
||||
}
|
||||
|
||||
function MeetingsBrowser() {
|
||||
const [timeframe, setTimeframe] = useBrowseState("crm.meetings.timeframe", "upcoming", meetingBrowseTimeframeSchema);
|
||||
const [searchRaw, setSearchRaw] = useBrowseState("crm.meetings.search", "", crmBrowseSearchSchema);
|
||||
const [search, setSearch] = React.useState(() => searchRaw.trim());
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
// priority, status), which preserves the TaskTypePicker + themed selectors.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { taskBrowseFiltersSchema, taskBrowseViewSchema } from "@/lib/browse-crm";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowUpDownIcon,
|
||||
@@ -181,8 +183,8 @@ function isOverdue(t: CRMTask): boolean {
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const [filters, setFilters] = React.useState<SearchTasks>(EMPTY_TASK_SEARCH);
|
||||
const [view, setView] = React.useState<"flat" | "grouped">("flat");
|
||||
const [filters, setFilters] = useBrowseState<SearchTasks>("crm.tasks.filters", EMPTY_TASK_SEARCH, taskBrowseFiltersSchema);
|
||||
const [view, setView] = useBrowseState("crm.tasks.view", "flat", taskBrowseViewSchema);
|
||||
const [newOpen, setNewOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<CRMTask | null>(null);
|
||||
|
||||
@@ -1354,6 +1356,11 @@ function AssigneeFacet({
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={220} className="max-h-64 overflow-y-auto">
|
||||
{selected.filter((id) => !members.some((m) => m.user_id === id)).map((id) => (
|
||||
<PopoverMenuItem key={id} onSelect={() => toggle(id)} selected closeOnSelect={false}>
|
||||
{memberLabel(undefined, id)}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
{members.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[11.5px] text-slate-400">No members</div>
|
||||
) : (
|
||||
@@ -1416,6 +1423,11 @@ function TeamFacet({
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={220} className="max-h-64 overflow-y-auto">
|
||||
{selected.filter((id) => !teams.some((t) => t.id === id)).map((id) => (
|
||||
<PopoverMenuItem key={id} onSelect={() => toggle(id)} selected closeOnSelect={false}>
|
||||
{teamLabel(undefined, id)}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
{teams.length === 0 ? (
|
||||
<Link
|
||||
to="/app/settings/teams"
|
||||
@@ -1481,6 +1493,11 @@ function TypeFacet({
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={200} className="max-h-64 overflow-y-auto">
|
||||
{selected.filter((name) => !types.some((t) => t.name === name)).map((name) => (
|
||||
<PopoverMenuItem key={name} onSelect={() => toggle(name)} selected closeOnSelect={false}>
|
||||
{name}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
{types.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[11.5px] text-slate-400">No types yet</div>
|
||||
) : (
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
// "N of M loaded" footer) so the CRM finally reuses the contacts toolkit.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { dealBrowseFiltersSchema } from "@/lib/browse-crm";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CircleDollarSignIcon,
|
||||
@@ -76,7 +78,7 @@ export default function DealsTable({
|
||||
pipelines: Pipeline[];
|
||||
onOpenDeal: (deal: Deal) => void;
|
||||
}) {
|
||||
const [filters, setFilters] = React.useState<SearchDeals>(EMPTY_DEAL_SEARCH);
|
||||
const [filters, setFilters] = useBrowseState<SearchDeals>("crm.deals.table.filters", EMPTY_DEAL_SEARCH, dealBrowseFiltersSchema);
|
||||
|
||||
const search = useSearchDeals({ filters, limit: 50 });
|
||||
const summary = useDealsSummary(filters);
|
||||
@@ -416,6 +418,11 @@ function PipelineFacet({
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={200} className="max-h-64 overflow-y-auto">
|
||||
{selected.filter((id) => !pipelines.some((p) => p.id === id)).map((id) => (
|
||||
<PopoverMenuItem key={id} onSelect={() => toggle(id)} selected closeOnSelect={false}>
|
||||
Selected pipeline {id.slice(0, 6)}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
{pipelines.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[11.5px] text-slate-400">No pipelines yet</div>
|
||||
) : (
|
||||
@@ -478,8 +485,11 @@ function FilterPopover({
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const setNum = (key: "min_value" | "max_value", v: string) =>
|
||||
onChange((f) => ({ ...f, [key]: v.trim() === "" ? undefined : Number(v) }));
|
||||
const setNum = (key: "min_value" | "max_value", v: string) => {
|
||||
const value = v.trim() === "" ? undefined : Number(v);
|
||||
if (value !== undefined && !Number.isFinite(value)) return;
|
||||
onChange((f) => ({ ...f, [key]: value }));
|
||||
};
|
||||
const setDate = (key: "close_after" | "close_before", v: string) =>
|
||||
onChange((f) => ({ ...f, [key]: v ? new Date(v).toISOString() : undefined }));
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type * as Router from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, 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 { EMPTY_TASK_SEARCH } from "@/lib/api/models/app/crm/SearchTasks";
|
||||
import { EMPTY_DEAL_SEARCH } from "@/lib/api/models/app/crm/SearchDeals";
|
||||
import type Pipeline from "@/lib/api/models/app/crm/Pipeline";
|
||||
import TasksPage from "@/app/app/crm/tasks/page";
|
||||
import DealsPage from "@/app/app/crm/deals/page";
|
||||
import MeetingsPage from "@/app/app/crm/meetings/page";
|
||||
import DealsTable from "./DealsTable";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tasks: vi.fn(), deals: vi.fn(), dealSummary: vi.fn(), meetings: vi.fn(), pipelines: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api/hooks/app/crm/tasks/useSearchTasks", () => ({ default: mocks.tasks }));
|
||||
vi.mock("@/lib/api/hooks/app/crm/tasks/useTasksSummary", () => ({ default: () => ({}) }));
|
||||
vi.mock("@/lib/api/hooks/app/crm/deals/useSearchDeals", () => ({ default: mocks.deals }));
|
||||
vi.mock("@/lib/api/hooks/app/crm/deals/useDealsSummary", () => ({ default: mocks.dealSummary }));
|
||||
vi.mock("@/lib/api/hooks/app/meetings/useSearchMeetings", () => ({ default: mocks.meetings }));
|
||||
vi.mock("@/lib/api/hooks/app/meetings/useMeetingsSummary", () => ({ default: () => ({}) }));
|
||||
vi.mock("@/lib/api/client/app/crm/pipelines/listPipelines", () => ({ default: mocks.pipelines }));
|
||||
vi.mock("@/lib/api/hooks/app/organizations/useMembers", () => ({ default: () => ({ data: [] }) }));
|
||||
vi.mock("@/lib/api/hooks/app/teams/useTeams", () => ({ default: () => ({ data: [] }) }));
|
||||
vi.mock("@/lib/api/hooks/app/crm/taskTypes/useTaskTypes", () => ({ default: () => ({ data: [] }) }));
|
||||
vi.mock("@/hooks/useCrmProvider", () => ({ default: () => ({ isExternal: false, crm: {} }) }));
|
||||
vi.mock("@/hooks/useLivePatch", () => ({ useLivePatch: () => ({ pushPatch: vi.fn() }) }));
|
||||
vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => vi.fn() }));
|
||||
vi.mock("@/hooks/PresenceProvider", () => ({ usePresenceResource: vi.fn() }));
|
||||
vi.mock("@/components/app/presence/GlobalCursors", () => ({ useSuppressGlobalCursors: vi.fn() }));
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => ({
|
||||
...await importOriginal<typeof Router>(),
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
let client: QueryClient;
|
||||
let userId = "user";
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const profile = { user: { id: userId } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={profile}><QueryClientProvider client={client}>{children}</QueryClientProvider></UserContext.Provider>;
|
||||
}
|
||||
function save(name: string, value: unknown, workspace = "workspace") {
|
||||
sessionStorage.setItem(`${BROWSE_STORAGE_PREFIX}user:${workspace}:${name}`, JSON.stringify({ value }));
|
||||
}
|
||||
function pipeline(id: string, organization_id = "workspace"): Pipeline {
|
||||
return { id, organization_id, name: `Pipeline ${id}`, position: 0, stages: [], created_at: new Date(), updated_at: new Date() };
|
||||
}
|
||||
|
||||
describe("CRM page browsing persistence", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
userId = "user";
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
vi.clearAllMocks();
|
||||
mocks.tasks.mockReturnValue({ tasks: [], total: 0, hasNextPage: false });
|
||||
mocks.deals.mockReturnValue({ deals: [], total: 0 });
|
||||
mocks.dealSummary.mockReturnValue({});
|
||||
mocks.meetings.mockReturnValue({ meetings: [], total: 0, isLoading: false });
|
||||
mocks.pipelines.mockResolvedValue([pipeline("first"), pipeline("second")]);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
client.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("restores task search, status and view on the very first query after remount", () => {
|
||||
const page = render(<TasksPage />, { wrapper: Owner });
|
||||
fireEvent.change(screen.getByPlaceholderText("Search tasks…"), { target: { value: "Call Alice" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Active" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Grouped by due date" }));
|
||||
page.unmount();
|
||||
mocks.tasks.mockClear();
|
||||
render(<TasksPage />, { wrapper: Owner });
|
||||
expect(mocks.tasks.mock.calls[0][0].filters).toEqual({ ...EMPTY_TASK_SEARCH, query: "Call Alice", statuses: ["in_progress"] });
|
||||
expect(screen.getByPlaceholderText("Search tasks…")).toHaveValue("Call Alice");
|
||||
expect(screen.getByRole("button", { name: "Grouped by due date" })).toHaveClass("text-slate-900");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
cleanup();
|
||||
mocks.tasks.mockClear();
|
||||
render(<TasksPage />, { wrapper: Owner });
|
||||
expect(mocks.tasks.mock.calls[0][0].filters).toEqual(EMPTY_TASK_SEARCH);
|
||||
});
|
||||
|
||||
it("keeps missing task types removable without waiting for option metadata", () => {
|
||||
save("crm.tasks.filters", { ...EMPTY_TASK_SEARCH, types: ["Retired type"], sort_by: "title", reverse: true });
|
||||
render(<TasksPage />, { wrapper: Owner });
|
||||
expect(mocks.tasks.mock.calls[0][0].filters.types).toEqual(["Retired type"]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 type/ }));
|
||||
fireEvent.click(screen.getByText("Retired type"));
|
||||
expect(mocks.tasks.mock.lastCall?.[0].filters).toEqual({ ...EMPTY_TASK_SEARCH, sort_by: "title", reverse: true });
|
||||
});
|
||||
|
||||
it("remembers the task popover's partial clear without erasing unrelated filters or sort", () => {
|
||||
const kept = { ...EMPTY_TASK_SEARCH, query: "Call", statuses: ["pending"], types: ["Email"], sort_by: "title", reverse: true };
|
||||
save("crm.tasks.filters", { ...kept, priorities: ["urgent"], due_after: "2026-10-01", overdue: true });
|
||||
const page = render(<TasksPage />, { wrapper: Owner });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Filters/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
page.unmount();
|
||||
mocks.tasks.mockClear();
|
||||
render(<TasksPage />, { wrapper: Owner });
|
||||
expect(mocks.tasks.mock.calls[0][0].filters).toEqual(kept);
|
||||
});
|
||||
|
||||
it("restores the deal table across unmounts and retains a removable deleted pipeline", () => {
|
||||
save("crm.deals.table.filters", { ...EMPTY_DEAL_SEARCH, pipeline_ids: ["gone"], min_value: 0, sort_by: "value", reverse: true });
|
||||
const page = render(<DealsTable pipelines={[]} onOpenDeal={vi.fn()} />, { wrapper: Owner });
|
||||
fireEvent.change(screen.getByPlaceholderText("Search deals…"), { target: { value: "Renewal" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Won" }));
|
||||
page.unmount();
|
||||
mocks.deals.mockClear();
|
||||
render(<DealsTable pipelines={[]} onOpenDeal={vi.fn()} />, { wrapper: Owner });
|
||||
expect(mocks.deals.mock.calls[0][0].filters).toEqual({
|
||||
...EMPTY_DEAL_SEARCH, query: "Renewal", statuses: ["won"], pipeline_ids: ["gone"], min_value: 0, sort_by: "value", reverse: true,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 pipeline/ }));
|
||||
fireEvent.click(screen.getByText("Selected pipeline gone"));
|
||||
expect(mocks.deals.mock.lastCall?.[0].filters.pipeline_ids).toEqual([]);
|
||||
});
|
||||
|
||||
it("starts meetings with the restored trimmed query, not a temporary default request", () => {
|
||||
vi.useFakeTimers();
|
||||
const page = render(<MeetingsPage />, { wrapper: Owner });
|
||||
fireEvent.change(screen.getByPlaceholderText("Search name, email, or event…"), { target: { value: " Discovery " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Past" }));
|
||||
act(() => vi.advanceTimersByTime(250));
|
||||
page.unmount();
|
||||
mocks.meetings.mockClear();
|
||||
render(<MeetingsPage />, { wrapper: Owner });
|
||||
expect(mocks.meetings.mock.calls[0][0].filters).toEqual({ timeframe: "past", q: "Discovery" });
|
||||
expect(screen.getByPlaceholderText("Search name, email, or event…")).toHaveValue(" Discovery ");
|
||||
act(() => vi.advanceTimersByTime(250));
|
||||
expect(mocks.meetings.mock.lastCall?.[0].filters).toEqual({ timeframe: "past", q: "Discovery" });
|
||||
});
|
||||
|
||||
it("rejects nonfinite deal value input and persists the advanced-filter partial clear", () => {
|
||||
const kept = { ...EMPTY_DEAL_SEARCH, query: "Renewal", statuses: ["open"], pipeline_ids: ["first"], sort_by: "value", reverse: true };
|
||||
save("crm.deals.table.filters", { ...kept, min_value: 0, max_value: 500, close_after: "2026-10-01" });
|
||||
const page = render(<DealsTable pipelines={[pipeline("first")]} onOpenDeal={vi.fn()} />, { wrapper: Owner });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Filters/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Min"), { target: { value: "Infinity" } });
|
||||
expect(mocks.deals.mock.lastCall?.[0].filters.min_value).toBe(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
page.unmount();
|
||||
mocks.deals.mockClear();
|
||||
render(<DealsTable pipelines={[pipeline("first")]} onOpenDeal={vi.fn()} />, { wrapper: Owner });
|
||||
expect(mocks.deals.mock.calls[0][0].filters).toEqual(kept);
|
||||
});
|
||||
|
||||
it("does not leak a pending meetings debounce into another workspace or signed-in user", () => {
|
||||
vi.useFakeTimers();
|
||||
save("crm.meetings.search", " receiving ", "another");
|
||||
save("crm.meetings.timeframe", "all", "another");
|
||||
const page = render(<MeetingsPage />, { wrapper: Owner });
|
||||
fireEvent.change(screen.getByPlaceholderText("Search name, email, or event…"), { target: { value: "old workspace" } });
|
||||
mocks.meetings.mockClear();
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "another", name: "Another", role: "owner" } }));
|
||||
expect(mocks.meetings.mock.calls[0][0].filters).toEqual({ timeframe: "", q: "receiving" });
|
||||
act(() => vi.advanceTimersByTime(250));
|
||||
expect(mocks.meetings.mock.lastCall?.[0].filters.q).toBe("receiving");
|
||||
userId = "other-user";
|
||||
mocks.meetings.mockClear();
|
||||
page.rerender(<MeetingsPage />);
|
||||
expect(mocks.meetings.mock.calls[0][0].filters).toEqual({ timeframe: "upcoming", q: undefined });
|
||||
});
|
||||
|
||||
it("waits for authoritative board metadata before replacing a deleted pipeline", async () => {
|
||||
let resolve!: (pipelines: Pipeline[]) => void;
|
||||
mocks.pipelines.mockReturnValue(new Promise<Pipeline[]>((done) => { resolve = done; }));
|
||||
save("crm.deals.view", "board");
|
||||
save("crm.deals.board.pipeline", "gone");
|
||||
save("crm.deals.board.search", "Board query");
|
||||
render(<DealsPage />, { wrapper: Owner });
|
||||
expect(mocks.dealSummary.mock.lastCall?.[0].pipeline_ids).toEqual(["gone"]);
|
||||
expect(JSON.parse(sessionStorage.getItem(`${BROWSE_STORAGE_PREFIX}user:workspace:crm.deals.board.pipeline`)!).value).toBe("gone");
|
||||
await act(async () => resolve([pipeline("first"), pipeline("second")]));
|
||||
await waitFor(() => expect(mocks.dealSummary.mock.lastCall?.[0].pipeline_ids).toEqual(["first"]));
|
||||
for (const input of screen.getAllByPlaceholderText("Search deals…")) expect(input).toHaveValue("Board query");
|
||||
});
|
||||
|
||||
it("clears a missing board pipeline only on successful empty metadata without an update loop", async () => {
|
||||
mocks.pipelines.mockResolvedValue([]);
|
||||
save("crm.deals.board.pipeline", "gone");
|
||||
render(<DealsPage />, { wrapper: Owner });
|
||||
await waitFor(() => expect(mocks.dealSummary.mock.lastCall?.[0].pipeline_ids).toEqual([]));
|
||||
expect(mocks.dealSummary.mock.calls.length).toBeLessThan(10);
|
||||
expect(JSON.parse(sessionStorage.getItem(`${BROWSE_STORAGE_PREFIX}user:workspace:crm.deals.board.pipeline`)!).value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not discard a restored board pipeline on failed metadata", async () => {
|
||||
mocks.pipelines.mockRejectedValue(new Error("Metadata unavailable"));
|
||||
save("crm.deals.board.pipeline", "remembered");
|
||||
render(<DealsPage />, { wrapper: Owner });
|
||||
await screen.findByText("No pipelines yet");
|
||||
expect(mocks.dealSummary.mock.lastCall?.[0].pipeline_ids).toEqual(["remembered"]);
|
||||
expect(JSON.parse(sessionStorage.getItem(`${BROWSE_STORAGE_PREFIX}user:workspace:crm.deals.board.pipeline`)!).value).toBe("remembered");
|
||||
});
|
||||
|
||||
it("restores board choices, retains table filters across view toggles and never defaults to previous workspace metadata", async () => {
|
||||
save("crm.deals.board.pipeline", "second");
|
||||
save("crm.deals.table.filters", { ...EMPTY_DEAL_SEARCH, query: "table query", statuses: ["lost"] });
|
||||
const page = render(<DealsPage />, { wrapper: Owner });
|
||||
await screen.findByPlaceholderText("Search deals…");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Board" }));
|
||||
fireEvent.change(screen.getAllByPlaceholderText("Search deals…")[0], { target: { value: "board query" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Table" }));
|
||||
expect(screen.getByPlaceholderText("Search deals…")).toHaveValue("table query");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Board" }));
|
||||
page.unmount();
|
||||
render(<DealsPage />, { wrapper: Owner });
|
||||
await screen.findAllByPlaceholderText("Search deals…");
|
||||
for (const input of screen.getAllByPlaceholderText("Search deals…")) expect(input).toHaveValue("board query");
|
||||
expect(mocks.dealSummary.mock.lastCall?.[0].pipeline_ids).toEqual(["second"]);
|
||||
let resolve!: (pipelines: Pipeline[]) => void;
|
||||
mocks.pipelines.mockReturnValue(new Promise<Pipeline[]>((done) => { resolve = done; }));
|
||||
save("crm.deals.board.pipeline", "new", "another");
|
||||
save("crm.deals.view", "board", "another");
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "another", name: "Another", role: "owner" } }));
|
||||
expect(mocks.dealSummary.mock.lastCall?.[0].pipeline_ids).toEqual(["new"]);
|
||||
await act(async () => resolve([pipeline("new", "another")]));
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: /Pipeline new/ })).toHaveLength(2));
|
||||
expect(mocks.dealSummary.mock.lastCall?.[0].pipeline_ids).toEqual(["new"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
|
||||
import {
|
||||
taskBrowseFiltersSchema, taskBrowseViewSchema,
|
||||
dealBrowseFiltersSchema, dealBrowseViewSchema, dealBoardPipelineSchema,
|
||||
crmBrowseSearchSchema, meetingBrowseTimeframeSchema,
|
||||
} from "@/lib/browse-crm";
|
||||
import { EMPTY_TASK_SEARCH } from "@/lib/api/models/app/crm/SearchTasks";
|
||||
import { EMPTY_DEAL_SEARCH } from "@/lib/api/models/app/crm/SearchDeals";
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const profile = { user: { id: "user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={profile}>{children}</UserContext.Provider>;
|
||||
}
|
||||
|
||||
describe("CRM browsing schemas and restoration", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores every task filter and view on remount and keeps functional partial resets", () => {
|
||||
const usePreferences = () => ({
|
||||
filters: useBrowseState("crm.tasks.filters", EMPTY_TASK_SEARCH, taskBrowseFiltersSchema),
|
||||
view: useBrowseState("crm.tasks.view", "flat", taskBrowseViewSchema),
|
||||
});
|
||||
const changed = {
|
||||
...EMPTY_TASK_SEARCH, query: " follow up ", statuses: ["in_progress" as const],
|
||||
priorities: ["urgent" as const], types: ["Call"], assigned_to: ["former-member"], team_ids: ["former-team"],
|
||||
due_after: "2026-10-01T00:00:00.000Z", due_before: "2026-10-30T00:00:00+02:00",
|
||||
overdue: true, sort_by: "due_date" as const, reverse: true,
|
||||
};
|
||||
const page = renderHook(usePreferences, { wrapper: Owner });
|
||||
act(() => {
|
||||
page.result.current.filters[1](changed);
|
||||
page.result.current.view[1]("grouped");
|
||||
});
|
||||
page.unmount();
|
||||
const refreshed = renderHook(usePreferences, { wrapper: Owner });
|
||||
expect(refreshed.result.current.filters[0]).toEqual(changed);
|
||||
expect(refreshed.result.current.view[0]).toBe("grouped");
|
||||
act(() => refreshed.result.current.filters[1]((f) => ({
|
||||
...f, priorities: [], due_after: undefined, due_before: undefined, overdue: undefined,
|
||||
})));
|
||||
refreshed.unmount();
|
||||
const cleared = renderHook(usePreferences, { wrapper: Owner });
|
||||
expect(cleared.result.current.filters[0]).toEqual({
|
||||
...changed, priorities: [], due_after: undefined, due_before: undefined, overdue: undefined,
|
||||
});
|
||||
act(() => cleared.result.current.filters[1](EMPTY_TASK_SEARCH));
|
||||
cleared.unmount();
|
||||
expect(renderHook(usePreferences, { wrapper: Owner }).result.current.filters[0]).toEqual(EMPTY_TASK_SEARCH);
|
||||
});
|
||||
|
||||
it("restores independent board and table state without storing derived board API scopes", () => {
|
||||
const usePreferences = () => ({
|
||||
filters: useBrowseState("crm.deals.table.filters", EMPTY_DEAL_SEARCH, dealBrowseFiltersSchema),
|
||||
pipeline: useBrowseState("crm.deals.board.pipeline", undefined, dealBoardPipelineSchema),
|
||||
search: useBrowseState("crm.deals.board.search", "", crmBrowseSearchSchema),
|
||||
view: useBrowseState("crm.deals.view", "table", dealBrowseViewSchema),
|
||||
});
|
||||
const changed = {
|
||||
...EMPTY_DEAL_SEARCH, query: "table query", statuses: ["won" as const], pipeline_ids: ["deleted-pipeline"],
|
||||
min_value: 0, max_value: 2500.5, close_after: "2026-10-01", close_before: "2026-10-30T00:00:00Z",
|
||||
sort_by: "value" as const, reverse: true,
|
||||
};
|
||||
const page = renderHook(usePreferences, { wrapper: Owner });
|
||||
act(() => {
|
||||
page.result.current.filters[1](changed);
|
||||
page.result.current.pipeline[1]("board-pipeline");
|
||||
page.result.current.search[1]("board query");
|
||||
page.result.current.view[1]("board");
|
||||
});
|
||||
page.unmount();
|
||||
const refreshed = renderHook(usePreferences, { wrapper: Owner });
|
||||
expect(refreshed.result.current.filters[0]).toEqual(changed);
|
||||
expect(refreshed.result.current.pipeline[0]).toBe("board-pipeline");
|
||||
expect(refreshed.result.current.search[0]).toBe("board query");
|
||||
expect(refreshed.result.current.view[0]).toBe("board");
|
||||
});
|
||||
|
||||
it("restores meeting tabs and exact raw input, and isolates workspaces while mounted", () => {
|
||||
const usePreferences = () => ({
|
||||
search: useBrowseState("crm.meetings.search", "", crmBrowseSearchSchema),
|
||||
timeframe: useBrowseState("crm.meetings.timeframe", "upcoming", meetingBrowseTimeframeSchema),
|
||||
});
|
||||
const page = renderHook(usePreferences, { wrapper: Owner });
|
||||
act(() => {
|
||||
page.result.current.search[1](" booked call ");
|
||||
page.result.current.timeframe[1]("past");
|
||||
});
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "another", name: "Another", role: "owner" } }));
|
||||
expect(page.result.current.search[0]).toBe("");
|
||||
expect(page.result.current.timeframe[0]).toBe("upcoming");
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }));
|
||||
expect(page.result.current.search[0]).toBe(" booked call ");
|
||||
expect(page.result.current.timeframe[0]).toBe("past");
|
||||
page.unmount();
|
||||
const refreshed = renderHook(usePreferences, { wrapper: Owner });
|
||||
expect(refreshed.result.current.search[0]).toBe(" booked call ");
|
||||
expect(refreshed.result.current.timeframe[0]).toBe("past");
|
||||
});
|
||||
|
||||
it("merges partial known filters into defaults without reviving hidden API scope", () => {
|
||||
expect(taskBrowseFiltersSchema.parse({ query: "a", contact_id: "contact", deal_id: "deal" }))
|
||||
.toEqual({ ...EMPTY_TASK_SEARCH, query: "a" });
|
||||
expect(dealBrowseFiltersSchema.parse({ query: "b", stage_ids: ["stage"], assigned_to: ["owner"], created_after: "bad" }))
|
||||
.toEqual({ ...EMPTY_DEAL_SEARCH, query: "b" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ statuses: ["done"] }, { priorities: ["important"] }, { types: null }, { assigned_to: [1] },
|
||||
{ team_ids: "team" }, { reverse: "true" }, { sort_by: "deprecated" }, { overdue: null },
|
||||
{ due_after: null }, { due_before: "2026-02-30" }, { due_after: "October 8" },
|
||||
])("rejects malformed task preferences %j", (value) => {
|
||||
expect(taskBrowseFiltersSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null, [], { statuses: ["archived"] }, { pipeline_ids: "pipeline" }, { min_value: null },
|
||||
{ max_value: NaN }, { min_value: Infinity }, { max_value: -Infinity }, { close_after: null },
|
||||
{ close_before: "2026-02-30T00:00:00Z" }, { sort_by: "priority" }, { query: false },
|
||||
])("rejects malformed deal preferences %j", (value) => {
|
||||
expect(dealBrowseFiltersSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects stale selections and safely falls back on a malformed stored filter", () => {
|
||||
expect(taskBrowseViewSchema.safeParse("board").success).toBe(false);
|
||||
expect(dealBrowseViewSchema.safeParse("grouped").success).toBe(false);
|
||||
expect(meetingBrowseTimeframeSchema.safeParse("today").success).toBe(false);
|
||||
expect(dealBoardPipelineSchema.safeParse("").success).toBe(false);
|
||||
expect(dealBoardPipelineSchema.safeParse(null).success).toBe(false);
|
||||
sessionStorage.setItem(`${BROWSE_STORAGE_PREFIX}user:workspace:crm.deals.table.filters`, JSON.stringify({
|
||||
value: { ...EMPTY_DEAL_SEARCH, query: "bad numeric shape", min_value: null },
|
||||
}));
|
||||
const page = renderHook(() => useBrowseState("crm.deals.table.filters", EMPTY_DEAL_SEARCH, dealBrowseFiltersSchema), { wrapper: Owner });
|
||||
expect(page.result.current[0]).toEqual(EMPTY_DEAL_SEARCH);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useContext } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { pipelinesQuery } from "@/lib/api/hooks/app/crm/pipelines/usePipelines";
|
||||
|
||||
export default function useBrowsePipelines() {
|
||||
const userId = useContext(UserContext)?.user.id;
|
||||
const workspaceId = useAppStore((state) => state.currentOrganization?.id ?? "personal");
|
||||
// Pipeline fallbacks must never use the previous workspace's cached list.
|
||||
return useQuery({
|
||||
...pipelinesQuery,
|
||||
queryKey: [...pipelinesQuery.queryKey, "browse", userId ?? "anonymous", workspaceId],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { EMPTY_TASK_SEARCH } from "@/lib/api/models/app/crm/SearchTasks";
|
||||
import { EMPTY_DEAL_SEARCH } from "@/lib/api/models/app/crm/SearchDeals";
|
||||
|
||||
const isoDate = z.union([z.iso.datetime({ offset: true }), z.iso.date()]);
|
||||
|
||||
export const taskBrowseFiltersSchema = z.object({
|
||||
query: z.string(),
|
||||
statuses: z.array(z.enum(["pending", "in_progress", "completed", "cancelled"])),
|
||||
priorities: z.array(z.enum(["low", "medium", "high", "urgent"])),
|
||||
types: z.array(z.string()),
|
||||
assigned_to: z.array(z.string()),
|
||||
team_ids: z.array(z.string()),
|
||||
due_after: isoDate,
|
||||
due_before: isoDate,
|
||||
overdue: z.boolean(),
|
||||
sort_by: z.enum(["created_at", "updated_at", "due_date", "priority", "title"]),
|
||||
reverse: z.boolean(),
|
||||
}).partial().transform((filters) => ({ ...EMPTY_TASK_SEARCH, ...filters }));
|
||||
|
||||
export const taskBrowseViewSchema = z.enum(["flat", "grouped"]);
|
||||
export const dealBrowseViewSchema = z.enum(["table", "board"]);
|
||||
export const dealBoardPipelineSchema = z.string().min(1).optional();
|
||||
export const crmBrowseSearchSchema = z.string();
|
||||
export const meetingBrowseTimeframeSchema = z.enum(["upcoming", "past", "all"]);
|
||||
|
||||
export const dealBrowseFiltersSchema = z.object({
|
||||
query: z.string(),
|
||||
statuses: z.array(z.enum(["open", "won", "lost"])),
|
||||
pipeline_ids: z.array(z.string()),
|
||||
min_value: z.number().finite(),
|
||||
max_value: z.number().finite(),
|
||||
close_after: isoDate,
|
||||
close_before: isoDate,
|
||||
sort_by: z.enum(["created_at", "updated_at", "value", "expected_close_date", "name"]),
|
||||
reverse: z.boolean(),
|
||||
}).partial().transform((filters) => ({ ...EMPTY_DEAL_SEARCH, ...filters }));
|
||||
Reference in New Issue
Block a user