@asteby/metacore-runtime-react 31.1.1 → 32.1.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/action-modal-dispatcher.d.ts +20 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +52 -74
  5. package/dist/addon-fiber.d.ts +57 -0
  6. package/dist/addon-fiber.d.ts.map +1 -0
  7. package/dist/addon-fiber.js +122 -0
  8. package/dist/addon-loader.d.ts +19 -3
  9. package/dist/addon-loader.d.ts.map +1 -1
  10. package/dist/addon-loader.js +36 -37
  11. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  12. package/dist/dialogs/dynamic-record.js +27 -28
  13. package/dist/dynamic-form-schema.d.ts.map +1 -1
  14. package/dist/dynamic-form-schema.js +2 -1
  15. package/dist/index.d.ts +4 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +4 -1
  18. package/dist/server-error.d.ts +17 -8
  19. package/dist/server-error.d.ts.map +1 -1
  20. package/dist/server-error.js +75 -28
  21. package/dist/types.d.ts +4 -5
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/validation-catalog.d.ts +8 -0
  24. package/dist/validation-catalog.d.ts.map +1 -0
  25. package/dist/validation-catalog.js +59 -0
  26. package/dist/validator.d.ts +22 -0
  27. package/dist/validator.d.ts.map +1 -0
  28. package/dist/validator.js +261 -0
  29. package/package.json +3 -3
  30. package/src/__tests__/addon-fiber.test.ts +111 -0
  31. package/src/__tests__/extract-field-errors.test.ts +25 -1
  32. package/src/__tests__/prefill-from-record.test.ts +146 -0
  33. package/src/__tests__/validator.test.ts +70 -0
  34. package/src/action-modal-dispatcher.tsx +57 -72
  35. package/src/addon-fiber.ts +155 -0
  36. package/src/addon-loader.tsx +66 -47
  37. package/src/dialogs/dynamic-record.tsx +25 -28
  38. package/src/dynamic-form-schema.ts +3 -2
  39. package/src/index.ts +24 -0
  40. package/src/server-error.ts +85 -30
  41. package/src/types.ts +9 -12
  42. package/src/validation-catalog.ts +60 -0
  43. package/src/validator.ts +275 -0
@@ -3,17 +3,26 @@
3
3
  // `remoteEntry.js` as an ESM container, loads the exposed `./register` module,
4
4
  // and calls `register(api)` with the AddonAPI injected by the host.
5
5
  //
6
- // Why @module-federation/runtime (not the old manual init/get machinery):
7
- // the host's Vite build uses `@module-federation/vite`'s `federation()` plugin,
8
- // which auto-initialises the shared scope at host boot. `registerRemotes` +
9
- // `loadRemote` then transparently wire the remote into that already-initialised
10
- // share scope so the remote consumes the HOST's React/SDK singletons instead
11
- // of bundling its own. That's the whole point: it fixes the `useState`-null
12
- // crash WITHOUT this loader ever touching a share scope manually.
6
+ // Fiber lifecycle (Cordis-style): when `url` (the `?v=` cache-bust) changes,
7
+ // the previous plugin.dispose / returned Disposable run, the host registry
8
+ // unbinds that addonKey, the SW addon-federation cache for that key is
9
+ // purged, and register() runs again against the new remote. The host shell
10
+ // (auth, QueryClient, WebSocket, service worker controller) is not touched.
13
11
  import { useEffect, useRef, useState } from 'react'
14
12
  import { registerRemotes, loadRemote } from '@module-federation/runtime'
15
- import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk'
13
+ import type { AddonAPI, AddonLayout, Registry } from '@asteby/metacore-sdk'
16
14
  import { useDeclareAddonLayout } from './addon-layout-context'
15
+ import {
16
+ composeDisposables,
17
+ disposableFromRegisterResult,
18
+ markRemoteRegistered,
19
+ purgeAddonFrontendCache,
20
+ resolvePluginExports,
21
+ runDispose,
22
+ shouldReregisterRemote,
23
+ type AddonRegisterModule,
24
+ type Disposable,
25
+ } from './addon-fiber'
17
26
 
18
27
  export interface AddonLoaderProps {
19
28
  /** Unique key of the addon — maps to the federation container name. */
@@ -24,9 +33,25 @@ export interface AddonLoaderProps {
24
33
  module?: string
25
34
  /** Host-provided API passed to the addon's register() call. */
26
35
  api: AddonAPI
36
+ /**
37
+ * Host registry used to {@link Registry.unbind} this addon's contributions
38
+ * on dispose. Optional so legacy hosts keep compiling; without it, fiber
39
+ * remounts leak routes/actions.
40
+ */
41
+ hostRegistry?: Registry
42
+ /**
43
+ * Addon key for SW cache purge. Defaults to `api.manifest.key`.
44
+ */
45
+ addonKey?: string
46
+ /**
47
+ * Registry owner passed to {@link Registry.unbind}. Defaults to `addonKey`.
48
+ * Immersive `./plugin` fibers use `${key}::view` so they don't wipe the
49
+ * shell `./register` contributions of the same addon.
50
+ */
51
+ unbindKey?: string
27
52
  /** Optional rendering while loading. */
28
53
  fallback?: React.ReactNode
29
- /** Called once the addon has successfully registered. */
54
+ /** Called once the addon has successfully registered (including re-register). */
30
55
  onReady?: () => void
31
56
  /** Called if loading fails. */
32
57
  onError?: (err: Error) => void
@@ -45,17 +70,6 @@ export interface AddonLoaderProps {
45
70
  children?: React.ReactNode
46
71
  }
47
72
 
48
- /** Shape of the exposed `./register` module. */
49
- interface AddonRegisterModule {
50
- register?: (api: AddonAPI) => void | Promise<void>
51
- default?: (api: AddonAPI) => void | Promise<void>
52
- }
53
-
54
- // `registerRemotes` is additive + idempotent across re-mounts; we still track
55
- // which scopes we've registered to avoid redundant `force` churn (each `force`
56
- // re-register wipes that remote's module cache and logs a runtime warning).
57
- const registered = new Set<string>()
58
-
59
73
  // Derive the `loadRemote` id from the scope + exposed module name. MF resolves
60
74
  // `"<remoteName>/<expose>"` — e.g. `metacore_tickets/register` for the
61
75
  // `"./register"` expose. We strip the leading `./` of the expose path.
@@ -100,28 +114,15 @@ async function loadAddon(
100
114
  url: string,
101
115
  module: string,
102
116
  ): Promise<AddonRegisterModule | null> {
103
- // Register the remote container as an ES module. `type: 'module'` matches
104
- // the `@module-federation/vite` remote (remoteEntry.js is an ESM bundle).
105
- // The `url` already carries the `?v=` cache-bust the host computed, so the
106
- // browser refetches a fresh remoteEntry when the addon version changes.
107
- //
108
- // Both calls are wrapped in `withRuntimeReady` because EITHER can throw
109
- // RUNTIME-009 when an addon mounts ahead of the host's federation init —
110
- // registration is what actually touches the (maybe-uninitialised) runtime.
111
- if (!registered.has(scope)) {
117
+ // Re-register whenever the `?v=` URL changes so a fiber swap actually
118
+ // fetches the new remoteEntry. `force: true` wipes that remote's module
119
+ // cache without it, loadRemote would keep serving the previous bundle.
120
+ if (shouldReregisterRemote(scope, url)) {
112
121
  await withRuntimeReady(() =>
113
- registerRemotes(
114
- [{ name: scope, entry: url, type: 'module' }],
115
- // `force: true` so a re-registration with a new `?v=` URL (addon
116
- // hot-swap / version bump) overwrites the stale entry + cache.
117
- { force: true },
118
- ),
122
+ registerRemotes([{ name: scope, entry: url, type: 'module' }], { force: true }),
119
123
  )
120
- registered.add(scope)
124
+ markRemoteRegistered(scope, url)
121
125
  }
122
- // loadRemote("<scope>/<expose>") returns the exposed module namespace (or
123
- // null if it can't be resolved). No manual share-scope init — the host's
124
- // federation runtime already initialised it.
125
126
  return withRuntimeReady(() =>
126
127
  loadRemote<AddonRegisterModule>(remoteId(scope, module)),
127
128
  )
@@ -132,6 +133,9 @@ export function AddonLoader({
132
133
  url,
133
134
  module = './register',
134
135
  api,
136
+ hostRegistry,
137
+ addonKey,
138
+ unbindKey,
135
139
  fallback = null,
136
140
  onReady,
137
141
  onError,
@@ -140,7 +144,7 @@ export function AddonLoader({
140
144
  }: AddonLoaderProps) {
141
145
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
142
146
  const [error, setError] = useState<Error | null>(null)
143
- const didRegister = useRef(false)
147
+ const disposeRef = useRef<Disposable | undefined>(undefined)
144
148
 
145
149
  // Propagate the addon's preferred layout to the host shell via context.
146
150
  // No-op when `layout` is undefined or `"shell"` (legacy default). Cleanup
@@ -150,20 +154,28 @@ export function AddonLoader({
150
154
 
151
155
  useEffect(() => {
152
156
  let cancelled = false
157
+ const key = addonKey || api.manifest?.key
158
+ const owner = unbindKey || key
153
159
  ;(async () => {
154
160
  try {
161
+ setStatus('loading')
162
+ if (key) await purgeAddonFrontendCache(key)
155
163
  const mod = await loadAddon(scope, url, module)
156
164
  if (cancelled) return
157
- const register = mod?.register ?? mod?.default
158
- if (typeof register !== 'function') {
165
+ const plugin = resolvePluginExports(mod)
166
+ if (typeof plugin.register !== 'function') {
159
167
  throw new Error(
160
168
  `Addon "${scope}" module "${module}" has no register() export`,
161
169
  )
162
170
  }
163
- if (!didRegister.current) {
164
- didRegister.current = true
165
- await Promise.resolve(register(api))
166
- }
171
+ // Drop leftover contributions from a previous fiber of this key
172
+ // before the new register() runs (idempotent if none).
173
+ if (owner && hostRegistry) hostRegistry.unbind(owner)
174
+ const ret = await Promise.resolve(plugin.register(api))
175
+ disposeRef.current = composeDisposables(
176
+ disposableFromRegisterResult(ret),
177
+ plugin.dispose,
178
+ )
167
179
  setStatus('ready')
168
180
  onReady?.()
169
181
  } catch (e: unknown) {
@@ -176,8 +188,15 @@ export function AddonLoader({
176
188
  })()
177
189
  return () => {
178
190
  cancelled = true
191
+ const d = disposeRef.current
192
+ disposeRef.current = undefined
193
+ void runDispose(d)
194
+ if (owner && hostRegistry) hostRegistry.unbind(owner)
179
195
  }
180
- }, [scope, url, module])
196
+ // api identity is expected to be stable per addon; including it would
197
+ // re-register on every parent render. Fiber identity is (scope, url, module).
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ }, [scope, url, module, addonKey, unbindKey, hostRegistry])
181
200
 
182
201
  if (status === 'loading') return <>{fallback}</>
183
202
  if (status === 'error')
@@ -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,
@@ -122,6 +135,17 @@ export {
122
135
  type ActionPlacement,
123
136
  } from './model-action-toolbar'
124
137
  export * from './addon-loader'
138
+ export {
139
+ PURGE_ADDON_MESSAGE,
140
+ isAddonFrontendCacheUrl,
141
+ purgeAddonFrontendCache,
142
+ resolvePluginExports,
143
+ composeDisposables,
144
+ runDispose,
145
+ type PurgeAddonMessage,
146
+ type AddonRegisterModule,
147
+ type ResolvedPlugin,
148
+ } from './addon-fiber'
125
149
  export {
126
150
  AddonLayoutProvider,
127
151
  useAddonLayout,
@@ -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
@@ -0,0 +1,60 @@
1
+ /** Laravel-style validation catalogs. Hosts override any key via i18next
2
+ * `validation.<code>`; these are the defaults when the key is missing, picked
3
+ * by the operator's language (es by default — the product language). */
4
+
5
+ export const VALIDATION_CATALOGS: Record<string, Record<string, string>> = {
6
+ es: {
7
+ failed: 'Revisa los campos marcados',
8
+ required: 'El campo {{label}} es obligatorio',
9
+ invalid_option: 'El valor de {{label}} no es válido',
10
+ not_found: 'El {{label}} seleccionado no existe',
11
+ duplicate: 'Ya existe un registro con ese {{label}}',
12
+ invalid_type: 'El campo {{label}} tiene un formato inválido',
13
+ min: 'El campo {{label}} debe ser al menos {{min}}',
14
+ min_length: '{{label}} debe tener al menos {{min}} caracteres',
15
+ max: 'El campo {{label}} no puede ser mayor que {{max}}',
16
+ max_length: '{{label}} no puede tener más de {{max}} caracteres',
17
+ regex: 'El formato de {{label}} no es válido',
18
+ email: '{{label}} debe ser un correo válido',
19
+ uuid: '{{label}} no es un identificador válido',
20
+ url: '{{label}} debe ser una URL válida',
21
+ numeric: '{{label}} debe ser un número',
22
+ integer: '{{label}} debe ser un entero',
23
+ custom: '{{label}} no es válido',
24
+ line_items_required: '{{label}} requiere al menos un renglón',
25
+ fallback: '{{label}}: valor inválido',
26
+ },
27
+ en: {
28
+ failed: 'Please check the highlighted fields',
29
+ required: 'The {{label}} field is required',
30
+ invalid_option: 'The selected {{label}} is invalid',
31
+ not_found: 'The selected {{label}} does not exist',
32
+ duplicate: 'A record with that {{label}} already exists',
33
+ invalid_type: 'The {{label}} field has an invalid format',
34
+ min: 'The {{label}} must be at least {{min}}',
35
+ min_length: 'The {{label}} must be at least {{min}} characters',
36
+ max: 'The {{label}} may not be greater than {{max}}',
37
+ max_length: 'The {{label}} may not be greater than {{max}} characters',
38
+ regex: 'The {{label}} format is invalid',
39
+ email: 'The {{label}} must be a valid email address',
40
+ uuid: 'The {{label}} is not a valid identifier',
41
+ url: 'The {{label}} must be a valid URL',
42
+ numeric: 'The {{label}} must be a number',
43
+ integer: 'The {{label}} must be an integer',
44
+ custom: 'The {{label}} is invalid',
45
+ line_items_required: '{{label}} requires at least one row',
46
+ fallback: '{{label}}: invalid value',
47
+ },
48
+ }
49
+
50
+ export function validationCatalog(language?: string): Record<string, string> {
51
+ const short = (language ?? 'es').split(/[-_]/)[0]!.toLowerCase()
52
+ return VALIDATION_CATALOGS[short] ?? VALIDATION_CATALOGS.es!
53
+ }
54
+
55
+ /** Pick the catalog key for a code, folding min/max + kind=length into *_length. */
56
+ export function validationMessageKey(code: string, params?: Record<string, unknown>): string {
57
+ if (code === 'min' && params?.kind === 'length') return 'min_length'
58
+ if (code === 'max' && params?.kind === 'length') return 'max_length'
59
+ return code
60
+ }