@asteby/metacore-runtime-react 32.0.0 → 33.0.0

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.
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseRuleString, checkValue, validateValues, bagHasErrors } from '../validator'
3
+ import type { ActionFieldDef } from '../types'
4
+
5
+ describe('parseRuleString', () => {
6
+ it('parses Laravel pipes', () => {
7
+ const s = parseRuleString('required|min:2|max:10|email')
8
+ expect(s.required).toBe(true)
9
+ expect(s.min).toBe(2)
10
+ expect(s.max).toBe(10)
11
+ expect(s.custom).toBe('email')
12
+ })
13
+ it('parses go-playground commas', () => {
14
+ const s = parseRuleString('required,min=2,max=100')
15
+ expect(s.required).toBe(true)
16
+ expect(s.min).toBe(2)
17
+ expect(s.max).toBe(100)
18
+ })
19
+ it('treats a slug as custom', () => {
20
+ expect(parseRuleString('$org.tax_id_validator').custom).toBe('$org.tax_id_validator')
21
+ })
22
+ })
23
+
24
+ describe('checkValue', () => {
25
+ it('required on empty', () => {
26
+ expect(checkValue('', { required: true })).toEqual([{ code: 'required' }])
27
+ expect(checkValue('x', { required: true })).toEqual([])
28
+ })
29
+ it('min/max length on strings', () => {
30
+ expect(checkValue('ab', { type: 'string', min: 3 })).toEqual([
31
+ { code: 'min', params: { min: 3, kind: 'length' } },
32
+ ])
33
+ })
34
+ it('regex + email', () => {
35
+ expect(checkValue('nope', { type: 'string', regex: '^[A-Z]+$' })[0]?.code).toBe('regex')
36
+ expect(checkValue('a@b.com', { custom: 'email' })).toEqual([])
37
+ expect(checkValue('not-an-email', { custom: 'email' })[0]?.code).toBe('email')
38
+ })
39
+ it('skips other rules when empty and not required', () => {
40
+ expect(checkValue('', { type: 'string', min: 3, custom: 'email' })).toEqual([])
41
+ })
42
+ })
43
+
44
+ describe('validateValues', () => {
45
+ const fields: ActionFieldDef[] = [
46
+ { key: 'name', label: 'Nombre', type: 'text', required: true },
47
+ { key: 'sku', label: 'SKU', type: 'text', validation: { min: 3, regex: '^[A-Z]+$' } },
48
+ {
49
+ key: 'items',
50
+ label: 'Renglones',
51
+ type: 'array',
52
+ required: true,
53
+ itemFields: [{ key: 'qty', label: 'Cantidad', type: 'number', required: true }],
54
+ },
55
+ ]
56
+ it('collects every field including dotted line-items', () => {
57
+ const bag = validateValues(fields, { sku: 'ab', items: [{ qty: '' }] })
58
+ expect(bag.name?.[0]?.code).toBe('required')
59
+ expect(bag.sku?.map(i => i.code).sort()).toEqual(['min', 'regex'])
60
+ expect(bag['items.0.qty']?.[0]?.code).toBe('required')
61
+ expect(bagHasErrors(bag)).toBe(true)
62
+ })
63
+ it('parses a laravel string on validation', () => {
64
+ const bag = validateValues(
65
+ [{ key: 'email', label: 'Email', type: 'text', validation: 'required|email' }],
66
+ { email: 'nope' },
67
+ )
68
+ expect(bag.email?.[0]?.code).toBe('email')
69
+ })
70
+ })
@@ -37,8 +37,10 @@ import {
37
37
  } from '@asteby/metacore-ui/primitives'
38
38
  import { Loader2 } from 'lucide-react'
39
39
  import { toast } from 'sonner'
40
- import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldIssue } from './server-error'
40
+ import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
41
41
  import type { Translate } from './server-error'
42
+ import { validateValues, bagHasErrors } from './validator'
43
+ import { validationCatalog } from './validation-catalog'
42
44
  import { useApi } from './api-context'
43
45
  import { DynamicIcon } from './dynamic-icon'
44
46
  import { DynamicLineItems } from './dynamic-line-items'
@@ -423,34 +425,38 @@ function localizeActionFieldErrors(
423
425
  err: unknown,
424
426
  fields: readonly ActionFieldDef[] | undefined,
425
427
  t: Translate,
428
+ language?: string,
426
429
  ): Record<string, string> | undefined {
427
430
  const map = extractFieldErrors(err)
428
431
  if (!map) return undefined
429
- const labelFor = (k: string) => {
430
- const f = (fields ?? []).find(x => x.key === k)
431
- return f?.label ? t(f.label, { defaultValue: f.label }) : humanizeKey(k)
432
+ const labels: Record<string, string> = {}
433
+ for (const f of fields ?? []) {
434
+ labels[f.key] = f.label ? t(f.label, { defaultValue: f.label }) : humanizeKey(f.key)
432
435
  }
433
- const out: Record<string, string> = {}
434
- for (const [k, issues] of Object.entries(map)) out[k] = localizeFieldIssue(issues[0], labelFor(k), t)
435
- return out
436
+ return localizeFieldErrorMap(map, t, { labels, language })
436
437
  }
437
438
 
438
439
  /** Toast a failed action: a summary + localized per-field lines when the server
439
440
  * returned a per-field `errors` map, else the standard cause-carrying toast.
440
441
  * Used where inline rendering isn't wired (confirm / multi-step wizard). */
441
- function toastActionError(err: unknown, fields: readonly ActionFieldDef[] | undefined, t: Translate): void {
442
- const localized = localizeActionFieldErrors(err, fields, t)
442
+ function toastActionError(
443
+ err: unknown,
444
+ fields: readonly ActionFieldDef[] | undefined,
445
+ t: Translate,
446
+ language?: string,
447
+ ): void {
448
+ const localized = localizeActionFieldErrors(err, fields, t, language)
443
449
  if (localized) {
444
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), {
450
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(language).failed }), {
445
451
  description: Object.values(localized).join('\n'),
446
452
  })
447
453
  return
448
454
  }
449
- toastServerError(err, { t })
455
+ toastServerError(err, { t, language })
450
456
  }
451
457
 
452
458
  function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
453
- const { t } = useTranslation()
459
+ const { t, i18n } = useTranslation()
454
460
  const api = useApi()
455
461
  const [executing, setExecuting] = useState(false)
456
462
  // `action.label` is an addon-contributed i18n key; its locale bundle loads
@@ -468,10 +474,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
468
474
  onOpenChange(false)
469
475
  onSuccess()
470
476
  } else {
471
- toastActionError({ response: { data: res.data } }, action.fields, t)
477
+ toastActionError({ response: { data: res.data } }, action.fields, t, i18n.language)
472
478
  }
473
479
  } catch (err: any) {
474
- toastActionError(err, action.fields, t)
480
+ toastActionError(err, action.fields, t, i18n.language)
475
481
  } finally {
476
482
  setExecuting(false)
477
483
  }
@@ -507,7 +513,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
507
513
  }
508
514
 
509
515
  function GenericActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
510
- const { t } = useTranslation()
516
+ const { t, i18n } = useTranslation()
511
517
  // Addon-contributed labels (action + fields) are i18n keys whose locale
512
518
  // bundle loads asynchronously; translate at render so they don't render raw.
513
519
  // defaultValue keeps an already-localized string unchanged.
@@ -574,39 +580,25 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
574
580
  })
575
581
  }
576
582
 
583
+ const lang = i18n.language
577
584
  const handleActionError = (err: unknown) => {
578
- const localized = localizeActionFieldErrors(err, action.fields, t)
585
+ const localized = localizeActionFieldErrors(err, action.fields, t, lang)
579
586
  if (localized) {
580
587
  setFieldErrors(localized)
581
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
588
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
582
589
  return
583
590
  }
584
- toastServerError(err, { t })
591
+ toastServerError(err, { t, language: lang })
585
592
  }
586
593
 
587
594
  const execute = async () => {
588
595
  if (action.fields) {
589
- // Client-side required check → mark ALL missing fields inline.
590
- const missing: Record<string, string> = {}
591
- for (const field of action.fields) {
592
- if (!field.required) continue
593
- if (isLineItemsField(field)) {
594
- const rows = formData[field.key]
595
- if (!Array.isArray(rows) || rows.length === 0) {
596
- missing[field.key] = t('validation.line_items_required', {
597
- defaultValue: '{{label}} requiere al menos un renglón',
598
- label: tl(field.label),
599
- })
600
- }
601
- continue
602
- }
603
- if (!formData[field.key] && formData[field.key] !== false) {
604
- missing[field.key] = localizeFieldIssue({ code: 'required' }, tl(field.label), t)
605
- }
606
- }
607
- if (Object.keys(missing).length) {
608
- setFieldErrors(missing)
609
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
596
+ const bag = validateValues(action.fields, formData)
597
+ if (bagHasErrors(bag)) {
598
+ const labels: Record<string, string> = {}
599
+ for (const f of action.fields) labels[f.key] = tl(f.label)
600
+ setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang }))
601
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
610
602
  return
611
603
  }
612
604
  }
@@ -738,28 +730,17 @@ function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<strin
738
730
  return defaults
739
731
  }
740
732
 
741
- // validateFields returns the first validation error for a set of required
742
- // fields, or null when they all pass. Shared by the wizard (per-step gate) and
743
- // mirrors GenericActionModal's inline checks. `tl` localizes the field label.
744
- function validateFields(
733
+ function wizardStepErrors(
745
734
  fields: ActionFieldDef[],
746
735
  formData: Record<string, any>,
747
- tl: (s: string) => string,
748
- ): string | null {
749
- for (const field of fields) {
750
- if (!field.required) continue
751
- if (isLineItemsField(field)) {
752
- const rows = formData[field.key]
753
- if (!Array.isArray(rows) || rows.length === 0) {
754
- return `${tl(field.label)} requiere al menos un renglón`
755
- }
756
- continue
757
- }
758
- if (!formData[field.key] && formData[field.key] !== false) {
759
- return `${tl(field.label)} es requerido`
760
- }
761
- }
762
- return null
736
+ t: Translate,
737
+ language?: string,
738
+ ): Record<string, string> | undefined {
739
+ const bag = validateValues(fields, formData)
740
+ if (!bagHasErrors(bag)) return undefined
741
+ const labels: Record<string, string> = {}
742
+ for (const f of fields) labels[f.key] = t(f.label, { defaultValue: f.label })
743
+ return localizeFieldErrorMap(bag, t, { labels, language })
763
744
  }
764
745
 
765
746
  // WizardActionModal — the third render-path: a multi-step form. It accumulates
@@ -770,7 +751,7 @@ function validateFields(
770
751
  // so line-items, dynamic_select, uploads and dates behave identically to a
771
752
  // single-page action form — no widget is duplicated.
772
753
  function WizardActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
773
- const { t } = useTranslation()
754
+ const { t, i18n } = useTranslation()
774
755
  const tl = (s: string) => t(s, { defaultValue: s })
775
756
  const api = useApi()
776
757
  const steps = action.steps ?? []
@@ -801,9 +782,11 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
801
782
  const widthPx = hasLineItems ? '820px' : undefined
802
783
 
803
784
  const goNext = () => {
804
- const err = validateFields(stepFields, formData, tl)
805
- if (err) {
806
- toast.error(err)
785
+ const localized = wizardStepErrors(stepFields, formData, t, i18n.language)
786
+ if (localized) {
787
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(i18n.language).failed }), {
788
+ description: Object.values(localized).join('\n'),
789
+ })
807
790
  return
808
791
  }
809
792
  setStepIndex((i) => Math.min(i + 1, steps.length - 1))
@@ -815,9 +798,11 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
815
798
  // Guard every step's required fields on final submit (a user could reach
816
799
  // the last step with an untouched earlier line-items grid otherwise).
817
800
  for (const s of steps) {
818
- const err = validateFields(s.fields ?? [], formData, tl)
819
- if (err) {
820
- toast.error(err)
801
+ const localized = wizardStepErrors(s.fields ?? [], formData, t, i18n.language)
802
+ if (localized) {
803
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(i18n.language).failed }), {
804
+ description: Object.values(localized).join('\n'),
805
+ })
821
806
  return
822
807
  }
823
808
  }
@@ -830,10 +815,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
830
815
  onOpenChange(false)
831
816
  onSuccess()
832
817
  } else {
833
- toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t)
818
+ toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t, i18n.language)
834
819
  }
835
820
  } catch (err: any) {
836
- toastActionError(err, steps.flatMap(s => s.fields ?? []), t)
821
+ toastActionError(err, steps.flatMap(s => s.fields ?? []), t, i18n.language)
837
822
  } finally {
838
823
  setExecuting(false)
839
824
  }
@@ -53,7 +53,9 @@ import { es } from 'date-fns/locale'
53
53
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
54
54
  import { BarcodeScanner } from '../barcode-scanner'
55
55
  import { useApi } from '../api-context'
56
- import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
56
+ import { toastServerError, extractFieldErrors, localizeFieldErrorMap } from '../server-error'
57
+ import { validateValues, bagHasErrors } from '../validator'
58
+ import { validationCatalog } from '../validation-catalog'
57
59
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
58
60
  import { DynamicRelations } from '../dynamic-relations'
59
61
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
@@ -557,7 +559,7 @@ export function DynamicRecordDialog({
557
559
  onChange,
558
560
  }: DynamicRecordDialogProps) {
559
561
  const api = useApi()
560
- const { t } = useTranslation()
562
+ const { t, i18n } = useTranslation()
561
563
  const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(
562
564
  schema ? (schema as ModalMetadata) : null,
563
565
  )
@@ -758,25 +760,25 @@ export function DynamicRecordDialog({
758
760
  // with no matching form field).
759
761
  const labelForKey = (key: string): string => {
760
762
  const f = (modalMeta?.fields ?? []).find(x => x.key === key)
761
- if (f?.label) return f.label
763
+ if (f?.label) return t(f.label, { defaultValue: f.label })
762
764
  return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
763
765
  }
764
766
 
767
+ const lang = i18n.language
768
+
765
769
  // Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into
766
770
  // inline field errors + a summary toast. When there is no field map, fall
767
771
  // back to the existing single cause-carrying toast.
768
772
  const handleSubmitError = (err: unknown) => {
769
773
  const map = extractFieldErrors(err)
770
774
  if (map) {
771
- const next: Record<string, string> = {}
772
- for (const [key, issues] of Object.entries(map)) {
773
- next[key] = localizeFieldIssue(issues[0], labelForKey(key), t)
774
- }
775
- setFieldErrors(next)
776
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
775
+ const labels: Record<string, string> = {}
776
+ for (const f of modalMeta?.fields ?? []) labels[f.key] = labelForKey(f.key)
777
+ setFieldErrors(localizeFieldErrorMap(map, t, { labels, language: lang }))
778
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
777
779
  return
778
780
  }
779
- toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
781
+ toastServerError(err, { t, language: lang, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
780
782
  }
781
783
 
782
784
  const handleSubmit = async (e?: React.FormEvent) => {
@@ -789,15 +791,13 @@ export function DynamicRecordDialog({
789
791
  // fields are gated: a field hidden by its `visible_when` predicate
790
792
  // must not block submit even when it is declared required (matching
791
793
  // the render, which drops it via the same filter).
792
- const missing: Record<string, string> = {}
793
- for (const field of filterVisibleFields(modalMeta.fields, mode, formValues)) {
794
- if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
795
- missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
796
- }
797
- }
798
- if (Object.keys(missing).length) {
799
- setFieldErrors(missing)
800
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
794
+ const visible = filterVisibleFields(modalMeta.fields, mode, formValues)
795
+ const bag = validateValues(visible as ActionFieldDef[], formValues)
796
+ if (bagHasErrors(bag)) {
797
+ const labels: Record<string, string> = {}
798
+ for (const f of visible) labels[f.key] = t(f.label, { defaultValue: f.label })
799
+ setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang }))
800
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
801
801
  return
802
802
  }
803
803
  }
@@ -938,15 +938,12 @@ export function DynamicRecordDialog({
938
938
  // then advance. Mirrors handleSubmit's required check but scoped to the step.
939
939
  const goNextStep = () => {
940
940
  const step = groups[clampedStep]
941
- const missing: Record<string, string> = {}
942
- for (const field of step?.fields ?? []) {
943
- if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
944
- missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
945
- }
946
- }
947
- if (Object.keys(missing).length) {
948
- setFieldErrors(missing)
949
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
941
+ const bag = validateValues((step?.fields ?? []) as ActionFieldDef[], formValues)
942
+ if (bagHasErrors(bag)) {
943
+ const labels: Record<string, string> = {}
944
+ for (const f of step?.fields ?? []) labels[f.key] = t(f.label, { defaultValue: f.label })
945
+ setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang }))
946
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
950
947
  return
951
948
  }
952
949
  setFieldErrors({})
@@ -2,7 +2,8 @@
2
2
  // callers (and unit tests) can use the zod schema without pulling in React or
3
3
  // metacore-ui primitives.
4
4
  import { z, type ZodTypeAny } from 'zod'
5
- import type { ActionFieldDef, FieldValidation, FieldOptionsConfig, OptionDef, VisibleWhen } from './types'
5
+ import type { ActionFieldDef, FieldOptionsConfig, OptionDef, VisibleWhen } from './types'
6
+ import { fieldValidationOf } from './validator'
6
7
  import { resolveValidatorToken } from './use-org-config-bridge'
7
8
 
8
9
  /**
@@ -164,7 +165,7 @@ function fieldToZod(field: ActionFieldDef): ZodTypeAny {
164
165
  return field.required ? arr.min(1, `${field.label} requiere al menos un renglón`) : arr
165
166
  }
166
167
 
167
- const v = field.validation ?? ({} as FieldValidation)
168
+ const v = fieldValidationOf(field)
168
169
  const isNumeric = field.type === 'number'
169
170
  const isBool = field.type === 'boolean'
170
171
 
package/src/index.ts CHANGED
@@ -22,11 +22,24 @@ export {
22
22
  export * from './options-context'
23
23
  export {
24
24
  extractServerError,
25
+ extractFieldErrors,
26
+ localizeFieldIssue,
27
+ localizeFieldErrorMap,
25
28
  toastServerError,
26
29
  toastServerSuccess,
27
30
  type ExtractedError,
28
31
  type Translate,
32
+ type FieldIssue,
29
33
  } from './server-error'
34
+ export {
35
+ parseRuleString,
36
+ fieldValidationOf,
37
+ checkValue,
38
+ validateValues,
39
+ bagHasErrors,
40
+ type ValidationSpec,
41
+ } from './validator'
42
+ export { VALIDATION_CATALOGS, validationCatalog, validationMessageKey } from './validation-catalog'
30
43
  export * from './dynamic-table'
31
44
  export {
32
45
  DynamicKanban,
@@ -10,6 +10,7 @@
10
10
  // or report it. This module keeps the headline but ALSO surfaces the cause as
11
11
  // the toast description, in ONE place so every call site behaves identically.
12
12
  import { toast } from 'sonner'
13
+ import { validationCatalog, validationMessageKey } from './validation-catalog'
13
14
 
14
15
  /** Structured, display-ready view of an error: a headline + an optional cause. */
15
16
  export interface ExtractedError {
@@ -21,16 +22,29 @@ export interface ExtractedError {
21
22
  description?: string
22
23
  }
23
24
 
25
+ function formatIssueEntry(v: unknown): string {
26
+ if (v == null) return ''
27
+ if (typeof v === 'string') return v
28
+ if (typeof v === 'object' && 'code' in (v as object)) {
29
+ const e = v as { code?: unknown; message?: unknown }
30
+ if (typeof e.message === 'string' && e.message.trim()) return e.message.trim()
31
+ if (typeof e.code === 'string' && e.code) return e.code
32
+ }
33
+ return String(v)
34
+ }
35
+
24
36
  /** Flattens a validation `errors` payload (string | string[] | field→msgs map)
25
- * into a single newline-joined string, or undefined when empty. */
37
+ * into a single newline-joined string, or undefined when empty. Object entries
38
+ * `{code, params}` render as the code (never `[object Object]`). */
26
39
  function joinErrors(errors: unknown): string | undefined {
27
40
  if (!errors) return undefined
28
41
  if (typeof errors === 'string') return errors || undefined
29
- if (Array.isArray(errors)) return errors.filter(Boolean).map(String).join('\n') || undefined
42
+ if (Array.isArray(errors)) return errors.map(formatIssueEntry).filter(Boolean).join('\n') || undefined
30
43
  if (typeof errors === 'object') {
31
- const parts = Object.entries(errors as Record<string, unknown>).map(
32
- ([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : String(v)}`,
33
- )
44
+ const parts = Object.entries(errors as Record<string, unknown>).map(([k, v]) => {
45
+ const body = Array.isArray(v) ? v.map(formatIssueEntry).filter(Boolean).join(', ') : formatIssueEntry(v)
46
+ return body ? `${k}: ${body}` : ''
47
+ }).filter(Boolean)
34
48
  return parts.join('\n') || undefined
35
49
  }
36
50
  return undefined
@@ -92,17 +106,12 @@ export interface FieldIssue {
92
106
  message?: string
93
107
  }
94
108
 
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',
109
+ /** Spanish/English catalogs live in `validation-catalog.ts`. Hosts override any
110
+ * key via i18next `validation.<code>`. `{{label}}` and code params (min/max/
111
+ * allowed/ref/expected) interpolate through i18next. */
112
+ function humanizeKey(k: string): string {
113
+ return k.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
104
114
  }
105
- const VALIDATION_FALLBACK = '{{label}}: valor inválido'
106
115
 
107
116
  /** Normalize one raw `errors` value entry into a `FieldIssue`.
108
117
  * A string → `{message}` (pre-localized, shown verbatim); an object → `{code,params}`. */
@@ -147,16 +156,37 @@ export function extractFieldErrors(err: unknown): Record<string, FieldIssue[]> |
147
156
  }
148
157
 
149
158
  /**
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.
159
+ * Localize a single `FieldIssue` to a human string using the field `label` and
160
+ * the operator's language. A pre-localized `message` passes through verbatim.
161
+ * Otherwise `code` is translated via `t('validation.'+key)` with a catalog
162
+ * default (es unless `language` is `en` / `en-*`).
154
163
  */
155
- export function localizeFieldIssue(issue: FieldIssue, label: string, t: Translate): string {
164
+ export function localizeFieldIssue(
165
+ issue: FieldIssue,
166
+ label: string,
167
+ t: Translate,
168
+ language?: string,
169
+ ): string {
156
170
  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 ?? {}) })
171
+ const key = validationMessageKey(issue.code ?? '', issue.params)
172
+ const cat = validationCatalog(language)
173
+ const defaultValue = cat[key] ?? cat.fallback ?? '{{label}}: valor inválido'
174
+ return t(`validation.${key}`, { defaultValue, label, ...(issue.params ?? {}) })
175
+ }
176
+
177
+ /** Localize a whole 422 `errors` map into `{ [field]: firstMessage }` using
178
+ * optional per-key labels (already translated) and the current language. */
179
+ export function localizeFieldErrorMap(
180
+ map: Record<string, FieldIssue[]>,
181
+ t: Translate,
182
+ opts?: { labels?: Record<string, string>; language?: string },
183
+ ): Record<string, string> {
184
+ const out: Record<string, string> = {}
185
+ for (const [k, issues] of Object.entries(map)) {
186
+ const label = opts?.labels?.[k] ?? humanizeKey(k)
187
+ out[k] = localizeFieldIssue(issues[0]!, label, t, opts?.language)
188
+ }
189
+ return out
160
190
  }
161
191
 
162
192
  /** A dotted, space-free token (e.g. "pos.rate.created") — the shape of an i18n
@@ -192,15 +222,40 @@ export function toastServerSuccess(
192
222
 
193
223
  /**
194
224
  * Toast a server/network error, surfacing the REAL cause as the description
195
- * instead of a bare generic line. Pass the app's `t` so an i18n-key `message`
196
- * (e.g. "pos.error.no_rate") is localized; a plain-text message passes through
197
- * unchanged. Use everywhere a mutation/action can fail.
225
+ * instead of a bare generic line. A 422 `{errors:{field:[{code}]}}` bag is
226
+ * localized per-field (never `[object Object]` / English "validation failed").
227
+ * Pass `language` (i18n.language) so catalogs match the operator's lang;
228
+ * pass `labels` so field keys map to translated headers.
198
229
  */
199
- export function toastServerError(err: unknown, opts?: { t?: Translate; fallback?: string }): void {
230
+ export function toastServerError(
231
+ err: unknown,
232
+ opts?: { t?: Translate; fallback?: string; language?: string; labels?: Record<string, string> },
233
+ ): void {
200
234
  const t = opts?.t
235
+ const lang = opts?.language
236
+ const cat = validationCatalog(lang)
237
+ const map = extractFieldErrors(err)
238
+ if (map) {
239
+ const localized = t
240
+ ? localizeFieldErrorMap(map, t, { labels: opts?.labels, language: lang })
241
+ : undefined
242
+ const title = t
243
+ ? t('validation.failed', { defaultValue: cat.failed })
244
+ : cat.failed
245
+ const description = localized
246
+ ? Object.values(localized).join('\n')
247
+ : Object.entries(map)
248
+ .map(([k, issues]) => `${k}: ${issues[0]?.code ?? issues[0]?.message ?? ''}`)
249
+ .join('\n')
250
+ toast.error(title, description ? { description } : undefined)
251
+ return
252
+ }
201
253
  const fallback =
202
254
  opts?.fallback ?? (t ? t('common.error', { defaultValue: 'Something went wrong' }) : 'Something went wrong')
203
- const { title, description } = extractServerError(err, fallback)
204
- const shownTitle = t ? t(title, { defaultValue: title }) : title
205
- toast.error(shownTitle, description ? { description } : undefined)
255
+ const extracted = extractServerError(err, fallback)
256
+ let shownTitle = t ? t(extracted.title, { defaultValue: extracted.title }) : extracted.title
257
+ if (extracted.title === 'validation failed' || extracted.title === 'validation.failed') {
258
+ shownTitle = t ? t('validation.failed', { defaultValue: cat.failed }) : cat.failed
259
+ }
260
+ toast.error(shownTitle, extracted.description ? { description: extracted.description } : undefined)
206
261
  }
package/src/types.ts CHANGED
@@ -317,11 +317,10 @@ export interface ColumnDefinition {
317
317
  */
318
318
  ref?: string
319
319
  /**
320
- * Server-side validation rules the SDK can also pre-flight in the
321
- * form layer. `custom` may be a literal slug or a $org.<key>
322
- * reference resolved through the OrgConfigProvider.
320
+ * Write-time rules the SDK also pre-flights. Object form `{regex,min,max,custom}`
321
+ * or a Laravel / go-playground string (`required|min:2|email`).
323
322
  */
324
- validation?: FieldValidation
323
+ validation?: FieldValidation | string
325
324
  /**
326
325
  * Declared schema for a jsonb line-items column (kernel v3 `item_fields`).
327
326
  * Each entry describes one sub-field of the array's row objects: a `key`
@@ -389,13 +388,11 @@ export interface VisibleWhen {
389
388
  in?: string[]
390
389
  }
391
390
 
392
- // Mirrors `ValidationRule` from packages/sdk/src/generated/manifest.ts. Kept
393
- // inline here so runtime-react does not import generated kernel types directly
394
- // apps and addons author ActionFieldDef literals.
395
- //
396
- // `custom` accepts either a literal validator slug (e.g. `mx.rfc`) registered
397
- // via `registerValidator`, or a `$org.<key>` reference resolved through the
398
- // OrgConfigProvider — same contract as kernel ColumnDef.Validation.Custom.
391
+ // Write-time + client-side constraints. The kernel enforces these on
392
+ // create/update and action payloads (locale-agnostic codes); the SDK
393
+ // pre-flights the same rules and localizes `validation.<code>` to the
394
+ // operator's language. `custom` is a slug (`email`, `rfc.tax_id`) or a
395
+ // `$org.<key>` reference resolved through OrgConfigProvider.
399
396
  export interface FieldValidation {
400
397
  regex?: string
401
398
  min?: number
@@ -469,7 +466,7 @@ export interface ActionFieldDef {
469
466
  defaultValue?: any
470
467
  placeholder?: string
471
468
  searchEndpoint?: string
472
- validation?: FieldValidation
469
+ validation?: FieldValidation | string
473
470
  widget?: FieldWidget | string
474
471
  /**
475
472
  * FK target model — same semantics as ColumnDefinition.ref. When