@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,275 @@
1
+ // Client-side twin of kernel `validate`: Laravel / go-playground rule strings
2
+ // plus the structured {regex,min,max,custom} object. Produces the same
3
+ // {code, params} issues the 422 envelope carries, so UI and server paint
4
+ // the same keys.
5
+ import type { ActionFieldDef, FieldValidation } from './types'
6
+ import type { FieldIssue } from './server-error'
7
+
8
+ const NIL_UUID = '00000000-0000-0000-0000-000000000000'
9
+ const NUMERIC_TYPES = new Set(['int', 'integer', 'bigint', 'decimal', 'numeric', 'number', 'float', 'double'])
10
+
11
+ export interface ValidationSpec {
12
+ required?: boolean
13
+ type?: string
14
+ regex?: string
15
+ min?: number
16
+ max?: number
17
+ custom?: string
18
+ options?: string[]
19
+ }
20
+
21
+ const RULE_NAMES = new Set([
22
+ 'required', 'nullable', 'sometimes', 'present',
23
+ 'min', 'max', 'regex', 'email', 'uuid', 'url',
24
+ 'numeric', 'integer', 'int', 'in',
25
+ ])
26
+
27
+ /** Parse `required|min:2|email` or `required,min=2,max=100` or a custom slug. */
28
+ export function parseRuleString(s: string): ValidationSpec {
29
+ const spec: ValidationSpec = {}
30
+ const trimmed = s.trim()
31
+ if (!trimmed) return spec
32
+ for (const part of splitRules(trimmed)) {
33
+ const { name, param } = splitNameParam(part)
34
+ switch (name.toLowerCase()) {
35
+ case 'required':
36
+ spec.required = true
37
+ break
38
+ case 'min': {
39
+ const n = Number(param)
40
+ if (!Number.isNaN(n)) spec.min = n
41
+ break
42
+ }
43
+ case 'max': {
44
+ const n = Number(param)
45
+ if (!Number.isNaN(n)) spec.max = n
46
+ break
47
+ }
48
+ case 'regex':
49
+ spec.regex = trimLaravelRegex(param)
50
+ break
51
+ case 'email':
52
+ case 'uuid':
53
+ case 'url':
54
+ case 'numeric':
55
+ case 'integer':
56
+ spec.custom = name.toLowerCase()
57
+ break
58
+ case 'int':
59
+ spec.custom = 'integer'
60
+ break
61
+ case 'in':
62
+ spec.options = param.split(',').map(x => x.trim()).filter(Boolean)
63
+ break
64
+ default:
65
+ if (!spec.custom) spec.custom = part
66
+ }
67
+ }
68
+ return spec
69
+ }
70
+
71
+ function splitRules(s: string): string[] {
72
+ if (s.includes('|')) return s.split('|').map(x => x.trim()).filter(Boolean)
73
+ const raw = s.split(',')
74
+ const out: string[] = []
75
+ for (const p of raw) {
76
+ const part = p.trim()
77
+ if (!part) continue
78
+ const { name } = splitNameParam(part)
79
+ if (!RULE_NAMES.has(name.toLowerCase()) && out.length) {
80
+ const prev = splitNameParam(out[out.length - 1]!).name
81
+ if (prev.toLowerCase() === 'in') {
82
+ out[out.length - 1] += `,${part}`
83
+ continue
84
+ }
85
+ }
86
+ out.push(part)
87
+ }
88
+ return out
89
+ }
90
+
91
+ function splitNameParam(p: string): { name: string; param: string } {
92
+ const colon = p.indexOf(':')
93
+ const eq = p.indexOf('=')
94
+ const i = colon >= 0 && (eq < 0 || colon < eq) ? colon : eq
95
+ if (i < 0) return { name: p.trim(), param: '' }
96
+ return { name: p.slice(0, i).trim(), param: p.slice(i + 1).trim() }
97
+ }
98
+
99
+ function trimLaravelRegex(p: string): string {
100
+ if (p.length >= 2 && p.startsWith('/') && p.lastIndexOf('/') > 0) {
101
+ return p.slice(1, p.lastIndexOf('/'))
102
+ }
103
+ return p
104
+ }
105
+
106
+ /** Normalize ActionFieldDef.validation (object, laravel string, or `rules`). */
107
+ export function fieldValidationOf(field: ActionFieldDef | Record<string, unknown>): FieldValidation {
108
+ const rec = field as Record<string, unknown>
109
+ const raw = rec.validation ?? rec.rules ?? rec.validation_rule
110
+ if (!raw) return {}
111
+ if (typeof raw === 'string') {
112
+ const s = parseRuleString(raw)
113
+ return { regex: s.regex, min: s.min, max: s.max, custom: s.custom }
114
+ }
115
+ if (typeof raw === 'object' && !Array.isArray(raw)) return raw as FieldValidation
116
+ return {}
117
+ }
118
+
119
+ function isEmpty(raw: unknown): boolean {
120
+ if (raw == null) return true
121
+ if (typeof raw === 'string') {
122
+ const t = raw.trim()
123
+ return t === '' || t === NIL_UUID
124
+ }
125
+ if (Array.isArray(raw)) return raw.length === 0
126
+ return false
127
+ }
128
+
129
+ function asString(raw: unknown): string {
130
+ if (raw == null) return ''
131
+ return String(raw).trim()
132
+ }
133
+
134
+ function isNumeric(raw: unknown): boolean {
135
+ if (typeof raw === 'number' && Number.isFinite(raw)) return true
136
+ if (typeof raw === 'string') return raw.trim() !== '' && !Number.isNaN(Number(raw))
137
+ return false
138
+ }
139
+
140
+ function numericValue(raw: unknown): number {
141
+ return typeof raw === 'number' ? raw : Number(asString(raw))
142
+ }
143
+
144
+ function lengthOf(raw: unknown): number {
145
+ if (typeof raw === 'string') return [...raw.trim()].length
146
+ if (Array.isArray(raw)) return raw.length
147
+ return [...asString(raw)].length
148
+ }
149
+
150
+ /** Evaluate spec against one value; collect every issue (not fail-fast). */
151
+ export function checkValue(value: unknown, spec: ValidationSpec): FieldIssue[] {
152
+ const empty = isEmpty(value)
153
+ if (spec.required && empty) return [{ code: 'required' }]
154
+ if (empty) return []
155
+ const out: FieldIssue[] = []
156
+ const typ = (spec.type ?? '').toLowerCase()
157
+ if (NUMERIC_TYPES.has(typ) && !isNumeric(value)) {
158
+ out.push({ code: 'invalid_type', params: { expected: 'number' } })
159
+ return out
160
+ }
161
+ if (spec.options && spec.options.length) {
162
+ const want = asString(value)
163
+ if (!spec.options.includes(want)) {
164
+ out.push({ code: 'invalid_option', params: { allowed: spec.options } })
165
+ }
166
+ }
167
+ if (spec.regex) {
168
+ try {
169
+ if (!new RegExp(spec.regex).test(asString(value))) {
170
+ out.push({ code: 'regex', params: { pattern: spec.regex } })
171
+ }
172
+ } catch { /* malformed — skip */ }
173
+ }
174
+ if (spec.min != null || spec.max != null) {
175
+ const isNum = NUMERIC_TYPES.has(typ)
176
+ const kind = isNum ? 'value' : 'length'
177
+ const n = isNum ? numericValue(value) : lengthOf(value)
178
+ if (spec.min != null && n < spec.min) out.push({ code: 'min', params: { min: spec.min, kind } })
179
+ if (spec.max != null && n > spec.max) out.push({ code: 'max', params: { max: spec.max, kind } })
180
+ }
181
+ if (spec.custom) {
182
+ const custom = checkBuiltin(spec.custom, value)
183
+ if (custom) out.push(custom)
184
+ }
185
+ return out
186
+ }
187
+
188
+ function checkBuiltin(slug: string, value: unknown): FieldIssue | undefined {
189
+ const s = asString(value)
190
+ switch (slug) {
191
+ case 'email':
192
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) ? undefined : { code: 'email' }
193
+ case 'uuid':
194
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(s)
195
+ ? undefined : { code: 'uuid' }
196
+ case 'url':
197
+ try { const u = new URL(s); return u.protocol && u.host ? undefined : { code: 'url' } }
198
+ catch { return { code: 'url' } }
199
+ case 'numeric':
200
+ return isNumeric(value) ? undefined : { code: 'numeric', params: { expected: 'number' } }
201
+ case 'integer':
202
+ case 'int':
203
+ return /^-?\d+$/.test(s) || (typeof value === 'number' && Number.isInteger(value))
204
+ ? undefined : { code: 'integer', params: { expected: 'integer' } }
205
+ default:
206
+ return undefined
207
+ }
208
+ }
209
+
210
+ function specFromField(field: ActionFieldDef): ValidationSpec {
211
+ const v = fieldValidationOf(field)
212
+ const spec: ValidationSpec = {
213
+ required: !!field.required,
214
+ type: field.type,
215
+ regex: v.regex,
216
+ min: v.min,
217
+ max: v.max,
218
+ custom: v.custom,
219
+ }
220
+ if (field.options?.length) spec.options = field.options.map(o => String(o.value))
221
+ return spec
222
+ }
223
+
224
+ function isLineItems(field: ActionFieldDef): boolean {
225
+ const raw = field.itemFields ?? (field as { item_fields?: ActionFieldDef[] }).item_fields
226
+ return Array.isArray(raw) && raw.length > 0
227
+ }
228
+
229
+ function itemFieldsOf(field: ActionFieldDef): ActionFieldDef[] {
230
+ const raw = field.itemFields ?? (field as { item_fields?: ActionFieldDef[] }).item_fields
231
+ return Array.isArray(raw) ? raw : []
232
+ }
233
+
234
+ /** Validate a form payload the way Laravel's Validator does: collect every
235
+ * field issue, dotted keys for line-items (`items.0.qty`). */
236
+ export function validateValues(
237
+ fields: readonly ActionFieldDef[],
238
+ values: Record<string, unknown>,
239
+ ): Record<string, FieldIssue[]> {
240
+ const bag: Record<string, FieldIssue[]> = {}
241
+ walk(fields, values ?? {}, '', bag)
242
+ return bag
243
+ }
244
+
245
+ function walk(
246
+ fields: readonly ActionFieldDef[],
247
+ values: Record<string, unknown>,
248
+ prefix: string,
249
+ bag: Record<string, FieldIssue[]>,
250
+ ): void {
251
+ for (const field of fields) {
252
+ const key = field.key
253
+ if (!key) continue
254
+ const path = prefix ? `${prefix}.${key}` : key
255
+ const raw = values[key]
256
+ if (isLineItems(field)) {
257
+ const rows = Array.isArray(raw) ? raw : []
258
+ if (field.required && rows.length === 0) {
259
+ bag[path] = [{ code: 'line_items_required' }]
260
+ continue
261
+ }
262
+ rows.forEach((row, i) => {
263
+ const obj = row && typeof row === 'object' ? (row as Record<string, unknown>) : {}
264
+ walk(itemFieldsOf(field), obj, `${path}.${i}`, bag)
265
+ })
266
+ continue
267
+ }
268
+ const issues = checkValue(raw, specFromField(field))
269
+ if (issues.length) bag[path] = issues
270
+ }
271
+ }
272
+
273
+ export function bagHasErrors(bag: Record<string, FieldIssue[]>): boolean {
274
+ return Object.keys(bag).length > 0
275
+ }