@asteby/metacore-runtime-react 25.1.2 → 25.1.4
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 +23 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +76 -13
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +70 -12
- package/dist/dialogs/normalize-submit.d.ts +17 -0
- package/dist/dialogs/normalize-submit.d.ts.map +1 -0
- package/dist/dialogs/normalize-submit.js +36 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/server-error.d.ts +26 -1
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +66 -0
- package/package.json +1 -1
- package/src/__tests__/extract-field-errors.test.ts +102 -0
- package/src/__tests__/normalize-ref-fields.test.ts +63 -0
- package/src/action-modal-dispatcher.tsx +84 -12
- package/src/dialogs/dynamic-record.tsx +78 -12
- package/src/dialogs/normalize-submit.ts +49 -0
- package/src/index.ts +1 -0
- package/src/server-error.ts +87 -2
package/dist/server-error.js
CHANGED
|
@@ -59,6 +59,72 @@ export function extractServerError(err, fallbackTitle) {
|
|
|
59
59
|
return { title: fallbackTitle, description: raw.trim() };
|
|
60
60
|
return { title: fallbackTitle };
|
|
61
61
|
}
|
|
62
|
+
/** Spanish defaults per known validation code. `{{label}}` (and code params like
|
|
63
|
+
* `allowed`/`ref`/`expected`) are interpolated by i18next, so a host can override
|
|
64
|
+
* any of these via its addon i18n bundle under `validation.<code>`. */
|
|
65
|
+
const VALIDATION_DEFAULTS = {
|
|
66
|
+
required: 'El campo {{label}} es obligatorio',
|
|
67
|
+
invalid_option: 'El valor de {{label}} no es válido',
|
|
68
|
+
not_found: 'El {{label}} seleccionado no existe',
|
|
69
|
+
duplicate: 'Ya existe un registro con ese {{label}}',
|
|
70
|
+
invalid_type: 'El campo {{label}} tiene un formato inválido',
|
|
71
|
+
};
|
|
72
|
+
const VALIDATION_FALLBACK = '{{label}}: valor inválido';
|
|
73
|
+
/** Normalize one raw `errors` value entry into a `FieldIssue`.
|
|
74
|
+
* A string → `{message}` (pre-localized, shown verbatim); an object → `{code,params}`. */
|
|
75
|
+
function toFieldIssue(entry) {
|
|
76
|
+
if (typeof entry === 'string') {
|
|
77
|
+
const s = entry.trim();
|
|
78
|
+
return s ? { message: s } : undefined;
|
|
79
|
+
}
|
|
80
|
+
if (entry && typeof entry === 'object') {
|
|
81
|
+
const e = entry;
|
|
82
|
+
if (typeof e.message === 'string' && e.message.trim())
|
|
83
|
+
return { message: e.message.trim() };
|
|
84
|
+
if (typeof e.code === 'string' && e.code) {
|
|
85
|
+
return {
|
|
86
|
+
code: e.code,
|
|
87
|
+
params: e.params && typeof e.params === 'object' ? e.params : undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Pull the per-field `errors` map out of an axios error (`err.response.data.errors`)
|
|
95
|
+
* or a bare response body (`{ errors }`), normalizing each field's value to a
|
|
96
|
+
* `FieldIssue[]`. A value may be a single entry or an array; string entries become
|
|
97
|
+
* `{message}`, object entries `{code,params}`. Returns `undefined` when there is no
|
|
98
|
+
* usable map (no `errors`, or nothing normalized). Pure — no i18n, no toast.
|
|
99
|
+
*/
|
|
100
|
+
export function extractFieldErrors(err) {
|
|
101
|
+
const maybeAxios = err?.response?.data;
|
|
102
|
+
const data = maybeAxios ?? err;
|
|
103
|
+
const errors = data?.errors;
|
|
104
|
+
if (!errors || typeof errors !== 'object' || Array.isArray(errors))
|
|
105
|
+
return undefined;
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [key, raw] of Object.entries(errors)) {
|
|
108
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
109
|
+
const issues = entries.map(toFieldIssue).filter((i) => !!i);
|
|
110
|
+
if (issues.length)
|
|
111
|
+
out[key] = issues;
|
|
112
|
+
}
|
|
113
|
+
return Object.keys(out).length ? out : undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Localize a single `FieldIssue` to a human, Spanish-by-default string using the
|
|
117
|
+
* field `label`. A pre-localized `message` passes through verbatim. Otherwise the
|
|
118
|
+
* `code` is translated via `t('validation.'+code, { defaultValue, label, ...params })`
|
|
119
|
+
* so hosts can override the copy and `{{label}}`/param interpolation still works.
|
|
120
|
+
*/
|
|
121
|
+
export function localizeFieldIssue(issue, label, t) {
|
|
122
|
+
if (issue.message)
|
|
123
|
+
return issue.message;
|
|
124
|
+
const code = issue.code ?? '';
|
|
125
|
+
const defaultValue = VALIDATION_DEFAULTS[code] ?? VALIDATION_FALLBACK;
|
|
126
|
+
return t(`validation.${code}`, { defaultValue, label, ...(issue.params ?? {}) });
|
|
127
|
+
}
|
|
62
128
|
/** A dotted, space-free token (e.g. "pos.rate.created") — the shape of an i18n
|
|
63
129
|
* key, as opposed to human prose ("Record created successfully"). Used to
|
|
64
130
|
* decide whether a server-sent message is safe to translate or should be
|
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { extractFieldErrors, localizeFieldIssue, type FieldIssue } from '../server-error'
|
|
3
|
+
|
|
4
|
+
// A minimal i18next-like translator: honors defaultValue and does {{var}}
|
|
5
|
+
// interpolation, so the tests assert BOTH the resolved Spanish default and that
|
|
6
|
+
// the field label / code params are interpolated.
|
|
7
|
+
const t = (_key: string, opts?: { defaultValue?: string; [k: string]: unknown }) => {
|
|
8
|
+
let out = opts?.defaultValue ?? _key
|
|
9
|
+
if (opts) {
|
|
10
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
11
|
+
if (k === 'defaultValue') continue
|
|
12
|
+
out = out.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v))
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return out
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('extractFieldErrors', () => {
|
|
19
|
+
it('normalizes object entries {code,params} from an axios error', () => {
|
|
20
|
+
const err = {
|
|
21
|
+
response: {
|
|
22
|
+
data: {
|
|
23
|
+
success: false,
|
|
24
|
+
message: 'validation failed',
|
|
25
|
+
errors: { name: [{ code: 'required', params: {} }], sku: [{ code: 'duplicate' }] },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
expect(extractFieldErrors(err)).toEqual({
|
|
30
|
+
name: [{ code: 'required', params: {} }],
|
|
31
|
+
sku: [{ code: 'duplicate', params: undefined }],
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('normalizes string entries to {message}', () => {
|
|
36
|
+
const err = { errors: { name: ['El nombre es obligatorio'], sku: 'Duplicado' } }
|
|
37
|
+
expect(extractFieldErrors(err)).toEqual({
|
|
38
|
+
name: [{ message: 'El nombre es obligatorio' }],
|
|
39
|
+
sku: [{ message: 'Duplicado' }],
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('accepts a bare response body as well as an axios error', () => {
|
|
44
|
+
const bare = { errors: { qty: [{ code: 'invalid_type', params: { expected: 'number' } }] } }
|
|
45
|
+
expect(extractFieldErrors(bare)).toEqual({
|
|
46
|
+
qty: [{ code: 'invalid_type', params: { expected: 'number' } }],
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('carries code params through (allowed / ref)', () => {
|
|
51
|
+
const err = {
|
|
52
|
+
errors: {
|
|
53
|
+
status: [{ code: 'invalid_option', params: { allowed: ['a', 'b'] } }],
|
|
54
|
+
owner: [{ code: 'not_found', params: { ref: 'users' } }],
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
expect(extractFieldErrors(err)).toEqual({
|
|
58
|
+
status: [{ code: 'invalid_option', params: { allowed: ['a', 'b'] } }],
|
|
59
|
+
owner: [{ code: 'not_found', params: { ref: 'users' } }],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns undefined when there is no usable errors map', () => {
|
|
64
|
+
expect(extractFieldErrors(undefined)).toBeUndefined()
|
|
65
|
+
expect(extractFieldErrors({ response: { data: { message: 'boom' } } })).toBeUndefined()
|
|
66
|
+
expect(extractFieldErrors({ errors: 'oops' })).toBeUndefined() // string, not a map
|
|
67
|
+
expect(extractFieldErrors({ errors: ['a', 'b'] })).toBeUndefined() // array, not a map
|
|
68
|
+
expect(extractFieldErrors({ errors: {} })).toBeUndefined() // empty
|
|
69
|
+
expect(extractFieldErrors({ errors: { x: [null, ''] } })).toBeUndefined() // nothing normalizes
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('localizeFieldIssue', () => {
|
|
74
|
+
it('required → interpolates label', () => {
|
|
75
|
+
expect(localizeFieldIssue({ code: 'required' }, 'Nombre', t)).toBe('El campo Nombre es obligatorio')
|
|
76
|
+
})
|
|
77
|
+
it('invalid_option', () => {
|
|
78
|
+
expect(localizeFieldIssue({ code: 'invalid_option', params: { allowed: ['a'] } }, 'Estado', t)).toBe(
|
|
79
|
+
'El valor de Estado no es válido',
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
it('not_found', () => {
|
|
83
|
+
expect(localizeFieldIssue({ code: 'not_found', params: { ref: 'users' } }, 'Responsable', t)).toBe(
|
|
84
|
+
'El Responsable seleccionado no existe',
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
it('duplicate', () => {
|
|
88
|
+
expect(localizeFieldIssue({ code: 'duplicate' }, 'SKU', t)).toBe('Ya existe un registro con ese SKU')
|
|
89
|
+
})
|
|
90
|
+
it('invalid_type', () => {
|
|
91
|
+
expect(localizeFieldIssue({ code: 'invalid_type', params: { expected: 'number' } }, 'Cantidad', t)).toBe(
|
|
92
|
+
'El campo Cantidad tiene un formato inválido',
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
it('unknown code → generic fallback with label', () => {
|
|
96
|
+
expect(localizeFieldIssue({ code: 'weird_code' }, 'Campo', t)).toBe('Campo: valor inválido')
|
|
97
|
+
})
|
|
98
|
+
it('message passthrough (pre-localized) ignores code/label', () => {
|
|
99
|
+
const issue: FieldIssue = { message: 'Texto ya localizado' }
|
|
100
|
+
expect(localizeFieldIssue(issue, 'Ignorado', t)).toBe('Texto ya localizado')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { normalizeRefFieldsForSubmit } from '../dialogs/normalize-submit'
|
|
3
|
+
|
|
4
|
+
const NIL = '00000000-0000-0000-0000-000000000000'
|
|
5
|
+
|
|
6
|
+
// A minimal field-metadata shape — only the keys the normalizer reads.
|
|
7
|
+
const fields: any[] = [
|
|
8
|
+
{ key: 'category_id', ref: 'categories' }, // reference (getFieldRef)
|
|
9
|
+
{ key: 'brand_id', type: 'dynamic_select' }, // reference by type
|
|
10
|
+
{ key: 'owner_id', type: 'search' }, // reference by type
|
|
11
|
+
{ key: 'name', type: 'text' }, // plain scalar
|
|
12
|
+
{ key: 'description', type: 'textarea' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
describe('normalizeRefFieldsForSubmit', () => {
|
|
16
|
+
it('nulls an empty-string reference so a nullable FK accepts it (no 23503)', () => {
|
|
17
|
+
const out = normalizeRefFieldsForSubmit(
|
|
18
|
+
{ category_id: '', name: 'Test' },
|
|
19
|
+
fields,
|
|
20
|
+
)
|
|
21
|
+
expect(out.category_id).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('nulls a nil-UUID reference value', () => {
|
|
25
|
+
const out = normalizeRefFieldsForSubmit({ brand_id: NIL }, fields)
|
|
26
|
+
expect(out.brand_id).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('nulls a nil-UUID on ANY field, reference or not', () => {
|
|
30
|
+
const out = normalizeRefFieldsForSubmit({ some_id: NIL }, fields)
|
|
31
|
+
expect(out.some_id).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('leaves a populated reference untouched', () => {
|
|
35
|
+
const id = '11111111-1111-1111-1111-111111111111'
|
|
36
|
+
const out = normalizeRefFieldsForSubmit({ category_id: id }, fields)
|
|
37
|
+
expect(out.category_id).toBe(id)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does NOT null an empty plain text field (empty string is legitimate)', () => {
|
|
41
|
+
const out = normalizeRefFieldsForSubmit(
|
|
42
|
+
{ name: '', description: '' },
|
|
43
|
+
fields,
|
|
44
|
+
)
|
|
45
|
+
expect(out.name).toBe('')
|
|
46
|
+
expect(out.description).toBe('')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('nulls empty search / dynamic_select references', () => {
|
|
50
|
+
const out = normalizeRefFieldsForSubmit(
|
|
51
|
+
{ brand_id: '', owner_id: '' },
|
|
52
|
+
fields,
|
|
53
|
+
)
|
|
54
|
+
expect(out.brand_id).toBeNull()
|
|
55
|
+
expect(out.owner_id).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not mutate the input object', () => {
|
|
59
|
+
const input = { category_id: '' }
|
|
60
|
+
normalizeRefFieldsForSubmit(input, fields)
|
|
61
|
+
expect(input.category_id).toBe('')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -35,7 +35,8 @@ import {
|
|
|
35
35
|
} from '@asteby/metacore-ui/primitives'
|
|
36
36
|
import { Loader2 } from 'lucide-react'
|
|
37
37
|
import { toast } from 'sonner'
|
|
38
|
-
import { toastServerError, toastServerSuccess } from './server-error'
|
|
38
|
+
import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldIssue } from './server-error'
|
|
39
|
+
import type { Translate } from './server-error'
|
|
39
40
|
import { useApi } from './api-context'
|
|
40
41
|
import { DynamicIcon } from './dynamic-icon'
|
|
41
42
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
@@ -403,6 +404,44 @@ function RecordPreview({ model, record }: { model: string; record: any }) {
|
|
|
403
404
|
)
|
|
404
405
|
}
|
|
405
406
|
|
|
407
|
+
/** Humanize a field key for a label fallback ("unit_price" → "Unit Price"). */
|
|
408
|
+
function humanizeKey(k: string): string {
|
|
409
|
+
return k.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Localize a failed action submit's per-field `errors` (422 map or `{errors}`
|
|
413
|
+
* body) into `{ [fieldKey]: localizedMessage }`, using each field's label.
|
|
414
|
+
* Returns undefined when there is no usable per-field map. */
|
|
415
|
+
function localizeActionFieldErrors(
|
|
416
|
+
err: unknown,
|
|
417
|
+
fields: readonly ActionFieldDef[] | undefined,
|
|
418
|
+
t: Translate,
|
|
419
|
+
): Record<string, string> | undefined {
|
|
420
|
+
const map = extractFieldErrors(err)
|
|
421
|
+
if (!map) return undefined
|
|
422
|
+
const labelFor = (k: string) => {
|
|
423
|
+
const f = (fields ?? []).find(x => x.key === k)
|
|
424
|
+
return f?.label ? t(f.label, { defaultValue: f.label }) : humanizeKey(k)
|
|
425
|
+
}
|
|
426
|
+
const out: Record<string, string> = {}
|
|
427
|
+
for (const [k, issues] of Object.entries(map)) out[k] = localizeFieldIssue(issues[0], labelFor(k), t)
|
|
428
|
+
return out
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Toast a failed action: a summary + localized per-field lines when the server
|
|
432
|
+
* returned a per-field `errors` map, else the standard cause-carrying toast.
|
|
433
|
+
* Used where inline rendering isn't wired (confirm / multi-step wizard). */
|
|
434
|
+
function toastActionError(err: unknown, fields: readonly ActionFieldDef[] | undefined, t: Translate): void {
|
|
435
|
+
const localized = localizeActionFieldErrors(err, fields, t)
|
|
436
|
+
if (localized) {
|
|
437
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), {
|
|
438
|
+
description: Object.values(localized).join('\n'),
|
|
439
|
+
})
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
toastServerError(err, { t })
|
|
443
|
+
}
|
|
444
|
+
|
|
406
445
|
function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
|
|
407
446
|
const { t } = useTranslation()
|
|
408
447
|
const api = useApi()
|
|
@@ -422,10 +461,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
422
461
|
onOpenChange(false)
|
|
423
462
|
onSuccess()
|
|
424
463
|
} else {
|
|
425
|
-
|
|
464
|
+
toastActionError({ response: { data: res.data } }, action.fields, t)
|
|
426
465
|
}
|
|
427
466
|
} catch (err: any) {
|
|
428
|
-
|
|
467
|
+
toastActionError(err, action.fields, t)
|
|
429
468
|
} finally {
|
|
430
469
|
setExecuting(false)
|
|
431
470
|
}
|
|
@@ -469,6 +508,8 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
469
508
|
const api = useApi()
|
|
470
509
|
const [formData, setFormData] = useState<Record<string, any>>({})
|
|
471
510
|
const [executing, setExecuting] = useState(false)
|
|
511
|
+
// Per-field validation errors (localized), shown inline under each input.
|
|
512
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
|
472
513
|
// Related records to surface BELOW the form, as read-only context for the
|
|
473
514
|
// record being acted on — e.g. the reception history of a transfer while
|
|
474
515
|
// receiving against it. Sourced from the model's metadata.relations (the
|
|
@@ -512,29 +553,57 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
512
553
|
defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
|
|
513
554
|
}
|
|
514
555
|
setFormData(defaults)
|
|
556
|
+
setFieldErrors({})
|
|
515
557
|
}
|
|
516
558
|
}, [open, action.fields, record])
|
|
517
559
|
|
|
518
|
-
const updateField = (key: string, value: any) =>
|
|
560
|
+
const updateField = (key: string, value: any) => {
|
|
561
|
+
setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
|
|
562
|
+
setFieldErrors(prev => {
|
|
563
|
+
if (!prev[key]) return prev
|
|
564
|
+
const next = { ...prev }
|
|
565
|
+
delete next[key]
|
|
566
|
+
return next
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const handleActionError = (err: unknown) => {
|
|
571
|
+
const localized = localizeActionFieldErrors(err, action.fields, t)
|
|
572
|
+
if (localized) {
|
|
573
|
+
setFieldErrors(localized)
|
|
574
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
toastServerError(err, { t })
|
|
578
|
+
}
|
|
519
579
|
|
|
520
580
|
const execute = async () => {
|
|
521
581
|
if (action.fields) {
|
|
582
|
+
// Client-side required check → mark ALL missing fields inline.
|
|
583
|
+
const missing: Record<string, string> = {}
|
|
522
584
|
for (const field of action.fields) {
|
|
523
585
|
if (!field.required) continue
|
|
524
586
|
if (isLineItemsField(field)) {
|
|
525
587
|
const rows = formData[field.key]
|
|
526
588
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
527
|
-
|
|
528
|
-
|
|
589
|
+
missing[field.key] = t('validation.line_items_required', {
|
|
590
|
+
defaultValue: '{{label}} requiere al menos un renglón',
|
|
591
|
+
label: tl(field.label),
|
|
592
|
+
})
|
|
529
593
|
}
|
|
530
594
|
continue
|
|
531
595
|
}
|
|
532
596
|
if (!formData[field.key] && formData[field.key] !== false) {
|
|
533
|
-
|
|
534
|
-
return
|
|
597
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, tl(field.label), t)
|
|
535
598
|
}
|
|
536
599
|
}
|
|
600
|
+
if (Object.keys(missing).length) {
|
|
601
|
+
setFieldErrors(missing)
|
|
602
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
603
|
+
return
|
|
604
|
+
}
|
|
537
605
|
}
|
|
606
|
+
setFieldErrors({})
|
|
538
607
|
setExecuting(true)
|
|
539
608
|
try {
|
|
540
609
|
const url = buildActionUrl(endpoint, model, record.id, action.key)
|
|
@@ -544,10 +613,10 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
544
613
|
onOpenChange(false)
|
|
545
614
|
onSuccess()
|
|
546
615
|
} else {
|
|
547
|
-
|
|
616
|
+
handleActionError({ response: { data: res.data } })
|
|
548
617
|
}
|
|
549
618
|
} catch (err: any) {
|
|
550
|
-
|
|
619
|
+
handleActionError(err)
|
|
551
620
|
} finally {
|
|
552
621
|
setExecuting(false)
|
|
553
622
|
}
|
|
@@ -609,6 +678,9 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
609
678
|
{tl(field.label)}
|
|
610
679
|
</FieldLabel>
|
|
611
680
|
{renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
|
|
681
|
+
{fieldErrors[field.key] && (
|
|
682
|
+
<p className="text-destructive text-xs mt-1">{fieldErrors[field.key]}</p>
|
|
683
|
+
)}
|
|
612
684
|
</FieldCell>
|
|
613
685
|
)
|
|
614
686
|
})}
|
|
@@ -749,10 +821,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
749
821
|
onOpenChange(false)
|
|
750
822
|
onSuccess()
|
|
751
823
|
} else {
|
|
752
|
-
|
|
824
|
+
toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t)
|
|
753
825
|
}
|
|
754
826
|
} catch (err: any) {
|
|
755
|
-
|
|
827
|
+
toastActionError(err, steps.flatMap(s => s.fields ?? []), t)
|
|
756
828
|
} finally {
|
|
757
829
|
setExecuting(false)
|
|
758
830
|
}
|
|
@@ -48,12 +48,14 @@ import { format, parseISO } from 'date-fns'
|
|
|
48
48
|
import { es } from 'date-fns/locale'
|
|
49
49
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react'
|
|
50
50
|
import { useApi } from '../api-context'
|
|
51
|
+
import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
|
|
51
52
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
|
|
52
53
|
import { DynamicRelations } from '../dynamic-relations'
|
|
53
54
|
import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
|
|
54
55
|
import { getFieldRef } from '../dynamic-form-schema'
|
|
55
56
|
import { FieldCell } from '../field-grid'
|
|
56
57
|
import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
58
|
+
import { normalizeRefFieldsForSubmit } from './normalize-submit'
|
|
57
59
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
|
|
58
60
|
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
59
61
|
import { formatDateCell } from '../dynamic-columns'
|
|
@@ -495,6 +497,10 @@ export function DynamicRecordDialog({
|
|
|
495
497
|
const [relations, setRelations] = useState<RelationMeta[]>([])
|
|
496
498
|
const [record, setRecord] = useState<any | null>(null)
|
|
497
499
|
const [formValues, setFormValues] = useState<Record<string, any>>({})
|
|
500
|
+
// Per-field validation errors (localized strings), keyed by field.key. Shown
|
|
501
|
+
// inline under each input; populated from a 422 `errors` map or the client
|
|
502
|
+
// required-field check, cleared per-field on change and wholesale on reopen.
|
|
503
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
|
498
504
|
const [loading, setLoading] = useState(false)
|
|
499
505
|
const [saving, setSaving] = useState(false)
|
|
500
506
|
const [deleting, setDeleting] = useState(false)
|
|
@@ -509,6 +515,9 @@ export function DynamicRecordDialog({
|
|
|
509
515
|
if (!open) return
|
|
510
516
|
if (!isCreate && !recordId) return
|
|
511
517
|
|
|
518
|
+
// Fresh open → drop any validation errors from a prior submit.
|
|
519
|
+
setFieldErrors({})
|
|
520
|
+
|
|
512
521
|
let cancelled = false
|
|
513
522
|
|
|
514
523
|
// Seed instantly from the row the table already has so view/edit render
|
|
@@ -669,23 +678,63 @@ export function DynamicRecordDialog({
|
|
|
669
678
|
onChange?.()
|
|
670
679
|
}, [api, endpoint, model, recordId, isCreate, modalMeta, onChange])
|
|
671
680
|
|
|
681
|
+
// The human label for a field key, for localizing validation errors. Falls
|
|
682
|
+
// back to a humanized key when the field is unknown (e.g. a server-side key
|
|
683
|
+
// with no matching form field).
|
|
684
|
+
const labelForKey = (key: string): string => {
|
|
685
|
+
const f = (modalMeta?.fields ?? []).find(x => x.key === key)
|
|
686
|
+
if (f?.label) return f.label
|
|
687
|
+
return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into
|
|
691
|
+
// inline field errors + a summary toast. When there is no field map, fall
|
|
692
|
+
// back to the existing single cause-carrying toast.
|
|
693
|
+
const handleSubmitError = (err: unknown) => {
|
|
694
|
+
const map = extractFieldErrors(err)
|
|
695
|
+
if (map) {
|
|
696
|
+
const next: Record<string, string> = {}
|
|
697
|
+
for (const [key, issues] of Object.entries(map)) {
|
|
698
|
+
next[key] = localizeFieldIssue(issues[0], labelForKey(key), t)
|
|
699
|
+
}
|
|
700
|
+
setFieldErrors(next)
|
|
701
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
|
|
705
|
+
}
|
|
706
|
+
|
|
672
707
|
const handleSubmit = async (e?: React.FormEvent) => {
|
|
673
708
|
e?.preventDefault()
|
|
674
709
|
if (!modalMeta) return
|
|
675
710
|
|
|
676
711
|
if (isEditable) {
|
|
712
|
+
// Collect ALL missing required fields (not just the first) and mark
|
|
713
|
+
// each inline instead of a single toast.
|
|
714
|
+
const missing: Record<string, string> = {}
|
|
677
715
|
for (const field of modalMeta.fields ?? []) {
|
|
678
716
|
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
679
|
-
|
|
680
|
-
return
|
|
717
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
|
|
681
718
|
}
|
|
682
719
|
}
|
|
720
|
+
if (Object.keys(missing).length) {
|
|
721
|
+
setFieldErrors(missing)
|
|
722
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
723
|
+
return
|
|
724
|
+
}
|
|
683
725
|
}
|
|
684
726
|
|
|
727
|
+
// Required check passed → clear any prior validation errors.
|
|
728
|
+
setFieldErrors({})
|
|
729
|
+
|
|
730
|
+
// Empty reference pickers → null (not "" / nil-UUID) so nullable FK
|
|
731
|
+
// columns accept them instead of raising a 23503 FK violation.
|
|
732
|
+
const payload = normalizeRefFieldsForSubmit(formValues, modalMeta.fields)
|
|
733
|
+
|
|
685
734
|
setSaving(true)
|
|
686
735
|
try {
|
|
687
736
|
if (isCreate && onCreate) {
|
|
688
|
-
const created = await onCreate(
|
|
737
|
+
const created = await onCreate(payload)
|
|
689
738
|
toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
|
|
690
739
|
onSaved?.(created ?? undefined)
|
|
691
740
|
onOpenChange(false)
|
|
@@ -693,7 +742,7 @@ export function DynamicRecordDialog({
|
|
|
693
742
|
}
|
|
694
743
|
|
|
695
744
|
if (!isCreate && recordId && onUpdate) {
|
|
696
|
-
const updated = await onUpdate(String(recordId),
|
|
745
|
+
const updated = await onUpdate(String(recordId), payload)
|
|
697
746
|
toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' }))
|
|
698
747
|
onSaved?.(updated ?? undefined)
|
|
699
748
|
onOpenChange(false)
|
|
@@ -703,12 +752,12 @@ export function DynamicRecordDialog({
|
|
|
703
752
|
let res
|
|
704
753
|
if (isCreate) {
|
|
705
754
|
const createEndpoint = endpoint || `/dynamic/${model}`
|
|
706
|
-
res = await api.post(createEndpoint,
|
|
755
|
+
res = await api.post(createEndpoint, payload)
|
|
707
756
|
} else {
|
|
708
757
|
const updateEndpoint = endpoint
|
|
709
758
|
? `${endpoint}/${recordId}`
|
|
710
759
|
: `/dynamic/${model}/${recordId}`
|
|
711
|
-
res = await api.put(updateEndpoint,
|
|
760
|
+
res = await api.put(updateEndpoint, payload)
|
|
712
761
|
}
|
|
713
762
|
|
|
714
763
|
if (res.data?.success !== false) {
|
|
@@ -725,10 +774,13 @@ export function DynamicRecordDialog({
|
|
|
725
774
|
onSaved?.(res.data?.data ?? res.data ?? undefined)
|
|
726
775
|
onOpenChange(false)
|
|
727
776
|
} else {
|
|
728
|
-
|
|
777
|
+
// Surface the server's real cause (`details`) as the toast
|
|
778
|
+
// description, not just the generic headline. `res.data` is the
|
|
779
|
+
// `{ success:false, message, details }` envelope.
|
|
780
|
+
handleSubmitError(res.data)
|
|
729
781
|
}
|
|
730
782
|
} catch (err: any) {
|
|
731
|
-
|
|
783
|
+
handleSubmitError(err)
|
|
732
784
|
} finally {
|
|
733
785
|
setSaving(false)
|
|
734
786
|
}
|
|
@@ -742,7 +794,7 @@ export function DynamicRecordDialog({
|
|
|
742
794
|
onOpenChange(false)
|
|
743
795
|
} catch (err: any) {
|
|
744
796
|
console.error('[DynamicRecordDialog] delete error:', err)
|
|
745
|
-
|
|
797
|
+
toastServerError(err, { t, fallback: t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }) })
|
|
746
798
|
} finally {
|
|
747
799
|
setDeleting(false)
|
|
748
800
|
}
|
|
@@ -789,9 +841,17 @@ export function DynamicRecordDialog({
|
|
|
789
841
|
record={record}
|
|
790
842
|
value={formValues[field.key] ?? ''}
|
|
791
843
|
mode={mode}
|
|
792
|
-
|
|
844
|
+
error={fieldErrors[field.key]}
|
|
845
|
+
onChange={val => {
|
|
793
846
|
setFormValues((prev: Record<string, any>) => ({ ...prev, [field.key]: val }))
|
|
794
|
-
|
|
847
|
+
// Clear this field's error as soon as the user edits it.
|
|
848
|
+
setFieldErrors(prev => {
|
|
849
|
+
if (!prev[field.key]) return prev
|
|
850
|
+
const next = { ...prev }
|
|
851
|
+
delete next[field.key]
|
|
852
|
+
return next
|
|
853
|
+
})
|
|
854
|
+
}}
|
|
795
855
|
/>
|
|
796
856
|
</FieldCell>
|
|
797
857
|
)
|
|
@@ -901,9 +961,11 @@ interface FieldRowProps {
|
|
|
901
961
|
value: any
|
|
902
962
|
mode: 'view' | 'edit' | 'create'
|
|
903
963
|
onChange: (val: any) => void
|
|
964
|
+
/** Localized validation error for this field, shown in red under the input. */
|
|
965
|
+
error?: string
|
|
904
966
|
}
|
|
905
967
|
|
|
906
|
-
function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
968
|
+
function FieldRow({ field, record, value, mode, onChange, error }: FieldRowProps) {
|
|
907
969
|
// A `readonly` field is server/system-generated (e.g. the GitHub addon's
|
|
908
970
|
// `number`/`github_url`, filled by the API after the outbound create). On
|
|
909
971
|
// CREATE it is excluded from the form entirely (see `visibleFields`); on EDIT
|
|
@@ -928,6 +990,10 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
|
928
990
|
) : (
|
|
929
991
|
<EditField field={field} value={value} onChange={onChange} record={record} />
|
|
930
992
|
)}
|
|
993
|
+
|
|
994
|
+
{error && mode !== 'view' && (
|
|
995
|
+
<p className="text-destructive text-xs mt-1">{error}</p>
|
|
996
|
+
)}
|
|
931
997
|
</div>
|
|
932
998
|
)
|
|
933
999
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Pure submit-payload normalization for dynamic record forms. Kept in its own
|
|
2
|
+
// module (no React / UI imports) so it is unit-testable in isolation and
|
|
3
|
+
// reusable by any caller that builds a create/update payload.
|
|
4
|
+
import { isNilUuid } from '../nil-uuid'
|
|
5
|
+
import { getFieldRef } from '../dynamic-form-schema'
|
|
6
|
+
import type { ActionFieldDef } from '../types'
|
|
7
|
+
|
|
8
|
+
/** Minimal field-metadata shape the normalizer reads. Kept permissive so any
|
|
9
|
+
* caller's field type (e.g. the dialog's `FieldDef[]`) is accepted. */
|
|
10
|
+
type RefFieldMeta = Record<string, any>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalize a submit payload so that EMPTY reference fields go to the server as
|
|
14
|
+
* `null` rather than as `""` or the nil UUID ("00000000-…"). A dynamic FK column
|
|
15
|
+
* (e.g. products.category_id, a nullable `ref`) rejects a non-null value that
|
|
16
|
+
* points at no row: sending `""`/nil-UUID triggers
|
|
17
|
+
* insert ... violates foreign key constraint "fk_products_category" (23503)
|
|
18
|
+
* even though the user left the picker empty. This is generic — it keys off the
|
|
19
|
+
* field metadata (`getFieldRef` / dynamic_select / search), so every addon's
|
|
20
|
+
* optional relations benefit, not just products. Non-reference fields are left
|
|
21
|
+
* untouched (an empty text field is a legitimate `""`). Does not mutate `values`.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeRefFieldsForSubmit(
|
|
24
|
+
values: Record<string, any>,
|
|
25
|
+
fields: RefFieldMeta[] | undefined,
|
|
26
|
+
): Record<string, any> {
|
|
27
|
+
const refKeys = new Set(
|
|
28
|
+
(fields ?? [])
|
|
29
|
+
.filter(
|
|
30
|
+
(f) =>
|
|
31
|
+
!!getFieldRef(f as unknown as ActionFieldDef) ||
|
|
32
|
+
f.type === 'dynamic_select' ||
|
|
33
|
+
f.widget === 'dynamic_select' ||
|
|
34
|
+
f.type === 'search',
|
|
35
|
+
)
|
|
36
|
+
.map((f) => f.key),
|
|
37
|
+
)
|
|
38
|
+
const out: Record<string, any> = { ...values }
|
|
39
|
+
for (const [k, v] of Object.entries(out)) {
|
|
40
|
+
// A nil UUID is never a real target row — null it out on any field.
|
|
41
|
+
if (isNilUuid(v)) {
|
|
42
|
+
out[k] = null
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
// An empty reference picker means "no relation" → null (not "").
|
|
46
|
+
if (refKeys.has(k) && (v === '' || v == null)) out[k] = null
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|