@asteby/metacore-runtime-react 25.1.3 → 25.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 25.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 6f20bf5: normalize-submit: prefer the explicit `nullable` field flag served by the kernel (v0.77.1+) when deciding to null an empty reference, falling back to the type-based heuristic for older hosts that don't serve the flag.
8
+
9
+ ## 25.1.4
10
+
11
+ ### Patch Changes
12
+
13
+ - bdbc542: Per-field validation error rendering + Spanish localization. On a 422 with a
14
+ `{ errors: { <field>: [{ code, params }] } }` map, create/edit dialogs and action
15
+ modals now show each field's first error inline (in red under the input) plus a
16
+ summary toast, instead of one flattened toast. Errors localize to Spanish from
17
+ locale-agnostic codes (`required`, `invalid_option`, `not_found`, `duplicate`,
18
+ `invalid_type`, generic fallback) using the field label via i18next `{{label}}`
19
+ interpolation, so hosts can override the copy under `validation.<code>`.
20
+ Pre-localized string entries pass through verbatim. Adds exported helpers
21
+ `extractFieldErrors` and `localizeFieldIssue` (with the `FieldIssue` type) in
22
+ `server-error`. The client-side required check now marks all missing fields
23
+ inline rather than toasting only the first.
24
+
3
25
  ## 25.1.3
4
26
 
5
27
  ### Patch Changes
@@ -1 +1 @@
1
- {"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AAyDA,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"}
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
- toastServerError({ response: { data: res.data } }, { t });
305
+ toastActionError({ response: { data: res.data } }, action.fields, t);
273
306
  }
274
307
  }
275
308
  catch (err) {
276
- toastServerError(err, { t });
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) => setFormData((prev) => ({ ...prev, [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
- toast.error(`${tl(field.label)} requiere al menos un renglón`);
349
- return;
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
- toast.error(`${tl(field.label)} es requerido`);
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
- toastServerError({ response: { data: res.data } }, { t });
432
+ handleActionError({ response: { data: res.data } });
370
433
  }
371
434
  }
372
435
  catch (err) {
373
- toastServerError(err, { t });
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
- toastServerError({ response: { data: res.data } }, { t });
561
+ toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t);
499
562
  }
500
563
  }
501
564
  catch (err) {
502
- toastServerError(err, { t });
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":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAwC1C,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAA;AAgBjF,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAEnE,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAK1F,YAAY,EAAE,WAAW,EAAE,CAAA;AAE3B,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,QAAQ;IACrB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAA;IACpH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,WAAW,EAAE,CAAA;IACvB,YAAY,CAAC,EAAE,GAAG,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;;;OAQG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACjC;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;IACxB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,SAAS,EAAE,CAAA;CAC5B;AAiCD,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;2DAEuD;IACvD,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpG;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAA;IAC1C;;;;OAIG;IACH,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACxB;AASD,wBAAgB,WAAW,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,GAAG,SAAS,CAK1D;AAaD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,GAAG,GAAG,CAgBtE;AAID,wBAAgB,eAAe,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,EAAE,GAAG,SAAS,CAExE;AAQD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,OAAO,CASrE;AASD,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,GAAG,cAAc,GAAG,IAAI,CAa5F;AA6FD,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,OAAO,CAUjE;AAMD,wBAAgB,mBAAmB,CAC/B,MAAM,EAAE,QAAQ,EAAE,GAAG,SAAS,EAC9B,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,GACjC,QAAQ,EAAE,CAMZ;AAED,wBAAgB,mBAAmB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,MAAM,EACN,cAAc,EACd,aAAa,EACb,WAA8B,EAC9B,QAAQ,EACR,QAAQ,EACR,QAAQ,GACX,EAAE,wBAAwB,+BAiZ1B;AAyDD,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;IAAE,KAAK,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,GAAG,CAAA;CAAE,+BAWlF;AAsDD,wBAAgB,SAAS,CAAC,EACtB,KAAK,EACL,KAAK,EAAE,QAAQ,EACf,MAAM,EACN,WAAW,EAAE,eAAe,EAC5B,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,GACzB,EAAE;IACC,KAAK,EAAE,QAAQ,CAAA;IACf,KAAK,EAAE,GAAG,CAAA;IACV,MAAM,EAAE,GAAG,CAAA;IACX,mFAAmF;IACnF,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB,+BAgQA;AAsID,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE;IAC1D,KAAK,EAAE,QAAQ,CAAA;IACf,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC5B,iFAAiF;IACjF,MAAM,CAAC,EAAE,GAAG,CAAA;CACf,+BA+KA"}
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;AAwC1C,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAA;AAgBjF,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAEnE,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAK1F,YAAY,EAAE,WAAW,EAAE,CAAA;AAE3B,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,QAAQ;IACrB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAA;IACpH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,WAAW,EAAE,CAAA;IACvB,YAAY,CAAC,EAAE,GAAG,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;;;OAQG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACjC;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;IACxB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,SAAS,EAAE,CAAA;CAC5B;AAiCD,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;2DAEuD;IACvD,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpG;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAA;IAC1C;;;;OAIG;IACH,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACxB;AASD,wBAAgB,WAAW,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,GAAG,SAAS,CAK1D;AAaD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,GAAG,GAAG,CAgBtE;AAID,wBAAgB,eAAe,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,EAAE,GAAG,SAAS,CAExE;AAQD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,OAAO,CASrE;AASD,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,GAAG,cAAc,GAAG,IAAI,CAa5F;AA6FD,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,OAAO,CAUjE;AAMD,wBAAgB,mBAAmB,CAC/B,MAAM,EAAE,QAAQ,EAAE,GAAG,SAAS,EAC9B,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,GACjC,QAAQ,EAAE,CAMZ;AAED,wBAAgB,mBAAmB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,MAAM,EACN,cAAc,EACd,aAAa,EACb,WAA8B,EAC9B,QAAQ,EACR,QAAQ,EACR,QAAQ,GACX,EAAE,wBAAwB,+BAoc1B;AA+DD,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;IAAE,KAAK,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,GAAG,CAAA;CAAE,+BAWlF;AAsDD,wBAAgB,SAAS,CAAC,EACtB,KAAK,EACL,KAAK,EAAE,QAAQ,EACf,MAAM,EACN,WAAW,EAAE,eAAe,EAC5B,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,GACzB,EAAE;IACC,KAAK,EAAE,QAAQ,CAAA;IACf,KAAK,EAAE,GAAG,CAAA;IACV,MAAM,EAAE,GAAG,CAAA;IACX,mFAAmF;IACnF,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB,+BAgQA;AAsID,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE;IAC1D,KAAK,EAAE,QAAQ,CAAA;IACf,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC5B,iFAAiF;IACjF,MAAM,CAAC,EAAE,GAAG,CAAA;CACf,+BA+KA"}
@@ -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
- toast.error(`El campo "${field.label}" es obligatorio`);
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
- toastServerError(res.data, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) });
525
+ handleSubmitError(res.data);
486
526
  }
487
527
  }
488
528
  catch (err) {
489
- toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) });
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, onChange: val => setFormValues((prev) => ({ ...prev, [field.key]: val })) }) }, field.key));
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
@@ -7,10 +7,23 @@ type RefFieldMeta = Record<string, any>;
7
7
  * (e.g. products.category_id, a nullable `ref`) rejects a non-null value that
8
8
  * points at no row: sending `""`/nil-UUID triggers
9
9
  * insert ... violates foreign key constraint "fk_products_category" (23503)
10
- * even though the user left the picker empty. This is generic — it keys off the
11
- * field metadata (`getFieldRef` / dynamic_select / search), so every addon's
12
- * optional relations benefit, not just products. Non-reference fields are left
13
- * untouched (an empty text field is a legitimate `""`). Does not mutate `values`.
10
+ * even though the user left the picker empty.
11
+ *
12
+ * Nullability decision (Fase 4): prefer the EXPLICIT `nullable` flag the kernel
13
+ * (v0.77.1+) now serves per field (`modelbase.FieldDef.Nullable`, `!Required`).
14
+ * When `field.nullable === true` an empty value is submitted as `null`; when
15
+ * `field.nullable === false` the value is respected. When the flag is absent
16
+ * (undefined — older hosts that don't serve it yet) we fall back to the legacy
17
+ * type-based heuristic (`getFieldRef` / dynamic_select / search). This keeps
18
+ * backward-compat while letting the contract, not a guess, drive the decision.
19
+ *
20
+ * The explicit flag also lets us null an empty OPTIONAL ref whose column is NOT
21
+ * a uuid (which the uuid-only heuristic never covered). To avoid nulling a plain
22
+ * optional text field — where `""` is a legitimate value, not "no relation" — we
23
+ * gate the explicit path on the field ALSO looking like a reference/relation
24
+ * (`getFieldRef` / dynamic_select / search). A nullable non-ref scalar keeps its
25
+ * `""`. This is generic — every addon's optional relations benefit. Does not
26
+ * mutate `values`.
14
27
  */
15
28
  export declare function normalizeRefFieldsForSubmit(values: Record<string, any>, fields: RefFieldMeta[] | undefined): Record<string, any>;
16
29
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"normalize-submit.d.ts","sourceRoot":"","sources":["../../src/dialogs/normalize-submit.ts"],"names":[],"mappings":"AAOA;wEACwE;AACxE,KAAK,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAEvC;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,YAAY,EAAE,GAAG,SAAS,GACnC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAuBrB"}
1
+ {"version":3,"file":"normalize-submit.d.ts","sourceRoot":"","sources":["../../src/dialogs/normalize-submit.ts"],"names":[],"mappings":"AAOA;wEACwE;AACxE,KAAK,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAEvC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,2BAA2B,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,YAAY,EAAE,GAAG,SAAS,GACnC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAuCrB"}
@@ -9,18 +9,35 @@ import { getFieldRef } from '../dynamic-form-schema';
9
9
  * (e.g. products.category_id, a nullable `ref`) rejects a non-null value that
10
10
  * points at no row: sending `""`/nil-UUID triggers
11
11
  * insert ... violates foreign key constraint "fk_products_category" (23503)
12
- * even though the user left the picker empty. This is generic — it keys off the
13
- * field metadata (`getFieldRef` / dynamic_select / search), so every addon's
14
- * optional relations benefit, not just products. Non-reference fields are left
15
- * untouched (an empty text field is a legitimate `""`). Does not mutate `values`.
12
+ * even though the user left the picker empty.
13
+ *
14
+ * Nullability decision (Fase 4): prefer the EXPLICIT `nullable` flag the kernel
15
+ * (v0.77.1+) now serves per field (`modelbase.FieldDef.Nullable`, `!Required`).
16
+ * When `field.nullable === true` an empty value is submitted as `null`; when
17
+ * `field.nullable === false` the value is respected. When the flag is absent
18
+ * (undefined — older hosts that don't serve it yet) we fall back to the legacy
19
+ * type-based heuristic (`getFieldRef` / dynamic_select / search). This keeps
20
+ * backward-compat while letting the contract, not a guess, drive the decision.
21
+ *
22
+ * The explicit flag also lets us null an empty OPTIONAL ref whose column is NOT
23
+ * a uuid (which the uuid-only heuristic never covered). To avoid nulling a plain
24
+ * optional text field — where `""` is a legitimate value, not "no relation" — we
25
+ * gate the explicit path on the field ALSO looking like a reference/relation
26
+ * (`getFieldRef` / dynamic_select / search). A nullable non-ref scalar keeps its
27
+ * `""`. This is generic — every addon's optional relations benefit. Does not
28
+ * mutate `values`.
16
29
  */
17
30
  export function normalizeRefFieldsForSubmit(values, fields) {
18
- const refKeys = new Set((fields ?? [])
19
- .filter((f) => !!getFieldRef(f) ||
31
+ const isRefField = (f) => !!getFieldRef(f) ||
20
32
  f.type === 'dynamic_select' ||
21
33
  f.widget === 'dynamic_select' ||
22
- f.type === 'search')
23
- .map((f) => f.key));
34
+ f.type === 'search';
35
+ // Index fields by key so we can consult the explicit `nullable` flag.
36
+ const fieldByKey = new Map();
37
+ for (const f of fields ?? []) {
38
+ if (f && typeof f.key === 'string')
39
+ fieldByKey.set(f.key, f);
40
+ }
24
41
  const out = { ...values };
25
42
  for (const [k, v] of Object.entries(out)) {
26
43
  // A nil UUID is never a real target row — null it out on any field.
@@ -28,8 +45,24 @@ export function normalizeRefFieldsForSubmit(values, fields) {
28
45
  out[k] = null;
29
46
  continue;
30
47
  }
31
- // An empty reference picker means "no relation" null (not "").
32
- if (refKeys.has(k) && (v === '' || v == null))
48
+ const isEmpty = v === '' || v == null;
49
+ if (!isEmpty)
50
+ continue;
51
+ const f = fieldByKey.get(k);
52
+ const nullable = f?.nullable;
53
+ if (nullable === true) {
54
+ // Explicit contract flag wins. Only null a reference/relation so a
55
+ // legitimately-empty optional plain-text field keeps its `""`.
56
+ if (f && isRefField(f))
57
+ out[k] = null;
58
+ continue;
59
+ }
60
+ if (nullable === false) {
61
+ // Explicitly non-nullable → respect the empty value as-is.
62
+ continue;
63
+ }
64
+ // No explicit flag (older host): fall back to the type-based heuristic.
65
+ if (f && isRefField(f))
33
66
  out[k] = null;
34
67
  }
35
68
  return out;
@@ -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,gFAAgF;AAChF,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,KAAK,MAAM,CAAA;AAUjF;;;;;;;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"}
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"}
@@ -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/dist/types.d.ts CHANGED
@@ -324,6 +324,14 @@ export interface ActionFieldDef {
324
324
  label: string;
325
325
  type: string;
326
326
  required?: boolean;
327
+ /**
328
+ * Explicit nullability flag served by the kernel (v0.77.1+) from
329
+ * `modelbase.FieldDef.Nullable` (populated as `!Required`). An optional `ref`
330
+ * arrives as `nullable: true`. When present it authoritatively decides whether
331
+ * an empty value should be submitted as `null`; when absent (older hosts) the
332
+ * SDK falls back to type-based heuristics. Additive / optional.
333
+ */
334
+ nullable?: boolean;
327
335
  options?: OptionDef[];
328
336
  defaultValue?: any;
329
337
  placeholder?: string;