diff --git a/.env.example b/.env.example index 4d84df36..4d0a9b5f 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,12 @@ INNGEST_DEV=1 # NEXT_PUBLIC_SENTRY_ENVIRONMENT=development # deploy sets staging | production # NEXT_PUBLIC_SENTRY_RELEASE= # deploy sets the git SHA # + +# ── Advanced filters UI (docs/ADVANCED-FILTERS-BRIEF.md Phase 3) ─────────────── +# Kill switch between the new field->operator->value bar and the legacy +# dropdown toolbar on the leads table. Off (unset) everywhere until sign-off. +# NEXT_PUBLIC_ADVANCED_FILTERS=1 +# # Source-map upload only (CI). SENTRY_AUTH_TOKEN is a real credential and rides a # BuildKit secret mount in the Dockerfile, never a build arg. Without it the build # still succeeds — it just skips source maps, so prod traces would be minified. diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index cf1e918c..58635beb 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -61,6 +61,7 @@ jobs: NEXT_PUBLIC_SENTRY_RELEASE=${{ github.sha }} SENTRY_ORG=${{ secrets.SENTRY_ORG }} SENTRY_PROJECT=${{ secrets.SENTRY_PROJECT }} + NEXT_PUBLIC_ADVANCED_FILTERS=1 # Real credential — mounted as a BuildKit secret so it never lands in # the image layer metadata that gets pushed to GHCR. secrets: | diff --git a/Dockerfile b/Dockerfile index fe126319..f332410f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,10 @@ ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT ARG NEXT_PUBLIC_SENTRY_RELEASE ARG SENTRY_ORG ARG SENTRY_PROJECT +# Advanced filters kill switch (docs/ADVANCED-FILTERS-BRIEF.md Phase 3.5). Same +# inlined-at-build-time rule as the rest of this block — undefined here means +# the deployed bundle renders the legacy toolbar regardless of runtime env. +ARG NEXT_PUBLIC_ADVANCED_FILTERS ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY @@ -30,6 +34,7 @@ ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT ENV NEXT_PUBLIC_SENTRY_RELEASE=$NEXT_PUBLIC_SENTRY_RELEASE ENV SENTRY_ORG=$SENTRY_ORG ENV SENTRY_PROJECT=$SENTRY_PROJECT +ENV NEXT_PUBLIC_ADVANCED_FILTERS=$NEXT_PUBLIC_ADVANCED_FILTERS ENV NODE_OPTIONS="--max-old-space-size=6144" # SENTRY_AUTH_TOKEN is a real credential, so it rides a BuildKit secret mount diff --git a/docs/ADVANCED-FILTERS-BRIEF.md b/docs/ADVANCED-FILTERS-BRIEF.md index 98adffe2..9faff131 100644 --- a/docs/ADVANCED-FILTERS-BRIEF.md +++ b/docs/ADVANCED-FILTERS-BRIEF.md @@ -492,6 +492,317 @@ Those are Phase 2b. One mirror at a time. --- +# PHASE 3 — The UI (first visible surface) + +**Branch:** `feature/filter-engine-ui` from latest `origin/stage` +**Migration:** none. +**⚠️ THIS PHASE HAS A VISIBLE SURFACE — the PR is not acceptable without a screenshot from local dev.** + +Phases 0/1/2 are merged and deployed to stage: migration `201`, `src/lib/filters/` core, the lead +registry, and `?f=` live on `GET /api/v1/leads`. The server already understands advanced filters; +this phase is the Notion/Twenty-style bar that produces them. + +## 0. First — the carried-forward correctness fix + +`compileAssignees` in `src/lib/filters/registry/leads.ts` ends with: + +```ts +return "id.not.is.null"; // no valid tokens — legacy applies no filter in this case +``` + +That comment is **accurate** — legacy `route.ts`'s tri-branch has no final `else`, so +`?assignees=garbage` genuinely applies no filter today. It was correct to preserve in Phase 2. + +**But it breaks the moment this phase ships OR groups.** A tautology inside `or(...)` makes the entire +group match every row, and it is reachable via `?f=` with +`{field:"assignees", op:"is_any_of", value:["garbage"]}` (zod permits it — a non-empty array of strings). + +**Fix: let the compiler DROP a no-op condition instead of emitting a tautology.** Have the per-condition +compile path return `null` for "contributes nothing", and have `compileGroup` filter those out before +joining. Dropping is identical to a tautology inside AND (so Phase 2's byte-identical contract holds), +and correct inside OR (the leg simply isn't there). If dropping empties a group entirely, the group +contributes nothing rather than becoming `or()` of nothing. + +Tests: the legacy `?assignees=garbage` equivalence test must still pass, plus a new one proving +`or(, X)` compiles to just `X` — **not** to something matching everything. + +## 1. shadcn primitives to add + +Both compose from packages already installed — **no new npm dependencies**. + +| File | Built from | +|---|---| +| `src/components/ui/scroll-area.tsx` | `radix-ui@^1.4.3` unified package — `import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"`, same import style as the existing `popover.tsx` | +| `src/components/ui/combobox.tsx` | A thin Popover + Command composition (not an upstream shadcn primitive). `popover.tsx` and `command.tsx` (cmdk `^1.1.1`) both already exist. | + +**Do not add `react-day-picker` / `calendar.tsx`.** Date editing is native `` inside +the existing `Input` (two of them for `between`) plus relative presets (Today / Last 7 days / Last 30 days +/ This month). Zero deps, native mobile pickers, and it covers the operator set. Revisit only if asked. + +## 2. Component tree — `src/components/filters/` + +``` +AdvancedFilterBar advanced-filter-bar.tsx +├── FilterChipRow → FilterChip click a chip to edit it in place +├── ConjunctionToggle "Where / and / or" — hand-rolled 2-state, no toggle-group primitive +└── AddFilterButton "+ Add filter" + └── FilterFieldPicker Command + CommandInput + grouped items (reference screenshot 1) + └── FilterConditionEditor + ├── FilterOperatorPicker options from isOperatorAllowed() (reference screenshot 2) + └── FilterValueEditor dispatch on field.type + operator arity + ├── text · number (1 or 2 inputs for `between`) · date + ├── select · boolean + └── multi-select ← WRAPS the existing FilterOptionList +``` + +`MultiSelectValueEditor` must **reuse `FilterOptionList` from `src/components/ui/filter-dropdown.tsx`** +rather than reimplement it. That component (search + checkbox rows + clear) is the one piece of today's +filter UI worth keeping verbatim. Do not fork it. + +Match the reference screenshots: chips read `Name: brian ✕`, with `+ Add filter` inline after them. + +## 3. What the host supplies + +```ts +export interface FilterHostConfig { + entity: EntityKey; + fields: FieldDef[]; // already industry- and permission-filtered + value: FilterTree; + onChange: (next: FilterTree) => void; + density?: "comfortable" | "compact"; // kanban toolbar is tight + showChips?: boolean; + allowGroups?: boolean; // depth-2 UI; false on narrow toolbars + maxConditions?: number; // default 25 + optionOverrides?: Partial>; +} +``` + +Only `fields`/`value`/`onChange` plus three cosmetic flags differ between surfaces — that is what makes +one component serve table, kanban and board. **Do not add surface-specific branches inside the bar.** + +Async option loaders (`members`, `stages`, `lists`, `forms`, `tags`, `sources`, …) go in a single +`use-filter-options.ts` with caching, so the field picker doesn't fire a request per dropdown open. +`optionOverrides` exists because Kanban already has `stages` in props. + +## 4. State — `src/lib/filters/use-advanced-filters.ts` + +URL-backed, modelled on the existing `src/industries/it-agency/features/project-board/hooks/use-workspace-filters.ts` +(the only URL-backed filter state in the app today — read it first and follow its shape, including +`router.replace(..., { scroll: false })`). + +Reads/writes `?f=` via Phase 1's `encodeFilterTree`/`decodeFilterTree`. A malformed or stale `?f=` +must **degrade with a toast, never crash** — drop unknown field keys and keep the rest. + +Enforce `MAX_ENCODED_LEN` client-side too, with a real message ("too many values — save this as a view"), +so the user hits a good error rather than a transport failure. + +## 5. Wiring into the leads table + +`src/components/dashboard/leads-table.tsx` — **a high-conflict shared file. Rebase onto latest +`origin/stage` immediately before merge and resolve hunk-by-hunk, never "keep my whole file."** + +- Render `AdvancedFilterBar` when `NEXT_PUBLIC_ADVANCED_FILTERS === "1"`, else the existing `FilterMenu`. + **Both paths must work** — this flag is the kill switch. +- Flag on: `buildFetchParams` sets `f` and **stops setting** the 8 legacy filter params. + `fetchSignature` must include the encoded tree, or the table won't refetch on filter change. +- Flag off: **pixel-identical to today.** That is the gate. +- Add `@deprecated` JSDoc to `FilterDef` / `FilterMenu` / `FilterChips` pointing at the new bar. +- **Kanban is Phase 4** — don't touch `KanbanBoard.tsx` or `kanban-column-params.ts`. + +## 6. Proof required in the PR body + +- **A screenshot (or short recording) of the filter bar working on local dev.** Non-negotiable. Show at + minimum: the field picker open, an operator dropdown open, and two stacked chips filtering real rows. +- Flag **off** screenshot proving the old toolbar is unchanged. +- Manual matrix: every operator × every field type actually exercised. +- `is not` on a field with empty values **includes** the empty rows (the negation rule — verify in the UI, + not just in a unit test). +- A URL with `?f=` copy-pasted into a fresh tab reproduces the same filtered view. +- `npm run test`, `npm run build`, `npx eslint --max-warnings 50` all clean. + +## 7. Stop + +PR to `stage`, **stop at the review gate.** Do not start Phase 4 (Kanban) or Phase 5 (saved views). + +--- + +# PHASE 3 ADDENDUM — facet counts (regression fix + server-side move) + +Found during Sadin's manual testing of the Phase 3 branch. Fold into the **same** PR — the bar must +not ship with a visible count regression. + +## A. The regression + +`origin/stage`'s legacy filter menu shows counts on **Assigned To** and **Collaborators** +(`Sadin (42)`) and **hides zero-count people** so the list stays short. The new +`advancedFilterOptionOverrides` in `leads-table.tsx` kept counts on `source` only: + +```ts +source: `${s} (${sourceCounts.get(s) ?? 0})` // ✓ counts +assignees: memberNames[userId] || email.split("@")[0] // ✗ dropped +collaborators: same // ✗ dropped +``` + +It also lists every counselor instead of only those with leads. Both must be restored. + +## B. Do NOT just port the legacy label — the legacy number is wrong + +`counselorCounts` (`leads-table.tsx`) is computed from `localLeads`, which is set from the fetched +page (`setLocalLeads(body.data)`). Under server pagination that's **25 rows**, so `Sadin (3)` means +"3 on this page", not "3 in the tenant". It is also a **fifth mirror** of the predicate set — it +re-implements source/tag/status/form/created matching client-side to cross-filter. + +Migration 194 already fixed exactly this for Source by moving it server-side; Assigned To never got +the same treatment (see 194's own header comment about the "current 25-row page only"). + +## C. The server-side number already exists + +`lead_aggregates()` **already returns a `counselor` dimension** — `assigned_to`-keyed with an +`"(unassigned)"` sentinel (`LeadAggregates.counselor` in `src/lib/leads/aggregates.ts`). It currently +feeds `LeadsByCounselorChart`. **No migration needed** — wire it into the facet path the way `source` +already is (`getSourceFacet()` + the `?facets=source` branch in `route.ts`), then delete the +client-side `counselorCounts` memo. + +Extend the facet param to accept more than one dimension (e.g. `?facets=source,assignee`) so the +table makes **one** facet round-trip, not two. Keep `?facets=source` alone behaving exactly as today. + +Collaborator counts have no existing dimension. **Do not add one in this PR** — collaborators come +from a join table (`lead_collaborators`) and that's a bigger change. Instead: keep the existing +client-side collaborator count, and add a code comment saying it is page-scoped and pending the same +server-side move. Being explicitly inconsistent-and-labelled beats silently shipping two different +meanings of the same-looking number. + +## D. Scope: universal, no industry gate + +Assignee counts are not education-specific. The legacy code gates on `isAdmin || isTeamScoped`, +**never on industry** — keep it that way. This is a Global feature per `CLAUDE.md`'s taxonomy; +adding an industry gate would be a regression, not a feature. + +## E. Pull `treeToAggregateParams()` forward from Phase 5 + +Phase 2 made the route **skip facets entirely and return `counts: null`** whenever `?f=` is present +(correctly — a partial translation would give subtly wrong counts). But that means counts would +disappear the moment a user adds an advanced filter, which is exactly when they want them. The +client has asked for these counts twice; that regression is not acceptable. + +Add `src/lib/filters/tree-to-aggregate-params.ts`: + +```ts +export function treeToAggregateParams(tree: FilterTree, registry: FieldRegistry): + | { ok: true; params: LeadAggregateFilterParams } // expressible in lead_aggregates()'s vocabulary + | { ok: false; reason: string }; // OR group / contains / is_empty / cf:* → caller skips facets +``` + +Rules: a **pure-AND** tree whose every condition maps onto an existing `lead_aggregates()` param +returns `ok: true` and exact counts are preserved. Anything else (any OR group, any operator the RPC +can't express, any unknown field) returns `ok: false` and the route keeps today's `counts: null` +behaviour. `pino`-log every fall-back with the reason, so we learn whether Phase 9's SQL-side +evaluator is ever actually warranted. + +**Never emit a partial translation.** Wrong counts are worse than absent counts. + +## F. Proof required (in addition to the Phase 3 screenshots) + +- Screenshot of Assigned To showing counts, with zero-count people absent. +- On stage's Admizz tenant (16k+ leads): a counselor's facet count matches + `SELECT count(*) FROM leads WHERE assigned_to = … AND deleted_at IS NULL AND converted_at IS NULL` + — i.e. **tenant-wide, not 25-capped**. This is the whole point of the change; prove the number moved. +- Counts still present with an advanced filter active (the §E path), and correctly **absent** + (`counts: null`, no badge, never a zero) for an OR-group tree. +- Unit tests for `treeToAggregateParams`: pure-AND maps; OR group → `ok:false`; `contains` → `ok:false`. + +## G. The Apply bug — diagnose before patching + +Do **not** shotgun this. The chain `handleApply → onAdd → setTree → router.replace → useSearchParams +→ buildFetchParams → fetchSignature` is structurally correct on inspection, so the fault is runtime. +Sadin will report which of these three he observes: + +1. URL never gains `?f=` → the bar isn't reaching `setTree`, or the flag is off / the dev server was + not restarted after adding `NEXT_PUBLIC_ADVANCED_FILTERS=1` (these are inlined at build time). +2. URL updates but no `/api/v1/leads?...f=...` request fires → `fetchSignature` isn't changing. +3. Request fires and returns filtered rows but the table still shows everything → the SSR-prop-sync + fix didn't hold. + +Fix only the branch that matches, and say in the PR which one it was. + +--- + +# PHASE 3.5 — Enable the flag on STAGE only, then prove the counts at real volume + +**Branch:** `feature/advanced-filters-stage-flag` from latest `origin/stage` +**Migration:** none. **Small change — two files.** The value is the verification it unlocks. + +Phases 0–3 are merged and deployed (`d3841a63`). But `NEXT_PUBLIC_ADVANCED_FILTERS` is not wired +into the image build, so the deployed bundle has it `undefined` and stage still renders the **legacy** +toolbar. The new bar exists only on local dev. + +`NEXT_PUBLIC_*` is **inlined at build time** (the Dockerfile says so at line 15). A container restart +or an `.env.local` edit on the VPS will NOT turn it on — it has to be a build arg. + +## The change + +**1. `Dockerfile`** — add alongside the existing `NEXT_PUBLIC_*` pairs (ARGs ~L11-23, ENVs ~L25-32): + +```dockerfile +ARG NEXT_PUBLIC_ADVANCED_FILTERS +ENV NEXT_PUBLIC_ADVANCED_FILTERS=$NEXT_PUBLIC_ADVANCED_FILTERS +``` + +**2. `.github/workflows/deploy-staging.yml`** — add one line to `build-args` (~L55-63): + +```yaml +NEXT_PUBLIC_ADVANCED_FILTERS=1 +``` + +A **literal `1`, not a secret** — matching how `NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging` is done. It +should be readable in the workflow file that staging has this on. + +**3. Do NOT touch `.github/workflows/deploy.yml`.** Prod stays off. With no ARG value passed, the +Dockerfile ARG resolves empty and the flag is `undefined` — the legacy toolbar. Say explicitly in the +PR that prod is unaffected, and confirm you did not edit `deploy.yml`. + +`docker-compose.yml` needs no change — it pulls the prebuilt image +(`image: ghcr.io/zunkireelabs/edgexcrm:stage`) and has no build section. + +## The verification this unlocks — the actual point of the PR + +Local dev has 33 leads, so it cannot prove the Assigned To counts moved off the old 25-row page cap. +Stage's Admizz tenant has ~16.7k. **This is the outstanding gate before any prod promotion of Phases 0–3.** + +After the deploy is green, on **stage** (`dymeudcddasqpomfpjvt`): + +1. Open `dev-lead-crm.zunkireelabs.com` → Leads → **+ Add filter → Assigned to**. Screenshot the counts. +2. For 2–3 counselors, compare the facet count against the DB directly: + ```sql + SELECT count(*) FROM leads + WHERE tenant_id = '' AND assigned_to = '' + AND deleted_at IS NULL AND converted_at IS NULL + AND NOT (tags @> ARRAY['other']::text[]); + ``` + They must match **exactly**. A number ≤25 that looks suspiciously like a page size means the facet + didn't move server-side and the whole A/B/C change is not doing what it claims. +3. Apply the filter and confirm the row count and the chip agree. +4. Confirm counts still render with a second filter stacked (the `treeToAggregateParams` path), and are + **absent** (no badge, never a zero) for a tree it can't express. +5. Sanity-check page-1 perf is not worse than the legacy toolbar on a 16.7k tenant. + +**Stage lead data is real customer PII** — screenshots for the PR are fine (it's our own stage), but do +not paste raw rows anywhere else, and do not point any third-party service at it. + +## Proof required in the PR body + +Both screenshots (stage Assigned To counts; the filter applied), the `SELECT count(*)` outputs beside +the facet numbers, and an explicit line confirming `deploy.yml` was not modified. + +## Rollback + +Remove the one build-arg line and redeploy — next stage build goes back to the legacy toolbar. No +migration, no data change. + +Stop at the review gate. + +--- + ## Non-negotiables for all phases - Branch from **latest `origin/stage`**; rebase again right before merge. Squash-merge to `stage`. diff --git a/package-lock.json b/package-lock.json index 9a78089b..6f642ec0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "dotenv": "^17.3.1", "eslint": "^9", "eslint-config-next": "16.1.6", + "jsdom": "^29.1.1", "shadcn": "^3.8.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", @@ -266,6 +267,57 @@ "module-details-from-path": "^1.0.4" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -694,12 +746,167 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1586,6 +1793,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", @@ -9724,6 +9949,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -10456,6 +10691,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -10612,6 +10861,58 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -10683,6 +10984,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -10987,6 +11295,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -12812,6 +13133,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -13552,6 +13886,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -13871,6 +14212,106 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -14842,6 +15283,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -16369,6 +16817,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -17816,6 +18277,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -18819,6 +19293,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/systeminformation": { "version": "5.31.17", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.17.tgz", @@ -19158,9 +19639,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -20057,6 +20538,19 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wasm-feature-detect": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", @@ -20170,6 +20664,16 @@ "node": ">=4.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -20457,6 +20961,23 @@ "node": ">=0.8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 4d9b4222..70b7892f 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "dotenv": "^17.3.1", "eslint": "^9", "eslint-config-next": "16.1.6", + "jsdom": "^29.1.1", "shadcn": "^3.8.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", diff --git a/src/app/(main)/api/v1/leads/route.test.ts b/src/app/(main)/api/v1/leads/route.test.ts index a5e16349..b297e0e0 100644 --- a/src/app/(main)/api/v1/leads/route.test.ts +++ b/src/app/(main)/api/v1/leads/route.test.ts @@ -814,15 +814,91 @@ describe("GET /api/v1/leads — ?f= compiles through the SAME compileFilter() as expect(res.status).toBe(422); }); - it("?facets=source with ?f= present skips getSourceFacet entirely and returns counts:null, never partial/wrong counts", async () => { + // ADVANCED-FILTERS-BRIEF Phase 3 addendum §E: treeToAggregateParams() pulled the + // Phase 5 downgrade forward — a pure-AND, fully-expressible ?f= tree now DOES drive + // real facet counts (superseding Phase 2's blanket counts:null-whenever-?f=-present + // behavior below, which is now only a fallback for the non-expressible case). + it("?facets=source with an EXPRESSIBLE ?f= tree computes real counts via treeToAggregateParams — not counts:null", async () => { + createClientMock.mockResolvedValue({ rpc: () => Promise.resolve({ data: [], error: null }) }); createServiceClientMock.mockResolvedValue(fakeDb({ leadsCalls: [] })); const { GET } = await import("./route"); const res = await GET(fakeReq({ facets: "source", [FILTER_PARAM]: encodedTreeFor({ status: "contacted" }) })); const body = await res.json(); expect(res.status).toBe(200); + expect(body.data).toEqual({ facet: "source", options: [] }); + }); + + it("?facets=source with a NON-expressible ?f= tree (OR group) skips getSourceFacet entirely and returns counts:null, never partial/wrong counts", async () => { + createServiceClientMock.mockResolvedValue(fakeDb({ leadsCalls: [] })); + const { GET } = await import("./route"); + const orTree = { + conjunction: "and" as const, + conditions: [], + groups: [ + { + conjunction: "or" as const, + conditions: [ + { id: "c1", field: "status", op: "is" as const, value: "contacted" }, + { id: "c2", field: "status", op: "is" as const, value: "new" }, + ], + }, + ], + }; + const res = await GET(fakeReq({ facets: "source", [FILTER_PARAM]: encodeFilterTree(orTree) })); + const body = await res.json(); + expect(res.status).toBe(200); expect(body.data).toEqual({ facet: "source", options: [], counts: null }); }); + it("?facets=source,assignee with a NON-expressible ?f= tree returns the multi-facet counts:null shape (no badge, never a zero)", async () => { + createServiceClientMock.mockResolvedValue(fakeDb({ leadsCalls: [] })); + const { GET } = await import("./route"); + const containsTree = { + conjunction: "and" as const, + conditions: [{ id: "c1", field: "search", op: "contains" as const, value: "jane" }], + }; + const res = await GET(fakeReq({ facets: "source,assignee", [FILTER_PARAM]: encodeFilterTree(containsTree) })); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.data).toEqual({ facets: null, counts: null }); + }); + + it("?facets=source,assignee returns the new multi-facet shape, one RPC round-trip, unassigned sentinel translated", async () => { + const rpcCalls: unknown[] = []; + createClientMock.mockResolvedValue({ + rpc: (name: string, params: unknown) => { + rpcCalls.push([name, params]); + return Promise.resolve({ + data: [ + { dimension: "intake_source", key: "Facebook", bucket: "all", cnt: 3 }, + { dimension: "counselor", key: "(unassigned)", bucket: "all", cnt: 2 }, + { dimension: "counselor", key: "user-1", bucket: "all", cnt: 5 }, + ], + error: null, + }); + }, + }); + createServiceClientMock.mockResolvedValue(fakeDb({ leadsCalls: [] })); + const { GET } = await import("./route"); + const res = await GET(fakeReq({ facets: "source,assignee" })); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.data).toEqual({ + facets: { + source: { options: [{ name: "Facebook", count: 3 }] }, + assignee: { + options: [ + { name: "user-1", count: 5 }, + { name: "unassigned", count: 2 }, + ], + }, + }, + }); + // One HTTP round-trip from the client; two RPC calls server-side is fine — each + // dimension needs its OWN filter set (assignee must not filter on itself). + expect(rpcCalls.length).toBe(2); + }); + it("?facets=source WITHOUT ?f= is completely unaffected — still returns the legacy {facet,options} shape", async () => { createClientMock.mockResolvedValue({ rpc: () => Promise.resolve({ data: [], error: null }) }); createServiceClientMock.mockResolvedValue(fakeDb({ leadsCalls: [] })); diff --git a/src/app/(main)/api/v1/leads/route.ts b/src/app/(main)/api/v1/leads/route.ts index f3f8075c..2a2655a1 100644 --- a/src/app/(main)/api/v1/leads/route.ts +++ b/src/app/(main)/api/v1/leads/route.ts @@ -34,11 +34,12 @@ import { branchMemberIds, syncOriginMembership } from "@/lib/leads/branch-member import { POSITION_ROUTE_MAP } from "@/industries/education-consultancy/features/new-leads-triage/position-routing"; import { addLeadCollaborator } from "@/lib/leads/collaborators"; import { visibleLeadsBase } from "@/lib/leads/visibility-query"; -import { getSourceFacet } from "@/lib/leads/aggregates"; +import { getSourceFacet, getAssigneeFacet } from "@/lib/leads/aggregates"; import { compileFilter, planFilter } from "@/lib/filters/compile"; import { decodeFilterTree, FILTER_PARAM } from "@/lib/filters/serialize"; import { legacyLeadsParamsToTree } from "@/lib/filters/legacy-leads-params"; import { leadFields } from "@/lib/filters/registry"; +import { treeToAggregateParams, type LeadAggregateFilterParams } from "@/lib/filters/tree-to-aggregate-params"; import type { CompileCtx, FilterTree, ResolvedPermissions as FilterResolvedPermissions } from "@/lib/filters/types"; import { normalizeEmail, @@ -433,44 +434,65 @@ export async function GET(request: NextRequest) { ? new Date(Date.now() - CREATED_WINDOW_MS[createdFilter]) : null; - // Opt-in Source facet (?facets=source) — same "opt-in, separate round-trip" shape - // as ?counts=1 (lead-lists route). Computed via lead_aggregates() (migration 194's - // ADDENDUM) over every filter above EXCEPT source itself, per the brief: the option - // list AND its counts used to come from `localLeads` — the current 25-row server - // page — which is what made this facet broken rather than merely incomplete once - // #332 shipped 25-row pages. Recycle-bin (onlyDeleted) leads are excluded from - // lead_aggregates unconditionally, so this facet is not offered there — a known, - // narrow gap (the recycle bin has no source dropdown today). + // Opt-in facets (?facets=source | ?facets=source,assignee | …) — same "opt-in, + // separate round-trip" shape as ?counts=1 (lead-lists route). Computed via + // lead_aggregates() (migration 194 + its ADDENDUM) over every filter above EXCEPT + // the dimension being faceted, per the brief. Recycle-bin (onlyDeleted) leads are + // excluded from lead_aggregates unconditionally, so no facet is offered there — a + // known, narrow gap (the recycle bin has no source/assignee dropdown today). // // KNOWN GAP (pipeline-column-pagination Phase 1): lead_aggregates() (migration 194) - // has no `p_stage_eq` param, so a `?stage=` filter is NOT mirrored into this facet the - // way `status`/`list`/etc. are below. A caller combining `stage` with `facets=source` - // gets a facet computed WITHOUT the stage restriction — flagged, not silently fixed; + // has no `p_stage_eq` param, so a `?stage=` filter is NOT mirrored into these facets + // the way `status`/`list`/etc. are below. A caller combining `stage` with `facets=` + // gets facets computed WITHOUT the stage restriction — flagged, not silently fixed; // closing it needs a migration (out of this PR's additive-only-locally scope). - if (searchParams.get("facets") === "source" && !onlyDeleted) { - // ADVANCED-FILTERS-BRIEF Phase 2: a ?f= tree has no lead_aggregates() mirror yet - // (that's the treeToAggregateParams() downgrade landing in Phase 5) — passing a - // PARTIAL translation of the tree into lead_aggregates would produce a facet with - // subtly WRONG counts (missing whatever the tree expresses that the RPC's fixed - // param list can't), which is worse than no counts at all. Skip getSourceFacet() - // entirely and say so explicitly via counts: null. Legacy callers (?facets=source - // without ?f=) are completely unaffected — this branch only short-circuits for ?f=. + // + // ?facets=source ALONE keeps the exact pre-existing single-dimension response shape + // (`{facet:"source", options}`) byte-for-byte — KanbanBoard.tsx (Phase 4 territory, + // untouched here) depends on it. Any other combination (?facets=assignee, + // ?facets=source,assignee, …) uses the new multi-facet shape (`{facets:{…}}`), + // introduced by ADVANCED-FILTERS-BRIEF Phase 3 addendum §C to let leads-table.tsx + // fetch source + assignee counts in one round-trip. + const facetsParam = searchParams.get("facets"); + const requestedFacets = facetsParam + ? (facetsParam.split(",").map((s) => s.trim()).filter(Boolean) as Array<"source" | "assignee">) + : []; + const legacySingleSourceFacet = requestedFacets.length === 1 && requestedFacets[0] === "source"; + + if (requestedFacets.length > 0 && !onlyDeleted) { + // ADVANCED-FILTERS-BRIEF Phase 3 addendum §E: pulled forward from Phase 5. A ?f= + // tree CAN drive facet counts now, but only when it translates losslessly onto + // lead_aggregates()'s fixed param list — treeToAggregateParams() is the single + // gate for that. Any tree it can't express (OR groups, `contains`, unknown + // fields/ops, …) keeps Phase 2's original counts:null behavior: passing a PARTIAL + // translation would produce subtly WRONG counts, which is worse than none. + let aggParams: LeadAggregateFilterParams | null = null; if (rawFilterParam !== null) { - return apiSuccess({ facet: "source", options: [], counts: null }); + const translated = treeToAggregateParams(filterTree, filterRegistry, compileCtx.now); + if (!translated.ok) { + log.info( + { tenantId: auth.tenantId, reason: translated.reason }, + "facet counts skipped: ?f= tree not expressible in lead_aggregates()" + ); + return legacySingleSourceFacet + ? apiSuccess({ facet: "source", options: [], counts: null }) + : apiSuccess({ facets: null, counts: null }); + } + aggParams = translated.params; } - // Match route.ts:370's `.in("pipeline_id", [])` semantics exactly: an empty allowlist - // means the page returns zero leads, so the facet must be empty too. aggregates.ts - // omits an empty p_pipeline_ids (→ NULL → no restriction), which would otherwise - // count the whole tenant for a user whose list is empty. + // Match route.ts's `.in("pipeline_id", [])` semantics exactly: an empty allowlist + // means the page returns zero leads, so every requested facet must be empty too. + // aggregates.ts omits an empty p_pipeline_ids (→ NULL → no restriction), which + // would otherwise count the whole tenant for a user whose list is empty. if (auth.permissions.pipelineAccess !== "all" && auth.permissions.pipelineAccess.ids.size === 0) { - return apiSuccess({ facet: "source", options: [] }); + return legacySingleSourceFacet + ? apiSuccess({ facet: "source", options: [] }) + : apiSuccess({ + facets: Object.fromEntries(requestedFacets.map((f) => [f, { options: [] }])), + }); } - const assigneesIds = assigneesTokens.filter((t) => t !== "unassigned" && UUID_RE.test(t)); - const wantsUnassigned = assigneesTokens.includes("unassigned"); - const validCollaboratorIds = collaboratorIds.filter((id) => UUID_RE.test(id)); - if (scope.restrictToSelf && !scope.userId) { throw new Error("leads/facets: scope.restrictToSelf requires scope.userId"); } @@ -486,47 +508,91 @@ export async function GET(request: NextRequest) { ? "branch" : "all"; - let options: Awaited>; + // Effective per-axis filter values: from the ?f= tree (via treeToAggregateParams) + // when ?f= is present, else from the legacy toolbar params — exactly like the page + // query's own filterTree/legacyLeadsParamsToTree split above. `search` and the + // industry "__none__" sentinel have no tree-translation path (search is always + // `contains`, rejected above; is_empty is rejected above too) — both are legacy-only. + const effectiveStatus = rawFilterParam !== null ? (aggParams?.status ?? null) : status || null; + const assigneesIdsLegacy = assigneesTokens.filter((t) => t !== "unassigned" && UUID_RE.test(t)); + const wantsUnassignedLegacy = assigneesTokens.includes("unassigned"); + const effectiveAssigneesAny = rawFilterParam !== null ? (aggParams?.assigneesAny ?? null) : (assigneesIdsLegacy.length > 0 ? assigneesIdsLegacy : null); + const effectiveIncludeUnassigned = rawFilterParam !== null ? !!aggParams?.includeUnassigned : wantsUnassignedLegacy; + const validCollaboratorIdsLegacy = collaboratorIds.filter((id) => UUID_RE.test(id)); + const effectiveCollaboratorIds = rawFilterParam !== null ? (aggParams?.collaboratorIds ?? null) : (validCollaboratorIdsLegacy.length > 0 ? validCollaboratorIdsLegacy : null); + const effectiveTag = rawFilterParam !== null ? (aggParams?.tag ?? null) : (tagFilter && tagFilter !== "all" ? tagFilter : null); + const effectiveProspectIndustry = rawFilterParam !== null ? (aggParams?.prospectIndustry ?? null) : (industryFilter && industryFilter !== "all" && industryFilter !== "__none__" ? industryFilter : null); + const effectiveProspectIndustryNone = rawFilterParam !== null ? false : industryFilter === "__none__"; + const effectiveFormConfigId = rawFilterParam !== null ? (aggParams?.formConfigId ?? null) : (formFilter && formFilter !== "all" && UUID_RE.test(formFilter) ? formFilter : null); + const effectiveCreatedAfter = rawFilterParam !== null ? (aggParams?.createdAfter ?? null) : createdAfter; + const effectiveSearch = rawFilterParam !== null ? null : (search ? search.replace(/[,().]/g, "") : null); + + const baseFacetParams = { + tenantId: auth.tenantId, + scope: facetScope, + user: facetScope === "own" ? scope.userId : null, + userBranchId: scope.userBranchId, + crossPoolSlug: scope.crossBranchPoolListSlug, + branchId: facetScope === "branch" ? scope.branchId : null, + sharedPoolAssignedToAny, + pipelineIds: auth.permissions.pipelineAccess !== "all" ? [...auth.permissions.pipelineAccess.ids] : null, + status: effectiveStatus, + collaboratorIds: effectiveCollaboratorIds, + tag: effectiveTag, + prospectIndustry: effectiveProspectIndustry, + prospectIndustryNone: effectiveProspectIndustryNone, + formConfigId: effectiveFormConfigId, + createdAfter: effectiveCreatedAfter, + // Mirror the page query's either/or (route.ts:309-316) exactly: an explicit list + // wins outright, a funnel's list set wins next, and the archive/staging exclusion + // only applies when neither is present. Passing more than one of these unconditionally + // ANDs them in lead_aggregates — for a staging list that becomes `list_id = X AND + // list_id NOT IN (…X…)`, an unsatisfiable predicate that zeroed the facet. + listIdEq: resolvedListId, + listIdAny: !resolvedListId && funnelListIds.length > 0 ? funnelListIds : null, + excludeListIds: + !resolvedListId && funnelListIds.length === 0 && excludeListIds.length > 0 + ? excludeListIds + : null, + search: effectiveSearch, + includeConverted, + }; + + let sourceOptions: Awaited> | undefined; + let assigneeOptions: Awaited> | undefined; try { - options = await getSourceFacet({ - tenantId: auth.tenantId, - scope: facetScope, - user: facetScope === "own" ? scope.userId : null, - userBranchId: scope.userBranchId, - crossPoolSlug: scope.crossBranchPoolListSlug, - branchId: facetScope === "branch" ? scope.branchId : null, - sharedPoolAssignedToAny, - pipelineIds: auth.permissions.pipelineAccess !== "all" ? [...auth.permissions.pipelineAccess.ids] : null, - status: status || null, - assigneesAny: assigneesIds.length > 0 ? assigneesIds : null, - includeUnassigned: wantsUnassigned, - collaboratorIds: validCollaboratorIds.length > 0 ? validCollaboratorIds : null, - tag: tagFilter && tagFilter !== "all" ? tagFilter : null, - prospectIndustry: industryFilter && industryFilter !== "all" && industryFilter !== "__none__" ? industryFilter : null, - prospectIndustryNone: industryFilter === "__none__", - formConfigId: formFilter && formFilter !== "all" && UUID_RE.test(formFilter) ? formFilter : null, - createdAfter, - // Mirror the page query's either/or (route.ts:309-316) exactly: an explicit list - // wins outright, a funnel's list set wins next, and the archive/staging exclusion - // only applies when neither is present. Passing more than one of these unconditionally - // ANDs them in lead_aggregates — for a staging list that becomes `list_id = X AND - // list_id NOT IN (…X…)`, an unsatisfiable predicate that zeroed the facet. - listIdEq: resolvedListId, - listIdAny: !resolvedListId && funnelListIds.length > 0 ? funnelListIds : null, - excludeListIds: - !resolvedListId && funnelListIds.length === 0 && excludeListIds.length > 0 - ? excludeListIds - : null, - search: search ? search.replace(/[,().]/g, "") : null, - includeConverted, - }); + if (requestedFacets.includes("source")) { + // Source facet: cross-filtered by the current assignee selection (there's no + // "source" axis to exclude — it has no p_source param at all). + sourceOptions = await getSourceFacet({ + ...baseFacetParams, + assigneesAny: effectiveAssigneesAny, + includeUnassigned: effectiveIncludeUnassigned, + }); + } + if (requestedFacets.includes("assignee")) { + // Assignee facet: MUST NOT apply the assignees filter to itself (same + // "every filter except the one being faceted" rule source already follows) — + // assigneesAny/includeUnassigned are deliberately omitted here. + assigneeOptions = await getAssigneeFacet(baseFacetParams); + } } catch (err) { - log.error({ err }, "Failed to fetch source facet"); - return apiServiceUnavailable("Failed to fetch source facet"); + log.error({ err }, "Failed to fetch lead facets"); + return apiServiceUnavailable("Failed to fetch lead facets"); } - log.info({ tenantId: auth.tenantId, options: options.length }, "Source facet fetched"); - return apiSuccess({ facet: "source", options }); + if (legacySingleSourceFacet) { + log.info({ tenantId: auth.tenantId, options: sourceOptions?.length ?? 0 }, "Source facet fetched"); + return apiSuccess({ facet: "source", options: sourceOptions ?? [] }); + } + + log.info({ tenantId: auth.tenantId, facets: requestedFacets }, "Lead facets fetched"); + return apiSuccess({ + facets: { + ...(sourceOptions ? { source: { options: sourceOptions } } : {}), + ...(assigneeOptions ? { assignee: { options: assigneeOptions } } : {}), + }, + }); } const from = (page - 1) * pageSize; diff --git a/src/components/dashboard/leads-table.tsx b/src/components/dashboard/leads-table.tsx index dbe87234..0aac415c 100644 --- a/src/components/dashboard/leads-table.tsx +++ b/src/components/dashboard/leads-table.tsx @@ -15,6 +15,15 @@ import { } from "@/components/ui/select"; import { FilterMenu, FilterChips, type FilterDef } from "@/components/ui/filter-menu"; import { TOOLBAR_BTN } from "@/components/dashboard/leads/toolbar-btn"; +// Advanced filters (Phase 3, docs/ADVANCED-FILTERS-BRIEF.md) — flag-gated via +// NEXT_PUBLIC_ADVANCED_FILTERS. The legacy FilterMenu/FilterChips imports above +// stay as the flag-off fallback; see their @deprecated JSDoc. +import { AdvancedFilterBar } from "@/components/filters/advanced-filter-bar"; +import type { FilterOption } from "@/components/filters/types"; +import { useAdvancedFilters } from "@/lib/filters/use-advanced-filters"; +import { leadFields } from "@/lib/filters/registry/leads"; +import { encodeFilterTree, isEmptyTree } from "@/lib/filters/serialize"; +import type { CompileCtx } from "@/lib/filters/types"; // Shared list/page title style — keep every leads-family heading (All Leads, Pre-qualified, // New Leads (Unrouted), Contacts, Leads Organise, ...) on one consistent size. @@ -322,13 +331,6 @@ export function LeadsTable({ // uses filtered.length instead, since `leads` there already holds everything. const [total, setTotal] = useState(initialTotal ?? leads.length); const [tableLoading, setTableLoading] = useState(false); - // Re-sync when the server sends a fresh lead set via a real navigation (list/funnel - // switch, router.refresh() after a bulk action) — the page's own SSR query already - // did the work; this is a prop sync, not a client fetch. - useEffect(() => { - setLocalLeads(leads); - setTotal(initialTotal ?? leads.length); - }, [leads, initialTotal]); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); @@ -357,6 +359,36 @@ export function LeadsTable({ const [tagFilter, setTagFilter] = useState("all"); const [createdFilter, setCreatedFilter] = useState("all"); const [prospectIndustryFilter, setProspectIndustryFilter] = useState("all"); + + // Advanced filters (Phase 3) — the field registry doesn't read `ctx` today + // (see registry/leads.ts's `void ctx`), so a fixed stub is safe and keeps + // this memo stable across renders regardless of wall-clock time. + const advancedFilterRegistry = useMemo( + () => leadFields({ tz: "UTC", now: new Date(0), industryId: industryId ?? null, permissions: {} } satisfies CompileCtx), + [industryId] + ); + const advancedFilters = useAdvancedFilters(advancedFilterRegistry); + const advancedFiltersEnabled = process.env.NEXT_PUBLIC_ADVANCED_FILTERS === "1"; + const advancedFilterActive = advancedFiltersEnabled && !isEmptyTree(advancedFilters.tree); + + // Re-sync when the server sends a fresh lead set via a real navigation (list/funnel + // switch, router.refresh() after a bulk action) — the page's own SSR query already + // did the work; this is a prop sync, not a client fetch. + // + // Skipped while an advanced filter is active: writing `?f=` to the URL (via + // advancedFilters.setTree -> router.replace) triggers Next.js to re-run this + // route's Server Component (page.tsx), which does NOT parse `f` (that's Phase + // 2b's getLeadsPage mirror, out of scope here) — its SSR props are always the + // unfiltered first page. Resyncing from them here would silently revert the + // client-fetched filtered result out from under the user right after Apply. + // The client fetch effect below is the sole source of truth for localLeads + // while an advanced filter is active. + useEffect(() => { + if (advancedFilterActive) return; + setLocalLeads(leads); + setTotal(initialTotal ?? leads.length); + }, [leads, initialTotal, advancedFilterActive]); + const [sortField, setSortField] = useState("activity"); const [sortDirection, setSortDirection] = useState("desc"); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -475,17 +507,26 @@ export function LeadsTable({ if (debouncedSearch) params.set("search", debouncedSearch); if (activeListSlug) params.set("list", activeListSlug); else if (activeFunnelKey) params.set("funnel", activeFunnelKey); - if (formFilter !== "all") params.set("form", formFilter); - if (counselorFilter.length > 0) params.set("assignees", counselorFilter.join(",")); - if (collaboratorFilter.length > 0) params.set("collaborators", collaboratorFilter.join(",")); - if (sourceFilter.length > 0) params.set("source", sourceFilter.join(",")); - if (tagFilter !== "all") params.set("tag", tagFilter); - if (createdFilter !== "all") params.set("created", createdFilter); - if (prospectIndustryFilter !== "all") params.set("industry", prospectIndustryFilter); + // Advanced filters ON: send the encoded tree and stop setting the 8 legacy + // filter params (form/assignees/collaborators/source/tag/created/industry — + // status/search/list/funnel above are scope/primary, not toolbar filters, + // and stay either way). Advanced filters OFF: byte-identical to before. + if (advancedFiltersEnabled) { + if (!isEmptyTree(advancedFilters.tree)) params.set("f", encodeFilterTree(advancedFilters.tree)); + } else { + if (formFilter !== "all") params.set("form", formFilter); + if (counselorFilter.length > 0) params.set("assignees", counselorFilter.join(",")); + if (collaboratorFilter.length > 0) params.set("collaborators", collaboratorFilter.join(",")); + if (sourceFilter.length > 0) params.set("source", sourceFilter.join(",")); + if (tagFilter !== "all") params.set("tag", tagFilter); + if (createdFilter !== "all") params.set("created", createdFilter); + if (prospectIndustryFilter !== "all") params.set("industry", prospectIndustryFilter); + } return params; }, [ sortField, sortDirection, statusFilter, debouncedSearch, activeListSlug, activeFunnelKey, + advancedFiltersEnabled, advancedFilters.tree, formFilter, counselorFilter, collaboratorFilter, sourceFilter, tagFilter, createdFilter, prospectIndustryFilter, ], @@ -493,20 +534,34 @@ export function LeadsTable({ // Everything that should reset to page 1 and force a fresh exact count (§3) when it // changes. itemsPerPage is included — a page-size change reshapes every page boundary. + // Advanced-filter changes must also refetch — fetchSignature includes the encoded + // tree (not the tree object itself, which is a fresh reference every render). const fetchSignature = JSON.stringify([ activeListSlug, activeFunnelKey, statusFilter, debouncedSearch, sortField, sortDirection, itemsPerPage, - formFilter, counselorFilter, collaboratorFilter, sourceFilter, tagFilter, createdFilter, prospectIndustryFilter, + advancedFiltersEnabled ? advancedFilters.encoded : null, + advancedFiltersEnabled ? null : formFilter, + advancedFiltersEnabled ? null : counselorFilter, + advancedFiltersEnabled ? null : collaboratorFilter, + advancedFiltersEnabled ? null : sourceFilter, + advancedFiltersEnabled ? null : tagFilter, + advancedFiltersEnabled ? null : createdFilter, + advancedFiltersEnabled ? null : prospectIndustryFilter, ]); useEffect(() => { if (!serverPaginated) return; // legacy consumers (Contacts, leads-organise) never fetch here if (isFirstFetchRef.current) { - // Page 1 is already SSR-seeded via the `leads`/`initialTotal` props — skip the - // redundant fetch on mount. isFirstFetchRef.current = false; prevSignatureRef.current = fetchSignature; - return; + // Page 1 is already SSR-seeded via the `leads`/`initialTotal` props — skip the + // redundant fetch on mount. EXCEPT when an advanced filter is already active + // on mount (e.g. a shared `?f=` link opened fresh): page.tsx's SSR query + // doesn't parse `f` (Phase 2b territory), so its seed is the unfiltered first + // page — fall through to fetch the real filtered result (and its real count) + // instead of trusting the SSR seed. + if (!advancedFilterActive) return; + needsCountRef.current = true; } const signatureChanged = fetchSignature !== prevSignatureRef.current; @@ -546,7 +601,7 @@ export function LeadsTable({ }); return () => controller.abort(); - }, [serverPaginated, fetchSignature, currentPage, itemsPerPage, buildFetchParams]); + }, [serverPaginated, fetchSignature, currentPage, itemsPerPage, buildFetchParams, advancedFilterActive]); const { counts } = useBadgeCounts(); const unreadLeadIds = useMemo(() => new Set(counts.unread_lead_ids), [counts.unread_lead_ids]); @@ -620,13 +675,17 @@ export function LeadsTable({ return m; }, [localLeads, isStagingView, counselorFilter, tagFilter, statusFilter, formFilter, createdFilter]); - // Server-computed Source facet (serverPaginated only) — replaces the localLeads-only - // (current 25-row page) computation above, which is what made the option list and - // its counts wrong once #332 shipped narrow server pages (see - // docs/DASHBOARD-AGGREGATES-BRIEF.md addendum). Reuses buildFetchParams' exact same - // filter params minus `source`/pagination/sort, so this reflects every OTHER active - // filter — matching current cross-filter behavior — via one extra opt-in round-trip. + // Server-computed Source + Assigned-To facets (serverPaginated only) — replaces the + // localLeads-only (current 25-row page) computation above/below, which is what made + // the option lists and their counts wrong once #332 shipped narrow server pages (see + // docs/DASHBOARD-AGGREGATES-BRIEF.md addendum + ADVANCED-FILTERS-BRIEF Phase 3 + // addendum §B/§C for the Assigned-To half). Reuses buildFetchParams' exact same + // filter params minus pagination/sort, so this reflects every OTHER active filter — + // matching current cross-filter behavior — via ONE extra opt-in round-trip that asks + // for both dimensions at once (?facets=source,assignee) rather than two. + const wantsAssigneeFacet = isAdmin || isTeamScoped; const [serverSourceFacet, setServerSourceFacet] = useState<{ name: string; count: number }[] | null>(null); + const [serverAssigneeFacet, setServerAssigneeFacet] = useState<{ name: string; count: number }[] | null>(null); const facetFetchParams = useMemo(() => { if (!serverPaginated || isStagingView) return null; // staging view isn't serverPaginated today const params = buildFetchParams(1, itemsPerPage, false); @@ -636,30 +695,50 @@ export function LeadsTable({ params.delete("sort"); params.delete("order"); params.delete("count"); - params.set("facets", "source"); + // A single "source" stays the pre-existing single-dimension request (and response + // shape) byte-for-byte — see route.ts's legacySingleSourceFacet branch. + params.set("facets", wantsAssigneeFacet ? "source,assignee" : "source"); return params; - }, [serverPaginated, isStagingView, buildFetchParams, itemsPerPage]); + }, [serverPaginated, isStagingView, buildFetchParams, itemsPerPage, wantsAssigneeFacet]); useEffect(() => { if (!facetFetchParams) { setServerSourceFacet(null); + setServerAssigneeFacet(null); return; } const controller = new AbortController(); fetch(`/api/v1/leads?${facetFetchParams.toString()}`, { signal: controller.signal }) .then((res) => res.json()) - .then((body: { data?: { options?: { name: string; count: number }[] } }) => { + .then((body: { + data?: { + // Legacy single-dimension shape (facets=source alone). + facet?: string; + options?: { name: string; count: number }[]; + // New multi-dimension shape (facets=source,assignee or facets=assignee). + facets?: { + source?: { options: { name: string; count: number }[] } | null; + assignee?: { options: { name: string; count: number }[] } | null; + } | null; + }; + }) => { if (controller.signal.aborted) return; - setServerSourceFacet(body.data?.options ?? []); + if (body.data?.facet === "source") { + setServerSourceFacet(body.data.options ?? []); + setServerAssigneeFacet(null); + } else { + setServerSourceFacet(body.data?.facets?.source?.options ?? []); + setServerAssigneeFacet(wantsAssigneeFacet ? (body.data?.facets?.assignee?.options ?? []) : null); + } }) .catch((err: unknown) => { if (controller.signal.aborted) return; // Keep the previous facet (if any) rather than blanking the dropdown on a // transient failure — the leads page itself already surfaced the error. - console.error("Failed to load source facet", err); + console.error("Failed to load lead facets", err); }); return () => controller.abort(); - }, [facetFetchParams]); + }, [facetFetchParams, wantsAssigneeFacet]); const sources = useMemo( () => (serverSourceFacet ? serverSourceFacet.map((o) => o.name) : clientSources), @@ -670,8 +749,12 @@ export function LeadsTable({ return new Map(serverSourceFacet.map((o) => [o.name, o.count])); }, [serverSourceFacet, clientSourceCounts]); - // Per-counselor counts — cross-filtered: reflects all active filters except counselor itself - const counselorCounts = useMemo(() => { + // Per-counselor counts — cross-filtered: reflects all active filters except counselor + // itself. Client-side fallback for surfaces that aren't serverPaginated (Contacts, + // leads-organise — see the comment on the block above) and computed from `localLeads` + // only, so it is page-scoped exactly like the old `counselorCounts` used to be — + // that's the bug §B of the Phase 3 addendum fixes for the serverPaginated table. + const clientCounselorCounts = useMemo(() => { const m = new Map(); const now = Date.now(); const dayMs = 24 * 60 * 60 * 1000; @@ -698,6 +781,14 @@ export function LeadsTable({ return m; }, [localLeads, sourceFilter, tagFilter, statusFilter, formFilter, createdFilter]); + // Server-computed Assigned-To facet takes priority when available (serverPaginated + + // isAdmin/isTeamScoped) — exact, tenant-wide counts via lead_aggregates()'s `counselor` + // dimension (ADVANCED-FILTERS-BRIEF Phase 3 addendum §C), not the 25-row page above. + const counselorCounts = useMemo(() => { + if (!serverAssigneeFacet) return clientCounselorCounts; + return new Map(serverAssigneeFacet.map((o) => [o.name, o.count])); + }, [serverAssigneeFacet, clientCounselorCounts]); + // Get unique counselors (assigned_to users) const counselors = useMemo(() => { const c = new Map(); @@ -815,6 +906,7 @@ export function LeadsTable({ lead.email?.toLowerCase().includes(searchLower) || lead.phone?.toLowerCase().includes(searchLower) || lead.city?.toLowerCase().includes(searchLower) || + lead.display_id?.toLowerCase().includes(searchLower) || assignedEmail.toLowerCase().includes(searchLower); return matchesStatus && matchesSearch && matchesSecondaryFilters(lead); }); @@ -1819,6 +1911,65 @@ export function LeadsTable({ : []), ]; + // Advanced filters (Phase 3) — field list + option lists, gated identically to + // filterDefs above so the two toolbars offer the same axes to the same users. + const advancedVisibleFieldKeys = useMemo(() => { + const hide = new Set(); + if (!(isAdmin || isTeamScoped)) { + hide.add("assignees"); + hide.add("collaborators"); + } + if (!showItAgencyFields) hide.add("industry"); + if (!showTags) hide.add("tags"); + if (!hasMultipleForms) hide.add("form"); + return hide; + }, [isAdmin, isTeamScoped, showItAgencyFields, showTags, hasMultipleForms]); + + const advancedVisibleFields = useMemo( + () => Object.values(advancedFilterRegistry).filter((f) => !advancedVisibleFieldKeys.has(f.key)), + [advancedFilterRegistry, advancedVisibleFieldKeys] + ); + + // Reuses the exact option arrays the legacy filterDefs above compute — same + // counselors/sources/forms/tags lists, just fed to a different UI. ADVANCED-FILTERS- + // BRIEF Phase 3 addendum §A: assignees/collaborators must carry counts AND hide + // zero-count people, matching the legacy filterDefs' "counselor"/"collaborator" + // entries above byte-for-byte — the bar must not ship with a visible count + // regression against the toolbar it replaces. + const advancedFilterOptionOverrides: Partial> = useMemo( + () => ({ + status: statusFilterOptions, + source: sources.map((s) => ({ value: s, label: `${s} (${(sourceCounts.get(s) ?? 0).toLocaleString()})` })), + assignees: [ + ...((counselorCounts.get("unassigned") ?? 0) > 0 + ? [{ value: "unassigned", label: `Unassigned (${(counselorCounts.get("unassigned") ?? 0).toLocaleString()})` }] + : []), + ...counselors + .filter(([userId]) => (counselorCounts.get(userId) ?? 0) > 0) + .map(([userId, email]) => ({ + value: userId, + label: `${memberNames[userId] || email.split("@")[0]} (${(counselorCounts.get(userId) ?? 0).toLocaleString()})`, + })), + ], + // Collaborator counts stay client-side, computed from `localLeads` (the current + // server page only) — deliberately, not silently: `lead_collaborators` is a join + // table with no existing `lead_aggregates()` dimension, and adding one is a + // bigger change than this addendum covers (ADVANCED-FILTERS-BRIEF Phase 3 + // addendum §C). Being explicitly page-scoped-and-labelled beats shipping a + // second, silently different meaning of the same-looking number. + collaborators: counselors + .filter(([userId]) => (collaboratorCounts.get(userId) ?? 0) > 0 && memberRoleMap[userId] !== "owner" && memberRoleMap[userId] !== "admin") + .map(([userId, email]) => ({ + value: userId, + label: `${memberNames[userId] || email.split("@")[0]} (${(collaboratorCounts.get(userId) ?? 0).toLocaleString()})`, + })), + tags: [{ value: "student", label: "Student" }], + industry: PROSPECT_INDUSTRIES.map((ind) => ({ value: ind.value, label: ind.label })), + form: formEntries.map(([id, name]) => ({ value: id, label: name })), + }), + [statusFilterOptions, sources, sourceCounts, counselorCounts, counselors, memberNames, collaboratorCounts, memberRoleMap, formEntries] + ); + return (
{/* Main Table Section - shrinks when preview is open */} @@ -1883,8 +2034,20 @@ export function LeadsTable({
- {/* Filters */} - + {/* Filters — advanced bar (field->operator->value, stacked chips) behind the + flag; legacy FilterMenu dropdown otherwise. Both paths must keep working. */} + {advancedFiltersEnabled ? ( + + ) : ( + + )} {/* Sort */} @@ -1969,8 +2132,10 @@ export function LeadsTable({ )}
- {/* Chips row — active filters only, replaces the old always-visible pill row */} - {activeFiltersCount > 0 && ( + {/* Chips row — active filters only, replaces the old always-visible pill row. + Advanced mode renders its own chips inline in the toolbar row above + (AdvancedFilterBar's FilterChipRow) — this legacy row is flag-off only. */} + {!advancedFiltersEnabled && activeFiltersCount > 0 && ( <>
diff --git a/src/components/filters/add-filter-button.tsx b/src/components/filters/add-filter-button.tsx new file mode 100644 index 00000000..54f51dde --- /dev/null +++ b/src/components/filters/add-filter-button.tsx @@ -0,0 +1,76 @@ +"use client"; + +// "+ Add filter" — opens a popover that starts on FilterFieldPicker, then +// swaps to FilterConditionEditor once a field is chosen. The new condition +// only lands in the tree when "Apply" is pressed (or the popover closes with +// a field chosen but not yet applied — in that case it's discarded, matching +// Notion/Twenty: closing an unfinished "+ Add filter" adds nothing). + +import { useState } from "react"; +import { Plus } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import type { FieldDef, FilterCondition } from "@/lib/filters/types"; +import { FilterFieldPicker } from "./filter-field-picker"; +import { FilterConditionEditor } from "./filter-condition-editor"; +import { newConditionForField } from "./condition-defaults"; +import type { FilterOption } from "./types"; + +export interface AddFilterButtonProps { + fields: FieldDef[]; + getOptions: (field: FieldDef) => FilterOption[]; + onAdd: (condition: FilterCondition) => void; + disabled?: boolean; + compact?: boolean; +} + +export function AddFilterButton({ fields, getOptions, onAdd, disabled, compact }: AddFilterButtonProps) { + const [open, setOpen] = useState(false); + const [picked, setPicked] = useState<{ field: FieldDef; condition: FilterCondition } | null>(null); + + function handleOpenChange(next: boolean) { + setOpen(next); + if (!next) setPicked(null); // discard an unfinished pick on close + } + + function handleFieldSelect(field: FieldDef) { + setPicked({ field, condition: newConditionForField(field) }); + } + + function handleApply() { + if (!picked) return; + onAdd(picked.condition); + setOpen(false); + setPicked(null); + } + + void compact; // reserved for `density` styling in a later phase; button text/size stays constant for now + + return ( + + + + + + {picked ? ( + setPicked({ field: picked.field, condition: next })} + onApply={handleApply} + /> + ) : ( + + )} + + + ); +} diff --git a/src/components/filters/advanced-filter-bar.tsx b/src/components/filters/advanced-filter-bar.tsx new file mode 100644 index 00000000..50271439 --- /dev/null +++ b/src/components/filters/advanced-filter-bar.tsx @@ -0,0 +1,69 @@ +"use client"; + +// The Notion/Twenty-style filter bar. Reads/writes only `value`/`onChange` — +// state (URL sync, decode-degrade) is the host's job via use-advanced-filters; +// this component is a pure controlled tree editor so it can serve table, +// kanban and board (Phase 4) with zero surface-specific branches inside it. + +import type { FieldDef, FilterCondition } from "@/lib/filters/types"; +import { FilterChipRow } from "./filter-chip-row"; +import { ConjunctionToggle } from "./conjunction-toggle"; +import { AddFilterButton } from "./add-filter-button"; +import { useFilterOptions } from "./use-filter-options"; +import type { FilterHostConfig } from "./types"; + +const DEFAULT_MAX_CONDITIONS = 25; + +export function AdvancedFilterBar({ + fields, + value, + onChange, + showChips = true, + maxConditions = DEFAULT_MAX_CONDITIONS, + optionOverrides, +}: FilterHostConfig) { + const { getOptions } = useFilterOptions(optionOverrides); + + const registry: Record = {}; + for (const field of fields) registry[field.key] = field; + + const conditions = value.conditions; + const atCap = conditions.length >= maxConditions; + + function handleAdd(condition: FilterCondition) { + onChange({ ...value, conditions: [...value.conditions, condition] }); + } + + function handleChangeCondition(id: string, next: FilterCondition) { + onChange({ ...value, conditions: value.conditions.map((c) => (c.id === id ? next : c)) }); + } + + function handleRemoveCondition(id: string) { + onChange({ ...value, conditions: value.conditions.filter((c) => c.id !== id) }); + } + + function handleConjunctionChange(conjunction: "and" | "or") { + onChange({ ...value, conjunction }); + } + + return ( +
+ {showChips && conditions.length > 1 && } + {showChips && ( + + )} + +
+ ); +} diff --git a/src/components/filters/chip-label.ts b/src/components/filters/chip-label.ts new file mode 100644 index 00000000..e8d63e50 --- /dev/null +++ b/src/components/filters/chip-label.ts @@ -0,0 +1,43 @@ +// Human-readable chip text — "Name: brian", "Status is not: Contacted", +// "Created within the last: Last 7 days". Matches the reference screenshots' +// "Field: value" shape, with the operator spelled out whenever it isn't the +// bare "is" (which reads fine as a bare colon). + +import type { FieldDef, FilterCondition } from "@/lib/filters/types"; +import type { FilterOption } from "@/components/ui/filter-dropdown"; +import { OPERATOR_LABELS, RELATIVE_DATE_PRESETS } from "./condition-defaults"; + +const NO_VALUE_OPS = new Set(["is_empty", "is_not_empty", "is_true", "is_false"]); +const LIST_OPS = new Set(["is_any_of", "is_none_of", "has_all"]); + +function optionLabel(options: FilterOption[], value: string): string { + return options.find((o) => o.value === value)?.label ?? value; +} + +function formatValue(condition: FilterCondition, options: FilterOption[]): string { + const { op, value } = condition; + + if (op === "within_last" || op === "within_next") { + return RELATIVE_DATE_PRESETS.find((p) => p.value === value)?.label ?? String(value); + } + if (LIST_OPS.has(op) && Array.isArray(value)) { + const shown = (value as string[]).slice(0, 2).map((v) => optionLabel(options, v)); + const extra = (value as string[]).length - shown.length; + return extra > 0 ? `${shown.join(", ")} +${extra}` : shown.join(", "); + } + if ((op === "between" || op === "date_between") && Array.isArray(value)) { + const [a, b] = value as [string | number, string | number]; + return `${a} – ${b}`; + } + if (typeof value === "string") return optionLabel(options, value); + if (typeof value === "number" || typeof value === "boolean") return String(value); + return ""; +} + +export function formatChipLabel(field: FieldDef, condition: FilterCondition, options: FilterOption[]): string { + if (NO_VALUE_OPS.has(condition.op)) { + return `${field.label}: ${OPERATOR_LABELS[condition.op]}`; + } + const opPrefix = condition.op === "is" ? "" : ` ${OPERATOR_LABELS[condition.op]}`; + return `${field.label}${opPrefix}: ${formatValue(condition, options)}`; +} diff --git a/src/components/filters/condition-defaults.ts b/src/components/filters/condition-defaults.ts new file mode 100644 index 00000000..fb75c68c --- /dev/null +++ b/src/components/filters/condition-defaults.ts @@ -0,0 +1,84 @@ +// Shared helpers for staging a FilterCondition being edited in the UI — +// picking a sane default operator/value when a field is first chosen, and +// producing a stable id. Pure, no React — used by both the "add" and +// "edit in place" flows (AddFilterButton and FilterChip share one editor). + +import type { FieldDef, FilterCondition, FilterOperator, FilterValue } from "@/lib/filters/types"; +import { operatorsForField } from "@/lib/filters/operators"; + +export function newConditionId(): string { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `c_${Date.now()}_${Math.random().toString(36).slice(2)}`; +} + +export function defaultOperatorForField(field: FieldDef): FilterOperator { + return operatorsForField(field)[0]; +} + +const RELATIVE_DATE_OPERATORS: readonly FilterOperator[] = ["within_last", "within_next"]; +const TUPLE_OPERATORS: readonly FilterOperator[] = ["between", "date_between"]; +const LIST_OPERATORS: readonly FilterOperator[] = ["is_any_of", "is_none_of", "has_all"]; +const NO_VALUE_OPERATORS: readonly FilterOperator[] = ["is_empty", "is_not_empty", "is_true", "is_false"]; + +export function defaultValueForOperator(field: FieldDef, op: FilterOperator): FilterValue | undefined { + if (NO_VALUE_OPERATORS.includes(op)) return undefined; + if (LIST_OPERATORS.includes(op)) return []; + if (op === "between") return field.type === "number" ? [0, 0] : ["", ""]; + if (op === "date_between") return ["", ""]; + if (RELATIVE_DATE_OPERATORS.includes(op)) return "7d"; + if (field.type === "date") return new Date().toISOString().slice(0, 10); + if (field.type === "number") return 0; + return ""; +} + +export function newConditionForField(field: FieldDef): FilterCondition { + const op = defaultOperatorForField(field); + return { id: newConditionId(), field: field.key, op, value: defaultValueForOperator(field, op) }; +} + +// When the operator changes on an existing condition, the old value's shape +// is very likely wrong for the new operator's arity (e.g. scalar -> list) — +// re-derive a fresh default rather than carrying over a mismatched shape. +export function reshapeValueForOperator(field: FieldDef, prevOp: FilterOperator, nextOp: FilterOperator, prevValue: FilterValue | undefined): FilterValue | undefined { + const sameShape = + (LIST_OPERATORS.includes(prevOp) && LIST_OPERATORS.includes(nextOp)) || + (TUPLE_OPERATORS.includes(prevOp) && TUPLE_OPERATORS.includes(nextOp)) || + (!LIST_OPERATORS.includes(prevOp) && !TUPLE_OPERATORS.includes(prevOp) && !NO_VALUE_OPERATORS.includes(prevOp) && !LIST_OPERATORS.includes(nextOp) && !TUPLE_OPERATORS.includes(nextOp) && !NO_VALUE_OPERATORS.includes(nextOp)); + if (sameShape) return prevValue; + return defaultValueForOperator(field, nextOp); +} + +export const OPERATOR_LABELS: Record = { + is: "is", + is_not: "is not", + is_empty: "is empty", + is_not_empty: "is not empty", + contains: "contains", + not_contains: "does not contain", + starts_with: "starts with", + ends_with: "ends with", + is_any_of: "is any of", + is_none_of: "is none of", + has_all: "has all of", + gt: "greater than", + gte: "greater than or equal to", + lt: "less than", + lte: "less than or equal to", + between: "between", + before: "before", + after: "after", + on: "on", + date_between: "between", + within_last: "within the last", + within_next: "within the next", + is_true: "is true", + is_false: "is false", +}; + +export const RELATIVE_DATE_PRESETS = [ + { value: "1d", label: "Today" }, + { value: "7d", label: "Last 7 days" }, + { value: "30d", label: "Last 30 days" }, + { value: "1m", label: "This month" }, +]; diff --git a/src/components/filters/conjunction-toggle.tsx b/src/components/filters/conjunction-toggle.tsx new file mode 100644 index 00000000..c1cf986a --- /dev/null +++ b/src/components/filters/conjunction-toggle.tsx @@ -0,0 +1,31 @@ +"use client"; + +// "Where / and / or" — the root tree.conjunction switch between stacked root +// conditions. Hand-rolled 2-state toggle rather than a toggle-group primitive +// (there isn't one installed, and two states don't warrant adding one). + +export interface ConjunctionToggleProps { + value: "and" | "or"; + onChange: (next: "and" | "or") => void; + disabled?: boolean; +} + +export function ConjunctionToggle({ value, onChange, disabled }: ConjunctionToggleProps) { + return ( +
+ {(["and", "or"] as const).map((opt) => ( + + ))} +
+ ); +} diff --git a/src/components/filters/filter-chip-row.tsx b/src/components/filters/filter-chip-row.tsx new file mode 100644 index 00000000..7d0d2a32 --- /dev/null +++ b/src/components/filters/filter-chip-row.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { FieldDef, FilterCondition } from "@/lib/filters/types"; +import { FilterChip } from "./filter-chip"; +import type { FilterOption } from "./types"; + +export interface FilterChipRowProps { + conditions: FilterCondition[]; + registry: Record; + getOptions: (field: FieldDef) => FilterOption[]; + onChangeCondition: (id: string, next: FilterCondition) => void; + onRemoveCondition: (id: string) => void; +} + +export function FilterChipRow({ conditions, registry, getOptions, onChangeCondition, onRemoveCondition }: FilterChipRowProps) { + return ( + <> + {conditions.map((condition) => { + const field = registry[condition.field]; + if (!field) return null; // stale/unknown field key — dropped silently, matches use-advanced-filters' degrade-on-decode contract + return ( + onChangeCondition(condition.id, next)} + onRemove={() => onRemoveCondition(condition.id)} + /> + ); + })} + + ); +} diff --git a/src/components/filters/filter-chip.tsx b/src/components/filters/filter-chip.tsx new file mode 100644 index 00000000..31021ac6 --- /dev/null +++ b/src/components/filters/filter-chip.tsx @@ -0,0 +1,59 @@ +"use client"; + +// A single "Name: brian ✕" pill. Clicking the label re-opens the exact same +// FilterConditionEditor used by AddFilterButton, pre-filled — editing a chip +// in place is the same screen as creating one, just seeded differently. + +import { useState } from "react"; +import { X } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import type { FieldDef, FilterCondition } from "@/lib/filters/types"; +import { FilterConditionEditor } from "./filter-condition-editor"; +import { formatChipLabel } from "./chip-label"; +import type { FilterOption } from "./types"; + +export interface FilterChipProps { + field: FieldDef; + condition: FilterCondition; + options: FilterOption[]; + onChange: (condition: FilterCondition) => void; + onRemove: () => void; +} + +export function FilterChip({ field, condition, options, onChange, onRemove }: FilterChipProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(condition); + + function handleOpenChange(next: boolean) { + if (next) setDraft(condition); // re-seed from the committed value each time it opens + setOpen(next); + } + + function handleApply() { + onChange(draft); + setOpen(false); + } + + return ( + +
+ + + + +
+ + + +
+ ); +} diff --git a/src/components/filters/filter-condition-editor.tsx b/src/components/filters/filter-condition-editor.tsx new file mode 100644 index 00000000..5365dc22 --- /dev/null +++ b/src/components/filters/filter-condition-editor.tsx @@ -0,0 +1,42 @@ +"use client"; + +// The operator + value screen — reached after a field is chosen (either fresh, +// from FilterFieldPicker, or by clicking an existing chip to edit it in place). + +import { Button } from "@/components/ui/button"; +import type { FieldDef, FilterCondition, FilterOperator, FilterValue } from "@/lib/filters/types"; +import type { FilterOption } from "@/components/ui/filter-dropdown"; +import { FilterOperatorPicker } from "./filter-operator-picker"; +import { FilterValueEditor } from "./filter-value-editor"; +import { reshapeValueForOperator } from "./condition-defaults"; + +export interface FilterConditionEditorProps { + field: FieldDef; + condition: FilterCondition; + options: FilterOption[]; + onChange: (condition: FilterCondition) => void; + onApply: () => void; +} + +export function FilterConditionEditor({ field, condition, options, onChange, onApply }: FilterConditionEditorProps) { + function handleOperatorChange(nextOp: FilterOperator) { + onChange({ ...condition, op: nextOp, value: reshapeValueForOperator(field, condition.op, nextOp, condition.value) }); + } + + function handleValueChange(nextValue: FilterValue | undefined) { + onChange({ ...condition, value: nextValue }); + } + + return ( +
+

{field.label}

+ + +
+ +
+
+ ); +} diff --git a/src/components/filters/filter-field-picker.tsx b/src/components/filters/filter-field-picker.tsx new file mode 100644 index 00000000..a1ea1048 --- /dev/null +++ b/src/components/filters/filter-field-picker.tsx @@ -0,0 +1,47 @@ +"use client"; + +// The "+ Add filter" popover's first screen — pick which field to filter on. +// Grouped by FieldDef.group ("Basic", "Dates", "Education", …), searchable. +// This is popover CONTENT (rendered inside AddFilterButton's Popover), not a +// second nested trigger — matches reference screenshot 1. + +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import type { FieldDef } from "@/lib/filters/types"; + +export interface FilterFieldPickerProps { + fields: FieldDef[]; + onSelect: (field: FieldDef) => void; +} + +function groupFields(fields: FieldDef[]): [string, FieldDef[]][] { + const groups = new Map(); + for (const field of fields) { + const list = groups.get(field.group) ?? []; + list.push(field); + groups.set(field.group, list); + } + return Array.from(groups.entries()); +} + +export function FilterFieldPicker({ fields, onSelect }: FilterFieldPickerProps) { + const filterable = fields.filter((f) => f.filterable); + const grouped = groupFields(filterable); + + return ( + + + + No matching fields. + {grouped.map(([group, groupFields]) => ( + + {groupFields.map((field) => ( + onSelect(field)}> + {field.label} + + ))} + + ))} + + + ); +} diff --git a/src/components/filters/filter-operator-picker.tsx b/src/components/filters/filter-operator-picker.tsx new file mode 100644 index 00000000..36a7d2d6 --- /dev/null +++ b/src/components/filters/filter-operator-picker.tsx @@ -0,0 +1,52 @@ +"use client"; + +// Operator dropdown — options come strictly from isOperatorAllowed() / +// operatorsForField(), never a hand-maintained list, so the UI can never +// offer an operator the compiler would 422 (e.g. is_none_of on a relation). + +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; +import type { FieldDef, FilterOperator } from "@/lib/filters/types"; +import { operatorsForField } from "@/lib/filters/operators"; +import { OPERATOR_LABELS } from "./condition-defaults"; + +export interface FilterOperatorPickerProps { + field: FieldDef; + value: FilterOperator; + onChange: (op: FilterOperator) => void; +} + +export function FilterOperatorPicker({ field, value, onChange }: FilterOperatorPickerProps) { + const operators = operatorsForField(field); + + return ( + + + + + + + + + {operators.map((op) => ( + onChange(op)}> + + {OPERATOR_LABELS[op]} + + ))} + + + + + + ); +} diff --git a/src/components/filters/filter-value-editor.tsx b/src/components/filters/filter-value-editor.tsx new file mode 100644 index 00000000..e4932e5f --- /dev/null +++ b/src/components/filters/filter-value-editor.tsx @@ -0,0 +1,146 @@ +"use client"; + +// Dispatches on field.type + operator arity. Text/number/date use native +// inputs (two, side by side, for `between`/`date_between`) — no calendar +// picker dependency, per the Phase 3 brief (§1: no react-day-picker). select/ +// multiselect/uuid/relation reuse the existing FilterOptionList verbatim for +// list-shaped operators (is_any_of/is_none_of/has_all) — that component +// (search + checkbox rows + clear) is the one piece of today's filter UI +// worth keeping, and the brief explicitly forbids forking it. + +import { Input } from "@/components/ui/input"; +import { FilterOptionList, type FilterOption } from "@/components/ui/filter-dropdown"; +import type { FieldDef, FilterCondition, FilterValue } from "@/lib/filters/types"; +import { RELATIVE_DATE_PRESETS } from "./condition-defaults"; + +const NO_VALUE_OPS = new Set(["is_empty", "is_not_empty", "is_true", "is_false"]); +const LIST_OPS = new Set(["is_any_of", "is_none_of", "has_all"]); +const RELATIVE_DATE_OPS = new Set(["within_last", "within_next"]); + +export interface FilterValueEditorProps { + field: FieldDef; + op: FilterCondition["op"]; + value: FilterValue | undefined; + options: FilterOption[]; + onChange: (value: FilterValue | undefined) => void; +} + +export function FilterValueEditor({ field, op, value, options, onChange }: FilterValueEditorProps) { + if (NO_VALUE_OPS.has(op)) { + return

No value needed.

; + } + + if (RELATIVE_DATE_OPS.has(op)) { + const current = typeof value === "string" ? value : "7d"; + return ( +
+ {RELATIVE_DATE_PRESETS.map((preset) => ( + + ))} +
+ ); + } + + if (field.type === "date") { + if (op === "date_between") { + const [from, to] = (Array.isArray(value) ? value : ["", ""]) as [string, string]; + return ( +
+ onChange([e.target.value, to])} /> + to + onChange([from, e.target.value])} /> +
+ ); + } + return ( +
+ onChange(e.target.value)} /> +
+ ); + } + + if (LIST_OPS.has(op)) { + const current = (Array.isArray(value) ? value : []) as string[]; + // FilterOptionList only renders its "Clear" row once the first option is + // selected (multiple && value.length > 0) — see filter-dropdown.tsx. That row + // is ~45px tall, and appearing mid-interaction pushes FilterConditionEditor's + // Apply button down by that same amount: the user checks an option, then + // clicks where Apply used to be and hits nothing (ADVANCED-FILTERS-BRIEF + // Phase 3 addendum — the "Apply bug" §2). Reserve the identical height with a + // spacer whenever Clear ISN'T shown, so the transition never changes this + // container's total height and Apply never moves under the cursor. Scoped to + // this file only — filter-dropdown.tsx (shared with the legacy FilterMenu, + // which has no Apply button to protect) stays untouched. + const clearRowShown = current.length > 0; + return ( +
+ {}} + onSelectMulti={(v) => onChange(current.includes(v) ? current.filter((x) => x !== v) : [...current, v])} + onClearMulti={() => onChange([])} + /> + {!clearRowShown && + ); + } + + if (field.type === "select" || field.type === "uuid" || field.type === "relation") { + // Single-value scalar ops (is/is_not) against an enumerable option list. + return ( +
+ onChange(v)} + onSelectMulti={() => {}} + /> +
+ ); + } + + if (op === "between") { + const [min, max] = (Array.isArray(value) ? value : [0, 0]) as [number, number]; + return ( +
+ onChange([Number(e.target.value), max])} /> + and + onChange([min, Number(e.target.value)])} /> +
+ ); + } + + if (field.type === "number") { + return ( +
+ onChange(Number(e.target.value))} /> +
+ ); + } + + // text (is/is_not/contains/not_contains/starts_with/ends_with) + return ( +
+ onChange(e.target.value)} + placeholder="Value…" + /> +
+ ); +} diff --git a/src/components/filters/types.ts b/src/components/filters/types.ts new file mode 100644 index 00000000..a24bca95 --- /dev/null +++ b/src/components/filters/types.ts @@ -0,0 +1,38 @@ +// Host contract for AdvancedFilterBar — see docs/ADVANCED-FILTERS-BRIEF.md Phase 3 §2/§3. +// Only `fields`/`value`/`onChange` plus three cosmetic flags differ between +// surfaces (leads table today; kanban/board are Phase 4) — that is what lets +// one component tree serve every one of them. Do not add surface-specific +// branches inside the bar itself; add a prop instead. + +import type { FieldDef, FilterTree } from "@/lib/filters/types"; +import type { FilterOption } from "@/components/ui/filter-dropdown"; + +export type EntityKey = "leads"; + +// Keys async option loaders are cached under (use-filter-options.ts) — one per +// field that needs a runtime-fetched or host-supplied option list (members, +// stages, lists, forms, tags, sources, …). Not every FieldDef needs one — +// fields with a static `options` array on the FieldDef never consult this. +export type OptionLoaderKey = string; + +export interface FilterHostConfig { + entity: EntityKey; + /** Already industry- and permission-filtered. */ + fields: FieldDef[]; + value: FilterTree; + onChange: (next: FilterTree) => void; + /** Kanban's toolbar is tight — Phase 4 will pass "compact". */ + density?: "comfortable" | "compact"; + showChips?: boolean; + /** Depth-2 group UI. false on narrow toolbars (not offered in Phase 3's leads-table wiring). */ + allowGroups?: boolean; + /** Default 25 — mirrors schema.ts's MAX_TOTAL_CONDITIONS. */ + maxConditions?: number; + /** Host-supplied option lists, keyed by field key — short-circuits the async + * loader in use-filter-options.ts. Kanban already has `stages` in props, + * which is why this exists as a first-class prop rather than forcing every + * surface through a fetch. */ + optionOverrides?: Partial>; +} + +export type { FilterOption } from "@/components/ui/filter-dropdown"; diff --git a/src/components/filters/use-filter-options.ts b/src/components/filters/use-filter-options.ts new file mode 100644 index 00000000..4b7a34c3 --- /dev/null +++ b/src/components/filters/use-filter-options.ts @@ -0,0 +1,41 @@ +"use client"; + +// Single place option lists for select/multiselect/uuid/relation fields come +// from, so the field picker + value editor never fire a request per dropdown +// open. Phase 3's only consumer (leads-table.tsx) supplies every list it +// needs via `optionOverrides` — the leads-table already computes them (sources, +// counselors, forms, tags, industries) for the legacy FilterMenu, so Phase 3 +// reuses those exact arrays rather than re-fetching. A field with a static +// `field.options` on its FieldDef (the registry-declared, non-dynamic case) +// never consults this hook at all. +// +// `optionOverrides` is the reason this hook has no fetch logic yet: every +// surface built so far (leads table) can supply its lists synchronously from +// already-loaded data. A real async loader (e.g. for a field with no natural +// host-side list) is additive — a `loading` flag already exists on the return +// shape for exactly that, unused until a caller needs it. + +import { useCallback } from "react"; +import type { FieldDef } from "@/lib/filters/types"; +import type { FilterOption, OptionLoaderKey } from "./types"; + +export interface UseFilterOptionsResult { + getOptions: (field: FieldDef) => FilterOption[]; + isLoading: (key: OptionLoaderKey) => boolean; +} + +export function useFilterOptions(optionOverrides?: Partial>): UseFilterOptionsResult { + const getOptions = useCallback( + (field: FieldDef): FilterOption[] => { + if (field.options && field.options.length > 0) return field.options; + return optionOverrides?.[field.key] ?? []; + }, + [optionOverrides] + ); + + // No field currently has an async-only source in Phase 3 — every one is + // either static (`field.options`) or host-supplied (`optionOverrides`). + const isLoading = useCallback((key: OptionLoaderKey) => (void key, false), []); + + return { getOptions, isLoading }; +} diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx new file mode 100644 index 00000000..93c7eda9 --- /dev/null +++ b/src/components/ui/combobox.tsx @@ -0,0 +1,127 @@ +"use client"; + +// A thin Popover + Command composition — not an upstream shadcn primitive. +// Built from popover.tsx + command.tsx (cmdk), both already installed. Used by +// the advanced-filter field/operator pickers and multi-select value editors. + +import * as React from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; + +export interface ComboboxOption { + value: string; + label: string; + description?: string; + group?: string; +} + +interface ComboboxBaseProps { + options: ComboboxOption[]; + placeholder?: string; + searchPlaceholder?: string; + emptyText?: string; + className?: string; + contentClassName?: string; + disabled?: boolean; + trigger?: React.ReactNode; + align?: "start" | "center" | "end"; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +function groupOptions(options: ComboboxOption[]): [string | undefined, ComboboxOption[]][] { + const groups = new Map(); + for (const opt of options) { + const list = groups.get(opt.group) ?? []; + list.push(opt); + groups.set(opt.group, list); + } + return Array.from(groups.entries()); +} + +interface ComboboxSingleProps extends ComboboxBaseProps { + multiple?: false; + value: string | null; + onChange: (value: string) => void; +} + +interface ComboboxMultiProps extends ComboboxBaseProps { + multiple: true; + value: string[]; + onChange: (value: string[]) => void; +} + +export type ComboboxProps = ComboboxSingleProps | ComboboxMultiProps; + +export function Combobox(props: ComboboxProps) { + const { options, placeholder = "Select…", searchPlaceholder = "Search…", emptyText = "No results found.", className, contentClassName, disabled, trigger, align = "start" } = props; + const [internalOpen, setInternalOpen] = React.useState(false); + const open = props.open ?? internalOpen; + const setOpen = props.onOpenChange ?? setInternalOpen; + + const selectedLabel = React.useMemo(() => { + if (props.multiple) { + const count = props.value.length; + if (count === 0) return placeholder; + if (count === 1) return options.find((o) => o.value === props.value[0])?.label ?? props.value[0]; + return `${count} selected`; + } + return options.find((o) => o.value === props.value)?.label ?? placeholder; + }, [props, options, placeholder]); + + function isSelected(value: string): boolean { + return props.multiple ? props.value.includes(value) : props.value === value; + } + + function handleSelect(value: string) { + if (props.multiple) { + const next = props.value.includes(value) ? props.value.filter((v) => v !== value) : [...props.value, value]; + props.onChange(next); + // keep the popover open for multi-select — mirrors FilterOptionList's behavior + } else { + props.onChange(value); + setOpen(false); + } + } + + const grouped = groupOptions(options); + + return ( + + + {trigger ?? ( + + )} + + + + + + {emptyText} + {grouped.map(([group, opts]) => ( + + {opts.map((opt) => ( + handleSelect(opt.value)}> + + {opt.label} + + ))} + + ))} + + + + + ); +} diff --git a/src/components/ui/filter-menu.tsx b/src/components/ui/filter-menu.tsx index b7d29bf7..d93c3ddb 100644 --- a/src/components/ui/filter-menu.tsx +++ b/src/components/ui/filter-menu.tsx @@ -9,6 +9,11 @@ import { } from "@/components/ui/popover"; import { FilterOptionList, type FilterOption } from "./filter-dropdown"; +/** @deprecated Superseded by the field/operator/value advanced-filter bar — + * see src/components/filters/advanced-filter-bar.tsx and + * docs/ADVANCED-FILTERS-BRIEF.md (Phase 3). Kept alive behind + * NEXT_PUBLIC_ADVANCED_FILTERS as the flag-off fallback; do not add new + * consumers. */ export interface FilterDef { id: string; label: string; @@ -60,6 +65,8 @@ function clearFilter(filter: FilterDef) { } } +/** @deprecated Superseded by AdvancedFilterBar (src/components/filters/) — see + * docs/ADVANCED-FILTERS-BRIEF.md Phase 3. Flag-off fallback only. */ export function FilterMenu({ filters, activeCount, onClearAll }: FilterMenuProps) { const [open, setOpen] = useState(false); const [drilledId, setDrilledId] = useState(null); @@ -179,6 +186,8 @@ export function FilterMenu({ filters, activeCount, onClearAll }: FilterMenuProps ); } +/** @deprecated Superseded by AdvancedFilterBar's own inline FilterChipRow — + * see docs/ADVANCED-FILTERS-BRIEF.md Phase 3. Flag-off fallback only. */ export function FilterChips({ filters, onClearAll }: { filters: FilterDef[]; onClearAll: () => void }) { const active = filters.filter(isFilterActive); if (active.length === 0) return null; diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..3dcac088 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/src/lib/filters/compile.test.ts b/src/lib/filters/compile.test.ts index 3ad7e009..b191e40f 100644 --- a/src/lib/filters/compile.test.ts +++ b/src/lib/filters/compile.test.ts @@ -120,6 +120,21 @@ const registry: FieldRegistry = { filterable: true, }, hidden: { key: "hidden", label: "Hidden", type: "text", source: { kind: "column", column: "secret" }, group: "Basic", filterable: false }, + // §0 fix fixture: a virtual field whose compile() can legitimately return + // null — "contributes nothing" — for a specific value, mirroring + // registry/leads.ts's compileAssignees("garbage"). + maybe_noop: { + key: "maybe_noop", + label: "Maybe no-op", + type: "select", + source: { + kind: "virtual", + compile: (c: FilterCondition) => (c.value === "noop" ? null : `some_col.eq.${String(c.value)}`), + }, + operators: ["is"], + group: "Basic", + filterable: true, + }, }; const ctx: CompileCtx = { tz: "UTC", now: new Date("2026-01-15T12:00:00.000Z"), industryId: null, permissions: {} }; @@ -386,6 +401,34 @@ describe("virtual field source", () => { }); }); +// ── §0 fix — a condition that compiles to null is DROPPED, never emitted as +// a tautology. This is the carried-forward correctness fix from Phase 3: a +// tautology inside an OR group would make the whole group match every row. +describe("no-op condition dropping (§0 fix)", () => { + it("a null-compiling condition inside AND makes zero .or() calls (not a tautology call)", () => { + const b = compile(andTree(cond("c1", "maybe_noop", "is", "noop"))); + expect(b.calls).toEqual([]); + }); + + it("or(, X) compiles to just X — not to something matching every row", () => { + const tree: FilterTree = { + conjunction: "or", + conditions: [cond("c1", "maybe_noop", "is", "noop"), cond("c2", "industry", "is", "engineering")], + }; + const b = compile(tree); + expect(b.calls).toEqual(["or(prospect_industry.eq.engineering)"]); + }); + + it("if every leg of an OR group drops, the group contributes nothing — no .or() call at all", () => { + const tree: FilterTree = { + conjunction: "or", + conditions: [cond("c1", "maybe_noop", "is", "noop"), cond("c2", "maybe_noop", "is", "noop")], + }; + const b = compile(tree); + expect(b.calls).toEqual([]); + }); +}); + // ── Search field (columns kind) + full-name-pair matching ──────────────── describe("columns-kind field (multi-column search)", () => { diff --git a/src/lib/filters/compile.ts b/src/lib/filters/compile.ts index b5333bda..2215d9be 100644 --- a/src/lib/filters/compile.ts +++ b/src/lib/filters/compile.ts @@ -341,7 +341,7 @@ function renderColumnsPredicate(field: FieldDef & { source: Extract(builder: B, registry: F } const predicate = renderCondition(field, cond, ctx); + // §0 fix: a condition that contributes nothing (e.g. compileAssignees with + // no valid tokens) is dropped rather than compiled to a tautology. Inside + // AND this is a no-op either way; the drop only matters for applyOrConditions + // below, but the rule lives at render time so it's uniform everywhere. + if (predicate === null) return builder; return builder.or(predicate); } @@ -481,10 +486,17 @@ function applyAndConditions(builder: B, registry: FieldR // and combined into ONE `.or(...)` call. function applyOrConditions(builder: B, registry: FieldRegistry, conditions: FilterCondition[], ctx: CompileCtx): B { if (conditions.length === 0) return builder; - const parts = conditions.map((cond) => { - const field = resolveAndValidate(registry, cond); - return renderCondition(field, cond, ctx); - }); + const parts = conditions + .map((cond) => { + const field = resolveAndValidate(registry, cond); + return renderCondition(field, cond, ctx); + }) + // §0 fix: a null leg contributes nothing and must be dropped, never joined + // in as a tautology — or(, X) must compile to just X, not to + // something that matches every row. If every leg drops, the whole group + // contributes nothing (no .or() call at all), not `or()` of nothing. + .filter((p): p is string => p !== null); + if (parts.length === 0) return builder; return builder.or(or(...parts)); } diff --git a/src/lib/filters/registry/leads.test.ts b/src/lib/filters/registry/leads.test.ts index b1f950a0..1f4ce973 100644 --- a/src/lib/filters/registry/leads.test.ts +++ b/src/lib/filters/registry/leads.test.ts @@ -116,7 +116,19 @@ describe("leads registry — legacy value-shape equivalence", () => { it("assignees: every token invalid and no 'unassigned' falls through to no filter, matching route.ts's silent no-op", () => { const b = compile(andTree(cond("c1", "assignees", "is_any_of", ["garbage"]))); - expect(b.calls).toEqual(["or(id.not.is.null)"]); + // §0 fix: dropped, not compiled to the tautology "id.not.is.null" — see + // compile.test.ts's "or(, X)" coverage for why that matters once + // this condition can land inside an OR group. + expect(b.calls).toEqual([]); + }); + + it("assignees: every token invalid, inside an OR group, drops out rather than making the group match every row (§0 fix)", () => { + const b = compile({ + conjunction: "and", + conditions: [], + groups: [{ conjunction: "or", conditions: [cond("c1", "assignees", "is_any_of", ["garbage"]), cond("c2", "status", "is", "contacted")] }], + }); + expect(b.calls).toEqual(["or(status.eq.contacted)"]); }); it("collaborators is embed-kind and is planned as the exact !inner select route.ts's selectColumns ternary builds today", () => { diff --git a/src/lib/filters/registry/leads.ts b/src/lib/filters/registry/leads.ts index 2ce6620c..c140ea19 100644 --- a/src/lib/filters/registry/leads.ts +++ b/src/lib/filters/registry/leads.ts @@ -67,7 +67,7 @@ function compileSource(cond: FilterCondition): string { // (surprising but current) fall-through-to-no-filter when every token is // invalid and "unassigned" wasn't requested. -function compileAssignees(cond: FilterCondition): string { +function compileAssignees(cond: FilterCondition): string | null { const values = asList(cond.value); const wantsUnassigned = values.includes("unassigned"); const ids = values.filter((v) => v !== "unassigned" && UUID_RE.test(v)); @@ -75,7 +75,12 @@ function compileAssignees(cond: FilterCondition): string { if (wantsUnassigned && ids.length > 0) return or("assigned_to.is.null", `assigned_to.in.(${ids.map(pgVal).join(",")})`); if (wantsUnassigned) return "assigned_to.is.null"; if (ids.length > 0) return ids.length === 1 ? `assigned_to.eq.${pgVal(ids[0])}` : `assigned_to.in.(${ids.map(pgVal).join(",")})`; - return "id.not.is.null"; // no valid tokens — legacy applies no filter in this case + // No valid tokens — legacy applies no filter in this case (route.ts's + // tri-branch has no final else). Dropping the condition (rather than + // emitting the tautology "id.not.is.null") is identical to a no-op inside + // AND, but is ALSO correct inside an OR group — a tautology there would + // make the whole group match every row. See §0 of the Phase 3 brief. + return null; } // ── location: virtual, city + country combined — no legacy equivalent ────── @@ -110,9 +115,9 @@ export function leadFields(ctx: CompileCtx): FieldRegistry { }, { key: "search", - label: "Search (name, email, phone)", + label: "Search (name, email, phone, ID)", type: "text", - source: { kind: "columns", columns: ["first_name", "last_name", "email", "phone"], fullNamePairs: true }, + source: { kind: "columns", columns: ["first_name", "last_name", "email", "phone", "display_id"], fullNamePairs: true }, group: "Basic", filterable: true, }, diff --git a/src/lib/filters/serialize.browser.test.ts b/src/lib/filters/serialize.browser.test.ts new file mode 100644 index 00000000..e21f5d4d --- /dev/null +++ b/src/lib/filters/serialize.browser.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +// +// The "Apply bug" postmortem (ADVANCED-FILTERS-BRIEF Phase 3 addendum): serialize.ts +// used `Buffer.from(...).toString("base64url")`, which throws "Unknown encoding: +// base64url" in a real browser bundle (the `buffer` npm shim webpack/Next ship there +// doesn't support the "base64url" encoding string, even though Node's real Buffer +// does). handleApply threw mid-call, before setOpen(false) ever ran — the popover +// never closed and the URL never gained ?f=. +// +// 21 tests in serialize.test.ts (vitest.config.ts: environment "node") never caught +// this because Node's real `Buffer` supports "base64url" natively — the whole bug is +// a Node-vs-browser API gap that a node-environment suite is structurally blind to. +// +// jsdom alone does NOT reproduce the gap (jsdom runs inside Node, so `Buffer` is still +// Node's real implementation) — the meaningful regression guard is deleting `Buffer` +// for the duration of this test, so any reintroduced `Buffer.from(...)` call throws +// a ReferenceError instead of silently passing because the test happened to run in +// an environment where Buffer.from(..., "base64url") works. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { encodeFilterTree, decodeFilterTree } from "./serialize"; +import type { FilterTree } from "./types"; + +describe("serialize — browser runtime (no Buffer)", () => { + const originalBuffer = globalThis.Buffer; + + beforeEach(() => { + // @ts-expect-error — simulating a real browser bundle, where Buffer either + // doesn't exist or (the actual production bug) exists but its base64url + // encoding throws. Deleting it entirely is the stricter guard: any code path + // that still references Buffer fails loudly here instead of silently passing. + delete globalThis.Buffer; + }); + + afterEach(() => { + globalThis.Buffer = originalBuffer; + }); + + it("encodeFilterTree works with no global Buffer (the exact browser-bundle gap)", () => { + const tree: FilterTree = { + conjunction: "and", + conditions: [{ id: "c1", field: "status", op: "is", value: "contacted" }], + }; + const encoded = encodeFilterTree(tree); + expect(typeof encoded).toBe("string"); + expect(encoded.length).toBeGreaterThan(0); + // base64url alphabet only — no +, /, or = padding. + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("round-trips encode -> decode with no global Buffer, including unicode", () => { + const tree: FilterTree = { + conjunction: "and", + conditions: [ + { id: "c1", field: "search", op: "contains", value: "café — 日本語" }, + { id: "c2", field: "assignees", op: "is_any_of", value: ["unassigned", "11111111-1111-4111-8111-111111111111"] }, + ], + }; + const encoded = encodeFilterTree(tree); + const decoded = decodeFilterTree(encoded); + expect(decoded).toEqual({ ok: true, tree }); + }); + + it("decodeFilterTree also works with no global Buffer, server-encoded input included", () => { + // Encode with Buffer present (server-side path), then decode with it absent — + // both directions of the "browser encodes, server decodes" contract must hold, + // and the codec must be identical either way (not two divergent implementations). + globalThis.Buffer = originalBuffer; + const tree: FilterTree = { conjunction: "and", conditions: [{ id: "c1", field: "status", op: "is", value: "new" }] }; + const encoded = encodeFilterTree(tree); + // @ts-expect-error — re-simulate the browser gap for the decode half. + delete globalThis.Buffer; + expect(decodeFilterTree(encoded)).toEqual({ ok: true, tree }); + }); +}); diff --git a/src/lib/filters/serialize.ts b/src/lib/filters/serialize.ts index 8327c588..0e98a016 100644 --- a/src/lib/filters/serialize.ts +++ b/src/lib/filters/serialize.ts @@ -14,8 +14,31 @@ export const MAX_ENCODED_LEN = 4096; export type DecodeResult = { ok: true; tree: FilterTree } | { ok: false; errors: Record }; +// Isomorphic base64url codec — Buffer.from(...).toString("base64url") is Node-only +// (the browser's Buffer shim throws "Unknown encoding: base64url"), and this module +// runs in BOTH runtimes: the browser encodes (use-advanced-filters.ts -> setTree -> +// router.replace), the server decodes (route.ts). TextEncoder/TextDecoder + btoa/atob +// are the one codec both runtimes actually implement — Node has had global btoa/atob +// since v16, browsers since forever. See the Phase 3 addendum's "Apply bug" postmortem: +// this exact call threw mid-handleApply, before setOpen(false) ever ran, so the +// popover never closed and the URL never gained ?f=. +function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(b64url: string): Uint8Array { + let base64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + while (base64.length % 4 !== 0) base64 += "="; + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + export function encodeFilterTree(tree: FilterTree): string { - return Buffer.from(JSON.stringify(tree), "utf8").toString("base64url"); + return toBase64Url(new TextEncoder().encode(JSON.stringify(tree))); } export function decodeFilterTree(raw: string): DecodeResult { @@ -32,7 +55,7 @@ export function decodeFilterTree(raw: string): DecodeResult { let json: string; try { - json = Buffer.from(raw, "base64url").toString("utf8"); + json = new TextDecoder().decode(fromBase64Url(raw)); } catch { return { ok: false, errors: { f: ["not valid base64url"] } }; } diff --git a/src/lib/filters/tree-to-aggregate-params.test.ts b/src/lib/filters/tree-to-aggregate-params.test.ts new file mode 100644 index 00000000..a89807de --- /dev/null +++ b/src/lib/filters/tree-to-aggregate-params.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { treeToAggregateParams } from "./tree-to-aggregate-params"; +import { leadFields } from "./registry/leads"; +import type { CompileCtx, FilterCondition, FilterTree } from "./types"; + +const ctx: CompileCtx = { tz: "UTC", now: new Date("2026-08-07T00:00:00Z"), industryId: null, permissions: {} }; +const registry = leadFields(ctx); +const NOW = new Date("2026-08-07T12:00:00Z"); + +function tree(conditions: FilterCondition[], extra: Partial = {}): FilterTree { + return { conjunction: "and", conditions, ...extra }; +} + +function cond(field: string, op: FilterCondition["op"], value?: FilterCondition["value"]): FilterCondition { + return { id: field, field, op, value }; +} + +describe("treeToAggregateParams", () => { + it("maps a pure-AND tree of expressible conditions", () => { + const result = treeToAggregateParams( + tree([ + cond("status", "is", "new"), + cond("assignees", "is_any_of", ["11111111-1111-1111-1111-111111111111", "unassigned"]), + cond("tags", "has_all", ["student"]), + cond("industry", "is", "software"), + cond("form", "is", "22222222-2222-2222-2222-222222222222"), + ]), + registry, + NOW + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.params).toEqual({ + status: "new", + assigneesAny: ["11111111-1111-1111-1111-111111111111"], + includeUnassigned: true, + tag: "student", + prospectIndustry: "software", + formConfigId: "22222222-2222-2222-2222-222222222222", + }); + }); + + it("maps created within_last to a createdAfter date", () => { + const result = treeToAggregateParams(tree([cond("created", "within_last", "7d")]), registry, NOW); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.params.createdAfter?.toISOString()).toBe(new Date("2026-07-31T12:00:00Z").toISOString()); + }); + + it("maps created after to a createdAfter date", () => { + const result = treeToAggregateParams(tree([cond("created", "after", "2026-01-01")]), registry, NOW); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.params.createdAfter?.toISOString()).toBe(new Date("2026-01-01").toISOString()); + }); + + it("maps collaborators is_any_of to collaboratorIds", () => { + const result = treeToAggregateParams( + tree([cond("collaborators", "is_any_of", ["33333333-3333-3333-3333-333333333333"])]), + registry, + NOW + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.params.collaboratorIds).toEqual(["33333333-3333-3333-3333-333333333333"]); + }); + + it("rejects a root OR conjunction", () => { + const result = treeToAggregateParams(tree([cond("status", "is", "new")], { conjunction: "or" }), registry, NOW); + expect(result.ok).toBe(false); + }); + + it("rejects a tree with OR sub-groups", () => { + const result = treeToAggregateParams( + tree([cond("status", "is", "new")], { groups: [{ conjunction: "or", conditions: [cond("status", "is", "contacted")] }] }), + registry, + NOW + ); + expect(result.ok).toBe(false); + }); + + it("rejects contains — no lead_aggregates() equivalent", () => { + const result = treeToAggregateParams(tree([cond("search", "contains", "jane")]), registry, NOW); + expect(result.ok).toBe(false); + }); + + it("rejects is_empty on any field", () => { + const result = treeToAggregateParams(tree([cond("industry", "is_empty")]), registry, NOW); + expect(result.ok).toBe(false); + }); + + it("rejects an unknown field", () => { + const result = treeToAggregateParams(tree([cond("phone", "is", "123")]), registry, NOW); + expect(result.ok).toBe(false); + }); + + it("rejects status is_not — no p_status_ne on the RPC", () => { + const result = treeToAggregateParams(tree([cond("status", "is_not", "new")]), registry, NOW); + expect(result.ok).toBe(false); + }); + + it("rejects a multi-tag has_all — only single-tag is expressible", () => { + const result = treeToAggregateParams(tree([cond("tags", "has_all", ["student", "vip"])]), registry, NOW); + expect(result.ok).toBe(false); + }); + + it("rejects a duplicate condition on the same aggregate axis", () => { + const result = treeToAggregateParams( + tree([cond("status", "is", "new"), cond("status", "is", "contacted")]), + registry, + NOW + ); + expect(result.ok).toBe(false); + }); + + it("returns ok:true with empty params for an empty tree", () => { + const result = treeToAggregateParams(tree([]), registry, NOW); + expect(result).toEqual({ ok: true, params: {} }); + }); +}); diff --git a/src/lib/filters/tree-to-aggregate-params.ts b/src/lib/filters/tree-to-aggregate-params.ts new file mode 100644 index 00000000..68e64e98 --- /dev/null +++ b/src/lib/filters/tree-to-aggregate-params.ts @@ -0,0 +1,182 @@ +// ADVANCED-FILTERS-BRIEF Phase 3 addendum §E — pulled forward from Phase 5. +// +// Phase 2 made the leads route skip facet counts entirely (`counts: null`) whenever +// `?f=` is present, rather than risk passing a PARTIAL translation of the tree into +// `lead_aggregates()` — which would produce a facet with subtly WRONG counts (worse +// than no counts at all). This module is the narrow, additive fix: it recognizes the +// one shape that IS safely translatable — a pure-AND tree whose every condition maps +// onto an existing `lead_aggregates()` param (migration 194) — and returns `ok: false` +// for anything else (any OR group, any operator/field the RPC can't express). +// +// Deliberately conservative. `lead_aggregates()` has a fixed, narrow param list (no +// `p_status_ne`, no `p_source_eq`, no multi-tag `p_tags_any`, no `p_created_before`) — +// every branch below either maps to an exact RPC param or falls through to `ok: false`. +// NEVER partially translate a condition (e.g. take a value and silently drop an +// operator's real semantics) — wrong counts are worse than absent counts. + +import type { FieldRegistry, FilterCondition, FilterTree } from "./types"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// "7d" | "30d" | "3m" | "1y" — same vocabulary the FilterOperator doc comment +// (types.ts) documents for within_last/within_next. +const WITHIN_LAST_RE = /^(\d+)(d|m|y)$/; + +function withinLastToDate(raw: string, now: Date): Date | null { + const match = WITHIN_LAST_RE.exec(raw); + if (!match) return null; + const amount = Number(match[1]); + const unit = match[2]; + const result = new Date(now); + if (unit === "d") result.setDate(result.getDate() - amount); + else if (unit === "m") result.setMonth(result.getMonth() - amount); + else result.setFullYear(result.getFullYear() - amount); + return result; +} + +function asList(value: FilterCondition["value"]): string[] { + if (Array.isArray(value)) return value as string[]; + return value === undefined ? [] : [String(value)]; +} + +/** The subset of SourceFacetParams (src/lib/leads/aggregates.ts) this module can + * populate. Named locally rather than imported to keep this file's dependency + * surface to `./types` only — aggregates.ts sits outside src/lib/filters/. The + * route maps these fields onto the real SourceFacetParams it already builds. */ +export interface LeadAggregateFilterParams { + status?: string; + assigneesAny?: string[]; + includeUnassigned?: boolean; + collaboratorIds?: string[]; + tag?: string; + prospectIndustry?: string; + formConfigId?: string; + createdAfter?: Date; +} + +export type TreeToAggregateParamsResult = + | { ok: true; params: LeadAggregateFilterParams } + | { ok: false; reason: string }; + +/** + * Translate a filter tree into `lead_aggregates()` params, or explain why it can't be + * done. `now` drives `within_last` — accepted as a parameter (rather than read off + * Date.now()) for the same determinism reason CompileCtx.now is injected in compile.ts. + */ +export function treeToAggregateParams( + tree: FilterTree, + registry: FieldRegistry, + now: Date +): TreeToAggregateParamsResult { + if (tree.conjunction === "or") { + return { ok: false, reason: "root conjunction is OR" }; + } + if (tree.groups && tree.groups.length > 0) { + return { ok: false, reason: "tree has OR sub-groups" }; + } + + const params: LeadAggregateFilterParams = {}; + const seenKeys = new Set(); + + const setOnce = (key: keyof LeadAggregateFilterParams, apply: () => void): TreeToAggregateParamsResult | null => { + if (seenKeys.has(key)) { + return { ok: false, reason: `duplicate condition on the same aggregate axis (${key})` }; + } + seenKeys.add(key); + apply(); + return null; + }; + + for (const cond of tree.conditions) { + const field = registry[cond.field]; + if (!field || !field.filterable) { + return { ok: false, reason: `unknown or non-filterable field "${cond.field}"` }; + } + + // Any of these are never expressible against the RPC's fixed param list, whatever + // the field — is_empty/is_not_empty/contains-family all lack a matching param. + if ( + cond.op === "is_empty" || + cond.op === "is_not_empty" || + cond.op === "contains" || + cond.op === "not_contains" || + cond.op === "starts_with" || + cond.op === "ends_with" + ) { + return { ok: false, reason: `operator "${cond.op}" has no lead_aggregates() equivalent` }; + } + + let err: TreeToAggregateParamsResult | null = null; + + switch (cond.field) { + case "status": + if (cond.op !== "is") return { ok: false, reason: `status: only "is" is expressible (got "${cond.op}")` }; + err = setOnce("status", () => { params.status = String(cond.value); }); + break; + + case "assignees": { + if (cond.op !== "is_any_of") return { ok: false, reason: `assignees: only "is_any_of" is expressible (got "${cond.op}")` }; + const values = asList(cond.value); + const ids = values.filter((v) => v !== "unassigned" && UUID_RE.test(v)); + const wantsUnassigned = values.includes("unassigned"); + err = setOnce("assigneesAny", () => { + if (ids.length > 0) params.assigneesAny = ids; + if (wantsUnassigned) params.includeUnassigned = true; + }); + break; + } + + case "collaborators": { + if (cond.op !== "is_any_of") return { ok: false, reason: `collaborators: only "is_any_of" is expressible (got "${cond.op}")` }; + const ids = asList(cond.value).filter((v) => UUID_RE.test(v)); + if (ids.length === 0) return { ok: false, reason: "collaborators: no valid uuid values" }; + err = setOnce("collaboratorIds", () => { params.collaboratorIds = ids; }); + break; + } + + case "tags": { + if (cond.op !== "has_all") return { ok: false, reason: `tags: only single-value "has_all" is expressible (got "${cond.op}")` }; + const values = asList(cond.value); + // p_tag is a single value (`tags @> ARRAY[p_tag]`) — a multi-tag has_all has + // no matching AND-of-N-tags param on the RPC. + if (values.length !== 1) return { ok: false, reason: "tags: only a single-tag has_all is expressible" }; + err = setOnce("tag", () => { params.tag = values[0]; }); + break; + } + + case "industry": + if (cond.op !== "is") return { ok: false, reason: `industry: only "is" is expressible (got "${cond.op}")` }; + err = setOnce("prospectIndustry", () => { params.prospectIndustry = String(cond.value); }); + break; + + case "form": { + if (cond.op !== "is") return { ok: false, reason: `form: only "is" is expressible (got "${cond.op}")` }; + const value = String(cond.value); + if (!UUID_RE.test(value)) return { ok: false, reason: "form: value is not a uuid" }; + err = setOnce("formConfigId", () => { params.formConfigId = value; }); + break; + } + + case "created": + case "created_at": { + if (cond.op === "after") { + err = setOnce("createdAfter", () => { params.createdAfter = new Date(String(cond.value)); }); + } else if (cond.op === "within_last") { + const resolved = withinLastToDate(String(cond.value), now); + if (!resolved) return { ok: false, reason: `created: unparseable within_last value "${String(cond.value)}"` }; + err = setOnce("createdAfter", () => { params.createdAfter = resolved; }); + } else { + return { ok: false, reason: `created: only "after"/"within_last" are expressible (got "${cond.op}")` }; + } + break; + } + + default: + return { ok: false, reason: `field "${cond.field}" has no lead_aggregates() equivalent` }; + } + + if (err) return err; + } + + return { ok: true, params }; +} diff --git a/src/lib/filters/types.ts b/src/lib/filters/types.ts index d0b82a9d..8b4732a8 100644 --- a/src/lib/filters/types.ts +++ b/src/lib/filters/types.ts @@ -96,7 +96,7 @@ export type FieldSource = | { kind: "jsonb"; column: "custom_fields"; path: string } | { kind: "promoted"; column: string; jsonb: { column: "custom_fields"; path: string } } | { kind: "embed"; relation: string; column: string; embedSelect: string } - | { kind: "virtual"; compile: (c: FilterCondition, ctx: CompileCtx) => string }; + | { kind: "virtual"; compile: (c: FilterCondition, ctx: CompileCtx) => string | null }; // Minimal local mirror of the permission shape a `visibleTo` predicate needs. // Deliberately NOT imported from src/lib/api/permissions.ts — that would break diff --git a/src/lib/filters/use-advanced-filters.ts b/src/lib/filters/use-advanced-filters.ts new file mode 100644 index 00000000..f74ad739 --- /dev/null +++ b/src/lib/filters/use-advanced-filters.ts @@ -0,0 +1,97 @@ +"use client"; + +// URL-backed advanced-filter state — the only URL-backed filter state in the +// app today is use-workspace-filters.ts (industries/it-agency/features/ +// project-board/hooks/); this hook follows its shape (useSearchParams + +// router.replace(..., { scroll: false })), but adds the Phase 1 wire format +// (base64url `?f=`) instead of one param per filter axis. +// +// A malformed or stale `?f=` must degrade with a toast, never crash — drop +// unknown field keys (a renamed/removed registry field) and keep the rest. +// MAX_ENCODED_LEN is enforced here too (client-side), not just server-side in +// decodeFilterTree, so a caller gets a real message ("save this as a view") +// instead of a silent write that the next page load then rejects. + +import { useCallback, useEffect, useMemo } from "react"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { toast } from "sonner"; +import { decodeFilterTree, encodeFilterTree, FILTER_PARAM, isEmptyTree, MAX_ENCODED_LEN } from "./serialize"; +import { EMPTY_TREE, type FieldRegistry, type FilterCondition, type FilterTree } from "./types"; + +export interface UseAdvancedFiltersResult { + tree: FilterTree; + setTree: (next: FilterTree) => void; + clear: () => void; + /** Raw `?f=` value currently on the URL, or null if absent. */ + encoded: string | null; +} + +type Degrade = "invalid" | "unknown_field" | null; + +function decodeAndDegrade(raw: string | null, registry: FieldRegistry): { tree: FilterTree; degrade: Degrade } { + if (!raw) return { tree: EMPTY_TREE, degrade: null }; + + const decoded = decodeFilterTree(raw); + if (!decoded.ok) return { tree: EMPTY_TREE, degrade: "invalid" }; + + const isKnown = (c: FilterCondition) => registry[c.field] !== undefined; + const hasUnknown = + decoded.tree.conditions.some((c) => !isKnown(c)) || (decoded.tree.groups ?? []).some((g) => g.conditions.some((c) => !isKnown(c))); + if (!hasUnknown) return { tree: decoded.tree, degrade: null }; + + return { + degrade: "unknown_field", + tree: { + conjunction: decoded.tree.conjunction, + conditions: decoded.tree.conditions.filter(isKnown), + groups: (decoded.tree.groups ?? []) + .map((g) => ({ ...g, conditions: g.conditions.filter(isKnown) })) + .filter((g) => g.conditions.length > 0), + }, + }; +} + +export function useAdvancedFilters(registry: FieldRegistry): UseAdvancedFiltersResult { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const raw = searchParams.get(FILTER_PARAM); + + // Pure — no side effects during render (the "cannot access/update refs + // during render" rule applies to any mutable state, not just useRef; a + // dedup-per-raw-value ref belongs in the effect below, not here). + const { tree, degrade } = useMemo(() => decodeAndDegrade(raw, registry), [raw, registry]); + + useEffect(() => { + if (degrade === "invalid") toast.error("Couldn't read that filter link — it was reset."); + else if (degrade === "unknown_field") toast.error("Some filters referenced fields that no longer exist and were removed."); + // Re-fires only when `raw` (or the degrade outcome) actually changes — + // effects don't re-run on unrelated re-renders, so no manual dedup needed. + }, [raw, degrade]); + + const setTree = useCallback( + (next: FilterTree) => { + const params = new URLSearchParams(searchParams.toString()); + + if (isEmptyTree(next)) { + params.delete(FILTER_PARAM); + } else { + const encoded = encodeFilterTree(next); + if (encoded.length > MAX_ENCODED_LEN) { + toast.error("This filter has too many values to fit in a link — save it as a view instead."); + return; + } + params.set(FILTER_PARAM, encoded); + } + + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, + [searchParams, router, pathname] + ); + + const clear = useCallback(() => setTree(EMPTY_TREE), [setTree]); + + return { tree, setTree, clear, encoded: raw }; +} diff --git a/src/lib/leads/aggregates.ts b/src/lib/leads/aggregates.ts index beabc363..6abc4991 100644 --- a/src/lib/leads/aggregates.ts +++ b/src/lib/leads/aggregates.ts @@ -282,7 +282,10 @@ export interface SourceFacetParams { dimension?: "intake_source" | "intake_source_part"; } -export async function getSourceFacet(params: SourceFacetParams): Promise { +/** Shared RPC call behind getSourceFacet/getAssigneeFacet — same lead_aggregates() + * round-trip, every dimension of which is computed unconditionally by the SQL + * (see migration 194) regardless of which one the caller actually reads. */ +async function fetchFacetRows(params: SourceFacetParams): Promise { const supabase = await createClient(); // Week boundaries are required params on lead_aggregates but irrelevant to the @@ -324,14 +327,47 @@ export async function getSourceFacet(params: SourceFacetParams): Promise { + const rows = await fetchFacetRows(params); const dimension = params.dimension ?? "intake_source"; - return (data as AggregateRow[]) + return rows .filter((row) => row.dimension === dimension) .map((row) => ({ name: row.key, count: Number(row.cnt) })) .sort((a, b) => b.count - a.count); } + +export interface AssigneeFacetOption { + /** A tenant_users.user_id, or the "unassigned" sentinel — translated from the + * RPC's "(unassigned)" key so callers never see the raw SQL sentinel string. */ + name: string; + count: number; +} + +/** + * Assigned-To facet — ADVANCED-FILTERS-BRIEF Phase 3 addendum §C. Same + * lead_aggregates() `counselor` dimension that already feeds LeadsByCounselorChart, + * reused here so the /leads Assigned To dropdown gets an exact, tenant-wide count + * instead of leads-table.tsx's old client-side `counselorCounts` (computed from + * `localLeads`, i.e. the current 25-row server page only — see the brief). + * + * `params.assigneesAny`/`params.includeUnassigned` must be omitted by the caller — + * the assignee axis is the one being faceted, so (per the same "every filter + * except the one being faceted" rule getSourceFacet already follows for source) + * it must not filter itself. Every other axis (status/tag/form/source/…) still + * cross-filters normally. + */ +export async function getAssigneeFacet(params: SourceFacetParams): Promise { + const rows = await fetchFacetRows(params); + return rows + .filter((row) => row.dimension === "counselor") + .map((row) => ({ name: row.key === "(unassigned)" ? "unassigned" : row.key, count: Number(row.cnt) })) + .sort((a, b) => b.count - a.count); +}