@svadmin/core 0.49.1 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.49.1",
3
+ "version": "0.50.0",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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,
@@ -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;