@asteby/metacore-runtime-react 23.9.0 → 23.9.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.9.0",
3
+ "version": "23.9.2",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "sonner": ">=1.7",
39
39
  "zustand": ">=5",
40
40
  "@asteby/metacore-sdk": "^3.2.0",
41
- "@asteby/metacore-ui": "^2.9.1"
41
+ "@asteby/metacore-ui": "^2.9.2"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@tanstack/react-router": {
@@ -68,7 +68,7 @@
68
68
  "vitest": "^4.0.0",
69
69
  "zustand": "^5.0.0",
70
70
  "@asteby/metacore-sdk": "3.2.0",
71
- "@asteby/metacore-ui": "2.9.1"
71
+ "@asteby/metacore-ui": "2.9.2"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,84 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Dependent STATIC enum options in DynamicForm:
4
+ // - a static `select` whose options are gated by a sibling field's value
5
+ // shows only the applicable options;
6
+ // - when the sibling value invalidates the current selection, it resets;
7
+ // - when no option applies, the whole field (label included) is hidden.
8
+ //
9
+ // The gating (parent) field here is a plain text field rather than a second
10
+ // Radix `select`. That is deliberate: under happy-dom a Radix Select rendered
11
+ // inside a <form> mounts a hidden native <select> (BubbleSelect) whose value,
12
+ // when set programmatically before its <option> list is present, collapses to
13
+ // '' and dispatches a spurious change event that resets the parent — a
14
+ // test-environment artifact that does not occur in a real browser. Driving the
15
+ // sibling through a text field exercises the exact same gating path
16
+ // (`applyOptionWhen` reads `values[gate]` regardless of the sibling's widget)
17
+ // without that artifact. The pure string-comparison semantics of `when`
18
+ // (in/not_in, field fallback) are covered by dependent-enum-options.test.ts.
19
+ import { afterEach, describe, expect, it, vi } from 'vitest'
20
+ import { act, cleanup, render, screen } from '@testing-library/react'
21
+ import { useState } from 'react'
22
+
23
+ vi.mock('react-i18next', () => ({
24
+ useTranslation: () => ({ t: (k: string) => k }),
25
+ }))
26
+
27
+ import { DynamicForm } from '../dynamic-form'
28
+ import type { ActionFieldDef } from '../types'
29
+
30
+ afterEach(cleanup)
31
+
32
+ const fields: ActionFieldDef[] = [
33
+ {
34
+ key: 'type',
35
+ label: 'Type',
36
+ type: 'text',
37
+ },
38
+ {
39
+ key: 'provider',
40
+ label: 'Provider',
41
+ type: 'select',
42
+ dependsOn: 'type',
43
+ options: [
44
+ { value: 'qr', label: 'QR', when: { field: 'type', in: ['whatsapp'] } },
45
+ { value: 'meta', label: 'Meta', when: { field: 'type', in: ['whatsapp'] } },
46
+ ],
47
+ },
48
+ ]
49
+
50
+ // Harness that lets the test drive `type` and observe the submitted `provider`.
51
+ function Harness({ onSubmit }: { onSubmit: (v: Record<string, any>) => void }) {
52
+ const [initial, setInitial] = useState<Record<string, any>>({ type: 'whatsapp', provider: 'qr' })
53
+ return (
54
+ <div>
55
+ <button type="button" onClick={() => setInitial({ type: 'sms', provider: 'qr' })}>
56
+ to-sms
57
+ </button>
58
+ <DynamicForm fields={fields} initialValues={initial} onSubmit={onSubmit} />
59
+ </div>
60
+ )
61
+ }
62
+
63
+ describe('DynamicForm dependent static enum', () => {
64
+ it('hides the gated select and resets its value when no option applies', async () => {
65
+ const onSubmit = vi.fn()
66
+ render(<Harness onSubmit={onSubmit} />)
67
+
68
+ // type=whatsapp → provider field visible.
69
+ expect(screen.getByText('Provider')).toBeTruthy()
70
+
71
+ // Switch type to sms → no provider option applies → field hidden.
72
+ await act(async () => {
73
+ screen.getByText('to-sms').click()
74
+ })
75
+ expect(screen.queryByText('Provider')).toBeNull()
76
+
77
+ // The invalidated selection was reset — submit carries empty provider.
78
+ await act(async () => {
79
+ screen.getByText('Guardar').click()
80
+ })
81
+ expect(onSubmit).toHaveBeenCalled()
82
+ expect(onSubmit.mock.calls[0][0].provider).toBe('')
83
+ })
84
+ })
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { applyOptionWhen } from '../dynamic-form-schema'
3
+ import type { OptionDef } from '../types'
4
+
5
+ // A cascading STATIC enum: `provider` options only apply when the sibling
6
+ // `type` field is "whatsapp". Mirrors the link-inbox Device use case, but the
7
+ // helper is fully domain-agnostic.
8
+ const providerOptions: OptionDef[] = [
9
+ { value: 'qr', label: 'QR', when: { field: 'type', in: ['whatsapp'] } },
10
+ { value: 'meta', label: 'Meta', when: { field: 'type', in: ['whatsapp'] } },
11
+ ]
12
+
13
+ describe('applyOptionWhen', () => {
14
+ it('keeps only options whose `when.in` matches the parent value', () => {
15
+ const kept = applyOptionWhen(providerOptions, { type: 'whatsapp' })
16
+ expect(kept.map((o) => o.value)).toEqual(['qr', 'meta'])
17
+ })
18
+
19
+ it('drops all gated options when the parent value is not in `in`', () => {
20
+ expect(applyOptionWhen(providerOptions, { type: 'sms' })).toEqual([])
21
+ expect(applyOptionWhen(providerOptions, { type: '' })).toEqual([])
22
+ expect(applyOptionWhen(providerOptions, {})).toEqual([])
23
+ })
24
+
25
+ it('falls back to `dependsOn` when `when.field` is omitted', () => {
26
+ const opts: OptionDef[] = [
27
+ { value: 'a', label: 'A', when: { in: ['x'] } },
28
+ { value: 'b', label: 'B', when: { in: ['y'] } },
29
+ ]
30
+ expect(applyOptionWhen(opts, { kind: 'x' }, 'kind').map((o) => o.value)).toEqual(['a'])
31
+ })
32
+
33
+ it('supports `not_in` (snake_case) and `notIn` (camelCase)', () => {
34
+ const opts: OptionDef[] = [
35
+ { value: 'a', label: 'A', when: { field: 'type', not_in: ['hidden'] } },
36
+ { value: 'b', label: 'B', when: { field: 'type', notIn: ['hidden'] } },
37
+ ]
38
+ expect(applyOptionWhen(opts, { type: 'shown' }).map((o) => o.value)).toEqual(['a', 'b'])
39
+ expect(applyOptionWhen(opts, { type: 'hidden' })).toEqual([])
40
+ })
41
+
42
+ it('compares values as strings', () => {
43
+ const opts: OptionDef[] = [{ value: 'a', label: 'A', when: { field: 'n', in: ['1'] } }]
44
+ expect(applyOptionWhen(opts, { n: 1 }).map((o) => o.value)).toEqual(['a'])
45
+ })
46
+
47
+ it('includes an option with no `when` regardless of parent value (retrocompat)', () => {
48
+ const opts: OptionDef[] = [
49
+ { value: 'always', label: 'Always' },
50
+ { value: 'gated', label: 'Gated', when: { field: 'type', in: ['whatsapp'] } },
51
+ ]
52
+ expect(applyOptionWhen(opts, { type: 'sms' }).map((o) => o.value)).toEqual(['always'])
53
+ expect(applyOptionWhen(opts, { type: 'whatsapp' }).map((o) => o.value)).toEqual([
54
+ 'always',
55
+ 'gated',
56
+ ])
57
+ })
58
+
59
+ it('includes a gated option when neither `when.field` nor `dependsOn` is present', () => {
60
+ const opts: OptionDef[] = [{ value: 'a', label: 'A', when: { in: ['x'] } }]
61
+ expect(applyOptionWhen(opts, { anything: 'x' }).map((o) => o.value)).toEqual(['a'])
62
+ })
63
+
64
+ it('returns [] for undefined options', () => {
65
+ expect(applyOptionWhen(undefined, {})).toEqual([])
66
+ })
67
+ })
@@ -2,7 +2,7 @@
2
2
  // callers (and unit tests) can use the zod schema without pulling in React or
3
3
  // metacore-ui primitives.
4
4
  import { z, type ZodTypeAny } from 'zod'
5
- import type { ActionFieldDef, FieldValidation, FieldOptionsConfig } from './types'
5
+ import type { ActionFieldDef, FieldValidation, FieldOptionsConfig, OptionDef } from './types'
6
6
  import { resolveValidatorToken } from './use-org-config-bridge'
7
7
 
8
8
  /**
@@ -287,6 +287,49 @@ export function resolveDependsValue(
287
287
  return String(raw)
288
288
  }
289
289
 
290
+ /**
291
+ * Filters a STATIC enum's `options[]` by each option's `when` gate against the
292
+ * current form values. Pure — no React, no side effects.
293
+ *
294
+ * Rule per option:
295
+ * - No `when` → always included (retrocompat; existing enums untouched).
296
+ * - With `when`: the gating field is `when.field ?? dependsOn`. If neither is
297
+ * present the option is included (nothing to gate on). Otherwise the form's
298
+ * value for that field is compared AS STRING: included when (no `in`, or
299
+ * value ∈ `in`) AND (no `not_in`, or value ∉ `not_in`). Tolerates the
300
+ * snake_case `not_in` the kernel serves alongside camelCase `notIn`.
301
+ *
302
+ * `formValues` is the flat map of the surrounding form/row values the gating
303
+ * field is read from; `dependsOn` is the containing field's declared dependency
304
+ * used as the default gating field.
305
+ */
306
+ export function applyOptionWhen(
307
+ options: OptionDef[] | undefined,
308
+ formValues: Record<string, any> | null | undefined,
309
+ dependsOn?: string,
310
+ ): OptionDef[] {
311
+ if (!Array.isArray(options)) return []
312
+ return options.filter((opt) => {
313
+ const when = opt?.when
314
+ if (!when) return true
315
+ const gate = (typeof when.field === 'string' && when.field.trim() !== '')
316
+ ? when.field.trim()
317
+ : dependsOn
318
+ if (!gate) return true
319
+ const raw = formValues ? formValues[gate] : undefined
320
+ const current = raw == null ? '' : String(raw)
321
+ const inList = when.in
322
+ const notIn = when.notIn ?? when.not_in
323
+ if (Array.isArray(inList) && inList.length > 0) {
324
+ if (!inList.some((v) => String(v) === current)) return false
325
+ }
326
+ if (Array.isArray(notIn) && notIn.length > 0) {
327
+ if (notIn.some((v) => String(v) === current)) return false
328
+ }
329
+ return true
330
+ })
331
+ }
332
+
290
333
  /**
291
334
  * Reads a field's enriched options-resolution config, tolerating the camelCase
292
335
  * `optionsConfig` (authored SDK shape) and the snake_case `options_config` the
@@ -20,6 +20,8 @@ import {
20
20
  resolveWidget,
21
21
  isLineItemsField,
22
22
  evaluateBalance,
23
+ applyOptionWhen,
24
+ getDependsOn,
23
25
  } from './dynamic-form-schema'
24
26
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
25
27
  import { DynamicLineItems } from './dynamic-line-items'
@@ -111,32 +113,17 @@ export function DynamicForm({
111
113
  return (
112
114
  <form onSubmit={handleSubmit} className="grid gap-4">
113
115
  <div className="grid gap-4 sm:grid-cols-2">
114
- {fields.map((field) => {
115
- const fullWidth =
116
- isLineItemsField(field) ||
117
- resolveWidget(field) === 'textarea' ||
118
- resolveWidget(field) === 'richtext'
119
- return (
120
- <div
121
- key={field.key}
122
- className={'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : '')}
123
- >
124
- <Label htmlFor={field.key}>
125
- {field.label}
126
- {field.required && <span className="text-red-500 ml-1">*</span>}
127
- </Label>
128
- <FieldRenderer
129
- field={field}
130
- value={values[field.key]}
131
- onChange={(v: any) => update(field.key, v)}
132
- initialValues={initialValues}
133
- />
134
- {errors[field.key] && (
135
- <span className="text-red-500 text-sm" role="alert">{errors[field.key]}</span>
136
- )}
137
- </div>
138
- )
139
- })}
116
+ {fields.map((field) => (
117
+ <FieldRow
118
+ key={field.key}
119
+ field={field}
120
+ value={values[field.key]}
121
+ onChange={(v: any) => update(field.key, v)}
122
+ values={values}
123
+ error={errors[field.key]}
124
+ initialValues={initialValues}
125
+ />
126
+ ))}
140
127
  </div>
141
128
  <div className="flex justify-end gap-2 pt-2">
142
129
  {onCancel && (
@@ -158,6 +145,61 @@ interface FieldRendererProps {
158
145
  onChange: (v: any) => void
159
146
  /** The form's initial record — used to seed an FK picker's existing label/image. */
160
147
  initialValues?: Record<string, any>
148
+ /**
149
+ * The full flat map of current form values — read by a STATIC enum select to
150
+ * gate its options by a sibling field's value (`applyOptionWhen`).
151
+ */
152
+ values?: Record<string, any>
153
+ }
154
+
155
+ interface FieldRowProps extends FieldRendererProps {
156
+ error?: string
157
+ }
158
+
159
+ // One form field row: label + renderer + inline error. Encapsulated as its own
160
+ // component so a STATIC enum select whose options are all gated out by a sibling
161
+ // value (`when`) can hide the ENTIRE row (label included) and run its reset
162
+ // effect with valid hook ordering.
163
+ function FieldRow({ field, value, onChange, values, error, initialValues }: FieldRowProps) {
164
+ const isStaticSelect =
165
+ resolveWidget(field) === 'select' && !field.ref && Array.isArray(field.options)
166
+ const effectiveOptions = isStaticSelect
167
+ ? applyOptionWhen(field.options, values, getDependsOn(field))
168
+ : undefined
169
+
170
+ // Reset a selection that the current sibling value no longer permits (e.g.
171
+ // the parent switched away from the value that made this option valid).
172
+ useEffect(() => {
173
+ if (!isStaticSelect || !effectiveOptions) return
174
+ if (value && !effectiveOptions.some((o) => String(o.value) === String(value))) {
175
+ onChange('')
176
+ }
177
+ }, [isStaticSelect, effectiveOptions, value, onChange])
178
+
179
+ // No option applies under the current sibling value → hide the whole field.
180
+ if (isStaticSelect && effectiveOptions && effectiveOptions.length === 0) return null
181
+
182
+ const fullWidth =
183
+ isLineItemsField(field) ||
184
+ resolveWidget(field) === 'textarea' ||
185
+ resolveWidget(field) === 'richtext'
186
+ return (
187
+ <div className={'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : '')}>
188
+ <Label htmlFor={field.key}>
189
+ {field.label}
190
+ {field.required && <span className="text-red-500 ml-1">*</span>}
191
+ </Label>
192
+ <FieldRenderer
193
+ field={field}
194
+ value={value}
195
+ onChange={onChange}
196
+ initialValues={initialValues}
197
+ values={values}
198
+ effectiveOptions={effectiveOptions}
199
+ />
200
+ {error && <span className="text-red-500 text-sm" role="alert">{error}</span>}
201
+ </div>
202
+ )
161
203
  }
162
204
 
163
205
  // seedOptionFromSibling builds a pre-resolved option for an FK field from the
@@ -187,7 +229,13 @@ function seedOptionFromSibling(
187
229
  }
188
230
  }
189
231
 
190
- function FieldRenderer({ field, value, onChange, initialValues }: FieldRendererProps) {
232
+ function FieldRenderer({
233
+ field,
234
+ value,
235
+ onChange,
236
+ initialValues,
237
+ effectiveOptions,
238
+ }: FieldRendererProps & { effectiveOptions?: import('./types').OptionDef[] }) {
191
239
  // Repeatable line-items group → render the row grid. Its value is an array
192
240
  // of row objects rather than a scalar.
193
241
  if (isLineItemsField(field)) {
@@ -228,7 +276,7 @@ function FieldRenderer({ field, value, onChange, initialValues }: FieldRendererP
228
276
  <Select value={value || ''} onValueChange={onChange}>
229
277
  <SelectTrigger className="w-full"><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
230
278
  <SelectContent>
231
- {field.options?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
279
+ {(effectiveOptions ?? field.options)?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
232
280
  </SelectContent>
233
281
  </Select>
234
282
  )
@@ -123,7 +123,7 @@ import { useMetadataCache } from './metadata-cache'
123
123
  import { ActivityValueRenderer } from './activity-value-renderer'
124
124
  import { DynamicIcon } from './dynamic-icon'
125
125
  import { isColumnVisibleInTable } from './column-visibility'
126
- import { isRowActionVisible } from './dynamic-columns'
126
+ import { isRowActionVisible, relationKeyFor } from './dynamic-columns'
127
127
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
128
128
  import { useDynamicRowActions } from './dynamic-row-actions'
129
129
  import { useStageLayout } from './stage-layout'
@@ -323,6 +323,27 @@ export function selectCardColumns(
323
323
  return { title, fields }
324
324
  }
325
325
 
326
+ /** The all-zeros UUID — a Go zero-value FK serialized as "set" when it isn't. */
327
+ const ZERO_UUID = /^0{8}-0{4}-0{4}-0{4}-0{12}$/
328
+
329
+ /**
330
+ * The value a card cell should render for a column — same resolution the
331
+ * table's cells apply. An FK column (`<rel>_id`) prefers the backend-resolved
332
+ * sibling object (`card.<rel>`, e.g. `{ name }` / `{ value, label }`) over the
333
+ * raw UUID; a zero-UUID FK counts as unset (renders the em-dash). Pure —
334
+ * exported for unit tests.
335
+ */
336
+ export function cardCellValue(card: any, col: ColumnDefinition): unknown {
337
+ const raw = card?.[col.key]
338
+ if (typeof raw === 'string' && ZERO_UUID.test(raw)) return null
339
+ const relKey = relationKeyFor(col)
340
+ if (relKey !== col.key) {
341
+ const sibling = card?.[relKey]
342
+ if (sibling && typeof sibling === 'object') return sibling
343
+ }
344
+ return raw
345
+ }
346
+
326
347
  /**
327
348
  * Whether a card passes a lane funnel. Picked select/facet `values` match by
328
349
  * equality (IN — the card's field value must be one of them); a free-text
@@ -2342,7 +2363,7 @@ function KanbanCard({
2342
2363
  <div className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
2343
2364
  {titleCol ? (
2344
2365
  <ActivityValueRenderer
2345
- value={card[titleCol.key]}
2366
+ value={cardCellValue(card, titleCol)}
2346
2367
  col={titleCol}
2347
2368
  locale={locale}
2348
2369
  timeZone={timeZone}
@@ -2394,7 +2415,7 @@ function KanbanCard({
2394
2415
  <span className="shrink-0 opacity-70">{col.label}:</span>
2395
2416
  <span className="min-w-0 break-words">
2396
2417
  <ActivityValueRenderer
2397
- value={card[col.key]}
2418
+ value={cardCellValue(card, col)}
2398
2419
  col={col}
2399
2420
  locale={locale}
2400
2421
  timeZone={timeZone}
@@ -2423,7 +2444,7 @@ function CardPreview({
2423
2444
  <div className="break-words text-sm font-medium leading-snug">
2424
2445
  {titleCol ? (
2425
2446
  <ActivityValueRenderer
2426
- value={card[titleCol.key]}
2447
+ value={cardCellValue(card, titleCol)}
2427
2448
  col={titleCol}
2428
2449
  locale={locale}
2429
2450
  timeZone={timeZone}
@@ -2441,7 +2462,7 @@ function CardPreview({
2441
2462
  <span className="shrink-0 opacity-70">{col.label}:</span>
2442
2463
  <span className="min-w-0 break-words">
2443
2464
  <ActivityValueRenderer
2444
- value={card[col.key]}
2465
+ value={cardCellValue(card, col)}
2445
2466
  col={col}
2446
2467
  locale={locale}
2447
2468
  timeZone={timeZone}
@@ -31,6 +31,7 @@ import {
31
31
  resolveDependsValue,
32
32
  getOptionsConfig,
33
33
  resolveOptionsSource,
34
+ applyOptionWhen,
34
35
  } from './dynamic-form-schema'
35
36
  import { DynamicSelectField, DEFAULT_DEPENDS_HINT } from './dynamic-select-field'
36
37
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
@@ -271,6 +272,25 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
271
272
  const dependsValue = getDependsOn(field)
272
273
  ? resolveDependsValue(field, formValues, rowValues)
273
274
  : undefined
275
+
276
+ // STATIC enum options gated per-cell by a sibling (row) or header value.
277
+ // Row values win over the header when a key exists in both, matching
278
+ // `resolveDependsValue`. Filtered against each option's `when`.
279
+ const isStaticSelect =
280
+ widget === 'select' && !field.ref && !getOptionsConfig(field)?.source && Array.isArray(field.options)
281
+ const gateValues = isStaticSelect ? { ...(formValues ?? {}), ...(rowValues ?? {}) } : undefined
282
+ const effectiveOptions = isStaticSelect
283
+ ? applyOptionWhen(field.options, gateValues, getDependsOn(field))
284
+ : undefined
285
+
286
+ // Reset a selection the current sibling value no longer permits.
287
+ useEffect(() => {
288
+ if (!isStaticSelect || !effectiveOptions) return
289
+ if (value && !effectiveOptions.some((o) => String(o.value) === String(value))) {
290
+ onChange('')
291
+ }
292
+ }, [isStaticSelect, effectiveOptions, value, onChange])
293
+
274
294
  // Async searchable picker per row cell — e.g. the account_id column of a
275
295
  // journal entry's debit/credit lines. Same widget as the flat form.
276
296
  if (widget === 'dynamic_select') {
@@ -309,14 +329,17 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
309
329
  disabled={off}
310
330
  />
311
331
  )
312
- case 'select':
332
+ case 'select': {
333
+ const opts = effectiveOptions ?? field.options
334
+ // No option applies under the current sibling value → hide the cell.
335
+ if (effectiveOptions && effectiveOptions.length === 0) return null
313
336
  return (
314
337
  <Select value={value || ''} onValueChange={onChange} disabled={off}>
315
338
  <SelectTrigger className="w-full">
316
339
  <SelectValue placeholder={field.placeholder || 'Seleccionar...'} />
317
340
  </SelectTrigger>
318
341
  <SelectContent>
319
- {field.options?.map((opt) => (
342
+ {opts?.map((opt) => (
320
343
  <SelectItem key={opt.value} value={opt.value}>
321
344
  {opt.label}
322
345
  </SelectItem>
@@ -324,6 +347,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
324
347
  </SelectContent>
325
348
  </Select>
326
349
  )
350
+ }
327
351
  case 'switch':
328
352
  return <Switch checked={!!value} onCheckedChange={onChange} disabled={off} />
329
353
  case 'number':
@@ -67,7 +67,7 @@ import { useApi, useCurrentBranch } from './api-context'
67
67
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
68
68
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
69
69
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
70
- import { FilterChipsRow, translateOptionLabels, type FilterChipField } from './filter-chips'
70
+ import { translateOptionLabels } from './filter-chips'
71
71
  import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
72
72
  import { OptionsContext } from './options-context'
73
73
  import type { TableMetadata, ApiResponse } from './types'
@@ -209,6 +209,10 @@ export function DynamicTable({
209
209
  if (colonIdx === -1) return value
210
210
  const prefix = value.substring(0, colonIdx).toLowerCase()
211
211
  const rest = value.substring(colonIdx + 1)
212
+ // `eq:` is the wire's explicit equality operator, but internally a
213
+ // select stores the bare value — unwrap it so `f_status=eq:reception`
214
+ // matches the option "reception" (header filter + chip label).
215
+ if (prefix === 'eq') return rest
212
216
  const operator = urlAliasToOperator[prefix]
213
217
  return operator ? `${operator}:${rest}` : value
214
218
  }
@@ -252,6 +256,11 @@ export function DynamicTable({
252
256
  if (Object.keys(filters).length > 0) setDynamicFilters(filters)
253
257
  }, []) // eslint-disable-line react-hooks/exhaustive-deps
254
258
 
259
+ // The exact query string this table last wrote to the URL — lets the
260
+ // resync effect below tell "our own replaceState" apart from an external
261
+ // rewrite by the host router.
262
+ const lastSelfSearch = useRef<string | null>(null)
263
+
255
264
  useEffect(() => {
256
265
  if (!enableUrlSync || !initializedFromUrl.current) return
257
266
  const params = new URLSearchParams()
@@ -283,9 +292,40 @@ export function DynamicTable({
283
292
  })
284
293
  const search = params.toString()
285
294
  const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname
295
+ lastSelfSearch.current = search ? `?${search}` : ''
286
296
  window.history.replaceState(null, '', newUrl)
287
297
  }, [enableUrlSync, pagination, sorting, globalFilter, dynamicFilters, defaultFilters])
288
298
 
299
+ // The host router can rewrite the query string WITHOUT remounting the
300
+ // table — e.g. sidebar sibling entries deep-link different `f_` filters
301
+ // into the same model route. The mount-time init above never re-runs, so
302
+ // the table kept showing the previous filter. On every render where the
303
+ // location's search differs from the last URL we ourselves wrote,
304
+ // re-parse the `f_` params and adopt them.
305
+ const locationSearch = typeof window !== 'undefined' ? window.location.search : ''
306
+ useEffect(() => {
307
+ if (!enableUrlSync || !initializedFromUrl.current) return
308
+ if (locationSearch === lastSelfSearch.current) return
309
+ lastSelfSearch.current = locationSearch
310
+ const params = new URLSearchParams(locationSearch)
311
+ const filters: Record<string, string[]> = {}
312
+ params.forEach((rawValue, key) => {
313
+ if (!key.startsWith('f_')) return
314
+ const filterKey = key.substring(2)
315
+ if (defaultFilters && filterKey in defaultFilters) return
316
+ const value = urlValueToInternal(rawValue)
317
+ if (value.startsWith('IN:')) filters[filterKey] = value.substring(3).split(',')
318
+ else filters[filterKey] = [value]
319
+ })
320
+ setDynamicFilters((prev: Record<string, string[]>) => {
321
+ if (JSON.stringify(prev) === JSON.stringify(filters)) return prev
322
+ setPagination((p: PaginationState) => ({ ...p, pageIndex: 0 }))
323
+ return filters
324
+ })
325
+ const search = params.get('search')
326
+ if (search !== null) setGlobalFilter(search)
327
+ })
328
+
289
329
  const prefetchOptions = useCallback(async (endpoints: string[]) => {
290
330
  if (endpoints.length === 0) return new Map<string, any[]>()
291
331
  const uniqueEndpoints = Array.from(new Set(endpoints))
@@ -786,30 +826,6 @@ export function DynamicTable({
786
826
  prefetchFacets(facetFieldsSig.split('|'))
787
827
  }, [facetFieldsSig, prefetchFacets])
788
828
 
789
- // Active-filter chips (shared row with the kanban). One chip per field with a
790
- // selection; the label is the translated filter/column label.
791
- const activeFilterChips = useMemo<FilterChipField[]>(() => {
792
- if (!metadata) return []
793
- const out: FilterChipField[] = []
794
- for (const [key, config] of columnFilterConfigs) {
795
- if ((config.selectedValues?.length ?? 0) === 0) continue
796
- const f = metadata.filters?.find((x) => x.key === key)
797
- const c = metadata.columns?.find((x) => x.key === key)
798
- const rawLabel = f?.label || c?.label || key
799
- out.push({
800
- key,
801
- label: t(rawLabel, { defaultValue: rawLabel }),
802
- config,
803
- })
804
- }
805
- return out
806
- }, [metadata, columnFilterConfigs, t])
807
-
808
- const clearAllDynamicFilters = useCallback(() => {
809
- setDynamicFilters({})
810
- setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }))
811
- }, [])
812
-
813
829
  const columns = useMemo(() => {
814
830
  if (!viewMetadata) return []
815
831
  // Row-action column only renders per-row actions. Table-level placements
@@ -929,17 +945,6 @@ export function DynamicTable({
929
945
  </>
930
946
  }
931
947
  />
932
- {/* Active-filter chips — shared with the kanban. Quick
933
- visibility + one-click removal of the header filters. */}
934
- {activeFilterChips.length > 0 && (
935
- <div className='pt-2'>
936
- <FilterChipsRow
937
- fields={activeFilterChips}
938
- onClearAll={clearAllDynamicFilters}
939
- data-testid='table-filter-chips'
940
- />
941
- </div>
942
- )}
943
948
  </div>
944
949
  {/* Desktop: classic horizontal-scroll table. Hidden on phones —
945
950
  a 7-column table forces a wide horizontal scroll there, so we