@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
package/src/validator.ts
ADDED
|
@@ -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
|
+
}
|