@svadmin/core 0.49.0 → 0.50.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 +32 -0
- package/package.json +1 -1
- package/src/form-hooks.svelte.ts +2 -2
- package/src/hooks.svelte.ts +8 -6
- package/src/index.ts +2 -0
- package/src/query-hooks.svelte.ts +7 -5
- package/src/schema-form.ts +31 -0
- package/src/types.ts +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Schema Form Validation
|
|
2
|
+
|
|
3
|
+
Use the existing `useForm.validate` callback with a shared TypeBox schema:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
7
|
+
import { createSchemaFormValidator, useForm } from '@svadmin/core';
|
|
8
|
+
|
|
9
|
+
const Input = Type.Object({
|
|
10
|
+
title: Type.String({ minLength: 1 }),
|
|
11
|
+
quantity: Type.Integer({ minimum: 1 }),
|
|
12
|
+
});
|
|
13
|
+
type Input = Static<typeof Input>;
|
|
14
|
+
|
|
15
|
+
const form = useForm({
|
|
16
|
+
resource: 'items',
|
|
17
|
+
defaultValues: { title: '', quantity: 1 } satisfies Input,
|
|
18
|
+
validate: createSchemaFormValidator(Input, {
|
|
19
|
+
message: ({ path }) => path === '/quantity' ? 'Enter a positive integer' : 'Required',
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Validation does not coerce or strip input. It returns `null` on success or the
|
|
25
|
+
first error per field. JSON Pointer paths map to dotted fields (`items.0.name`);
|
|
26
|
+
root errors map to `_form`. Use `field(pointer)` and `formField` when an
|
|
27
|
+
application uses another naming convention. `references` supports TypeBox refs.
|
|
28
|
+
Message callbacks receive only the path and error type, not submitted values.
|
|
29
|
+
|
|
30
|
+
This adapter prevents invalid form submissions through normal `useForm`
|
|
31
|
+
validation. Authorization, domain commands, and server validation remain the
|
|
32
|
+
application's responsibility. Root errors require a form-level error display.
|
package/package.json
CHANGED
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -462,8 +462,8 @@ export function useForm<
|
|
|
462
462
|
queryKey: adminContext.queryKeys(resource, options.dataProviderName).data.one(resource, currentId ?? '', {
|
|
463
463
|
meta: queryMeta,
|
|
464
464
|
}),
|
|
465
|
-
queryFn: async () => {
|
|
466
|
-
const result = await provider.getOne<BaseRecord>({ resource, id: currentId as string, meta: queryMeta });
|
|
465
|
+
queryFn: async ({ signal }) => {
|
|
466
|
+
const result = await provider.getOne<BaseRecord>({ resource, id: currentId as string, meta: queryMeta, signal });
|
|
467
467
|
return result;
|
|
468
468
|
},
|
|
469
469
|
enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone' || action === 'show') && currentId != null,
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -68,8 +68,9 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
|
|
|
68
68
|
filters: options.filters,
|
|
69
69
|
meta: options.meta,
|
|
70
70
|
}),
|
|
71
|
-
queryFn: async ({ pageParam = 1 }) => {
|
|
71
|
+
queryFn: async ({ pageParam = 1, signal }) => {
|
|
72
72
|
const result = await provider.getList<TData>({
|
|
73
|
+
signal,
|
|
73
74
|
resource,
|
|
74
75
|
pagination: { current: pageParam as number, pageSize: options.pagination?.pageSize ?? 10 },
|
|
75
76
|
sorters: options.sorters,
|
|
@@ -179,7 +180,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
|
|
|
179
180
|
pagination,
|
|
180
181
|
meta,
|
|
181
182
|
}),
|
|
182
|
-
queryFn: () => provider.getList<TData>({ resource, sorters, filters: allFilters, pagination: { current: 1, pageSize: effectivePageSize }, meta }),
|
|
183
|
+
queryFn: ({ signal }) => provider.getList<TData>({ resource, sorters, filters: allFilters, pagination: { current: 1, pageSize: effectivePageSize }, meta, signal }),
|
|
183
184
|
enabled: options.queryOptions?.enabled ?? true,
|
|
184
185
|
staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
|
|
185
186
|
};
|
|
@@ -195,9 +196,9 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
|
|
|
195
196
|
ids: defaultValueIds,
|
|
196
197
|
meta,
|
|
197
198
|
}),
|
|
198
|
-
queryFn: async () => {
|
|
199
|
-
if (provider.getMany) return provider.getMany<TData>({ resource, ids: defaultValueIds, meta });
|
|
200
|
-
const results = await Promise.all(defaultValueIds.map(id => provider.getOne<TData>({ resource, id, meta })));
|
|
199
|
+
queryFn: async ({ signal }) => {
|
|
200
|
+
if (provider.getMany) return provider.getMany<TData>({ resource, ids: defaultValueIds, meta, signal });
|
|
201
|
+
const results = await Promise.all(defaultValueIds.map(id => provider.getOne<TData>({ resource, id, meta, signal })));
|
|
201
202
|
return { data: results.map(r => r.data) };
|
|
202
203
|
},
|
|
203
204
|
enabled: (options.defaultValueQueryOptions?.enabled ?? true) && defaultValueIds.length > 0,
|
|
@@ -297,9 +298,10 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
|
|
|
297
298
|
options.method,
|
|
298
299
|
{ config: options.config, meta: options.meta },
|
|
299
300
|
),
|
|
300
|
-
queryFn: async () => {
|
|
301
|
+
queryFn: async ({ signal }) => {
|
|
301
302
|
if (!provider.custom) throw new Error('DataProvider does not support custom method');
|
|
302
303
|
return provider.custom<TData>({
|
|
304
|
+
signal,
|
|
303
305
|
url: options.url,
|
|
304
306
|
method: options.method,
|
|
305
307
|
payload: options.config?.payload,
|
package/src/index.ts
CHANGED
|
@@ -230,3 +230,5 @@ export type {
|
|
|
230
230
|
// HTTP fetch utilities
|
|
231
231
|
export { createFetchWithInterceptor, fetchWithInterceptor } from './http';
|
|
232
232
|
export type { FetchWithInterceptor, FetchWithInterceptorOptions } from './http';
|
|
233
|
+
export { createSchemaFormValidator } from './schema-form';
|
|
234
|
+
export type { SchemaFormIssue, SchemaFormOptions } from './schema-form';
|
|
@@ -115,7 +115,8 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
|
|
|
115
115
|
filters,
|
|
116
116
|
meta,
|
|
117
117
|
}),
|
|
118
|
-
queryFn: async () => provider.getList<TData>({
|
|
118
|
+
queryFn: async ({ signal }) => provider.getList<TData>({
|
|
119
|
+
signal,
|
|
119
120
|
resource,
|
|
120
121
|
pagination,
|
|
121
122
|
sorters,
|
|
@@ -215,9 +216,10 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
|
|
|
215
216
|
queryKey: adminContext.queryKeys(resource, opts.dataProviderName).data.one(resource, id ?? '', {
|
|
216
217
|
meta: cloneQueryKeyPart(opts.meta),
|
|
217
218
|
}),
|
|
218
|
-
queryFn: async () => {
|
|
219
|
+
queryFn: async ({ signal }) => {
|
|
219
220
|
if (id == null) throw new Error('useOne requires an id');
|
|
220
221
|
const result = await provider.getOne<TData>({
|
|
222
|
+
signal,
|
|
221
223
|
resource,
|
|
222
224
|
id,
|
|
223
225
|
meta: opts.meta,
|
|
@@ -335,12 +337,12 @@ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpErro
|
|
|
335
337
|
ids: cloneQueryKeyPart(ids),
|
|
336
338
|
meta: cloneQueryKeyPart(meta),
|
|
337
339
|
}),
|
|
338
|
-
queryFn: async () => {
|
|
340
|
+
queryFn: async ({ signal }) => {
|
|
339
341
|
if (!ids.length) return { data: [] };
|
|
340
342
|
if (provider.getMany) {
|
|
341
|
-
return provider.getMany<TData>({ resource, ids, meta });
|
|
343
|
+
return provider.getMany<TData>({ resource, ids, meta, signal });
|
|
342
344
|
}
|
|
343
|
-
const results = await Promise.all(ids.map(id => provider.getOne<TData>({ resource, id, meta })));
|
|
345
|
+
const results = await Promise.all(ids.map(id => provider.getOne<TData>({ resource, id, meta, signal })));
|
|
344
346
|
return { data: results.map(r => r.data) };
|
|
345
347
|
},
|
|
346
348
|
enabled: (queryOptions?.enabled ?? true) && ids.length > 0,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { Value } from '@sinclair/typebox/value';
|
|
3
|
+
|
|
4
|
+
export interface SchemaFormIssue {
|
|
5
|
+
/** JSON Pointer from the schema validator; never includes submitted values. */
|
|
6
|
+
path: string;
|
|
7
|
+
type: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SchemaFormOptions {
|
|
11
|
+
references?: TSchema[];
|
|
12
|
+
message?: (issue: SchemaFormIssue) => string;
|
|
13
|
+
field?: (pointer: string) => string;
|
|
14
|
+
formField?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Adapter for useForm.validate. It validates without coercing or clearing input. */
|
|
18
|
+
export function createSchemaFormValidator(schema: TSchema, options: SchemaFormOptions = {}) {
|
|
19
|
+
return (values: unknown): Record<string, string> | null => {
|
|
20
|
+
const errors: Record<string, string> = Object.create(null);
|
|
21
|
+
for (const error of Value.Errors(schema, options.references ?? [], values)) {
|
|
22
|
+
const field = options.field?.(error.path) ?? (error.path
|
|
23
|
+
? error.path.slice(1).split('/').map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~')).join('.')
|
|
24
|
+
: options.formField ?? '_form');
|
|
25
|
+
if (!Object.hasOwn(errors, field)) {
|
|
26
|
+
errors[field] = options.message?.({ path: error.path, type: error.type }) ?? 'Invalid value';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Object.keys(errors).length ? errors : null;
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -103,6 +103,8 @@ export interface LogicalFilter {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export interface GetListParams {
|
|
106
|
+
/** Query-owned cancellation signal. Never include it in a cache key or URL. */
|
|
107
|
+
signal?: AbortSignal;
|
|
106
108
|
resource: string;
|
|
107
109
|
pagination?: Pagination;
|
|
108
110
|
sorters?: Sort[];
|
|
@@ -117,6 +119,7 @@ export interface GetListResult<TData extends BaseRecord = BaseRecord> {
|
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
export interface GetOneParams {
|
|
122
|
+
signal?: AbortSignal;
|
|
120
123
|
resource: string;
|
|
121
124
|
id: string | number;
|
|
122
125
|
meta?: Record<string, unknown>;
|
|
@@ -127,6 +130,7 @@ export interface GetOneResult<TData extends BaseRecord = BaseRecord> {
|
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
export interface GetManyParams {
|
|
133
|
+
signal?: AbortSignal;
|
|
130
134
|
resource: string;
|
|
131
135
|
ids: (string | number)[];
|
|
132
136
|
meta?: Record<string, unknown>;
|
|
@@ -201,6 +205,7 @@ export interface DeleteManyResult<TData extends BaseRecord = BaseRecord> {
|
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
export interface CustomParams<TVariables = unknown> {
|
|
208
|
+
signal?: AbortSignal;
|
|
204
209
|
url: string;
|
|
205
210
|
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
206
211
|
payload?: TVariables;
|