@svadmin/lite 0.3.26 → 0.5.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 +1 -1
- package/dist/components/LiteForm.svelte +1 -1
- package/dist/components/LiteForm.svelte.d.ts +1 -1
- package/dist/components/LiteShowField.svelte +3 -1
- package/dist/components/LiteTable.svelte +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -2
- package/dist/lite.css +30 -0
- package/dist/schema-generator.d.ts +49 -13
- package/dist/schema-generator.js +381 -199
- package/dist/server-adapter.d.ts +7 -0
- package/dist/server-adapter.js +59 -17
- package/dist/value-normalization.d.ts +1 -0
- package/dist/value-normalization.js +18 -0
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -218,7 +218,7 @@ export const handle = createLegacyRedirectHook('/lite');
|
|
|
218
218
|
| `createAuthGuard(authProvider)` | Server hook for authentication |
|
|
219
219
|
| `createAuthActions(authProvider)` | Login/logout actions plus optional provider-delegating account actions |
|
|
220
220
|
| `createLegacyRedirectHook()` | Auto-redirect IE11 to `/lite/` |
|
|
221
|
-
| `
|
|
221
|
+
| `fieldsToTypeBoxSchema(fields)` | Generate the TypeBox schema used by Lite actions or other consumers (with `fieldsToZodSchema` alias) |
|
|
222
222
|
|
|
223
223
|
## CSS
|
|
224
224
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* LiteForm — Server-rendered form driven by FieldDefinitions.
|
|
4
4
|
* Uses a native <form> with method="POST" for no-JavaScript submission.
|
|
5
|
-
* Uses the generated
|
|
5
|
+
* Uses the generated TypeBox schema for server-side validation.
|
|
6
6
|
*/
|
|
7
7
|
import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
|
|
8
8
|
import { t } from '@svadmin/core/i18n';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LiteForm — Server-rendered form driven by FieldDefinitions.
|
|
3
3
|
* Uses a native <form> with method="POST" for no-JavaScript submission.
|
|
4
|
-
* Uses the generated
|
|
4
|
+
* Uses the generated TypeBox schema for server-side validation.
|
|
5
5
|
*/
|
|
6
6
|
import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
|
|
7
7
|
interface Props {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { FieldDefinition } from '@svadmin/core';
|
|
7
7
|
import { toSafeHref, toSafeText } from '../security';
|
|
8
|
-
import { isExplicitBooleanTrue } from '../value-normalization';
|
|
8
|
+
import { isExplicitBooleanTrue, getStatusBadgeClass } from '../value-normalization';
|
|
9
9
|
import LiteMediaThumbnail from './LiteMediaThumbnail.svelte';
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
{/each}
|
|
61
61
|
{:else if field.type === 'json' && value}
|
|
62
62
|
<pre style="margin:0;font-size:12px;background:#f8fafc;padding:12px;border-radius:6px;border:1px solid #e2e8f0;overflow-x:auto;">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre>
|
|
63
|
+
{:else if field.type === "select" && field.options}
|
|
64
|
+
<span class={getStatusBadgeClass(value)}>{formatValue(value, field)}</span>
|
|
63
65
|
{:else}
|
|
64
66
|
{formatValue(value, field)}
|
|
65
67
|
{/if}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ResourceDefinition, FieldDefinition } from '@svadmin/core';
|
|
7
7
|
import { t } from '@svadmin/core/i18n';
|
|
8
|
-
import { isExplicitBooleanTrue } from '../value-normalization';
|
|
8
|
+
import { isExplicitBooleanTrue, getStatusBadgeClass } from '../value-normalization';
|
|
9
9
|
import { liteFragmentId } from '../fragment-id';
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
<span class="lite-badge">{tag}</span>
|
|
105
105
|
{/each}
|
|
106
106
|
{:else if field.type === 'select' && field.options}
|
|
107
|
-
<span class=
|
|
107
|
+
<span class={getStatusBadgeClass(record[field.key])}>{formatValue(record[field.key], field)}</span>
|
|
108
108
|
{:else}
|
|
109
109
|
{formatValue(record[field.key], field)}
|
|
110
110
|
{/if}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createListLoader, createDetailLoader, createCrudActions, createAuthGuard, createAuthActions, createLegacyRedirectHook, isLegacyBrowser, } from './server-adapter';
|
|
2
2
|
export type { ListLoaderResult } from './server-adapter';
|
|
3
|
-
export { fieldsToZodSchema, resourceToZodSchema, fieldToInputType, fieldToPlaceholder, } from './schema-generator';
|
|
3
|
+
export { fieldsToTypeBoxSchema, resourceToTypeBoxSchema, fieldsToZodSchema, resourceToZodSchema, fieldToInputType, fieldToPlaceholder, } from './schema-generator';
|
|
4
|
+
export { getStatusBadgeClass, parseExplicitBoolean, isExplicitBooleanTrue } from './value-normalization';
|
|
4
5
|
export { default as LiteLayout } from './components/LiteLayout.svelte';
|
|
5
6
|
export { default as LiteTable } from './components/LiteTable.svelte';
|
|
6
7
|
export { default as LitePagination } from './components/LitePagination.svelte';
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// Server-rendered by default, with optional progressive enhancement.
|
|
3
3
|
// Server utilities (use in +page.server.ts / hooks.server.ts)
|
|
4
4
|
export { createListLoader, createDetailLoader, createCrudActions, createAuthGuard, createAuthActions, createLegacyRedirectHook, isLegacyBrowser, } from './server-adapter';
|
|
5
|
-
// Schema generator (used by Lite actions
|
|
6
|
-
export { fieldsToZodSchema, resourceToZodSchema, fieldToInputType, fieldToPlaceholder, } from './schema-generator';
|
|
5
|
+
// Schema generator (TypeBox schemas used by Lite actions and client forms)
|
|
6
|
+
export { fieldsToTypeBoxSchema, resourceToTypeBoxSchema, fieldsToZodSchema, resourceToZodSchema, fieldToInputType, fieldToPlaceholder, } from './schema-generator';
|
|
7
7
|
// UI Components (use in +page.svelte with csr = false)
|
|
8
|
+
export { getStatusBadgeClass, parseExplicitBoolean, isExplicitBooleanTrue } from './value-normalization';
|
|
8
9
|
export { default as LiteLayout } from './components/LiteLayout.svelte';
|
|
9
10
|
export { default as LiteTable } from './components/LiteTable.svelte';
|
|
10
11
|
export { default as LitePagination } from './components/LitePagination.svelte';
|
package/dist/lite.css
CHANGED
|
@@ -791,6 +791,36 @@ textarea.lite-input {
|
|
|
791
791
|
border-color: #fecaca;
|
|
792
792
|
}
|
|
793
793
|
|
|
794
|
+
.lite-badge-info {
|
|
795
|
+
background: #eff6ff;
|
|
796
|
+
color: #1d4ed8;
|
|
797
|
+
border-color: #bfdbfe;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.lite-badge-indigo {
|
|
801
|
+
background: #eef2ff;
|
|
802
|
+
color: #4338ca;
|
|
803
|
+
border-color: #c7d2fe;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.lite-filter-bar {
|
|
807
|
+
display: flex;
|
|
808
|
+
align-items: center;
|
|
809
|
+
flex-wrap: wrap;
|
|
810
|
+
}
|
|
811
|
+
.lite-filter-bar > * + * {
|
|
812
|
+
margin-left: 8px;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.lite-batch-bar {
|
|
816
|
+
display: flex;
|
|
817
|
+
align-items: center;
|
|
818
|
+
justify-content: space-between;
|
|
819
|
+
padding: 10px 16px;
|
|
820
|
+
background: #f8fafc;
|
|
821
|
+
border-top: 1px solid #e2e8f0;
|
|
822
|
+
}
|
|
823
|
+
|
|
794
824
|
.lite-badge-warning {
|
|
795
825
|
background: #fffbeb;
|
|
796
826
|
color: #92400e;
|
|
@@ -1,26 +1,62 @@
|
|
|
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 type { FieldDefinition, ResourceDefinition } from
|
|
7
|
+
import { type TObject } from "@sinclair/typebox";
|
|
8
|
+
import type { FieldDefinition, ResourceDefinition } from "@svadmin/core";
|
|
9
|
+
export interface SchemaValidationIssue {
|
|
10
|
+
path: (string | number)[];
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
export type SchemaValidationResult<T = Record<string, unknown>> = {
|
|
14
|
+
success: true;
|
|
15
|
+
data: T;
|
|
16
|
+
error?: never;
|
|
17
|
+
} | {
|
|
18
|
+
success: false;
|
|
19
|
+
error: {
|
|
20
|
+
issues: SchemaValidationIssue[];
|
|
21
|
+
};
|
|
22
|
+
data?: never;
|
|
23
|
+
};
|
|
24
|
+
export type TypeBoxEnhancedSchema<T = Record<string, unknown>> = TObject & {
|
|
25
|
+
parse: (values: unknown) => T;
|
|
26
|
+
safeParse: (values: unknown) => SchemaValidationResult<T>;
|
|
27
|
+
Check: (values: unknown) => boolean;
|
|
28
|
+
"~standard": {
|
|
29
|
+
version: 1;
|
|
30
|
+
vendor: "svadmin";
|
|
31
|
+
validate: (values: unknown) => {
|
|
32
|
+
value: T;
|
|
33
|
+
} | {
|
|
34
|
+
issues: Array<{
|
|
35
|
+
message: string;
|
|
36
|
+
path?: (string | number)[];
|
|
37
|
+
}>;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
};
|
|
9
41
|
/**
|
|
10
|
-
* Generate a
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
42
|
+
* Generate a TypeBox object schema from a list of FieldDefinitions.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fieldsToTypeBoxSchema(fields: FieldDefinition[], mode?: "create" | "edit"): TypeBoxEnhancedSchema;
|
|
45
|
+
/**
|
|
46
|
+
* Generate a TypeBox schema from a ResourceDefinition.
|
|
47
|
+
* Convenience wrapper around fieldsToTypeBoxSchema.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resourceToTypeBoxSchema(resource: ResourceDefinition, mode?: "create" | "edit"): TypeBoxEnhancedSchema;
|
|
50
|
+
/**
|
|
51
|
+
* Backward compatibility alias for fieldsToTypeBoxSchema
|
|
14
52
|
*/
|
|
15
|
-
export declare
|
|
53
|
+
export declare const fieldsToZodSchema: typeof fieldsToTypeBoxSchema;
|
|
16
54
|
/**
|
|
17
|
-
*
|
|
18
|
-
* Convenience wrapper around fieldsToZodSchema.
|
|
55
|
+
* Backward compatibility alias for resourceToTypeBoxSchema
|
|
19
56
|
*/
|
|
20
|
-
export declare
|
|
57
|
+
export declare const resourceToZodSchema: typeof resourceToTypeBoxSchema;
|
|
21
58
|
/**
|
|
22
59
|
* Determine a conservative HTML input type for server-rendered forms.
|
|
23
|
-
* Text fallbacks avoid inconsistent native validation and date widgets.
|
|
24
60
|
*/
|
|
25
61
|
export declare function fieldToInputType(field: FieldDefinition): string;
|
|
26
62
|
/**
|
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
|
}
|
package/dist/server-adapter.d.ts
CHANGED
|
@@ -92,6 +92,13 @@ export declare function createCrudActions(dp: DataProvider, resource: ResourceDe
|
|
|
92
92
|
success: boolean;
|
|
93
93
|
error?: undefined;
|
|
94
94
|
}>;
|
|
95
|
+
batchDelete: ({ request }: RequestEvent) => Promise<{
|
|
96
|
+
success: boolean;
|
|
97
|
+
error: string;
|
|
98
|
+
} | {
|
|
99
|
+
success: boolean;
|
|
100
|
+
error?: undefined;
|
|
101
|
+
}>;
|
|
95
102
|
};
|
|
96
103
|
/**
|
|
97
104
|
* Creates a SvelteKit server hook that checks auth via AuthProvider
|
package/dist/server-adapter.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { redirect, isRedirect } from '@sveltejs/kit';
|
|
2
|
-
import {
|
|
2
|
+
import { resourceToTypeBoxSchema } from './schema-generator';
|
|
3
3
|
import { parseExplicitBoolean } from './value-normalization';
|
|
4
4
|
function listRequestState(url, resource) {
|
|
5
5
|
const requestedPage = Number(url.searchParams.get('page'));
|
|
@@ -19,19 +19,35 @@ function listRequestState(url, resource) {
|
|
|
19
19
|
: resource.defaultSort?.order ?? 'asc';
|
|
20
20
|
return { page, pageSize, sort, order, search: url.searchParams.get('q') ?? undefined };
|
|
21
21
|
}
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
function listRequestFilters(resource, url) {
|
|
23
|
+
const filters = [];
|
|
24
|
+
const search = url.searchParams.get('q') ?? undefined;
|
|
25
|
+
if (search) {
|
|
26
|
+
const searchFilters = resource.fields
|
|
27
|
+
.filter((field) => field.searchable)
|
|
28
|
+
.map((field) => ({
|
|
29
|
+
field: field.key,
|
|
30
|
+
operator: 'contains',
|
|
31
|
+
value: search,
|
|
32
|
+
}));
|
|
33
|
+
if (searchFilters.length === 1) {
|
|
34
|
+
filters.push(searchFilters[0]);
|
|
35
|
+
}
|
|
36
|
+
else if (searchFilters.length > 1) {
|
|
37
|
+
filters.push({ operator: 'or', value: searchFilters });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const field of resource.fields) {
|
|
41
|
+
const rawVal = url.searchParams.get(`filter_${field.key}`) ?? (field.key !== 'q' && field.key !== 'sort' && field.key !== 'order' && field.key !== 'page' ? url.searchParams.get(field.key) : null);
|
|
42
|
+
if (rawVal != null && rawVal.trim() !== '') {
|
|
43
|
+
filters.push({
|
|
44
|
+
field: field.key,
|
|
45
|
+
operator: 'eq',
|
|
46
|
+
value: field.type === 'number' && !Number.isNaN(Number(rawVal)) ? Number(rawVal) : rawVal,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return filters;
|
|
35
51
|
}
|
|
36
52
|
/**
|
|
37
53
|
* Creates a SvelteKit `load` function that fetches a resource list
|
|
@@ -45,7 +61,7 @@ export function createListLoader(dp, resource) {
|
|
|
45
61
|
resource: resource.name,
|
|
46
62
|
pagination: { current: page, pageSize },
|
|
47
63
|
sorters,
|
|
48
|
-
filters:
|
|
64
|
+
filters: listRequestFilters(resource, url),
|
|
49
65
|
});
|
|
50
66
|
return {
|
|
51
67
|
records: listResponse.data,
|
|
@@ -155,6 +171,32 @@ export function createCrudActions(dp, resource) {
|
|
|
155
171
|
return { success: false, error: 'Delete failed' };
|
|
156
172
|
}
|
|
157
173
|
},
|
|
174
|
+
batchDelete: async ({ request }) => {
|
|
175
|
+
if (resource.canDelete === false) {
|
|
176
|
+
return { success: false, error: 'Delete is disabled for this resource' };
|
|
177
|
+
}
|
|
178
|
+
const formData = await request.formData();
|
|
179
|
+
const ids = formData.getAll('ids').map(v => String(v).trim()).filter(Boolean);
|
|
180
|
+
if (ids.length === 0)
|
|
181
|
+
return { success: false, error: 'No records selected' };
|
|
182
|
+
const redirectTo = toSafeLocalRedirect(formData.get('redirect'));
|
|
183
|
+
try {
|
|
184
|
+
if (dp.deleteMany) {
|
|
185
|
+
await dp.deleteMany({ resource: resource.name, ids });
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
await Promise.all(ids.map(id => dp.deleteOne({ resource: resource.name, id })));
|
|
189
|
+
}
|
|
190
|
+
if (redirectTo)
|
|
191
|
+
throw redirect(303, redirectTo);
|
|
192
|
+
return { success: true };
|
|
193
|
+
}
|
|
194
|
+
catch (caughtError) {
|
|
195
|
+
if (isRedirect(caughtError))
|
|
196
|
+
throw caughtError;
|
|
197
|
+
return { success: false, error: 'Batch delete failed' };
|
|
198
|
+
}
|
|
199
|
+
},
|
|
158
200
|
};
|
|
159
201
|
}
|
|
160
202
|
// ─── Auth Helpers ─────────────────────────────────────────────
|
|
@@ -374,7 +416,7 @@ function formatValidationErrors(issues) {
|
|
|
374
416
|
return errors;
|
|
375
417
|
}
|
|
376
418
|
function validateFormVariables(resource, mode, values) {
|
|
377
|
-
const result =
|
|
419
|
+
const result = resourceToTypeBoxSchema(resource, mode).safeParse(values);
|
|
378
420
|
if (result.success)
|
|
379
421
|
return { success: true, data: result.data };
|
|
380
422
|
return {
|
|
@@ -471,7 +513,7 @@ function formDataToObject(formData, fields) {
|
|
|
471
513
|
obj[field.key] = strRaw ? JSON.parse(strRaw) : null;
|
|
472
514
|
}
|
|
473
515
|
catch {
|
|
474
|
-
obj[field.key] = strRaw; // Let
|
|
516
|
+
obj[field.key] = strRaw; // Let TypeBox handle validation errors
|
|
475
517
|
}
|
|
476
518
|
break;
|
|
477
519
|
default:
|
|
@@ -19,3 +19,21 @@ export function parseExplicitBoolean(rawBoolean) {
|
|
|
19
19
|
export function isExplicitBooleanTrue(rawBoolean) {
|
|
20
20
|
return parseExplicitBoolean(rawBoolean) === true;
|
|
21
21
|
}
|
|
22
|
+
export function getStatusBadgeClass(value) {
|
|
23
|
+
if (typeof value !== "string")
|
|
24
|
+
return "lite-badge";
|
|
25
|
+
const v = value.toLowerCase().trim();
|
|
26
|
+
if (["active", "completed", "published", "delivered", "paid", "approved", "success", "in_stock", "healthy"].includes(v)) {
|
|
27
|
+
return "lite-badge lite-badge-success";
|
|
28
|
+
}
|
|
29
|
+
if (["draft", "pending", "processing", "in_transit", "review", "waiting", "warning", "low_stock"].includes(v)) {
|
|
30
|
+
return "lite-badge lite-badge-warning";
|
|
31
|
+
}
|
|
32
|
+
if (["cancelled", "rejected", "failed", "suspended", "inactive", "banned", "error", "out_of_stock", "closed"].includes(v)) {
|
|
33
|
+
return "lite-badge lite-badge-danger";
|
|
34
|
+
}
|
|
35
|
+
if (["shipped", "submitted", "open", "new", "admin", "verified"].includes(v)) {
|
|
36
|
+
return "lite-badge lite-badge-info";
|
|
37
|
+
}
|
|
38
|
+
return "lite-badge";
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/lite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "SSR-first lightweight admin UI for @svadmin with optional progressive enhancement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -48,22 +48,22 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"svelte": "^5.56.
|
|
52
|
-
"@svadmin/core": ">=0.34.2 <0.
|
|
53
|
-
"@sveltejs/kit": "^2.70.
|
|
51
|
+
"svelte": "^5.56.10",
|
|
52
|
+
"@svadmin/core": ">=0.34.2 <0.46.0",
|
|
53
|
+
"@sveltejs/kit": "^2.70.3"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@lucide/svelte": "^1.
|
|
57
|
-
"
|
|
56
|
+
"@lucide/svelte": "^1.35.0",
|
|
57
|
+
"@sinclair/typebox": "^0.34.52"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "^1.4.0",
|
|
61
|
-
"@happy-dom/global-registrator": "^20.11.
|
|
61
|
+
"@happy-dom/global-registrator": "^20.11.12",
|
|
62
62
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
63
63
|
"@testing-library/dom": "^10.4.1",
|
|
64
64
|
"@testing-library/svelte": "^5.4.2",
|
|
65
|
-
"happy-dom": "^20.11.
|
|
66
|
-
"vitest": "^4.1.
|
|
65
|
+
"happy-dom": "^20.11.12",
|
|
66
|
+
"vitest": "^4.1.11"
|
|
67
67
|
},
|
|
68
68
|
"scripts": {
|
|
69
69
|
"build": "node ../../node_modules/@sveltejs/package/svelte-package.js -i src -o dist && bun ../../scripts/copy-package-assets.ts static/enhance.js dist/enhance.js",
|