@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3278.1.a4d26475e4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/DataTable.js +149 -53
  3. package/dist/backend/DataTable.js.map +2 -2
  4. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  5. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  6. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  7. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  8. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  9. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  10. package/dist/backend/filters/FilterEmptyState.js +41 -0
  11. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  12. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  13. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  14. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  15. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  16. package/dist/backend/filters/QuickFilters.js +41 -0
  17. package/dist/backend/filters/QuickFilters.js.map +7 -0
  18. package/dist/backend/filters/filterValidation.js +47 -0
  19. package/dist/backend/filters/filterValidation.js.map +7 -0
  20. package/dist/backend/filters/treeReducer.js +159 -0
  21. package/dist/backend/filters/treeReducer.js.map +7 -0
  22. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  23. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  24. package/dist/backend/utils/api.js +18 -1
  25. package/dist/backend/utils/api.js.map +2 -2
  26. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  27. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  28. package/dist/backend/utils/useCurrentUserId.js +31 -0
  29. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  30. package/dist/primitives/dialog.js +7 -5
  31. package/dist/primitives/dialog.js.map +2 -2
  32. package/dist/primitives/tag.js +4 -2
  33. package/dist/primitives/tag.js.map +2 -2
  34. package/package.json +3 -3
  35. package/src/backend/DataTable.tsx +307 -63
  36. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  37. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  38. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  39. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  40. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  41. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  42. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  43. package/src/backend/filters/QuickFilters.tsx +62 -0
  44. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  45. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  46. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  47. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  48. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  49. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  50. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  51. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  52. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  53. package/src/backend/filters/filterValidation.ts +61 -0
  54. package/src/backend/filters/treeReducer.ts +201 -0
  55. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  56. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  57. package/src/backend/utils/api.ts +27 -2
  58. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  59. package/src/backend/utils/useCurrentUserId.ts +40 -0
  60. package/src/primitives/__tests__/zindex-overlay.test.tsx +22 -0
  61. package/src/primitives/dialog.tsx +18 -7
  62. package/src/primitives/tag.tsx +3 -1
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { Filter, Plus } from "lucide-react";
4
+ import { Button } from "../../primitives/button.js";
5
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
6
+ function FilterEmptyState({ onAddCondition, addConditionRef, aiSlot, quickFilters }) {
7
+ const t = useT();
8
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-stretch gap-4 p-2", "data-testid": "filter-empty-state", children: [
9
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center text-center py-6 gap-3", children: [
10
+ /* @__PURE__ */ jsx("div", { className: "flex size-14 items-center justify-center rounded-full bg-brand-violet/10", children: /* @__PURE__ */ jsx(Filter, { className: "size-6 text-brand-violet" }) }),
11
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
12
+ /* @__PURE__ */ jsx("div", { className: "text-base font-semibold", children: t("ui.advancedFilter.empty.title", "No filters applied") }),
13
+ /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground max-w-md", children: t("ui.advancedFilter.empty.subtitle", "Add a condition to narrow down the list, or describe what you want in natural language.") })
14
+ ] }),
15
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
16
+ /* @__PURE__ */ jsxs(
17
+ Button,
18
+ {
19
+ type: "button",
20
+ ref: addConditionRef,
21
+ onClick: onAddCondition,
22
+ className: "bg-brand-violet text-brand-violet-foreground hover:bg-brand-violet/90",
23
+ children: [
24
+ /* @__PURE__ */ jsx(Plus, { className: "size-4" }),
25
+ t("ui.advancedFilter.empty.addCondition", "Add condition")
26
+ ]
27
+ }
28
+ ),
29
+ aiSlot
30
+ ] })
31
+ ] }),
32
+ quickFilters ? /* @__PURE__ */ jsxs(Fragment, { children: [
33
+ /* @__PURE__ */ jsx("div", { className: "border-t border-border" }),
34
+ quickFilters
35
+ ] }) : null
36
+ ] });
37
+ }
38
+ export {
39
+ FilterEmptyState
40
+ };
41
+ //# sourceMappingURL=FilterEmptyState.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/filters/FilterEmptyState.tsx"],
4
+ "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { Filter, Plus } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type FilterEmptyStateProps = {\n onAddCondition: () => void\n addConditionRef: React.RefObject<HTMLButtonElement | null>\n aiSlot?: React.ReactNode\n quickFilters?: React.ReactNode\n}\n\nexport function FilterEmptyState({ onAddCondition, addConditionRef, aiSlot, quickFilters }: FilterEmptyStateProps) {\n const t = useT()\n return (\n <div className=\"flex flex-col items-stretch gap-4 p-2\" data-testid=\"filter-empty-state\">\n <div className=\"flex flex-col items-center text-center py-6 gap-3\">\n <div className=\"flex size-14 items-center justify-center rounded-full bg-brand-violet/10\">\n <Filter className=\"size-6 text-brand-violet\" />\n </div>\n <div className=\"flex flex-col gap-1\">\n <div className=\"text-base font-semibold\">\n {t('ui.advancedFilter.empty.title', 'No filters applied')}\n </div>\n <div className=\"text-sm text-muted-foreground max-w-md\">\n {t('ui.advancedFilter.empty.subtitle', 'Add a condition to narrow down the list, or describe what you want in natural language.')}\n </div>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button\n type=\"button\"\n ref={addConditionRef}\n onClick={onAddCondition}\n className=\"bg-brand-violet text-brand-violet-foreground hover:bg-brand-violet/90\"\n >\n <Plus className=\"size-4\" />\n {t('ui.advancedFilter.empty.addCondition', 'Add condition')}\n </Button>\n {aiSlot}\n </div>\n </div>\n {quickFilters ? (\n <>\n <div className=\"border-t border-border\" />\n {quickFilters}\n </>\n ) : null}\n </div>\n )\n}\n"],
5
+ "mappings": ";AAmBU,SAwBF,UAxBE,KAEF,YAFE;AAjBV,SAAS,QAAQ,YAAY;AAC7B,SAAS,cAAc;AACvB,SAAS,YAAY;AASd,SAAS,iBAAiB,EAAE,gBAAgB,iBAAiB,QAAQ,aAAa,GAA0B;AACjH,QAAM,IAAI,KAAK;AACf,SACE,qBAAC,SAAI,WAAU,yCAAwC,eAAY,sBACjE;AAAA,yBAAC,SAAI,WAAU,qDACb;AAAA,0BAAC,SAAI,WAAU,4EACb,8BAAC,UAAO,WAAU,4BAA2B,GAC/C;AAAA,MACA,qBAAC,SAAI,WAAU,uBACb;AAAA,4BAAC,SAAI,WAAU,2BACZ,YAAE,iCAAiC,oBAAoB,GAC1D;AAAA,QACA,oBAAC,SAAI,WAAU,0CACZ,YAAE,oCAAoC,yFAAyF,GAClI;AAAA,SACF;AAAA,MACA,qBAAC,SAAI,WAAU,2BACb;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,KAAK;AAAA,YACL,SAAS;AAAA,YACT,WAAU;AAAA,YAEV;AAAA,kCAAC,QAAK,WAAU,UAAS;AAAA,cACxB,EAAE,wCAAwC,eAAe;AAAA;AAAA;AAAA,QAC5D;AAAA,QACC;AAAA,SACH;AAAA,OACF;AAAA,IACC,eACC,iCACE;AAAA,0BAAC,SAAI,WAAU,0BAAyB;AAAA,MACvC;AAAA,OACH,IACE;AAAA,KACN;AAEJ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,148 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter } from "lucide-react";
5
+ import { Popover, PopoverContent, PopoverAnchor } from "../../primitives/popover.js";
6
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
7
+ const TYPE_ICON = {
8
+ text: ALargeSmall,
9
+ select: Activity,
10
+ tags: Tag,
11
+ date: Calendar,
12
+ number: Hash,
13
+ boolean: ArrowRight
14
+ };
15
+ const NAMED_ICONS = {
16
+ "user-round": UserRound,
17
+ mail: Mail,
18
+ phone: Phone,
19
+ tag: Tag,
20
+ filter: Filter
21
+ };
22
+ function resolveIcon(field) {
23
+ if (field.iconName && NAMED_ICONS[field.iconName]) return NAMED_ICONS[field.iconName];
24
+ return TYPE_ICON[field.type] ?? ALargeSmall;
25
+ }
26
+ function FilterFieldPicker({ fields, open, onOpenChange, onSelect, triggerRef }) {
27
+ const t = useT();
28
+ const [query, setQuery] = React.useState("");
29
+ const [activeIdx, setActiveIdx] = React.useState(0);
30
+ const searchRef = React.useRef(null);
31
+ React.useEffect(() => {
32
+ if (open) {
33
+ setQuery("");
34
+ setActiveIdx(0);
35
+ searchRef.current?.focus();
36
+ }
37
+ }, [open]);
38
+ const filtered = React.useMemo(() => {
39
+ if (!query.trim()) return fields;
40
+ const q = query.trim().toLowerCase();
41
+ return fields.filter((f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q));
42
+ }, [fields, query]);
43
+ const grouped = React.useMemo(() => {
44
+ const map = /* @__PURE__ */ new Map();
45
+ const order = [];
46
+ for (const f of filtered) {
47
+ const g = f.group ?? "More";
48
+ if (!map.has(g)) {
49
+ map.set(g, []);
50
+ order.push(g);
51
+ }
52
+ map.get(g).push(f);
53
+ }
54
+ return order.map((g) => ({ group: g, items: map.get(g) }));
55
+ }, [filtered]);
56
+ const flatVisible = React.useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
57
+ React.useEffect(() => {
58
+ if (!open) return;
59
+ function onKey(e) {
60
+ if (e.key === "Escape") {
61
+ onOpenChange(false);
62
+ return;
63
+ }
64
+ if (e.key === "ArrowDown") {
65
+ e.preventDefault();
66
+ setActiveIdx((i) => Math.min(i + 1, flatVisible.length - 1));
67
+ }
68
+ if (e.key === "ArrowUp") {
69
+ e.preventDefault();
70
+ setActiveIdx((i) => Math.max(i - 1, 0));
71
+ }
72
+ if (e.key === "Enter") {
73
+ e.preventDefault();
74
+ const f = flatVisible[activeIdx];
75
+ if (f) {
76
+ onSelect(f);
77
+ onOpenChange(false);
78
+ }
79
+ }
80
+ if (e.key === "/" && document.activeElement !== searchRef.current) {
81
+ e.preventDefault();
82
+ searchRef.current?.focus();
83
+ }
84
+ }
85
+ window.addEventListener("keydown", onKey);
86
+ return () => window.removeEventListener("keydown", onKey);
87
+ }, [open, flatVisible, activeIdx, onSelect, onOpenChange]);
88
+ return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange, children: [
89
+ /* @__PURE__ */ jsx(PopoverAnchor, { virtualRef: triggerRef }),
90
+ /* @__PURE__ */ jsx(PopoverContent, { className: "w-80 p-0", align: "start", "data-advanced-filter-portal": "", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
91
+ /* @__PURE__ */ jsxs("div", { className: "relative p-2 border-b border-border", children: [
92
+ /* @__PURE__ */ jsx(Search, { className: "absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" }),
93
+ /* @__PURE__ */ jsx(
94
+ "input",
95
+ {
96
+ ref: searchRef,
97
+ type: "text",
98
+ value: query,
99
+ onChange: (e) => {
100
+ setQuery(e.target.value);
101
+ setActiveIdx(0);
102
+ },
103
+ placeholder: t("ui.advancedFilter.fieldPicker.search", "Search field\u2026"),
104
+ className: "h-8 w-full rounded-md border border-input bg-background pl-8 pr-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50",
105
+ "aria-label": t("ui.advancedFilter.fieldPicker.search", "Search field\u2026")
106
+ }
107
+ )
108
+ ] }),
109
+ /* @__PURE__ */ jsx("div", { className: "max-h-[400px] overflow-y-auto py-1", role: "listbox", children: grouped.map(({ group, items }) => /* @__PURE__ */ jsxs("div", { children: [
110
+ /* @__PURE__ */ jsx("div", { className: "px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground", children: group }),
111
+ items.map((f) => {
112
+ const flatIdx = flatVisible.indexOf(f);
113
+ const Icon = resolveIcon(f);
114
+ const active = flatIdx === activeIdx;
115
+ return (
116
+ // Raw <button> required: needs role="option" inside the listbox, which <Button>
117
+ // would override (Button forces role="button"). a11y semantics take precedence
118
+ // over the primitive contract for listbox items.
119
+ /* @__PURE__ */ jsxs(
120
+ "button",
121
+ {
122
+ type: "button",
123
+ role: "option",
124
+ "aria-selected": active,
125
+ onClick: () => {
126
+ onSelect(f);
127
+ onOpenChange(false);
128
+ },
129
+ onMouseEnter: () => setActiveIdx(flatIdx),
130
+ className: `flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent ${active ? "bg-secondary" : ""}`,
131
+ children: [
132
+ /* @__PURE__ */ jsx(Icon, { className: "size-4 text-muted-foreground" }),
133
+ /* @__PURE__ */ jsx("span", { children: f.label })
134
+ ]
135
+ },
136
+ f.key
137
+ )
138
+ );
139
+ })
140
+ ] }, group)) }),
141
+ /* @__PURE__ */ jsx("div", { className: "px-3 py-2 border-t border-border text-xs text-muted-foreground", children: t("ui.advancedFilter.fieldPicker.hint", "Tip: Type to search, \u2191\u2193 to navigate, Enter to select") })
142
+ ] }) })
143
+ ] });
144
+ }
145
+ export {
146
+ FilterFieldPicker
147
+ };
148
+ //# sourceMappingURL=FilterFieldPicker.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/filters/FilterFieldPicker.tsx"],
4
+ "sourcesContent": ["// packages/ui/src/backend/filters/FilterFieldPicker.tsx\n'use client'\nimport * as React from 'react'\nimport { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter, type LucideIcon } from 'lucide-react'\nimport { Popover, PopoverContent, PopoverAnchor } from '../../primitives/popover'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { FilterFieldDef, FilterFieldType } from '@open-mercato/shared/lib/query/advanced-filter'\n\nconst TYPE_ICON: Record<FilterFieldType, LucideIcon> = {\n text: ALargeSmall,\n select: Activity,\n tags: Tag,\n date: Calendar,\n number: Hash,\n boolean: ArrowRight,\n}\n\nconst NAMED_ICONS: Record<string, LucideIcon> = {\n 'user-round': UserRound,\n mail: Mail,\n phone: Phone,\n tag: Tag,\n filter: Filter,\n}\n\nfunction resolveIcon(field: FilterFieldDef): LucideIcon {\n if (field.iconName && NAMED_ICONS[field.iconName]) return NAMED_ICONS[field.iconName]\n return TYPE_ICON[field.type] ?? ALargeSmall\n}\n\nexport type FilterFieldPickerProps = {\n fields: FilterFieldDef[]\n open: boolean\n onOpenChange: (open: boolean) => void\n onSelect: (field: FilterFieldDef) => void\n triggerRef: React.RefObject<HTMLElement | null>\n}\n\nexport function FilterFieldPicker({ fields, open, onOpenChange, onSelect, triggerRef }: FilterFieldPickerProps) {\n const t = useT()\n const [query, setQuery] = React.useState('')\n const [activeIdx, setActiveIdx] = React.useState(0)\n const searchRef = React.useRef<HTMLInputElement>(null)\n\n React.useEffect(() => { if (open) { setQuery(''); setActiveIdx(0); searchRef.current?.focus() } }, [open])\n\n const filtered = React.useMemo(() => {\n if (!query.trim()) return fields\n const q = query.trim().toLowerCase()\n return fields.filter(f => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q))\n }, [fields, query])\n\n const grouped = React.useMemo(() => {\n const map = new Map<string, FilterFieldDef[]>()\n const order: string[] = []\n for (const f of filtered) {\n const g = f.group ?? 'More'\n if (!map.has(g)) { map.set(g, []); order.push(g) }\n map.get(g)!.push(f)\n }\n return order.map(g => ({ group: g, items: map.get(g)! }))\n }, [filtered])\n\n const flatVisible = React.useMemo(() => grouped.flatMap(g => g.items), [grouped])\n\n React.useEffect(() => {\n if (!open) return\n function onKey(e: KeyboardEvent) {\n if (e.key === 'Escape') { onOpenChange(false); return }\n if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, flatVisible.length - 1)) }\n if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, 0)) }\n if (e.key === 'Enter') {\n e.preventDefault()\n const f = flatVisible[activeIdx]\n if (f) { onSelect(f); onOpenChange(false) }\n }\n if (e.key === '/' && document.activeElement !== searchRef.current) {\n e.preventDefault(); searchRef.current?.focus()\n }\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [open, flatVisible, activeIdx, onSelect, onOpenChange])\n\n return (\n <Popover open={open} onOpenChange={onOpenChange}>\n {/* Radix expects RefObject<Measurable>; our prop accepts null. Narrow cast covers the React 19 \u2194 Radix variance. */}\n <PopoverAnchor virtualRef={triggerRef as React.RefObject<{ getBoundingClientRect(): DOMRect }>} />\n <PopoverContent className=\"w-80 p-0\" align=\"start\" data-advanced-filter-portal=\"\">\n <div className=\"flex flex-col\">\n <div className=\"relative p-2 border-b border-border\">\n <Search className=\"absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\" />\n <input\n ref={searchRef}\n type=\"text\"\n value={query}\n onChange={(e) => { setQuery(e.target.value); setActiveIdx(0) }}\n placeholder={t('ui.advancedFilter.fieldPicker.search', 'Search field\u2026')}\n className=\"h-8 w-full rounded-md border border-input bg-background pl-8 pr-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50\"\n aria-label={t('ui.advancedFilter.fieldPicker.search', 'Search field\u2026')}\n />\n </div>\n <div className=\"max-h-[400px] overflow-y-auto py-1\" role=\"listbox\">\n {grouped.map(({ group, items }) => (\n <div key={group}>\n <div className=\"px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground\">{group}</div>\n {items.map((f) => {\n const flatIdx = flatVisible.indexOf(f)\n const Icon = resolveIcon(f)\n const active = flatIdx === activeIdx\n return (\n // Raw <button> required: needs role=\"option\" inside the listbox, which <Button>\n // would override (Button forces role=\"button\"). a11y semantics take precedence\n // over the primitive contract for listbox items.\n <button\n key={f.key}\n type=\"button\"\n role=\"option\"\n aria-selected={active}\n onClick={() => { onSelect(f); onOpenChange(false) }}\n onMouseEnter={() => setActiveIdx(flatIdx)}\n className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent ${active ? 'bg-secondary' : ''}`}\n >\n <Icon className=\"size-4 text-muted-foreground\" />\n <span>{f.label}</span>\n </button>\n )\n })}\n </div>\n ))}\n </div>\n <div className=\"px-3 py-2 border-t border-border text-xs text-muted-foreground\">\n {t('ui.advancedFilter.fieldPicker.hint', 'Tip: Type to search, \u2191\u2193 to navigate, Enter to select')}\n </div>\n </div>\n </PopoverContent>\n </Popover>\n )\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { SearchX, Undo2 } from "lucide-react";
4
+ import { Button } from "../../primitives/button.js";
5
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
6
+ function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast }) {
7
+ const t = useT();
8
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-4 py-12", "data-testid": "filtered-empty-results", children: [
9
+ /* @__PURE__ */ jsx("div", { className: "flex size-14 items-center justify-center rounded-full bg-muted", children: /* @__PURE__ */ jsx(SearchX, { className: "size-6 text-muted-foreground" }) }),
10
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-1", children: [
11
+ /* @__PURE__ */ jsx("div", { className: "text-base font-semibold", children: t("ui.advancedFilter.empty.noMatches", "No {entity} match these filters", { entity: entityNamePlural }) }),
12
+ /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground max-w-md text-center", children: t("ui.advancedFilter.empty.tryRemoving", "Try removing the most restrictive filter, or clear all filters to see everyone.") })
13
+ ] }),
14
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
15
+ /* @__PURE__ */ jsx(Button, { type: "button", onClick: onClearAll, children: t("ui.advancedFilter.empty.clearAll", "Clear all filters") }),
16
+ /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", disabled: !canRemoveLast, onClick: onRemoveLast, children: [
17
+ /* @__PURE__ */ jsx(Undo2, { className: "size-4" }),
18
+ t("ui.advancedFilter.empty.removeLast", "Remove last filter")
19
+ ] })
20
+ ] })
21
+ ] });
22
+ }
23
+ export {
24
+ FilteredEmptyResults
25
+ };
26
+ //# sourceMappingURL=FilteredEmptyResults.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/filters/FilteredEmptyResults.tsx"],
4
+ "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { SearchX, Undo2 } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type FilteredEmptyResultsProps = {\n entityNamePlural: string\n canRemoveLast: boolean\n onClearAll: () => void\n onRemoveLast: () => void\n}\n\nexport function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast }: FilteredEmptyResultsProps) {\n const t = useT()\n return (\n <div className=\"flex flex-col items-center justify-center gap-4 py-12\" data-testid=\"filtered-empty-results\">\n <div className=\"flex size-14 items-center justify-center rounded-full bg-muted\">\n <SearchX className=\"size-6 text-muted-foreground\" />\n </div>\n <div className=\"flex flex-col items-center gap-1\">\n <div className=\"text-base font-semibold\">\n {t('ui.advancedFilter.empty.noMatches', 'No {entity} match these filters', { entity: entityNamePlural })}\n </div>\n <div className=\"text-sm text-muted-foreground max-w-md text-center\">\n {t('ui.advancedFilter.empty.tryRemoving', 'Try removing the most restrictive filter, or clear all filters to see everyone.')}\n </div>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button type=\"button\" onClick={onClearAll}>\n {t('ui.advancedFilter.empty.clearAll', 'Clear all filters')}\n </Button>\n <Button type=\"button\" variant=\"outline\" disabled={!canRemoveLast} onClick={onRemoveLast}>\n <Undo2 className=\"size-4\" />\n {t('ui.advancedFilter.empty.removeLast', 'Remove last filter')}\n </Button>\n </div>\n </div>\n )\n}\n"],
5
+ "mappings": ";AAkBQ,cAEF,YAFE;AAhBR,SAAS,SAAS,aAAa;AAC/B,SAAS,cAAc;AACvB,SAAS,YAAY;AASd,SAAS,qBAAqB,EAAE,kBAAkB,eAAe,YAAY,aAAa,GAA8B;AAC7H,QAAM,IAAI,KAAK;AACf,SACE,qBAAC,SAAI,WAAU,yDAAwD,eAAY,0BACjF;AAAA,wBAAC,SAAI,WAAU,kEACb,8BAAC,WAAQ,WAAU,gCAA+B,GACpD;AAAA,IACA,qBAAC,SAAI,WAAU,oCACb;AAAA,0BAAC,SAAI,WAAU,2BACZ,YAAE,qCAAqC,mCAAmC,EAAE,QAAQ,iBAAiB,CAAC,GACzG;AAAA,MACA,oBAAC,SAAI,WAAU,sDACZ,YAAE,uCAAuC,iFAAiF,GAC7H;AAAA,OACF;AAAA,IACA,qBAAC,SAAI,WAAU,2BACb;AAAA,0BAAC,UAAO,MAAK,UAAS,SAAS,YAC5B,YAAE,oCAAoC,mBAAmB,GAC5D;AAAA,MACA,qBAAC,UAAO,MAAK,UAAS,SAAQ,WAAU,UAAU,CAAC,eAAe,SAAS,cACzE;AAAA,4BAAC,SAAM,WAAU,UAAS;AAAA,QACzB,EAAE,sCAAsC,oBAAoB;AAAA,SAC/D;AAAA,OACF;AAAA,KACF;AAEJ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Clock, Filter } from "lucide-react";
5
+ import { Button } from "../../primitives/button.js";
6
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
7
+ const ICONS = { clock: Clock, filter: Filter };
8
+ function QuickFilters({ presets, userId, onApply }) {
9
+ const t = useT();
10
+ const availablePresets = React.useMemo(
11
+ () => presets.filter((preset) => !preset.requiresUser || userId.trim().length > 0),
12
+ [presets, userId]
13
+ );
14
+ if (!availablePresets.length) return null;
15
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
16
+ /* @__PURE__ */ jsx("div", { className: "text-overline font-medium uppercase tracking-widest text-muted-foreground", children: t("ui.advancedFilter.quickFilters.heading", "Quick filters") }),
17
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: availablePresets.map((p) => {
18
+ const Icon = p.iconName ? ICONS[p.iconName] ?? Filter : Filter;
19
+ return /* @__PURE__ */ jsxs(
20
+ Button,
21
+ {
22
+ type: "button",
23
+ variant: "outline",
24
+ size: "sm",
25
+ className: "rounded-full h-8 px-3 gap-1.5",
26
+ onClick: () => onApply(p.build({ userId, now: /* @__PURE__ */ new Date() }), p),
27
+ children: [
28
+ /* @__PURE__ */ jsx(Icon, { className: "size-3.5 text-muted-foreground" }),
29
+ t(p.labelKey, p.labelKey)
30
+ ]
31
+ },
32
+ p.id
33
+ );
34
+ }) }),
35
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("ui.advancedFilter.quickFilters.hint", "Click a quick filter to apply it instantly. Editable after applying.") })
36
+ ] });
37
+ }
38
+ export {
39
+ QuickFilters
40
+ };
41
+ //# sourceMappingURL=QuickFilters.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/filters/QuickFilters.tsx"],
4
+ "sourcesContent": ["// packages/ui/src/backend/filters/QuickFilters.tsx\n'use client'\nimport * as React from 'react'\nimport { Clock, Filter, type LucideIcon } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'\n\nexport type FilterPreset = {\n id: string\n labelKey: string\n iconName?: string\n requiresUser?: boolean\n build: (ctx: { userId: string; now: Date }) => AdvancedFilterTree\n}\n\nconst ICONS: Record<string, LucideIcon> = { clock: Clock, filter: Filter }\n\nexport type QuickFiltersProps = {\n presets: FilterPreset[]\n userId: string\n onApply: (tree: AdvancedFilterTree, preset: FilterPreset) => void\n}\n\nexport function QuickFilters({ presets, userId, onApply }: QuickFiltersProps) {\n const t = useT()\n const availablePresets = React.useMemo(\n () => presets.filter((preset) => !preset.requiresUser || userId.trim().length > 0),\n [presets, userId],\n )\n if (!availablePresets.length) return null\n return (\n <div className=\"flex flex-col gap-2\">\n <div className=\"text-overline font-medium uppercase tracking-widest text-muted-foreground\">\n {t('ui.advancedFilter.quickFilters.heading', 'Quick filters')}\n </div>\n <div className=\"flex flex-wrap gap-2\">\n {availablePresets.map((p) => {\n const Icon = p.iconName ? ICONS[p.iconName] ?? Filter : Filter\n return (\n <Button\n key={p.id}\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"rounded-full h-8 px-3 gap-1.5\"\n onClick={() => onApply(p.build({ userId, now: new Date() }), p)}\n >\n <Icon className=\"size-3.5 text-muted-foreground\" />\n {t(p.labelKey, p.labelKey)}\n </Button>\n )\n })}\n </div>\n {/* Footer hint mirrors the Figma SPEC-048 empty state \u2014 explains that\n presets apply immediately and remain editable. */}\n <p className=\"text-xs text-muted-foreground\">\n {t('ui.advancedFilter.quickFilters.hint', 'Click a quick filter to apply it instantly. Editable after applying.')}\n </p>\n </div>\n )\n}\n"],
5
+ "mappings": ";AAiCM,cAOM,YAPN;AA/BN,YAAY,WAAW;AACvB,SAAS,OAAO,cAA+B;AAC/C,SAAS,cAAc;AACvB,SAAS,YAAY;AAWrB,MAAM,QAAoC,EAAE,OAAO,OAAO,QAAQ,OAAO;AAQlE,SAAS,aAAa,EAAE,SAAS,QAAQ,QAAQ,GAAsB;AAC5E,QAAM,IAAI,KAAK;AACf,QAAM,mBAAmB,MAAM;AAAA,IAC7B,MAAM,QAAQ,OAAO,CAAC,WAAW,CAAC,OAAO,gBAAgB,OAAO,KAAK,EAAE,SAAS,CAAC;AAAA,IACjF,CAAC,SAAS,MAAM;AAAA,EAClB;AACA,MAAI,CAAC,iBAAiB,OAAQ,QAAO;AACrC,SACE,qBAAC,SAAI,WAAU,uBACb;AAAA,wBAAC,SAAI,WAAU,6EACZ,YAAE,0CAA0C,eAAe,GAC9D;AAAA,IACA,oBAAC,SAAI,WAAU,wBACZ,2BAAiB,IAAI,CAAC,MAAM;AAC3B,YAAM,OAAO,EAAE,WAAW,MAAM,EAAE,QAAQ,KAAK,SAAS;AACxD,aACE;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,SAAQ;AAAA,UACR,MAAK;AAAA,UACL,WAAU;AAAA,UACV,SAAS,MAAM,QAAQ,EAAE,MAAM,EAAE,QAAQ,KAAK,oBAAI,KAAK,EAAE,CAAC,GAAG,CAAC;AAAA,UAE9D;AAAA,gCAAC,QAAK,WAAU,kCAAiC;AAAA,YAChD,EAAE,EAAE,UAAU,EAAE,QAAQ;AAAA;AAAA;AAAA,QARpB,EAAE;AAAA,MAST;AAAA,IAEJ,CAAC,GACH;AAAA,IAGA,oBAAC,OAAE,WAAU,iCACV,YAAE,uCAAuC,sEAAsE,GAClH;AAAA,KACF;AAEJ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,47 @@
1
+ import { isValuelessOperator } from "@open-mercato/shared/lib/query/advanced-filter";
2
+ const MULTI_VALUE_OPS = /* @__PURE__ */ new Set(["is_any_of", "is_none_of", "has_any_of", "has_all_of", "has_none_of"]);
3
+ function isBlank(value) {
4
+ if (value == null) return true;
5
+ if (typeof value === "string") return value.trim().length === 0;
6
+ return false;
7
+ }
8
+ function validateRule(rule) {
9
+ if (isValuelessOperator(rule.operator)) return null;
10
+ const op = rule.operator;
11
+ if (op === "between") {
12
+ if (!Array.isArray(rule.value) || rule.value.length !== 2 || isBlank(rule.value[0]) || isBlank(rule.value[1])) {
13
+ return { ruleId: rule.id, messageKey: "ui.advancedFilter.error.betweenIncomplete", message: "Pick both endpoints" };
14
+ }
15
+ return null;
16
+ }
17
+ if (MULTI_VALUE_OPS.has(op)) {
18
+ if (!Array.isArray(rule.value) || rule.value.length === 0) {
19
+ return { ruleId: rule.id, messageKey: "ui.advancedFilter.error.missingValue", message: "Pick at least one value" };
20
+ }
21
+ return null;
22
+ }
23
+ if (isBlank(rule.value)) {
24
+ return { ruleId: rule.id, messageKey: "ui.advancedFilter.error.missingValue", message: "Pick a value to apply this filter" };
25
+ }
26
+ return null;
27
+ }
28
+ function walkGroup(group, acc) {
29
+ for (const child of group.children) {
30
+ if (child.type === "rule") {
31
+ const err = validateRule(child);
32
+ if (err) acc.push(err);
33
+ } else {
34
+ walkGroup(child, acc);
35
+ }
36
+ }
37
+ }
38
+ function validateTreeForApply(tree, fields) {
39
+ void fields;
40
+ const errors = [];
41
+ walkGroup(tree.root, errors);
42
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
43
+ }
44
+ export {
45
+ validateTreeForApply
46
+ };
47
+ //# sourceMappingURL=filterValidation.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/filters/filterValidation.ts"],
4
+ "sourcesContent": ["// packages/ui/src/backend/filters/filterValidation.ts\nimport type { AdvancedFilterTree, FilterRule, FilterGroup } from '@open-mercato/shared/lib/query/advanced-filter-tree'\nimport type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'\nimport { isValuelessOperator } from '@open-mercato/shared/lib/query/advanced-filter'\n\nexport type ValidationError = {\n ruleId: string\n messageKey: string\n message: string\n}\n\nexport type ValidationResult =\n | { ok: true }\n | { ok: false; errors: ValidationError[] }\n\nconst MULTI_VALUE_OPS = new Set(['is_any_of', 'is_none_of', 'has_any_of', 'has_all_of', 'has_none_of'])\n\nfunction isBlank(value: unknown): boolean {\n if (value == null) return true\n if (typeof value === 'string') return value.trim().length === 0\n return false\n}\n\nfunction validateRule(rule: FilterRule): ValidationError | null {\n if (isValuelessOperator(rule.operator)) return null\n const op = rule.operator\n if (op === 'between') {\n if (!Array.isArray(rule.value) || rule.value.length !== 2 || isBlank(rule.value[0]) || isBlank(rule.value[1])) {\n return { ruleId: rule.id, messageKey: 'ui.advancedFilter.error.betweenIncomplete', message: 'Pick both endpoints' }\n }\n return null\n }\n if (MULTI_VALUE_OPS.has(op)) {\n if (!Array.isArray(rule.value) || rule.value.length === 0) {\n return { ruleId: rule.id, messageKey: 'ui.advancedFilter.error.missingValue', message: 'Pick at least one value' }\n }\n return null\n }\n if (isBlank(rule.value)) {\n return { ruleId: rule.id, messageKey: 'ui.advancedFilter.error.missingValue', message: 'Pick a value to apply this filter' }\n }\n return null\n}\n\nfunction walkGroup(group: FilterGroup, acc: ValidationError[]): void {\n for (const child of group.children) {\n if (child.type === 'rule') {\n const err = validateRule(child)\n if (err) acc.push(err)\n } else {\n walkGroup(child, acc)\n }\n }\n}\n\nexport function validateTreeForApply(tree: AdvancedFilterTree, fields: FilterFieldDef[]): ValidationResult {\n void fields\n const errors: ValidationError[] = []\n walkGroup(tree.root, errors)\n return errors.length === 0 ? { ok: true } : { ok: false, errors }\n}\n"],
5
+ "mappings": "AAGA,SAAS,2BAA2B;AAYpC,MAAM,kBAAkB,oBAAI,IAAI,CAAC,aAAa,cAAc,cAAc,cAAc,aAAa,CAAC;AAEtG,SAAS,QAAQ,OAAyB;AACxC,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,OAAO,UAAU,SAAU,QAAO,MAAM,KAAK,EAAE,WAAW;AAC9D,SAAO;AACT;AAEA,SAAS,aAAa,MAA0C;AAC9D,MAAI,oBAAoB,KAAK,QAAQ,EAAG,QAAO;AAC/C,QAAM,KAAK,KAAK;AAChB,MAAI,OAAO,WAAW;AACpB,QAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,KAAK,KAAK,MAAM,WAAW,KAAK,QAAQ,KAAK,MAAM,CAAC,CAAC,KAAK,QAAQ,KAAK,MAAM,CAAC,CAAC,GAAG;AAC7G,aAAO,EAAE,QAAQ,KAAK,IAAI,YAAY,6CAA6C,SAAS,sBAAsB;AAAA,IACpH;AACA,WAAO;AAAA,EACT;AACA,MAAI,gBAAgB,IAAI,EAAE,GAAG;AAC3B,QAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,KAAK,KAAK,MAAM,WAAW,GAAG;AACzD,aAAO,EAAE,QAAQ,KAAK,IAAI,YAAY,wCAAwC,SAAS,0BAA0B;AAAA,IACnH;AACA,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,KAAK,KAAK,GAAG;AACvB,WAAO,EAAE,QAAQ,KAAK,IAAI,YAAY,wCAAwC,SAAS,oCAAoC;AAAA,EAC7H;AACA,SAAO;AACT;AAEA,SAAS,UAAU,OAAoB,KAA8B;AACnE,aAAW,SAAS,MAAM,UAAU;AAClC,QAAI,MAAM,SAAS,QAAQ;AACzB,YAAM,MAAM,aAAa,KAAK;AAC9B,UAAI,IAAK,KAAI,KAAK,GAAG;AAAA,IACvB,OAAO;AACL,gBAAU,OAAO,GAAG;AAAA,IACtB;AAAA,EACF;AACF;AAEO,SAAS,qBAAqB,MAA0B,QAA4C;AACzG,OAAK;AACL,QAAM,SAA4B,CAAC;AACnC,YAAU,KAAK,MAAM,MAAM;AAC3B,SAAO,OAAO,WAAW,IAAI,EAAE,IAAI,KAAK,IAAI,EAAE,IAAI,OAAO,OAAO;AAClE;",
6
+ "names": []
7
+ }
@@ -0,0 +1,159 @@
1
+ import {
2
+ validateTreeLimits,
3
+ TREE_LIMITS
4
+ } from "@open-mercato/shared/lib/query/advanced-filter-tree";
5
+ let addedAtCounter = 0;
6
+ function nextAddedAt() {
7
+ addedAtCounter += 1;
8
+ return addedAtCounter;
9
+ }
10
+ function emptyRule(defaultField, defaultOperator) {
11
+ return {
12
+ id: crypto.randomUUID(),
13
+ type: "rule",
14
+ field: defaultField ?? "",
15
+ operator: defaultOperator ?? "contains",
16
+ value: "",
17
+ addedAt: nextAddedAt()
18
+ };
19
+ }
20
+ function emptyGroup(defaultField, defaultOperator) {
21
+ return {
22
+ id: crypto.randomUUID(),
23
+ type: "group",
24
+ combinator: "and",
25
+ children: [emptyRule(defaultField, defaultOperator)],
26
+ addedAt: nextAddedAt()
27
+ };
28
+ }
29
+ function findMaxAddedAtNode(group) {
30
+ let best = null;
31
+ function walk(g) {
32
+ g.children.forEach((c, idx) => {
33
+ const a = c.addedAt;
34
+ if (typeof a === "number") {
35
+ if (!best || a > best.addedAt) best = { parent: g, idx, addedAt: a };
36
+ }
37
+ if (c.type === "group") walk(c);
38
+ });
39
+ }
40
+ walk(group);
41
+ return best;
42
+ }
43
+ function findGroupAndDepth(root, id, depth = 1) {
44
+ if (root.id === id) return { group: root, depth };
45
+ for (const child of root.children) {
46
+ if (child.type === "group") {
47
+ const r = findGroupAndDepth(child, id, depth + 1);
48
+ if (r) return r;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ function countRules(group) {
54
+ let n = 0;
55
+ for (const c of group.children) {
56
+ if (c.type === "rule") n += 1;
57
+ else n += countRules(c);
58
+ }
59
+ return n;
60
+ }
61
+ function treeReducer(state, action) {
62
+ if (action.type === "replaceRoot") {
63
+ const next2 = { root: action.root };
64
+ const v2 = validateTreeLimits(next2);
65
+ return v2.ok ? next2 : state;
66
+ }
67
+ const next = JSON.parse(JSON.stringify(state));
68
+ if (action.type === "removeLast") {
69
+ const found = findMaxAddedAtNode(next.root);
70
+ if (!found) return state;
71
+ found.parent.children.splice(found.idx, 1);
72
+ const v2 = validateTreeLimits(next);
73
+ return v2.ok ? next : state;
74
+ }
75
+ let mutated = false;
76
+ function apply(group) {
77
+ switch (action.type) {
78
+ case "addRule":
79
+ if (group.id === action.groupId) {
80
+ group.children.push(emptyRule(action.defaultField, action.defaultOperator));
81
+ return true;
82
+ }
83
+ break;
84
+ case "addGroup":
85
+ if (group.id === action.groupId) {
86
+ group.children.push(emptyGroup(action.defaultField, action.defaultOperator));
87
+ return true;
88
+ }
89
+ break;
90
+ case "updateGroupCombinator":
91
+ if (group.id === action.groupId) {
92
+ group.combinator = action.combinator;
93
+ return true;
94
+ }
95
+ break;
96
+ case "removeNode": {
97
+ const idx = group.children.findIndex((c) => c.id === action.nodeId);
98
+ if (idx >= 0) {
99
+ group.children.splice(idx, 1);
100
+ return true;
101
+ }
102
+ break;
103
+ }
104
+ case "updateRule":
105
+ for (const c of group.children) {
106
+ if (c.type === "rule" && c.id === action.ruleId) {
107
+ Object.assign(c, action.updates);
108
+ return true;
109
+ }
110
+ }
111
+ break;
112
+ case "reorderChildren":
113
+ if (group.id === action.groupId) {
114
+ const arr = group.children;
115
+ if (action.fromIdx >= 0 && action.fromIdx < arr.length && action.toIdx >= 0 && action.toIdx < arr.length && action.fromIdx !== action.toIdx) {
116
+ const [moved] = arr.splice(action.fromIdx, 1);
117
+ arr.splice(action.toIdx, 0, moved);
118
+ return true;
119
+ }
120
+ }
121
+ break;
122
+ }
123
+ for (const c of group.children) {
124
+ if (c.type === "group" && apply(c)) return true;
125
+ }
126
+ return false;
127
+ }
128
+ mutated = apply(next.root);
129
+ if (!mutated) return state;
130
+ const v = validateTreeLimits(next);
131
+ if (!v.ok) return state;
132
+ return next;
133
+ }
134
+ function canAddRule(tree, groupId) {
135
+ const found = findGroupAndDepth(tree.root, groupId);
136
+ if (!found) return false;
137
+ if (found.group.children.length >= TREE_LIMITS.maxChildrenPerGroup) return false;
138
+ if (countRules(tree.root) >= TREE_LIMITS.maxTotalRules) return false;
139
+ return true;
140
+ }
141
+ function canAddGroup(tree, groupId) {
142
+ const found = findGroupAndDepth(tree.root, groupId);
143
+ if (!found) return false;
144
+ if (found.group.children.length >= TREE_LIMITS.maxChildrenPerGroup) return false;
145
+ if (found.depth >= TREE_LIMITS.maxGroupLevel) return false;
146
+ if (countRules(tree.root) >= TREE_LIMITS.maxTotalRules) return false;
147
+ return true;
148
+ }
149
+ function getGroupDepth(tree, groupId) {
150
+ const found = findGroupAndDepth(tree.root, groupId);
151
+ return found?.depth ?? null;
152
+ }
153
+ export {
154
+ canAddGroup,
155
+ canAddRule,
156
+ getGroupDepth,
157
+ treeReducer
158
+ };
159
+ //# sourceMappingURL=treeReducer.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/filters/treeReducer.ts"],
4
+ "sourcesContent": ["import {\n type AdvancedFilterTree,\n type FilterRule,\n type FilterGroup,\n type FilterCombinator,\n validateTreeLimits,\n TREE_LIMITS,\n} from '@open-mercato/shared/lib/query/advanced-filter-tree'\nimport type { FilterOperator } from '@open-mercato/shared/lib/query/advanced-filter'\n\nexport type TreeAction =\n | { type: 'addRule'; groupId: string; defaultField?: string; defaultOperator?: FilterOperator }\n | { type: 'addGroup'; groupId: string; defaultField?: string; defaultOperator?: FilterOperator }\n | { type: 'removeNode'; nodeId: string }\n | { type: 'updateRule'; ruleId: string; updates: Partial<Pick<FilterRule, 'field' | 'operator' | 'value'>> }\n | { type: 'updateGroupCombinator'; groupId: string; combinator: FilterCombinator }\n | { type: 'reorderChildren'; groupId: string; fromIdx: number; toIdx: number }\n | { type: 'removeLast' }\n | { type: 'replaceRoot'; root: FilterGroup }\n\n// Pure monotonic counter for tracking insert order within a session.\n// MUST NOT be mixed with wall-clock time \u2014 that leaks load timing into tests\n// (fixture ordering depended on `Date.now()` resolution across runs).\n// Runtime-only \u2014 never serialized.\nlet addedAtCounter = 0\nfunction nextAddedAt(): number {\n addedAtCounter += 1\n return addedAtCounter\n}\n\nfunction emptyRule(defaultField?: string, defaultOperator?: FilterOperator): FilterRule {\n return {\n id: crypto.randomUUID(),\n type: 'rule',\n field: defaultField ?? '',\n operator: defaultOperator ?? 'contains',\n value: '',\n addedAt: nextAddedAt(),\n }\n}\n\nfunction emptyGroup(defaultField?: string, defaultOperator?: FilterOperator): FilterGroup {\n return {\n id: crypto.randomUUID(),\n type: 'group',\n combinator: 'and',\n children: [emptyRule(defaultField, defaultOperator)],\n addedAt: nextAddedAt(),\n }\n}\n\ntype WithAddedAt = { addedAt?: number }\n\nfunction findMaxAddedAtNode(\n group: FilterGroup,\n): { parent: FilterGroup; idx: number; addedAt: number } | null {\n let best: { parent: FilterGroup; idx: number; addedAt: number } | null = null\n function walk(g: FilterGroup) {\n g.children.forEach((c, idx) => {\n const a = (c as unknown as WithAddedAt).addedAt\n if (typeof a === 'number') {\n if (!best || a > best.addedAt) best = { parent: g, idx, addedAt: a }\n }\n if (c.type === 'group') walk(c)\n })\n }\n walk(group)\n return best\n}\n\nfunction findGroupAndDepth(root: FilterGroup, id: string, depth = 1): { group: FilterGroup; depth: number } | null {\n if (root.id === id) return { group: root, depth }\n for (const child of root.children) {\n if (child.type === 'group') {\n const r = findGroupAndDepth(child, id, depth + 1)\n if (r) return r\n }\n }\n return null\n}\n\nfunction countRules(group: FilterGroup): number {\n let n = 0\n for (const c of group.children) {\n if (c.type === 'rule') n += 1\n else n += countRules(c)\n }\n return n\n}\n\nexport function treeReducer(state: AdvancedFilterTree, action: TreeAction): AdvancedFilterTree {\n // replaceRoot bypasses cloning/walking \u2014 it overwrites the entire root.\n if (action.type === 'replaceRoot') {\n const next: AdvancedFilterTree = { root: action.root }\n const v = validateTreeLimits(next)\n return v.ok ? next : state\n }\n\n // Deep clone so React detects state change. The tree is JSON-safe so JSON\n // round-tripping is sufficient; the Jest jsdom env doesn't expose\n // `structuredClone`, so avoid relying on it.\n const next: AdvancedFilterTree = JSON.parse(JSON.stringify(state))\n\n if (action.type === 'removeLast') {\n const found = findMaxAddedAtNode(next.root)\n if (!found) return state\n found.parent.children.splice(found.idx, 1)\n const v = validateTreeLimits(next)\n return v.ok ? next : state\n }\n\n let mutated = false\n\n function apply(group: FilterGroup): boolean {\n switch (action.type) {\n case 'addRule':\n if (group.id === action.groupId) {\n group.children.push(emptyRule(action.defaultField, action.defaultOperator))\n return true\n }\n break\n case 'addGroup':\n if (group.id === action.groupId) {\n group.children.push(emptyGroup(action.defaultField, action.defaultOperator))\n return true\n }\n break\n case 'updateGroupCombinator':\n if (group.id === action.groupId) {\n group.combinator = action.combinator\n return true\n }\n break\n case 'removeNode': {\n const idx = group.children.findIndex((c) => c.id === action.nodeId)\n if (idx >= 0) {\n group.children.splice(idx, 1)\n return true\n }\n break\n }\n case 'updateRule':\n for (const c of group.children) {\n if (c.type === 'rule' && c.id === action.ruleId) {\n Object.assign(c, action.updates)\n return true\n }\n }\n break\n case 'reorderChildren':\n if (group.id === action.groupId) {\n const arr = group.children\n if (\n action.fromIdx >= 0 &&\n action.fromIdx < arr.length &&\n action.toIdx >= 0 &&\n action.toIdx < arr.length &&\n action.fromIdx !== action.toIdx\n ) {\n const [moved] = arr.splice(action.fromIdx, 1)\n arr.splice(action.toIdx, 0, moved)\n return true\n }\n }\n break\n }\n for (const c of group.children) {\n if (c.type === 'group' && apply(c)) return true\n }\n return false\n }\n\n mutated = apply(next.root)\n if (!mutated) return state\n\n const v = validateTreeLimits(next)\n if (!v.ok) return state // reject by returning original (caller can show a tooltip)\n return next\n}\n\nexport function canAddRule(tree: AdvancedFilterTree, groupId: string): boolean {\n const found = findGroupAndDepth(tree.root, groupId)\n if (!found) return false\n if (found.group.children.length >= TREE_LIMITS.maxChildrenPerGroup) return false\n if (countRules(tree.root) >= TREE_LIMITS.maxTotalRules) return false\n return true\n}\n\nexport function canAddGroup(tree: AdvancedFilterTree, groupId: string): boolean {\n const found = findGroupAndDepth(tree.root, groupId)\n if (!found) return false\n if (found.group.children.length >= TREE_LIMITS.maxChildrenPerGroup) return false\n if (found.depth >= TREE_LIMITS.maxGroupLevel) return false\n if (countRules(tree.root) >= TREE_LIMITS.maxTotalRules) return false\n return true\n}\n\nexport function getGroupDepth(tree: AdvancedFilterTree, groupId: string): number | null {\n const found = findGroupAndDepth(tree.root, groupId)\n return found?.depth ?? null\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["next", "v"]
7
+ }