@svadmin/core 0.0.3 → 0.3.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 (41) hide show
  1. package/package.json +4 -1
  2. package/src/audit.ts +37 -0
  3. package/src/auth-hooks.svelte.ts +293 -0
  4. package/src/auth-hooks.test.ts +205 -0
  5. package/src/context.ts +77 -15
  6. package/src/data-transfer.test.ts +118 -0
  7. package/src/data-transfer.ts +148 -70
  8. package/src/form-hooks.svelte.ts +266 -0
  9. package/src/helpers.ts +157 -0
  10. package/src/hook-utils.svelte.ts +121 -0
  11. package/src/hooks.svelte.ts +278 -496
  12. package/src/http-error.test.ts +85 -0
  13. package/src/i18n.svelte.ts +24 -4
  14. package/src/i18n.test.ts +90 -0
  15. package/src/index.ts +51 -8
  16. package/src/inferencer-openapi.test.ts +192 -0
  17. package/src/inferencer-openapi.ts +290 -0
  18. package/src/inferencer.test.ts +189 -0
  19. package/src/inferencer.ts +247 -0
  20. package/src/live-sse.ts +146 -0
  21. package/src/live-websocket.ts +141 -0
  22. package/src/live.test.ts +134 -0
  23. package/src/live.ts +60 -8
  24. package/src/mutation-hooks.svelte.ts +301 -0
  25. package/src/options.ts +95 -0
  26. package/src/permissions.test.ts +55 -0
  27. package/src/query-hooks.svelte.ts +213 -0
  28. package/src/query-keys.ts +22 -0
  29. package/src/resource-types.test.ts +79 -0
  30. package/src/router-provider.test.ts +71 -0
  31. package/src/router-provider.ts +100 -0
  32. package/src/router.ts +28 -4
  33. package/src/routing-hooks.svelte.ts +99 -0
  34. package/src/table-hooks.svelte.ts +178 -0
  35. package/src/toast.svelte.ts +8 -4
  36. package/src/types.ts +124 -47
  37. package/src/unsaved-changes.svelte.ts +62 -0
  38. package/src/url-sync.ts +15 -0
  39. package/src/useParsed.ts +110 -0
  40. package/src/useStepsForm.svelte.ts +71 -0
  41. package/src/utility-hooks.svelte.ts +161 -0
@@ -1,583 +1,365 @@
1
- // Complete CRUD hooks — 100% Refine-compatible
2
- // TanStack Query v6 (Svelte 5 runes-native) wrappers
3
-
4
- import {
5
- createQuery,
6
- createMutation,
7
- createInfiniteQuery,
8
- useQueryClient,
9
- } from '@tanstack/svelte-query';
10
- import { getDataProvider } from './context';
11
- import type { GetListResult, Sort, Filter, Pagination, MutationMode } from './types';
12
- import { toast } from './toast.svelte';
1
+ // @svadmin/core — Core Data and Hook APIs (Modularized for v0.2.29+)
2
+
3
+ // ─── Re-exports from modular hook files ─────────────────────────────
4
+
5
+ export * from './query-hooks.svelte';
6
+ export * from './mutation-hooks.svelte';
7
+ export * from './form-hooks.svelte';
8
+ export * from './table-hooks.svelte';
9
+ export * from './routing-hooks.svelte';
10
+ export * from './utility-hooks.svelte';
11
+
12
+ // ─── Re-export types from options ─────────────────────────────────
13
+ export type { InvalidateScope, OvertimeConfig } from './options';
14
+
15
+ // ─── Export shared utilities ────────────────────────────────────────
16
+
17
+ export { createOvertimeTracker, createLiveSubscription } from './hook-utils.svelte';
18
+ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook-utils.svelte';
19
+
20
+ // ─── Missing hooks stubs ───────────────────────────────────────────
21
+ // These hooks were in the original monolithic file. They are re-created
22
+ // here as thin wrappers or stubs until they get their own modular files.
23
+
24
+ import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
25
+ import { getAdminOptions } from './options';
26
+ import { getDataProviderForResource, getDataProvider } from './context';
27
+ import { useParsed } from './useParsed';
28
+ import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
29
+ import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
30
+ import type { BaseRecord, HttpError, Pagination, Sort, Filter, DataProvider, KnownResources } from './types';
31
+ import type { LiveMode, LiveEvent } from './live';
32
+ import { useList } from './query-hooks.svelte';
33
+ import type { UseListOptions } from './query-hooks.svelte';
13
34
  import { audit } from './audit';
14
- import { navigate } from './router';
15
- import { t } from './i18n.svelte';
16
- import { readURLState, writeURLState } from './url-sync';
17
35
 
18
- // ─── useList ────────────────────────────────────────────────────
36
+ // ─── useInfiniteList ────────────────────────────────────────────────
19
37
 
20
- interface UseListOptions {
21
- resource: string;
38
+ export interface UseInfiniteListOptions<TData extends BaseRecord = BaseRecord, TError = HttpError> {
39
+ resource?: KnownResources;
22
40
  pagination?: Pagination;
23
41
  sorters?: Sort[];
24
42
  filters?: Filter[];
25
43
  meta?: Record<string, unknown>;
26
- enabled?: boolean;
44
+ dataProviderName?: string;
45
+ queryOptions?: { staleTime?: number; enabled?: boolean };
46
+ successNotification?: NotificationConfig;
47
+ errorNotification?: NotificationConfig;
48
+ liveMode?: LiveMode;
49
+ onLiveEvent?: (event: LiveEvent) => void;
50
+ overtimeOptions?: OvertimeOptions;
27
51
  }
28
52
 
29
- export function useList<T = Record<string, unknown>>(options: UseListOptions) {
30
- const provider = getDataProvider();
31
- const { resource, pagination, sorters, filters, meta, enabled = true } = options;
53
+ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseInfiniteListOptions<TData, TError> = {}) {
54
+ const parsed = useParsed();
55
+ const resource = options.resource ?? parsed.resource ?? '';
56
+ const adminOptions = getAdminOptions();
57
+ const provider = getDataProviderForResource(resource, options.dataProviderName);
32
58
 
33
- return createQuery<GetListResult<T>>(() => ({
34
- queryKey: [resource, 'list', { pagination, sorters, filters }],
35
- queryFn: () => provider.getList<T>({ resource, pagination, sorters, filters, meta }),
36
- enabled,
37
- }));
38
- }
39
-
40
- // ─── useInfiniteList ────────────────────────────────────────────
41
-
42
- interface UseInfiniteListOptions {
43
- resource: string;
44
- pageSize?: number;
45
- sorters?: Sort[];
46
- filters?: Filter[];
47
- meta?: Record<string, unknown>;
48
- enabled?: boolean;
49
- }
50
-
51
- export function useInfiniteList<T = Record<string, unknown>>(options: UseInfiniteListOptions) {
52
- const provider = getDataProvider();
53
- const { resource, pageSize = 10, sorters, filters, meta, enabled = true } = options;
54
-
55
- return createInfiniteQuery<GetListResult<T>>(() => ({
56
- queryKey: [resource, 'infinite', { sorters, filters }],
57
- queryFn: ({ pageParam = 1 }) =>
58
- provider.getList<T>({
59
+ const query = createInfiniteQuery<{ data: TData[]; total: number }, TError>(() => ({
60
+ queryKey: [resource, 'infiniteList', options.sorters, options.filters, options.meta],
61
+ queryFn: async ({ pageParam = 1 }) => {
62
+ const result = await provider.getList<TData>({
59
63
  resource,
60
- pagination: { current: pageParam as number, pageSize },
61
- sorters,
62
- filters,
63
- meta,
64
- }),
65
- initialPageParam: 1,
66
- getNextPageParam: (lastPage, allPages) => {
67
- const totalFetched = allPages.reduce((sum, p) => sum + p.data.length, 0);
68
- return totalFetched < lastPage.total ? allPages.length + 1 : undefined;
64
+ pagination: { current: pageParam as number, pageSize: options.pagination?.pageSize ?? 10 },
65
+ sorters: options.sorters,
66
+ filters: options.filters,
67
+ meta: options.meta,
68
+ });
69
+ return result;
69
70
  },
70
- enabled,
71
- }));
72
- }
73
-
74
- // ─── useOne ─────────────────────────────────────────────────────
75
-
76
- interface UseOneOptions {
77
- resource: string;
78
- id: string | number;
79
- meta?: Record<string, unknown>;
80
- enabled?: boolean;
81
- }
82
-
83
- export function useOne<T = Record<string, unknown>>(options: UseOneOptions) {
84
- const provider = getDataProvider();
85
- const { resource, id, meta, enabled = true } = options;
86
-
87
- return createQuery(() => ({
88
- queryKey: [resource, 'one', id],
89
- queryFn: async () => {
90
- const result = await provider.getOne<T>({ resource, id, meta });
91
- return result.data;
71
+ initialPageParam: 1,
72
+ getNextPageParam: (lastPage: { data: TData[]; total: number }, allPages: { data: TData[]; total: number }[]) => {
73
+ const totalFetched = allPages.reduce((acc: number, p) => acc + (p.data?.length ?? 0), 0);
74
+ if (totalFetched >= (lastPage.total ?? 0)) return undefined;
75
+ return allPages.length + 1;
92
76
  },
93
- enabled: enabled && id != null,
77
+ enabled: options.queryOptions?.enabled ?? true,
78
+ staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
94
79
  }));
95
- }
96
80
 
97
- // ─── useShow (alias for useOne with route awareness) ────────────
81
+ const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
98
82
 
99
- export function useShow<T = Record<string, unknown>>(options: UseOneOptions) {
100
- return useOne<T>(options);
83
+ return { query, get overtime() { return overtime; } };
101
84
  }
102
85
 
103
- // ─── useSelect ──────────────────────────────────────────────────
86
+ // ─── useSelect ──────────────────────────────────────────────────────
104
87
 
105
- interface UseSelectOptions {
106
- resource: string;
107
- optionLabel?: string;
108
- optionValue?: string;
109
- filters?: Filter[];
88
+ export interface UseSelectOptions<TData extends BaseRecord = BaseRecord, TOption = { label: string; value: string | number }> {
89
+ resource: KnownResources;
90
+ optionLabel?: string | ((item: TData) => string);
91
+ optionValue?: string | ((item: TData) => string | number);
110
92
  sorters?: Sort[];
111
- enabled?: boolean;
93
+ filters?: Filter[];
94
+ defaultValue?: (string | number)[];
95
+ fetchSize?: number;
96
+ pagination?: Pagination;
97
+ debounce?: number;
98
+ queryOptions?: { staleTime?: number; enabled?: boolean };
99
+ defaultValueQueryOptions?: { staleTime?: number; enabled?: boolean };
100
+ meta?: Record<string, unknown>;
101
+ dataProviderName?: string;
102
+ onSearch?: (value: string) => Filter[];
103
+ successNotification?: NotificationConfig;
104
+ errorNotification?: NotificationConfig;
105
+ overtimeOptions?: OvertimeOptions;
112
106
  }
113
107
 
114
- export function useSelect(options: UseSelectOptions) {
115
- const provider = getDataProvider();
116
- const {
117
- resource, optionLabel = 'name', optionValue = 'id',
118
- filters, sorters, enabled = true,
119
- } = options;
108
+ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { label: string; value: string | number }>(options: UseSelectOptions<TData, TOption>) {
109
+ const { resource, optionLabel = 'title', optionValue = 'id', sorters, filters, pagination, meta, dataProviderName, onSearch, debounce: debounceMs = 300 } = options;
110
+ const adminOptions = getAdminOptions();
111
+ const provider = getDataProviderForResource(resource, dataProviderName);
120
112
 
121
- return createQuery(() => ({
122
- queryKey: [resource, 'select', { optionLabel, optionValue, filters }],
123
- queryFn: async () => {
124
- const result = await provider.getList({ resource, pagination: { current: 1, pageSize: 1000 }, filters, sorters });
125
- return result.data.map(item => ({
126
- label: String(item[optionLabel] ?? ''),
127
- value: String(item[optionValue] ?? ''),
128
- }));
129
- },
130
- enabled,
131
- staleTime: 10 * 60 * 1000,
132
- }));
133
- }
113
+ let searchText = $state('');
114
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
134
115
 
135
- // ─── useMany ────────────────────────────────────────────────────
116
+ const searchFilters = $derived.by<Filter[]>(() => {
117
+ if (!onSearch || !searchText) return [];
118
+ return onSearch(searchText);
119
+ });
136
120
 
137
- interface UseManyOptions {
138
- resource: string;
139
- ids: (string | number)[];
140
- meta?: Record<string, unknown>;
141
- enabled?: boolean;
142
- }
121
+ const allFilters = $derived([...(filters ?? []), ...searchFilters]);
143
122
 
144
- export function useMany<T = Record<string, unknown>>(options: UseManyOptions) {
145
- const provider = getDataProvider();
146
- const { resource, ids, meta, enabled = true } = options;
123
+ const effectivePageSize = options.fetchSize ?? pagination?.pageSize ?? 999;
147
124
 
148
- return createQuery(() => ({
149
- queryKey: [resource, 'many', ids],
150
- queryFn: async () => {
151
- if (provider.getMany) {
152
- const result = await provider.getMany<T>({ resource, ids, meta });
153
- return result.data;
154
- }
155
- const results = await Promise.all(ids.map(id => provider.getOne<T>({ resource, id, meta })));
156
- return results.map(r => r.data);
157
- },
158
- enabled: enabled && ids.length > 0,
125
+ const query = createQuery<{ data: TData[]; total: number }>(() => ({
126
+ queryKey: [resource, 'select', allFilters, sorters, pagination, meta],
127
+ queryFn: () => provider.getList<TData>({ resource, sorters, filters: allFilters, pagination: { current: 1, pageSize: effectivePageSize }, meta }),
128
+ enabled: options.queryOptions?.enabled ?? true,
129
+ staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
159
130
  }));
160
- }
161
-
162
- // ─── useCustom ──────────────────────────────────────────────────
163
131
 
164
- interface UseCustomQueryOptions<T = unknown> {
165
- queryKey: unknown[];
166
- queryFn: () => Promise<T>;
167
- enabled?: boolean;
168
- }
169
-
170
- export function useCustom<T = unknown>(options: UseCustomQueryOptions<T>) {
171
- return createQuery(() => ({
172
- queryKey: options.queryKey,
173
- queryFn: options.queryFn,
174
- enabled: options.enabled ?? true,
175
- }));
176
- }
132
+ // Parallel query to ensure default values are always available in options
133
+ const defaultValueIds = options.defaultValue ?? [];
134
+ const defaultValueQuery = defaultValueIds.length > 0
135
+ ? createQuery<{ data: TData[] }>(() => ({
136
+ queryKey: [resource, 'select-defaults', defaultValueIds],
137
+ queryFn: async () => {
138
+ if (provider.getMany) return provider.getMany<TData>({ resource, ids: defaultValueIds, meta });
139
+ const results = await Promise.all(defaultValueIds.map(id => provider.getOne<TData>({ resource, id, meta })));
140
+ return { data: results.map(r => r.data) };
141
+ },
142
+ enabled: (options.defaultValueQueryOptions?.enabled ?? true) && defaultValueIds.length > 0,
143
+ staleTime: options.defaultValueQueryOptions?.staleTime ?? Infinity,
144
+ }))
145
+ : null;
177
146
 
178
- // ─── useApiUrl ──────────────────────────────────────────────────
147
+ const selectOptions = $derived.by(() => {
148
+ const queryResult = query.data as { data: TData[]; total: number } | undefined;
149
+ const data: TData[] = queryResult?.data ?? [];
150
+ // Merge default value items if not already in data
151
+ const defaultQueryResult = defaultValueQuery as { data: { data: TData[] } | undefined } | null;
152
+ const defaultData: TData[] = defaultQueryResult?.data?.data ?? [];
153
+ const allData: TData[] = [...data];
154
+ const existingIds = new Set(data.map((d: TData) => String((d as Record<string, unknown>)[typeof optionValue === 'string' ? optionValue : 'id'])));
155
+ for (const item of defaultData) {
156
+ const itemId = String((item as Record<string, unknown>)[typeof optionValue === 'string' ? optionValue : 'id']);
157
+ if (!existingIds.has(itemId)) allData.push(item);
158
+ }
159
+ return allData.map((item: TData) => {
160
+ const label = typeof optionLabel === 'function' ? optionLabel(item) : String((item as Record<string, unknown>)[optionLabel] ?? '');
161
+ const value = typeof optionValue === 'function' ? optionValue(item) : (item as Record<string, unknown>)[optionValue];
162
+ return { label, value } as unknown as TOption;
163
+ });
164
+ });
179
165
 
180
- export function useApiUrl(): string {
181
- const provider = getDataProvider();
182
- return provider.getApiUrl();
183
- }
166
+ const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
184
167
 
185
- // ─── useNavigation ──────────────────────────────────────────────
168
+ function onSearchChange(value: string) {
169
+ if (debounceTimer) clearTimeout(debounceTimer);
170
+ debounceTimer = setTimeout(() => { searchText = value; }, debounceMs);
171
+ }
186
172
 
187
- export function useNavigation() {
188
173
  return {
189
- list: (resource: string) => navigate(`/${resource}`),
190
- create: (resource: string) => navigate(`/${resource}/create`),
191
- edit: (resource: string, id: string | number) => navigate(`/${resource}/edit/${id}`),
192
- show: (resource: string, id: string | number) => navigate(`/${resource}/show/${id}`),
193
- goBack: () => history.back(),
194
- push: (path: string) => navigate(path),
174
+ query,
175
+ get options() { return selectOptions; },
176
+ get overtime() { return overtime; },
177
+ onSearchChange,
195
178
  };
196
179
  }
197
180
 
198
- // ─── useResource ────────────────────────────────────────────────
199
-
200
- export { getResource as useResource } from './context';
181
+ // ─── useCustom ──────────────────────────────────────────────────────
201
182
 
202
- // ─── Mutation options ───────────────────────────────────────────
203
-
204
- interface UseMutationOptions {
205
- showToast?: boolean;
206
- auditLog?: boolean;
207
- mutationMode?: MutationMode;
183
+ export interface UseCustomOptions<TData = unknown, TError = HttpError> {
184
+ url: string;
185
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete';
186
+ config?: { payload?: unknown; query?: Record<string, unknown>; headers?: Record<string, string>; sorters?: Sort[]; filters?: Filter[] };
187
+ meta?: Record<string, unknown>;
188
+ dataProviderName?: string;
189
+ queryOptions?: { staleTime?: number; enabled?: boolean };
190
+ successNotification?: NotificationConfig;
191
+ errorNotification?: NotificationConfig;
192
+ overtimeOptions?: OvertimeOptions;
208
193
  }
209
194
 
210
- // ─── useCreate ──────────────────────────────────────────────────
195
+ export function useCustom<TData = unknown, TError = HttpError>(options: UseCustomOptions<TData, TError>) {
196
+ const adminOptions = getAdminOptions();
197
+ const provider = getDataProvider(options.dataProviderName);
211
198
 
212
- export function useCreate<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
213
- const provider = getDataProvider();
214
- const queryClient = useQueryClient();
215
- const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
216
-
217
- return createMutation(() => ({
218
- mutationFn: (variables: Record<string, unknown>) =>
219
- provider.create<T>({ resource, variables }),
220
- onMutate: mutationMode === 'optimistic'
221
- ? async (_variables: Record<string, unknown>) => {
222
- await queryClient.cancelQueries({ queryKey: [resource] });
223
- const previousData = queryClient.getQueryData([resource, 'list']);
224
- return { previousData };
225
- }
226
- : undefined,
227
- onSuccess: (data: { data: T }) => {
228
- queryClient.invalidateQueries({ queryKey: [resource] });
229
- if (showToast) toast.success(t('common.createSuccess'));
230
- if (auditLog) {
231
- const record = data.data as Record<string, unknown>;
232
- audit({ action: 'create', resource, recordId: record.id as string });
233
- }
234
- },
235
- onError: (error: Error, _variables: Record<string, unknown>, context: { previousData?: unknown } | undefined) => {
236
- if (mutationMode === 'optimistic' && context?.previousData) {
237
- queryClient.setQueryData([resource, 'list'], context.previousData);
238
- }
239
- if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
199
+ const query = createQuery<{ data: TData }, TError>(() => ({
200
+ queryKey: ['custom', options.url, options.method, options.config, options.meta],
201
+ queryFn: async () => {
202
+ if (!provider.custom) throw new Error('DataProvider does not support custom method');
203
+ return provider.custom<TData>({
204
+ url: options.url,
205
+ method: options.method,
206
+ payload: options.config?.payload as any,
207
+ query: options.config?.query,
208
+ headers: options.config?.headers,
209
+ sorters: options.config?.sorters,
210
+ filters: options.config?.filters,
211
+ meta: options.meta,
212
+ });
240
213
  },
214
+ enabled: options.queryOptions?.enabled ?? true,
215
+ staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
241
216
  }));
242
- }
243
217
 
244
- // ─── useCreateMany ──────────────────────────────────────────────
218
+ const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
245
219
 
246
- export function useCreateMany<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
247
- const provider = getDataProvider();
248
- const queryClient = useQueryClient();
249
- const { showToast = true, auditLog = true } = opts ?? {};
250
-
251
- return createMutation(() => ({
252
- mutationFn: async (variables: Record<string, unknown>[]) => {
253
- if (provider.createMany) {
254
- return provider.createMany<T>({ resource, variables });
255
- }
256
- const results = await Promise.all(variables.map(v => provider.create<T>({ resource, variables: v })));
257
- return { data: results.map(r => r.data) };
258
- },
259
- onSuccess: () => {
260
- queryClient.invalidateQueries({ queryKey: [resource] });
261
- if (showToast) toast.success(t('common.createSuccess'));
262
- if (auditLog) audit({ action: 'create', resource, details: { batch: true } });
263
- },
264
- onError: (error: Error) => {
265
- if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
266
- },
267
- }));
220
+ return { query, get overtime() { return overtime; } };
268
221
  }
269
222
 
270
- // ─── useUpdate ──────────────────────────────────────────────────
271
-
272
- interface UpdateVariables {
273
- id: string | number;
274
- variables: Record<string, unknown>;
275
- }
223
+ // ─── useCustomMutation ──────────────────────────────────────────────
276
224
 
277
- export function useUpdate<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
278
- const provider = getDataProvider();
225
+ export function useCustomMutation<TData = unknown, TError = HttpError, TVariables = unknown>(dataProviderName?: string) {
226
+ const provider = getDataProvider(dataProviderName);
279
227
  const queryClient = useQueryClient();
280
- const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
281
-
282
- return createMutation<{ data: T }, Error, UpdateVariables>(() => ({
283
- mutationFn: ({ id, variables }: UpdateVariables) =>
284
- provider.update<T>({ resource, id, variables }),
285
- onMutate: mutationMode === 'optimistic'
286
- ? async ({ id }: UpdateVariables) => {
287
- await queryClient.cancelQueries({ queryKey: [resource, 'one', id] });
288
- const previousData = queryClient.getQueryData([resource, 'one', id]);
289
- return { previousData };
290
- }
291
- : undefined,
292
- onSuccess: (_data: { data: T }, vars: UpdateVariables) => {
293
- queryClient.invalidateQueries({ queryKey: [resource] });
294
- if (showToast) toast.success(t('common.updateSuccess'));
295
- if (auditLog) audit({ action: 'update', resource, recordId: vars.id });
296
- },
297
- onError: (error: Error) => {
298
- if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
299
- },
300
- }));
301
- }
302
228
 
303
- // ─── useUpdateMany ──────────────────────────────────────────────
304
-
305
- export function useUpdateMany<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
306
- const provider = getDataProvider();
307
- const queryClient = useQueryClient();
308
- const { showToast = true, auditLog = true } = opts ?? {};
309
-
310
- return createMutation(() => ({
311
- mutationFn: async ({ ids, variables }: { ids: (string | number)[]; variables: Record<string, unknown> }) => {
312
- if (provider.updateMany) {
313
- return provider.updateMany<T>({ resource, ids, variables });
314
- }
315
- const results = await Promise.all(ids.map(id => provider.update<T>({ resource, id, variables })));
316
- return { data: results.map(r => r.data) };
317
- },
318
- onSuccess: () => {
319
- queryClient.invalidateQueries({ queryKey: [resource] });
320
- if (showToast) toast.success(t('common.updateSuccess'));
321
- if (auditLog) audit({ action: 'update', resource, details: { batch: true } });
322
- },
323
- onError: (error: Error) => {
324
- if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
229
+ const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; meta?: Record<string, unknown> }>(() => ({
230
+ mutationFn: async (params) => {
231
+ if (!provider.custom) throw new Error('DataProvider does not support custom method');
232
+ return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values as any, meta: params.meta });
325
233
  },
326
234
  }));
235
+
236
+ return { mutation };
327
237
  }
328
238
 
329
- // ─── useDelete ──────────────────────────────────────────────────
239
+ // ─── useCreateMany / useUpdateMany / useDeleteMany ──────────────────
330
240
 
331
- export function useDelete(resource: string, opts?: UseMutationOptions) {
332
- const provider = getDataProvider();
241
+ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
242
+ const parsed = useParsed();
243
+ const resource = options.resource ?? parsed.resource ?? '';
244
+ const adminOptions = getAdminOptions();
333
245
  const queryClient = useQueryClient();
334
- const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
335
-
336
- return createMutation(() => ({
337
- mutationFn: (id: string | number) =>
338
- provider.deleteOne({ resource, id }),
339
- onMutate: mutationMode === 'optimistic'
340
- ? async (_id: string | number) => {
341
- await queryClient.cancelQueries({ queryKey: [resource] });
342
- const previousData = queryClient.getQueryData([resource, 'list']);
343
- return { previousData };
344
- }
345
- : undefined,
346
- onSuccess: (_data: unknown, id: string | number) => {
347
- queryClient.invalidateQueries({ queryKey: [resource] });
348
- if (showToast) toast.success(t('common.deleteSuccess'));
349
- if (auditLog) audit({ action: 'delete', resource, recordId: id });
350
- },
351
- onError: (error: Error) => {
352
- if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
246
+ let isMutating = $state(false);
247
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
248
+
249
+ const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; variables: TVariables[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
250
+ mutationFn: async (params) => {
251
+ isMutating = true;
252
+ const resName = params.resource ?? resource;
253
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
254
+ try {
255
+ if (provider.createMany) return await provider.createMany<TData, TVariables>({ resource: resName, variables: params.variables, meta: params.meta });
256
+ const results = await Promise.all(params.variables.map(v => provider.create<TData, TVariables>({ resource: resName, variables: v, meta: params.meta })));
257
+ return { data: results.map(r => r.data) };
258
+ } finally { isMutating = false; }
353
259
  },
260
+ onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
354
261
  }));
355
- }
356
262
 
357
- // ─── useDeleteMany ──────────────────────────────────────────────
263
+ return { mutation, get overtime() { return overtime; } };
264
+ }
358
265
 
359
- export function useDeleteMany(resource: string, opts?: UseMutationOptions) {
360
- const provider = getDataProvider();
266
+ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
267
+ const parsed = useParsed();
268
+ const resource = options.resource ?? parsed.resource ?? '';
269
+ const adminOptions = getAdminOptions();
361
270
  const queryClient = useQueryClient();
362
- const { showToast = true, auditLog = true } = opts ?? {};
363
-
364
- return createMutation(() => ({
365
- mutationFn: async (ids: (string | number)[]) => {
366
- if (provider.deleteMany) {
367
- return provider.deleteMany({ resource, ids });
368
- }
369
- const results = await Promise.all(ids.map(id => provider.deleteOne({ resource, id })));
370
- return { data: results.map(r => r.data) };
371
- },
372
- onSuccess: () => {
373
- queryClient.invalidateQueries({ queryKey: [resource] });
374
- if (showToast) toast.success(t('common.deleteSuccess'));
375
- if (auditLog) audit({ action: 'delete', resource, details: { batch: true } });
376
- },
377
- onError: (error: Error) => {
378
- if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
271
+ let isMutating = $state(false);
272
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
273
+
274
+ const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; variables: TVariables; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
275
+ mutationFn: async (params) => {
276
+ isMutating = true;
277
+ const resName = params.resource ?? resource;
278
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
279
+ try {
280
+ if (provider.updateMany) return await provider.updateMany<TData, TVariables>({ resource: resName, ids: params.ids, variables: params.variables, meta: params.meta });
281
+ const results = await Promise.all(params.ids.map(id => provider.update<TData, TVariables>({ resource: resName, id, variables: params.variables, meta: params.meta })));
282
+ return { data: results.map(r => r.data) };
283
+ } finally { isMutating = false; }
379
284
  },
285
+ onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
380
286
  }));
381
- }
382
287
 
383
- // ─── useForm ────────────────────────────────────────────────────
384
- // Standalone form hook (like Refine's useForm)
385
-
386
- interface UseFormOptions {
387
- resource: string;
388
- action: 'create' | 'edit' | 'clone';
389
- id?: string | number;
390
- redirect?: 'list' | 'edit' | 'show' | false;
391
- mutationMode?: MutationMode;
392
- onMutationSuccess?: (data: unknown) => void;
393
- onMutationError?: (error: Error) => void;
394
- meta?: Record<string, unknown>;
395
- validate?: (values: Record<string, unknown>) => Record<string, string> | null;
288
+ return { mutation, get overtime() { return overtime; } };
396
289
  }
397
290
 
398
- export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
399
- const provider = getDataProvider();
291
+ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
292
+ const parsed = useParsed();
293
+ const resource = options.resource ?? parsed.resource ?? '';
294
+ const adminOptions = getAdminOptions();
400
295
  const queryClient = useQueryClient();
401
- const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta, validate } = options;
402
-
403
- // Validation state
404
- let errors = $state<Record<string, string>>({});
405
-
406
- function setFieldError(field: string, message: string) {
407
- errors = { ...errors, [field]: message };
408
- }
409
-
410
- function clearErrors() {
411
- errors = {};
412
- }
413
-
414
- function clearFieldError(field: string) {
415
- const next = { ...errors };
416
- delete next[field];
417
- errors = next;
418
- }
419
-
420
- function runValidation(values: Record<string, unknown>): boolean {
421
- clearErrors();
422
- if (validate) {
423
- const result = validate(values);
424
- if (result && Object.keys(result).length > 0) {
425
- errors = result;
426
- return false;
427
- }
428
- }
429
- return true;
430
- }
431
-
432
- // Fetch existing data for edit/clone
433
- const query = (action === 'edit' || action === 'clone') && id != null
434
- ? createQuery(() => ({
435
- queryKey: [resource, 'one', id],
436
- queryFn: async () => {
437
- const result = await provider.getOne<T>({ resource, id, meta });
438
- return result.data;
439
- },
440
- enabled: id != null,
441
- }))
442
- : null;
443
-
444
- const createMut = createMutation(() => ({
445
- mutationFn: (variables: Record<string, unknown>) =>
446
- provider.create<T>({ resource, variables }),
447
- onSuccess: (data: { data: T }) => {
448
- queryClient.invalidateQueries({ queryKey: [resource] });
449
- toast.success(t('common.createSuccess'));
450
- const record = data.data as Record<string, unknown>;
451
- audit({ action: 'create', resource, recordId: record.id as string });
452
- onMutationSuccess?.(data);
453
- handleRedirect();
454
- },
455
- onError: (error: Error) => {
456
- toast.error(t('common.operationFailed') + ': ' + error.message);
457
- onMutationError?.(error);
296
+ let isMutating = $state(false);
297
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
298
+
299
+ const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
300
+ mutationFn: async (params) => {
301
+ isMutating = true;
302
+ const resName = params.resource ?? resource;
303
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
304
+ try {
305
+ if (provider.deleteMany) return await provider.deleteMany<TData>({ resource: resName, ids: params.ids, meta: params.meta });
306
+ const results = await Promise.all(params.ids.map(id => provider.deleteOne<TData>({ resource: resName, id, meta: params.meta })));
307
+ return { data: results.map(r => r.data) };
308
+ } finally { isMutating = false; }
458
309
  },
310
+ onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
459
311
  }));
460
312
 
461
- const updateMut = createMutation(() => ({
462
- mutationFn: (variables: Record<string, unknown>) =>
463
- provider.update<T>({ resource, id: id!, variables }),
464
- onSuccess: (data: { data: T }) => {
465
- queryClient.invalidateQueries({ queryKey: [resource] });
466
- toast.success(t('common.updateSuccess'));
467
- audit({ action: 'update', resource, recordId: id });
468
- onMutationSuccess?.(data);
469
- handleRedirect();
470
- },
471
- onError: (error: Error) => {
472
- toast.error(t('common.operationFailed') + ': ' + error.message);
473
- onMutationError?.(error);
474
- },
475
- }));
313
+ return { mutation, get overtime() { return overtime; } };
314
+ }
476
315
 
477
- function handleRedirect() {
478
- if (redirect === 'list') navigate(`/${resource}`);
479
- else if (redirect === 'edit' && id) navigate(`/${resource}/edit/${id}`);
480
- else if (redirect === 'show' && id) navigate(`/${resource}/show/${id}`);
481
- }
316
+ // ─── useInvalidate ──────────────────────────────────────────────────
482
317
 
483
- async function onFinish(values: Record<string, unknown>) {
484
- // Run validation before submitting
485
- if (!runValidation(values)) {
486
- toast.warning(t('validation.required'));
318
+ export function useInvalidate() {
319
+ const queryClient = useQueryClient();
320
+ return (params: { resource?: string; invalidates?: string[] | 'all' | false; id?: string | number; dataProviderName?: string }) => {
321
+ if (params.invalidates === false) return;
322
+ if (params.invalidates === 'all' || !params.resource) {
323
+ queryClient.invalidateQueries();
487
324
  return;
488
325
  }
326
+ queryClient.invalidateQueries({ queryKey: [params.resource] });
327
+ };
328
+ }
489
329
 
490
- if (action === 'create' || action === 'clone') {
491
- // @ts-expect-error $ rune prefix — Svelte compiler transforms this
492
- await $createMut.mutateAsync(values);
493
- } else {
494
- // @ts-expect-error $ rune prefix — Svelte compiler transforms this
495
- await $updateMut.mutateAsync(values);
496
- }
497
- }
330
+ // ─── useOvertime ────────────────────────────────────────────────────
498
331
 
332
+ export function useOvertime(options?: OvertimeOptions) {
333
+ let isLoading = $state(false);
334
+ const overtime = createOvertimeTracker(() => isLoading, options);
499
335
  return {
500
- query,
501
- get formLoading() {
502
- // @ts-expect-error $ rune prefix — Svelte compiler transforms this
503
- return (query ? $query?.isLoading : false) || $createMut.isPending || $updateMut.isPending;
504
- },
505
- mutation: action === 'edit' ? updateMut : createMut,
506
- onFinish,
507
- get errors() { return errors; },
508
- setFieldError,
509
- clearErrors,
510
- clearFieldError,
336
+ get elapsedTime() { return overtime.elapsedTime; },
337
+ start() { isLoading = true; },
338
+ stop() { isLoading = false; },
511
339
  };
512
340
  }
513
341
 
514
- // ─── useTable ───────────────────────────────────────────────────
515
- // Standalone table hook (like Refine's useTable)
342
+ // ─── useDataProvider ────────────────────────────────────────────────
516
343
 
517
- interface UseTableOptions {
518
- resource: string;
519
- pagination?: Pagination;
520
- sorters?: Sort[];
521
- filters?: Filter[];
522
- syncWithLocation?: boolean;
523
- meta?: Record<string, unknown>;
344
+ export function useDataProvider(): (dataProviderName?: string) => DataProvider {
345
+ return (name?: string) => getDataProvider(name);
524
346
  }
525
347
 
526
- export function useTable<T = Record<string, unknown>>(options: UseTableOptions) {
527
- const { resource, meta, syncWithLocation = false } = options;
528
-
529
- // Read initial state from URL if syncWithLocation
530
- let initialPagination = options.pagination ?? { current: 1, pageSize: 10 };
531
- let initialSorters = options.sorters ?? [];
532
-
533
- if (syncWithLocation) {
534
- const urlState = readURLState();
535
- if (urlState.page || urlState.pageSize) {
536
- initialPagination = {
537
- current: urlState.page ?? initialPagination.current,
538
- pageSize: urlState.pageSize ?? initialPagination.pageSize,
539
- };
540
- }
541
- if (urlState.sortField) {
542
- initialSorters = [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }];
543
- }
544
- }
545
-
546
- let pagination = $state<Pagination>(initialPagination);
547
- let sorters = $state<Sort[]>(initialSorters);
548
- let filters = $state<Filter[]>(options.filters ?? []);
549
-
550
- const query = useList<T>({ resource, pagination, sorters, filters, meta });
551
-
552
- function setSorters(newSorters: Sort[]) { sorters = newSorters; }
553
- function setFilters(newFilters: Filter[]) { filters = newFilters; }
554
- function setPage(page: number) { pagination = { ...pagination, current: page }; }
555
- function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
556
-
557
- // Sync state to URL when syncWithLocation is enabled
558
- if (syncWithLocation) {
559
- $effect(() => {
560
- writeURLState({
561
- page: pagination.current,
562
- pageSize: pagination.pageSize,
563
- sortField: sorters[0]?.field,
564
- sortOrder: sorters[0]?.order,
565
- });
566
- });
567
- }
348
+ // ─── useThemedLayoutContext ─────────────────────────────────────────
568
349
 
350
+ export function useThemedLayoutContext() {
351
+ let sidebarCollapsed = $state(false);
569
352
  return {
570
- query,
571
- pagination,
572
- sorters,
573
- filters,
574
- setSorters,
575
- setFilters,
576
- setPage,
577
- setPageSize,
578
- get totalPages() {
579
- // @ts-expect-error $ rune prefix — Svelte compiler transforms this
580
- return Math.ceil(($query.data?.total ?? 0) / (pagination.pageSize ?? 10));
581
- },
353
+ get sidebarCollapsed() { return sidebarCollapsed; },
354
+ setSidebarCollapsed(v: boolean) { sidebarCollapsed = v; },
355
+ toggleSidebar() { sidebarCollapsed = !sidebarCollapsed; },
582
356
  };
583
357
  }
358
+
359
+ // ─── Logging hooks (legacy aliases) ─────────────────────────────────
360
+
361
+ export function useLogList(options?: Partial<UseListOptions>) {
362
+ return useList({ resource: 'logs', ...options });
363
+ }
364
+
365
+ export const useLog = useLogList;