@svadmin/lite 0.3.27 → 0.6.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/README.md +106 -1
- package/dist/compatibility.d.ts +23 -0
- package/dist/compatibility.js +95 -0
- package/dist/components/LiteForm.svelte +1 -1
- package/dist/components/LiteForm.svelte.d.ts +1 -1
- package/dist/components/LiteShowField.svelte +32 -22
- package/dist/components/LiteShowField.svelte.d.ts +3 -2
- package/dist/components/LiteTable.svelte +90 -37
- package/dist/components/LiteTable.svelte.d.ts +3 -2
- package/dist/components/compatibility/LiteCapabilityBoundary.svelte +51 -0
- package/dist/components/compatibility/LiteCapabilityBoundary.svelte.d.ts +13 -0
- package/dist/components/compatibility/LiteClipboardFallback.svelte +16 -0
- package/dist/components/compatibility/LiteClipboardFallback.svelte.d.ts +8 -0
- package/dist/components/compatibility/LiteComputeFallback.svelte +50 -0
- package/dist/components/compatibility/LiteComputeFallback.svelte.d.ts +14 -0
- package/dist/components/compatibility/LiteDirectoryUpload.svelte +44 -0
- package/dist/components/compatibility/LiteDirectoryUpload.svelte.d.ts +11 -0
- package/dist/components/compatibility/LiteOrderedList.svelte +47 -0
- package/dist/components/compatibility/LiteOrderedList.svelte.d.ts +14 -0
- package/dist/components/compatibility/LiteRealtimeStatus.svelte +43 -0
- package/dist/components/compatibility/LiteRealtimeStatus.svelte.d.ts +11 -0
- package/dist/components/compatibility/LiteVisualFallback.svelte +80 -0
- package/dist/components/compatibility/LiteVisualFallback.svelte.d.ts +18 -0
- package/dist/components/compatibility/index.d.ts +7 -0
- package/dist/components/compatibility/index.js +7 -0
- package/dist/components/pages/LiteCreatePage.svelte +15 -6
- package/dist/components/pages/LiteCreatePage.svelte.d.ts +1 -1
- package/dist/components/pages/LiteEditPage.svelte +18 -9
- package/dist/components/pages/LiteEditPage.svelte.d.ts +1 -1
- package/dist/components/pages/LiteListPage.svelte +86 -16
- package/dist/components/pages/LiteListPage.svelte.d.ts +4 -2
- package/dist/components/pages/LiteShowPage.svelte +19 -10
- package/dist/components/pages/LiteShowPage.svelte.d.ts +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +8 -3
- package/dist/lite.css +269 -0
- package/dist/schema-generator.d.ts +49 -13
- package/dist/schema-generator.js +381 -199
- package/dist/server-adapter.d.ts +25 -1
- package/dist/server-adapter.js +132 -33
- package/dist/value-normalization.d.ts +1 -0
- package/dist/value-normalization.js +18 -0
- package/package.json +10 -10
package/dist/schema-generator.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @svadmin/lite — Schema Generator
|
|
3
3
|
*
|
|
4
|
-
* Auto-generates
|
|
5
|
-
* Used by
|
|
4
|
+
* Auto-generates TypeBox schemas from @svadmin/core FieldDefinitions.
|
|
5
|
+
* Used by Lite server actions and high-speed JIT form validation.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import { parseExplicitBoolean } from
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { parseExplicitBoolean } from "./value-normalization";
|
|
9
9
|
function isNativeFile(value) {
|
|
10
|
-
return typeof File !==
|
|
10
|
+
return typeof File !== "undefined" && value instanceof File;
|
|
11
11
|
}
|
|
12
12
|
function isNonEmptyNativeFile(value) {
|
|
13
|
-
return isNativeFile(value) && value.size > 0 && value.name !==
|
|
13
|
+
return isNativeFile(value) && value.size > 0 && value.name !== "";
|
|
14
14
|
}
|
|
15
15
|
function normalizeSingleUpload(submittedUpload) {
|
|
16
|
-
if (submittedUpload === undefined || submittedUpload === null || submittedUpload ===
|
|
16
|
+
if (submittedUpload === undefined || submittedUpload === null || submittedUpload === "")
|
|
17
17
|
return undefined;
|
|
18
18
|
if (isNativeFile(submittedUpload) && !isNonEmptyNativeFile(submittedUpload))
|
|
19
19
|
return undefined;
|
|
@@ -22,7 +22,7 @@ function normalizeSingleUpload(submittedUpload) {
|
|
|
22
22
|
function hasRequiredValue(value) {
|
|
23
23
|
if (value === undefined || value === null)
|
|
24
24
|
return false;
|
|
25
|
-
if (typeof value ===
|
|
25
|
+
if (typeof value === "string")
|
|
26
26
|
return value.trim().length > 0;
|
|
27
27
|
if (Array.isArray(value))
|
|
28
28
|
return value.length > 0;
|
|
@@ -30,246 +30,428 @@ function hasRequiredValue(value) {
|
|
|
30
30
|
return isNonEmptyNativeFile(value);
|
|
31
31
|
return true;
|
|
32
32
|
}
|
|
33
|
-
function applyCustomValidation(schema, field) {
|
|
34
|
-
if (!field.validate)
|
|
35
|
-
return schema;
|
|
36
|
-
return schema.superRefine((parsedFieldValue, context) => {
|
|
37
|
-
if (parsedFieldValue === undefined || parsedFieldValue === null || parsedFieldValue === '')
|
|
38
|
-
return;
|
|
39
|
-
const message = field.validate?.(parsedFieldValue);
|
|
40
|
-
if (message)
|
|
41
|
-
context.addIssue({ code: 'custom', message });
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
function numberFieldToZod(field) {
|
|
45
|
-
const numberSchema = z.coerce.number({ message: `${field.label} must be a number` });
|
|
46
|
-
const targetSchema = field.required ? numberSchema : numberSchema.optional();
|
|
47
|
-
return z.preprocess((value) => {
|
|
48
|
-
if (value === undefined || value === null)
|
|
49
|
-
return undefined;
|
|
50
|
-
if (typeof value === 'string' && value.trim() === '')
|
|
51
|
-
return undefined;
|
|
52
|
-
return value;
|
|
53
|
-
}, targetSchema);
|
|
54
|
-
}
|
|
55
|
-
function booleanFieldToZod(field) {
|
|
56
|
-
const booleanSchema = z.boolean();
|
|
57
|
-
const targetSchema = field.required ? booleanSchema : booleanSchema.optional();
|
|
58
|
-
return z.preprocess((rawBoolean) => {
|
|
59
|
-
if (rawBoolean === undefined || rawBoolean === null || rawBoolean === '')
|
|
60
|
-
return undefined;
|
|
61
|
-
return parseExplicitBoolean(rawBoolean) ?? rawBoolean;
|
|
62
|
-
}, targetSchema);
|
|
63
|
-
}
|
|
64
33
|
function restoreOptionValue(field, submittedOption) {
|
|
65
34
|
const option = field.options?.find((candidate) => String(candidate.value) === String(submittedOption));
|
|
66
35
|
return option?.value ?? submittedOption;
|
|
67
36
|
}
|
|
68
|
-
function optionValueSchema(field) {
|
|
69
|
-
const options = field.options ?? [];
|
|
70
|
-
return z.union([z.string(), z.number()]).refine((parsedOption) => options.length === 0
|
|
71
|
-
|| options.some((option) => Object.is(option.value, parsedOption)), { message: `${field.label} must be one of the options` });
|
|
72
|
-
}
|
|
73
|
-
function singleOptionFieldToZod(field) {
|
|
74
|
-
return z.preprocess((submittedOption) => restoreOptionValue(field, submittedOption), optionValueSchema(field));
|
|
75
|
-
}
|
|
76
|
-
function multipleOptionFieldToZod(field) {
|
|
77
|
-
return z.preprocess((submittedOptions) => Array.isArray(submittedOptions)
|
|
78
|
-
? submittedOptions.map((submittedOption) => restoreOptionValue(field, submittedOption))
|
|
79
|
-
: submittedOptions, z.array(optionValueSchema(field)).default([]));
|
|
80
|
-
}
|
|
81
|
-
function singleFileFieldToZod(field, options) {
|
|
82
|
-
const fileSchema = z.custom(isNonEmptyNativeFile, {
|
|
83
|
-
message: `${field.label} must be a non-empty file`,
|
|
84
|
-
});
|
|
85
|
-
const referenceSchema = z.string().trim().min(1, `${field.label} must reference an existing file`);
|
|
86
|
-
const uploadSchema = options.allowReference ? z.union([fileSchema, referenceSchema]) : fileSchema;
|
|
87
|
-
const targetSchema = options.required ? uploadSchema : uploadSchema.optional();
|
|
88
|
-
return z.preprocess(normalizeSingleUpload, targetSchema);
|
|
89
|
-
}
|
|
90
|
-
function imageFieldSchemas(field) {
|
|
91
|
-
const imageSchema = z.union([
|
|
92
|
-
z.string().trim().min(1, `${field.label} must reference an image`),
|
|
93
|
-
z.custom(isNonEmptyNativeFile, {
|
|
94
|
-
message: `${field.label} must reference an image or contain a non-empty file`,
|
|
95
|
-
}),
|
|
96
|
-
]);
|
|
97
|
-
return {
|
|
98
|
-
required: z.preprocess(normalizeSingleUpload, imageSchema),
|
|
99
|
-
optional: z.preprocess(normalizeSingleUpload, imageSchema.optional()),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
37
|
function normalizeImageEntries(submittedImages) {
|
|
103
|
-
if (submittedImages === undefined || submittedImages === null || submittedImages ===
|
|
38
|
+
if (submittedImages === undefined || submittedImages === null || submittedImages === "")
|
|
104
39
|
return undefined;
|
|
105
40
|
const entries = Array.isArray(submittedImages) ? submittedImages : [submittedImages];
|
|
106
41
|
const normalized = entries.flatMap((entry) => {
|
|
107
|
-
if (typeof entry !==
|
|
42
|
+
if (typeof entry !== "string")
|
|
108
43
|
return [entry];
|
|
109
44
|
return entry.split(/[\r\n]+/u).map((reference) => reference.trim()).filter(Boolean);
|
|
110
45
|
}).filter((entry) => !isNativeFile(entry) || isNonEmptyNativeFile(entry));
|
|
111
46
|
return normalized.length > 0 ? normalized : undefined;
|
|
112
47
|
}
|
|
113
|
-
function
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
z.custom(isNonEmptyNativeFile, {
|
|
117
|
-
message: `${field.label} contains an empty or invalid file`,
|
|
118
|
-
}),
|
|
119
|
-
]);
|
|
120
|
-
const requiredImages = z.array(imageEntrySchema)
|
|
121
|
-
.min(1, `${field.label} must contain at least one image`);
|
|
122
|
-
return {
|
|
123
|
-
required: z.preprocess(normalizeImageEntries, requiredImages),
|
|
124
|
-
optional: z.preprocess(normalizeImageEntries, z.array(imageEntrySchema).optional()),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Convert a single FieldDefinition to its corresponding Zod type.
|
|
129
|
-
*/
|
|
130
|
-
function fieldToZod(field, mode, withinArray = false) {
|
|
131
|
-
let schema;
|
|
48
|
+
function coerceAndValidateField(field, value, mode, withinArray = false, path = [field.key]) {
|
|
49
|
+
const issues = [];
|
|
50
|
+
let coerced = value;
|
|
132
51
|
switch (field.type) {
|
|
133
|
-
case
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
52
|
+
case "number": {
|
|
53
|
+
if (coerced === undefined || coerced === null || coerced === "") {
|
|
54
|
+
coerced = undefined;
|
|
55
|
+
if (field.required) {
|
|
56
|
+
issues.push({ path, message: `${field.label} is required` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (typeof coerced === "string") {
|
|
60
|
+
const trimmed = coerced.trim();
|
|
61
|
+
if (trimmed === "") {
|
|
62
|
+
coerced = undefined;
|
|
63
|
+
if (field.required) {
|
|
64
|
+
issues.push({ path, message: `${field.label} is required` });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const num = Number(trimmed);
|
|
69
|
+
if (Number.isNaN(num)) {
|
|
70
|
+
issues.push({ path, message: `${field.label} must be a number` });
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
coerced = num;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (typeof coerced === "number") {
|
|
78
|
+
if (Number.isNaN(coerced)) {
|
|
79
|
+
issues.push({ path, message: `${field.label} must be a number` });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
issues.push({ path, message: `${field.label} must be a number` });
|
|
84
|
+
}
|
|
139
85
|
break;
|
|
140
|
-
|
|
141
|
-
|
|
86
|
+
}
|
|
87
|
+
case "boolean": {
|
|
88
|
+
if (coerced === undefined || coerced === null || coerced === "") {
|
|
89
|
+
coerced = field.required ? false : undefined;
|
|
90
|
+
if (field.required && coerced === undefined) {
|
|
91
|
+
issues.push({ path, message: `${field.label} is required` });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (typeof coerced === "boolean") {
|
|
95
|
+
// ok
|
|
96
|
+
}
|
|
97
|
+
else if (typeof coerced === "string") {
|
|
98
|
+
const parsed = parseExplicitBoolean(coerced);
|
|
99
|
+
if (parsed === undefined) {
|
|
100
|
+
issues.push({ path, message: `${field.label} must be a boolean` });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
coerced = parsed;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (typeof coerced === "number") {
|
|
107
|
+
if (coerced === 1)
|
|
108
|
+
coerced = true;
|
|
109
|
+
else if (coerced === 0)
|
|
110
|
+
coerced = false;
|
|
111
|
+
else
|
|
112
|
+
issues.push({ path, message: `${field.label} must be a boolean` });
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
issues.push({ path, message: `${field.label} must be a boolean` });
|
|
116
|
+
}
|
|
142
117
|
break;
|
|
143
|
-
|
|
144
|
-
|
|
118
|
+
}
|
|
119
|
+
case "email": {
|
|
120
|
+
if (typeof coerced === "string" && coerced.length > 0) {
|
|
121
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
122
|
+
if (!emailRegex.test(coerced)) {
|
|
123
|
+
issues.push({ path, message: `${field.label} must be a valid email` });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
145
126
|
break;
|
|
146
|
-
|
|
147
|
-
|
|
127
|
+
}
|
|
128
|
+
case "url": {
|
|
129
|
+
if (typeof coerced === "string" && coerced.length > 0) {
|
|
130
|
+
try {
|
|
131
|
+
new URL(coerced);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
issues.push({ path, message: `${field.label} must be a valid URL` });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
148
137
|
break;
|
|
149
|
-
|
|
150
|
-
|
|
138
|
+
}
|
|
139
|
+
case "date": {
|
|
140
|
+
if (typeof coerced === "string" && coerced.length > 0) {
|
|
141
|
+
if (Number.isNaN(Date.parse(coerced))) {
|
|
142
|
+
issues.push({ path, message: `${field.label} must be a valid date` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
151
145
|
break;
|
|
152
|
-
|
|
153
|
-
|
|
146
|
+
}
|
|
147
|
+
case "select": {
|
|
148
|
+
if (coerced !== undefined && coerced !== null && coerced !== "") {
|
|
149
|
+
coerced = restoreOptionValue(field, coerced);
|
|
150
|
+
const options = field.options ?? [];
|
|
151
|
+
if (options.length > 0 && !options.some((opt) => Object.is(opt.value, coerced))) {
|
|
152
|
+
issues.push({ path, message: `${field.label} must be one of the options` });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
154
155
|
break;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
}
|
|
157
|
+
case "multiselect": {
|
|
158
|
+
if (Array.isArray(coerced)) {
|
|
159
|
+
coerced = coerced.map((item) => restoreOptionValue(field, item));
|
|
160
|
+
const options = field.options ?? [];
|
|
161
|
+
if (options.length > 0) {
|
|
162
|
+
for (const item of coerced) {
|
|
163
|
+
if (!options.some((opt) => Object.is(opt.value, item))) {
|
|
164
|
+
issues.push({ path, message: `${field.label} must be one of the options` });
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else if (coerced === undefined || coerced === null || coerced === "") {
|
|
171
|
+
coerced = [];
|
|
159
172
|
}
|
|
160
|
-
|
|
161
|
-
return field.required
|
|
162
|
-
? arraySchema.min(1, `${field.label} must contain at least one item`)
|
|
163
|
-
: arraySchema.optional().or(z.literal(''));
|
|
173
|
+
break;
|
|
164
174
|
}
|
|
165
|
-
case
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
175
|
+
case "relation": {
|
|
176
|
+
if (field.options?.length && coerced !== undefined && coerced !== null && coerced !== "") {
|
|
177
|
+
coerced = restoreOptionValue(field, coerced);
|
|
178
|
+
if (!field.options.some((opt) => Object.is(opt.value, coerced))) {
|
|
179
|
+
issues.push({ path, message: `${field.label} must be one of the options` });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
170
182
|
break;
|
|
171
|
-
|
|
172
|
-
case
|
|
173
|
-
|
|
174
|
-
|
|
183
|
+
}
|
|
184
|
+
case "array": {
|
|
185
|
+
if (Array.isArray(coerced)) {
|
|
186
|
+
if (field.required && coerced.length === 0) {
|
|
187
|
+
issues.push({ path, message: `${field.label} must contain at least one item` });
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const processedRows = [];
|
|
191
|
+
for (let r = 0; r < coerced.length; r++) {
|
|
192
|
+
const row = coerced[r];
|
|
193
|
+
if (typeof row !== "object" || row === null) {
|
|
194
|
+
issues.push({ path: [...path, r], message: "Row must be an object" });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const rowObj = row;
|
|
198
|
+
const processedRow = {};
|
|
199
|
+
for (const subField of field.subFields ?? []) {
|
|
200
|
+
const subRes = coerceAndValidateField(subField, rowObj[subField.key], mode, true, [...path, r, subField.key]);
|
|
201
|
+
issues.push(...subRes.issues);
|
|
202
|
+
if (subRes.value !== undefined) {
|
|
203
|
+
processedRow[subField.key] = subRes.value;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
processedRows.push(processedRow);
|
|
207
|
+
}
|
|
208
|
+
coerced = processedRows;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (coerced === undefined || coerced === null || coerced === "") {
|
|
212
|
+
if (field.required) {
|
|
213
|
+
issues.push({ path, message: `${field.label} must contain at least one item` });
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
coerced = [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
issues.push({ path, message: `${field.label} must be an array` });
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "tags": {
|
|
225
|
+
if (typeof coerced === "string") {
|
|
226
|
+
coerced = coerced ? coerced.split(",").map((tag) => tag.trim()).filter(Boolean) : [];
|
|
227
|
+
}
|
|
228
|
+
else if (!Array.isArray(coerced)) {
|
|
229
|
+
coerced = [];
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "textarea":
|
|
234
|
+
case "richtext":
|
|
235
|
+
case "markdown": {
|
|
236
|
+
if (typeof coerced === "string" && coerced.length > 50000) {
|
|
237
|
+
issues.push({ path, message: `${field.label} is too long` });
|
|
238
|
+
}
|
|
175
239
|
break;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return value;
|
|
240
|
+
}
|
|
241
|
+
case "json": {
|
|
242
|
+
if (typeof coerced === "string") {
|
|
180
243
|
try {
|
|
181
|
-
|
|
244
|
+
coerced = JSON.parse(coerced);
|
|
182
245
|
}
|
|
183
246
|
catch {
|
|
184
|
-
|
|
185
|
-
|
|
247
|
+
issues.push({ path, message: `${field.label} must be valid JSON` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case "phone": {
|
|
253
|
+
if (typeof coerced === "string" && coerced.length > 0) {
|
|
254
|
+
if (!/^[+\d\s()-]*$/.test(coerced)) {
|
|
255
|
+
issues.push({ path, message: `${field.label} must be a valid phone number` });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "file": {
|
|
261
|
+
coerced = normalizeSingleUpload(coerced);
|
|
262
|
+
const isFile = isNonEmptyNativeFile(coerced);
|
|
263
|
+
const isStringRef = typeof coerced === "string" && coerced.trim().length > 0;
|
|
264
|
+
const isRequired = field.required === true && (mode === "create" || withinArray);
|
|
265
|
+
const allowRef = withinArray && mode === "edit";
|
|
266
|
+
if (isRequired) {
|
|
267
|
+
if (!isFile && (!allowRef || !isStringRef)) {
|
|
268
|
+
issues.push({ path, message: `${field.label} must be a non-empty file` });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else if (coerced !== undefined) {
|
|
272
|
+
if (!isFile && (!allowRef || !isStringRef)) {
|
|
273
|
+
issues.push({ path, message: `${field.label} must be a non-empty file` });
|
|
186
274
|
}
|
|
187
|
-
}
|
|
275
|
+
}
|
|
188
276
|
break;
|
|
189
|
-
|
|
190
|
-
|
|
277
|
+
}
|
|
278
|
+
case "image": {
|
|
279
|
+
coerced = normalizeSingleUpload(coerced);
|
|
280
|
+
const isFile = isNonEmptyNativeFile(coerced);
|
|
281
|
+
const isStringRef = typeof coerced === "string" && coerced.trim().length > 0;
|
|
282
|
+
const isRequired = field.required === true;
|
|
283
|
+
if (isRequired) {
|
|
284
|
+
if (mode === "create" || withinArray) {
|
|
285
|
+
if (!isFile && !isStringRef) {
|
|
286
|
+
issues.push({ path, message: `${field.label} must reference an image or contain a non-empty file` });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (mode === "edit") {
|
|
290
|
+
if (coerced !== undefined && !isFile && !isStringRef) {
|
|
291
|
+
issues.push({ path, message: `${field.label} must reference an image or contain a non-empty file` });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
191
295
|
break;
|
|
192
|
-
case 'file':
|
|
193
|
-
return singleFileFieldToZod(field, {
|
|
194
|
-
required: field.required === true && (mode === 'create' || withinArray),
|
|
195
|
-
allowReference: mode === 'edit' && withinArray,
|
|
196
|
-
});
|
|
197
|
-
case 'image': {
|
|
198
|
-
const imageSchemas = imageFieldSchemas(field);
|
|
199
|
-
return field.required === true && (mode === 'create' || withinArray)
|
|
200
|
-
? imageSchemas.required
|
|
201
|
-
: imageSchemas.optional;
|
|
202
296
|
}
|
|
203
|
-
case
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
297
|
+
case "images": {
|
|
298
|
+
coerced = normalizeImageEntries(coerced);
|
|
299
|
+
const isRequired = field.required === true;
|
|
300
|
+
const entries = Array.isArray(coerced) ? coerced : (coerced ? [coerced] : []);
|
|
301
|
+
if (isRequired && (mode === "create" || withinArray)) {
|
|
302
|
+
if (entries.length === 0) {
|
|
303
|
+
issues.push({ path, message: `${field.label} must contain at least one image` });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
208
307
|
}
|
|
209
308
|
default:
|
|
210
|
-
|
|
309
|
+
break;
|
|
211
310
|
}
|
|
212
|
-
//
|
|
213
|
-
if (field.
|
|
214
|
-
|
|
311
|
+
// Check required
|
|
312
|
+
if (field.type !== "number" && field.type !== "boolean" && field.type !== "array" && field.type !== "file" && field.type !== "image" && field.type !== "images") {
|
|
313
|
+
if (field.required && !hasRequiredValue(coerced)) {
|
|
314
|
+
issues.push({ path, message: `${field.label} is required` });
|
|
315
|
+
}
|
|
215
316
|
}
|
|
216
|
-
|
|
217
|
-
|
|
317
|
+
// Custom validate
|
|
318
|
+
if (field.validate && issues.length === 0 && coerced !== undefined && coerced !== null && coerced !== "") {
|
|
319
|
+
const customMessage = field.validate(coerced);
|
|
320
|
+
if (customMessage) {
|
|
321
|
+
issues.push({ path, message: customMessage });
|
|
322
|
+
}
|
|
218
323
|
}
|
|
219
|
-
return
|
|
324
|
+
return { value: coerced, issues };
|
|
220
325
|
}
|
|
221
326
|
/**
|
|
222
|
-
* Generate a
|
|
223
|
-
* Only includes fields that are relevant for form rendering.
|
|
224
|
-
*
|
|
225
|
-
* @param mode - 'create' | 'edit' to filter fields by showInCreate / showInEdit
|
|
327
|
+
* Generate a TypeBox object schema from a list of FieldDefinitions.
|
|
226
328
|
*/
|
|
227
|
-
export function
|
|
329
|
+
export function fieldsToTypeBoxSchema(fields, mode = "create") {
|
|
228
330
|
const shape = {};
|
|
331
|
+
const activeFields = [];
|
|
229
332
|
for (const field of fields) {
|
|
230
|
-
// Skip fields not shown in forms
|
|
231
333
|
if (field.showInForm === false)
|
|
232
334
|
continue;
|
|
233
|
-
if (mode ===
|
|
335
|
+
if (mode === "create" && field.showInCreate === false)
|
|
234
336
|
continue;
|
|
235
|
-
if (mode ===
|
|
337
|
+
if (mode === "edit" && field.showInEdit === false)
|
|
236
338
|
continue;
|
|
237
|
-
|
|
339
|
+
activeFields.push(field);
|
|
340
|
+
switch (field.type) {
|
|
341
|
+
case "number":
|
|
342
|
+
shape[field.key] = field.required ? Type.Number() : Type.Optional(Type.Number());
|
|
343
|
+
break;
|
|
344
|
+
case "boolean":
|
|
345
|
+
shape[field.key] = field.required ? Type.Boolean() : Type.Optional(Type.Boolean());
|
|
346
|
+
break;
|
|
347
|
+
case "array":
|
|
348
|
+
shape[field.key] = field.required ? Type.Array(Type.Any(), { minItems: 1 }) : Type.Optional(Type.Array(Type.Any()));
|
|
349
|
+
break;
|
|
350
|
+
default:
|
|
351
|
+
shape[field.key] = field.required ? Type.Any() : Type.Optional(Type.Any());
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
238
354
|
}
|
|
239
|
-
|
|
355
|
+
const baseSchema = Type.Object(shape, { additionalProperties: true });
|
|
356
|
+
const safeParse = (values) => {
|
|
357
|
+
if (typeof values !== "object" || values === null) {
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: { issues: [{ path: ["_root"], message: "Values must be an object" }] },
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const input = values;
|
|
364
|
+
const resultData = {};
|
|
365
|
+
const allIssues = [];
|
|
366
|
+
for (const field of activeFields) {
|
|
367
|
+
const val = input[field.key];
|
|
368
|
+
const fieldRes = coerceAndValidateField(field, val, mode, false, [field.key]);
|
|
369
|
+
allIssues.push(...fieldRes.issues);
|
|
370
|
+
if (fieldRes.value !== undefined) {
|
|
371
|
+
resultData[field.key] = fieldRes.value;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (allIssues.length > 0) {
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
error: { issues: allIssues },
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
success: true,
|
|
382
|
+
data: resultData,
|
|
383
|
+
};
|
|
384
|
+
};
|
|
385
|
+
const parse = (values) => {
|
|
386
|
+
const res = safeParse(values);
|
|
387
|
+
if (!res.success) {
|
|
388
|
+
const err = new Error(res.error?.issues[0]?.message || "Validation failed");
|
|
389
|
+
err.issues = res.error?.issues ?? [];
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
return res.data ?? {};
|
|
393
|
+
};
|
|
394
|
+
return Object.assign(baseSchema, {
|
|
395
|
+
parse,
|
|
396
|
+
safeParse,
|
|
397
|
+
Check: (val) => safeParse(val).success,
|
|
398
|
+
"~standard": {
|
|
399
|
+
version: 1,
|
|
400
|
+
vendor: "svadmin",
|
|
401
|
+
validate: (val) => {
|
|
402
|
+
const res = safeParse(val);
|
|
403
|
+
if (res.success) {
|
|
404
|
+
return { value: res.data ?? {} };
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
issues: (res.error?.issues ?? []).map((iss) => ({
|
|
408
|
+
message: iss.message,
|
|
409
|
+
path: iss.path,
|
|
410
|
+
})),
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
});
|
|
240
415
|
}
|
|
241
416
|
/**
|
|
242
|
-
* Generate a
|
|
243
|
-
* Convenience wrapper around
|
|
417
|
+
* Generate a TypeBox schema from a ResourceDefinition.
|
|
418
|
+
* Convenience wrapper around fieldsToTypeBoxSchema.
|
|
244
419
|
*/
|
|
245
|
-
export function
|
|
246
|
-
const primaryKey = resource.primaryKey ??
|
|
247
|
-
return
|
|
420
|
+
export function resourceToTypeBoxSchema(resource, mode = "create") {
|
|
421
|
+
const primaryKey = resource.primaryKey ?? "id";
|
|
422
|
+
return fieldsToTypeBoxSchema(resource.fields.filter((field) => field.key !== primaryKey), mode);
|
|
248
423
|
}
|
|
424
|
+
/**
|
|
425
|
+
* Backward compatibility alias for fieldsToTypeBoxSchema
|
|
426
|
+
*/
|
|
427
|
+
export const fieldsToZodSchema = fieldsToTypeBoxSchema;
|
|
428
|
+
/**
|
|
429
|
+
* Backward compatibility alias for resourceToTypeBoxSchema
|
|
430
|
+
*/
|
|
431
|
+
export const resourceToZodSchema = resourceToTypeBoxSchema;
|
|
249
432
|
/**
|
|
250
433
|
* Determine a conservative HTML input type for server-rendered forms.
|
|
251
|
-
* Text fallbacks avoid inconsistent native validation and date widgets.
|
|
252
434
|
*/
|
|
253
435
|
export function fieldToInputType(field) {
|
|
254
436
|
switch (field.type) {
|
|
255
|
-
case
|
|
256
|
-
case
|
|
257
|
-
case
|
|
258
|
-
case
|
|
259
|
-
case
|
|
260
|
-
case
|
|
261
|
-
case
|
|
262
|
-
case
|
|
263
|
-
case
|
|
264
|
-
case
|
|
265
|
-
case
|
|
266
|
-
case
|
|
267
|
-
case
|
|
268
|
-
case
|
|
269
|
-
case
|
|
270
|
-
case
|
|
271
|
-
case
|
|
272
|
-
default: return
|
|
437
|
+
case "number": return "text";
|
|
438
|
+
case "email": return "text";
|
|
439
|
+
case "url": return "text";
|
|
440
|
+
case "phone": return "tel";
|
|
441
|
+
case "boolean": return "checkbox";
|
|
442
|
+
case "date": return "text";
|
|
443
|
+
case "textarea":
|
|
444
|
+
case "richtext":
|
|
445
|
+
case "markdown":
|
|
446
|
+
case "images": return "textarea";
|
|
447
|
+
case "json": return "textarea";
|
|
448
|
+
case "select":
|
|
449
|
+
case "multiselect": return "select";
|
|
450
|
+
case "relation": return field.options?.length ? "select" : "text";
|
|
451
|
+
case "array": return "text";
|
|
452
|
+
case "file": return "file";
|
|
453
|
+
case "password": return "password";
|
|
454
|
+
default: return "text";
|
|
273
455
|
}
|
|
274
456
|
}
|
|
275
457
|
/**
|
|
@@ -277,11 +459,11 @@ export function fieldToInputType(field) {
|
|
|
277
459
|
*/
|
|
278
460
|
export function fieldToPlaceholder(field) {
|
|
279
461
|
switch (field.type) {
|
|
280
|
-
case
|
|
281
|
-
case
|
|
282
|
-
case
|
|
283
|
-
case
|
|
284
|
-
case
|
|
285
|
-
default: return
|
|
462
|
+
case "date": return "YYYY-MM-DD";
|
|
463
|
+
case "email": return "user@example.com";
|
|
464
|
+
case "url": return "https://example.com";
|
|
465
|
+
case "phone": return "+1 (555) 000-0000";
|
|
466
|
+
case "number": return "0";
|
|
467
|
+
default: return "";
|
|
286
468
|
}
|
|
287
469
|
}
|