@svadmin/core 0.49.1 → 0.52.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.
Files changed (97) hide show
  1. package/README.md +32 -0
  2. package/package.json +21 -4
  3. package/src/access-control-contract.ts +250 -0
  4. package/src/adapters/casbin.ts +20 -14
  5. package/src/adapters/casl.ts +22 -19
  6. package/src/admin-tool.ts +7 -7
  7. package/src/audit-contract.ts +105 -0
  8. package/src/audit.ts +88 -128
  9. package/src/auth-hooks.svelte.ts +593 -261
  10. package/src/auth-mutation-contract.ts +143 -0
  11. package/src/auth-query-contract.ts +97 -0
  12. package/src/captured-mutation.svelte.ts +45 -0
  13. package/src/chatProvider.svelte.ts +1 -1
  14. package/src/command-contract.ts +195 -0
  15. package/src/context.svelte.ts +217 -205
  16. package/src/contract-context.svelte.ts +37 -0
  17. package/src/contract-form.svelte.ts +566 -0
  18. package/src/create-contract.ts +56 -0
  19. package/src/create-hooks.svelte.ts +347 -0
  20. package/src/create-many-contract.ts +90 -0
  21. package/src/create-many-hooks.svelte.ts +396 -0
  22. package/src/data-transfer.svelte.ts +1 -215
  23. package/src/defined-options.ts +50 -0
  24. package/src/delete-contract.ts +50 -0
  25. package/src/delete-hooks.svelte.ts +403 -0
  26. package/src/delete-many-contract.ts +60 -0
  27. package/src/delete-many-hooks.svelte.ts +402 -0
  28. package/src/enterprise.ts +4 -4
  29. package/src/export-format.ts +16 -14
  30. package/src/export-hooks.svelte.ts +191 -0
  31. package/src/feature-gate-contract.ts +53 -0
  32. package/src/hook-utils.svelte.ts +53 -105
  33. package/src/hooks.svelte.ts +19 -774
  34. package/src/http.ts +88 -87
  35. package/src/i18n.svelte.ts +56 -2
  36. package/src/import-contract.ts +106 -0
  37. package/src/import-hooks.svelte.ts +310 -0
  38. package/src/index.ts +77 -28
  39. package/src/inferencer-graphql.ts +7 -6
  40. package/src/infinite-query.svelte.ts +228 -0
  41. package/src/invalidation-contract.ts +26 -0
  42. package/src/live-sse.ts +91 -127
  43. package/src/live-subscription.svelte.ts +130 -0
  44. package/src/live-transport.ts +127 -0
  45. package/src/live-websocket.ts +85 -118
  46. package/src/live.svelte.ts +66 -68
  47. package/src/notification.svelte.ts +17 -15
  48. package/src/permission-catalog.ts +121 -0
  49. package/src/permission-hints-contract.ts +48 -0
  50. package/src/permissions.svelte.ts +112 -59
  51. package/src/plain-data.ts +61 -0
  52. package/src/provider-bundle.ts +85 -79
  53. package/src/provider-response.ts +35 -0
  54. package/src/query-hooks.svelte.ts +241 -286
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +216 -0
  58. package/src/query-snapshot.ts +144 -0
  59. package/src/reactive-projection.ts +53 -0
  60. package/src/record-decoder.ts +63 -0
  61. package/src/resource-binding.svelte.ts +19 -0
  62. package/src/resource-contract.ts +595 -0
  63. package/src/resource-mutation-types.ts +30 -0
  64. package/src/resource-schemas.ts +371 -0
  65. package/src/resource-transport-config.ts +156 -0
  66. package/src/route-parsing.ts +11 -11
  67. package/src/router-provider.ts +64 -59
  68. package/src/router.ts +8 -3
  69. package/src/routing-hooks.svelte.ts +90 -62
  70. package/src/schema-validation.ts +92 -0
  71. package/src/schema.ts +13 -0
  72. package/src/select-options.ts +95 -0
  73. package/src/select-query.svelte.ts +191 -0
  74. package/src/session-query.svelte.ts +88 -0
  75. package/src/strict-command-hooks.svelte.ts +333 -0
  76. package/src/strict-hooks.svelte.ts +198 -0
  77. package/src/table-hooks.svelte.ts +180 -174
  78. package/src/table-state.svelte.ts +117 -117
  79. package/src/task-contract.ts +87 -0
  80. package/src/task-hooks.svelte.ts +193 -149
  81. package/src/task-provider.ts +115 -0
  82. package/src/task-subscription.ts +132 -0
  83. package/src/theme.svelte.ts +155 -154
  84. package/src/toast.svelte.ts +38 -37
  85. package/src/types.ts +55 -103
  86. package/src/unsafe.ts +7 -0
  87. package/src/update-contract.ts +65 -0
  88. package/src/update-hooks.svelte.ts +347 -0
  89. package/src/update-many-contract.ts +79 -0
  90. package/src/update-many-hooks.svelte.ts +373 -0
  91. package/src/url-sync.ts +11 -10
  92. package/src/useCan.ts +96 -31
  93. package/src/useParsed.svelte.ts +18 -14
  94. package/src/useStepsForm.svelte.ts +123 -63
  95. package/src/utility-hooks.svelte.ts +101 -158
  96. package/src/form-hooks.svelte.ts +0 -889
  97. package/src/mutation-hooks.svelte.ts +0 -561
@@ -1,77 +1,137 @@
1
- import { useForm } from './form-hooks.svelte';
2
- import type { UseFormOptions, UseFormReturn } from './form-hooks.svelte';
3
- import type { BaseRecord, HttpError } from './types';
1
+ import { Type } from '@sinclair/typebox';
2
+ import { useContractForm, type ContractFormOptions } from './contract-form.svelte';
3
+ import { getContractFormFields, type ContractSchemas, type ContractFormValues } from './resource-contract';
4
+ import { snapshotPlainData } from './plain-data';
5
+ import { checkExact } from './schema-validation';
6
+ import { replaceReactiveMembers } from './reactive-projection';
7
+ import { HttpError } from './types';
8
+ import { captureAdminContext } from './context.svelte';
9
+ import { captureAuthLiveScope } from './auth-hooks.svelte';
4
10
 
5
- export interface UseStepsFormOptions<
6
- TVariables extends Record<string, unknown> = Record<string, unknown>,
7
- TData extends BaseRecord = BaseRecord,
8
- TError = HttpError,
9
- > extends UseFormOptions<TVariables, TData, TError> {
10
- /** Total number of steps. Defaults to 1 */
11
- stepsCount?: number;
12
- /** Default step index. Starts at 0. */
13
- defaultStep?: number;
14
- /** Validate the form when navigating back? */
15
- isBackValidate?: boolean;
16
- }
17
-
18
- export interface UseStepsFormReturn<
19
- TVariables extends Record<string, unknown> = Record<string, unknown>,
20
- TData extends BaseRecord = BaseRecord,
21
- > extends UseFormReturn<TVariables, TData> {
22
- steps: {
23
- currentStep: number;
24
- totalSteps: number;
25
- canGoNext: boolean;
26
- canGoPrev: boolean;
27
- gotoStep: (step: number) => void;
28
- nextStep: () => void;
29
- prevStep: () => void;
11
+ type Action = 'create' | 'edit';
12
+ type Field<S extends ContractSchemas, A extends Action> = Extract<keyof ContractFormValues<S, A>, string>;
13
+ export type UseStepsFormOptions<S extends ContractSchemas = ContractSchemas, A extends Action = Action> =
14
+ ContractFormOptions<S, A> & {
15
+ steps: readonly { readonly fields: readonly NoInfer<Field<S, A>>[] }[];
16
+ defaultStep?: number;
17
+ isBackValidate?: boolean;
30
18
  };
31
- }
32
-
33
- /**
34
- * useStepsForm
35
- * Extends `useForm` with a nested `steps` object tracking navigation state.
36
- */
37
- export function useStepsForm<
38
- TVariables extends Record<string, unknown> = Record<string, unknown>,
39
- TData extends BaseRecord = BaseRecord,
40
- TError = HttpError,
41
- >(options: UseStepsFormOptions<TVariables, TData, TError> = {} as UseStepsFormOptions<TVariables, TData, TError>): UseStepsFormReturn<TVariables, TData> {
42
- const form = useForm<TVariables, TData, TError>(options);
43
19
 
44
- let currentStep = $state(options.defaultStep ?? 0);
45
- const totalSteps = options.stepsCount ?? 1;
20
+ const stepsSchema = Type.Array(Type.Object({
21
+ fields: Type.Array(Type.String(), { uniqueItems: true }),
22
+ }, { additionalProperties: false }), { minItems: 1 });
23
+ function invalid(): HttpError {
24
+ return new HttpError('Invalid form steps', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
25
+ }
46
26
 
47
- function gotoStep(step: number) {
48
- currentStep = Math.max(0, Math.min(step, totalSteps - 1));
27
+ /** Step navigation shares the form's validation, request lifecycle and checked receipts. */
28
+ export function useStepsForm<S extends ContractSchemas, A extends Action>(options: UseStepsFormOptions<S, A>) {
29
+ const context = captureAdminContext();
30
+ const layout = $derived.by(() => {
31
+ try {
32
+ const steps = snapshotPlainData(options.steps);
33
+ if (!checkExact(stepsSchema, steps)) throw invalid();
34
+ const names: readonly string[] = getContractFormFields(options.resource, options.action);
35
+ if (steps.some(step => step.fields.some(field => !names.includes(field)))) throw invalid();
36
+ const defaultStep = options.defaultStep ?? 0;
37
+ if (!Number.isInteger(defaultStep) || defaultStep < 0 || defaultStep >= steps.length ||
38
+ (options.isBackValidate !== undefined && typeof options.isBackValidate !== 'boolean')) throw invalid();
39
+ return { ok: true as const, steps, defaultStep, key: JSON.stringify([steps, defaultStep]) };
40
+ } catch {
41
+ return { ok: false as const, error: invalid() };
42
+ }
43
+ });
44
+ let currentStep = $state(0);
45
+ let epoch = 0;
46
+ let navigation: object | undefined;
47
+ let submission: object | undefined;
48
+ let mounted = true;
49
+ function resetNavigation() {
50
+ currentStep = layout.ok ? layout.defaultStep : 0;
51
+ epoch++;
52
+ navigation = undefined;
53
+ submission = undefined;
49
54
  }
55
+ const form = useContractForm<S, A>(options, {
56
+ get enabled() { return layout.ok; },
57
+ get key() { return layout.ok ? layout.key : 'invalid'; },
58
+ reset: resetNavigation,
59
+ });
60
+ $effect(() => () => { mounted = false; resetNavigation(); });
50
61
 
51
- function nextStep() {
52
- if (currentStep < totalSteps - 1) gotoStep(currentStep + 1);
62
+ function capture() {
63
+ const revision = epoch;
64
+ const auth = context.authProvider;
65
+ const session = captureAuthLiveScope(auth);
66
+ const router = context.routerProvider;
67
+ const key = layout.ok ? layout.key : undefined;
68
+ return () => mounted && revision === epoch && session.isCurrent() && context.authProvider === auth &&
69
+ context.routerProvider === router && layout.ok && layout.key === key && form.ready;
53
70
  }
54
71
 
55
- function prevStep() {
56
- if (currentStep > 0) gotoStep(currentStep - 1);
72
+ function focusErrorStep(current: () => boolean): void {
73
+ if (!current() || !layout.ok) return;
74
+ const first = layout.steps.findIndex(step => step.fields.some(field => Boolean(form.errors[field])));
75
+ if (first >= 0 && current()) currentStep = first;
76
+ }
77
+ function validateTo(step: number, current: () => boolean): boolean {
78
+ if (!current() || !layout.ok) return false;
79
+ const fields = step > currentStep
80
+ ? layout.steps.slice(0, step).flatMap(item => item.fields)
81
+ : layout.steps[currentStep]?.fields ?? [];
82
+ const selected = getContractFormFields(options.resource, options.action).filter(field => fields.includes(field));
83
+ const valid = form.validateFields(selected);
84
+ if (!current()) return false;
85
+ if (!valid) focusErrorStep(current);
86
+ return valid;
87
+ }
88
+ function gotoStep(step: number): boolean {
89
+ if (!layout.ok || !form.ready || form.submitting) return false;
90
+ if (!Number.isInteger(step) || step < 0 || step >= layout.steps.length) throw invalid();
91
+ const token = {};
92
+ navigation = token;
93
+ const active = capture();
94
+ const current = () => navigation === token && active();
95
+ if (step === currentStep) return true;
96
+ if ((step > currentStep || options.isBackValidate === true) && !validateTo(step, current)) return false;
97
+ if (!current()) return false;
98
+ currentStep = step;
99
+ return true;
100
+ }
101
+ function submit(overrides?: Parameters<typeof form.submit>[0]) {
102
+ const current = capture();
103
+ const navigationAtSubmit = navigation;
104
+ const token = {};
105
+ submission = token;
106
+ const source = form.submit(overrides);
107
+ // Observe errors without adding another result handoff after the base form's final check.
108
+ void source.catch(() => {
109
+ try {
110
+ if (submission === token && navigation === navigationAtSubmit && current() && !form.submitting) {
111
+ focusErrorStep(() => submission === token && navigation === navigationAtSubmit && current());
112
+ }
113
+ } catch { /* Workflow presentation cannot replace a checked submission result. */ }
114
+ });
115
+ return source;
116
+ }
117
+ function reset() {
118
+ form.reset();
119
+ resetNavigation();
57
120
  }
58
-
59
- const result = form as unknown as UseStepsFormReturn<TVariables, TData>;
60
-
61
121
  const steps = {
62
- get currentStep() { return currentStep; },
63
- get totalSteps() { return totalSteps; },
64
- get canGoNext() { return currentStep < totalSteps - 1; },
65
- get canGoPrev() { return currentStep > 0; },
122
+ get currentStep() { return mounted && form.ready ? currentStep : 0; },
123
+ get totalSteps() { return layout.ok ? layout.steps.length : 0; },
124
+ get canGoNext() { return form.ready && !form.submitting && layout.ok && currentStep < layout.steps.length - 1; },
125
+ get canGoPrev() { return form.ready && !form.submitting && currentStep > 0; },
66
126
  gotoStep,
67
- nextStep,
68
- prevStep,
127
+ nextStep() { return layout.ok && currentStep < layout.steps.length - 1 ? gotoStep(currentStep + 1) : false; },
128
+ prevStep() { return currentStep > 0 ? gotoStep(currentStep - 1) : false; },
69
129
  };
70
-
71
- Object.defineProperty(result, 'steps', {
72
- get: () => steps,
73
- enumerable: true
130
+ return replaceReactiveMembers(form, {
131
+ steps, submit, reset,
132
+ get error() { return layout.ok ? form.error : invalid(); },
74
133
  });
75
-
76
- return result;
77
134
  }
135
+
136
+ export type UseStepsFormReturn<S extends ContractSchemas = ContractSchemas, A extends Action = Action> =
137
+ ReturnType<typeof useStepsForm<S, A>>;
@@ -1,83 +1,24 @@
1
- import { useForm, type UseFormOptions } from './form-hooks.svelte';
2
- import { useQueryClient } from '@tanstack/svelte-query';
1
+ import { definedOptions } from './defined-options';
3
2
  import { useParsed } from './useParsed.svelte';
4
3
  import { captureAdminContext } from './context.svelte';
5
- import { dataQueryMatches } from './query-keys';
6
4
  import { getAdminOptions } from './options.svelte';
7
5
  import { t } from './i18n.svelte';
8
- import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
6
+ import type { BaseRecord,HttpError,Filter,KnownResources,ResourceDefinition } from './types';
9
7
  import { useList } from './query-hooks.svelte';
10
8
  import { toast } from './toast.svelte';
11
- import type { UseSelectOptions } from './hooks.svelte';
12
- import { useSelect as useSelectImpl } from './hooks.svelte';
9
+ import { createSelectQuery as useSelectImpl, type UseSelectOptions } from './select-query.svelte';
10
+ import { decodeBaseRecord } from './record-decoder';
13
11
 
14
12
  // ─── useModal ─────────────────────────────────────────────────
15
13
  export function useModal(options?: { defaultVisible?: boolean }) {
16
- let visible = $state(options?.defaultVisible ?? false);
14
+ let visible=$state(options?.defaultVisible??false);
17
15
  return {
18
16
  get visible() { return visible; },
19
- show() { visible = true; },
20
- close() { visible = false; },
17
+ show() { visible=true; },
18
+ close() { visible=false; },
21
19
  };
22
20
  }
23
21
 
24
- // ─── useModalForm ─────────────────────────────────────────────
25
-
26
- export interface UseModalFormOptions<
27
- TVariables extends Record<string, unknown> = Record<string, unknown>,
28
- TData extends BaseRecord = BaseRecord,
29
- TError = HttpError,
30
- > extends UseFormOptions<TVariables, TData, TError> {
31
- defaultVisible?: boolean;
32
- autoSave?: UseFormOptions<TVariables, TData, TError>['autoSave'] & {
33
- invalidateOnClose?: boolean;
34
- };
35
- }
36
-
37
- export function useModalForm<
38
- TVariables extends Record<string, unknown> = Record<string, unknown>,
39
- TData extends BaseRecord = BaseRecord,
40
- TError = HttpError,
41
- >(options: UseModalFormOptions<TVariables, TData, TError> = {} as UseModalFormOptions<TVariables, TData, TError>) {
42
- const queryClient = useQueryClient();
43
- const adminContext = captureAdminContext();
44
- const formState = useForm<TVariables, TData, TError>(options);
45
- let visible = $state(options.defaultVisible ?? false);
46
-
47
- function show(id?: string | number) {
48
- if (id !== undefined) {
49
- formState.setAction('edit');
50
- formState.setId(id);
51
- } else {
52
- formState.setAction('create');
53
- formState.setId(undefined);
54
- }
55
- visible = true;
56
- }
57
-
58
- function close() {
59
- visible = false;
60
- formState.reset();
61
- if (options.autoSave?.invalidateOnClose) {
62
- const resource = formState.resource;
63
- const matcher = adminContext.queryKeyMatcher(resource, options.dataProviderName);
64
- queryClient.invalidateQueries({
65
- predicate: (q) => dataQueryMatches(q.queryKey, { ...matcher, resource }),
66
- });
67
- }
68
- }
69
-
70
- return {
71
- get visible() { return visible; },
72
- show,
73
- close,
74
- get modalProps() { return { visible, close }; },
75
- formState,
76
- };
77
- }
78
-
79
- export const useDrawerForm = useModalForm; // Alias for UI variations
80
-
81
22
  // ─── useMenu ──────────────────────────────────────────────────
82
23
 
83
24
  export interface MenuConfig {
@@ -90,31 +31,33 @@ export interface MenuConfig {
90
31
  }
91
32
 
92
33
  export function useMenu() {
93
- const adminContext = captureAdminContext();
94
- const parsed = useParsed();
95
-
96
- const menuItems = $derived.by<MenuConfig[]>(() => {
97
- const adminOptions = getAdminOptions();
98
- const resources = adminContext.resources;
99
- let items: MenuConfig[] = resources
100
- .filter((r: ResourceDefinition) => r.showInMenu !== false)
34
+ const adminContext=captureAdminContext();
35
+ const parsed=useParsed();
36
+
37
+ const menuItems=$derived.by<MenuConfig[]>(() => {
38
+ const adminOptions=getAdminOptions();
39
+ const resources=adminContext.resources;
40
+ let items: MenuConfig[]=resources
41
+ .filter((r: ResourceDefinition) => r.showInMenu!==false)
101
42
  .map((r: ResourceDefinition): MenuConfig => ({
102
43
  name: r.name,
103
44
  route: `/${r.name}`,
104
- label: r.label || r.name, // Usually handled by i18n label mapping
105
- icon: r.icon,
106
- parentName: r.parentName,
107
- order: r.menuOrder ?? 999,
45
+ label: r.label||r.name, // Usually handled by i18n label mapping
46
+ ...definedOptions({
47
+ icon: r.icon,
48
+ parentName: r.parentName,
49
+ }),
50
+ order: r.menuOrder??999,
108
51
  }));
109
52
 
110
- if (adminOptions.menuItems) items = adminOptions.menuItems(items);
111
- items.sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
53
+ if(adminOptions.menuItems) items=adminOptions.menuItems(items);
54
+ items.sort((a,b) => (a.order??999)-(b.order??999));
112
55
  return items;
113
56
  });
114
57
 
115
58
  return {
116
59
  get menuItems() { return menuItems; },
117
- get selectedKey() { return parsed.resource ?? '/'; },
60
+ get selectedKey() { return parsed.resource??'/'; },
118
61
  };
119
62
  }
120
63
 
@@ -127,32 +70,32 @@ export interface BreadcrumbItem {
127
70
  }
128
71
 
129
72
  export function useBreadcrumb() {
130
- const adminContext = captureAdminContext();
131
- const parsed = useParsed();
132
-
133
- const items = $derived.by<BreadcrumbItem[]>(() => {
134
- const list: BreadcrumbItem[] = [];
135
- if (!parsed.resource) return list;
73
+ const adminContext=captureAdminContext();
74
+ const parsed=useParsed();
75
+
76
+ const items=$derived.by<BreadcrumbItem[]>(() => {
77
+ const list: BreadcrumbItem[]=[];
78
+ if(!parsed.resource) return list;
136
79
 
137
80
  try {
138
- const res = adminContext.getResource(parsed.resource);
139
- const chain: { label: string; link: string; icon?: string }[] = [];
140
- let current: typeof res | undefined = res;
141
- const visited = new Set<string>();
142
- while (current) {
143
- if (visited.has(current.name)) break;
81
+ const res=adminContext.getResource(parsed.resource);
82
+ const chain: { label: string; link: string; icon?: string }[]=[];
83
+ let current: typeof res|undefined=res;
84
+ const visited=new Set<string>();
85
+ while(current) {
86
+ if(visited.has(current.name)) break;
144
87
  visited.add(current.name);
145
- chain.unshift({ label: current.label || current.name, link: `/${current.name}`, icon: current.icon });
146
- current = current.parentName ? adminContext.getResource(current.parentName) : undefined;
88
+ chain.unshift({ label: current.label||current.name,link: `/${current.name}`,...definedOptions({ icon: current.icon }) });
89
+ current=current.parentName? adminContext.getResource(current.parentName):undefined;
147
90
  }
148
91
  list.push(...chain);
149
92
 
150
- if (parsed.action === 'create') {
93
+ if(parsed.action==='create') {
151
94
  list.push({ label: t('common.create') });
152
- } else if (parsed.action === 'edit' && parsed.id) {
95
+ } else if(parsed.action==='edit'&&parsed.id) {
153
96
  list.push({ label: t('common.edit') });
154
97
  list.push({ label: String(parsed.id) });
155
- } else if (parsed.action === 'show' && parsed.id) {
98
+ } else if(parsed.action==='show'&&parsed.id) {
156
99
  list.push({ label: t('common.show') });
157
100
  list.push({ label: String(parsed.id) });
158
101
  }
@@ -167,48 +110,48 @@ export function useBreadcrumb() {
167
110
 
168
111
  // ─── useRelation ───────────────────────────────────────────
169
112
 
170
- export interface UseRelationOptions<_TData extends BaseRecord = BaseRecord, _TError = HttpError> {
113
+ export interface UseRelationOptions<_TData extends BaseRecord=BaseRecord,_TError=HttpError> {
171
114
  resource: KnownResources;
172
- id?: string | number;
115
+ id?: string|number;
173
116
  pagination?: { current?: number; pageSize?: number };
174
- sorters?: { field: string; order: 'asc' | 'desc' }[];
117
+ sorters?: { field: string; order: 'asc'|'desc' }[];
175
118
  filters?: Filter[];
176
119
  queryOptions?: { enabled?: boolean; staleTime?: number };
177
120
  }
178
121
 
179
- export function useRelation<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseRelationOptions<TData, TError>) {
180
- return useList<TData, TError>({
122
+ export function useRelation(options: UseRelationOptions) {
123
+ return useList({
181
124
  ...options
182
125
  });
183
126
  }
184
127
 
185
128
  // ─── useNotification ────────────────────────────────────────
186
129
  export function useNotification() {
187
- const adminContext = captureAdminContext();
130
+ const adminContext=captureAdminContext();
188
131
 
189
132
  function sendToast(
190
- type: 'success' | 'error' | 'warning' | 'info',
133
+ type: 'success'|'error'|'warning'|'info',
191
134
  message: string,
192
135
  duration?: number,
193
136
  options?: { key?: string },
194
137
  ): void {
195
- const provider = adminContext.notificationProvider;
196
- if (provider) {
197
- provider.open({ type, message, key: options?.key });
138
+ const provider=adminContext.notificationProvider;
139
+ if(provider) {
140
+ provider.open({ type,message,...definedOptions({ key: options?.key }) });
198
141
  return;
199
142
  }
200
- toast[type](message, duration, options);
143
+ toast[type](message,duration,options);
201
144
  }
202
145
 
203
146
  return {
204
- open: (message: string, duration?: number, options?: { key?: string }) =>
205
- sendToast('info', message, duration, options),
206
- success: (message: string, duration?: number, options?: { key?: string }) =>
207
- sendToast('success', message, duration, options),
208
- error: (message: string, duration?: number, options?: { key?: string }) =>
209
- sendToast('error', message, duration, options),
210
- warning: (message: string, duration?: number, options?: { key?: string }) =>
211
- sendToast('warning', message, duration, options),
147
+ open: (message: string,duration?: number,options?: { key?: string }) =>
148
+ sendToast('info',message,duration,options),
149
+ success: (message: string,duration?: number,options?: { key?: string }) =>
150
+ sendToast('success',message,duration,options),
151
+ error: (message: string,duration?: number,options?: { key?: string }) =>
152
+ sendToast('error',message,duration,options),
153
+ warning: (message: string,duration?: number,options?: { key?: string }) =>
154
+ sendToast('warning',message,duration,options),
212
155
  close: (key: string) => adminContext.notificationProvider?.close(key),
213
156
  };
214
157
  }
@@ -217,34 +160,34 @@ export function useNotification() {
217
160
  // useCheckboxGroup / useRadioGroup / useAutocomplete are convenience
218
161
  // wrappers around useSelect for common form field patterns.
219
162
 
220
- export interface UseCheckboxGroupOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'optionLabel' | 'optionValue' | 'defaultValue'> {
163
+ export interface UseCheckboxGroupOptions<TData extends BaseRecord=BaseRecord> extends Omit<UseSelectOptions<TData>,'optionLabel'|'optionValue'|'defaultValue'> {
221
164
  /** Field on the record used as the option label (default: 'title') */
222
- optionLabel?: string | ((item: TData) => string);
165
+ optionLabel?: string|((item: TData) => string);
223
166
  /** Field on the record used as the checkbox value (default: 'id') */
224
- optionValue?: string | ((item: TData) => string | number);
167
+ optionValue?: string|((item: TData) => string|number);
225
168
  /** Currently selected values — controlled mode */
226
- defaultValue?: (string | number)[];
169
+ defaultValue?: (string|number)[];
227
170
  }
228
171
 
229
172
  /**
230
173
  * useCheckboxGroup — multi-select field state backed by a resource query.
231
174
  * Provides a list of checkbox options plus helpers to toggle individual values.
232
175
  */
233
- export function useCheckboxGroup<TData extends BaseRecord = BaseRecord>(options: UseCheckboxGroupOptions<TData>) {
234
- const select = useSelectImpl<TData>(options);
235
- let selected = $state<(string | number)[]>(options.defaultValue ?? []);
236
-
237
- function toggle(value: string | number) {
238
- const idx = selected.findIndex(v => String(v) === String(value));
239
- if (idx >= 0) {
240
- selected = selected.filter(v => String(v) !== String(value));
176
+ export function useCheckboxGroup(options: UseCheckboxGroupOptions) {
177
+ const select=useSelectImpl(options, () => decodeBaseRecord);
178
+ let selected=$state<(string|number)[]>(options.defaultValue??[]);
179
+
180
+ function toggle(value: string|number) {
181
+ const idx=selected.findIndex(v => String(v)===String(value));
182
+ if(idx>=0) {
183
+ selected=selected.filter(v => String(v)!==String(value));
241
184
  } else {
242
- selected = [...selected, value];
185
+ selected=[...selected,value];
243
186
  }
244
187
  }
245
188
 
246
- function isSelected(value: string | number): boolean {
247
- return selected.some(v => String(v) === String(value));
189
+ function isSelected(value: string|number): boolean {
190
+ return selected.some(v => String(v)===String(value));
248
191
  }
249
192
 
250
193
  return {
@@ -254,70 +197,70 @@ export function useCheckboxGroup<TData extends BaseRecord = BaseRecord>(options:
254
197
  get selected() { return selected; },
255
198
  toggle,
256
199
  isSelected,
257
- setSelected: (values: (string | number)[]) => { selected = values; },
200
+ setSelected: (values: (string|number)[]) => { selected=values; },
258
201
  };
259
202
  }
260
203
 
261
- export interface UseRadioGroupOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'optionLabel' | 'optionValue' | 'defaultValue'> {
262
- optionLabel?: string | ((item: TData) => string);
263
- optionValue?: string | ((item: TData) => string | number);
264
- defaultValue?: string | number;
204
+ export interface UseRadioGroupOptions<TData extends BaseRecord=BaseRecord> extends Omit<UseSelectOptions<TData>,'optionLabel'|'optionValue'|'defaultValue'> {
205
+ optionLabel?: string|((item: TData) => string);
206
+ optionValue?: string|((item: TData) => string|number);
207
+ defaultValue?: string|number;
265
208
  }
266
209
 
267
210
  /**
268
211
  * useRadioGroup — single-select field state backed by a resource query.
269
212
  * Provides a list of radio options plus a setter for the currently selected value.
270
213
  */
271
- export function useRadioGroup<TData extends BaseRecord = BaseRecord>(options: UseRadioGroupOptions<TData>) {
272
- const { defaultValue: _dv, ...selectOpts } = options;
273
- // useSelect 的 defaultValue 是数组类型;透传单值以便远程选项回填当前已选值
274
- const select = useSelectImpl<TData>({
214
+ export function useRadioGroup(options: UseRadioGroupOptions) {
215
+ const { defaultValue: _dv,...selectOpts }=options;
216
+ // useSelect defaultValue is an array; pass the scalar value through so remote options can restore the selection.
217
+ const select=useSelectImpl({
275
218
  ...selectOpts,
276
- defaultValue: _dv !== undefined ? [_dv] : [],
277
- });
278
- let value = $state<string | number | undefined>(_dv);
219
+ defaultValue: _dv!==undefined? [_dv]:[],
220
+ }, () => decodeBaseRecord);
221
+ let value=$state<string|number|undefined>(_dv);
279
222
 
280
223
  return {
281
224
  query: select.query,
282
225
  get options() { return select.options; },
283
226
  onSearchChange: select.onSearchChange,
284
227
  get value() { return value; },
285
- setValue: (v: string | number) => { value = v; },
228
+ setValue: (v: string|number) => { value=v; },
286
229
  };
287
230
  }
288
231
 
289
- export interface UseAutocompleteOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'onSearch' | 'defaultValue'> {
290
- optionLabel?: string | ((item: TData) => string);
291
- optionValue?: string | ((item: TData) => string | number);
232
+ export interface UseAutocompleteOptions<TData extends BaseRecord=BaseRecord> extends Omit<UseSelectOptions<TData>,'onSearch'|'defaultValue'> {
233
+ optionLabel?: string|((item: TData) => string);
234
+ optionValue?: string|((item: TData) => string|number);
292
235
  /** Field name to search against (default: optionLabel) */
293
236
  searchField?: string;
294
- defaultValue?: string | number;
237
+ defaultValue?: string|number;
295
238
  }
296
239
 
297
240
  /**
298
241
  * useAutocomplete — typeahead search field backed by a resource query.
299
242
  * Builds a 'contains' filter on the search field and debounces input.
300
243
  */
301
- export function useAutocomplete<TData extends BaseRecord = BaseRecord>(options: UseAutocompleteOptions<TData>) {
302
- const { searchField, defaultValue: _dv, ...selectOpts } = options;
303
- const labelField = typeof options.optionLabel === 'string' ? options.optionLabel : 'title';
244
+ export function useAutocomplete(options: UseAutocompleteOptions) {
245
+ const { searchField,defaultValue: _dv,...selectOpts }=options;
246
+ const labelField=typeof options.optionLabel==='string'? options.optionLabel:'title';
304
247
 
305
- const select = useSelectImpl<TData>({
248
+ const select=useSelectImpl({
306
249
  ...selectOpts,
307
- defaultValue: _dv !== undefined ? [_dv] : [],
250
+ defaultValue: _dv!==undefined? [_dv]:[],
308
251
  onSearch: (term) => [{
309
- field: searchField ?? labelField,
252
+ field: searchField??labelField,
310
253
  operator: 'contains' as const,
311
254
  value: term,
312
255
  }],
313
- });
314
- let value = $state<string | number | undefined>(_dv);
256
+ }, () => decodeBaseRecord);
257
+ let value=$state<string|number|undefined>(_dv);
315
258
 
316
259
  return {
317
260
  query: select.query,
318
261
  get options() { return select.options; },
319
262
  onSearchChange: select.onSearchChange,
320
263
  get value() { return value; },
321
- setValue: (v: string | number) => { value = v; },
264
+ setValue: (v: string|number) => { value=v; },
322
265
  };
323
266
  }