diff --git a/web/src/app/app/crm/deals/page.tsx b/web/src/app/app/crm/deals/page.tsx index b6b5d6246..273f596a5 100644 --- a/web/src/app/app/crm/deals/page.tsx +++ b/web/src/app/app/crm/deals/page.tsx @@ -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(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(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 diff --git a/web/src/app/app/crm/meetings/page.tsx b/web/src/app/app/crm/meetings/page.tsx index 3b15fbcb2..f8a892b21 100644 --- a/web/src/app/app/crm/meetings/page.tsx +++ b/web/src/app/app/crm/meetings/page.tsx @@ -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("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 ; +} + +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(() => { diff --git a/web/src/app/app/crm/tasks/page.tsx b/web/src/app/app/crm/tasks/page.tsx index fab9fbd0b..8df800cf4 100644 --- a/web/src/app/app/crm/tasks/page.tsx +++ b/web/src/app/app/crm/tasks/page.tsx @@ -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(EMPTY_TASK_SEARCH); - const [view, setView] = React.useState<"flat" | "grouped">("flat"); + const [filters, setFilters] = useBrowseState("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(null); @@ -1354,6 +1356,11 @@ function AssigneeFacet({ + {selected.filter((id) => !members.some((m) => m.user_id === id)).map((id) => ( + toggle(id)} selected closeOnSelect={false}> + {memberLabel(undefined, id)} + + ))} {members.length === 0 ? (
No members
) : ( @@ -1416,6 +1423,11 @@ function TeamFacet({ + {selected.filter((id) => !teams.some((t) => t.id === id)).map((id) => ( + toggle(id)} selected closeOnSelect={false}> + {teamLabel(undefined, id)} + + ))} {teams.length === 0 ? ( + {selected.filter((name) => !types.some((t) => t.name === name)).map((name) => ( + toggle(name)} selected closeOnSelect={false}> + {name} + + ))} {types.length === 0 ? (
No types yet
) : ( diff --git a/web/src/components/app/crm/DealsTable.tsx b/web/src/components/app/crm/DealsTable.tsx index 8810fe114..7bc0f2e66 100644 --- a/web/src/components/app/crm/DealsTable.tsx +++ b/web/src/components/app/crm/DealsTable.tsx @@ -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(EMPTY_DEAL_SEARCH); + const [filters, setFilters] = useBrowseState("crm.deals.table.filters", EMPTY_DEAL_SEARCH, dealBrowseFiltersSchema); const search = useSearchDeals({ filters, limit: 50 }); const summary = useDealsSummary(filters); @@ -416,6 +418,11 @@ function PipelineFacet({ + {selected.filter((id) => !pipelines.some((p) => p.id === id)).map((id) => ( + toggle(id)} selected closeOnSelect={false}> + Selected pipeline {id.slice(0, 6)} + + ))} {pipelines.length === 0 ? (
No pipelines yet
) : ( @@ -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 })); diff --git a/web/src/components/app/crm/browsePages.test.tsx b/web/src/components/app/crm/browsePages.test.tsx new file mode 100644 index 000000000..61dcf8346 --- /dev/null +++ b/web/src/components/app/crm/browsePages.test.tsx @@ -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(), + Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, +})); + +let client: QueryClient; +let userId = "user"; +function Owner({ children }: { children: ReactNode }) { + const profile = { user: { id: userId } } as ComponentProps["value"]; + return {children}; +} +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(, { 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(, { 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(, { 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(, { 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(, { wrapper: Owner }); + fireEvent.click(screen.getByRole("button", { name: /Filters/ })); + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + page.unmount(); + mocks.tasks.mockClear(); + render(, { 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(, { wrapper: Owner }); + fireEvent.change(screen.getByPlaceholderText("Search deals…"), { target: { value: "Renewal" } }); + fireEvent.click(screen.getByRole("button", { name: "Won" })); + page.unmount(); + mocks.deals.mockClear(); + render(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(); + 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((done) => { resolve = done; })); + save("crm.deals.view", "board"); + save("crm.deals.board.pipeline", "gone"); + save("crm.deals.board.search", "Board query"); + render(, { 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(, { 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(, { 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(, { 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(, { 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((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"]); + }); +}); diff --git a/web/src/components/app/crm/browseState.test.tsx b/web/src/components/app/crm/browseState.test.tsx new file mode 100644 index 000000000..93d6499a8 --- /dev/null +++ b/web/src/components/app/crm/browseState.test.tsx @@ -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["value"]; + return {children}; +} + +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); + }); +}); diff --git a/web/src/components/app/crm/useBrowsePipelines.ts b/web/src/components/app/crm/useBrowsePipelines.ts new file mode 100644 index 000000000..a75c4d3c4 --- /dev/null +++ b/web/src/components/app/crm/useBrowsePipelines.ts @@ -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], + }); +} diff --git a/web/src/lib/browse-crm.ts b/web/src/lib/browse-crm.ts new file mode 100644 index 000000000..a473c85b4 --- /dev/null +++ b/web/src/lib/browse-crm.ts @@ -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 }));