@asteby/metacore-runtime-react 32.0.0 → 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 +9 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +49 -71
- 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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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 +1 -1
- package/src/__tests__/extract-field-errors.test.ts +25 -1
- package/src/__tests__/validator.test.ts +70 -0
- package/src/action-modal-dispatcher.tsx +53 -68
- package/src/dialogs/dynamic-record.tsx +25 -28
- package/src/dynamic-form-schema.ts +3 -2
- package/src/index.ts +13 -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
|
@@ -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,
|
|
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
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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(
|
|
442
|
-
|
|
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('
|
|
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('
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
805
|
-
if (
|
|
806
|
-
toast.error(
|
|
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
|
|
819
|
-
if (
|
|
820
|
-
toast.error(
|
|
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,
|
|
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,
|
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
|