@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.
- package/CHANGELOG.md +12 -0
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +18 -10
- package/dist/dialogs/dynamic-record.d.ts +10 -2
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +67 -36
- package/dist/dynamic-form-schema.d.ts +27 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +79 -0
- package/dist/dynamic-select-field.d.ts +3 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +3 -2
- package/dist/dynamic-table.d.ts +1 -8
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +100 -20
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +7 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/dynamic-record-field-validation.test.tsx +110 -0
- package/src/__tests__/visible-when.test.ts +41 -1
- package/src/addon-loader.tsx +22 -10
- package/src/dialogs/dynamic-record.tsx +86 -32
- package/src/dynamic-form-schema.ts +81 -0
- package/src/dynamic-select-field.tsx +8 -1
- package/src/dynamic-table.tsx +89 -27
- package/src/index.ts +3 -0
- package/src/types.ts +7 -6
|
@@ -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
|
-
|
|
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
|
-
//
|
|
822
|
-
//
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
>
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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 }) => {
|
|
714
|
-
|
|
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
|
-
|
|
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
|
|
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>) => !
|
|
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,
|
|
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
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
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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`. */
|