@svadmin/core 0.0.6 → 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.
@@ -1,618 +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
- clone: (resource: string, id: string | number) => navigate(`/${resource}/create?clone_id=${id}`),
194
- goBack: () => history.back(),
195
- push: (path: string) => navigate(path),
174
+ query,
175
+ get options() { return selectOptions; },
176
+ get overtime() { return overtime; },
177
+ onSearchChange,
196
178
  };
197
179
  }
198
180
 
199
- // ─── useResource ────────────────────────────────────────────────
200
-
201
- export { getResource as useResource } from './context';
202
-
203
- // ─── Mutation options ───────────────────────────────────────────
181
+ // ─── useCustom ──────────────────────────────────────────────────────
204
182
 
205
- interface UseMutationOptions {
206
- showToast?: boolean;
207
- auditLog?: boolean;
208
- 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;
209
193
  }
210
194
 
211
- // ─── useCreate ──────────────────────────────────────────────────
195
+ export function useCustom<TData = unknown, TError = HttpError>(options: UseCustomOptions<TData, TError>) {
196
+ const adminOptions = getAdminOptions();
197
+ const provider = getDataProvider(options.dataProviderName);
212
198
 
213
- export function useCreate<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
214
- const provider = getDataProvider();
215
- const queryClient = useQueryClient();
216
- const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
217
-
218
- return createMutation(() => ({
219
- mutationFn: (variables: Record<string, unknown>) =>
220
- provider.create<T>({ resource, variables }),
221
- onMutate: mutationMode === 'optimistic'
222
- ? async (_variables: Record<string, unknown>) => {
223
- await queryClient.cancelQueries({ queryKey: [resource] });
224
- const previousData = queryClient.getQueryData([resource, 'list']);
225
- return { previousData };
226
- }
227
- : undefined,
228
- onSuccess: (data: { data: T }) => {
229
- queryClient.invalidateQueries({ queryKey: [resource] });
230
- if (showToast) toast.success(t('common.createSuccess'));
231
- if (auditLog) {
232
- const record = data.data as Record<string, unknown>;
233
- audit({ action: 'create', resource, recordId: record.id as string });
234
- }
235
- },
236
- onError: (error: Error, _variables: Record<string, unknown>, context: { previousData?: unknown } | undefined) => {
237
- if (mutationMode === 'optimistic' && context?.previousData) {
238
- queryClient.setQueryData([resource, 'list'], context.previousData);
239
- }
240
- 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
+ });
241
213
  },
214
+ enabled: options.queryOptions?.enabled ?? true,
215
+ staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
242
216
  }));
243
- }
244
217
 
245
- // ─── useCreateMany ──────────────────────────────────────────────
218
+ const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
246
219
 
247
- export function useCreateMany<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
248
- const provider = getDataProvider();
249
- const queryClient = useQueryClient();
250
- const { showToast = true, auditLog = true } = opts ?? {};
251
-
252
- return createMutation(() => ({
253
- mutationFn: async (variables: Record<string, unknown>[]) => {
254
- if (provider.createMany) {
255
- return provider.createMany<T>({ resource, variables });
256
- }
257
- const results = await Promise.all(variables.map(v => provider.create<T>({ resource, variables: v })));
258
- return { data: results.map(r => r.data) };
259
- },
260
- onSuccess: () => {
261
- queryClient.invalidateQueries({ queryKey: [resource] });
262
- if (showToast) toast.success(t('common.createSuccess'));
263
- if (auditLog) audit({ action: 'create', resource, details: { batch: true } });
264
- },
265
- onError: (error: Error) => {
266
- if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
267
- },
268
- }));
220
+ return { query, get overtime() { return overtime; } };
269
221
  }
270
222
 
271
- // ─── useUpdate ──────────────────────────────────────────────────
272
-
273
- interface UpdateVariables {
274
- id: string | number;
275
- variables: Record<string, unknown>;
276
- }
223
+ // ─── useCustomMutation ──────────────────────────────────────────────
277
224
 
278
- export function useUpdate<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
279
- const provider = getDataProvider();
225
+ export function useCustomMutation<TData = unknown, TError = HttpError, TVariables = unknown>(dataProviderName?: string) {
226
+ const provider = getDataProvider(dataProviderName);
280
227
  const queryClient = useQueryClient();
281
- const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
282
-
283
- return createMutation<{ data: T }, Error, UpdateVariables>(() => ({
284
- mutationFn: ({ id, variables }: UpdateVariables) =>
285
- provider.update<T>({ resource, id, variables }),
286
- onMutate: mutationMode === 'optimistic'
287
- ? async ({ id }: UpdateVariables) => {
288
- await queryClient.cancelQueries({ queryKey: [resource, 'one', id] });
289
- const previousData = queryClient.getQueryData([resource, 'one', id]);
290
- return { previousData };
291
- }
292
- : undefined,
293
- onSuccess: (_data: { data: T }, vars: UpdateVariables) => {
294
- queryClient.invalidateQueries({ queryKey: [resource] });
295
- if (showToast) toast.success(t('common.updateSuccess'));
296
- if (auditLog) audit({ action: 'update', resource, recordId: vars.id });
297
- },
298
- onError: (error: Error) => {
299
- if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
300
- },
301
- }));
302
- }
303
228
 
304
- // ─── useUpdateMany ──────────────────────────────────────────────
305
-
306
- export function useUpdateMany<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
307
- const provider = getDataProvider();
308
- const queryClient = useQueryClient();
309
- const { showToast = true, auditLog = true } = opts ?? {};
310
-
311
- return createMutation(() => ({
312
- mutationFn: async ({ ids, variables }: { ids: (string | number)[]; variables: Record<string, unknown> }) => {
313
- if (provider.updateMany) {
314
- return provider.updateMany<T>({ resource, ids, variables });
315
- }
316
- const results = await Promise.all(ids.map(id => provider.update<T>({ resource, id, variables })));
317
- return { data: results.map(r => r.data) };
318
- },
319
- onSuccess: () => {
320
- queryClient.invalidateQueries({ queryKey: [resource] });
321
- if (showToast) toast.success(t('common.updateSuccess'));
322
- if (auditLog) audit({ action: 'update', resource, details: { batch: true } });
323
- },
324
- onError: (error: Error) => {
325
- 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 });
326
233
  },
327
234
  }));
235
+
236
+ return { mutation };
328
237
  }
329
238
 
330
- // ─── useDelete ──────────────────────────────────────────────────
239
+ // ─── useCreateMany / useUpdateMany / useDeleteMany ──────────────────
331
240
 
332
- export function useDelete(resource: string, opts?: UseMutationOptions) {
333
- 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();
334
245
  const queryClient = useQueryClient();
335
- const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
336
-
337
- return createMutation(() => ({
338
- mutationFn: (id: string | number) =>
339
- provider.deleteOne({ resource, id }),
340
- onMutate: mutationMode === 'optimistic'
341
- ? async (_id: string | number) => {
342
- await queryClient.cancelQueries({ queryKey: [resource] });
343
- const previousData = queryClient.getQueryData([resource, 'list']);
344
- return { previousData };
345
- }
346
- : undefined,
347
- onSuccess: (_data: unknown, id: string | number) => {
348
- queryClient.invalidateQueries({ queryKey: [resource] });
349
- if (showToast) toast.success(t('common.deleteSuccess'));
350
- if (auditLog) audit({ action: 'delete', resource, recordId: id });
351
- },
352
- onError: (error: Error) => {
353
- 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; }
354
259
  },
260
+ onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
355
261
  }));
356
- }
357
262
 
358
- // ─── useDeleteMany ──────────────────────────────────────────────
263
+ return { mutation, get overtime() { return overtime; } };
264
+ }
359
265
 
360
- export function useDeleteMany(resource: string, opts?: UseMutationOptions) {
361
- 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();
362
270
  const queryClient = useQueryClient();
363
- const { showToast = true, auditLog = true } = opts ?? {};
364
-
365
- return createMutation(() => ({
366
- mutationFn: async (ids: (string | number)[]) => {
367
- if (provider.deleteMany) {
368
- return provider.deleteMany({ resource, ids });
369
- }
370
- const results = await Promise.all(ids.map(id => provider.deleteOne({ resource, id })));
371
- return { data: results.map(r => r.data) };
372
- },
373
- onSuccess: () => {
374
- queryClient.invalidateQueries({ queryKey: [resource] });
375
- if (showToast) toast.success(t('common.deleteSuccess'));
376
- if (auditLog) audit({ action: 'delete', resource, details: { batch: true } });
377
- },
378
- onError: (error: Error) => {
379
- 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; }
380
284
  },
285
+ onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
381
286
  }));
382
- }
383
287
 
384
- // ─── useForm ────────────────────────────────────────────────────
385
- // Standalone form hook (like Refine's useForm)
386
-
387
- interface UseFormOptions {
388
- resource: string;
389
- action: 'create' | 'edit' | 'clone';
390
- id?: string | number;
391
- redirect?: 'list' | 'edit' | 'show' | false;
392
- mutationMode?: MutationMode;
393
- onMutationSuccess?: (data: unknown) => void;
394
- onMutationError?: (error: Error) => void;
395
- meta?: Record<string, unknown>;
396
- validate?: (values: Record<string, unknown>) => Record<string, string> | null;
397
- autoSave?: {
398
- enabled: boolean;
399
- debounce?: number; // ms, default 1000
400
- onFinish?: (values: Record<string, unknown>) => Record<string, unknown>; // transform before save
401
- };
288
+ return { mutation, get overtime() { return overtime; } };
402
289
  }
403
290
 
404
- export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
405
- 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();
406
295
  const queryClient = useQueryClient();
407
- const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta, validate, autoSave } = options;
408
-
409
- // Validation state
410
- let errors = $state<Record<string, string>>({});
411
-
412
- function setFieldError(field: string, message: string) {
413
- errors = { ...errors, [field]: message };
414
- }
415
-
416
- function clearErrors() {
417
- errors = {};
418
- }
419
-
420
- function clearFieldError(field: string) {
421
- const next = { ...errors };
422
- delete next[field];
423
- errors = next;
424
- }
425
-
426
- function runValidation(values: Record<string, unknown>): boolean {
427
- clearErrors();
428
- if (validate) {
429
- const result = validate(values);
430
- if (result && Object.keys(result).length > 0) {
431
- errors = result;
432
- return false;
433
- }
434
- }
435
- return true;
436
- }
437
-
438
- // Fetch existing data for edit/clone
439
- const query = (action === 'edit' || action === 'clone') && id != null
440
- ? createQuery(() => ({
441
- queryKey: [resource, 'one', id],
442
- queryFn: async () => {
443
- const result = await provider.getOne<T>({ resource, id, meta });
444
- return result.data;
445
- },
446
- enabled: id != null,
447
- }))
448
- : null;
449
-
450
- const createMut = createMutation(() => ({
451
- mutationFn: (variables: Record<string, unknown>) =>
452
- provider.create<T>({ resource, variables }),
453
- onSuccess: (data: { data: T }) => {
454
- queryClient.invalidateQueries({ queryKey: [resource] });
455
- toast.success(t('common.createSuccess'));
456
- const record = data.data as Record<string, unknown>;
457
- audit({ action: 'create', resource, recordId: record.id as string });
458
- onMutationSuccess?.(data);
459
- handleRedirect();
460
- },
461
- onError: (error: Error) => {
462
- toast.error(t('common.operationFailed') + ': ' + error.message);
463
- 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; }
464
309
  },
310
+ onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
465
311
  }));
466
312
 
467
- const updateMut = createMutation(() => ({
468
- mutationFn: (variables: Record<string, unknown>) =>
469
- provider.update<T>({ resource, id: id!, variables }),
470
- onSuccess: (data: { data: T }) => {
471
- queryClient.invalidateQueries({ queryKey: [resource] });
472
- toast.success(t('common.updateSuccess'));
473
- audit({ action: 'update', resource, recordId: id });
474
- onMutationSuccess?.(data);
475
- handleRedirect();
476
- },
477
- onError: (error: Error) => {
478
- toast.error(t('common.operationFailed') + ': ' + error.message);
479
- onMutationError?.(error);
480
- },
481
- }));
313
+ return { mutation, get overtime() { return overtime; } };
314
+ }
482
315
 
483
- function handleRedirect() {
484
- if (redirect === 'list') navigate(`/${resource}`);
485
- else if (redirect === 'edit' && id) navigate(`/${resource}/edit/${id}`);
486
- else if (redirect === 'show' && id) navigate(`/${resource}/show/${id}`);
487
- }
316
+ // ─── useInvalidate ──────────────────────────────────────────────────
488
317
 
489
- async function onFinish(values: Record<string, unknown>) {
490
- // Run validation before submitting
491
- if (!runValidation(values)) {
492
- 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();
493
324
  return;
494
325
  }
495
-
496
- if (action === 'create' || action === 'clone') {
497
- // @ts-expect-error $ rune prefix — Svelte compiler transforms this
498
- await $createMut.mutateAsync(values);
499
- } else {
500
- // @ts-expect-error $ rune prefix — Svelte compiler transforms this
501
- await $updateMut.mutateAsync(values);
502
- }
503
- }
504
-
505
- const base = {
506
- query,
507
- get formLoading() {
508
- // @ts-expect-error $ rune prefix — Svelte compiler transforms this
509
- return (query ? $query?.isLoading : false) || $createMut.isPending || $updateMut.isPending;
510
- },
511
- mutation: action === 'edit' ? updateMut : createMut,
512
- onFinish,
513
- get errors() { return errors; },
514
- setFieldError,
515
- clearErrors,
516
- clearFieldError,
326
+ queryClient.invalidateQueries({ queryKey: [params.resource] });
517
327
  };
328
+ }
518
329
 
519
- // ─── autoSave ─────────────────────────────────────────────
520
- let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
521
- let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
522
-
523
- function triggerAutoSave(values: Record<string, unknown>) {
524
- if (!autoSave?.enabled || action === 'create') return;
525
- if (autoSaveTimer) clearTimeout(autoSaveTimer);
526
-
527
- autoSaveTimer = setTimeout(async () => {
528
- const finalValues = autoSave.onFinish ? autoSave.onFinish(values) : values;
529
- autoSaveStatus = 'saving';
530
- try {
531
- await provider.update<T>({ resource, id: id!, variables: finalValues });
532
- queryClient.invalidateQueries({ queryKey: [resource] });
533
- autoSaveStatus = 'saved';
534
- // Reset to idle after 2s
535
- setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
536
- } catch {
537
- autoSaveStatus = 'error';
538
- }
539
- }, autoSave.debounce ?? 1000);
540
- }
330
+ // ─── useOvertime ────────────────────────────────────────────────────
541
331
 
332
+ export function useOvertime(options?: OvertimeOptions) {
333
+ let isLoading = $state(false);
334
+ const overtime = createOvertimeTracker(() => isLoading, options);
542
335
  return {
543
- ...base,
544
- triggerAutoSave,
545
- get autoSaveStatus() { return autoSaveStatus; },
336
+ get elapsedTime() { return overtime.elapsedTime; },
337
+ start() { isLoading = true; },
338
+ stop() { isLoading = false; },
546
339
  };
547
340
  }
548
341
 
549
- // ─── useTable ───────────────────────────────────────────────────
550
- // Standalone table hook (like Refine's useTable)
342
+ // ─── useDataProvider ────────────────────────────────────────────────
551
343
 
552
- interface UseTableOptions {
553
- resource: string;
554
- pagination?: Pagination;
555
- sorters?: Sort[];
556
- filters?: Filter[];
557
- syncWithLocation?: boolean;
558
- meta?: Record<string, unknown>;
344
+ export function useDataProvider(): (dataProviderName?: string) => DataProvider {
345
+ return (name?: string) => getDataProvider(name);
559
346
  }
560
347
 
561
- export function useTable<T = Record<string, unknown>>(options: UseTableOptions) {
562
- const { resource, meta, syncWithLocation = false } = options;
563
-
564
- // Read initial state from URL if syncWithLocation
565
- let initialPagination = options.pagination ?? { current: 1, pageSize: 10 };
566
- let initialSorters = options.sorters ?? [];
567
-
568
- if (syncWithLocation) {
569
- const urlState = readURLState();
570
- if (urlState.page || urlState.pageSize) {
571
- initialPagination = {
572
- current: urlState.page ?? initialPagination.current,
573
- pageSize: urlState.pageSize ?? initialPagination.pageSize,
574
- };
575
- }
576
- if (urlState.sortField) {
577
- initialSorters = [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }];
578
- }
579
- }
580
-
581
- let pagination = $state<Pagination>(initialPagination);
582
- let sorters = $state<Sort[]>(initialSorters);
583
- let filters = $state<Filter[]>(options.filters ?? []);
584
-
585
- const query = useList<T>({ resource, pagination, sorters, filters, meta });
586
-
587
- function setSorters(newSorters: Sort[]) { sorters = newSorters; }
588
- function setFilters(newFilters: Filter[]) { filters = newFilters; }
589
- function setPage(page: number) { pagination = { ...pagination, current: page }; }
590
- function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
591
-
592
- // Sync state to URL when syncWithLocation is enabled
593
- if (syncWithLocation) {
594
- $effect(() => {
595
- writeURLState({
596
- page: pagination.current,
597
- pageSize: pagination.pageSize,
598
- sortField: sorters[0]?.field,
599
- sortOrder: sorters[0]?.order,
600
- });
601
- });
602
- }
348
+ // ─── useThemedLayoutContext ─────────────────────────────────────────
603
349
 
350
+ export function useThemedLayoutContext() {
351
+ let sidebarCollapsed = $state(false);
604
352
  return {
605
- query,
606
- pagination,
607
- sorters,
608
- filters,
609
- setSorters,
610
- setFilters,
611
- setPage,
612
- setPageSize,
613
- get totalPages() {
614
- // @ts-expect-error $ rune prefix — Svelte compiler transforms this
615
- return Math.ceil(($query.data?.total ?? 0) / (pagination.pageSize ?? 10));
616
- },
353
+ get sidebarCollapsed() { return sidebarCollapsed; },
354
+ setSidebarCollapsed(v: boolean) { sidebarCollapsed = v; },
355
+ toggleSidebar() { sidebarCollapsed = !sidebarCollapsed; },
617
356
  };
618
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;