@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
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildPrefillRows, isPrefillSpec, applyPrefillLock, type PrefillSpec } from '../action-modal-dispatcher'
3
+ import type { ActionFieldDef } from '../types'
4
+
5
+ // receive-goods-style item_fields: the canonical use case ($prefillFromRecord
6
+ // + map + remaining + lock), same shape inventory's receive_transfer and
7
+ // purchases' receive_goods declare in their manifest.json.
8
+ const receiveField = (overrides: Partial<ActionFieldDef> = {}): ActionFieldDef => ({
9
+ key: 'lines',
10
+ label: 'Renglones',
11
+ type: 'array',
12
+ itemFields: [
13
+ { key: 'product_id', label: 'Producto', type: 'dynamic_select', ref: 'Product' },
14
+ { key: 'ordered', label: 'Ordenado', type: 'number' },
15
+ { key: 'received_so_far', label: 'Ya recibido', type: 'number' },
16
+ { key: 'qty_received', label: 'Cantidad recibida', type: 'number', required: true },
17
+ ],
18
+ ...overrides,
19
+ })
20
+
21
+ describe('isPrefillSpec', () => {
22
+ it('reconoce un objeto con $prefillFromRecord como PrefillSpec', () => {
23
+ expect(isPrefillSpec({ $prefillFromRecord: 'items' })).toBe(true)
24
+ })
25
+
26
+ it('rechaza un default literal (string/number) o un objeto sin $prefillFromRecord', () => {
27
+ expect(isPrefillSpec('walk-in')).toBe(false)
28
+ expect(isPrefillSpec(42)).toBe(false)
29
+ expect(isPrefillSpec(null)).toBe(false)
30
+ expect(isPrefillSpec(undefined)).toBe(false)
31
+ expect(isPrefillSpec({ map: { a: 'b' } })).toBe(false)
32
+ })
33
+ })
34
+
35
+ describe('buildPrefillRows', () => {
36
+ it('proyecta record[$prefillFromRecord] a filas usando map', () => {
37
+ const spec: PrefillSpec = {
38
+ $prefillFromRecord: 'items',
39
+ map: { product_id: 'product_id', ordered: 'quantity' },
40
+ }
41
+ const record = {
42
+ items: [
43
+ { product_id: 'p1', quantity: 10 },
44
+ { product_id: 'p2', quantity: 5 },
45
+ ],
46
+ }
47
+ expect(buildPrefillRows(spec, record)).toEqual([
48
+ { product_id: 'p1', ordered: 10 },
49
+ { product_id: 'p2', ordered: 5 },
50
+ ])
51
+ })
52
+
53
+ it('calcula remaining.target = of - minus por fila', () => {
54
+ const spec: PrefillSpec = {
55
+ $prefillFromRecord: 'items',
56
+ map: { product_id: 'product_id' },
57
+ remaining: { target: 'qty_received', of: 'quantity', minus: 'received' },
58
+ }
59
+ const record = { items: [{ product_id: 'p1', quantity: 10, received: 4 }] }
60
+ expect(buildPrefillRows(spec, record)).toEqual([{ product_id: 'p1', qty_received: 6 }])
61
+ })
62
+
63
+ it('con remaining.minus omitido, remaining = of tal cual (minus lee como 0)', () => {
64
+ const spec: PrefillSpec = {
65
+ $prefillFromRecord: 'items',
66
+ remaining: { target: 'qty_received', of: 'quantity' },
67
+ }
68
+ const record = { items: [{ quantity: 7 }] }
69
+ expect(buildPrefillRows(spec, record)).toEqual([{ qty_received: 7 }])
70
+ })
71
+
72
+ it('omite filas ya satisfechas por completo (remaining <= 0)', () => {
73
+ const spec: PrefillSpec = {
74
+ $prefillFromRecord: 'items',
75
+ map: { product_id: 'product_id' },
76
+ remaining: { target: 'qty_received', of: 'quantity', minus: 'received' },
77
+ }
78
+ const record = {
79
+ items: [
80
+ { product_id: 'p1', quantity: 10, received: 10 }, // satisfecha -> fuera
81
+ { product_id: 'p2', quantity: 10, received: 12 }, // sobre-recibida -> fuera
82
+ { product_id: 'p3', quantity: 10, received: 3 }, // pendiente -> queda
83
+ ],
84
+ }
85
+ expect(buildPrefillRows(spec, record)).toEqual([{ product_id: 'p3', qty_received: 7 }])
86
+ })
87
+
88
+ it('combina map + remaining en el mismo caso real de receive_transfer/receive_goods', () => {
89
+ const spec: PrefillSpec = {
90
+ $prefillFromRecord: 'items',
91
+ map: { product_id: 'product_id', ordered: 'quantity', received_so_far: 'received' },
92
+ remaining: { target: 'qty_received', of: 'quantity', minus: 'received' },
93
+ lock: ['product_id', 'ordered', 'received_so_far'],
94
+ }
95
+ const record = { items: [{ product_id: 'p1', quantity: 10, received: 4 }] }
96
+ expect(buildPrefillRows(spec, record)).toEqual([
97
+ { product_id: 'p1', ordered: 10, received_so_far: 4, qty_received: 6 },
98
+ ])
99
+ })
100
+
101
+ it('registro sin filas (o campo ausente/no-array) da prefill vacío, sin explotar', () => {
102
+ const spec: PrefillSpec = { $prefillFromRecord: 'items' }
103
+ expect(buildPrefillRows(spec, { items: [] })).toEqual([])
104
+ expect(buildPrefillRows(spec, {})).toEqual([])
105
+ expect(buildPrefillRows(spec, { items: 'not-an-array' })).toEqual([])
106
+ expect(buildPrefillRows(spec, null)).toEqual([])
107
+ })
108
+
109
+ it('ignora entradas no-objeto dentro del array origen', () => {
110
+ const spec: PrefillSpec = { $prefillFromRecord: 'items', map: { product_id: 'product_id' } }
111
+ const record = { items: [null, { product_id: 'p1' }, undefined, 42] }
112
+ expect(buildPrefillRows(spec, record)).toEqual([{ product_id: 'p1' }])
113
+ })
114
+ })
115
+
116
+ describe('applyPrefillLock', () => {
117
+ it('marca readonly las columnas listadas en lock', () => {
118
+ const field = receiveField({
119
+ default: {
120
+ $prefillFromRecord: 'items',
121
+ lock: ['product_id', 'ordered', 'received_so_far'],
122
+ } as PrefillSpec,
123
+ } as Partial<ActionFieldDef>)
124
+ const patched = applyPrefillLock(field) as ActionFieldDef & { itemFields?: any[] }
125
+ const byKey = Object.fromEntries((patched.itemFields ?? []).map((c: any) => [c.key, c]))
126
+ expect(byKey.product_id.readonly).toBe(true)
127
+ expect(byKey.ordered.readonly).toBe(true)
128
+ expect(byKey.received_so_far.readonly).toBe(true)
129
+ expect(byKey.qty_received.readonly).toBeUndefined()
130
+ })
131
+
132
+ it('sin lock (o sin prefill spec) deja el field intacto', () => {
133
+ const plain = receiveField()
134
+ expect(applyPrefillLock(plain)).toBe(plain)
135
+
136
+ const noLock = receiveField({
137
+ default: { $prefillFromRecord: 'items' } as PrefillSpec,
138
+ } as Partial<ActionFieldDef>)
139
+ expect(applyPrefillLock(noLock)).toBe(noLock)
140
+ })
141
+
142
+ it('un default literal (no PrefillSpec) deja el field intacto', () => {
143
+ const field = receiveField({ default: 'walk-in' } as Partial<ActionFieldDef>)
144
+ expect(applyPrefillLock(field)).toBe(field)
145
+ })
146
+ })
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseRuleString, checkValue, validateValues, bagHasErrors } from '../validator'
3
+ import type { ActionFieldDef } from '../types'
4
+
5
+ describe('parseRuleString', () => {
6
+ it('parses Laravel pipes', () => {
7
+ const s = parseRuleString('required|min:2|max:10|email')
8
+ expect(s.required).toBe(true)
9
+ expect(s.min).toBe(2)
10
+ expect(s.max).toBe(10)
11
+ expect(s.custom).toBe('email')
12
+ })
13
+ it('parses go-playground commas', () => {
14
+ const s = parseRuleString('required,min=2,max=100')
15
+ expect(s.required).toBe(true)
16
+ expect(s.min).toBe(2)
17
+ expect(s.max).toBe(100)
18
+ })
19
+ it('treats a slug as custom', () => {
20
+ expect(parseRuleString('$org.tax_id_validator').custom).toBe('$org.tax_id_validator')
21
+ })
22
+ })
23
+
24
+ describe('checkValue', () => {
25
+ it('required on empty', () => {
26
+ expect(checkValue('', { required: true })).toEqual([{ code: 'required' }])
27
+ expect(checkValue('x', { required: true })).toEqual([])
28
+ })
29
+ it('min/max length on strings', () => {
30
+ expect(checkValue('ab', { type: 'string', min: 3 })).toEqual([
31
+ { code: 'min', params: { min: 3, kind: 'length' } },
32
+ ])
33
+ })
34
+ it('regex + email', () => {
35
+ expect(checkValue('nope', { type: 'string', regex: '^[A-Z]+$' })[0]?.code).toBe('regex')
36
+ expect(checkValue('a@b.com', { custom: 'email' })).toEqual([])
37
+ expect(checkValue('not-an-email', { custom: 'email' })[0]?.code).toBe('email')
38
+ })
39
+ it('skips other rules when empty and not required', () => {
40
+ expect(checkValue('', { type: 'string', min: 3, custom: 'email' })).toEqual([])
41
+ })
42
+ })
43
+
44
+ describe('validateValues', () => {
45
+ const fields: ActionFieldDef[] = [
46
+ { key: 'name', label: 'Nombre', type: 'text', required: true },
47
+ { key: 'sku', label: 'SKU', type: 'text', validation: { min: 3, regex: '^[A-Z]+$' } },
48
+ {
49
+ key: 'items',
50
+ label: 'Renglones',
51
+ type: 'array',
52
+ required: true,
53
+ itemFields: [{ key: 'qty', label: 'Cantidad', type: 'number', required: true }],
54
+ },
55
+ ]
56
+ it('collects every field including dotted line-items', () => {
57
+ const bag = validateValues(fields, { sku: 'ab', items: [{ qty: '' }] })
58
+ expect(bag.name?.[0]?.code).toBe('required')
59
+ expect(bag.sku?.map(i => i.code).sort()).toEqual(['min', 'regex'])
60
+ expect(bag['items.0.qty']?.[0]?.code).toBe('required')
61
+ expect(bagHasErrors(bag)).toBe(true)
62
+ })
63
+ it('parses a laravel string on validation', () => {
64
+ const bag = validateValues(
65
+ [{ key: 'email', label: 'Email', type: 'text', validation: 'required|email' }],
66
+ { email: 'nope' },
67
+ )
68
+ expect(bag.email?.[0]?.code).toBe('email')
69
+ })
70
+ })
@@ -37,8 +37,10 @@ import {
37
37
  } from '@asteby/metacore-ui/primitives'
38
38
  import { Loader2 } from 'lucide-react'
39
39
  import { toast } from 'sonner'
40
- import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldIssue } from './server-error'
40
+ import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
41
41
  import type { Translate } from './server-error'
42
+ import { validateValues, bagHasErrors } from './validator'
43
+ import { validationCatalog } from './validation-catalog'
42
44
  import { useApi } from './api-context'
43
45
  import { DynamicIcon } from './dynamic-icon'
44
46
  import { DynamicLineItems } from './dynamic-line-items'
@@ -81,7 +83,7 @@ export type { ActionMetadata, ActionModalProps }
81
83
  // "map": { "product_id": "product_id" },
82
84
  // "remaining": { "target": "qty_received", "of": "quantity", "minus": "received" }
83
85
  // }
84
- interface PrefillSpec {
86
+ export interface PrefillSpec {
85
87
  $prefillFromRecord: string
86
88
  map?: Record<string, string>
87
89
  remaining?: { target: string; of: string; minus?: string }
@@ -94,7 +96,7 @@ interface PrefillSpec {
94
96
  lock?: string[]
95
97
  }
96
98
 
97
- function isPrefillSpec(v: unknown): v is PrefillSpec {
99
+ export function isPrefillSpec(v: unknown): v is PrefillSpec {
98
100
  return (
99
101
  typeof v === 'object' &&
100
102
  v !== null &&
@@ -122,7 +124,7 @@ function toNum(v: unknown): number {
122
124
  // untouched when there is no prefill spec or no lock list (the create flow,
123
125
  // which carries no prefill, stays fully editable). The readonly flag is set on
124
126
  // BOTH itemFields aliases the renderers tolerate.
125
- function applyPrefillLock(field: ActionFieldDef): ActionFieldDef {
127
+ export function applyPrefillLock(field: ActionFieldDef): ActionFieldDef {
126
128
  const spec = lineItemsDefault(field)
127
129
  if (!isPrefillSpec(spec) || !spec.lock || spec.lock.length === 0) return field
128
130
  const lock = new Set(spec.lock)
@@ -134,7 +136,7 @@ function applyPrefillLock(field: ActionFieldDef): ActionFieldDef {
134
136
  }
135
137
 
136
138
  // buildPrefillRows projects record[spec.$prefillFromRecord] into modal rows.
137
- function buildPrefillRows(spec: PrefillSpec, record: any): Array<Record<string, any>> {
139
+ export function buildPrefillRows(spec: PrefillSpec, record: any): Array<Record<string, any>> {
138
140
  const src = record?.[spec.$prefillFromRecord]
139
141
  if (!Array.isArray(src)) return []
140
142
  const rows: Array<Record<string, any>> = []
@@ -423,34 +425,38 @@ function localizeActionFieldErrors(
423
425
  err: unknown,
424
426
  fields: readonly ActionFieldDef[] | undefined,
425
427
  t: Translate,
428
+ language?: string,
426
429
  ): Record<string, string> | undefined {
427
430
  const map = extractFieldErrors(err)
428
431
  if (!map) return undefined
429
- const labelFor = (k: string) => {
430
- const f = (fields ?? []).find(x => x.key === k)
431
- return f?.label ? t(f.label, { defaultValue: f.label }) : humanizeKey(k)
432
+ const labels: Record<string, string> = {}
433
+ for (const f of fields ?? []) {
434
+ labels[f.key] = f.label ? t(f.label, { defaultValue: f.label }) : humanizeKey(f.key)
432
435
  }
433
- const out: Record<string, string> = {}
434
- for (const [k, issues] of Object.entries(map)) out[k] = localizeFieldIssue(issues[0], labelFor(k), t)
435
- return out
436
+ return localizeFieldErrorMap(map, t, { labels, language })
436
437
  }
437
438
 
438
439
  /** Toast a failed action: a summary + localized per-field lines when the server
439
440
  * returned a per-field `errors` map, else the standard cause-carrying toast.
440
441
  * Used where inline rendering isn't wired (confirm / multi-step wizard). */
441
- function toastActionError(err: unknown, fields: readonly ActionFieldDef[] | undefined, t: Translate): void {
442
- const localized = localizeActionFieldErrors(err, fields, t)
442
+ function toastActionError(
443
+ err: unknown,
444
+ fields: readonly ActionFieldDef[] | undefined,
445
+ t: Translate,
446
+ language?: string,
447
+ ): void {
448
+ const localized = localizeActionFieldErrors(err, fields, t, language)
443
449
  if (localized) {
444
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), {
450
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(language).failed }), {
445
451
  description: Object.values(localized).join('\n'),
446
452
  })
447
453
  return
448
454
  }
449
- toastServerError(err, { t })
455
+ toastServerError(err, { t, language })
450
456
  }
451
457
 
452
458
  function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
453
- const { t } = useTranslation()
459
+ const { t, i18n } = useTranslation()
454
460
  const api = useApi()
455
461
  const [executing, setExecuting] = useState(false)
456
462
  // `action.label` is an addon-contributed i18n key; its locale bundle loads
@@ -468,10 +474,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
468
474
  onOpenChange(false)
469
475
  onSuccess()
470
476
  } else {
471
- toastActionError({ response: { data: res.data } }, action.fields, t)
477
+ toastActionError({ response: { data: res.data } }, action.fields, t, i18n.language)
472
478
  }
473
479
  } catch (err: any) {
474
- toastActionError(err, action.fields, t)
480
+ toastActionError(err, action.fields, t, i18n.language)
475
481
  } finally {
476
482
  setExecuting(false)
477
483
  }
@@ -507,7 +513,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
507
513
  }
508
514
 
509
515
  function GenericActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
510
- const { t } = useTranslation()
516
+ const { t, i18n } = useTranslation()
511
517
  // Addon-contributed labels (action + fields) are i18n keys whose locale
512
518
  // bundle loads asynchronously; translate at render so they don't render raw.
513
519
  // defaultValue keeps an already-localized string unchanged.
@@ -574,39 +580,25 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
574
580
  })
575
581
  }
576
582
 
583
+ const lang = i18n.language
577
584
  const handleActionError = (err: unknown) => {
578
- const localized = localizeActionFieldErrors(err, action.fields, t)
585
+ const localized = localizeActionFieldErrors(err, action.fields, t, lang)
579
586
  if (localized) {
580
587
  setFieldErrors(localized)
581
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
588
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
582
589
  return
583
590
  }
584
- toastServerError(err, { t })
591
+ toastServerError(err, { t, language: lang })
585
592
  }
586
593
 
587
594
  const execute = async () => {
588
595
  if (action.fields) {
589
- // Client-side required check → mark ALL missing fields inline.
590
- const missing: Record<string, string> = {}
591
- for (const field of action.fields) {
592
- if (!field.required) continue
593
- if (isLineItemsField(field)) {
594
- const rows = formData[field.key]
595
- if (!Array.isArray(rows) || rows.length === 0) {
596
- missing[field.key] = t('validation.line_items_required', {
597
- defaultValue: '{{label}} requiere al menos un renglón',
598
- label: tl(field.label),
599
- })
600
- }
601
- continue
602
- }
603
- if (!formData[field.key] && formData[field.key] !== false) {
604
- missing[field.key] = localizeFieldIssue({ code: 'required' }, tl(field.label), t)
605
- }
606
- }
607
- if (Object.keys(missing).length) {
608
- setFieldErrors(missing)
609
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
596
+ const bag = validateValues(action.fields, formData)
597
+ if (bagHasErrors(bag)) {
598
+ const labels: Record<string, string> = {}
599
+ for (const f of action.fields) labels[f.key] = tl(f.label)
600
+ setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang }))
601
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
610
602
  return
611
603
  }
612
604
  }
@@ -738,28 +730,17 @@ function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<strin
738
730
  return defaults
739
731
  }
740
732
 
741
- // validateFields returns the first validation error for a set of required
742
- // fields, or null when they all pass. Shared by the wizard (per-step gate) and
743
- // mirrors GenericActionModal's inline checks. `tl` localizes the field label.
744
- function validateFields(
733
+ function wizardStepErrors(
745
734
  fields: ActionFieldDef[],
746
735
  formData: Record<string, any>,
747
- tl: (s: string) => string,
748
- ): string | null {
749
- for (const field of fields) {
750
- if (!field.required) continue
751
- if (isLineItemsField(field)) {
752
- const rows = formData[field.key]
753
- if (!Array.isArray(rows) || rows.length === 0) {
754
- return `${tl(field.label)} requiere al menos un renglón`
755
- }
756
- continue
757
- }
758
- if (!formData[field.key] && formData[field.key] !== false) {
759
- return `${tl(field.label)} es requerido`
760
- }
761
- }
762
- return null
736
+ t: Translate,
737
+ language?: string,
738
+ ): Record<string, string> | undefined {
739
+ const bag = validateValues(fields, formData)
740
+ if (!bagHasErrors(bag)) return undefined
741
+ const labels: Record<string, string> = {}
742
+ for (const f of fields) labels[f.key] = t(f.label, { defaultValue: f.label })
743
+ return localizeFieldErrorMap(bag, t, { labels, language })
763
744
  }
764
745
 
765
746
  // WizardActionModal — the third render-path: a multi-step form. It accumulates
@@ -770,7 +751,7 @@ function validateFields(
770
751
  // so line-items, dynamic_select, uploads and dates behave identically to a
771
752
  // single-page action form — no widget is duplicated.
772
753
  function WizardActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
773
- const { t } = useTranslation()
754
+ const { t, i18n } = useTranslation()
774
755
  const tl = (s: string) => t(s, { defaultValue: s })
775
756
  const api = useApi()
776
757
  const steps = action.steps ?? []
@@ -801,9 +782,11 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
801
782
  const widthPx = hasLineItems ? '820px' : undefined
802
783
 
803
784
  const goNext = () => {
804
- const err = validateFields(stepFields, formData, tl)
805
- if (err) {
806
- toast.error(err)
785
+ const localized = wizardStepErrors(stepFields, formData, t, i18n.language)
786
+ if (localized) {
787
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(i18n.language).failed }), {
788
+ description: Object.values(localized).join('\n'),
789
+ })
807
790
  return
808
791
  }
809
792
  setStepIndex((i) => Math.min(i + 1, steps.length - 1))
@@ -815,9 +798,11 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
815
798
  // Guard every step's required fields on final submit (a user could reach
816
799
  // the last step with an untouched earlier line-items grid otherwise).
817
800
  for (const s of steps) {
818
- const err = validateFields(s.fields ?? [], formData, tl)
819
- if (err) {
820
- toast.error(err)
801
+ const localized = wizardStepErrors(s.fields ?? [], formData, t, i18n.language)
802
+ if (localized) {
803
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(i18n.language).failed }), {
804
+ description: Object.values(localized).join('\n'),
805
+ })
821
806
  return
822
807
  }
823
808
  }
@@ -830,10 +815,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
830
815
  onOpenChange(false)
831
816
  onSuccess()
832
817
  } else {
833
- toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t)
818
+ toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t, i18n.language)
834
819
  }
835
820
  } catch (err: any) {
836
- toastActionError(err, steps.flatMap(s => s.fields ?? []), t)
821
+ toastActionError(err, steps.flatMap(s => s.fields ?? []), t, i18n.language)
837
822
  } finally {
838
823
  setExecuting(false)
839
824
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Addon fiber primitives — Cordis-style dispose/reload for federated modules
3
+ * running inside a PWA host. Pure helpers so they unit-test without React
4
+ * or the Module Federation runtime.
5
+ */
6
+
7
+ import type { AddonAPI, Disposable, Plugin } from '@asteby/metacore-sdk'
8
+
9
+ export const PURGE_ADDON_MESSAGE = 'PURGE_ADDON' as const
10
+
11
+ export interface PurgeAddonMessage {
12
+ type: typeof PURGE_ADDON_MESSAGE
13
+ key: string
14
+ }
15
+
16
+ /** Shape of the exposed `./register` (or `./plugin`) module. */
17
+ export interface AddonRegisterModule {
18
+ register?: Plugin['register']
19
+ dispose?: Plugin['dispose']
20
+ default?: Plugin['register'] | Plugin
21
+ }
22
+
23
+ export interface ResolvedPlugin {
24
+ register?: Plugin['register']
25
+ dispose?: Plugin['dispose']
26
+ }
27
+
28
+ /**
29
+ * Accept every historical export shape:
30
+ * - `{ register, dispose? }`
31
+ * - `{ default: { register, dispose? } }` (definePlugin)
32
+ * - `{ default: (api) => ... }` (function module)
33
+ */
34
+ export function resolvePluginExports(mod: AddonRegisterModule | null | undefined): ResolvedPlugin {
35
+ if (!mod) return {}
36
+ const fromDefault =
37
+ mod.default && typeof mod.default === 'object'
38
+ ? (mod.default as Plugin)
39
+ : undefined
40
+ const registerFn =
41
+ typeof mod.register === 'function'
42
+ ? mod.register
43
+ : typeof fromDefault?.register === 'function'
44
+ ? fromDefault.register.bind(fromDefault)
45
+ : typeof mod.default === 'function'
46
+ ? mod.default
47
+ : undefined
48
+ const disposeFn =
49
+ typeof mod.dispose === 'function'
50
+ ? mod.dispose
51
+ : typeof fromDefault?.dispose === 'function'
52
+ ? fromDefault.dispose.bind(fromDefault)
53
+ : undefined
54
+ return { register: registerFn, dispose: disposeFn }
55
+ }
56
+
57
+ export async function runDispose(dispose?: Disposable | null): Promise<void> {
58
+ if (typeof dispose !== 'function') return
59
+ try {
60
+ await Promise.resolve(dispose())
61
+ } catch {
62
+ /* a failing disposer must not block the next fiber mount */
63
+ }
64
+ }
65
+
66
+ export function composeDisposables(...fns: Array<Disposable | undefined | null>): Disposable {
67
+ const list = fns.filter((f): f is Disposable => typeof f === 'function')
68
+ return async () => {
69
+ for (let i = list.length - 1; i >= 0; i--) {
70
+ await runDispose(list[i])
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * True when a Cache API / SW request is a federated frontend asset of `addonKey`.
77
+ * Matches both `/api/addons/<key>/frontend` and `/api/metacore/addons/<key>/frontend`.
78
+ */
79
+ export function isAddonFrontendCacheUrl(url: string, addonKey: string): boolean {
80
+ if (!addonKey || !url) return false
81
+ try {
82
+ const path = new URL(url, 'http://local.invalid').pathname
83
+ const escaped = addonKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
84
+ return new RegExp(`/api/(metacore/)?addons/${escaped}/frontend(/|\\.js)`).test(path)
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Drop cached federation assets for one addon. Primary path is the page-side
92
+ * Cache API (works even before the SW learns `PURGE_ADDON`). Also posts the
93
+ * message so a current SW can drop its own entries.
94
+ *
95
+ * Never unregisters the SW and never deletes other caches — L1, not L2.
96
+ */
97
+ export async function purgeAddonFrontendCache(addonKey: string): Promise<void> {
98
+ if (!addonKey) return
99
+ try {
100
+ if (typeof caches !== 'undefined') {
101
+ const names = await caches.keys()
102
+ await Promise.all(
103
+ names.map(async (name) => {
104
+ if (!name.includes('addon-federation')) return
105
+ const cache = await caches.open(name)
106
+ const requests = await cache.keys()
107
+ await Promise.all(
108
+ requests
109
+ .filter((req) => isAddonFrontendCacheUrl(req.url, addonKey))
110
+ .map((req) => cache.delete(req)),
111
+ )
112
+ }),
113
+ )
114
+ }
115
+ } catch {
116
+ /* private mode / no Cache API */
117
+ }
118
+ try {
119
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
120
+ const controller = navigator.serviceWorker.controller
121
+ controller?.postMessage({ type: PURGE_ADDON_MESSAGE, key: addonKey } satisfies PurgeAddonMessage)
122
+ }
123
+ } catch {
124
+ /* no SW */
125
+ }
126
+ }
127
+
128
+ /** Test seam: which remote URL is currently registered per federation scope. */
129
+ export const remoteEntryByScope = new Map<string, string>()
130
+
131
+ export function shouldReregisterRemote(scope: string, url: string): boolean {
132
+ return remoteEntryByScope.get(scope) !== url
133
+ }
134
+
135
+ export function markRemoteRegistered(scope: string, url: string): void {
136
+ remoteEntryByScope.set(scope, url)
137
+ }
138
+
139
+ /** @internal tests */
140
+ export function resetRemoteRegistry(): void {
141
+ remoteEntryByScope.clear()
142
+ }
143
+
144
+ /**
145
+ * Safe no-op when `register()` returns void. Used by AddonLoader to treat
146
+ * both historical and fiber-style plugins uniformly.
147
+ */
148
+ export function disposableFromRegisterResult(
149
+ result: void | Disposable,
150
+ ): Disposable | undefined {
151
+ return typeof result === 'function' ? result : undefined
152
+ }
153
+
154
+ /** Type-only re-export so AddonLoader does not import Plugin internals twice. */
155
+ export type { AddonAPI, Disposable }