@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
|
@@ -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,
|
|
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
|
|
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
|
}
|
|
@@ -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 }
|