@asteby/metacore-runtime-react 37.0.3 → 37.0.5
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/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +41 -23
- 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 +70 -36
- package/dist/dynamic-form.js +12 -2
- package/dist/dynamic-line-items.d.ts +6 -1
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +26 -11
- 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/field-validation-ui.d.ts +22 -0
- package/dist/field-validation-ui.d.ts.map +1 -0
- package/dist/field-validation-ui.js +123 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/__tests__/dynamic-record-field-validation.test.tsx +110 -0
- package/src/__tests__/field-validation-ui.test.ts +57 -0
- package/src/action-modal-dispatcher.tsx +60 -19
- package/src/dialogs/dynamic-record.tsx +89 -32
- package/src/dynamic-form.tsx +12 -4
- package/src/dynamic-line-items.tsx +58 -9
- package/src/dynamic-select-field.tsx +8 -1
- package/src/field-validation-ui.ts +140 -0
- package/src/index.ts +7 -0
|
@@ -40,6 +40,13 @@ import { toast } from 'sonner'
|
|
|
40
40
|
import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
|
|
41
41
|
import type { Translate } from './server-error'
|
|
42
42
|
import { validateValues, bagHasErrors } from './validator'
|
|
43
|
+
import {
|
|
44
|
+
clearFieldErrorTree,
|
|
45
|
+
formatFieldErrorsDescription,
|
|
46
|
+
labelsForValidationFields,
|
|
47
|
+
labelForValidationPath,
|
|
48
|
+
lineItemErrorsFor,
|
|
49
|
+
} from './field-validation-ui'
|
|
43
50
|
import { validationCatalog } from './validation-catalog'
|
|
44
51
|
import { useApi } from './api-context'
|
|
45
52
|
import { DynamicIcon } from './dynamic-icon'
|
|
@@ -621,33 +628,43 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
621
628
|
|
|
622
629
|
const updateField = (key: string, value: any) => {
|
|
623
630
|
setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
|
|
624
|
-
setFieldErrors(prev =>
|
|
625
|
-
if (!prev[key]) return prev
|
|
626
|
-
const next = { ...prev }
|
|
627
|
-
delete next[key]
|
|
628
|
-
return next
|
|
629
|
-
})
|
|
631
|
+
setFieldErrors((prev) => clearFieldErrorTree(prev, key))
|
|
630
632
|
}
|
|
631
633
|
|
|
632
634
|
const lang = i18n.language
|
|
633
635
|
const handleActionError = (err: unknown) => {
|
|
636
|
+
const labels = labelsForValidationFields(action.fields, t)
|
|
634
637
|
const localized = localizeActionFieldErrors(err, action.fields, t, lang)
|
|
635
638
|
if (localized) {
|
|
636
|
-
|
|
637
|
-
|
|
639
|
+
// Enrich labels for dotted line-item paths before toasting.
|
|
640
|
+
const withPathLabels: Record<string, string> = {}
|
|
641
|
+
for (const [path, msg] of Object.entries(localized)) {
|
|
642
|
+
withPathLabels[path] = msg
|
|
643
|
+
if (!labels[path]) labels[path] = labelForValidationPath(path, action.fields, t)
|
|
644
|
+
}
|
|
645
|
+
setFieldErrors(withPathLabels)
|
|
646
|
+
toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), {
|
|
647
|
+
description: formatFieldErrorsDescription(withPathLabels, action.fields, t),
|
|
648
|
+
})
|
|
638
649
|
return
|
|
639
650
|
}
|
|
640
|
-
toastServerError(err, { t, language: lang })
|
|
651
|
+
toastServerError(err, { t, language: lang, labels })
|
|
641
652
|
}
|
|
642
653
|
|
|
643
654
|
const execute = async () => {
|
|
644
655
|
if (action.fields) {
|
|
645
656
|
const bag = validateValues(action.fields, formData)
|
|
646
657
|
if (bagHasErrors(bag)) {
|
|
647
|
-
const labels
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
658
|
+
const labels = labelsForValidationFields(action.fields, (k, o) => t(k, o))
|
|
659
|
+
// Exact dotted keys (`lines.0.qty`) need path-aware labels.
|
|
660
|
+
for (const path of Object.keys(bag)) {
|
|
661
|
+
if (!labels[path]) labels[path] = labelForValidationPath(path, action.fields, t)
|
|
662
|
+
}
|
|
663
|
+
const next = localizeFieldErrorMap(bag, t, { labels, language: lang })
|
|
664
|
+
setFieldErrors(next)
|
|
665
|
+
toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), {
|
|
666
|
+
description: formatFieldErrorsDescription(next, action.fields, t),
|
|
667
|
+
})
|
|
651
668
|
return
|
|
652
669
|
}
|
|
653
670
|
}
|
|
@@ -727,7 +744,14 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
727
744
|
<FieldLabel htmlFor={field.key} required={field.required}>
|
|
728
745
|
{tl(field.label)}
|
|
729
746
|
</FieldLabel>
|
|
730
|
-
{renderField(
|
|
747
|
+
{renderField(
|
|
748
|
+
field,
|
|
749
|
+
formData[field.key],
|
|
750
|
+
(v: any) => updateField(field.key, v),
|
|
751
|
+
formData,
|
|
752
|
+
record,
|
|
753
|
+
fieldErrors,
|
|
754
|
+
)}
|
|
731
755
|
{fieldErrors[field.key] && (
|
|
732
756
|
<p className="text-destructive text-xs mt-1">{fieldErrors[field.key]}</p>
|
|
733
757
|
)}
|
|
@@ -1011,17 +1035,30 @@ function renderField(
|
|
|
1011
1035
|
// then treated as having no resolvable dependency).
|
|
1012
1036
|
formValues?: Record<string, any>,
|
|
1013
1037
|
record?: Record<string, any>,
|
|
1038
|
+
fieldErrors?: Record<string, string>,
|
|
1014
1039
|
) {
|
|
1015
1040
|
// Repeatable line-items group → row grid (value is an array of row objects).
|
|
1016
1041
|
// The header form values flow in so a cell can depend on a header field.
|
|
1017
1042
|
if (isLineItemsField(field)) {
|
|
1018
|
-
return
|
|
1043
|
+
return (
|
|
1044
|
+
<DynamicLineItems
|
|
1045
|
+
field={applyPrefillLock(field)}
|
|
1046
|
+
value={value}
|
|
1047
|
+
onChange={onChange}
|
|
1048
|
+
formValues={formValues}
|
|
1049
|
+
errors={lineItemErrorsFor(field.key, fieldErrors)}
|
|
1050
|
+
/>
|
|
1051
|
+
)
|
|
1019
1052
|
}
|
|
1020
1053
|
// Resolve the widget the same way DynamicForm does (explicit widget wins,
|
|
1021
1054
|
// else inferred from type) so action modals and the standalone form stay in
|
|
1022
1055
|
// lockstep — previously this switch keyed off `field.type` and silently
|
|
1023
1056
|
// dropped `dynamic_select` to a plain text input.
|
|
1024
1057
|
const widget = resolveWidget(field)
|
|
1058
|
+
const invalid = !!(fieldErrors && fieldErrors[field.key])
|
|
1059
|
+
const invalidCls = invalid
|
|
1060
|
+
? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive'
|
|
1061
|
+
: ''
|
|
1025
1062
|
if (widget === 'dynamic_select') {
|
|
1026
1063
|
// A header-level dynamic_select may itself depend on another header
|
|
1027
1064
|
// field; resolve its filter_value from the form context.
|
|
@@ -1035,6 +1072,7 @@ function renderField(
|
|
|
1035
1072
|
onChange={onChange}
|
|
1036
1073
|
dependsValue={dependsValue}
|
|
1037
1074
|
seedOption={seedOptionFromRecord(field, value, record)}
|
|
1075
|
+
invalid={invalid}
|
|
1038
1076
|
/>
|
|
1039
1077
|
)
|
|
1040
1078
|
}
|
|
@@ -1045,11 +1083,11 @@ function renderField(
|
|
|
1045
1083
|
}
|
|
1046
1084
|
switch (widget) {
|
|
1047
1085
|
case 'textarea':
|
|
1048
|
-
return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
1086
|
+
return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} aria-invalid={invalid || undefined} className={invalidCls || undefined} />
|
|
1049
1087
|
case 'select':
|
|
1050
1088
|
return (
|
|
1051
1089
|
<Select value={value || ''} onValueChange={onChange}>
|
|
1052
|
-
<SelectTrigger className=
|
|
1090
|
+
<SelectTrigger className={'w-full' + (invalidCls ? ` ${invalidCls}` : '')} aria-invalid={invalid || undefined}><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
|
|
1053
1091
|
<SelectContent>
|
|
1054
1092
|
{field.options?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
|
1055
1093
|
</SelectContent>
|
|
@@ -1058,12 +1096,15 @@ function renderField(
|
|
|
1058
1096
|
case 'switch':
|
|
1059
1097
|
return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
|
|
1060
1098
|
case 'number':
|
|
1061
|
-
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
1099
|
+
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1100
|
+
const n = e.target.valueAsNumber
|
|
1101
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
|
|
1102
|
+
}} placeholder={field.placeholder} aria-invalid={invalid || undefined} className={invalidCls || undefined} />
|
|
1062
1103
|
case 'date':
|
|
1063
1104
|
// Modern shadcn Calendar in a Popover (portaled, never clipped by the
|
|
1064
1105
|
// modal) instead of the native, dated, easily-cut <input type=date>.
|
|
1065
1106
|
return <DynamicDateField field={field} value={value} onChange={onChange} />
|
|
1066
1107
|
default:
|
|
1067
|
-
return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
1108
|
+
return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} aria-invalid={invalid || undefined} className={invalidCls || undefined} />
|
|
1068
1109
|
}
|
|
1069
1110
|
}
|
|
@@ -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,27 @@ 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 description = Object.entries(next)
|
|
868
|
+
.map(([k, msg]) => {
|
|
869
|
+
const label = labels[k] || labelForKey(k)
|
|
870
|
+
return msg.toLowerCase().startsWith(String(label).toLowerCase())
|
|
871
|
+
? msg
|
|
872
|
+
: `${label}: ${msg}`
|
|
873
|
+
})
|
|
874
|
+
.join(' · ')
|
|
875
|
+
toast.error(
|
|
876
|
+
t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }),
|
|
877
|
+
description ? { description } : undefined,
|
|
878
|
+
)
|
|
835
879
|
return
|
|
836
880
|
}
|
|
837
881
|
}
|
|
@@ -973,14 +1017,12 @@ export function DynamicRecordDialog({
|
|
|
973
1017
|
// then advance. Mirrors handleSubmit's required check but scoped to the step.
|
|
974
1018
|
const goNextStep = () => {
|
|
975
1019
|
const step = groups[clampedStep]
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
if (Object.keys(missing).length) {
|
|
983
|
-
setFieldErrors(missing)
|
|
1020
|
+
const stepFields = step?.fields ?? []
|
|
1021
|
+
const bag = validateValues(stepFields as ActionFieldDef[], formValues)
|
|
1022
|
+
if (bagHasErrors(bag)) {
|
|
1023
|
+
const labels: Record<string, string> = {}
|
|
1024
|
+
for (const f of stepFields) labels[f.key] = f.label
|
|
1025
|
+
setFieldErrors(localizeFieldErrorMap(bag, t, { labels }))
|
|
984
1026
|
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
985
1027
|
return
|
|
986
1028
|
}
|
|
@@ -1014,7 +1056,7 @@ export function DynamicRecordDialog({
|
|
|
1014
1056
|
cell `min-w-0` so a long select/input value can't
|
|
1015
1057
|
blow the two columns past the dialog width. */}
|
|
1016
1058
|
<form
|
|
1017
|
-
id=
|
|
1059
|
+
id={formId}
|
|
1018
1060
|
onSubmit={handleSubmit}
|
|
1019
1061
|
className="grid gap-y-4"
|
|
1020
1062
|
>
|
|
@@ -1124,7 +1166,7 @@ export function DynamicRecordDialog({
|
|
|
1124
1166
|
{isEditable && (!isSteps || isLastStep) && (
|
|
1125
1167
|
<Button
|
|
1126
1168
|
type="submit"
|
|
1127
|
-
form=
|
|
1169
|
+
form={formId}
|
|
1128
1170
|
disabled={saving || loading}
|
|
1129
1171
|
>
|
|
1130
1172
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
@@ -1193,7 +1235,7 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
|
|
|
1193
1235
|
) : isEditReadonly ? (
|
|
1194
1236
|
<ReadonlyEditField field={field} value={value} />
|
|
1195
1237
|
) : (
|
|
1196
|
-
<EditField field={field} value={value} onChange={onChange} record={record} />
|
|
1238
|
+
<EditField field={field} value={value} onChange={onChange} record={record} invalid={!!error} />
|
|
1197
1239
|
)}
|
|
1198
1240
|
|
|
1199
1241
|
{error && mode !== 'view' && (
|
|
@@ -1836,13 +1878,19 @@ function JsonObjectViewValue({ value }: { value: Record<string, unknown> }) {
|
|
|
1836
1878
|
)
|
|
1837
1879
|
}
|
|
1838
1880
|
|
|
1839
|
-
export function EditField({ field, value, onChange, record }: {
|
|
1881
|
+
export function EditField({ field, value, onChange, record, invalid }: {
|
|
1840
1882
|
field: FieldDef
|
|
1841
1883
|
value: any
|
|
1842
1884
|
onChange: (val: any) => void
|
|
1843
1885
|
/** The full record being edited — supplies FK relation siblings + line-items. */
|
|
1844
1886
|
record?: any
|
|
1887
|
+
/** When true, paint the control with a destructive border (Laravel-style). */
|
|
1888
|
+
invalid?: boolean
|
|
1845
1889
|
}) {
|
|
1890
|
+
const invalidCls = invalid
|
|
1891
|
+
? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive aria-invalid:border-destructive'
|
|
1892
|
+
: undefined
|
|
1893
|
+
|
|
1846
1894
|
const { t, i18n } = useTranslation()
|
|
1847
1895
|
const editFieldImageUrl = useContext(ImageUrlContext)
|
|
1848
1896
|
const dialogModel = useContext(RecordDialogModelContext)
|
|
@@ -1892,6 +1940,8 @@ export function EditField({ field, value, onChange, record }: {
|
|
|
1892
1940
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
|
1893
1941
|
placeholder={field.placeholder}
|
|
1894
1942
|
rows={4}
|
|
1943
|
+
aria-invalid={invalid || undefined}
|
|
1944
|
+
className={invalidCls}
|
|
1895
1945
|
/>
|
|
1896
1946
|
)
|
|
1897
1947
|
}
|
|
@@ -1952,6 +2002,7 @@ export function EditField({ field, value, onChange, record }: {
|
|
|
1952
2002
|
// for the popover to open and fetch a page.
|
|
1953
2003
|
seedOption={fkSeedOption(field, value, record)}
|
|
1954
2004
|
hideCreate={hideSelfCreate}
|
|
2005
|
+
invalid={invalid}
|
|
1955
2006
|
/>
|
|
1956
2007
|
)
|
|
1957
2008
|
}
|
|
@@ -1967,7 +2018,7 @@ export function EditField({ field, value, onChange, record }: {
|
|
|
1967
2018
|
if (field.type === 'select' && field.options?.length) {
|
|
1968
2019
|
return (
|
|
1969
2020
|
<Select value={String(value ?? '')} onValueChange={onChange}>
|
|
1970
|
-
<SelectTrigger className="w-full">
|
|
2021
|
+
<SelectTrigger className={cn("w-full", invalidCls)} aria-invalid={invalid || undefined}>
|
|
1971
2022
|
<SelectValue placeholder="Seleccionar..." />
|
|
1972
2023
|
</SelectTrigger>
|
|
1973
2024
|
<SelectContent>
|
|
@@ -2043,7 +2094,7 @@ export function EditField({ field, value, onChange, record }: {
|
|
|
2043
2094
|
? 'email'
|
|
2044
2095
|
: 'text'
|
|
2045
2096
|
|
|
2046
|
-
return <ScannableRecordInput field={field} value={value} onChange={onChange} inputType={inputType} />
|
|
2097
|
+
return <ScannableRecordInput field={field} value={value} onChange={onChange} inputType={inputType} invalid={invalid} className={invalidCls} />
|
|
2047
2098
|
}
|
|
2048
2099
|
|
|
2049
2100
|
/**
|
|
@@ -2062,11 +2113,15 @@ function ScannableRecordInput({
|
|
|
2062
2113
|
value,
|
|
2063
2114
|
onChange,
|
|
2064
2115
|
inputType,
|
|
2116
|
+
invalid,
|
|
2117
|
+
className,
|
|
2065
2118
|
}: {
|
|
2066
2119
|
field: FieldDef
|
|
2067
2120
|
value: any
|
|
2068
2121
|
onChange: (val: any) => void
|
|
2069
2122
|
inputType: string
|
|
2123
|
+
invalid?: boolean
|
|
2124
|
+
className?: string
|
|
2070
2125
|
}) {
|
|
2071
2126
|
const [scanOpen, setScanOpen] = useState(false)
|
|
2072
2127
|
// El botón de escaneo aparece siempre que el campo declara `scan` (como el
|
|
@@ -2086,6 +2141,8 @@ function ScannableRecordInput({
|
|
|
2086
2141
|
)
|
|
2087
2142
|
}
|
|
2088
2143
|
placeholder={field.placeholder}
|
|
2144
|
+
aria-invalid={invalid || undefined}
|
|
2145
|
+
className={className}
|
|
2089
2146
|
/>
|
|
2090
2147
|
)
|
|
2091
2148
|
if (!scanEnabled) return input
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -420,7 +420,10 @@ function FieldRenderer({
|
|
|
420
420
|
case 'switch':
|
|
421
421
|
return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
|
|
422
422
|
case 'number':
|
|
423
|
-
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
423
|
+
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
424
|
+
const n = e.target.valueAsNumber
|
|
425
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
|
|
426
|
+
}} placeholder={field.placeholder} />
|
|
424
427
|
case 'date':
|
|
425
428
|
return <DynamicDateField field={field} value={value} onChange={onChange} />
|
|
426
429
|
default:
|
|
@@ -463,9 +466,14 @@ function ScannableInput({
|
|
|
463
466
|
id={field.key}
|
|
464
467
|
type={type}
|
|
465
468
|
value={value ?? ''}
|
|
466
|
-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
467
|
-
|
|
468
|
-
|
|
469
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
470
|
+
if (type !== 'number') {
|
|
471
|
+
onChange(e.target.value)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
const n = e.target.valueAsNumber
|
|
475
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
|
|
476
|
+
}}
|
|
469
477
|
placeholder={field.placeholder}
|
|
470
478
|
/>
|
|
471
479
|
)
|
|
@@ -47,6 +47,11 @@ export interface DynamicLineItemsProps {
|
|
|
47
47
|
* (e.g. `source_warehouse_id`), not just a sibling cell on the same row.
|
|
48
48
|
*/
|
|
49
49
|
formValues?: Record<string, any>
|
|
50
|
+
/**
|
|
51
|
+
* Localized validation messages keyed as `rowIndex.columnKey` (e.g.
|
|
52
|
+
* `0.unit_price`). Painted as destructive borders + under-cell text.
|
|
53
|
+
*/
|
|
54
|
+
errors?: Record<string, string>
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
const fmtNumber = (n: number): string =>
|
|
@@ -65,9 +70,10 @@ function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
|
|
|
65
70
|
return row
|
|
66
71
|
}
|
|
67
72
|
|
|
68
|
-
export function DynamicLineItems({ field, value, onChange, disabled = false, formValues }: DynamicLineItemsProps) {
|
|
73
|
+
export function DynamicLineItems({ field, value, onChange, disabled = false, formValues, errors }: DynamicLineItemsProps) {
|
|
69
74
|
const itemFields = getItemFields(field)
|
|
70
75
|
const rows: any[] = Array.isArray(value) ? value : []
|
|
76
|
+
const errMap = errors ?? {}
|
|
71
77
|
|
|
72
78
|
// `lock_rows` fixes the row set: no add-row button, no per-row delete. Rows
|
|
73
79
|
// stay editable cell-by-cell. Snake_case is what the kernel serves; tolerate
|
|
@@ -149,7 +155,9 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
149
155
|
)}
|
|
150
156
|
</div>
|
|
151
157
|
<div className="grid gap-2.5">
|
|
152
|
-
{itemFields.map((col) =>
|
|
158
|
+
{itemFields.map((col) => {
|
|
159
|
+
const cellErr = errMap[`${idx}.${col.key}`]
|
|
160
|
+
return (
|
|
153
161
|
<div key={col.key} className="grid gap-1">
|
|
154
162
|
<span className="text-xs font-medium">
|
|
155
163
|
{col.label}
|
|
@@ -164,9 +172,14 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
164
172
|
disabled={disabled}
|
|
165
173
|
formValues={formValues}
|
|
166
174
|
rowValues={row}
|
|
175
|
+
invalid={!!cellErr}
|
|
167
176
|
/>
|
|
177
|
+
{cellErr && (
|
|
178
|
+
<p className="text-destructive text-xs">{cellErr}</p>
|
|
179
|
+
)}
|
|
168
180
|
</div>
|
|
169
|
-
|
|
181
|
+
)
|
|
182
|
+
})}
|
|
170
183
|
</div>
|
|
171
184
|
</div>
|
|
172
185
|
))}
|
|
@@ -219,7 +232,9 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
219
232
|
)}
|
|
220
233
|
{rows.map((row, idx) => (
|
|
221
234
|
<tr key={idx} className="border-t align-top">
|
|
222
|
-
{itemFields.map((col) =>
|
|
235
|
+
{itemFields.map((col) => {
|
|
236
|
+
const cellErr = errMap[`${idx}.${col.key}`]
|
|
237
|
+
return (
|
|
223
238
|
<td key={col.key} className="px-2 py-1.5">
|
|
224
239
|
<CellRenderer
|
|
225
240
|
field={col}
|
|
@@ -228,9 +243,14 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
228
243
|
disabled={disabled}
|
|
229
244
|
formValues={formValues}
|
|
230
245
|
rowValues={row}
|
|
246
|
+
invalid={!!cellErr}
|
|
231
247
|
/>
|
|
248
|
+
{cellErr && (
|
|
249
|
+
<p className="text-destructive mt-1 text-xs">{cellErr}</p>
|
|
250
|
+
)}
|
|
232
251
|
</td>
|
|
233
|
-
|
|
252
|
+
)
|
|
253
|
+
})}
|
|
234
254
|
{!lockRows && (
|
|
235
255
|
<td className="px-2 py-1.5 text-center">
|
|
236
256
|
<Button
|
|
@@ -342,14 +362,19 @@ interface CellRendererProps {
|
|
|
342
362
|
formValues?: Record<string, any>
|
|
343
363
|
/** This row's values — for resolving a cell's `dependsOn` to a sibling cell. */
|
|
344
364
|
rowValues?: Record<string, any>
|
|
365
|
+
/** Paint the control as invalid (destructive border). */
|
|
366
|
+
invalid?: boolean
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
// Per-cell widget. Mirrors the flat FieldRenderer in dynamic-form.tsx but
|
|
348
370
|
// without the per-field Label (the column header is the label) and sized for a
|
|
349
371
|
// table cell. Nested line-items inside a row are not supported (a row column is
|
|
350
372
|
// a scalar widget).
|
|
351
|
-
function CellRenderer({ field, value, onChange, disabled, formValues, rowValues }: CellRendererProps) {
|
|
373
|
+
function CellRenderer({ field, value, onChange, disabled, formValues, rowValues, invalid }: CellRendererProps) {
|
|
352
374
|
const widget = resolveWidget(field)
|
|
375
|
+
const invalidCls = invalid
|
|
376
|
+
? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive'
|
|
377
|
+
: ''
|
|
353
378
|
// Per-field read-only: a column locked by a PrefillSpec.lock (e.g. the
|
|
354
379
|
// "ordered" / "already received" progress columns of a receive-goods modal)
|
|
355
380
|
// renders disabled so it shows context without being editable. Tolerates the
|
|
@@ -384,7 +409,16 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
384
409
|
// Async searchable picker per row cell — e.g. the account_id column of a
|
|
385
410
|
// journal entry's debit/credit lines. Same widget as the flat form.
|
|
386
411
|
if (widget === 'dynamic_select') {
|
|
387
|
-
return
|
|
412
|
+
return (
|
|
413
|
+
<DynamicSelectField
|
|
414
|
+
field={field}
|
|
415
|
+
value={value}
|
|
416
|
+
onChange={onChange}
|
|
417
|
+
dependsValue={dependsValue}
|
|
418
|
+
readonly={ro}
|
|
419
|
+
invalid={invalid}
|
|
420
|
+
/>
|
|
421
|
+
)
|
|
388
422
|
}
|
|
389
423
|
if (widget === 'select' && (field.ref || getOptionsConfig(field)?.source)) {
|
|
390
424
|
return (
|
|
@@ -408,6 +442,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
408
442
|
placeholder={field.placeholder}
|
|
409
443
|
disabled={off}
|
|
410
444
|
rows={2}
|
|
445
|
+
aria-invalid={invalid || undefined}
|
|
446
|
+
className={invalidCls || undefined}
|
|
411
447
|
/>
|
|
412
448
|
)
|
|
413
449
|
case 'color':
|
|
@@ -417,6 +453,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
417
453
|
value={value || '#000000'}
|
|
418
454
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
419
455
|
disabled={off}
|
|
456
|
+
aria-invalid={invalid || undefined}
|
|
457
|
+
className={invalidCls || undefined}
|
|
420
458
|
/>
|
|
421
459
|
)
|
|
422
460
|
case 'select': {
|
|
@@ -425,7 +463,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
425
463
|
if (effectiveOptions && effectiveOptions.length === 0) return null
|
|
426
464
|
return (
|
|
427
465
|
<Select value={value || ''} onValueChange={onChange} disabled={off}>
|
|
428
|
-
<SelectTrigger className=
|
|
466
|
+
<SelectTrigger className={'w-full' + (invalidCls ? ` ${invalidCls}` : '')} aria-invalid={invalid || undefined}>
|
|
429
467
|
<SelectValue placeholder={field.placeholder || 'Seleccionar...'} />
|
|
430
468
|
</SelectTrigger>
|
|
431
469
|
<SelectContent>
|
|
@@ -445,9 +483,16 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
445
483
|
<Input
|
|
446
484
|
type="number"
|
|
447
485
|
value={value ?? ''}
|
|
448
|
-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
486
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
487
|
+
const n = e.target.valueAsNumber
|
|
488
|
+
// `0 || ''` would wipe a legitimate zero (unit_price=0)
|
|
489
|
+
// and then required-validation fires with nothing marked.
|
|
490
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
|
|
491
|
+
}}
|
|
449
492
|
placeholder={field.placeholder}
|
|
450
493
|
disabled={off}
|
|
494
|
+
aria-invalid={invalid || undefined}
|
|
495
|
+
className={invalidCls || undefined}
|
|
451
496
|
/>
|
|
452
497
|
)
|
|
453
498
|
case 'date':
|
|
@@ -457,6 +502,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
457
502
|
value={value || ''}
|
|
458
503
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
459
504
|
disabled={off}
|
|
505
|
+
aria-invalid={invalid || undefined}
|
|
506
|
+
className={invalidCls || undefined}
|
|
460
507
|
/>
|
|
461
508
|
)
|
|
462
509
|
default:
|
|
@@ -467,6 +514,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
467
514
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
468
515
|
placeholder={field.placeholder}
|
|
469
516
|
disabled={off}
|
|
517
|
+
aria-invalid={invalid || undefined}
|
|
518
|
+
className={invalidCls || undefined}
|
|
470
519
|
/>
|
|
471
520
|
)
|
|
472
521
|
}
|