@asteby/metacore-runtime-react 37.0.3 → 37.0.5

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.
@@ -204,6 +204,8 @@ export interface DynamicSelectFieldProps {
204
204
  * host's create modal renders them locked. Pairs with `createDefaults`.
205
205
  */
206
206
  createLockedFields?: string[]
207
+ /** Paint trigger with destructive border when validation failed. */
208
+ invalid?: boolean
207
209
  }
208
210
 
209
211
  export function DynamicSelectField({
@@ -219,6 +221,7 @@ export function DynamicSelectField({
219
221
  hideCreate = false,
220
222
  createDefaults,
221
223
  createLockedFields,
224
+ invalid = false,
222
225
  }: DynamicSelectFieldProps) {
223
226
  const { t } = useTranslation()
224
227
  const ph = (fallback: string) =>
@@ -398,7 +401,11 @@ export function DynamicSelectField({
398
401
  aria-expanded={open}
399
402
  id={field.key}
400
403
  disabled={blockedByDependency}
401
- className="min-w-0 flex-1 justify-between font-normal"
404
+ aria-invalid={invalid || undefined}
405
+ className={
406
+ 'min-w-0 flex-1 justify-between font-normal' +
407
+ (invalid ? ' border-destructive ring-1 ring-destructive/30' : '')
408
+ }
402
409
  data-empty={!value}
403
410
  data-depends-blocked={blockedByDependency ? '' : undefined}
404
411
  >
@@ -0,0 +1,140 @@
1
+ // Shared helpers for painting + toasting client/server field validation so
2
+ // action modals and DynamicRecordDialog stay in lockstep.
3
+ import type { ActionFieldDef } from './types'
4
+ import type { Translate } from './server-error'
5
+
6
+ function itemFieldsOf(field: ActionFieldDef): ActionFieldDef[] {
7
+ const raw = field.itemFields ?? (field as { item_fields?: ActionFieldDef[] }).item_fields
8
+ return Array.isArray(raw) ? raw : []
9
+ }
10
+
11
+ function translateLabel(raw: string | undefined, t?: Translate): string {
12
+ if (!raw) return ''
13
+ if (!t) return raw
14
+ return t(raw, { defaultValue: raw })
15
+ }
16
+
17
+ function humanizeToken(key: string): string {
18
+ return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
19
+ }
20
+
21
+ /**
22
+ * Resolve a validation path (`customer_id`, `lines.0.unit_price`) to a
23
+ * human label using the action/modal field schema. Nested line-item paths
24
+ * become "Renglones → Precio unitario (fila 1)".
25
+ */
26
+ export function labelForValidationPath(
27
+ path: string,
28
+ fields: readonly ActionFieldDef[] | undefined,
29
+ t?: Translate,
30
+ ): string {
31
+ if (!path) return ''
32
+ const parts = path.split('.')
33
+ if (!fields?.length) return humanizeToken(path)
34
+
35
+ let cursor: readonly ActionFieldDef[] | undefined = fields
36
+ const bits: string[] = []
37
+ let i = 0
38
+ while (i < parts.length && cursor) {
39
+ const seg = parts[i]!
40
+ if (/^\d+$/.test(seg)) {
41
+ const row = Number(seg) + 1
42
+ const nextKey = parts[i + 1]
43
+ if (nextKey && cursor) {
44
+ const col = cursor.find((f) => f.key === nextKey)
45
+ const colLabel = translateLabel(col?.label, t) || humanizeToken(nextKey)
46
+ bits.push(`${colLabel} (fila ${row})`)
47
+ i += 2
48
+ cursor = undefined
49
+ continue
50
+ }
51
+ bits.push(`fila ${row}`)
52
+ i += 1
53
+ continue
54
+ }
55
+ const field = cursor.find((f) => f.key === seg)
56
+ if (!field) {
57
+ bits.push(humanizeToken(parts.slice(i).join('.')))
58
+ break
59
+ }
60
+ bits.push(translateLabel(field.label, t) || humanizeToken(seg))
61
+ const nested = itemFieldsOf(field)
62
+ cursor = nested.length ? nested : undefined
63
+ i += 1
64
+ if (!cursor && i < parts.length) {
65
+ bits.push(humanizeToken(parts.slice(i).join('.')))
66
+ break
67
+ }
68
+ }
69
+ return bits.join(' → ')
70
+ }
71
+
72
+ /** Build `{path: label}` covering scalar fields and `parent.N.child` templates
73
+ * used by localizeFieldErrorMap (exact keys win when present). */
74
+ export function labelsForValidationFields(
75
+ fields: readonly ActionFieldDef[] | undefined,
76
+ t?: Translate,
77
+ ): Record<string, string> {
78
+ const out: Record<string, string> = {}
79
+ if (!fields) return out
80
+ for (const f of fields) {
81
+ if (!f.key) continue
82
+ out[f.key] = translateLabel(f.label, t) || humanizeToken(f.key)
83
+ for (const child of itemFieldsOf(f)) {
84
+ if (!child.key) continue
85
+ out[`${f.key}.${child.key}`] = translateLabel(child.label, t) || humanizeToken(child.key)
86
+ }
87
+ }
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * Toast description: one "Label: message" line per error. Re-resolves labels
93
+ * for dotted line-item paths so operators see e.g.
94
+ * "Precio unitario (fila 1): es obligatorio" instead of a bare headline.
95
+ */
96
+ export function formatFieldErrorsDescription(
97
+ errors: Record<string, string>,
98
+ fields?: readonly ActionFieldDef[],
99
+ t?: Translate,
100
+ ): string | undefined {
101
+ const entries = Object.entries(errors)
102
+ if (!entries.length) return undefined
103
+ return entries
104
+ .map(([path, msg]) => {
105
+ const label = labelForValidationPath(path, fields, t)
106
+ // localizeFieldIssue already prefixes "{{label}}: …" — avoid
107
+ // "Label: Label: msg" when the message already starts with the label.
108
+ if (label && msg.toLowerCase().startsWith(label.toLowerCase())) return msg
109
+ return label ? `${label}: ${msg}` : msg
110
+ })
111
+ .join(' · ')
112
+ }
113
+
114
+ /** Drop `key` and every `key.*` / `key.N.*` entry after the operator edits that field. */
115
+ export function clearFieldErrorTree(
116
+ prev: Record<string, string>,
117
+ key: string,
118
+ ): Record<string, string> {
119
+ if (!prev[key] && !Object.keys(prev).some((k) => k.startsWith(`${key}.`))) return prev
120
+ const next: Record<string, string> = {}
121
+ for (const [k, v] of Object.entries(prev)) {
122
+ if (k === key || k.startsWith(`${key}.`)) continue
123
+ next[k] = v
124
+ }
125
+ return next
126
+ }
127
+
128
+ /** Slice `parent.0.child` errors into `{ "0.child": msg }` for DynamicLineItems. */
129
+ export function lineItemErrorsFor(
130
+ parentKey: string,
131
+ errors: Record<string, string> | undefined,
132
+ ): Record<string, string> {
133
+ if (!errors) return {}
134
+ const prefix = `${parentKey}.`
135
+ const out: Record<string, string> = {}
136
+ for (const [k, v] of Object.entries(errors)) {
137
+ if (k.startsWith(prefix)) out[k.slice(prefix.length)] = v
138
+ }
139
+ return out
140
+ }
package/src/index.ts CHANGED
@@ -40,6 +40,13 @@ export {
40
40
  type ValidationSpec,
41
41
  } from './validator'
42
42
  export { VALIDATION_CATALOGS, validationCatalog, validationMessageKey } from './validation-catalog'
43
+ export {
44
+ labelForValidationPath,
45
+ labelsForValidationFields,
46
+ formatFieldErrorsDescription,
47
+ clearFieldErrorTree,
48
+ lineItemErrorsFor,
49
+ } from './field-validation-ui'
43
50
  export * from './dynamic-table'
44
51
  export {
45
52
  DynamicKanban,