@svadmin/core 0.11.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.11.0",
3
+ "version": "0.14.0",
4
4
  "description": "Core SDK — hooks, types, context, i18n, permissions, router",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -290,3 +290,61 @@ export function usePermissions<T = unknown>() {
290
290
  get error() { return error; },
291
291
  };
292
292
  }
293
+
294
+ // ─── useHasPermission ────────────────────────────────────────
295
+
296
+ /**
297
+ * Reactive permission checker — returns a closure that re-evaluates
298
+ * when the underlying permissions data changes.
299
+ *
300
+ * Works with Casbin-style permission arrays, Sets, or permission objects.
301
+ * The returned function is safe to use inside `$derived()` for reactive UI updates.
302
+ *
303
+ * @example
304
+ * ```svelte
305
+ * <script>
306
+ * import { useHasPermission } from '@svadmin/core';
307
+ *
308
+ * const hasPermission = useHasPermission();
309
+ * // ✅ Reactive — UI updates when permissions finish loading
310
+ * const canDelete = $derived(hasPermission('admin'));
311
+ * const canExport = $derived(hasPermission('data:export'));
312
+ * </script>
313
+ *
314
+ * {#if canDelete}
315
+ * <Button variant="destructive">Delete</Button>
316
+ * {/if}
317
+ * ```
318
+ */
319
+ export function useHasPermission() {
320
+ const provider = getAuthProvider();
321
+ let permissions = $state<unknown>(null);
322
+ let loaded = $state(false);
323
+
324
+ if (provider?.getPermissions) {
325
+ provider.getPermissions().then(p => {
326
+ permissions = p;
327
+ loaded = true;
328
+ }).catch(err => {
329
+ console.warn('[svadmin] useHasPermission: getPermissions failed', err);
330
+ loaded = true;
331
+ });
332
+ } else {
333
+ loaded = true;
334
+ }
335
+
336
+ /**
337
+ * Check if the current user has a specific permission.
338
+ * Supports: `string[]`, `Set<string>`, `Record<string, boolean>`, or any object with `.includes` / `.has`.
339
+ */
340
+ function hasPermission(perm: string): boolean {
341
+ if (!loaded || permissions == null) return false;
342
+ if (Array.isArray(permissions)) return permissions.includes(perm);
343
+ if (permissions instanceof Set) return (permissions as Set<string>).has(perm);
344
+ if (typeof permissions === 'object') return !!(permissions as Record<string, boolean>)[perm];
345
+ return false;
346
+ }
347
+
348
+ return hasPermission;
349
+ }
350
+
@@ -112,7 +112,7 @@ export function getRouterProvider(): RouterProvider | undefined {
112
112
 
113
113
  // ─── Live Provider ──────────────────────────────────────────────
114
114
 
115
- let liveProviderState: import('./live.svelte').LiveProvider | undefined = undefined;
115
+ let liveProviderState = $state<import('./live.svelte').LiveProvider | undefined>(undefined);
116
116
 
117
117
  export function setLiveProvider(provider: import('./live.svelte').LiveProvider): void {
118
118
  liveProviderState = provider;
@@ -1,6 +1,6 @@
1
1
  import { useQueryClient } from '@tanstack/svelte-query';
2
2
  import { useParsed } from './useParsed.svelte';
3
- import { getAdminOptions } from './options';
3
+ import { getAdminOptions } from './options.svelte';
4
4
  import { getDataProviderForResource } from './context.svelte';
5
5
  import { createQuery, createMutation } from '@tanstack/svelte-query';
6
6
  import { toast } from './toast.svelte';
@@ -10,21 +10,48 @@ import { navigate } from './router';
10
10
  import { HttpError } from './types';
11
11
  import type { BaseRecord, MutationMode, KnownResources } from './types';
12
12
 
13
- export interface UseFormOptions<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> {
13
+ // ─── Types ───────────────────────────────────────────────────────────
14
+
15
+ export interface UseFormOptions<
16
+ TVariables extends Record<string, unknown> = Record<string, unknown>,
17
+ TData extends BaseRecord = BaseRecord,
18
+ TError = HttpError,
19
+ > {
14
20
  resource?: KnownResources;
15
21
  action?: 'create' | 'edit' | 'clone';
16
22
  id?: string | number;
17
23
  redirect?: 'list' | 'edit' | 'show' | false;
24
+
25
+ /** Initial values for the form. Merged with query data in edit mode (query wins). */
26
+ defaultValues?: TVariables;
27
+
28
+ // ─── Validation ───────────────────────────────────────────────
29
+ /** Full-form validator. Returns field→error map or null. */
30
+ validate?: (values: TVariables) => Record<string, string> | null;
31
+ /** Per-field validator for real-time validation. */
32
+ validateField?: (field: string, value: unknown, values: TVariables) => string | null;
33
+
34
+ // ─── Notifications ────────────────────────────────────────────
18
35
  successNotification?: string | false;
19
36
  errorNotification?: string | false;
20
- dataProviderName?: string;
37
+
38
+ // ─── Callbacks ────────────────────────────────────────────────
21
39
  onMutationSuccess?: (data: unknown) => void;
22
40
  onMutationError?: (error: Error) => void;
41
+ /** Called every time a field value changes. */
42
+ onChange?: (event: { field: string; value: unknown; values: TVariables }) => void;
43
+ /** Called before mutation. Return false or call cancel() to prevent submission. */
44
+ onSubmit?: (ctx: { values: TVariables; action: 'create' | 'edit' | 'clone'; cancel: () => void }) => void | boolean;
45
+
46
+ // ─── Meta ─────────────────────────────────────────────────────
23
47
  meta?: Record<string, unknown>;
24
48
  queryMeta?: Record<string, unknown>;
25
49
  mutationMeta?: Record<string, unknown>;
50
+
51
+ // ─── Server validation ────────────────────────────────────────
26
52
  disableServerSideValidation?: boolean;
27
- validate?: (values: TVariables) => Record<string, string> | null;
53
+
54
+ // ─── AutoSave ─────────────────────────────────────────────────
28
55
  autoSave?: {
29
56
  enabled: boolean;
30
57
  debounce?: number;
@@ -32,6 +59,8 @@ export interface UseFormOptions<TQueryFnData extends BaseRecord = BaseRecord, TE
32
59
  invalidateOnUnmount?: boolean;
33
60
  invalidates?: string[];
34
61
  };
62
+
63
+ // ─── Mutation behavior ────────────────────────────────────────
35
64
  mutationMode?: MutationMode;
36
65
  undoableTimeout?: number;
37
66
  invalidates?: string[] | false;
@@ -39,58 +68,95 @@ export interface UseFormOptions<TQueryFnData extends BaseRecord = BaseRecord, TE
39
68
  list?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
40
69
  detail?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
41
70
  };
71
+
72
+ // ─── Query config ─────────────────────────────────────────────
42
73
  queryOptions?: { staleTime?: number; enabled?: boolean };
43
74
  createMutationOptions?: Record<string, unknown>;
44
75
  updateMutationOptions?: Record<string, unknown>;
76
+
77
+ /** DataProvider to use for this form */
78
+ dataProviderName?: string;
79
+
45
80
  warnWhenUnsavedChanges?: boolean;
46
81
  }
47
82
 
48
- export type UseFormResult<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> = {
49
- readonly query: unknown;
50
- readonly mutation: unknown;
51
- readonly formLoading: boolean;
83
+ export interface UseFormReturn<
84
+ TVariables extends Record<string, unknown> = Record<string, unknown>,
85
+ TData extends BaseRecord = BaseRecord,
86
+ > {
87
+ // ─── Form values (single source of truth) ─────────────────────
88
+ /** Reactive form values. Read/write directly. */
89
+ readonly values: TVariables;
90
+ /** Set a single field value. Tracks tainted state and fires onChange. */
91
+ setFieldValue: (field: string, value: unknown, opts?: { taint?: boolean }) => void;
92
+ /** Bulk-set multiple field values. */
93
+ setValues: (newValues: Partial<TVariables>, opts?: { taint?: boolean }) => void;
94
+
95
+ // ─── Tainted (dirty) ──────────────────────────────────────────
96
+ readonly tainted: Record<string, boolean>;
97
+ isTainted: (field?: string) => boolean;
98
+
99
+ // ─── Errors ───────────────────────────────────────────────────
52
100
  readonly errors: Record<string, string>;
53
101
  setFieldError: (field: string, message: string) => void;
54
- clearErrors: () => void;
55
102
  clearFieldError: (field: string) => void;
56
- onFinish: (values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) => Promise<void>;
57
- onFinishAutoSave: (values: TVariables) => Promise<void>;
58
- triggerAutoSave: (values: TVariables) => void;
59
- readonly autoSaveStatus: 'idle' | 'saving' | 'saved' | 'error';
103
+ clearErrors: () => void;
104
+ /** Validate a single field. Sets/clears the field error. Returns the error or null. */
105
+ validateField: (field: string) => string | null;
106
+
107
+ // ─── Submission ───────────────────────────────────────────────
108
+ /** Submit the form. Validates → onSubmit hook → mutation → redirect. */
109
+ submit: (overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) => Promise<void>;
110
+ /** Reset form to initial/query values. Clears tainted and errors. */
111
+ reset: () => void;
112
+
113
+ // ─── State ────────────────────────────────────────────────────
114
+ readonly loading: boolean;
115
+ readonly submitting: boolean;
60
116
  readonly resource: string;
61
117
  readonly action: 'create' | 'edit' | 'clone';
62
118
  readonly id: string | number | undefined;
63
119
  setId: (newId: string | number) => void;
64
120
  readonly mutationMode: MutationMode;
65
121
  redirect: (to: 'list' | 'edit' | 'show' | false) => void;
66
- readonly autoSaveProps: { status: 'idle' | 'loading' | 'success' | 'error'; data: unknown; error: unknown };
67
- };
122
+
123
+ // ─── AutoSave ─────────────────────────────────────────────────
124
+ triggerAutoSave: () => void;
125
+ readonly autoSave: { status: 'idle' | 'saving' | 'saved' | 'error'; data: unknown; error: unknown };
126
+
127
+ // ─── Raw query/mutation (escape hatch) ────────────────────────
128
+ readonly query: ReturnType<typeof createQuery> | null;
129
+ readonly mutation: ReturnType<typeof createMutation>;
130
+ }
131
+
132
+ // ─── Implementation ──────────────────────────────────────────────────
68
133
 
69
134
  export function useForm<
70
- TQueryFnData extends BaseRecord = BaseRecord,
135
+ TVariables extends Record<string, unknown> = Record<string, unknown>,
136
+ TData extends BaseRecord = BaseRecord,
71
137
  TError = HttpError,
72
- TVariables = Record<string, unknown>,
73
- TData extends BaseRecord = TQueryFnData
74
- >(options: UseFormOptions<TQueryFnData, TError, TVariables, TData> = {}): UseFormResult<TQueryFnData, TError, TVariables, TData> {
138
+ >(options: UseFormOptions<TVariables, TData, TError> = {} as UseFormOptions<TVariables, TData, TError>): UseFormReturn<TVariables, TData> {
75
139
  const queryClient = useQueryClient();
76
140
  const parsed = useParsed();
77
141
  const adminOptions = getAdminOptions();
78
-
142
+
79
143
  const resource = options.resource ?? parsed.resource ?? '';
80
144
  const action = options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create';
81
145
  let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
82
-
146
+
83
147
  const {
84
148
  redirect: redirectDefault = 'list',
85
149
  successNotification, errorNotification,
86
150
  onMutationSuccess, onMutationError,
87
151
  meta: hookMeta, queryMeta: hookQueryMeta, mutationMeta: hookMutationMeta,
88
- disableServerSideValidation, validate, autoSave,
152
+ disableServerSideValidation, autoSave: autoSaveOpts,
89
153
  mutationMode = adminOptions.mutationMode ?? 'pessimistic',
90
154
  undoableTimeout = adminOptions.undoableTimeout ?? 5000,
91
155
  dataProviderName, invalidates: invalidateScopes,
92
156
  optimisticUpdateMap,
93
157
  queryOptions,
158
+ onChange: onChangeFn,
159
+ onSubmit: onSubmitFn,
94
160
  } = options;
95
161
 
96
162
  const provider = getDataProviderForResource(resource, dataProviderName);
@@ -100,13 +166,61 @@ export function useForm<
100
166
 
101
167
  function setId(newId: string | number) { currentId = newId; }
102
168
 
169
+ // ─── Form values (single source of truth) ───────────────────────
170
+ let values = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
171
+ let initialValues = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
172
+
173
+ function setFieldValue(field: string, value: unknown, opts?: { taint?: boolean }) {
174
+ values = { ...values, [field]: value } as TVariables;
175
+ const shouldTaint = opts?.taint ?? true;
176
+ if (shouldTaint) tainted = { ...tainted, [field]: true };
177
+ // Clear field error on change
178
+ if (errors[field]) clearFieldError(field);
179
+ onChangeFn?.({ field, value, values });
180
+ }
181
+
182
+ function setValues(newValues: Partial<TVariables>, opts?: { taint?: boolean }) {
183
+ values = { ...values, ...newValues } as TVariables;
184
+ const shouldTaint = opts?.taint ?? true;
185
+ if (shouldTaint) {
186
+ const newTainted = { ...tainted };
187
+ for (const key of Object.keys(newValues)) newTainted[key] = true;
188
+ tainted = newTainted;
189
+ }
190
+ }
191
+
192
+ // ─── Tainted (dirty) state ──────────────────────────────────────
193
+ let tainted = $state<Record<string, boolean>>({});
194
+
195
+ function isTainted(field?: string): boolean {
196
+ if (field !== undefined) return !!tainted[field];
197
+ return Object.values(tainted).some(Boolean);
198
+ }
199
+
200
+ // ─── Errors ─────────────────────────────────────────────────────
103
201
  let errors = $state<Record<string, string>>({});
202
+
104
203
  function setFieldError(field: string, message: string) { errors = { ...errors, [field]: message }; }
105
204
  function clearErrors() { errors = {}; }
106
- function clearFieldError(field: string) { delete errors[field]; }
205
+ function clearFieldError(field: string) {
206
+ const next = { ...errors };
207
+ delete next[field];
208
+ errors = next;
209
+ }
107
210
 
108
- function runValidation(values: TVariables): boolean {
211
+ function doValidateField(field: string): string | null {
212
+ const value = values[field];
213
+ if (options.validateField) {
214
+ const msg = options.validateField(field, value, values);
215
+ if (msg) { setFieldError(field, msg); return msg; }
216
+ else { clearFieldError(field); }
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function runValidation(): boolean {
109
222
  clearErrors();
223
+ const validate = options.validate;
110
224
  if (validate) {
111
225
  const result = validate(values);
112
226
  if (result && Object.keys(result).length > 0) {
@@ -117,6 +231,14 @@ export function useForm<
117
231
  return true;
118
232
  }
119
233
 
234
+ // ─── Reset ──────────────────────────────────────────────────────
235
+ function reset() {
236
+ values = { ...initialValues } as TVariables;
237
+ tainted = {};
238
+ clearErrors();
239
+ }
240
+
241
+ // ─── Server error handling ──────────────────────────────────────
120
242
  function handleHttpError(error: Error) {
121
243
  if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
122
244
  for (const [field, messages] of Object.entries(error.errors)) {
@@ -129,11 +251,12 @@ export function useForm<
129
251
  }
130
252
  }
131
253
 
254
+ // ─── Query (edit/clone mode) ────────────────────────────────────
132
255
  const query = (action === 'edit' || action === 'clone') && currentId != null
133
256
  ? createQuery(() => ({
134
257
  queryKey: [resource, 'one', currentId],
135
258
  queryFn: async () => {
136
- const result = await provider.getOne<TQueryFnData>({ resource, id: currentId!, meta: queryMeta });
259
+ const result = await provider.getOne<BaseRecord>({ resource, id: currentId!, meta: queryMeta });
137
260
  return result.data;
138
261
  },
139
262
  enabled: (queryOptions?.enabled ?? true) && currentId != null,
@@ -141,13 +264,32 @@ export function useForm<
141
264
  }))
142
265
  : null;
143
266
 
267
+ // Auto-populate values from query data
268
+ let queryInitialized = false;
269
+ if (query) {
270
+ $effect.pre(() => {
271
+ if (queryInitialized) return;
272
+ const data = query.data as Record<string, unknown> | undefined;
273
+ if (data) {
274
+ // Merge: defaultValues < query data
275
+ const merged = { ...(options.defaultValues ?? {}), ...data } as TVariables;
276
+ values = merged;
277
+ initialValues = { ...merged } as TVariables;
278
+ queryInitialized = true;
279
+ }
280
+ });
281
+ }
282
+
283
+ // ─── Mutations ──────────────────────────────────────────────────
284
+ let redirectOverride: 'list' | 'edit' | 'show' | false | undefined;
285
+
144
286
  const createMut = createMutation(() => ({
145
287
  ...options.createMutationOptions,
146
288
  mutationFn: (variables: TVariables) => provider.create<TData, TVariables>({ resource, variables, meta: mutationMeta }),
147
289
  onSuccess: (data: { data: TData }) => {
148
290
  if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
149
291
  if (successNotification !== false) toast.success(successNotification || t('common.createSuccess'));
150
- audit({ action: 'create', resource, recordId: String((data.data as any).id) });
292
+ audit({ action: 'create', resource, recordId: String((data.data as Record<string, unknown>).id) });
151
293
  onMutationSuccess?.(data);
152
294
  if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
153
295
  },
@@ -167,73 +309,73 @@ export function useForm<
167
309
  onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
168
310
  }));
169
311
 
170
- let redirectOverride: 'list' | 'edit' | 'show' | false | undefined;
171
-
172
312
  function doRedirect(to: 'list' | 'edit' | 'show' | false) {
173
313
  if (to === 'list') navigate(`/${resource}`);
174
314
  else if (to === 'edit' && currentId) navigate(`/${resource}/edit/${currentId}`);
175
315
  else if (to === 'show' && currentId) navigate(`/${resource}/show/${currentId}`);
176
316
  }
177
317
 
178
- async function onFinish(values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) {
179
- if (!runValidation(values)) { toast.warning(t('validation.required')); return; }
180
- redirectOverride = finishOptions?.redirect;
318
+ // ─── Submit ─────────────────────────────────────────────────────
319
+ async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
320
+ // onSubmit pre-hook
321
+ if (onSubmitFn) {
322
+ let cancelled = false;
323
+ const result = onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
324
+ if (result === false || cancelled) return;
325
+ }
326
+
327
+ if (!runValidation()) { toast.warning(t('validation.required')); return; }
328
+ redirectOverride = overrides?.redirect;
181
329
  if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
182
330
  else await updateMut.mutateAsync(values);
331
+ // Untaint on success
332
+ tainted = {};
183
333
  }
184
334
 
185
- async function onFinishAutoSave(values: TVariables) {
186
- if (action === 'create' || action === 'clone') return; // Only autosave on edit
187
- redirectOverride = false; // Never redirect on autoSave
188
- await updateMut.mutateAsync(values);
189
- }
190
-
335
+ // ─── AutoSave ───────────────────────────────────────────────────
191
336
  let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
192
337
  let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
338
+ let lastAutoSaveData = $state<unknown>(null);
339
+ let lastAutoSaveError = $state<unknown>(null);
193
340
 
194
- function triggerAutoSave(values: TVariables) {
195
- if (!autoSave?.enabled || action === 'create') return;
341
+ function triggerAutoSave() {
342
+ if (!autoSaveOpts?.enabled || action === 'create') return;
196
343
  if (autoSaveTimer) clearTimeout(autoSaveTimer);
197
344
 
198
345
  autoSaveTimer = setTimeout(async () => {
199
- const finalValues = autoSave.onFinish ? autoSave.onFinish(values) : values;
346
+ const finalValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(values) : values;
200
347
  autoSaveStatus = 'saving';
201
348
  try {
202
349
  await provider.update<TData, TVariables>({ resource, id: currentId!, variables: finalValues, meta: mutationMeta });
203
- const scopes = autoSave.invalidates ?? ['resourceAll'];
350
+ const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
204
351
  for (const scope of scopes) {
205
352
  if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [resource] });
206
353
  else if (scope === 'detail' && currentId) queryClient.invalidateQueries({ queryKey: [resource, 'one', currentId] });
207
354
  else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [resource, 'list'] });
208
355
  }
209
356
  autoSaveStatus = 'saved';
210
- lastAutoSaveData = undefined; // placeholder for returned data
357
+ lastAutoSaveData = undefined;
211
358
  lastAutoSaveError = null;
212
359
  setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
213
360
  } catch (e) {
214
361
  autoSaveStatus = 'error';
215
362
  lastAutoSaveError = e;
216
363
  }
217
- }, autoSave.debounce ?? 1000);
364
+ }, autoSaveOpts.debounce ?? 1000);
218
365
  }
219
366
 
220
- let lastAutoSaveData = $state<unknown>(null);
221
- let lastAutoSaveError = $state<unknown>(null);
222
-
223
- if (autoSave?.invalidateOnUnmount) {
367
+ if (autoSaveOpts?.invalidateOnUnmount) {
224
368
  $effect(() => {
225
369
  return () => queryClient.invalidateQueries({ queryKey: [resource] });
226
370
  });
227
371
  }
228
372
 
229
- // warnWhenUnsavedChanges
373
+ // ─── warnWhenUnsavedChanges ─────────────────────────────────────
230
374
  const shouldWarn = options.warnWhenUnsavedChanges ?? adminOptions.warnWhenUnsavedChanges;
231
375
  if (shouldWarn && typeof window !== 'undefined') {
232
376
  $effect(() => {
377
+ if (!isTainted()) return;
233
378
  const handler = (e: BeforeUnloadEvent) => {
234
- // Here we'd typically check a dirty state, but useForm itself doesn't track dirty fields.
235
- // Usually the form library (svelte-forms etc) handles dirty state.
236
- // Assuming we always warn if enabled for now.
237
379
  e.preventDefault();
238
380
  e.returnValue = '';
239
381
  };
@@ -242,25 +384,45 @@ export function useForm<
242
384
  });
243
385
  }
244
386
 
387
+ // ─── Return ─────────────────────────────────────────────────────
245
388
  return {
246
- query,
247
- get formLoading() { return (query?.isLoading ?? false) || createMut.isPending || updateMut.isPending; },
248
- mutation: action === 'edit' ? updateMut : createMut,
249
- onFinish,
250
- onFinishAutoSave,
389
+ // Values
390
+ get values() { return values; },
391
+ setFieldValue,
392
+ setValues,
393
+
394
+ // Tainted
395
+ get tainted() { return tainted; },
396
+ isTainted,
397
+
398
+ // Errors
251
399
  get errors() { return errors; },
252
- setFieldError, clearErrors, clearFieldError,
253
- triggerAutoSave,
254
- get autoSaveStatus() { return autoSaveStatus; },
400
+ setFieldError, clearFieldError, clearErrors,
401
+ validateField: doValidateField,
402
+
403
+ // Submission
404
+ submit,
405
+ reset,
406
+
407
+ // State
408
+ get loading() { return query?.isLoading ?? false; },
409
+ get submitting() { return createMut.isPending || updateMut.isPending; },
255
410
  resource, action,
256
411
  get id() { return currentId; },
257
412
  setId, mutationMode, redirect: doRedirect,
258
- get autoSaveProps() {
413
+
414
+ // AutoSave
415
+ triggerAutoSave,
416
+ get autoSave() {
259
417
  return {
260
- status: autoSaveStatus as 'idle' | 'loading' | 'success' | 'error',
418
+ status: autoSaveStatus as 'idle' | 'saving' | 'saved' | 'error',
261
419
  data: lastAutoSaveData,
262
420
  error: lastAutoSaveError,
263
421
  };
264
422
  },
423
+
424
+ // Raw escape hatches
425
+ query,
426
+ mutation: (action === 'edit' ? updateMut : createMut) as unknown as ReturnType<typeof createMutation>,
265
427
  };
266
428
  }
package/src/helpers.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { Filter, Sort, CrudOperator } from './types';
4
4
  import { t } from './i18n.svelte';
5
- import { getTextTransformers } from './options';
5
+ import { getTextTransformers } from './options.svelte';
6
6
 
7
7
  // ─── Table Helpers ────────────────────────────────────────────
8
8
 
@@ -155,3 +155,97 @@ export function generateDefaultDocumentTitle(options?: {
155
155
  const prefix = action ? (actionPrefixes[action] ?? '') : '';
156
156
  return `${prefix}${resourceLabel}${suffix}`;
157
157
  }
158
+
159
+ // ─── Form Validation Derivation ─────────────────────────────────
160
+
161
+ /**
162
+ * Derive a `useForm`-compatible `validate` function from `FieldDefinition[]`.
163
+ *
164
+ * Automatically validates:
165
+ * - `required` fields (non-empty check)
166
+ * - `type === 'email'` fields (basic email format)
167
+ * - `type === 'url'` fields (basic URL format)
168
+ * - `type === 'number'` fields (must be a valid number)
169
+ * - Custom per-field `field.validate()` callback
170
+ *
171
+ * This eliminates the need for hand-written Zod schemas in most CRUD scenarios.
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * const form = useForm({
176
+ * resource: 'users',
177
+ * validate: deriveValidator(resource.fields),
178
+ * });
179
+ * ```
180
+ */
181
+ export function deriveValidator(
182
+ fields: import('./types').FieldDefinition[],
183
+ options?: {
184
+ /** Only validate these field keys. Default: all fields. */
185
+ only?: string[];
186
+ /** Skip these field keys. */
187
+ except?: string[];
188
+ }
189
+ ): (values: Record<string, unknown>) => Record<string, string> | null {
190
+ return (values: Record<string, unknown>) => {
191
+ const errors: Record<string, string> = {};
192
+ const only = options?.only ? new Set(options.only) : null;
193
+ const except = options?.except ? new Set(options.except) : null;
194
+
195
+ for (const field of fields) {
196
+ if (only && !only.has(field.key)) continue;
197
+ if (except && except.has(field.key)) continue;
198
+
199
+ const value = values[field.key];
200
+
201
+ // Required check
202
+ if (field.required) {
203
+ if (value === undefined || value === null || value === '') {
204
+ errors[field.key] = t('validation.required');
205
+ continue;
206
+ }
207
+ // Empty array check for multi-value fields
208
+ if (Array.isArray(value) && value.length === 0 && (field.type === 'multiselect' || field.type === 'tags')) {
209
+ errors[field.key] = t('validation.required');
210
+ continue;
211
+ }
212
+ }
213
+
214
+ // Skip further checks for empty non-required fields
215
+ if (value === undefined || value === null || value === '') continue;
216
+
217
+ // Type-specific validation
218
+ switch (field.type) {
219
+ case 'email': {
220
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
221
+ if (typeof value === 'string' && !emailRegex.test(value)) {
222
+ errors[field.key] = t('validation.email', { defaultValue: 'Invalid email address' });
223
+ }
224
+ break;
225
+ }
226
+ case 'url': {
227
+ try {
228
+ new URL(value as string);
229
+ } catch {
230
+ errors[field.key] = t('validation.url', { defaultValue: 'Invalid URL' });
231
+ }
232
+ break;
233
+ }
234
+ case 'number': {
235
+ if (typeof value === 'string' && isNaN(Number(value))) {
236
+ errors[field.key] = t('validation.number', { defaultValue: 'Must be a number' });
237
+ }
238
+ break;
239
+ }
240
+ }
241
+
242
+ // Custom per-field validator (always runs last, can override type checks)
243
+ if (field.validate && !errors[field.key]) {
244
+ const msg = field.validate(value);
245
+ if (msg) { errors[field.key] = msg; }
246
+ }
247
+ }
248
+
249
+ return Object.keys(errors).length > 0 ? errors : null;
250
+ };
251
+ }
@@ -10,7 +10,7 @@ export * from './routing-hooks.svelte';
10
10
  export * from './utility-hooks.svelte';
11
11
 
12
12
  // ─── Re-export types from options ─────────────────────────────────
13
- export type { InvalidateScope, OvertimeConfig } from './options';
13
+ export type { InvalidateScope, OvertimeConfig } from './options.svelte';
14
14
 
15
15
  // ─── Export shared utilities ────────────────────────────────────────
16
16
 
@@ -22,7 +22,7 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
22
22
  // here as thin wrappers or stubs until they get their own modular files.
23
23
 
24
24
  import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
25
- import { getAdminOptions } from './options';
25
+ import { getAdminOptions } from './options.svelte';
26
26
  import { getDataProviderForResource, getDataProvider } from './context.svelte';
27
27
  import { useParsed } from './useParsed.svelte';
28
28
  import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
@@ -356,10 +356,3 @@ export function useThemedLayoutContext() {
356
356
  };
357
357
  }
358
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;
package/src/index.ts CHANGED
@@ -20,7 +20,6 @@ export {
20
20
  useNavigation, useGo, useBack,
21
21
  useGetToPath, useLink,
22
22
  useResource,
23
- useLog, useLogList,
24
23
  useModalForm, useDrawerForm, useModal,
25
24
  useOvertime, useRelation,
26
25
  useNotification, useDataProvider,
@@ -28,7 +27,7 @@ export {
28
27
  } from './hooks.svelte';
29
28
  export { matchRoute, navigate, currentPath, setActiveRouterProvider } from './router';
30
29
  export { readURLState, writeURLState } from './url-sync';
31
- export { setAccessControl, setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccess, canAccessAsync } from './permissions';
30
+ export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccess, canAccessAsync } from './permissions';
32
31
  export { useLive, useSubscription, usePublish } from './live.svelte';
33
32
  export { toast } from './toast.svelte';
34
33
  export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
@@ -40,8 +39,8 @@ export type { ChatProvider, ChatMessage, ChatContext, ChatAction } from './chatP
40
39
  export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes, configureTheme, getThemeConfig, clearCssOverrides, builtinPresets, registerColorPreset, getColorPresets } from './theme.svelte';
41
40
  export type { ThemeMode, ColorTheme, ThemeStrategy, ThemeConfig, ColorPreset } from './theme.svelte';
42
41
  export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier } from './unsaved-changes.svelte';
43
- export { setAdminOptions, getAdminOptions, getTextTransformers } from './options';
44
- export type { AdminOptions, TextTransformers, OvertimeConfig } from './options';
42
+ export { setAdminOptions, getAdminOptions, getTextTransformers } from './options.svelte';
43
+ export type { AdminOptions, TextTransformers, OvertimeConfig } from './options.svelte';
45
44
 
46
45
  export * from './query-keys';
47
46
  export { HttpError } from './types';
@@ -64,9 +63,9 @@ export type {
64
63
  ResourceTypeMap, KnownResources, InferData,
65
64
  BaseRecord,
66
65
  } from './types';
67
- export type { InvalidateScope } from './options';
66
+ export type { InvalidateScope } from './options.svelte';
68
67
  export type { LiveProvider, LiveEvent, LiveMode } from './live.svelte';
69
- export type { Action, CanParams, CanResult, AccessControlProvider, AccessControlResult, AccessControlFn } from './permissions';
68
+ export type { Action, CanParams, CanResult, AccessControlProvider } from './permissions';
70
69
  export type { AuditEntry, AuditHandler } from './audit';
71
70
  export { useCan } from './useCan';
72
71
  export type { UseCanOptions, UseCanResult } from './useCan';
@@ -79,7 +78,7 @@ export {
79
78
  useLogin, useLogout,
80
79
  useRegister, useForgotPassword, useUpdatePassword,
81
80
  useGetIdentity, useIsAuthenticated,
82
- useOnError, usePermissions,
81
+ useOnError, usePermissions, useHasPermission,
83
82
  } from './auth-hooks.svelte';
84
83
  export { useParsed } from './useParsed.svelte';
85
84
  export * from './useStepsForm.svelte';
@@ -97,6 +96,7 @@ export {
97
96
  getDefaultFilter, getDefaultSortOrder,
98
97
  unionFilters, unionSorters,
99
98
  file2Base64, generateDefaultDocumentTitle,
99
+ deriveValidator,
100
100
  } from './helpers';
101
101
  export { TableState } from './table-state.svelte';
102
102
  export type { TableStateOptions } from './table-state.svelte';
@@ -1,5 +1,5 @@
1
1
  import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
- import { getAdminOptions } from './options';
2
+ import { getAdminOptions } from './options.svelte';
3
3
  import { getDataProviderForResource } from './context.svelte';
4
4
  import { useParsed } from './useParsed.svelte';
5
5
  import { audit } from './audit';
@@ -61,7 +61,7 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
61
61
  queryClient.invalidateQueries({ queryKey: [resName] });
62
62
  }
63
63
  fireSuccessNotification(params.successNotification, 'Created successfully', data.data, params.variables, resName);
64
- audit({ action: 'create', resource: resName, recordId: String((data.data as any).id) });
64
+ audit({ action: 'create', resource: resName, recordId: String((data.data as Record<string, unknown>).id) });
65
65
  },
66
66
  onError: (error, params) => {
67
67
  fireErrorNotification(params.errorNotification, 'Create failed', error);
@@ -1,13 +1,10 @@
1
- // Global admin options — centralized configuration
1
+ // Global admin options — centralized configuration (module-level $state)
2
2
  // Provides defaults that individual hooks can override
3
3
 
4
- import { setContext, getContext } from 'svelte';
5
4
  import type { MutationMode } from './types';
6
5
 
7
6
  export type InvalidateScope = 'all' | 'resourceAll' | 'detail' | 'list' | false | string[];
8
7
 
9
- const OPTIONS_KEY = Symbol('admin-options');
10
-
11
8
  export interface TextTransformers {
12
9
  humanize: (text: string) => string;
13
10
  plural: (text: string) => string;
@@ -27,7 +24,6 @@ export interface AdminOptions {
27
24
  liveMode?: 'auto' | 'manual' | 'off';
28
25
  disableServerSideValidation?: boolean;
29
26
  undoableTimeout?: number;
30
- // v0.2.26 additions
31
27
  textTransformers?: Partial<TextTransformers>;
32
28
  redirect?: {
33
29
  afterCreate?: 'list' | 'edit' | 'show' | false;
@@ -36,7 +32,7 @@ export interface AdminOptions {
36
32
  };
37
33
  reactQuery?: {
38
34
  staleTime?: number;
39
- cacheTime?: number;
35
+ gcTime?: number;
40
36
  refetchOnWindowFocus?: boolean;
41
37
  };
42
38
  title?: {
@@ -69,22 +65,18 @@ const defaultOptions: AdminOptions = {
69
65
  disableRouteChangeHandler: false,
70
66
  };
71
67
 
68
+ let adminOptions = $state<AdminOptions>(defaultOptions);
69
+
72
70
  export function setAdminOptions(options: AdminOptions): void {
73
- const merged = {
71
+ adminOptions = {
74
72
  ...defaultOptions,
75
73
  ...options,
76
74
  textTransformers: { ...defaultTextTransformers, ...options.textTransformers },
77
75
  };
78
- setContext(OPTIONS_KEY, merged);
79
76
  }
80
77
 
81
78
  export function getAdminOptions(): AdminOptions {
82
- try {
83
- const opts = getContext<AdminOptions>(OPTIONS_KEY);
84
- return opts ?? defaultOptions;
85
- } catch {
86
- return defaultOptions;
87
- }
79
+ return adminOptions;
88
80
  }
89
81
 
90
82
  /** Get the resolved text transformers (always returns full set) */
@@ -92,4 +84,3 @@ export function getTextTransformers(): TextTransformers {
92
84
  const opts = getAdminOptions();
93
85
  return { ...defaultTextTransformers, ...opts.textTransformers };
94
86
  }
95
-
@@ -1,56 +1,60 @@
1
1
  // Unit tests for permissions module
2
2
  import { describe, test, expect, beforeEach } from 'bun:test';
3
- import { setAccessControl, setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccess, canAccessAsync } from './permissions';
3
+ import { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccess, canAccessAsync } from './permissions';
4
4
  import type { Action, AccessControlProvider } from './permissions';
5
5
 
6
6
  describe('permissions', () => {
7
7
  beforeEach(() => {
8
- // Reset to no access control (allow all)
9
- setAccessControl((() => ({ can: true })) as Parameters<typeof setAccessControl>[0]);
8
+ // Reset to a permissive provider
9
+ setAccessControlProvider({
10
+ can: async () => ({ can: true }),
11
+ });
10
12
  });
11
13
 
12
- test('canAccess returns { can: true } when no deny rules', () => {
14
+ test('canAccess returns { can: true } when provider is async (warns)', () => {
15
+ // canAccess is sync but provider.can() is async — should default to { can: true }
13
16
  const result = canAccess('posts', 'list' as Action);
14
17
  expect(result.can).toBe(true);
15
18
  });
16
19
 
17
- test('canAccess respects deny rule', () => {
18
- setAccessControl((_resource: string, action: Action) => {
19
- if (action === 'delete') return { can: false, reason: 'Denied' };
20
- return { can: true };
20
+ test('canAccessAsync respects deny rule', async () => {
21
+ setAccessControlProvider({
22
+ can: async ({ action }) => {
23
+ if (action === 'delete') return { can: false, reason: 'Denied' };
24
+ return { can: true };
25
+ },
21
26
  });
22
- expect(canAccess('posts', 'delete')).toEqual({ can: false, reason: 'Denied' });
23
- expect(canAccess('posts', 'list')).toEqual({ can: true });
24
- });
25
-
26
- test('canAccess returns { can: true } when async fn used sync', () => {
27
- setAccessControl(async () => ({ can: false }));
28
- // Should warn and default to { can: true }
29
- const result = canAccess('posts', 'list');
30
- expect(result.can).toBe(true);
27
+ expect(await canAccessAsync('posts', 'delete')).toEqual({ can: false, reason: 'Denied' });
28
+ expect(await canAccessAsync('posts', 'list')).toEqual({ can: true });
31
29
  });
32
30
 
33
31
  test('canAccessAsync resolves with allowed status', async () => {
34
- setAccessControl(async () => ({ can: true }));
32
+ setAccessControlProvider({
33
+ can: async () => ({ can: true }),
34
+ });
35
35
  const result = await canAccessAsync('posts', 'edit');
36
36
  expect(result.can).toBe(true);
37
37
  });
38
38
 
39
39
  test('canAccessAsync resolves with denied status', async () => {
40
- setAccessControl(async (_r: string, _a: Action) => ({ can: false, reason: 'No permission' }));
40
+ setAccessControlProvider({
41
+ can: async () => ({ can: false, reason: 'No permission' }),
42
+ });
41
43
  const result = await canAccessAsync('users', 'delete');
42
44
  expect(result.can).toBe(false);
43
45
  expect(result.reason).toBe('No permission');
44
46
  });
45
47
 
46
- test('resource-specific rules', () => {
47
- setAccessControl((resource: string, action: Action) => {
48
- if (resource === 'users' && action === 'delete') return { can: false, reason: 'Cannot delete users' };
49
- return { can: true };
48
+ test('resource-specific rules', async () => {
49
+ setAccessControlProvider({
50
+ can: async ({ resource, action }) => {
51
+ if (resource === 'users' && action === 'delete') return { can: false, reason: 'Cannot delete users' };
52
+ return { can: true };
53
+ },
50
54
  });
51
- expect(canAccess('users', 'delete').can).toBe(false);
52
- expect(canAccess('users', 'list').can).toBe(true);
53
- expect(canAccess('posts', 'delete').can).toBe(true);
55
+ expect((await canAccessAsync('users', 'delete')).can).toBe(false);
56
+ expect((await canAccessAsync('users', 'list')).can).toBe(true);
57
+ expect((await canAccessAsync('posts', 'delete')).can).toBe(true);
54
58
  });
55
59
  });
56
60
 
@@ -16,8 +16,7 @@ export interface CanResult {
16
16
  reason?: string;
17
17
  }
18
18
 
19
- /** @deprecated Use `CanResult` instead */
20
- export type AccessControlResult = CanResult;
19
+
21
20
 
22
21
  /**
23
22
  * Formal Access Control Provider interface.
@@ -50,17 +49,9 @@ export interface AccessControlProvider {
50
49
  };
51
50
  }
52
51
 
53
- /** @deprecated Use `AccessControlProvider['can']` instead */
54
- export type AccessControlFn = (
55
- resource: string,
56
- action: Action,
57
- params?: Record<string, unknown>,
58
- ) => CanResult | Promise<CanResult>;
59
-
60
52
  // ─── State ────────────────────────────────────────────────────
61
53
 
62
54
  let provider: AccessControlProvider | null = null;
63
- let legacyFn: AccessControlFn | null = null;
64
55
 
65
56
  // ─── API ──────────────────────────────────────────────────────
66
57
 
@@ -69,21 +60,6 @@ let legacyFn: AccessControlFn | null = null;
69
60
  */
70
61
  export function setAccessControlProvider(p: AccessControlProvider): void {
71
62
  provider = p;
72
- legacyFn = null;
73
- }
74
-
75
- /**
76
- * @deprecated Use `setAccessControlProvider()` with full `AccessControlProvider` interface.
77
- * Legacy API preserved for backward compatibility.
78
- */
79
- export function setAccessControl(fn: AccessControlFn): void {
80
- legacyFn = fn;
81
- provider = {
82
- can: async ({ resource, action, params }) => {
83
- const result = fn(resource, action, params);
84
- return result instanceof Promise ? result : Promise.resolve(result);
85
- },
86
- };
87
63
  }
88
64
 
89
65
  /** Get the current AccessControlProvider (or null) */
@@ -97,23 +73,12 @@ export function getAccessControlOptions() {
97
73
  }
98
74
 
99
75
  /**
100
- * Synchronous access check. Returns `{ can: true }` if no provider/fn is set.
101
- * When a legacy sync fn is registered via `setAccessControl()`, this calls it synchronously.
102
- * When only a provider is registered, this warns if provider.can() returns a Promise.
76
+ * Synchronous access check. Returns `{ can: true }` if no provider is set.
77
+ * When a provider is registered, this warns since provider.can() is always async.
103
78
  */
104
79
  export function canAccess(resource: string, action: Action, params?: Record<string, unknown>): CanResult {
105
- // Fast path: use legacy sync fn if available
106
- if (legacyFn) {
107
- const result = legacyFn(resource, action, params);
108
- if (result instanceof Promise) {
109
- console.warn('[permissions] canAccess called synchronously but accessControlFn returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
110
- return { can: true };
111
- }
112
- return result;
113
- }
114
80
  if (!provider) return { can: true };
115
- // Provider path (always async) — warn
116
- console.warn('[permissions] canAccess called synchronously but provider.can() returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
81
+ console.warn('[permissions] canAccess called synchronously but provider.can() is async. Defaulting to { can: true }. Use canAccessAsync() instead.');
117
82
  return { can: true };
118
83
  }
119
84
 
@@ -121,9 +86,6 @@ export function canAccess(resource: string, action: Action, params?: Record<stri
121
86
  * Async access check. Preferred over `canAccess()`.
122
87
  */
123
88
  export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>): Promise<CanResult> {
124
- if (legacyFn) {
125
- return legacyFn(resource, action, params);
126
- }
127
89
  if (!provider) return { can: true };
128
90
  return provider.can({ resource, action, params });
129
91
  }
@@ -1,5 +1,5 @@
1
1
  import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
2
- import { getAdminOptions } from './options';
2
+ import { getAdminOptions } from './options.svelte';
3
3
  import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
4
4
  import { useParsed } from './useParsed.svelte';
5
5
  import {
@@ -25,7 +25,7 @@ export interface UseListOptions<TData extends BaseRecord = BaseRecord, TError =
25
25
  filters?: Filter[];
26
26
  meta?: Record<string, unknown>;
27
27
  dataProviderName?: string;
28
- queryOptions?: { staleTime?: number; enabled?: boolean; cacheTime?: number; refetchOnWindowFocus?: boolean };
28
+ queryOptions?: { staleTime?: number; enabled?: boolean; gcTime?: number; refetchOnWindowFocus?: boolean };
29
29
  successNotification?: NotificationConfig;
30
30
  errorNotification?: NotificationConfig;
31
31
  liveMode?: LiveMode;
@@ -56,7 +56,7 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
56
56
  },
57
57
  enabled: queryOptions?.enabled ?? true,
58
58
  staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
59
- gcTime: queryOptions?.cacheTime ?? adminOptions.reactQuery?.cacheTime,
59
+ gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
60
60
  refetchOnWindowFocus: queryOptions?.refetchOnWindowFocus ?? adminOptions.reactQuery?.refetchOnWindowFocus,
61
61
  }));
62
62
 
@@ -95,7 +95,7 @@ export interface UseOneOptions<TData extends BaseRecord = BaseRecord, TError = H
95
95
  id?: string | number;
96
96
  meta?: Record<string, unknown>;
97
97
  dataProviderName?: string;
98
- queryOptions?: { staleTime?: number; enabled?: boolean; cacheTime?: number; refetchOnWindowFocus?: boolean };
98
+ queryOptions?: { staleTime?: number; enabled?: boolean; gcTime?: number; refetchOnWindowFocus?: boolean };
99
99
  successNotification?: NotificationConfig;
100
100
  errorNotification?: NotificationConfig;
101
101
  liveMode?: LiveMode;
@@ -126,7 +126,7 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
126
126
  },
127
127
  enabled: (queryOptions?.enabled ?? true) && id != null,
128
128
  staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
129
- gcTime: queryOptions?.cacheTime ?? adminOptions.reactQuery?.cacheTime,
129
+ gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
130
130
  }));
131
131
 
132
132
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
@@ -170,7 +170,7 @@ export interface UseManyOptions<TData extends BaseRecord = BaseRecord, TError =
170
170
  ids: (string | number)[];
171
171
  meta?: Record<string, unknown>;
172
172
  dataProviderName?: string;
173
- queryOptions?: { staleTime?: number; enabled?: boolean; cacheTime?: number; refetchOnWindowFocus?: boolean };
173
+ queryOptions?: { staleTime?: number; enabled?: boolean; gcTime?: number; refetchOnWindowFocus?: boolean };
174
174
  successNotification?: NotificationConfig;
175
175
  errorNotification?: NotificationConfig;
176
176
  liveMode?: LiveMode;
package/src/router.ts CHANGED
@@ -57,7 +57,7 @@ export function navigate(path: string): void {
57
57
  if (_routerProvider) {
58
58
  _routerProvider.go({ to: path });
59
59
  } else {
60
- window.location.hash = path.startsWith('#') ? path : path;
60
+ window.location.hash = '#' + path.replace(/^#/, '');
61
61
  }
62
62
  }
63
63
 
@@ -138,13 +138,11 @@ export function useTable<
138
138
 
139
139
  return {
140
140
  query,
141
- tableQuery: query,
142
141
  get overtime() { return overtime; },
143
142
  get pagination() { return pagination; },
144
143
  get sorters() { return currentSorters; },
145
144
  get filters() { return currentFilters; },
146
145
  setSorters, setFilters, setPage, setPageSize,
147
- setCurrentPage: setPage,
148
146
  createLinkForSyncWithLocation,
149
147
  get totalPages() {
150
148
  // ts-expect-error reactive access
package/src/types.ts CHANGED
@@ -25,7 +25,7 @@ export class UndoError extends Error {
25
25
 
26
26
  // ─── Base Types ───────────────────────────────────────────────
27
27
 
28
- export type BaseRecord = any;
28
+ export type BaseRecord = Record<string, unknown>;
29
29
 
30
30
  // ─── DataProvider ─────────────────────────────────────────────
31
31
 
@@ -288,7 +288,8 @@ export interface FieldDefinition {
288
288
  key: string;
289
289
  label: string;
290
290
  type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'multiselect' | 'tags'
291
- | 'textarea' | 'richtext' | 'image' | 'images' | 'json' | 'relation' | 'color' | 'url' | 'email' | 'phone';
291
+ | 'textarea' | 'richtext' | 'image' | 'images' | 'json' | 'relation' | 'color'
292
+ | 'url' | 'email' | 'phone' | 'file' | 'markdown' | 'password';
292
293
  required?: boolean;
293
294
  searchable?: boolean;
294
295
  sortable?: boolean;
@@ -1,25 +1,22 @@
1
1
  import { useForm } from './form-hooks.svelte';
2
- import type { UseFormOptions, UseFormResult } from './form-hooks.svelte';
2
+ import type { UseFormOptions, UseFormReturn } from './form-hooks.svelte';
3
3
  import type { BaseRecord, HttpError } from './types';
4
4
 
5
5
  export interface UseStepsFormOptions<
6
- TQueryFnData extends BaseRecord = BaseRecord,
6
+ TVariables extends Record<string, unknown> = Record<string, unknown>,
7
+ TData extends BaseRecord = BaseRecord,
7
8
  TError = HttpError,
8
- TVariables = Record<string, unknown>,
9
- TData extends BaseRecord = TQueryFnData
10
- > extends UseFormOptions<TQueryFnData, TError, TVariables, TData> {
9
+ > extends UseFormOptions<TVariables, TData, TError> {
11
10
  /** Default step index. Starts at 0. */
12
11
  defaultStep?: number;
13
12
  /** Validate the form when navigating back? */
14
13
  isBackValidate?: boolean;
15
14
  }
16
15
 
17
- export interface UseStepsFormResult<
18
- TQueryFnData extends BaseRecord = BaseRecord,
19
- TError = HttpError,
20
- TVariables = Record<string, unknown>,
21
- TData extends BaseRecord = TQueryFnData
22
- > extends UseFormResult<TQueryFnData, TError, TVariables, TData> {
16
+ export interface UseStepsFormReturn<
17
+ TVariables extends Record<string, unknown> = Record<string, unknown>,
18
+ TData extends BaseRecord = BaseRecord,
19
+ > extends UseFormReturn<TVariables, TData> {
23
20
  currentStep: number;
24
21
  gotoStep: (step: number) => void;
25
22
  next: () => void;
@@ -31,23 +28,22 @@ export interface UseStepsFormResult<
31
28
  * Extends `useForm` with step navigation state (currentStep, next, prev, gotoStep).
32
29
  */
33
30
  export function useStepsForm<
34
- TQueryFnData extends BaseRecord = BaseRecord,
31
+ TVariables extends Record<string, unknown> = Record<string, unknown>,
32
+ TData extends BaseRecord = BaseRecord,
35
33
  TError = HttpError,
36
- TVariables = Record<string, unknown>,
37
- TData extends BaseRecord = TQueryFnData
38
- >(options: UseStepsFormOptions<TQueryFnData, TError, TVariables, TData> = {} as UseStepsFormOptions<TQueryFnData, TError, TVariables, TData>): UseStepsFormResult<TQueryFnData, TError, TVariables, TData> {
39
- const form = useForm<TQueryFnData, TError, TVariables, TData>(options);
40
-
34
+ >(options: UseStepsFormOptions<TVariables, TData, TError> = {} as UseStepsFormOptions<TVariables, TData, TError>): UseStepsFormReturn<TVariables, TData> {
35
+ const form = useForm<TVariables, TData, TError>(options);
36
+
41
37
  let currentStep = $state(options.defaultStep ?? 0);
42
-
38
+
43
39
  function gotoStep(step: number) {
44
40
  currentStep = Math.max(0, step);
45
41
  }
46
-
42
+
47
43
  function next() {
48
44
  gotoStep(currentStep + 1);
49
45
  }
50
-
46
+
51
47
  function prev() {
52
48
  if (currentStep > 0) {
53
49
  gotoStep(currentStep - 1);
@@ -55,14 +51,14 @@ export function useStepsForm<
55
51
  }
56
52
 
57
53
  // Inject stepping features into the returned useForm object while preserving its reactive getters.
58
- const result = form as unknown as UseStepsFormResult<TQueryFnData, TError, TVariables, TData>;
59
-
54
+ const result = form as unknown as UseStepsFormReturn<TVariables, TData>;
55
+
60
56
  Object.defineProperty(result, 'currentStep', {
61
57
  get: () => currentStep,
62
58
  enumerable: true,
63
59
  configurable: true
64
60
  });
65
-
61
+
66
62
  result.gotoStep = gotoStep;
67
63
  result.next = next;
68
64
  result.prev = prev;
@@ -1,8 +1,8 @@
1
- import { useForm, type UseFormOptions } from './form-hooks.svelte';
1
+ import { useForm, type UseFormOptions, type UseFormReturn } from './form-hooks.svelte';
2
2
  import { useQueryClient } from '@tanstack/svelte-query';
3
3
  import { useParsed } from './useParsed.svelte';
4
4
  import { getResource, getResources } from './context.svelte';
5
- import { getAdminOptions } from './options';
5
+ import { getAdminOptions } from './options.svelte';
6
6
  import { navigate } from './router';
7
7
  import { t } from './i18n.svelte';
8
8
  import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
@@ -21,21 +21,24 @@ export function useModal(options?: { defaultVisible?: boolean }) {
21
21
 
22
22
  // ─── useModalForm ─────────────────────────────────────────────
23
23
 
24
- export interface UseModalFormOptions<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> extends UseFormOptions<TQueryFnData, TError, TVariables, TData> {
24
+ export interface UseModalFormOptions<
25
+ TVariables extends Record<string, unknown> = Record<string, unknown>,
26
+ TData extends BaseRecord = BaseRecord,
27
+ TError = HttpError,
28
+ > extends UseFormOptions<TVariables, TData, TError> {
25
29
  defaultVisible?: boolean;
26
- autoSave?: UseFormOptions<TQueryFnData, TError, TVariables, TData>['autoSave'] & {
30
+ autoSave?: UseFormOptions<TVariables, TData, TError>['autoSave'] & {
27
31
  invalidateOnClose?: boolean;
28
32
  };
29
33
  }
30
34
 
31
35
  export function useModalForm<
32
- TQueryFnData extends BaseRecord = BaseRecord,
36
+ TVariables extends Record<string, unknown> = Record<string, unknown>,
37
+ TData extends BaseRecord = BaseRecord,
33
38
  TError = HttpError,
34
- TVariables = Record<string, unknown>,
35
- TData extends BaseRecord = TQueryFnData
36
- >(options: UseModalFormOptions<TQueryFnData, TError, TVariables, TData> = {}) {
39
+ >(options: UseModalFormOptions<TVariables, TData, TError> = {} as UseModalFormOptions<TVariables, TData, TError>) {
37
40
  const queryClient = useQueryClient();
38
- const formState = useForm(options);
41
+ const formState = useForm<TVariables, TData, TError>(options);
39
42
  let visible = $state(options.defaultVisible ?? false);
40
43
 
41
44
  function show(id?: string | number) {
@@ -45,7 +48,7 @@ export function useModalForm<
45
48
 
46
49
  function close() {
47
50
  visible = false;
48
- formState.clearErrors();
51
+ formState.reset();
49
52
  if (options.autoSave?.invalidateOnClose) {
50
53
  queryClient.invalidateQueries({ queryKey: [formState.resource] });
51
54
  }