@asteby/metacore-runtime-react 37.0.2 → 37.0.4

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.
@@ -9,7 +9,7 @@
9
9
  // flows through <ApiProvider> from runtime-react. Host-specific runtime values —
10
10
  // the image-url resolver and the org IANA timezone — are passed as props so the
11
11
  // SDK stays transport- and host-agnostic.
12
- import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
12
+ import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
13
13
  import { useTranslation } from 'react-i18next'
14
14
  import type { ModelSchema } from './types'
15
15
 
@@ -53,7 +53,7 @@ import { es } from 'date-fns/locale'
53
53
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
54
54
  import { BarcodeScanner } from '../barcode-scanner'
55
55
  import { useApi } from '../api-context'
56
- import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
56
+ import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeFieldErrorMap } from '../server-error'
57
57
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
58
58
  import { DynamicRelations } from '../dynamic-relations'
59
59
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
@@ -64,6 +64,7 @@ import { FieldSection, WizardProgress } from '../form-layout-ui'
64
64
  import { FieldCell } from '../field-grid'
65
65
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
66
66
  import { normalizeRefFieldsForSubmit } from './normalize-submit'
67
+ import { validateValues, bagHasErrors } from '../validator'
67
68
  import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
68
69
  import { IconPickerField } from '../icon-picker-field'
69
70
  import { humanizeToken } from '../dynamic-columns-helpers'
@@ -224,6 +225,12 @@ export interface DynamicRecordDialogProps {
224
225
  * lets it close while the depth lock is held.
225
226
  */
226
227
  nestedInlineCreateSelf?: boolean
228
+ /**
229
+ * Fields merged into the modal schema after load (by key). Existing keys are
230
+ * shallow-merged; missing keys are prepended. Hosts use this to inject
231
+ * required scope fields (e.g. branch_id) omitted from compiled DefineModal.
232
+ */
233
+ ensureFields?: FieldDef[]
227
234
  mode: 'view' | 'edit' | 'create'
228
235
  model: string
229
236
  recordId?: string | null
@@ -567,10 +574,26 @@ export function stripHiddenFieldValues(
567
574
  return out
568
575
  }
569
576
 
577
+ function applyEnsureFields(meta: ModalMetadata | null | undefined, ensureFields?: FieldDef[]): ModalMetadata | null {
578
+ if (!meta) return meta ?? null
579
+ if (!ensureFields?.length) return meta
580
+ const fields = Array.isArray(meta.fields) ? [...meta.fields] : []
581
+ for (const ensure of ensureFields) {
582
+ const idx = fields.findIndex((f) => f?.key === ensure.key)
583
+ if (idx >= 0) {
584
+ fields[idx] = { ...fields[idx], ...ensure }
585
+ } else {
586
+ fields.unshift(ensure)
587
+ }
588
+ }
589
+ return { ...meta, fields }
590
+ }
591
+
570
592
  export function DynamicRecordDialog({
571
593
  open,
572
594
  onOpenChange,
573
595
  nestedInlineCreateSelf,
596
+ ensureFields,
574
597
  mode,
575
598
  model,
576
599
  recordId,
@@ -602,6 +625,9 @@ export function DynamicRecordDialog({
602
625
  // inline under each input; populated from a 422 `errors` map or the client
603
626
  // required-field check, cleared per-field on change and wholesale on reopen.
604
627
  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
628
+ // Unique form id per dialog instance — nested create must not share
629
+ // id={formId} or the child footer submits the parent.
630
+ const formId = useId()
605
631
  const [loading, setLoading] = useState(false)
606
632
  const [saving, setSaving] = useState(false)
607
633
  const [deleting, setDeleting] = useState(false)
@@ -663,6 +689,7 @@ export function DynamicRecordDialog({
663
689
  if (cancelled) return
664
690
  meta = metaRes.data?.data ?? metaRes.data
665
691
  }
692
+ meta = applyEnsureFields(meta, ensureFields)
666
693
  setModalMeta(meta)
667
694
 
668
695
  if (isCreate) {
@@ -710,7 +737,7 @@ export function DynamicRecordDialog({
710
737
  // initialRecord intentionally omitted: the row identity is captured per open
711
738
  // via recordId; re-seeding mid-open would clobber edits.
712
739
  // eslint-disable-next-line react-hooks/exhaustive-deps
713
- }, [open, recordId, model, endpoint, isCreate, schema])
740
+ }, [open, recordId, model, endpoint, isCreate, schema, ensureFields])
714
741
 
715
742
  // Reset when closed
716
743
  useEffect(() => {
@@ -807,7 +834,17 @@ export function DynamicRecordDialog({
807
834
  next[key] = localizeFieldIssue(issues[0], labelForKey(key), t)
808
835
  }
809
836
  setFieldErrors(next)
810
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
837
+ const visibleKeys = new Set(
838
+ filterVisibleFields(modalMeta?.fields ?? [], mode, formValues).map(f => f.key),
839
+ )
840
+ const orphans = Object.entries(next).filter(([k]) => !visibleKeys.has(k))
841
+ const description = orphans.length
842
+ ? orphans.map(([k, msg]) => `${labelForKey(k)}: ${msg}`).join(' · ')
843
+ : undefined
844
+ toast.error(
845
+ t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }),
846
+ description ? { description } : undefined,
847
+ )
811
848
  return
812
849
  }
813
850
  toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
@@ -818,20 +855,24 @@ export function DynamicRecordDialog({
818
855
  if (!modalMeta) return
819
856
 
820
857
  if (isEditable) {
821
- // Collect ALL missing required fields (not just the first) and mark
822
- // each inline instead of a single toast. Only CURRENTLY-VISIBLE
823
- // fields are gated: a field hidden by its `visible_when` predicate
824
- // must not block submit even when it is declared required (matching
825
- // the render, which drops it via the same filter).
826
- const missing: Record<string, string> = {}
827
- for (const field of filterVisibleFields(modalMeta.fields, mode, formValues)) {
828
- if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
829
- missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
830
- }
831
- }
832
- if (Object.keys(missing).length) {
833
- setFieldErrors(missing)
834
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
858
+ // Laravel-style: collect every issue from the shared validator
859
+ // (required + rule strings / min/max / email…) on visible fields only.
860
+ const visible = filterVisibleFields(modalMeta.fields, mode, formValues)
861
+ const bag = validateValues(visible as ActionFieldDef[], formValues)
862
+ if (bagHasErrors(bag)) {
863
+ const labels: Record<string, string> = {}
864
+ for (const f of visible) labels[f.key] = f.label
865
+ const next = localizeFieldErrorMap(bag, t, { labels })
866
+ setFieldErrors(next)
867
+ const visibleKeys = new Set(visible.map(f => f.key))
868
+ const orphans = Object.entries(next).filter(([k]) => !visibleKeys.has(k))
869
+ const description = orphans.length
870
+ ? orphans.map(([k, msg]) => `${labelForKey(k)}: ${msg}`).join(' · ')
871
+ : undefined
872
+ toast.error(
873
+ t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }),
874
+ description ? { description } : undefined,
875
+ )
835
876
  return
836
877
  }
837
878
  }
@@ -973,14 +1014,12 @@ export function DynamicRecordDialog({
973
1014
  // then advance. Mirrors handleSubmit's required check but scoped to the step.
974
1015
  const goNextStep = () => {
975
1016
  const step = groups[clampedStep]
976
- const missing: Record<string, string> = {}
977
- for (const field of step?.fields ?? []) {
978
- if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
979
- missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
980
- }
981
- }
982
- if (Object.keys(missing).length) {
983
- setFieldErrors(missing)
1017
+ const stepFields = step?.fields ?? []
1018
+ const bag = validateValues(stepFields as ActionFieldDef[], formValues)
1019
+ if (bagHasErrors(bag)) {
1020
+ const labels: Record<string, string> = {}
1021
+ for (const f of stepFields) labels[f.key] = f.label
1022
+ setFieldErrors(localizeFieldErrorMap(bag, t, { labels }))
984
1023
  toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
985
1024
  return
986
1025
  }
@@ -1014,7 +1053,7 @@ export function DynamicRecordDialog({
1014
1053
  cell `min-w-0` so a long select/input value can't
1015
1054
  blow the two columns past the dialog width. */}
1016
1055
  <form
1017
- id="dynamic-record-form"
1056
+ id={formId}
1018
1057
  onSubmit={handleSubmit}
1019
1058
  className="grid gap-y-4"
1020
1059
  >
@@ -1124,7 +1163,7 @@ export function DynamicRecordDialog({
1124
1163
  {isEditable && (!isSteps || isLastStep) && (
1125
1164
  <Button
1126
1165
  type="submit"
1127
- form="dynamic-record-form"
1166
+ form={formId}
1128
1167
  disabled={saving || loading}
1129
1168
  >
1130
1169
  {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
@@ -1193,7 +1232,7 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
1193
1232
  ) : isEditReadonly ? (
1194
1233
  <ReadonlyEditField field={field} value={value} />
1195
1234
  ) : (
1196
- <EditField field={field} value={value} onChange={onChange} record={record} />
1235
+ <EditField field={field} value={value} onChange={onChange} record={record} invalid={!!error} />
1197
1236
  )}
1198
1237
 
1199
1238
  {error && mode !== 'view' && (
@@ -1836,13 +1875,19 @@ function JsonObjectViewValue({ value }: { value: Record<string, unknown> }) {
1836
1875
  )
1837
1876
  }
1838
1877
 
1839
- export function EditField({ field, value, onChange, record }: {
1878
+ export function EditField({ field, value, onChange, record, invalid }: {
1840
1879
  field: FieldDef
1841
1880
  value: any
1842
1881
  onChange: (val: any) => void
1843
1882
  /** The full record being edited — supplies FK relation siblings + line-items. */
1844
1883
  record?: any
1884
+ /** When true, paint the control with a destructive border (Laravel-style). */
1885
+ invalid?: boolean
1845
1886
  }) {
1887
+ const invalidCls = invalid
1888
+ ? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive aria-invalid:border-destructive'
1889
+ : undefined
1890
+
1846
1891
  const { t, i18n } = useTranslation()
1847
1892
  const editFieldImageUrl = useContext(ImageUrlContext)
1848
1893
  const dialogModel = useContext(RecordDialogModelContext)
@@ -1892,6 +1937,8 @@ export function EditField({ field, value, onChange, record }: {
1892
1937
  onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
1893
1938
  placeholder={field.placeholder}
1894
1939
  rows={4}
1940
+ aria-invalid={invalid || undefined}
1941
+ className={invalidCls}
1895
1942
  />
1896
1943
  )
1897
1944
  }
@@ -1952,6 +1999,7 @@ export function EditField({ field, value, onChange, record }: {
1952
1999
  // for the popover to open and fetch a page.
1953
2000
  seedOption={fkSeedOption(field, value, record)}
1954
2001
  hideCreate={hideSelfCreate}
2002
+ invalid={invalid}
1955
2003
  />
1956
2004
  )
1957
2005
  }
@@ -1967,7 +2015,7 @@ export function EditField({ field, value, onChange, record }: {
1967
2015
  if (field.type === 'select' && field.options?.length) {
1968
2016
  return (
1969
2017
  <Select value={String(value ?? '')} onValueChange={onChange}>
1970
- <SelectTrigger className="w-full">
2018
+ <SelectTrigger className={cn("w-full", invalidCls)} aria-invalid={invalid || undefined}>
1971
2019
  <SelectValue placeholder="Seleccionar..." />
1972
2020
  </SelectTrigger>
1973
2021
  <SelectContent>
@@ -2043,7 +2091,7 @@ export function EditField({ field, value, onChange, record }: {
2043
2091
  ? 'email'
2044
2092
  : 'text'
2045
2093
 
2046
- return <ScannableRecordInput field={field} value={value} onChange={onChange} inputType={inputType} />
2094
+ return <ScannableRecordInput field={field} value={value} onChange={onChange} inputType={inputType} invalid={invalid} className={invalidCls} />
2047
2095
  }
2048
2096
 
2049
2097
  /**
@@ -2062,11 +2110,15 @@ function ScannableRecordInput({
2062
2110
  value,
2063
2111
  onChange,
2064
2112
  inputType,
2113
+ invalid,
2114
+ className,
2065
2115
  }: {
2066
2116
  field: FieldDef
2067
2117
  value: any
2068
2118
  onChange: (val: any) => void
2069
2119
  inputType: string
2120
+ invalid?: boolean
2121
+ className?: string
2070
2122
  }) {
2071
2123
  const [scanOpen, setScanOpen] = useState(false)
2072
2124
  // El botón de escaneo aparece siempre que el campo declara `scan` (como el
@@ -2086,6 +2138,8 @@ function ScannableRecordInput({
2086
2138
  )
2087
2139
  }
2088
2140
  placeholder={field.placeholder}
2141
+ aria-invalid={invalid || undefined}
2142
+ className={className}
2089
2143
  />
2090
2144
  )
2091
2145
  if (!scanEnabled) return input
@@ -382,6 +382,87 @@ export function evaluateVisibleWhen(
382
382
  return true
383
383
  }
384
384
 
385
+ /**
386
+ * Strip a DynamicTable / URL filter token down to the comparable scalar the
387
+ * kernel `visible_when.equals` / `.in` predicates expect.
388
+ *
389
+ * `eq:customer` → `customer`, bare `customer` stays, `in:a,b` keeps the raw
390
+ * multi-value (list-scope only gates on known single-eq scopes today).
391
+ */
392
+ export function scopeValueFromFilterToken(raw: unknown): string {
393
+ if (raw == null) return ''
394
+ const s = String(raw)
395
+ const i = s.indexOf(':')
396
+ if (i <= 0) return s
397
+ const op = s.slice(0, i).toLowerCase()
398
+ const rest = s.slice(i + 1)
399
+ if (
400
+ op === 'eq' ||
401
+ op === 'neq' ||
402
+ op === 'gt' ||
403
+ op === 'gte' ||
404
+ op === 'lt' ||
405
+ op === 'lte' ||
406
+ op === 'like' ||
407
+ op === 'ilike' ||
408
+ op === 'contains'
409
+ ) {
410
+ return rest
411
+ }
412
+ return s
413
+ }
414
+
415
+ /**
416
+ * Flat "known field → value" map for list/board surfaces. Built from locked
417
+ * `defaultFilters` (nav / branch scope) plus active `dynamicFilters`. Only
418
+ * fields present here are considered known — see
419
+ * `evaluateVisibleWhenForListScope`.
420
+ */
421
+ export function buildListScopeValues(
422
+ defaultFilters?: Record<string, unknown> | null,
423
+ dynamicFilters?: Record<string, string[] | undefined> | null,
424
+ ): Record<string, string> {
425
+ const out: Record<string, string> = {}
426
+ if (defaultFilters) {
427
+ for (const [key, value] of Object.entries(defaultFilters)) {
428
+ const v = scopeValueFromFilterToken(value)
429
+ if (v !== '') out[key] = v
430
+ }
431
+ }
432
+ if (dynamicFilters) {
433
+ for (const [key, values] of Object.entries(dynamicFilters)) {
434
+ if (defaultFilters && key in defaultFilters) continue
435
+ if (!values || values.length !== 1) continue
436
+ const v = scopeValueFromFilterToken(values[0])
437
+ if (v !== '') out[key] = v
438
+ }
439
+ }
440
+ return out
441
+ }
442
+
443
+ /**
444
+ * List/board variant of `evaluateVisibleWhen`.
445
+ *
446
+ * Forms always have a live sibling value (or ''). Lists often do not — a
447
+ * mixed AccountStatement table has no single `party_type`. Hiding every
448
+ * `visible_when` column when the governing field is unknown would wipe both
449
+ * Cliente and Proveedor on the unscoped view. Rule: if the governing field is
450
+ * not in `scope` (or is empty), keep the column; once the scope pins it
451
+ * (sidebar locked_scope / defaultFilters / a single-eq chip), apply the same
452
+ * predicate as the form.
453
+ */
454
+ export function evaluateVisibleWhenForListScope(
455
+ cond: VisibleWhen | null | undefined,
456
+ scope: Record<string, any> | null | undefined,
457
+ ): boolean {
458
+ if (!cond || typeof cond.field !== 'string' || cond.field.trim() === '') return true
459
+ const key = cond.field.trim()
460
+ if (!scope || !(key in scope)) return true
461
+ const raw = scope[key]
462
+ if (raw == null || String(raw) === '') return true
463
+ return evaluateVisibleWhen(cond, scope)
464
+ }
465
+
385
466
  /**
386
467
  * Reads a field's enriched options-resolution config, tolerating the camelCase
387
468
  * `optionsConfig` (authored SDK shape) and the snake_case `options_config` the
@@ -204,6 +204,8 @@ export interface DynamicSelectFieldProps {
204
204
  * host's create modal renders them locked. Pairs with `createDefaults`.
205
205
  */
206
206
  createLockedFields?: string[]
207
+ /** Paint trigger with destructive border when validation failed. */
208
+ invalid?: boolean
207
209
  }
208
210
 
209
211
  export function DynamicSelectField({
@@ -219,6 +221,7 @@ export function DynamicSelectField({
219
221
  hideCreate = false,
220
222
  createDefaults,
221
223
  createLockedFields,
224
+ invalid = false,
222
225
  }: DynamicSelectFieldProps) {
223
226
  const { t } = useTranslation()
224
227
  const ph = (fallback: string) =>
@@ -398,7 +401,11 @@ export function DynamicSelectField({
398
401
  aria-expanded={open}
399
402
  id={field.key}
400
403
  disabled={blockedByDependency}
401
- className="min-w-0 flex-1 justify-between font-normal"
404
+ aria-invalid={invalid || undefined}
405
+ className={
406
+ 'min-w-0 flex-1 justify-between font-normal' +
407
+ (invalid ? ' border-destructive ring-1 ring-destructive/30' : '')
408
+ }
402
409
  data-empty={!value}
403
410
  data-depends-blocked={blockedByDependency ? '' : undefined}
404
411
  >
@@ -64,20 +64,25 @@ import { toast } from 'sonner'
64
64
  import { Progress } from './dialogs/_primitives'
65
65
  import { useMetadataCache } from './metadata-cache'
66
66
  import { useApi, useCurrentBranch } from './api-context'
67
- import { useRealtimeDefault, useRealtimeTick } from './realtime-context'
68
67
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
69
68
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
70
69
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
71
70
  import { translateOptionLabels } from './filter-chips'
72
71
  import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
73
72
  import { OptionsContext } from './options-context'
74
- import type { TableMetadata, ApiResponse } from './types'
73
+ import type { TableMetadata, ApiResponse, ColumnDefinition } from './types'
75
74
  import { getSearchableColumnKeys } from './column-visibility'
76
75
  import { useDebouncedValue } from './use-debounced-value'
77
76
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
78
77
  import { useDynamicRowActions } from './dynamic-row-actions'
79
78
  import { ExportDialog } from './dialogs/export'
80
79
  import { ImportDialog } from './dialogs/import'
80
+ import {
81
+ buildListScopeValues,
82
+ evaluateVisibleWhenForListScope,
83
+ getVisibleWhen,
84
+ scopeValueFromFilterToken,
85
+ } from './dynamic-form-schema'
81
86
 
82
87
  // ---------------------------------------------------------------------------
83
88
  // Row-data cache (perceived performance).
@@ -197,13 +202,6 @@ export interface DynamicTableProps {
197
202
  */
198
203
  onRowClick?: (row: any) => void
199
204
  refreshTrigger?: any
200
- /**
201
- * Refetch when the host's realtime client reports a data event for this
202
- * model (created/updated/deleted by anyone in the org, or a `resync`).
203
- * Off by default; `<RealtimeProvider defaultRealtime>` turns it on for
204
- * every table, and an explicit prop always wins. No-op without a client.
205
- */
206
- realtime?: boolean
207
205
  defaultFilters?: Record<string, any>
208
206
  extraColumns?: ColumnDef<any>[]
209
207
  /**
@@ -261,7 +259,6 @@ export function DynamicTable({
261
259
  onAction,
262
260
  onRowClick,
263
261
  refreshTrigger,
264
- realtime: realtimeProp,
265
262
  defaultFilters,
266
263
  extraColumns = [],
267
264
  getDynamicColumns = defaultGetDynamicColumns,
@@ -276,13 +273,6 @@ export function DynamicTable({
276
273
  const { t, i18n } = useTranslation()
277
274
  const api = useApi()
278
275
  const currentBranch = useCurrentBranch()
279
- // Realtime refetch (opt-in): a debounced counter that bumps on every
280
- // DATA_EVENT for this model and rides the same deps as `refreshTrigger`.
281
- const realtimeDefault = useRealtimeDefault()
282
- const realtimeTick = useRealtimeTick({
283
- models: [model],
284
- enabled: realtimeProp ?? realtimeDefault,
285
- })
286
276
 
287
277
  const prevBranchId = useRef(currentBranch?.id)
288
278
 
@@ -512,6 +502,22 @@ export function DynamicTable({
512
502
  if (values.length === 1) params.set(`f_${key}`, internalValueToUrl(values[0]))
513
503
  else params.set(`f_${key}`, `in:${values.join(',')}`)
514
504
  })
505
+ // Re-pin locked scope into the URL so sidebar matching + deep-links keep
506
+ // working after "Limpiar filtros" (those keys are not in dynamicFilters).
507
+ if (defaultFilters) {
508
+ Object.entries(defaultFilters).forEach(([key, value]) => {
509
+ params.set(`f_${key}`, internalValueToUrl(String(value ?? '')))
510
+ })
511
+ }
512
+ // Preserve f_* already in the location that we did not rebuild above.
513
+ // Sidebar deep-links (CxC→CxP) push f_party_type=eq:supplier before React
514
+ // re-renders with matching defaultFilters; without this carry-through the
515
+ // write effect races and strips the filter down to bare ?view=list.
516
+ current.forEach((value, key) => {
517
+ if (!key.startsWith('f_')) return
518
+ if (params.has(key)) return
519
+ params.set(key, value)
520
+ })
515
521
  const search = params.toString()
516
522
  // If what we'd write is semantically identical to what's already in the
517
523
  // bar (only key order / colon-encoding differ from the router's form),
@@ -695,6 +701,52 @@ export function DynamicTable({
695
701
  !hideImport && (viewMetadata?.canImport ?? Boolean(viewMetadata?.import?.columns?.length))
696
702
  const exportEnabled = !hideExport && Boolean(viewMetadata?.canExport)
697
703
 
704
+ const listScopeValues = useMemo(() => {
705
+ const base = buildListScopeValues(defaultFilters, dynamicFilters)
706
+ // Deep-links pin f_* in the URL before defaultFilters / nav matching
707
+ // catch up; adopt single-eq scope values so visible_when columns hide
708
+ // on first paint (CxC hides Proveedor without waiting for navFilter).
709
+ if (!enableUrlSync || typeof window === 'undefined') return base
710
+ const params = new URLSearchParams(window.location.search)
711
+ const fromUrl: Record<string, string> = {}
712
+ params.forEach((raw, key) => {
713
+ if (!key.startsWith('f_')) return
714
+ const col = key.slice(2)
715
+ if (!col || col in base) return
716
+ const v = scopeValueFromFilterToken(raw)
717
+ if (!v) return
718
+ // Multi-value / range tokens are not a single list-scope pin.
719
+ if (v.includes(',') || /^(in|not_in|range|gte|lte):/i.test(String(raw))) return
720
+ fromUrl[col] = v
721
+ })
722
+ return Object.keys(fromUrl).length === 0 ? base : { ...fromUrl, ...base }
723
+ }, [defaultFilters, dynamicFilters, enableUrlSync, urlSynced])
724
+
725
+ // Columns whose kernel `visible_when` fails against the known list scope
726
+ // (locked nav defaultFilters, single-eq chips). Host `hiddenColumns` still
727
+ // wins; this is the scalable path so CxC hides Proveedor without each nav
728
+ // item re-listing every allowed column.
729
+ const scopeHiddenColumns = useMemo(() => {
730
+ const cols = (viewMetadata?.columns ?? metadata?.columns ?? []) as ColumnDefinition[]
731
+ if (cols.length === 0 || Object.keys(listScopeValues).length === 0) return [] as string[]
732
+ const hidden: string[] = []
733
+ for (const col of cols) {
734
+ const key = col.key
735
+ if (!key) continue
736
+ if (!evaluateVisibleWhenForListScope(getVisibleWhen(col), listScopeValues)) {
737
+ hidden.push(key)
738
+ }
739
+ }
740
+ return hidden
741
+ }, [metadata, viewMetadata, listScopeValues])
742
+
743
+ const effectiveHiddenColumns = useMemo(() => {
744
+ if (scopeHiddenColumns.length === 0) return hiddenColumns
745
+ const set = new Set(hiddenColumns)
746
+ for (const k of scopeHiddenColumns) set.add(k)
747
+ return Array.from(set)
748
+ }, [hiddenColumns, scopeHiddenColumns])
749
+
698
750
  const buildFilterParams = useCallback(() => {
699
751
  const params: Record<string, any> = {}
700
752
  if (sorting.length > 0) {
@@ -710,9 +762,12 @@ export function DynamicTable({
710
762
  }
711
763
  // searchableKeys === [] → drop the search request entirely
712
764
  }
713
- columnFilters.forEach((filter: { id: string; value: unknown }) => { params[`f_${filter.id}`] = filter.value })
714
- if (defaultFilters) Object.entries(defaultFilters).forEach(([key, value]) => { params[`f_${key}`] = value })
765
+ columnFilters.forEach((filter: { id: string; value: unknown }) => {
766
+ if (defaultFilters && filter.id in defaultFilters) return
767
+ params[`f_${filter.id}`] = filter.value
768
+ })
715
769
  Object.entries(dynamicFilters).forEach(([key, values]) => {
770
+ if (defaultFilters && key in defaultFilters) return
716
771
  if (values.length === 0) return
717
772
  const gteVal = values.find(v => v.startsWith('GTE:'))
718
773
  const lteVal = values.find(v => v.startsWith('LTE:'))
@@ -725,6 +780,8 @@ export function DynamicTable({
725
780
  if (values.length === 1) params[`f_${key}`] = values[0]
726
781
  else params[`f_${key}`] = `IN:${values.join(',')}`
727
782
  })
783
+ // Locked scope last so it always wins over stale dynamic/column filters.
784
+ if (defaultFilters) Object.entries(defaultFilters).forEach(([key, value]) => { params[`f_${key}`] = value })
728
785
  if (dateRange?.from) {
729
786
  const startDate = format(dateRange.from, 'yyyy-MM-dd')
730
787
  const endDate = dateRange.to ? format(dateRange.to, 'yyyy-MM-dd') : startDate
@@ -770,8 +827,7 @@ export function DynamicTable({
770
827
  } finally {
771
828
  setLoadingData(false)
772
829
  }
773
- // eslint-disable-next-line react-hooks/exhaustive-deps
774
- }, [model, metadata, pagination, buildFilterParams, refreshTrigger, realtimeTick, endpoint, currentBranch?.id, api, enableUrlSync])
830
+ }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync])
775
831
 
776
832
  // Columns whose metadata opts into a footer total (display_config.aggregate
777
833
  // → styleConfig.aggregate). When empty, no footer row is rendered and no
@@ -940,9 +996,8 @@ export function DynamicTable({
940
996
  // matching the classic path (fetchData carries refreshTrigger in its
941
997
  // deps). Without it the comment above lied: infinite lists silently
942
998
  // failed to reload after a create ("a veces no recarga la tabla").
943
- // realtimeTick plays the same role for data events (see the `realtime` prop).
944
999
  // eslint-disable-next-line react-hooks/exhaustive-deps
945
- }, [infiniteScroll, metadata, filterSignature, refreshTrigger, realtimeTick])
1000
+ }, [infiniteScroll, metadata, filterSignature, refreshTrigger])
946
1001
 
947
1002
  const handleRefresh = useCallback(() => {
948
1003
  // Infinite mode owns its own list: refresh reloads page 1 and drops the
@@ -997,9 +1052,11 @@ export function DynamicTable({
997
1052
  }
998
1053
 
999
1054
  const handleDynamicFilterChange = useCallback((filterKey: string, values: string[]) => {
1055
+ // Locked scope (nav / branch defaultFilters) cannot be changed from the UI.
1056
+ if (defaultFilters && filterKey in defaultFilters) return
1000
1057
  setDynamicFilters((prev: Record<string, string[]>) => ({ ...prev, [filterKey]: values }))
1001
1058
  setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }))
1002
- }, [])
1059
+ }, [defaultFilters])
1003
1060
 
1004
1061
  // Same facet loader machinery the board uses, so a text column filters
1005
1062
  // identically in the table header and the kanban Sheet (the host's
@@ -1026,6 +1083,9 @@ export function DynamicTable({
1026
1083
  // `filterable: true` — keeps the kernel API minimal (one flag on the
1027
1084
  // column) while still rendering the FilterableColumnHeader.
1028
1085
  for (const f of metadata.filters ?? []) {
1086
+ const filterCol = f.column || f.key
1087
+ if (defaultFilters && filterCol in defaultFilters) continue
1088
+ if (effectiveHiddenColumns.includes(filterCol)) continue
1029
1089
  let fType = f.type as ColumnFilterConfig['filterType']
1030
1090
  let options: { label: string; value: string; icon?: string; color?: string }[] = []
1031
1091
  if (f.options && f.options.length > 0) {
@@ -1069,6 +1129,8 @@ export function DynamicTable({
1069
1129
  }
1070
1130
  for (const c of metadata.columns ?? []) {
1071
1131
  if (!c.filterable || map.has(c.key)) continue
1132
+ if (defaultFilters && c.key in defaultFilters) continue
1133
+ if (effectiveHiddenColumns.includes(c.key)) continue
1072
1134
  const hasStaticOptions = (c.options?.length ?? 0) > 0
1073
1135
  const hasEndpoint = !!c.searchEndpoint
1074
1136
  const isRelation = !!c.ref || c.filterType === 'dynamic_select'
@@ -1133,7 +1195,7 @@ export function DynamicTable({
1133
1195
  })
1134
1196
  }
1135
1197
  return map
1136
- }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader, facetOptions, t])
1198
+ }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader, facetOptions, t, defaultFilters, effectiveHiddenColumns])
1137
1199
 
1138
1200
  // Prewarm every facet field once the configs settle, so a text column's
1139
1201
  // header filter opens instantly with values + counts (same as the kanban).
@@ -1170,11 +1232,11 @@ export function DynamicTable({
1170
1232
  return actions === viewMetadata.actions ? viewMetadata : { ...viewMetadata, actions }
1171
1233
  })()
1172
1234
  const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency)
1173
- const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !hiddenColumns.includes(col.id as string))
1235
+ const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !effectiveHiddenColumns.includes(col.id as string))
1174
1236
  const actionsCol = filteredBase.find((c: ColumnDef<any>) => c.id === 'actions')
1175
1237
  const otherCols = filteredBase.filter((c: ColumnDef<any>) => c.id !== 'actions')
1176
1238
  return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])]
1177
- }, [viewMetadata, handleInternalAction, hiddenColumns, allowedActionKeys, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
1239
+ }, [viewMetadata, handleInternalAction, effectiveHiddenColumns, allowedActionKeys, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
1178
1240
 
1179
1241
  const filters = useMemo(() => [], [])
1180
1242
 
package/src/index.ts CHANGED
@@ -375,6 +375,9 @@ export {
375
375
  resolveOptionsSource,
376
376
  getVisibleWhen,
377
377
  evaluateVisibleWhen,
378
+ scopeValueFromFilterToken,
379
+ buildListScopeValues,
380
+ evaluateVisibleWhenForListScope,
378
381
  } from './dynamic-form-schema'
379
382
  export {
380
383
  ActivityValueRenderer,
package/src/types.ts CHANGED
@@ -337,12 +337,13 @@ export interface ColumnDefinition {
337
337
  /** snake_case alias served by the kernel for `itemFields`. */
338
338
  item_fields?: ColumnItemField[]
339
339
  /**
340
- * Conditional visibility in the create/edit modal: render this field only
341
- * when a sibling field's current value matches the predicate. Mirrors the
342
- * kernel v3 `Column.visible_when` (projected onto the served ColumnDef).
343
- * Tolerates the camelCase alias. Absent = always visible; a hidden field is
344
- * skipped by the required-gate so it never blocks submit. See
345
- * `evaluateVisibleWhen`.
340
+ * Conditional visibility (kernel v3 `Column.visible_when`):
341
+ * - create/edit modal `evaluateVisibleWhen` against live form values
342
+ * - list/board `evaluateVisibleWhenForListScope` against known filter
343
+ * scope (`defaultFilters` / single-eq chips), so a locked nav scope
344
+ * like `party_type=customer` hides `supplier_id` without each nav
345
+ * item re-declaring a full column allowlist.
346
+ * Tolerates the camelCase alias. Absent = always visible.
346
347
  */
347
348
  visible_when?: VisibleWhen
348
349
  /** camelCase alias for `visible_when`. */