@open-mercato/ui 0.6.1-develop.3260.1.dc719cac84 → 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,213 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useAdvancedFilterTree } from '../useAdvancedFilter'
3
+ import { createEmptyTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
4
+
5
+ jest.useFakeTimers()
6
+
7
+ describe('useAdvancedFilterTree — auto-apply', () => {
8
+ it('debounces onApply by 400 ms when valid; suppresses when invalid', () => {
9
+ const onApply = jest.fn()
10
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields: [], onApply }))
11
+ act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id }) })
12
+ expect(onApply).not.toHaveBeenCalled()
13
+ act(() => { jest.advanceTimersByTime(400) })
14
+ // tree has invalid rule (empty value) - should NOT apply
15
+ expect(onApply).not.toHaveBeenCalled()
16
+ expect(result.current.pendingErrors.length).toBeGreaterThan(0)
17
+ })
18
+
19
+ it('flush() applies immediately if valid', () => {
20
+ const onApply = jest.fn()
21
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
22
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply }))
23
+ act(() => {
24
+ result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' })
25
+ })
26
+ const ruleId = result.current.tree.root.children[0].id
27
+ act(() => {
28
+ // Substring text operators now require >= 3 characters to validate.
29
+ result.current.dispatch({ type: 'updateRule', ruleId, updates: { value: 'XYZ' } })
30
+ })
31
+ act(() => { result.current.flush() })
32
+ expect(onApply).toHaveBeenCalled()
33
+ })
34
+
35
+ it('flush() suppresses apply on invalid tree', () => {
36
+ const onApply = jest.fn()
37
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
38
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply }))
39
+ act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
40
+ act(() => { result.current.flush() })
41
+ expect(onApply).not.toHaveBeenCalled()
42
+ expect(result.current.pendingErrors.length).toBe(1)
43
+ })
44
+
45
+ it('hasActiveRules reflects tree.root.children.length', () => {
46
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields: [], onApply: () => {} }))
47
+ expect(result.current.hasActiveRules).toBe(false)
48
+ act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id }) })
49
+ expect(result.current.hasActiveRules).toBe(true)
50
+ })
51
+
52
+ it('clear() empties root children', () => {
53
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields: [], onApply: () => {} }))
54
+ act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id }) })
55
+ expect(result.current.tree.root.children.length).toBe(1)
56
+ act(() => { result.current.clear() })
57
+ expect(result.current.tree.root.children.length).toBe(0)
58
+ })
59
+
60
+ it('setTree replaces tree', () => {
61
+ const onApply = jest.fn()
62
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields: [], onApply }))
63
+ const newTree = { root: { id: 'r2', type: 'group' as const, combinator: 'or' as const, children: [] } }
64
+ act(() => { result.current.setTree(newTree) })
65
+ expect(result.current.tree.root.id).toBe('r2')
66
+ })
67
+
68
+ it('does NOT auto-apply on mount when initial is non-empty and valid', () => {
69
+ const onApply = jest.fn()
70
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
71
+ const initial = {
72
+ root: {
73
+ id: 'r',
74
+ type: 'group' as const,
75
+ combinator: 'and' as const,
76
+ // Substring text operators now require >= 3 characters; use 'XYZ' so the
77
+ // initial tree is valid.
78
+ children: [{ id: 'a', type: 'rule' as const, field: 'n', operator: 'contains' as const, value: 'XYZ' } as any],
79
+ },
80
+ }
81
+ renderHook(() => useAdvancedFilterTree({ initial, fields, onApply }))
82
+ act(() => { jest.advanceTimersByTime(500) })
83
+ expect(onApply).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('does auto-apply after a dispatch from a non-empty initial', () => {
87
+ const onApply = jest.fn()
88
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
89
+ const initial = {
90
+ root: {
91
+ id: 'r',
92
+ type: 'group' as const,
93
+ combinator: 'and' as const,
94
+ children: [{ id: 'a', type: 'rule' as const, field: 'n', operator: 'contains' as const, value: 'XYZ' } as any],
95
+ },
96
+ }
97
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial, fields, onApply }))
98
+ act(() => { result.current.dispatch({ type: 'updateRule', ruleId: 'a', updates: { value: 'WXY' } }) })
99
+ act(() => { jest.advanceTimersByTime(400) })
100
+ expect(onApply).toHaveBeenCalledTimes(1)
101
+ })
102
+
103
+ it('auto-applies non-blank short substring text rules', () => {
104
+ const onApply = jest.fn()
105
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
106
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply }))
107
+ act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
108
+ const ruleId = result.current.tree.root.children[0].id
109
+ act(() => { result.current.dispatch({ type: 'updateRule', ruleId, updates: { value: 'a' } }) })
110
+ act(() => { jest.advanceTimersByTime(400) })
111
+ expect(onApply).toHaveBeenCalledTimes(1)
112
+ expect(result.current.pendingErrors).toEqual([])
113
+ })
114
+ })
115
+
116
+ describe('useAdvancedFilterTree — single source of truth', () => {
117
+ it('seeds appliedTree from initial and exposes it alongside tree', () => {
118
+ const initial = {
119
+ root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [] },
120
+ }
121
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial, fields: [], onApply: () => {} }))
122
+ expect(result.current.tree).toEqual(initial)
123
+ expect(result.current.appliedTree).toEqual(initial)
124
+ })
125
+
126
+ it('appliedTree lags tree until the debounced apply fires', () => {
127
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
128
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply: () => {} }))
129
+
130
+ act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
131
+ const ruleId = result.current.tree.root.children[0].id
132
+ act(() => { result.current.dispatch({ type: 'updateRule', ruleId, updates: { value: 'abc' } }) })
133
+
134
+ // Editor advanced immediately, appliedTree is still empty until debounce fires.
135
+ expect(result.current.tree.root.children).toHaveLength(1)
136
+ expect(result.current.appliedTree.root.children).toHaveLength(0)
137
+
138
+ act(() => { jest.advanceTimersByTime(400) })
139
+
140
+ // Debounce + validation passed — appliedTree now matches editor.
141
+ expect(result.current.appliedTree.root.children).toHaveLength(1)
142
+ })
143
+
144
+ it('appliedTree stays put when validation fails', () => {
145
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
146
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply: () => {} }))
147
+
148
+ // Empty-value rule is invalid.
149
+ act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
150
+ act(() => { jest.advanceTimersByTime(500) })
151
+
152
+ expect(result.current.tree.root.children).toHaveLength(1)
153
+ expect(result.current.appliedTree.root.children).toHaveLength(0)
154
+ expect(result.current.pendingErrors.length).toBeGreaterThan(0)
155
+ })
156
+
157
+ it('clear() resets both tree and appliedTree immediately and fires onApply', () => {
158
+ const onApply = jest.fn()
159
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
160
+ const initial = {
161
+ root: {
162
+ id: 'r',
163
+ type: 'group' as const,
164
+ combinator: 'and' as const,
165
+ children: [{ id: 'a', type: 'rule' as const, field: 'n', operator: 'contains' as const, value: 'XYZ' } as any],
166
+ },
167
+ }
168
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial, fields, onApply }))
169
+
170
+ act(() => { result.current.clear() })
171
+
172
+ expect(result.current.tree.root.children).toHaveLength(0)
173
+ expect(result.current.appliedTree.root.children).toHaveLength(0)
174
+ expect(onApply).toHaveBeenCalledTimes(1)
175
+ })
176
+
177
+ it('replaceTree() jumps both tree and appliedTree immediately (perspective restore)', () => {
178
+ const onApply = jest.fn()
179
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
180
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply }))
181
+
182
+ const restored = {
183
+ root: {
184
+ id: 'restored',
185
+ type: 'group' as const,
186
+ combinator: 'or' as const,
187
+ children: [{ id: 'b', type: 'rule' as const, field: 'n', operator: 'contains' as const, value: 'foo' } as any],
188
+ },
189
+ }
190
+ act(() => { result.current.replaceTree(restored) })
191
+
192
+ expect(result.current.tree.root.id).toBe('restored')
193
+ expect(result.current.appliedTree.root.id).toBe('restored')
194
+ expect(result.current.pendingErrors).toEqual([])
195
+ expect(onApply).toHaveBeenCalledWith(restored)
196
+ })
197
+
198
+ it('flush() advances appliedTree to the validated editor tree', () => {
199
+ const fields = [{ key: 'n', label: 'Name', type: 'text' as const }]
200
+ const { result } = renderHook(() => useAdvancedFilterTree({ initial: createEmptyTree(), fields, onApply: () => {} }))
201
+
202
+ act(() => { result.current.dispatch({ type: 'addRule', groupId: result.current.tree.root.id, defaultField: 'n' }) })
203
+ const ruleId = result.current.tree.root.children[0].id
204
+ act(() => { result.current.dispatch({ type: 'updateRule', ruleId, updates: { value: 'abc' } }) })
205
+
206
+ // Before flush: appliedTree still empty.
207
+ expect(result.current.appliedTree.root.children).toHaveLength(0)
208
+
209
+ act(() => { result.current.flush() })
210
+
211
+ expect(result.current.appliedTree.root.children).toHaveLength(1)
212
+ })
213
+ })
@@ -2,6 +2,9 @@
2
2
  import * as React from 'react'
3
3
  import type { AdvancedFilterState, FilterCondition, FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
4
4
  import { createEmptyCondition, getDefaultOperator, normalizeAdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter'
5
+ import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
6
+ import { treeReducer, type TreeAction } from '../filters/treeReducer'
7
+ import { validateTreeForApply, type ValidationError } from '../filters/filterValidation'
5
8
 
6
9
  export type UseAdvancedFilterOptions = {
7
10
  fields: FilterFieldDef[]
@@ -75,3 +78,130 @@ export function useAdvancedFilter({ fields, onChange }: UseAdvancedFilterOptions
75
78
  hasActiveConditions,
76
79
  }
77
80
  }
81
+
82
+ export type UseAdvancedFilterTreeArgs = {
83
+ initial: AdvancedFilterTree
84
+ fields: FilterFieldDef[]
85
+ /**
86
+ * Optional side-effect that fires whenever the tree is validated and applied
87
+ * (debounced auto-apply, explicit `flush()`, `clear()`, or `replaceTree()`).
88
+ * Use this for non-state effects such as resetting the page index when the
89
+ * filter changes. State propagation should read `appliedTree` directly — the
90
+ * hook is the single source of truth and the page MUST NOT keep a parallel
91
+ * copy.
92
+ */
93
+ onApply?: (tree: AdvancedFilterTree) => void
94
+ debounceMs?: number
95
+ }
96
+
97
+ export type UseAdvancedFilterTreeResult = {
98
+ /** In-progress editor tree. Bound to the filter panel's `value` / `onChange`. */
99
+ tree: AdvancedFilterTree
100
+ /**
101
+ * Last validated, applied tree. Bound to URL serialization, query requests,
102
+ * active chips, and the filter-aware empty-state. Lags `tree` by `debounceMs`
103
+ * while the user is typing in an editor; advances immediately on `flush()`,
104
+ * `clear()`, and `replaceTree()`.
105
+ */
106
+ appliedTree: AdvancedFilterTree
107
+ setTree: (t: AdvancedFilterTree) => void
108
+ dispatch: (a: TreeAction) => void
109
+ pendingErrors: ValidationError[]
110
+ flush: () => void
111
+ clear: () => void
112
+ /**
113
+ * Replace BOTH the editor and applied trees immediately, bypassing the
114
+ * debounce. Use this for perspective restoration and saved-filter apply —
115
+ * actions that semantically jump the filter to a new known-good state rather
116
+ * than building it up via user edits.
117
+ */
118
+ replaceTree: (next: AdvancedFilterTree) => void
119
+ hasActiveRules: boolean
120
+ }
121
+
122
+ /**
123
+ * Tree-shaped advanced filter state with debounced auto-apply, validation gating,
124
+ * and helpers for the CRM filter builder UI.
125
+ *
126
+ * **State ownership**: this hook is the single source of truth for the filter
127
+ * tree. The host page MUST NOT keep a parallel `useState<AdvancedFilterTree>` —
128
+ * read `appliedTree` for URL/query consumption and `tree` for the editor panel.
129
+ *
130
+ * - Debounced apply (default 400 ms) — fires only when the tree validates.
131
+ * `appliedTree` advances and the optional `onApply(tree)` callback fires.
132
+ * - `pendingErrors` exposes the latest validation errors for inline UI rendering.
133
+ * - `flush()` applies immediately if valid; `clear()` empties root children.
134
+ * - `replaceTree(t)` jumps BOTH editor and applied trees (perspective restore).
135
+ * - `hasActiveRules` mirrors `tree.root.children.length > 0`.
136
+ */
137
+ export function useAdvancedFilterTree({
138
+ initial,
139
+ fields,
140
+ onApply,
141
+ debounceMs = 400,
142
+ }: UseAdvancedFilterTreeArgs): UseAdvancedFilterTreeResult {
143
+ const [tree, setTree] = React.useState<AdvancedFilterTree>(initial)
144
+ const [appliedTree, setAppliedTree] = React.useState<AdvancedFilterTree>(initial)
145
+ const [pendingErrors, setPendingErrors] = React.useState<ValidationError[]>([])
146
+ const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
147
+ const didMountRef = React.useRef(false)
148
+ const onApplyRef = React.useRef(onApply)
149
+ const fieldsRef = React.useRef(fields)
150
+ React.useEffect(() => { onApplyRef.current = onApply }, [onApply])
151
+ React.useEffect(() => { fieldsRef.current = fields }, [fields])
152
+
153
+ const tryApply = React.useCallback((candidate: AdvancedFilterTree): boolean => {
154
+ const result = validateTreeForApply(candidate, fieldsRef.current)
155
+ if (result.ok) {
156
+ setPendingErrors([])
157
+ setAppliedTree(candidate)
158
+ onApplyRef.current?.(candidate)
159
+ return true
160
+ }
161
+ setPendingErrors(result.errors)
162
+ return false
163
+ }, [])
164
+
165
+ React.useEffect(() => {
166
+ if (!didMountRef.current) {
167
+ didMountRef.current = true
168
+ return
169
+ }
170
+ if (timerRef.current) clearTimeout(timerRef.current)
171
+ const timer = setTimeout(() => { tryApply(tree) }, debounceMs)
172
+ timerRef.current = timer
173
+ return () => clearTimeout(timer)
174
+ }, [tree, debounceMs, tryApply])
175
+
176
+ const dispatch = React.useCallback((action: TreeAction) => {
177
+ setTree((prev) => treeReducer(prev, action))
178
+ }, [])
179
+
180
+ const flush = React.useCallback(() => {
181
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
182
+ tryApply(tree)
183
+ }, [tree, tryApply])
184
+
185
+ const clear = React.useCallback(() => {
186
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
187
+ setTree((prev) => {
188
+ const emptied: AdvancedFilterTree = { root: { ...prev.root, children: [] } }
189
+ setAppliedTree(emptied)
190
+ return emptied
191
+ })
192
+ setPendingErrors([])
193
+ onApplyRef.current?.({ root: { id: '', type: 'group', combinator: 'and', children: [] } })
194
+ }, [])
195
+
196
+ const replaceTree = React.useCallback((next: AdvancedFilterTree) => {
197
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
198
+ setTree(next)
199
+ setAppliedTree(next)
200
+ setPendingErrors([])
201
+ onApplyRef.current?.(next)
202
+ }, [])
203
+
204
+ const hasActiveRules = tree.root.children.length > 0
205
+
206
+ return { tree, appliedTree, setTree, dispatch, pendingErrors, flush, clear, replaceTree, hasActiveRules }
207
+ }
@@ -23,6 +23,23 @@ function readRedirectOverride(headers: Headers, headerName: string): boolean {
23
23
  return headers.get(headerName) === '0'
24
24
  }
25
25
 
26
+ function isSameOriginRequest(input: RequestInfo | URL): boolean {
27
+ if (typeof window === 'undefined') return false
28
+ const host = window.location?.host
29
+ if (!host) return false
30
+ let urlString: string
31
+ if (typeof input === 'string') urlString = input
32
+ else if (input instanceof URL) urlString = input.toString()
33
+ else if (typeof Request !== 'undefined' && input instanceof Request) urlString = input.url
34
+ else return false
35
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(urlString)) return true
36
+ try {
37
+ return new URL(urlString).host === host
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
26
43
  export async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {
27
44
  return scopedHeaders.withScopedHeaders(headers, run)
28
45
  }
@@ -109,9 +126,17 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
109
126
  )
110
127
  }
111
128
  const scoped = scopedHeaders.resolveScopedHeaders()
112
- const mergedInit = Object.keys(scoped).length
129
+ const baseInit: RequestInit = Object.keys(scoped).length
113
130
  ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }
114
- : init
131
+ : init ?? {}
132
+ // Only auto-inject credentials: 'include' for same-origin requests so cookies
133
+ // round-trip across Next.js proxy.ts rewrites (custom-domain portal flows)
134
+ // without leaking session cookies to third-party hosts.
135
+ const mergedInit: RequestInit = baseInit.credentials
136
+ ? baseInit
137
+ : isSameOriginRequest(input)
138
+ ? { ...baseInit, credentials: 'include' }
139
+ : baseInit
115
140
  const requestHeaders = new Headers(mergedInit?.headers)
116
141
  const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')
117
142
  const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')
@@ -17,6 +17,7 @@ type ColumnMeta = {
17
17
  filterLoadOptions?: (query?: string) => Promise<FilterOption[]>
18
18
  filterable?: boolean
19
19
  filterGroup?: string
20
+ filterIconName?: string
20
21
  columnChooserGroup?: string
21
22
  alwaysVisible?: boolean
22
23
  hidden?: boolean
@@ -85,6 +86,7 @@ export function useAutoDiscoveredFields({
85
86
  type,
86
87
  group: meta?.filterGroup ?? meta?.columnChooserGroup,
87
88
  }
89
+ if (meta?.filterIconName) field.iconName = meta.filterIconName
88
90
  if (meta?.filterOptions) field.options = meta.filterOptions
89
91
  if (meta?.filterLoadOptions) field.loadOptions = meta.filterLoadOptions
90
92
  filterFields.push(field)
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { apiCall } from './apiCall'
4
+
5
+ type FeatureCheckResponse = {
6
+ ok: boolean
7
+ granted: string[]
8
+ userId: string
9
+ }
10
+
11
+ /**
12
+ * Read the current user's id on the client. Returns `''` until the request resolves.
13
+ *
14
+ * Implementation note: there is no first-class `/api/auth/me` endpoint yet, so this
15
+ * piggy-backs on `/api/auth/feature-check` with an empty `features` array — the response
16
+ * always carries the JWT `userId`. Replace with a dedicated `/api/auth/me` or an
17
+ * SSR-bootstrapped user context once available, and remove this hop.
18
+ */
19
+ export function useCurrentUserId(): string {
20
+ const [userId, setUserId] = React.useState<string>('')
21
+ React.useEffect(() => {
22
+ let cancelled = false
23
+ void (async () => {
24
+ try {
25
+ const res = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json' },
28
+ body: JSON.stringify({ features: [] }),
29
+ })
30
+ if (!cancelled && res.ok && typeof res.result?.userId === 'string') {
31
+ setUserId(res.result.userId)
32
+ }
33
+ } catch {
34
+ if (!cancelled) setUserId('')
35
+ }
36
+ })()
37
+ return () => { cancelled = true }
38
+ }, [])
39
+ return userId
40
+ }
@@ -14,6 +14,7 @@ const Z_INDEX_BY_TOKEN: Record<string, number> = {
14
14
  'z-modal': 40,
15
15
  'z-popover': 45,
16
16
  'z-toast': 50,
17
+ 'z-modal-elevated': 55,
17
18
  'z-tooltip': 60,
18
19
  'z-banner': 70,
19
20
  'z-top': 100,
@@ -129,4 +130,25 @@ describe('Issue #1836: portaled overlay primitives sit above modals (z-popover >
129
130
  expect(css).toContain('z-index: var(--z-index-popover);')
130
131
  }
131
132
  })
133
+
134
+ it('globals.css defines z-modal-elevated (55) above z-popover so dialogs opened from inside popovers are visible', () => {
135
+ const repoRoot = path.resolve(__dirname, '../../../../..')
136
+ const cssPaths = [
137
+ path.join(repoRoot, 'apps/mercato/src/app/globals.css'),
138
+ path.join(repoRoot, 'packages/create-app/template/src/app/globals.css'),
139
+ ]
140
+
141
+ for (const cssPath of cssPaths) {
142
+ const css = fs.readFileSync(cssPath, 'utf8')
143
+
144
+ expect(css).toContain('@utility z-modal-elevated')
145
+ expect(css).toMatch(/z-index:\s*var\(--z-index-modal-elevated(?:,\s*\d+)?\);/)
146
+
147
+ const elevatedMatch = css.match(/--z-index-modal-elevated:\s*(\d+);/)
148
+ const popoverMatch = css.match(/--z-index-popover:\s*(\d+);/)
149
+ expect(elevatedMatch).not.toBeNull()
150
+ expect(popoverMatch).not.toBeNull()
151
+ expect(Number(elevatedMatch![1])).toBeGreaterThan(Number(popoverMatch![1]))
152
+ }
153
+ })
132
154
  })
@@ -16,12 +16,17 @@ const DialogClose = DialogPrimitive.Close
16
16
 
17
17
  const DialogOverlay = React.forwardRef<
18
18
  React.ElementRef<typeof DialogPrimitive.Overlay>,
19
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20
- >(({ className, ...props }, ref) => (
19
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
20
+ /** Render above popovers (z-modal-elevated, 55) instead of the default z-modal (40).
21
+ * Use when this dialog is opened from inside a popover so it isn't occluded. */
22
+ elevated?: boolean
23
+ }
24
+ >(({ className, elevated, ...props }, ref) => (
21
25
  <DialogPrimitive.Overlay
22
26
  ref={ref}
23
27
  className={cn(
24
- 'fixed inset-0 z-modal bg-black/50 backdrop-blur-sm transition-opacity data-[state=open]:animate-in data-[state=closed]:animate-out',
28
+ 'fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity data-[state=open]:animate-in data-[state=closed]:animate-out',
29
+ elevated ? 'z-modal-elevated' : 'z-modal',
25
30
  className
26
31
  )}
27
32
  {...props}
@@ -31,8 +36,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31
36
 
32
37
  const DialogContent = React.forwardRef<
33
38
  React.ElementRef<typeof DialogPrimitive.Content>,
34
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
35
- >(({ className, children, ...props }, ref) => {
39
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
40
+ /** Render above popovers (z-modal-elevated, 55) instead of the default z-modal (40).
41
+ * Set on dialogs that open from inside another popover (e.g. the SaveFilterDialog
42
+ * inside the AdvancedFilterPanel popover) so they aren't hidden behind the popover. */
43
+ elevated?: boolean
44
+ }
45
+ >(({ className, children, elevated, ...props }, ref) => {
36
46
  const t = useT()
37
47
 
38
48
  React.useEffect(() => {
@@ -48,12 +58,13 @@ const DialogContent = React.forwardRef<
48
58
 
49
59
  return (
50
60
  <DialogPortal>
51
- <DialogOverlay />
61
+ <DialogOverlay elevated={elevated} />
52
62
  <DialogPrimitive.Content
53
63
  ref={ref}
54
64
  data-dialog-content=""
55
65
  className={cn(
56
- 'fixed inset-x-0 bottom-0 z-modal flex min-h-[50vh] max-h-[70vh] w-full translate-x-0 translate-y-0 flex-col gap-4 overflow-y-auto rounded-t-2xl border-t bg-card p-6 shadow-lg',
66
+ 'fixed inset-x-0 bottom-0 flex min-h-[50vh] max-h-[70vh] w-full translate-x-0 translate-y-0 flex-col gap-4 overflow-y-auto rounded-t-2xl border-t bg-card p-6 shadow-lg',
67
+ elevated ? 'z-modal-elevated' : 'z-modal',
57
68
  'sm:inset-auto sm:left-1/2 sm:top-1/2 sm:min-h-0 sm:h-auto sm:w-full sm:max-w-lg sm:max-h-[90vh] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border',
58
69
  'focus-visible:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out',
59
70
  className,
@@ -15,6 +15,7 @@ const tagVariants = cva(
15
15
  info: 'border-status-info-border bg-status-info-bg text-status-info-text',
16
16
  neutral: 'border-status-neutral-border bg-status-neutral-bg text-status-neutral-text',
17
17
  brand: 'border-brand-violet/30 bg-brand-violet/10 text-brand-violet',
18
+ pink: 'border-status-pink-border bg-status-pink-bg text-status-pink-text',
18
19
  },
19
20
  shape: {
20
21
  pill: 'rounded-full px-2.5 py-0.5',
@@ -33,9 +34,10 @@ const dotColorMap: Record<TagVariant, string> = {
33
34
  info: 'bg-status-info-icon',
34
35
  neutral: 'bg-status-neutral-icon',
35
36
  brand: 'bg-brand-violet',
37
+ pink: 'bg-status-pink-icon',
36
38
  }
37
39
 
38
- export type TagVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'brand'
40
+ export type TagVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'brand' | 'pink'
39
41
 
40
42
  export type TagShape = 'pill' | 'square'
41
43