@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.
- package/CHANGELOG.md +27 -0
- package/dist/action-modal-dispatcher.d.ts +20 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +52 -74
- package/dist/addon-fiber.d.ts +57 -0
- package/dist/addon-fiber.d.ts.map +1 -0
- package/dist/addon-fiber.js +122 -0
- package/dist/addon-loader.d.ts +19 -3
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +36 -37
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +27 -28
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +2 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/server-error.d.ts +17 -8
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +75 -28
- package/dist/types.d.ts +4 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-catalog.d.ts +8 -0
- package/dist/validation-catalog.d.ts.map +1 -0
- package/dist/validation-catalog.js +59 -0
- package/dist/validator.d.ts +22 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +261 -0
- package/package.json +3 -3
- package/src/__tests__/addon-fiber.test.ts +111 -0
- package/src/__tests__/extract-field-errors.test.ts +25 -1
- package/src/__tests__/prefill-from-record.test.ts +146 -0
- package/src/__tests__/validator.test.ts +70 -0
- package/src/action-modal-dispatcher.tsx +57 -72
- package/src/addon-fiber.ts +155 -0
- package/src/addon-loader.tsx +66 -47
- package/src/dialogs/dynamic-record.tsx +25 -28
- package/src/dynamic-form-schema.ts +3 -2
- package/src/index.ts +24 -0
- package/src/server-error.ts +85 -30
- package/src/types.ts +9 -12
- package/src/validation-catalog.ts +60 -0
- package/src/validator.ts +275 -0
package/src/addon-loader.tsx
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
// the
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
104
|
-
// the
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
772
|
-
for (const [key
|
|
773
|
-
|
|
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
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
|
|
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
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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,
|
|
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
|
|
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,
|
package/src/server-error.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
151
|
-
*
|
|
152
|
-
* `code` is translated via `t('validation.'+
|
|
153
|
-
*
|
|
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(
|
|
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
|
|
158
|
-
const
|
|
159
|
-
|
|
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.
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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(
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
*
|
|
321
|
-
*
|
|
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
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
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
|
+
}
|