@asteby/metacore-runtime-react 25.1.3 → 25.1.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 +16 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +76 -13
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +58 -8
- package/dist/server-error.d.ts +26 -1
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +66 -0
- package/package.json +3 -3
- package/src/__tests__/extract-field-errors.test.ts +102 -0
- package/src/action-modal-dispatcher.tsx +84 -12
- package/src/dialogs/dynamic-record.tsx +65 -8
- package/src/server-error.ts +87 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 25.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- bdbc542: Per-field validation error rendering + Spanish localization. On a 422 with a
|
|
8
|
+
`{ errors: { <field>: [{ code, params }] } }` map, create/edit dialogs and action
|
|
9
|
+
modals now show each field's first error inline (in red under the input) plus a
|
|
10
|
+
summary toast, instead of one flattened toast. Errors localize to Spanish from
|
|
11
|
+
locale-agnostic codes (`required`, `invalid_option`, `not_found`, `duplicate`,
|
|
12
|
+
`invalid_type`, generic fallback) using the field label via i18next `{{label}}`
|
|
13
|
+
interpolation, so hosts can override the copy under `validation.<code>`.
|
|
14
|
+
Pre-localized string entries pass through verbatim. Adds exported helpers
|
|
15
|
+
`extractFieldErrors` and `localizeFieldIssue` (with the `FieldIssue` type) in
|
|
16
|
+
`server-error`. The client-side required check now marks all missing fields
|
|
17
|
+
inline rather than toasting only the first.
|
|
18
|
+
|
|
3
19
|
## 25.1.3
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AA0DA,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAExB,MAAM,sBAAsB,CAAA;AAE7B,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,CAAA;AA0FhD,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,sCA+DlB"}
|
|
@@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
12
12
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Button, Input, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, } from '@asteby/metacore-ui/primitives';
|
|
13
13
|
import { Loader2 } from 'lucide-react';
|
|
14
14
|
import { toast } from 'sonner';
|
|
15
|
-
import { toastServerError, toastServerSuccess } from './server-error';
|
|
15
|
+
import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldIssue } from './server-error';
|
|
16
16
|
import { useApi } from './api-context';
|
|
17
17
|
import { DynamicIcon } from './dynamic-icon';
|
|
18
18
|
import { DynamicLineItems } from './dynamic-line-items';
|
|
@@ -250,6 +250,39 @@ function RecordPreview({ model, record }) {
|
|
|
250
250
|
return (_jsxs("div", { className: "flex items-baseline gap-3 border-t px-3 py-1.5 first:border-t-0", children: [_jsx("span", { className: "w-1/3 shrink-0 truncate text-muted-foreground", children: label }), _jsx("div", { className: "min-w-0 flex-1 [&_p]:py-0", children: _jsx(ViewValue, { field: col, value: value, record: record }) })] }, col.key));
|
|
251
251
|
}) }));
|
|
252
252
|
}
|
|
253
|
+
/** Humanize a field key for a label fallback ("unit_price" → "Unit Price"). */
|
|
254
|
+
function humanizeKey(k) {
|
|
255
|
+
return k.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
256
|
+
}
|
|
257
|
+
/** Localize a failed action submit's per-field `errors` (422 map or `{errors}`
|
|
258
|
+
* body) into `{ [fieldKey]: localizedMessage }`, using each field's label.
|
|
259
|
+
* Returns undefined when there is no usable per-field map. */
|
|
260
|
+
function localizeActionFieldErrors(err, fields, t) {
|
|
261
|
+
const map = extractFieldErrors(err);
|
|
262
|
+
if (!map)
|
|
263
|
+
return undefined;
|
|
264
|
+
const labelFor = (k) => {
|
|
265
|
+
const f = (fields ?? []).find(x => x.key === k);
|
|
266
|
+
return f?.label ? t(f.label, { defaultValue: f.label }) : humanizeKey(k);
|
|
267
|
+
};
|
|
268
|
+
const out = {};
|
|
269
|
+
for (const [k, issues] of Object.entries(map))
|
|
270
|
+
out[k] = localizeFieldIssue(issues[0], labelFor(k), t);
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
/** Toast a failed action: a summary + localized per-field lines when the server
|
|
274
|
+
* returned a per-field `errors` map, else the standard cause-carrying toast.
|
|
275
|
+
* Used where inline rendering isn't wired (confirm / multi-step wizard). */
|
|
276
|
+
function toastActionError(err, fields, t) {
|
|
277
|
+
const localized = localizeActionFieldErrors(err, fields, t);
|
|
278
|
+
if (localized) {
|
|
279
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), {
|
|
280
|
+
description: Object.values(localized).join('\n'),
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
toastServerError(err, { t });
|
|
285
|
+
}
|
|
253
286
|
function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }) {
|
|
254
287
|
const { t } = useTranslation();
|
|
255
288
|
const api = useApi();
|
|
@@ -269,11 +302,11 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
269
302
|
onSuccess();
|
|
270
303
|
}
|
|
271
304
|
else {
|
|
272
|
-
|
|
305
|
+
toastActionError({ response: { data: res.data } }, action.fields, t);
|
|
273
306
|
}
|
|
274
307
|
}
|
|
275
308
|
catch (err) {
|
|
276
|
-
|
|
309
|
+
toastActionError(err, action.fields, t);
|
|
277
310
|
}
|
|
278
311
|
finally {
|
|
279
312
|
setExecuting(false);
|
|
@@ -290,6 +323,8 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
290
323
|
const api = useApi();
|
|
291
324
|
const [formData, setFormData] = useState({});
|
|
292
325
|
const [executing, setExecuting] = useState(false);
|
|
326
|
+
// Per-field validation errors (localized), shown inline under each input.
|
|
327
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
293
328
|
// Related records to surface BELOW the form, as read-only context for the
|
|
294
329
|
// record being acted on — e.g. the reception history of a transfer while
|
|
295
330
|
// receiving against it. Sourced from the model's metadata.relations (the
|
|
@@ -334,28 +369,56 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
334
369
|
defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '');
|
|
335
370
|
}
|
|
336
371
|
setFormData(defaults);
|
|
372
|
+
setFieldErrors({});
|
|
337
373
|
}
|
|
338
374
|
}, [open, action.fields, record]);
|
|
339
|
-
const updateField = (key, value) =>
|
|
375
|
+
const updateField = (key, value) => {
|
|
376
|
+
setFormData((prev) => ({ ...prev, [key]: value }));
|
|
377
|
+
setFieldErrors(prev => {
|
|
378
|
+
if (!prev[key])
|
|
379
|
+
return prev;
|
|
380
|
+
const next = { ...prev };
|
|
381
|
+
delete next[key];
|
|
382
|
+
return next;
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
const handleActionError = (err) => {
|
|
386
|
+
const localized = localizeActionFieldErrors(err, action.fields, t);
|
|
387
|
+
if (localized) {
|
|
388
|
+
setFieldErrors(localized);
|
|
389
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
toastServerError(err, { t });
|
|
393
|
+
};
|
|
340
394
|
const execute = async () => {
|
|
341
395
|
if (action.fields) {
|
|
396
|
+
// Client-side required check → mark ALL missing fields inline.
|
|
397
|
+
const missing = {};
|
|
342
398
|
for (const field of action.fields) {
|
|
343
399
|
if (!field.required)
|
|
344
400
|
continue;
|
|
345
401
|
if (isLineItemsField(field)) {
|
|
346
402
|
const rows = formData[field.key];
|
|
347
403
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
348
|
-
|
|
349
|
-
|
|
404
|
+
missing[field.key] = t('validation.line_items_required', {
|
|
405
|
+
defaultValue: '{{label}} requiere al menos un renglón',
|
|
406
|
+
label: tl(field.label),
|
|
407
|
+
});
|
|
350
408
|
}
|
|
351
409
|
continue;
|
|
352
410
|
}
|
|
353
411
|
if (!formData[field.key] && formData[field.key] !== false) {
|
|
354
|
-
|
|
355
|
-
return;
|
|
412
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, tl(field.label), t);
|
|
356
413
|
}
|
|
357
414
|
}
|
|
415
|
+
if (Object.keys(missing).length) {
|
|
416
|
+
setFieldErrors(missing);
|
|
417
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
358
420
|
}
|
|
421
|
+
setFieldErrors({});
|
|
359
422
|
setExecuting(true);
|
|
360
423
|
try {
|
|
361
424
|
const url = buildActionUrl(endpoint, model, record.id, action.key);
|
|
@@ -366,11 +429,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
366
429
|
onSuccess();
|
|
367
430
|
}
|
|
368
431
|
else {
|
|
369
|
-
|
|
432
|
+
handleActionError({ response: { data: res.data } });
|
|
370
433
|
}
|
|
371
434
|
}
|
|
372
435
|
catch (err) {
|
|
373
|
-
|
|
436
|
+
handleActionError(err);
|
|
374
437
|
}
|
|
375
438
|
finally {
|
|
376
439
|
setExecuting(false);
|
|
@@ -394,7 +457,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
394
457
|
const fullWidth = isLineItemsField(field) ||
|
|
395
458
|
resolveWidget(field) === 'textarea' ||
|
|
396
459
|
resolveWidget(field) === 'richtext';
|
|
397
|
-
return (_jsxs(FieldCell, { fullWidth: fullWidth, children: [_jsx(FieldLabel, { htmlFor: field.key, required: field.required, children: tl(field.label) }), renderField(field, formData[field.key], (v) => updateField(field.key, v), formData)] }, field.key));
|
|
460
|
+
return (_jsxs(FieldCell, { fullWidth: fullWidth, children: [_jsx(FieldLabel, { htmlFor: field.key, required: field.required, children: tl(field.label) }), renderField(field, formData[field.key], (v) => updateField(field.key, v), formData), fieldErrors[field.key] && (_jsx("p", { className: "text-destructive text-xs mt-1", children: fieldErrors[field.key] }))] }, field.key));
|
|
398
461
|
}), relations.length > 0 && (_jsx(FieldCell, { fullWidth: true, children: _jsx(DynamicRelations, { record: record, relations: relations }) }))] }) }), _jsxs(DialogFooter, { className: "shrink-0", children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: executing, children: t('common.cancel') }), _jsxs(Button, { onClick: execute, disabled: executing, style: action.color ? { backgroundColor: action.color, color: 'white' } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), tl(action.label)] })] })] }) }));
|
|
399
462
|
}
|
|
400
463
|
// buildFieldDefaults seeds formData for a set of action fields, honoring the
|
|
@@ -495,11 +558,11 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
495
558
|
onSuccess();
|
|
496
559
|
}
|
|
497
560
|
else {
|
|
498
|
-
|
|
561
|
+
toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t);
|
|
499
562
|
}
|
|
500
563
|
}
|
|
501
564
|
catch (err) {
|
|
502
|
-
|
|
565
|
+
toastActionError(err, steps.flatMap(s => s.fields ?? []), t);
|
|
503
566
|
}
|
|
504
567
|
finally {
|
|
505
568
|
setExecuting(false);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
|
|
@@ -19,7 +19,7 @@ import { format, parseISO } from 'date-fns';
|
|
|
19
19
|
import { es } from 'date-fns/locale';
|
|
20
20
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react';
|
|
21
21
|
import { useApi } from '../api-context';
|
|
22
|
-
import { toastServerError } from '../server-error';
|
|
22
|
+
import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error';
|
|
23
23
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field';
|
|
24
24
|
import { DynamicRelations } from '../dynamic-relations';
|
|
25
25
|
import { useOptionsResolver } from '../use-options-resolver';
|
|
@@ -254,6 +254,10 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
254
254
|
const [relations, setRelations] = useState([]);
|
|
255
255
|
const [record, setRecord] = useState(null);
|
|
256
256
|
const [formValues, setFormValues] = useState({});
|
|
257
|
+
// Per-field validation errors (localized strings), keyed by field.key. Shown
|
|
258
|
+
// inline under each input; populated from a 422 `errors` map or the client
|
|
259
|
+
// required-field check, cleared per-field on change and wholesale on reopen.
|
|
260
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
257
261
|
const [loading, setLoading] = useState(false);
|
|
258
262
|
const [saving, setSaving] = useState(false);
|
|
259
263
|
const [deleting, setDeleting] = useState(false);
|
|
@@ -267,6 +271,8 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
267
271
|
return;
|
|
268
272
|
if (!isCreate && !recordId)
|
|
269
273
|
return;
|
|
274
|
+
// Fresh open → drop any validation errors from a prior submit.
|
|
275
|
+
setFieldErrors({});
|
|
270
276
|
let cancelled = false;
|
|
271
277
|
// Seed instantly from the row the table already has so view/edit render
|
|
272
278
|
// without a spinner. The list row carries the pro siblings (resolved
|
|
@@ -424,18 +430,52 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
424
430
|
}
|
|
425
431
|
onChange?.();
|
|
426
432
|
}, [api, endpoint, model, recordId, isCreate, modalMeta, onChange]);
|
|
433
|
+
// The human label for a field key, for localizing validation errors. Falls
|
|
434
|
+
// back to a humanized key when the field is unknown (e.g. a server-side key
|
|
435
|
+
// with no matching form field).
|
|
436
|
+
const labelForKey = (key) => {
|
|
437
|
+
const f = (modalMeta?.fields ?? []).find(x => x.key === key);
|
|
438
|
+
if (f?.label)
|
|
439
|
+
return f.label;
|
|
440
|
+
return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
441
|
+
};
|
|
442
|
+
// Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into
|
|
443
|
+
// inline field errors + a summary toast. When there is no field map, fall
|
|
444
|
+
// back to the existing single cause-carrying toast.
|
|
445
|
+
const handleSubmitError = (err) => {
|
|
446
|
+
const map = extractFieldErrors(err);
|
|
447
|
+
if (map) {
|
|
448
|
+
const next = {};
|
|
449
|
+
for (const [key, issues] of Object.entries(map)) {
|
|
450
|
+
next[key] = localizeFieldIssue(issues[0], labelForKey(key), t);
|
|
451
|
+
}
|
|
452
|
+
setFieldErrors(next);
|
|
453
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) });
|
|
457
|
+
};
|
|
427
458
|
const handleSubmit = async (e) => {
|
|
428
459
|
e?.preventDefault();
|
|
429
460
|
if (!modalMeta)
|
|
430
461
|
return;
|
|
431
462
|
if (isEditable) {
|
|
463
|
+
// Collect ALL missing required fields (not just the first) and mark
|
|
464
|
+
// each inline instead of a single toast.
|
|
465
|
+
const missing = {};
|
|
432
466
|
for (const field of modalMeta.fields ?? []) {
|
|
433
467
|
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
434
|
-
|
|
435
|
-
return;
|
|
468
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t);
|
|
436
469
|
}
|
|
437
470
|
}
|
|
471
|
+
if (Object.keys(missing).length) {
|
|
472
|
+
setFieldErrors(missing);
|
|
473
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
438
476
|
}
|
|
477
|
+
// Required check passed → clear any prior validation errors.
|
|
478
|
+
setFieldErrors({});
|
|
439
479
|
// Empty reference pickers → null (not "" / nil-UUID) so nullable FK
|
|
440
480
|
// columns accept them instead of raising a 23503 FK violation.
|
|
441
481
|
const payload = normalizeRefFieldsForSubmit(formValues, modalMeta.fields);
|
|
@@ -482,11 +522,11 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
482
522
|
// Surface the server's real cause (`details`) as the toast
|
|
483
523
|
// description, not just the generic headline. `res.data` is the
|
|
484
524
|
// `{ success:false, message, details }` envelope.
|
|
485
|
-
|
|
525
|
+
handleSubmitError(res.data);
|
|
486
526
|
}
|
|
487
527
|
}
|
|
488
528
|
catch (err) {
|
|
489
|
-
|
|
529
|
+
handleSubmitError(err);
|
|
490
530
|
}
|
|
491
531
|
finally {
|
|
492
532
|
setSaving(false);
|
|
@@ -514,13 +554,23 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
514
554
|
const isFullWidth = field.type === 'textarea' ||
|
|
515
555
|
field.widget === 'textarea' ||
|
|
516
556
|
field.widget === 'richtext';
|
|
517
|
-
return (_jsx(FieldCell, { fullWidth: isFullWidth, children: _jsx(FieldRow, { field: field, record: record, value: formValues[field.key] ?? '', mode: mode,
|
|
557
|
+
return (_jsx(FieldCell, { fullWidth: isFullWidth, children: _jsx(FieldRow, { field: field, record: record, value: formValues[field.key] ?? '', mode: mode, error: fieldErrors[field.key], onChange: val => {
|
|
558
|
+
setFormValues((prev) => ({ ...prev, [field.key]: val }));
|
|
559
|
+
// Clear this field's error as soon as the user edits it.
|
|
560
|
+
setFieldErrors(prev => {
|
|
561
|
+
if (!prev[field.key])
|
|
562
|
+
return prev;
|
|
563
|
+
const next = { ...prev };
|
|
564
|
+
delete next[field.key];
|
|
565
|
+
return next;
|
|
566
|
+
});
|
|
567
|
+
} }) }, field.key));
|
|
518
568
|
}), record?.external_url && (_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, 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: [_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 && (_jsxs(Button, { type: "submit", form: "dynamic-record-form", disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] })] }) }));
|
|
519
569
|
}
|
|
520
570
|
function LoadingSkeleton() {
|
|
521
571
|
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))) }));
|
|
522
572
|
}
|
|
523
|
-
function FieldRow({ field, record, value, mode, onChange }) {
|
|
573
|
+
function FieldRow({ field, record, value, mode, onChange, error }) {
|
|
524
574
|
// A `readonly` field is server/system-generated (e.g. the GitHub addon's
|
|
525
575
|
// `number`/`github_url`, filled by the API after the outbound create). On
|
|
526
576
|
// CREATE it is excluded from the form entirely (see `visibleFields`); on EDIT
|
|
@@ -528,7 +578,7 @@ function FieldRow({ field, record, value, mode, onChange }) {
|
|
|
528
578
|
// so the user sees its value without being able to change it. View mode keeps
|
|
529
579
|
// the rich read-only renderer.
|
|
530
580
|
const isEditReadonly = mode === 'edit' && !!field.readonly;
|
|
531
|
-
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 }))] }));
|
|
581
|
+
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 }))] }));
|
|
532
582
|
}
|
|
533
583
|
// ReadonlyEditField — the edit-mode rendering of a `readonly` (system-generated)
|
|
534
584
|
// field: a disabled, muted input that shows the current value without allowing
|
package/dist/server-error.d.ts
CHANGED
|
@@ -18,10 +18,35 @@ export interface ExtractedError {
|
|
|
18
18
|
* description ← data.details → data.error → joined data.errors → raw err.message
|
|
19
19
|
*/
|
|
20
20
|
export declare function extractServerError(err: unknown, fallbackTitle: string): ExtractedError;
|
|
21
|
-
/** i18n translator, tolerant of non-keys (returns `defaultValue`/the input).
|
|
21
|
+
/** i18n translator, tolerant of non-keys (returns `defaultValue`/the input).
|
|
22
|
+
* Accepts arbitrary interpolation values (e.g. `label`, plus a code's `params`)
|
|
23
|
+
* so `localizeFieldIssue` can pass `{{label}}` and friends through i18next. */
|
|
22
24
|
export type Translate = (key: string, opts?: {
|
|
23
25
|
defaultValue?: string;
|
|
26
|
+
[k: string]: unknown;
|
|
24
27
|
}) => string;
|
|
28
|
+
/** A single normalized validation issue for one field: either a machine `code`
|
|
29
|
+
* (+ optional `params`) to localize, or a ready-to-show `message` string. */
|
|
30
|
+
export interface FieldIssue {
|
|
31
|
+
code?: string;
|
|
32
|
+
params?: Record<string, any>;
|
|
33
|
+
message?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Pull the per-field `errors` map out of an axios error (`err.response.data.errors`)
|
|
37
|
+
* or a bare response body (`{ errors }`), normalizing each field's value to a
|
|
38
|
+
* `FieldIssue[]`. A value may be a single entry or an array; string entries become
|
|
39
|
+
* `{message}`, object entries `{code,params}`. Returns `undefined` when there is no
|
|
40
|
+
* usable map (no `errors`, or nothing normalized). Pure — no i18n, no toast.
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractFieldErrors(err: unknown): Record<string, FieldIssue[]> | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Localize a single `FieldIssue` to a human, Spanish-by-default string using the
|
|
45
|
+
* field `label`. A pre-localized `message` passes through verbatim. Otherwise the
|
|
46
|
+
* `code` is translated via `t('validation.'+code, { defaultValue, label, ...params })`
|
|
47
|
+
* so hosts can override the copy and `{{label}}`/param interpolation still works.
|
|
48
|
+
*/
|
|
49
|
+
export declare function localizeFieldIssue(issue: FieldIssue, label: string, t: Translate): string;
|
|
25
50
|
/**
|
|
26
51
|
* Toast a successful mutation/action response, LOCALIZED. The kernel/host often
|
|
27
52
|
* returns a hardcoded English `message` ("Record created successfully"); echoing
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-error.d.ts","sourceRoot":"","sources":["../src/server-error.ts"],"names":[],"mappings":"AAaA,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC3B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAA;IACb;;gDAE4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AAiBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,GAAG,cAAc,CAuBtF;AAED
|
|
1
|
+
{"version":3,"file":"server-error.d.ts","sourceRoot":"","sources":["../src/server-error.ts"],"names":[],"mappings":"AAaA,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC3B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAA;IACb;;gDAE4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AAiBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,GAAG,cAAc,CAuBtF;AAED;;gFAEgF;AAChF,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,KAAK,MAAM,CAAA;AAUvG;8EAC8E;AAC9E,MAAM,WAAW,UAAU;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAA;CACnB;AAkCD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,GAAG,SAAS,CAazF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,GAAG,MAAM,CAKzF;AAUD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAC9B,IAAI,EAAE,OAAO,EACb,IAAI,CAAC,EAAE;IAAE,CAAC,CAAC,EAAE,SAAS,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/C,IAAI,CAUN;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE;IAAE,CAAC,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAOhG"}
|
package/dist/server-error.js
CHANGED
|
@@ -59,6 +59,72 @@ export function extractServerError(err, fallbackTitle) {
|
|
|
59
59
|
return { title: fallbackTitle, description: raw.trim() };
|
|
60
60
|
return { title: fallbackTitle };
|
|
61
61
|
}
|
|
62
|
+
/** Spanish defaults per known validation code. `{{label}}` (and code params like
|
|
63
|
+
* `allowed`/`ref`/`expected`) are interpolated by i18next, so a host can override
|
|
64
|
+
* any of these via its addon i18n bundle under `validation.<code>`. */
|
|
65
|
+
const VALIDATION_DEFAULTS = {
|
|
66
|
+
required: 'El campo {{label}} es obligatorio',
|
|
67
|
+
invalid_option: 'El valor de {{label}} no es válido',
|
|
68
|
+
not_found: 'El {{label}} seleccionado no existe',
|
|
69
|
+
duplicate: 'Ya existe un registro con ese {{label}}',
|
|
70
|
+
invalid_type: 'El campo {{label}} tiene un formato inválido',
|
|
71
|
+
};
|
|
72
|
+
const VALIDATION_FALLBACK = '{{label}}: valor inválido';
|
|
73
|
+
/** Normalize one raw `errors` value entry into a `FieldIssue`.
|
|
74
|
+
* A string → `{message}` (pre-localized, shown verbatim); an object → `{code,params}`. */
|
|
75
|
+
function toFieldIssue(entry) {
|
|
76
|
+
if (typeof entry === 'string') {
|
|
77
|
+
const s = entry.trim();
|
|
78
|
+
return s ? { message: s } : undefined;
|
|
79
|
+
}
|
|
80
|
+
if (entry && typeof entry === 'object') {
|
|
81
|
+
const e = entry;
|
|
82
|
+
if (typeof e.message === 'string' && e.message.trim())
|
|
83
|
+
return { message: e.message.trim() };
|
|
84
|
+
if (typeof e.code === 'string' && e.code) {
|
|
85
|
+
return {
|
|
86
|
+
code: e.code,
|
|
87
|
+
params: e.params && typeof e.params === 'object' ? e.params : undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Pull the per-field `errors` map out of an axios error (`err.response.data.errors`)
|
|
95
|
+
* or a bare response body (`{ errors }`), normalizing each field's value to a
|
|
96
|
+
* `FieldIssue[]`. A value may be a single entry or an array; string entries become
|
|
97
|
+
* `{message}`, object entries `{code,params}`. Returns `undefined` when there is no
|
|
98
|
+
* usable map (no `errors`, or nothing normalized). Pure — no i18n, no toast.
|
|
99
|
+
*/
|
|
100
|
+
export function extractFieldErrors(err) {
|
|
101
|
+
const maybeAxios = err?.response?.data;
|
|
102
|
+
const data = maybeAxios ?? err;
|
|
103
|
+
const errors = data?.errors;
|
|
104
|
+
if (!errors || typeof errors !== 'object' || Array.isArray(errors))
|
|
105
|
+
return undefined;
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [key, raw] of Object.entries(errors)) {
|
|
108
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
109
|
+
const issues = entries.map(toFieldIssue).filter((i) => !!i);
|
|
110
|
+
if (issues.length)
|
|
111
|
+
out[key] = issues;
|
|
112
|
+
}
|
|
113
|
+
return Object.keys(out).length ? out : undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Localize a single `FieldIssue` to a human, Spanish-by-default string using the
|
|
117
|
+
* field `label`. A pre-localized `message` passes through verbatim. Otherwise the
|
|
118
|
+
* `code` is translated via `t('validation.'+code, { defaultValue, label, ...params })`
|
|
119
|
+
* so hosts can override the copy and `{{label}}`/param interpolation still works.
|
|
120
|
+
*/
|
|
121
|
+
export function localizeFieldIssue(issue, label, t) {
|
|
122
|
+
if (issue.message)
|
|
123
|
+
return issue.message;
|
|
124
|
+
const code = issue.code ?? '';
|
|
125
|
+
const defaultValue = VALIDATION_DEFAULTS[code] ?? VALIDATION_FALLBACK;
|
|
126
|
+
return t(`validation.${code}`, { defaultValue, label, ...(issue.params ?? {}) });
|
|
127
|
+
}
|
|
62
128
|
/** A dotted, space-free token (e.g. "pos.rate.created") — the shape of an i18n
|
|
63
129
|
* key, as opposed to human prose ("Record created successfully"). Used to
|
|
64
130
|
* decide whether a server-sent message is safe to translate or should be
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "25.1.
|
|
3
|
+
"version": "25.1.4",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^6.0.0",
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@asteby/metacore-
|
|
71
|
-
"@asteby/metacore-
|
|
70
|
+
"@asteby/metacore-sdk": "3.3.0",
|
|
71
|
+
"@asteby/metacore-ui": "2.10.1"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { extractFieldErrors, localizeFieldIssue, type FieldIssue } from '../server-error'
|
|
3
|
+
|
|
4
|
+
// A minimal i18next-like translator: honors defaultValue and does {{var}}
|
|
5
|
+
// interpolation, so the tests assert BOTH the resolved Spanish default and that
|
|
6
|
+
// the field label / code params are interpolated.
|
|
7
|
+
const t = (_key: string, opts?: { defaultValue?: string; [k: string]: unknown }) => {
|
|
8
|
+
let out = opts?.defaultValue ?? _key
|
|
9
|
+
if (opts) {
|
|
10
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
11
|
+
if (k === 'defaultValue') continue
|
|
12
|
+
out = out.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v))
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return out
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('extractFieldErrors', () => {
|
|
19
|
+
it('normalizes object entries {code,params} from an axios error', () => {
|
|
20
|
+
const err = {
|
|
21
|
+
response: {
|
|
22
|
+
data: {
|
|
23
|
+
success: false,
|
|
24
|
+
message: 'validation failed',
|
|
25
|
+
errors: { name: [{ code: 'required', params: {} }], sku: [{ code: 'duplicate' }] },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
expect(extractFieldErrors(err)).toEqual({
|
|
30
|
+
name: [{ code: 'required', params: {} }],
|
|
31
|
+
sku: [{ code: 'duplicate', params: undefined }],
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('normalizes string entries to {message}', () => {
|
|
36
|
+
const err = { errors: { name: ['El nombre es obligatorio'], sku: 'Duplicado' } }
|
|
37
|
+
expect(extractFieldErrors(err)).toEqual({
|
|
38
|
+
name: [{ message: 'El nombre es obligatorio' }],
|
|
39
|
+
sku: [{ message: 'Duplicado' }],
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('accepts a bare response body as well as an axios error', () => {
|
|
44
|
+
const bare = { errors: { qty: [{ code: 'invalid_type', params: { expected: 'number' } }] } }
|
|
45
|
+
expect(extractFieldErrors(bare)).toEqual({
|
|
46
|
+
qty: [{ code: 'invalid_type', params: { expected: 'number' } }],
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('carries code params through (allowed / ref)', () => {
|
|
51
|
+
const err = {
|
|
52
|
+
errors: {
|
|
53
|
+
status: [{ code: 'invalid_option', params: { allowed: ['a', 'b'] } }],
|
|
54
|
+
owner: [{ code: 'not_found', params: { ref: 'users' } }],
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
expect(extractFieldErrors(err)).toEqual({
|
|
58
|
+
status: [{ code: 'invalid_option', params: { allowed: ['a', 'b'] } }],
|
|
59
|
+
owner: [{ code: 'not_found', params: { ref: 'users' } }],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns undefined when there is no usable errors map', () => {
|
|
64
|
+
expect(extractFieldErrors(undefined)).toBeUndefined()
|
|
65
|
+
expect(extractFieldErrors({ response: { data: { message: 'boom' } } })).toBeUndefined()
|
|
66
|
+
expect(extractFieldErrors({ errors: 'oops' })).toBeUndefined() // string, not a map
|
|
67
|
+
expect(extractFieldErrors({ errors: ['a', 'b'] })).toBeUndefined() // array, not a map
|
|
68
|
+
expect(extractFieldErrors({ errors: {} })).toBeUndefined() // empty
|
|
69
|
+
expect(extractFieldErrors({ errors: { x: [null, ''] } })).toBeUndefined() // nothing normalizes
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('localizeFieldIssue', () => {
|
|
74
|
+
it('required → interpolates label', () => {
|
|
75
|
+
expect(localizeFieldIssue({ code: 'required' }, 'Nombre', t)).toBe('El campo Nombre es obligatorio')
|
|
76
|
+
})
|
|
77
|
+
it('invalid_option', () => {
|
|
78
|
+
expect(localizeFieldIssue({ code: 'invalid_option', params: { allowed: ['a'] } }, 'Estado', t)).toBe(
|
|
79
|
+
'El valor de Estado no es válido',
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
it('not_found', () => {
|
|
83
|
+
expect(localizeFieldIssue({ code: 'not_found', params: { ref: 'users' } }, 'Responsable', t)).toBe(
|
|
84
|
+
'El Responsable seleccionado no existe',
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
it('duplicate', () => {
|
|
88
|
+
expect(localizeFieldIssue({ code: 'duplicate' }, 'SKU', t)).toBe('Ya existe un registro con ese SKU')
|
|
89
|
+
})
|
|
90
|
+
it('invalid_type', () => {
|
|
91
|
+
expect(localizeFieldIssue({ code: 'invalid_type', params: { expected: 'number' } }, 'Cantidad', t)).toBe(
|
|
92
|
+
'El campo Cantidad tiene un formato inválido',
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
it('unknown code → generic fallback with label', () => {
|
|
96
|
+
expect(localizeFieldIssue({ code: 'weird_code' }, 'Campo', t)).toBe('Campo: valor inválido')
|
|
97
|
+
})
|
|
98
|
+
it('message passthrough (pre-localized) ignores code/label', () => {
|
|
99
|
+
const issue: FieldIssue = { message: 'Texto ya localizado' }
|
|
100
|
+
expect(localizeFieldIssue(issue, 'Ignorado', t)).toBe('Texto ya localizado')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -35,7 +35,8 @@ import {
|
|
|
35
35
|
} from '@asteby/metacore-ui/primitives'
|
|
36
36
|
import { Loader2 } from 'lucide-react'
|
|
37
37
|
import { toast } from 'sonner'
|
|
38
|
-
import { toastServerError, toastServerSuccess } from './server-error'
|
|
38
|
+
import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldIssue } from './server-error'
|
|
39
|
+
import type { Translate } from './server-error'
|
|
39
40
|
import { useApi } from './api-context'
|
|
40
41
|
import { DynamicIcon } from './dynamic-icon'
|
|
41
42
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
@@ -403,6 +404,44 @@ function RecordPreview({ model, record }: { model: string; record: any }) {
|
|
|
403
404
|
)
|
|
404
405
|
}
|
|
405
406
|
|
|
407
|
+
/** Humanize a field key for a label fallback ("unit_price" → "Unit Price"). */
|
|
408
|
+
function humanizeKey(k: string): string {
|
|
409
|
+
return k.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Localize a failed action submit's per-field `errors` (422 map or `{errors}`
|
|
413
|
+
* body) into `{ [fieldKey]: localizedMessage }`, using each field's label.
|
|
414
|
+
* Returns undefined when there is no usable per-field map. */
|
|
415
|
+
function localizeActionFieldErrors(
|
|
416
|
+
err: unknown,
|
|
417
|
+
fields: readonly ActionFieldDef[] | undefined,
|
|
418
|
+
t: Translate,
|
|
419
|
+
): Record<string, string> | undefined {
|
|
420
|
+
const map = extractFieldErrors(err)
|
|
421
|
+
if (!map) return undefined
|
|
422
|
+
const labelFor = (k: string) => {
|
|
423
|
+
const f = (fields ?? []).find(x => x.key === k)
|
|
424
|
+
return f?.label ? t(f.label, { defaultValue: f.label }) : humanizeKey(k)
|
|
425
|
+
}
|
|
426
|
+
const out: Record<string, string> = {}
|
|
427
|
+
for (const [k, issues] of Object.entries(map)) out[k] = localizeFieldIssue(issues[0], labelFor(k), t)
|
|
428
|
+
return out
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Toast a failed action: a summary + localized per-field lines when the server
|
|
432
|
+
* returned a per-field `errors` map, else the standard cause-carrying toast.
|
|
433
|
+
* Used where inline rendering isn't wired (confirm / multi-step wizard). */
|
|
434
|
+
function toastActionError(err: unknown, fields: readonly ActionFieldDef[] | undefined, t: Translate): void {
|
|
435
|
+
const localized = localizeActionFieldErrors(err, fields, t)
|
|
436
|
+
if (localized) {
|
|
437
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), {
|
|
438
|
+
description: Object.values(localized).join('\n'),
|
|
439
|
+
})
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
toastServerError(err, { t })
|
|
443
|
+
}
|
|
444
|
+
|
|
406
445
|
function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
|
|
407
446
|
const { t } = useTranslation()
|
|
408
447
|
const api = useApi()
|
|
@@ -422,10 +461,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
422
461
|
onOpenChange(false)
|
|
423
462
|
onSuccess()
|
|
424
463
|
} else {
|
|
425
|
-
|
|
464
|
+
toastActionError({ response: { data: res.data } }, action.fields, t)
|
|
426
465
|
}
|
|
427
466
|
} catch (err: any) {
|
|
428
|
-
|
|
467
|
+
toastActionError(err, action.fields, t)
|
|
429
468
|
} finally {
|
|
430
469
|
setExecuting(false)
|
|
431
470
|
}
|
|
@@ -469,6 +508,8 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
469
508
|
const api = useApi()
|
|
470
509
|
const [formData, setFormData] = useState<Record<string, any>>({})
|
|
471
510
|
const [executing, setExecuting] = useState(false)
|
|
511
|
+
// Per-field validation errors (localized), shown inline under each input.
|
|
512
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
|
472
513
|
// Related records to surface BELOW the form, as read-only context for the
|
|
473
514
|
// record being acted on — e.g. the reception history of a transfer while
|
|
474
515
|
// receiving against it. Sourced from the model's metadata.relations (the
|
|
@@ -512,29 +553,57 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
512
553
|
defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
|
|
513
554
|
}
|
|
514
555
|
setFormData(defaults)
|
|
556
|
+
setFieldErrors({})
|
|
515
557
|
}
|
|
516
558
|
}, [open, action.fields, record])
|
|
517
559
|
|
|
518
|
-
const updateField = (key: string, value: any) =>
|
|
560
|
+
const updateField = (key: string, value: any) => {
|
|
561
|
+
setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
|
|
562
|
+
setFieldErrors(prev => {
|
|
563
|
+
if (!prev[key]) return prev
|
|
564
|
+
const next = { ...prev }
|
|
565
|
+
delete next[key]
|
|
566
|
+
return next
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const handleActionError = (err: unknown) => {
|
|
571
|
+
const localized = localizeActionFieldErrors(err, action.fields, t)
|
|
572
|
+
if (localized) {
|
|
573
|
+
setFieldErrors(localized)
|
|
574
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
toastServerError(err, { t })
|
|
578
|
+
}
|
|
519
579
|
|
|
520
580
|
const execute = async () => {
|
|
521
581
|
if (action.fields) {
|
|
582
|
+
// Client-side required check → mark ALL missing fields inline.
|
|
583
|
+
const missing: Record<string, string> = {}
|
|
522
584
|
for (const field of action.fields) {
|
|
523
585
|
if (!field.required) continue
|
|
524
586
|
if (isLineItemsField(field)) {
|
|
525
587
|
const rows = formData[field.key]
|
|
526
588
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
527
|
-
|
|
528
|
-
|
|
589
|
+
missing[field.key] = t('validation.line_items_required', {
|
|
590
|
+
defaultValue: '{{label}} requiere al menos un renglón',
|
|
591
|
+
label: tl(field.label),
|
|
592
|
+
})
|
|
529
593
|
}
|
|
530
594
|
continue
|
|
531
595
|
}
|
|
532
596
|
if (!formData[field.key] && formData[field.key] !== false) {
|
|
533
|
-
|
|
534
|
-
return
|
|
597
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, tl(field.label), t)
|
|
535
598
|
}
|
|
536
599
|
}
|
|
600
|
+
if (Object.keys(missing).length) {
|
|
601
|
+
setFieldErrors(missing)
|
|
602
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
603
|
+
return
|
|
604
|
+
}
|
|
537
605
|
}
|
|
606
|
+
setFieldErrors({})
|
|
538
607
|
setExecuting(true)
|
|
539
608
|
try {
|
|
540
609
|
const url = buildActionUrl(endpoint, model, record.id, action.key)
|
|
@@ -544,10 +613,10 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
544
613
|
onOpenChange(false)
|
|
545
614
|
onSuccess()
|
|
546
615
|
} else {
|
|
547
|
-
|
|
616
|
+
handleActionError({ response: { data: res.data } })
|
|
548
617
|
}
|
|
549
618
|
} catch (err: any) {
|
|
550
|
-
|
|
619
|
+
handleActionError(err)
|
|
551
620
|
} finally {
|
|
552
621
|
setExecuting(false)
|
|
553
622
|
}
|
|
@@ -609,6 +678,9 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
609
678
|
{tl(field.label)}
|
|
610
679
|
</FieldLabel>
|
|
611
680
|
{renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
|
|
681
|
+
{fieldErrors[field.key] && (
|
|
682
|
+
<p className="text-destructive text-xs mt-1">{fieldErrors[field.key]}</p>
|
|
683
|
+
)}
|
|
612
684
|
</FieldCell>
|
|
613
685
|
)
|
|
614
686
|
})}
|
|
@@ -749,10 +821,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
749
821
|
onOpenChange(false)
|
|
750
822
|
onSuccess()
|
|
751
823
|
} else {
|
|
752
|
-
|
|
824
|
+
toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t)
|
|
753
825
|
}
|
|
754
826
|
} catch (err: any) {
|
|
755
|
-
|
|
827
|
+
toastActionError(err, steps.flatMap(s => s.fields ?? []), t)
|
|
756
828
|
} finally {
|
|
757
829
|
setExecuting(false)
|
|
758
830
|
}
|
|
@@ -48,7 +48,7 @@ import { format, parseISO } from 'date-fns'
|
|
|
48
48
|
import { es } from 'date-fns/locale'
|
|
49
49
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react'
|
|
50
50
|
import { useApi } from '../api-context'
|
|
51
|
-
import { toastServerError } from '../server-error'
|
|
51
|
+
import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
|
|
52
52
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
|
|
53
53
|
import { DynamicRelations } from '../dynamic-relations'
|
|
54
54
|
import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
|
|
@@ -497,6 +497,10 @@ export function DynamicRecordDialog({
|
|
|
497
497
|
const [relations, setRelations] = useState<RelationMeta[]>([])
|
|
498
498
|
const [record, setRecord] = useState<any | null>(null)
|
|
499
499
|
const [formValues, setFormValues] = useState<Record<string, any>>({})
|
|
500
|
+
// Per-field validation errors (localized strings), keyed by field.key. Shown
|
|
501
|
+
// inline under each input; populated from a 422 `errors` map or the client
|
|
502
|
+
// required-field check, cleared per-field on change and wholesale on reopen.
|
|
503
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
|
500
504
|
const [loading, setLoading] = useState(false)
|
|
501
505
|
const [saving, setSaving] = useState(false)
|
|
502
506
|
const [deleting, setDeleting] = useState(false)
|
|
@@ -511,6 +515,9 @@ export function DynamicRecordDialog({
|
|
|
511
515
|
if (!open) return
|
|
512
516
|
if (!isCreate && !recordId) return
|
|
513
517
|
|
|
518
|
+
// Fresh open → drop any validation errors from a prior submit.
|
|
519
|
+
setFieldErrors({})
|
|
520
|
+
|
|
514
521
|
let cancelled = false
|
|
515
522
|
|
|
516
523
|
// Seed instantly from the row the table already has so view/edit render
|
|
@@ -671,19 +678,55 @@ export function DynamicRecordDialog({
|
|
|
671
678
|
onChange?.()
|
|
672
679
|
}, [api, endpoint, model, recordId, isCreate, modalMeta, onChange])
|
|
673
680
|
|
|
681
|
+
// The human label for a field key, for localizing validation errors. Falls
|
|
682
|
+
// back to a humanized key when the field is unknown (e.g. a server-side key
|
|
683
|
+
// with no matching form field).
|
|
684
|
+
const labelForKey = (key: string): string => {
|
|
685
|
+
const f = (modalMeta?.fields ?? []).find(x => x.key === key)
|
|
686
|
+
if (f?.label) return f.label
|
|
687
|
+
return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into
|
|
691
|
+
// inline field errors + a summary toast. When there is no field map, fall
|
|
692
|
+
// back to the existing single cause-carrying toast.
|
|
693
|
+
const handleSubmitError = (err: unknown) => {
|
|
694
|
+
const map = extractFieldErrors(err)
|
|
695
|
+
if (map) {
|
|
696
|
+
const next: Record<string, string> = {}
|
|
697
|
+
for (const [key, issues] of Object.entries(map)) {
|
|
698
|
+
next[key] = localizeFieldIssue(issues[0], labelForKey(key), t)
|
|
699
|
+
}
|
|
700
|
+
setFieldErrors(next)
|
|
701
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
|
|
705
|
+
}
|
|
706
|
+
|
|
674
707
|
const handleSubmit = async (e?: React.FormEvent) => {
|
|
675
708
|
e?.preventDefault()
|
|
676
709
|
if (!modalMeta) return
|
|
677
710
|
|
|
678
711
|
if (isEditable) {
|
|
712
|
+
// Collect ALL missing required fields (not just the first) and mark
|
|
713
|
+
// each inline instead of a single toast.
|
|
714
|
+
const missing: Record<string, string> = {}
|
|
679
715
|
for (const field of modalMeta.fields ?? []) {
|
|
680
716
|
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
681
|
-
|
|
682
|
-
return
|
|
717
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
|
|
683
718
|
}
|
|
684
719
|
}
|
|
720
|
+
if (Object.keys(missing).length) {
|
|
721
|
+
setFieldErrors(missing)
|
|
722
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
723
|
+
return
|
|
724
|
+
}
|
|
685
725
|
}
|
|
686
726
|
|
|
727
|
+
// Required check passed → clear any prior validation errors.
|
|
728
|
+
setFieldErrors({})
|
|
729
|
+
|
|
687
730
|
// Empty reference pickers → null (not "" / nil-UUID) so nullable FK
|
|
688
731
|
// columns accept them instead of raising a 23503 FK violation.
|
|
689
732
|
const payload = normalizeRefFieldsForSubmit(formValues, modalMeta.fields)
|
|
@@ -734,10 +777,10 @@ export function DynamicRecordDialog({
|
|
|
734
777
|
// Surface the server's real cause (`details`) as the toast
|
|
735
778
|
// description, not just the generic headline. `res.data` is the
|
|
736
779
|
// `{ success:false, message, details }` envelope.
|
|
737
|
-
|
|
780
|
+
handleSubmitError(res.data)
|
|
738
781
|
}
|
|
739
782
|
} catch (err: any) {
|
|
740
|
-
|
|
783
|
+
handleSubmitError(err)
|
|
741
784
|
} finally {
|
|
742
785
|
setSaving(false)
|
|
743
786
|
}
|
|
@@ -798,9 +841,17 @@ export function DynamicRecordDialog({
|
|
|
798
841
|
record={record}
|
|
799
842
|
value={formValues[field.key] ?? ''}
|
|
800
843
|
mode={mode}
|
|
801
|
-
|
|
844
|
+
error={fieldErrors[field.key]}
|
|
845
|
+
onChange={val => {
|
|
802
846
|
setFormValues((prev: Record<string, any>) => ({ ...prev, [field.key]: val }))
|
|
803
|
-
|
|
847
|
+
// Clear this field's error as soon as the user edits it.
|
|
848
|
+
setFieldErrors(prev => {
|
|
849
|
+
if (!prev[field.key]) return prev
|
|
850
|
+
const next = { ...prev }
|
|
851
|
+
delete next[field.key]
|
|
852
|
+
return next
|
|
853
|
+
})
|
|
854
|
+
}}
|
|
804
855
|
/>
|
|
805
856
|
</FieldCell>
|
|
806
857
|
)
|
|
@@ -910,9 +961,11 @@ interface FieldRowProps {
|
|
|
910
961
|
value: any
|
|
911
962
|
mode: 'view' | 'edit' | 'create'
|
|
912
963
|
onChange: (val: any) => void
|
|
964
|
+
/** Localized validation error for this field, shown in red under the input. */
|
|
965
|
+
error?: string
|
|
913
966
|
}
|
|
914
967
|
|
|
915
|
-
function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
968
|
+
function FieldRow({ field, record, value, mode, onChange, error }: FieldRowProps) {
|
|
916
969
|
// A `readonly` field is server/system-generated (e.g. the GitHub addon's
|
|
917
970
|
// `number`/`github_url`, filled by the API after the outbound create). On
|
|
918
971
|
// CREATE it is excluded from the form entirely (see `visibleFields`); on EDIT
|
|
@@ -937,6 +990,10 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
|
937
990
|
) : (
|
|
938
991
|
<EditField field={field} value={value} onChange={onChange} record={record} />
|
|
939
992
|
)}
|
|
993
|
+
|
|
994
|
+
{error && mode !== 'view' && (
|
|
995
|
+
<p className="text-destructive text-xs mt-1">{error}</p>
|
|
996
|
+
)}
|
|
940
997
|
</div>
|
|
941
998
|
)
|
|
942
999
|
}
|
package/src/server-error.ts
CHANGED
|
@@ -71,8 +71,93 @@ export function extractServerError(err: unknown, fallbackTitle: string): Extract
|
|
|
71
71
|
return { title: fallbackTitle }
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/** i18n translator, tolerant of non-keys (returns `defaultValue`/the input).
|
|
75
|
-
|
|
74
|
+
/** i18n translator, tolerant of non-keys (returns `defaultValue`/the input).
|
|
75
|
+
* Accepts arbitrary interpolation values (e.g. `label`, plus a code's `params`)
|
|
76
|
+
* so `localizeFieldIssue` can pass `{{label}}` and friends through i18next. */
|
|
77
|
+
export type Translate = (key: string, opts?: { defaultValue?: string; [k: string]: unknown }) => string
|
|
78
|
+
|
|
79
|
+
// ── Per-field validation errors ────────────────────────────────────────────
|
|
80
|
+
// The kernel answers a failed create/edit with HTTP 422 and a per-field map:
|
|
81
|
+
// { success:false, message:"validation failed",
|
|
82
|
+
// errors: { "<fieldKey>": [ { code, params } ] } }
|
|
83
|
+
// Codes are locale-agnostic; the SDK localizes them to Spanish using the field
|
|
84
|
+
// label. Some endpoints (7leguas-style) instead send pre-localized STRINGS —
|
|
85
|
+
// so a value entry may be an object `{code,params}` OR a plain string.
|
|
86
|
+
|
|
87
|
+
/** A single normalized validation issue for one field: either a machine `code`
|
|
88
|
+
* (+ optional `params`) to localize, or a ready-to-show `message` string. */
|
|
89
|
+
export interface FieldIssue {
|
|
90
|
+
code?: string
|
|
91
|
+
params?: Record<string, any>
|
|
92
|
+
message?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Spanish defaults per known validation code. `{{label}}` (and code params like
|
|
96
|
+
* `allowed`/`ref`/`expected`) are interpolated by i18next, so a host can override
|
|
97
|
+
* any of these via its addon i18n bundle under `validation.<code>`. */
|
|
98
|
+
const VALIDATION_DEFAULTS: Record<string, string> = {
|
|
99
|
+
required: 'El campo {{label}} es obligatorio',
|
|
100
|
+
invalid_option: 'El valor de {{label}} no es válido',
|
|
101
|
+
not_found: 'El {{label}} seleccionado no existe',
|
|
102
|
+
duplicate: 'Ya existe un registro con ese {{label}}',
|
|
103
|
+
invalid_type: 'El campo {{label}} tiene un formato inválido',
|
|
104
|
+
}
|
|
105
|
+
const VALIDATION_FALLBACK = '{{label}}: valor inválido'
|
|
106
|
+
|
|
107
|
+
/** Normalize one raw `errors` value entry into a `FieldIssue`.
|
|
108
|
+
* A string → `{message}` (pre-localized, shown verbatim); an object → `{code,params}`. */
|
|
109
|
+
function toFieldIssue(entry: unknown): FieldIssue | undefined {
|
|
110
|
+
if (typeof entry === 'string') {
|
|
111
|
+
const s = entry.trim()
|
|
112
|
+
return s ? { message: s } : undefined
|
|
113
|
+
}
|
|
114
|
+
if (entry && typeof entry === 'object') {
|
|
115
|
+
const e = entry as { code?: unknown; params?: unknown; message?: unknown }
|
|
116
|
+
if (typeof e.message === 'string' && e.message.trim()) return { message: e.message.trim() }
|
|
117
|
+
if (typeof e.code === 'string' && e.code) {
|
|
118
|
+
return {
|
|
119
|
+
code: e.code,
|
|
120
|
+
params: e.params && typeof e.params === 'object' ? (e.params as Record<string, any>) : undefined,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pull the per-field `errors` map out of an axios error (`err.response.data.errors`)
|
|
129
|
+
* or a bare response body (`{ errors }`), normalizing each field's value to a
|
|
130
|
+
* `FieldIssue[]`. A value may be a single entry or an array; string entries become
|
|
131
|
+
* `{message}`, object entries `{code,params}`. Returns `undefined` when there is no
|
|
132
|
+
* usable map (no `errors`, or nothing normalized). Pure — no i18n, no toast.
|
|
133
|
+
*/
|
|
134
|
+
export function extractFieldErrors(err: unknown): Record<string, FieldIssue[]> | undefined {
|
|
135
|
+
const maybeAxios = (err as { response?: { data?: unknown } } | undefined)?.response?.data
|
|
136
|
+
const data = maybeAxios ?? err
|
|
137
|
+
const errors = (data as { errors?: unknown } | undefined)?.errors
|
|
138
|
+
if (!errors || typeof errors !== 'object' || Array.isArray(errors)) return undefined
|
|
139
|
+
|
|
140
|
+
const out: Record<string, FieldIssue[]> = {}
|
|
141
|
+
for (const [key, raw] of Object.entries(errors as Record<string, unknown>)) {
|
|
142
|
+
const entries = Array.isArray(raw) ? raw : [raw]
|
|
143
|
+
const issues = entries.map(toFieldIssue).filter((i): i is FieldIssue => !!i)
|
|
144
|
+
if (issues.length) out[key] = issues
|
|
145
|
+
}
|
|
146
|
+
return Object.keys(out).length ? out : undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Localize a single `FieldIssue` to a human, Spanish-by-default string using the
|
|
151
|
+
* field `label`. A pre-localized `message` passes through verbatim. Otherwise the
|
|
152
|
+
* `code` is translated via `t('validation.'+code, { defaultValue, label, ...params })`
|
|
153
|
+
* so hosts can override the copy and `{{label}}`/param interpolation still works.
|
|
154
|
+
*/
|
|
155
|
+
export function localizeFieldIssue(issue: FieldIssue, label: string, t: Translate): string {
|
|
156
|
+
if (issue.message) return issue.message
|
|
157
|
+
const code = issue.code ?? ''
|
|
158
|
+
const defaultValue = VALIDATION_DEFAULTS[code] ?? VALIDATION_FALLBACK
|
|
159
|
+
return t(`validation.${code}`, { defaultValue, label, ...(issue.params ?? {}) })
|
|
160
|
+
}
|
|
76
161
|
|
|
77
162
|
/** A dotted, space-free token (e.g. "pos.rate.created") — the shape of an i18n
|
|
78
163
|
* key, as opposed to human prose ("Record created successfully"). Used to
|