@svadmin/core 0.12.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 +1 -1
- package/src/context.svelte.ts +1 -1
- package/src/form-hooks.svelte.ts +223 -61
- package/src/helpers.ts +95 -1
- package/src/hooks.svelte.ts +2 -9
- package/src/index.ts +6 -6
- package/src/mutation-hooks.svelte.ts +2 -2
- package/src/{options.ts → options.svelte.ts} +6 -15
- package/src/permissions.test.ts +30 -26
- package/src/permissions.ts +4 -42
- package/src/query-hooks.svelte.ts +6 -6
- package/src/router.ts +1 -1
- package/src/table-hooks.svelte.ts +0 -2
- package/src/types.ts +3 -2
- package/src/useStepsForm.svelte.ts +19 -23
- package/src/utility-hooks.svelte.ts +13 -10
package/package.json
CHANGED
package/src/context.svelte.ts
CHANGED
|
@@ -112,7 +112,7 @@ export function getRouterProvider(): RouterProvider | undefined {
|
|
|
112
112
|
|
|
113
113
|
// ─── Live Provider ──────────────────────────────────────────────
|
|
114
114
|
|
|
115
|
-
let liveProviderState
|
|
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;
|
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
TVariables extends Record<string, unknown> = Record<string, unknown>,
|
|
136
|
+
TData extends BaseRecord = BaseRecord,
|
|
71
137
|
TError = HttpError,
|
|
72
|
-
|
|
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,
|
|
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) {
|
|
205
|
+
function clearFieldError(field: string) {
|
|
206
|
+
const next = { ...errors };
|
|
207
|
+
delete next[field];
|
|
208
|
+
errors = next;
|
|
209
|
+
}
|
|
107
210
|
|
|
108
|
-
function
|
|
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<
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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(
|
|
195
|
-
if (!
|
|
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 =
|
|
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 =
|
|
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;
|
|
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
|
-
},
|
|
364
|
+
}, autoSaveOpts.debounce ?? 1000);
|
|
218
365
|
}
|
|
219
366
|
|
|
220
|
-
|
|
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
|
-
|
|
247
|
-
get
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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,
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
413
|
+
|
|
414
|
+
// AutoSave
|
|
415
|
+
triggerAutoSave,
|
|
416
|
+
get autoSave() {
|
|
259
417
|
return {
|
|
260
|
-
status: autoSaveStatus as 'idle' | '
|
|
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
|
+
}
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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';
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
package/src/permissions.test.ts
CHANGED
|
@@ -1,56 +1,60 @@
|
|
|
1
1
|
// Unit tests for permissions module
|
|
2
2
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
3
|
-
import {
|
|
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
|
|
9
|
-
|
|
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
|
|
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('
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
23
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
52
|
-
expect(
|
|
53
|
-
expect(
|
|
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
|
|
package/src/permissions.ts
CHANGED
|
@@ -16,8 +16,7 @@ export interface CanResult {
|
|
|
16
16
|
reason?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
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
|
|
101
|
-
* When a
|
|
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
|
-
|
|
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;
|
|
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?.
|
|
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;
|
|
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?.
|
|
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;
|
|
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
|
@@ -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 =
|
|
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'
|
|
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,
|
|
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
|
-
|
|
6
|
+
TVariables extends Record<string, unknown> = Record<string, unknown>,
|
|
7
|
+
TData extends BaseRecord = BaseRecord,
|
|
7
8
|
TError = HttpError,
|
|
8
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
31
|
+
TVariables extends Record<string, unknown> = Record<string, unknown>,
|
|
32
|
+
TData extends BaseRecord = BaseRecord,
|
|
35
33
|
TError = HttpError,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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<
|
|
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<
|
|
30
|
+
autoSave?: UseFormOptions<TVariables, TData, TError>['autoSave'] & {
|
|
27
31
|
invalidateOnClose?: boolean;
|
|
28
32
|
};
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export function useModalForm<
|
|
32
|
-
|
|
36
|
+
TVariables extends Record<string, unknown> = Record<string, unknown>,
|
|
37
|
+
TData extends BaseRecord = BaseRecord,
|
|
33
38
|
TError = HttpError,
|
|
34
|
-
|
|
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.
|
|
51
|
+
formState.reset();
|
|
49
52
|
if (options.autoSave?.invalidateOnClose) {
|
|
50
53
|
queryClient.invalidateQueries({ queryKey: [formState.resource] });
|
|
51
54
|
}
|