@asteby/metacore-runtime-react 37.0.3 → 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 +6 -0
- 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-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/package.json +1 -1
- package/src/__tests__/dynamic-record-field-validation.test.tsx +110 -0
- package/src/dialogs/dynamic-record.tsx +86 -32
- package/src/dynamic-select-field.tsx +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 37.0.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0396b95: DynamicRecordDialog: unique form ids (nested create), Laravel-style validateValues on submit, destructive borders on invalid fields, toast descriptions for errors on non-rendered fields, and `ensureFields` to inject host scope keys into the modal schema.
|
|
8
|
+
|
|
3
9
|
## 37.0.3
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -100,6 +100,12 @@ export interface DynamicRecordDialogProps {
|
|
|
100
100
|
* lets it close while the depth lock is held.
|
|
101
101
|
*/
|
|
102
102
|
nestedInlineCreateSelf?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Fields merged into the modal schema after load (by key). Existing keys are
|
|
105
|
+
* shallow-merged; missing keys are prepended. Hosts use this to inject
|
|
106
|
+
* required scope fields (e.g. branch_id) omitted from compiled DefineModal.
|
|
107
|
+
*/
|
|
108
|
+
ensureFields?: FieldDef[];
|
|
103
109
|
mode: 'view' | 'edit' | 'create';
|
|
104
110
|
model: string;
|
|
105
111
|
recordId?: string | null;
|
|
@@ -198,7 +204,7 @@ export declare function fkSeedOption(field: FieldDef, value: any, record: any):
|
|
|
198
204
|
export declare function isMoneyField(field: FieldDef, value: any): boolean;
|
|
199
205
|
export declare function filterVisibleFields(fields: FieldDef[] | undefined, mode: 'view' | 'edit' | 'create', formValues?: Record<string, any>): FieldDef[];
|
|
200
206
|
export declare function stripHiddenFieldValues(values: Record<string, any>, fields: FieldDef[] | undefined, mode: 'view' | 'edit' | 'create'): Record<string, any>;
|
|
201
|
-
export declare function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, lockedFields, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl, timeZone, currency, onChange, }: DynamicRecordDialogProps): import("react").JSX.Element;
|
|
207
|
+
export declare function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf, ensureFields, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, lockedFields, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl, timeZone, currency, onChange, }: DynamicRecordDialogProps): import("react").JSX.Element;
|
|
202
208
|
export declare function ReadonlyEditField({ field, value }: {
|
|
203
209
|
field: FieldDef;
|
|
204
210
|
value: any;
|
|
@@ -214,11 +220,13 @@ export declare function ViewValue({ field, value: rawValue, record, getImageUrl:
|
|
|
214
220
|
/** Optional override; when omitted falls back to the nearest provider. */
|
|
215
221
|
currency?: string;
|
|
216
222
|
}): import("react").JSX.Element;
|
|
217
|
-
export declare function EditField({ field, value, onChange, record }: {
|
|
223
|
+
export declare function EditField({ field, value, onChange, record, invalid }: {
|
|
218
224
|
field: FieldDef;
|
|
219
225
|
value: any;
|
|
220
226
|
onChange: (val: any) => void;
|
|
221
227
|
/** The full record being edited — supplies FK relation siblings + line-items. */
|
|
222
228
|
record?: any;
|
|
229
|
+
/** When true, paint the control with a destructive border (Laravel-style). */
|
|
230
|
+
invalid?: boolean;
|
|
223
231
|
}): import("react").JSX.Element;
|
|
224
232
|
//# sourceMappingURL=dynamic-record.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AA6C1C,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAA;AAEjF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
|
|
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
// flows through <ApiProvider> from runtime-react. Host-specific runtime values —
|
|
11
11
|
// the image-url resolver and the org IANA timezone — are passed as props so the
|
|
12
12
|
// SDK stays transport- and host-agnostic.
|
|
13
|
-
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
13
|
+
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react';
|
|
14
14
|
import { useTranslation } from 'react-i18next';
|
|
15
15
|
/** Model key of the open create/edit dialog — used to hide "+" on self-FK pickers
|
|
16
16
|
* (e.g. Customer.parent_id) so they don't nest another "Crear Cliente" modal. */
|
|
@@ -23,7 +23,7 @@ import { es } from 'date-fns/locale';
|
|
|
23
23
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react';
|
|
24
24
|
import { BarcodeScanner } from '../barcode-scanner';
|
|
25
25
|
import { useApi } from '../api-context';
|
|
26
|
-
import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error';
|
|
26
|
+
import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeFieldErrorMap } from '../server-error';
|
|
27
27
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field';
|
|
28
28
|
import { DynamicRelations } from '../dynamic-relations';
|
|
29
29
|
import { useOptionsResolver } from '../use-options-resolver';
|
|
@@ -33,6 +33,7 @@ import { FieldSection, WizardProgress } from '../form-layout-ui';
|
|
|
33
33
|
import { FieldCell } from '../field-grid';
|
|
34
34
|
import { isNilUuid, normalizeNilUuid } from '../nil-uuid';
|
|
35
35
|
import { normalizeRefFieldsForSubmit } from './normalize-submit';
|
|
36
|
+
import { validateValues, bagHasErrors } from '../validator';
|
|
36
37
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon';
|
|
37
38
|
import { IconPickerField } from '../icon-picker-field';
|
|
38
39
|
import { humanizeToken } from '../dynamic-columns-helpers';
|
|
@@ -299,7 +300,24 @@ export function stripHiddenFieldValues(values, fields, mode) {
|
|
|
299
300
|
}
|
|
300
301
|
return out;
|
|
301
302
|
}
|
|
302
|
-
|
|
303
|
+
function applyEnsureFields(meta, ensureFields) {
|
|
304
|
+
if (!meta)
|
|
305
|
+
return meta ?? null;
|
|
306
|
+
if (!ensureFields?.length)
|
|
307
|
+
return meta;
|
|
308
|
+
const fields = Array.isArray(meta.fields) ? [...meta.fields] : [];
|
|
309
|
+
for (const ensure of ensureFields) {
|
|
310
|
+
const idx = fields.findIndex((f) => f?.key === ensure.key);
|
|
311
|
+
if (idx >= 0) {
|
|
312
|
+
fields[idx] = { ...fields[idx], ...ensure };
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
fields.unshift(ensure);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { ...meta, fields };
|
|
319
|
+
}
|
|
320
|
+
export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf, ensureFields, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, lockedFields, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl = identityImageUrl, timeZone, currency, onChange, }) {
|
|
303
321
|
const api = useApi();
|
|
304
322
|
const { t } = useTranslation();
|
|
305
323
|
const [modalMeta, setModalMeta] = useState(schema ? schema : null);
|
|
@@ -310,6 +328,9 @@ export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf
|
|
|
310
328
|
// inline under each input; populated from a 422 `errors` map or the client
|
|
311
329
|
// required-field check, cleared per-field on change and wholesale on reopen.
|
|
312
330
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
331
|
+
// Unique form id per dialog instance — nested create must not share
|
|
332
|
+
// id={formId} or the child footer submits the parent.
|
|
333
|
+
const formId = useId();
|
|
313
334
|
const [loading, setLoading] = useState(false);
|
|
314
335
|
const [saving, setSaving] = useState(false);
|
|
315
336
|
const [deleting, setDeleting] = useState(false);
|
|
@@ -367,6 +388,7 @@ export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf
|
|
|
367
388
|
return;
|
|
368
389
|
meta = metaRes.data?.data ?? metaRes.data;
|
|
369
390
|
}
|
|
391
|
+
meta = applyEnsureFields(meta, ensureFields);
|
|
370
392
|
setModalMeta(meta);
|
|
371
393
|
if (isCreate) {
|
|
372
394
|
const initial = {};
|
|
@@ -415,7 +437,7 @@ export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf
|
|
|
415
437
|
// initialRecord intentionally omitted: the row identity is captured per open
|
|
416
438
|
// via recordId; re-seeding mid-open would clobber edits.
|
|
417
439
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
418
|
-
}, [open, recordId, model, endpoint, isCreate, schema]);
|
|
440
|
+
}, [open, recordId, model, endpoint, isCreate, schema, ensureFields]);
|
|
419
441
|
// Reset when closed
|
|
420
442
|
useEffect(() => {
|
|
421
443
|
if (!open) {
|
|
@@ -509,7 +531,12 @@ export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf
|
|
|
509
531
|
next[key] = localizeFieldIssue(issues[0], labelForKey(key), t);
|
|
510
532
|
}
|
|
511
533
|
setFieldErrors(next);
|
|
512
|
-
|
|
534
|
+
const visibleKeys = new Set(filterVisibleFields(modalMeta?.fields ?? [], mode, formValues).map(f => f.key));
|
|
535
|
+
const orphans = Object.entries(next).filter(([k]) => !visibleKeys.has(k));
|
|
536
|
+
const description = orphans.length
|
|
537
|
+
? orphans.map(([k, msg]) => `${labelForKey(k)}: ${msg}`).join(' · ')
|
|
538
|
+
: undefined;
|
|
539
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), description ? { description } : undefined);
|
|
513
540
|
return;
|
|
514
541
|
}
|
|
515
542
|
toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) });
|
|
@@ -519,20 +546,22 @@ export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf
|
|
|
519
546
|
if (!modalMeta)
|
|
520
547
|
return;
|
|
521
548
|
if (isEditable) {
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
549
|
+
// Laravel-style: collect every issue from the shared validator
|
|
550
|
+
// (required + rule strings / min/max / email…) on visible fields only.
|
|
551
|
+
const visible = filterVisibleFields(modalMeta.fields, mode, formValues);
|
|
552
|
+
const bag = validateValues(visible, formValues);
|
|
553
|
+
if (bagHasErrors(bag)) {
|
|
554
|
+
const labels = {};
|
|
555
|
+
for (const f of visible)
|
|
556
|
+
labels[f.key] = f.label;
|
|
557
|
+
const next = localizeFieldErrorMap(bag, t, { labels });
|
|
558
|
+
setFieldErrors(next);
|
|
559
|
+
const visibleKeys = new Set(visible.map(f => f.key));
|
|
560
|
+
const orphans = Object.entries(next).filter(([k]) => !visibleKeys.has(k));
|
|
561
|
+
const description = orphans.length
|
|
562
|
+
? orphans.map(([k, msg]) => `${labelForKey(k)}: ${msg}`).join(' · ')
|
|
563
|
+
: undefined;
|
|
564
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), description ? { description } : undefined);
|
|
536
565
|
return;
|
|
537
566
|
}
|
|
538
567
|
}
|
|
@@ -653,14 +682,13 @@ export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf
|
|
|
653
682
|
// then advance. Mirrors handleSubmit's required check but scoped to the step.
|
|
654
683
|
const goNextStep = () => {
|
|
655
684
|
const step = groups[clampedStep];
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
setFieldErrors(missing);
|
|
685
|
+
const stepFields = step?.fields ?? [];
|
|
686
|
+
const bag = validateValues(stepFields, formValues);
|
|
687
|
+
if (bagHasErrors(bag)) {
|
|
688
|
+
const labels = {};
|
|
689
|
+
for (const f of stepFields)
|
|
690
|
+
labels[f.key] = f.label;
|
|
691
|
+
setFieldErrors(localizeFieldErrorMap(bag, t, { labels }));
|
|
664
692
|
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
665
693
|
return;
|
|
666
694
|
}
|
|
@@ -668,7 +696,7 @@ export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf
|
|
|
668
696
|
setStepIndex(Math.min(clampedStep + 1, groups.length - 1));
|
|
669
697
|
};
|
|
670
698
|
const goBackStep = () => setStepIndex(Math.max(clampedStep - 1, 0));
|
|
671
|
-
return (_jsx(RecordDialogModelContext.Provider, { value: model, children: _jsx(Dialog, { open: open, onOpenChange: onOpenChange, nestedInlineCreateSelf: nestedInlineCreateSelf, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90dvh] flex flex-col p-0 gap-0 overflow-hidden", style: { maxHeight: '90dvh' }, children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsx(ImageUrlContext.Provider, { value: getImageUrl, children: _jsx(TimeZoneContext.Provider, { value: timeZone, children: _jsxs(CurrencyContext.Provider, { value: currency, children: [_jsxs("form", { id:
|
|
699
|
+
return (_jsx(RecordDialogModelContext.Provider, { value: model, children: _jsx(Dialog, { open: open, onOpenChange: onOpenChange, nestedInlineCreateSelf: nestedInlineCreateSelf, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90dvh] flex flex-col p-0 gap-0 overflow-hidden", style: { maxHeight: '90dvh' }, children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsx(ImageUrlContext.Provider, { value: getImageUrl, children: _jsx(TimeZoneContext.Provider, { value: timeZone, children: _jsxs(CurrencyContext.Provider, { value: currency, children: [_jsxs("form", { id: formId, onSubmit: handleSubmit, className: "grid gap-y-4", children: [isSteps && (_jsx(WizardProgress, { groups: groups, stepIndex: clampedStep })), (isSteps ? [groups[clampedStep]] : groups).map(group => (_jsx(FieldSection, { group: group, children: _jsx("div", { className: "grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2", children: renderFields(group.fields) }) }, group.key))), record?.external_url && (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2", children: _jsx("div", { className: "sm:col-span-2 min-w-0", children: _jsxs("a", { href: record.external_url, target: "_blank", rel: "noreferrer", className: "inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), "Ver en ", record.external_provider ?? 'proveedor externo'] }) }) }))] }), !isCreate && record && relations.length > 0 && (_jsx("div", { className: "mt-6", children: _jsx(DynamicRelations, { record: record, relations: relations, lineSubtable: true, embedOnly: true, canCreate: mode === 'edit', canEdit: mode === 'edit', canDelete: mode === 'edit', onChange: handleChildChange }) }))] }) }) }) })) : null }), _jsxs(DialogFooter, { className: "p-4 border-t shrink-0 sm:justify-between", children: [isView && onOpenFullPage ? (_jsxs(Button, { variant: "ghost", size: "sm", className: "text-muted-foreground", onClick: () => { onOpenChange(false); onOpenFullPage(); }, children: [_jsx(ExternalLink, { className: "mr-1.5 h-3.5 w-3.5" }), "Ver p\u00E1gina completa"] })) : _jsx("span", {}), _jsxs("div", { className: "flex items-center gap-2", children: [isSteps && clampedStep > 0 ? (_jsx(Button, { variant: "outline", onClick: goBackStep, disabled: saving || deleting, children: "Anterior" })) : (_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: saving || deleting, children: config.cancelLabel })), isView && onDelete && (_jsxs(Button, { variant: "destructive", onClick: handleDelete, disabled: deleting || loading, children: [deleting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), deleting ? 'Eliminando...' : 'Eliminar'] })), isView && onEdit && (_jsx(Button, { onClick: onEdit, disabled: deleting || loading, children: "Editar" })), isEditable && isSteps && !isLastStep && (_jsx(Button, { type: "button", onClick: goNextStep, disabled: saving || loading, children: "Siguiente" })), isEditable && (!isSteps || isLastStep) && (_jsxs(Button, { type: "submit", form: formId, disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] })] }) }) }));
|
|
672
700
|
}
|
|
673
701
|
function LoadingSkeleton() {
|
|
674
702
|
return (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4", children: Array.from({ length: 6 }).map((_, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-24" }), _jsx(Skeleton, { className: "h-9 w-full" })] }, i))) }));
|
|
@@ -682,7 +710,7 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }) {
|
|
|
682
710
|
// the rich read-only renderer. `locked` forces the same disabled rendering on
|
|
683
711
|
// CREATE for a caller-specified field (see `lockedFields`).
|
|
684
712
|
const isEditReadonly = (mode === 'edit' && !!field.readonly) || !!locked;
|
|
685
|
-
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-xs font-medium text-muted-foreground uppercase tracking-wide", children: [field.label, field.required && mode !== 'view' && !isEditReadonly && (_jsx("span", { className: "text-destructive ml-0.5", children: "*" }))] }), mode === 'view' ? (_jsx(ViewValue, { field: field, value: value, record: record })) : isEditReadonly ? (_jsx(ReadonlyEditField, { field: field, value: value })) : (_jsx(EditField, { field: field, value: value, onChange: onChange, record: record })), error && mode !== 'view' && (_jsx("p", { className: "text-destructive text-xs mt-1", children: error }))] }));
|
|
713
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-xs font-medium text-muted-foreground uppercase tracking-wide", children: [field.label, field.required && mode !== 'view' && !isEditReadonly && (_jsx("span", { className: "text-destructive ml-0.5", children: "*" }))] }), mode === 'view' ? (_jsx(ViewValue, { field: field, value: value, record: record })) : isEditReadonly ? (_jsx(ReadonlyEditField, { field: field, value: value })) : (_jsx(EditField, { field: field, value: value, onChange: onChange, record: record, invalid: !!error })), error && mode !== 'view' && (_jsx("p", { className: "text-destructive text-xs mt-1", children: error }))] }));
|
|
686
714
|
}
|
|
687
715
|
// ReadonlyEditField — the edit-mode rendering of a `readonly` (system-generated)
|
|
688
716
|
// field: a disabled, muted input that shows the current value without allowing
|
|
@@ -1047,7 +1075,10 @@ function JsonObjectViewValue({ value }) {
|
|
|
1047
1075
|
return (_jsxs("div", { className: "min-w-0", children: [_jsx("dt", { className: "text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: label }), _jsx("dd", { className: "mt-0.5 break-words text-foreground", children: isNest ? (_jsx("pre", { className: "max-h-48 overflow-auto rounded-md bg-muted/40 p-2 text-[11px] leading-relaxed whitespace-pre-wrap", children: JSON.stringify(raw, null, 2) })) : raw === null || raw === '' ? (_jsx("span", { className: "text-muted-foreground", children: "\u2014" })) : (String(raw)) })] }, key));
|
|
1048
1076
|
}) }));
|
|
1049
1077
|
}
|
|
1050
|
-
export function EditField({ field, value, onChange, record }) {
|
|
1078
|
+
export function EditField({ field, value, onChange, record, invalid }) {
|
|
1079
|
+
const invalidCls = invalid
|
|
1080
|
+
? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive aria-invalid:border-destructive'
|
|
1081
|
+
: undefined;
|
|
1051
1082
|
const { t, i18n } = useTranslation();
|
|
1052
1083
|
const editFieldImageUrl = useContext(ImageUrlContext);
|
|
1053
1084
|
const dialogModel = useContext(RecordDialogModelContext);
|
|
@@ -1062,7 +1093,7 @@ export function EditField({ field, value, onChange, record }) {
|
|
|
1062
1093
|
return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [_jsx(Switch, { checked: !!value, onCheckedChange: onChange }), _jsx("span", { className: "text-sm text-muted-foreground", children: value ? 'Sí' : 'No' })] }));
|
|
1063
1094
|
}
|
|
1064
1095
|
if (field.type === 'textarea') {
|
|
1065
|
-
return (_jsx(Textarea, { value: value ?? '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder, rows: 4 }));
|
|
1096
|
+
return (_jsx(Textarea, { value: value ?? '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder, rows: 4, "aria-invalid": invalid || undefined, className: invalidCls }));
|
|
1066
1097
|
}
|
|
1067
1098
|
// Icon picker: the kernel serves `type: 'icon'` (or an explicit
|
|
1068
1099
|
// `widget: 'icon'`) when the manifest column declares `widget: "icon"`.
|
|
@@ -1103,7 +1134,7 @@ export function EditField({ field, value, onChange, record }) {
|
|
|
1103
1134
|
// backend-injected FK sibling, key without `_id`) so an existing
|
|
1104
1135
|
// selection shows the label, not the raw uuid — without waiting
|
|
1105
1136
|
// for the popover to open and fetch a page.
|
|
1106
|
-
seedOption: fkSeedOption(field, value, record), hideCreate: hideSelfCreate }));
|
|
1137
|
+
seedOption: fkSeedOption(field, value, record), hideCreate: hideSelfCreate, invalid: invalid }));
|
|
1107
1138
|
}
|
|
1108
1139
|
if (field.type === 'search' && field.searchEndpoint) {
|
|
1109
1140
|
return _jsx(SearchField, { field: field, value: value, onChange: onChange });
|
|
@@ -1112,7 +1143,7 @@ export function EditField({ field, value, onChange, record }) {
|
|
|
1112
1143
|
return _jsx(SearchField, { field: { ...field, type: 'search' }, value: value, onChange: onChange });
|
|
1113
1144
|
}
|
|
1114
1145
|
if (field.type === 'select' && field.options?.length) {
|
|
1115
|
-
return (_jsxs(Select, { value: String(value ?? ''), onValueChange: onChange, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: "Seleccionar..." }) }), _jsx(SelectContent, { children: field.options.map(opt => (_jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value))) })] }));
|
|
1146
|
+
return (_jsxs(Select, { value: String(value ?? ''), onValueChange: onChange, children: [_jsx(SelectTrigger, { className: cn("w-full", invalidCls), "aria-invalid": invalid || undefined, children: _jsx(SelectValue, { placeholder: "Seleccionar..." }) }), _jsx(SelectContent, { children: field.options.map(opt => (_jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value))) })] }));
|
|
1116
1147
|
}
|
|
1117
1148
|
if (field.type === 'color') {
|
|
1118
1149
|
return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "color", value: value || '#6366f1', onChange: (e) => onChange(e.target.value), className: "h-9 w-14 cursor-pointer rounded-md border p-1" }), _jsx(Input, { value: value || '', onChange: (e) => onChange(e.target.value), placeholder: "#6366f1", className: "flex-1 h-9" })] }));
|
|
@@ -1133,7 +1164,7 @@ export function EditField({ field, value, onChange, record }) {
|
|
|
1133
1164
|
: field.type === 'email'
|
|
1134
1165
|
? 'email'
|
|
1135
1166
|
: 'text';
|
|
1136
|
-
return _jsx(ScannableRecordInput, { field: field, value: value, onChange: onChange, inputType: inputType });
|
|
1167
|
+
return _jsx(ScannableRecordInput, { field: field, value: value, onChange: onChange, inputType: inputType, invalid: invalid, className: invalidCls });
|
|
1137
1168
|
}
|
|
1138
1169
|
/**
|
|
1139
1170
|
* Input de texto/número del modal de crear/editar registro con opción de ESCANEO
|
|
@@ -1146,14 +1177,14 @@ export function EditField({ field, value, onChange, record }) {
|
|
|
1146
1177
|
* eso el escaneo se cablea también acá (si no, el auto-CRUD de crear producto
|
|
1147
1178
|
* nunca mostraba el botón).
|
|
1148
1179
|
*/
|
|
1149
|
-
function ScannableRecordInput({ field, value, onChange, inputType, }) {
|
|
1180
|
+
function ScannableRecordInput({ field, value, onChange, inputType, invalid, className, }) {
|
|
1150
1181
|
const [scanOpen, setScanOpen] = useState(false);
|
|
1151
1182
|
// El botón de escaneo aparece siempre que el campo declara `scan` (como el
|
|
1152
1183
|
// POS), sin esconderse por `isCameraScanSupported()`. El BarcodeScanner ya
|
|
1153
1184
|
// muestra un mensaje amable cuando el navegador no tiene cámara.
|
|
1154
1185
|
const scanEnabled = !!(field.scan ??
|
|
1155
1186
|
field.scannable);
|
|
1156
|
-
const input = (_jsx(Input, { type: inputType, value: value ?? '', onChange: (e) => onChange(inputType === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value), placeholder: field.placeholder }));
|
|
1187
|
+
const input = (_jsx(Input, { type: inputType, value: value ?? '', onChange: (e) => onChange(inputType === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value), placeholder: field.placeholder, "aria-invalid": invalid || undefined, className: className }));
|
|
1157
1188
|
if (!scanEnabled)
|
|
1158
1189
|
return input;
|
|
1159
1190
|
return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1", children: input }), _jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: () => setScanOpen(true), title: "Escanear con la c\u00E1mara", "aria-label": "Escanear c\u00F3digo de barras con la c\u00E1mara", children: _jsx(ScanLine, { className: "size-4" }) }), _jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: (code) => onChange(code),
|
|
@@ -88,7 +88,9 @@ export interface DynamicSelectFieldProps {
|
|
|
88
88
|
* host's create modal renders them locked. Pairs with `createDefaults`.
|
|
89
89
|
*/
|
|
90
90
|
createLockedFields?: string[];
|
|
91
|
+
/** Paint trigger with destructive border when validation failed. */
|
|
92
|
+
invalid?: boolean;
|
|
91
93
|
}
|
|
92
|
-
export declare function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, readonly, staticOptions, descriptionAsBadge, hideCreate, createDefaults, createLockedFields, }: DynamicSelectFieldProps): import("react").JSX.Element;
|
|
94
|
+
export declare function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, readonly, staticOptions, descriptionAsBadge, hideCreate, createDefaults, createLockedFields, invalid, }: DynamicSelectFieldProps): import("react").JSX.Element;
|
|
93
95
|
export default DynamicSelectField;
|
|
94
96
|
//# sourceMappingURL=dynamic-select-field.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AA4CA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA4BA;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAMD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI,CAAA;IAChD;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAA;IAC5B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACxC;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AA4CA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA4BA;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAMD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI,CAAA;IAChD;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAA;IAC5B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACxC;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC7B,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAgB,EAChB,aAAoB,EACpB,kBAA0B,EAC1B,UAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,OAAe,GAClB,EAAE,uBAAuB,+BA0TzB;AAED,eAAe,kBAAkB,CAAA"}
|
|
@@ -88,7 +88,7 @@ export function OptionLead({ option, size = 20, }) {
|
|
|
88
88
|
function useDebounced(value, ms) {
|
|
89
89
|
return useDebouncedValue(value, ms);
|
|
90
90
|
}
|
|
91
|
-
export function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, readonly = false, staticOptions = null, descriptionAsBadge = false, hideCreate = false, createDefaults, createLockedFields, }) {
|
|
91
|
+
export function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, readonly = false, staticOptions = null, descriptionAsBadge = false, hideCreate = false, createDefaults, createLockedFields, invalid = false, }) {
|
|
92
92
|
const { t } = useTranslation();
|
|
93
93
|
const ph = (fallback) => field.placeholder ? t(field.placeholder, { defaultValue: field.placeholder }) : fallback;
|
|
94
94
|
const [open, setOpen] = useState(false);
|
|
@@ -221,7 +221,8 @@ export function DynamicSelectField({ field, value, onChange, seedOption, depends
|
|
|
221
221
|
// (the long empty-state placeholder) and overflows the column, pushing the
|
|
222
222
|
// "+" off-screen — it only "fit" once a short value was selected.
|
|
223
223
|
return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsxs(Popover, { open: open && !blockedByDependency, onOpenChange: (o) => { if (!blockedByDependency)
|
|
224
|
-
setOpen(o); }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", role: "combobox", "aria-expanded": open, id: field.key, disabled: blockedByDependency,
|
|
224
|
+
setOpen(o); }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", role: "combobox", "aria-expanded": open, id: field.key, disabled: blockedByDependency, "aria-invalid": invalid || undefined, className: 'min-w-0 flex-1 justify-between font-normal' +
|
|
225
|
+
(invalid ? ' border-destructive ring-1 ring-destructive/30' : ''), "data-empty": !value, "data-depends-blocked": blockedByDependency ? '' : undefined, children: [_jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [value && selectedOption ? (_jsx(OptionLead, { option: selectedOption, size: 20 })) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: blockedByDependency
|
|
225
226
|
? (dependsHint || DEFAULT_DEPENDS_HINT)
|
|
226
227
|
: selectedLabel || ph('Buscar…') }), descriptionAsBadge && selectedOption?.description ? (_jsx(Badge, { variant: "secondary", className: "shrink-0 font-normal tabular-nums", children: selectedOption.description })) : null] }), _jsx(ChevronsUpDown, { className: "ml-2 size-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "p-0", align: "start",
|
|
227
228
|
// Match the trigger width without an arbitrary Tailwind class
|
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Client-side validateValues must paint invalid fields (border + message) so a
|
|
4
|
+
// toast "Revisa los campos marcados" is never orphaned when the field exists
|
|
5
|
+
// in the schema. ensureFields merges host-injected keys (e.g. branch_id) into
|
|
6
|
+
// the loaded modal before seed/render.
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
9
|
+
import { I18nextProvider } from 'react-i18next'
|
|
10
|
+
import i18next from 'i18next'
|
|
11
|
+
|
|
12
|
+
import { DynamicRecordDialog } from '../dialogs/dynamic-record'
|
|
13
|
+
import { ApiProvider } from '../api-context'
|
|
14
|
+
|
|
15
|
+
afterEach(cleanup)
|
|
16
|
+
|
|
17
|
+
vi.mock('sonner', () => ({
|
|
18
|
+
toast: {
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
success: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
function makeI18n() {
|
|
25
|
+
const inst = i18next.createInstance()
|
|
26
|
+
void inst.init({
|
|
27
|
+
lng: 'es',
|
|
28
|
+
fallbackLng: 'es',
|
|
29
|
+
react: { useSuspense: false },
|
|
30
|
+
resources: { es: { translation: {} } },
|
|
31
|
+
})
|
|
32
|
+
return inst
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const api = {
|
|
36
|
+
get: vi.fn(async (url: string) => {
|
|
37
|
+
if (String(url).includes('/metadata/modal/')) {
|
|
38
|
+
return {
|
|
39
|
+
data: {
|
|
40
|
+
data: {
|
|
41
|
+
title: 'Nuevo pedido',
|
|
42
|
+
fields: [{ key: 'name', label: 'Nombre', type: 'text', required: true }],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { data: { data: {} } }
|
|
48
|
+
}),
|
|
49
|
+
post: vi.fn(async () => ({ data: { success: true } })),
|
|
50
|
+
put: async () => ({ data: { success: true } }),
|
|
51
|
+
delete: async () => ({ data: { success: true } }),
|
|
52
|
+
} as never
|
|
53
|
+
|
|
54
|
+
describe('DynamicRecordDialog field validation UX', () => {
|
|
55
|
+
it('marks required empty fields invalid on submit', async () => {
|
|
56
|
+
render(
|
|
57
|
+
<I18nextProvider i18n={makeI18n()}>
|
|
58
|
+
<ApiProvider client={api}>
|
|
59
|
+
<DynamicRecordDialog
|
|
60
|
+
open
|
|
61
|
+
onOpenChange={() => {}}
|
|
62
|
+
mode="create"
|
|
63
|
+
model="SalesOrder"
|
|
64
|
+
schema={{
|
|
65
|
+
title: 'Nuevo pedido',
|
|
66
|
+
fields: [{ key: 'name', label: 'Nombre', type: 'text', required: true }],
|
|
67
|
+
} as never}
|
|
68
|
+
/>
|
|
69
|
+
</ApiProvider>
|
|
70
|
+
</I18nextProvider>,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
await waitFor(() => expect(screen.getByText('Nombre')).toBeTruthy())
|
|
74
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
|
|
75
|
+
|
|
76
|
+
await waitFor(() => {
|
|
77
|
+
const input = document.querySelector('input[aria-invalid="true"]')
|
|
78
|
+
expect(input).toBeTruthy()
|
|
79
|
+
expect(input!.className).toMatch(/border-destructive/)
|
|
80
|
+
expect(screen.getByText(/obligatorio|required/i)).toBeTruthy()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('merges ensureFields into fetched modal schema', async () => {
|
|
85
|
+
render(
|
|
86
|
+
<I18nextProvider i18n={makeI18n()}>
|
|
87
|
+
<ApiProvider client={api}>
|
|
88
|
+
<DynamicRecordDialog
|
|
89
|
+
open
|
|
90
|
+
onOpenChange={() => {}}
|
|
91
|
+
mode="create"
|
|
92
|
+
model="SalesOrder"
|
|
93
|
+
ensureFields={[
|
|
94
|
+
{
|
|
95
|
+
key: 'branch_id',
|
|
96
|
+
label: 'Sucursal',
|
|
97
|
+
type: 'dynamic_select',
|
|
98
|
+
ref: 'Branch',
|
|
99
|
+
required: true,
|
|
100
|
+
},
|
|
101
|
+
]}
|
|
102
|
+
/>
|
|
103
|
+
</ApiProvider>
|
|
104
|
+
</I18nextProvider>,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
await waitFor(() => expect(screen.getByText('Sucursal')).toBeTruthy())
|
|
108
|
+
expect(screen.getByText('Nombre')).toBeTruthy()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -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
|
|
@@ -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
|
>
|