@svadmin/core 0.49.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +32 -0
  2. package/package.json +21 -4
  3. package/src/access-control-contract.ts +250 -0
  4. package/src/adapters/casbin.ts +20 -14
  5. package/src/adapters/casl.ts +22 -19
  6. package/src/admin-tool.ts +7 -7
  7. package/src/audit-contract.ts +105 -0
  8. package/src/audit.ts +88 -128
  9. package/src/auth-hooks.svelte.ts +593 -261
  10. package/src/auth-mutation-contract.ts +143 -0
  11. package/src/auth-query-contract.ts +97 -0
  12. package/src/captured-mutation.svelte.ts +45 -0
  13. package/src/chatProvider.svelte.ts +1 -1
  14. package/src/command-contract.ts +195 -0
  15. package/src/context.svelte.ts +217 -205
  16. package/src/contract-context.svelte.ts +37 -0
  17. package/src/contract-form.svelte.ts +566 -0
  18. package/src/create-contract.ts +56 -0
  19. package/src/create-hooks.svelte.ts +347 -0
  20. package/src/create-many-contract.ts +90 -0
  21. package/src/create-many-hooks.svelte.ts +396 -0
  22. package/src/data-transfer.svelte.ts +1 -215
  23. package/src/defined-options.ts +50 -0
  24. package/src/delete-contract.ts +50 -0
  25. package/src/delete-hooks.svelte.ts +403 -0
  26. package/src/delete-many-contract.ts +60 -0
  27. package/src/delete-many-hooks.svelte.ts +402 -0
  28. package/src/enterprise.ts +4 -4
  29. package/src/export-format.ts +16 -14
  30. package/src/export-hooks.svelte.ts +191 -0
  31. package/src/feature-gate-contract.ts +53 -0
  32. package/src/hook-utils.svelte.ts +53 -105
  33. package/src/hooks.svelte.ts +19 -774
  34. package/src/http.ts +88 -87
  35. package/src/i18n.svelte.ts +56 -2
  36. package/src/import-contract.ts +106 -0
  37. package/src/import-hooks.svelte.ts +310 -0
  38. package/src/index.ts +77 -28
  39. package/src/inferencer-graphql.ts +7 -6
  40. package/src/infinite-query.svelte.ts +228 -0
  41. package/src/invalidation-contract.ts +26 -0
  42. package/src/live-sse.ts +91 -127
  43. package/src/live-subscription.svelte.ts +130 -0
  44. package/src/live-transport.ts +127 -0
  45. package/src/live-websocket.ts +85 -118
  46. package/src/live.svelte.ts +66 -68
  47. package/src/notification.svelte.ts +17 -15
  48. package/src/permission-catalog.ts +121 -0
  49. package/src/permission-hints-contract.ts +48 -0
  50. package/src/permissions.svelte.ts +112 -59
  51. package/src/plain-data.ts +61 -0
  52. package/src/provider-bundle.ts +85 -79
  53. package/src/provider-response.ts +35 -0
  54. package/src/query-hooks.svelte.ts +241 -286
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +216 -0
  58. package/src/query-snapshot.ts +144 -0
  59. package/src/reactive-projection.ts +53 -0
  60. package/src/record-decoder.ts +63 -0
  61. package/src/resource-binding.svelte.ts +19 -0
  62. package/src/resource-contract.ts +595 -0
  63. package/src/resource-mutation-types.ts +30 -0
  64. package/src/resource-schemas.ts +371 -0
  65. package/src/resource-transport-config.ts +156 -0
  66. package/src/route-parsing.ts +11 -11
  67. package/src/router-provider.ts +64 -59
  68. package/src/router.ts +8 -3
  69. package/src/routing-hooks.svelte.ts +90 -62
  70. package/src/schema-validation.ts +92 -0
  71. package/src/schema.ts +13 -0
  72. package/src/select-options.ts +95 -0
  73. package/src/select-query.svelte.ts +191 -0
  74. package/src/session-query.svelte.ts +88 -0
  75. package/src/strict-command-hooks.svelte.ts +333 -0
  76. package/src/strict-hooks.svelte.ts +198 -0
  77. package/src/table-hooks.svelte.ts +180 -174
  78. package/src/table-state.svelte.ts +117 -117
  79. package/src/task-contract.ts +87 -0
  80. package/src/task-hooks.svelte.ts +193 -149
  81. package/src/task-provider.ts +115 -0
  82. package/src/task-subscription.ts +132 -0
  83. package/src/theme.svelte.ts +155 -154
  84. package/src/toast.svelte.ts +38 -37
  85. package/src/types.ts +55 -103
  86. package/src/unsafe.ts +7 -0
  87. package/src/update-contract.ts +65 -0
  88. package/src/update-hooks.svelte.ts +347 -0
  89. package/src/update-many-contract.ts +79 -0
  90. package/src/update-many-hooks.svelte.ts +373 -0
  91. package/src/url-sync.ts +11 -10
  92. package/src/useCan.ts +96 -31
  93. package/src/useParsed.svelte.ts +18 -14
  94. package/src/useStepsForm.svelte.ts +123 -63
  95. package/src/utility-hooks.svelte.ts +101 -158
  96. package/src/form-hooks.svelte.ts +0 -889
  97. package/src/mutation-hooks.svelte.ts +0 -561
@@ -1,889 +0,0 @@
1
- import { useQueryClient } from '@tanstack/svelte-query';
2
- import { useParsed } from './useParsed.svelte';
3
- import { getAdminOptions } from './options.svelte';
4
- import { captureAdminContext } from './context.svelte';
5
- import { queryKeyMatches } from './query-keys';
6
- import { createQuery, createMutation } from '@tanstack/svelte-query';
7
- import { notifyWithProvider } from './notification.svelte';
8
- import type { NotificationParams } from './notification.svelte';
9
- import { useTranslation } from './i18n.svelte';
10
- import { auditWithProvider } from './audit';
11
- import type { AuditEntry } from './audit';
12
- import { HttpError, UndoError } from './types';
13
- import type { BaseRecord, MutationMode, KnownResources } from './types';
14
- import { checkError } from './hook-utils.svelte';
15
- import { toast } from './toast.svelte';
16
- import { deepMerge, invalidateByScopes } from './mutation-hooks.svelte';
17
- import { appendListQueryFromPath } from './url-sync';
18
-
19
- // ─── Types ───────────────────────────────────────────────────────────
20
-
21
- export interface UseFormOptions<
22
- TVariables extends Record<string, unknown> = Record<string, unknown>,
23
- _TData extends BaseRecord = BaseRecord,
24
- _TError = HttpError,
25
- > {
26
- resource?: KnownResources;
27
- action?: 'create' | 'edit' | 'clone' | 'show';
28
- id?: string | number;
29
- redirect?: 'list' | 'edit' | 'show' | false;
30
-
31
- /** Initial values for the form. Merged with query data in edit mode (query wins). */
32
- defaultValues?: TVariables;
33
-
34
- // ─── Validation ───────────────────────────────────────────────
35
- /** Full-form validator. Returns field→error map or null. */
36
- validate?: (values: TVariables) => Record<string, string> | null;
37
- /** Per-field validator for real-time validation. */
38
- validateField?: (field: string, value: unknown, values: TVariables) => string | null;
39
-
40
- // ─── Notifications ────────────────────────────────────────────
41
- successNotification?: string | false;
42
- errorNotification?: string | false;
43
-
44
- // ─── Callbacks ────────────────────────────────────────────────
45
- onMutationSuccess?: (data: unknown) => void;
46
- onMutationError?: (error: Error) => void;
47
- /** Called every time a field value changes. */
48
- onChange?: (event: { field: string; value: unknown; values: TVariables }) => void;
49
- /** Called before mutation. Return false or call cancel() to prevent submission. */
50
- onSubmit?: (ctx: { values: TVariables; action: 'create' | 'edit' | 'clone' | 'show'; cancel: () => void }) => boolean | undefined;
51
-
52
- // ─── Meta ─────────────────────────────────────────────────────
53
- meta?: Record<string, unknown>;
54
- queryMeta?: Record<string, unknown>;
55
- mutationMeta?: Record<string, unknown>;
56
-
57
- // ─── Server validation ────────────────────────────────────────
58
- disableServerSideValidation?: boolean;
59
-
60
- // ─── AutoSave ─────────────────────────────────────────────────
61
- autoSave?: {
62
- enabled: boolean;
63
- debounce?: number;
64
- onFinish?: (values: TVariables) => TVariables;
65
- invalidateOnUnmount?: boolean;
66
- invalidates?: string[];
67
- };
68
-
69
- // ─── Mutation behavior ────────────────────────────────────────
70
- mutationMode?: MutationMode;
71
- undoableTimeout?: number;
72
- invalidates?: string[] | false;
73
- optimisticUpdateMap?: {
74
- list?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
75
- detail?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
76
- };
77
-
78
- // ─── Query config ─────────────────────────────────────────────
79
- queryOptions?: { staleTime?: number; enabled?: boolean };
80
- createMutationOptions?: Record<string, unknown>;
81
- updateMutationOptions?: Record<string, unknown>;
82
-
83
- /** DataProvider to use for this form */
84
- dataProviderName?: string;
85
-
86
- warnWhenUnsavedChanges?: boolean;
87
- }
88
-
89
- export interface UseFormReturn<
90
- TVariables extends Record<string, unknown> = Record<string, unknown>,
91
- _TData extends BaseRecord = BaseRecord,
92
- > {
93
- // ─── Form values (single source of truth) ─────────────────────
94
- /** Reactive form values. Read/write directly. */
95
- readonly values: TVariables;
96
- /** Set a single field value. Tracks tainted state and fires onChange. */
97
- setFieldValue: (field: string, value: unknown, opts?: { taint?: boolean }) => void;
98
- /** Bulk-set multiple field values. */
99
- setValues: (newValues: Partial<TVariables>, opts?: { taint?: boolean }) => void;
100
-
101
- // ─── Tainted (dirty) ──────────────────────────────────────────
102
- readonly tainted: Record<string, boolean>;
103
- isTainted: (field?: string) => boolean;
104
- untaint: () => void;
105
-
106
- // ─── Errors ───────────────────────────────────────────────────
107
- readonly errors: Record<string, string>;
108
- setFieldError: (field: string, message: string) => void;
109
- clearFieldError: (field: string) => void;
110
- clearErrors: () => void;
111
- /** Validate a single field. Sets/clears the field error. Returns the error or null. */
112
- validateField: (field: string) => string | null;
113
-
114
- // ─── Submission ───────────────────────────────────────────────
115
- /** Submit the form. Validates → onSubmit hook → mutation → redirect. */
116
- submit: (overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) => Promise<void>;
117
- /** Reset form to initial/query values. Clears tainted and errors. */
118
- reset: () => void;
119
-
120
- // ─── State ──────────────────────────────────────────────────────
121
- readonly loading: boolean;
122
- readonly submitting: boolean;
123
- readonly action: 'create' | 'edit' | 'clone' | 'show';
124
- readonly resource: string;
125
- readonly id: string | number | undefined;
126
- readonly isDirty: boolean;
127
- setId: (id: string | number | undefined) => void;
128
- setAction: (action: 'create' | 'edit' | 'clone' | 'show') => void;
129
- mutationMode: MutationMode;
130
- redirect: (to: 'list' | 'edit' | 'show' | false) => void;
131
-
132
- // ─── AutoSave ─────────────────────────────────────────────────
133
- triggerAutoSave: () => void;
134
- readonly autoSave: { status: 'idle' | 'saving' | 'saved' | 'error'; data: unknown; error: unknown };
135
-
136
- // ─── Raw query/mutation (escape hatch) ────────────────────────
137
- readonly query: unknown;
138
- readonly mutation: unknown;
139
- }
140
-
141
- interface FormRecordIdentity {
142
- generation: number;
143
- resource: string;
144
- action: 'create' | 'edit' | 'clone' | 'show';
145
- id: string | number | undefined;
146
- }
147
-
148
- // ─── Implementation ──────────────────────────────────────────────────
149
-
150
- export function useForm<
151
- TVariables extends Record<string, unknown> = Record<string, unknown>,
152
- TData extends BaseRecord = BaseRecord,
153
- TError = HttpError,
154
- >(options: UseFormOptions<TVariables, TData, TError> = {} as UseFormOptions<TVariables, TData, TError>): UseFormReturn<TVariables, TData> {
155
- const adminContext = captureAdminContext();
156
- const i18n = useTranslation();
157
- const queryClient = useQueryClient();
158
- const parsed = useParsed();
159
- const adminOptions = getAdminOptions();
160
- const sendNotification = (params: NotificationParams) =>
161
- notifyWithProvider(params, adminContext.notificationProvider);
162
- const recordAudit = (entry: Omit<AuditEntry, 'timestamp'>) =>
163
- auditWithProvider(entry, adminContext.auditLogProvider);
164
-
165
- const resource = $derived(options.resource ?? parsed.resource ?? '');
166
- let action = $state<'create' | 'edit' | 'clone' | 'show'>(options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone' | 'show') ?? 'create');
167
- let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
168
-
169
- const defaultRedirectOpt = $derived(action === 'clone' ? adminOptions.redirect?.afterClone : action === 'edit' ? adminOptions.redirect?.afterEdit : adminOptions.redirect?.afterCreate);
170
- const {
171
- redirect: redirectOption,
172
- successNotification, errorNotification,
173
- onMutationSuccess, onMutationError,
174
- meta: hookMeta, queryMeta: hookQueryMeta, mutationMeta: hookMutationMeta,
175
- disableServerSideValidation, autoSave: autoSaveOpts,
176
- mutationMode = adminOptions.mutationMode ?? 'pessimistic',
177
- undoableTimeout = adminOptions.undoableTimeout ?? 5000,
178
- dataProviderName, invalidates: invalidateScopes,
179
- optimisticUpdateMap,
180
- queryOptions,
181
- onChange: onChangeFn,
182
- onSubmit: onSubmitFn,
183
- } = options;
184
- // Reactive redirect: uses $derived `defaultRedirectOpt` so redirect target
185
- // updates when `action` changes at runtime (e.g., via `setAction`).
186
- const redirectDefault = $derived(redirectOption ?? defaultRedirectOpt ?? 'list');
187
-
188
- const provider = $derived(adminContext.getDataProviderForResource(resource, dataProviderName));
189
- const queryMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookQueryMeta });
190
- const mutationMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookMutationMeta });
191
-
192
- let recordGeneration = 0;
193
- let formDestroyed = false;
194
-
195
- function captureRecordIdentity(): FormRecordIdentity {
196
- return { generation: recordGeneration, resource, action, id: currentId };
197
- }
198
-
199
- function isRecordIdentityCurrent(identity: FormRecordIdentity): boolean {
200
- return !formDestroyed
201
- && identity.generation === recordGeneration
202
- && identity.resource === resource
203
- && identity.action === action;
204
- }
205
-
206
- function invalidateRecordIdentity() {
207
- recordGeneration += 1;
208
- if (autoSaveTimer) {
209
- clearTimeout(autoSaveTimer);
210
- autoSaveTimer = null;
211
- }
212
- if (autoSaveStatusTimer) {
213
- clearTimeout(autoSaveStatusTimer);
214
- autoSaveStatusTimer = null;
215
- }
216
- autoSaveDirty = false;
217
- autoSaveStatus = 'idle';
218
- lastAutoSaveData = null;
219
- lastAutoSaveError = null;
220
- tainted = {};
221
- clearErrors();
222
- }
223
-
224
- function setId(newId: string | number | undefined) {
225
- if (Object.is(currentId, newId)) return;
226
- invalidateRecordIdentity();
227
- currentId = newId;
228
- }
229
-
230
- function setAction(newAction: 'create' | 'edit' | 'clone' | 'show') {
231
- if (action === newAction) return;
232
- invalidateRecordIdentity();
233
- action = newAction;
234
- }
235
-
236
- // ─── Form values (single source of truth) ───────────────────────
237
- const defaultValuesSource = (options.defaultValues ?? {}) as TVariables;
238
- let values = $state<TVariables>(createPersistenceSnapshot(defaultValuesSource));
239
- let initialValues = createPersistenceSnapshot(defaultValuesSource);
240
-
241
- function setFieldValue(field: string, value: unknown, opts?: { taint?: boolean }) {
242
- values = { ...values, [field]: value } as TVariables;
243
- const shouldTaint = opts?.taint ?? true;
244
- if (shouldTaint) tainted = { ...tainted, [field]: true };
245
- // Clear field error on change
246
- if (errors[field]) clearFieldError(field);
247
- onChangeFn?.({ field, value, values });
248
- triggerAutoSave();
249
- }
250
-
251
- function setValues(newValues: Partial<TVariables>, opts?: { taint?: boolean }) {
252
- values = { ...values, ...newValues } as TVariables;
253
- const shouldTaint = opts?.taint ?? true;
254
- if (shouldTaint) {
255
- const newTainted = { ...tainted };
256
- for (const key of Object.keys(newValues)) newTainted[key] = true;
257
- tainted = newTainted;
258
- }
259
- triggerAutoSave();
260
- }
261
-
262
- // ─── Tainted (dirty) state ──────────────────────────────────────
263
- let tainted = $state<Record<string, boolean>>({});
264
-
265
- function isTainted(field?: string): boolean {
266
- if (field !== undefined) return !!tainted[field];
267
- return Object.values(tainted).some(Boolean);
268
- }
269
-
270
- function isPlainObject(value: object): boolean {
271
- const prototype = Object.getPrototypeOf(value);
272
- return prototype === Object.prototype || prototype === null;
273
- }
274
-
275
- function isFormData(value: object): value is FormData {
276
- return typeof FormData !== 'undefined' && value instanceof FormData;
277
- }
278
-
279
- function readOwnValue(value: object, key: PropertyKey): unknown {
280
- const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
281
- if (descriptor === undefined) return undefined;
282
- return 'value' in descriptor ? descriptor.value : Reflect.get(value, key);
283
- }
284
-
285
- function snapshotPersistenceValue(value: unknown, seen: WeakMap<object, unknown>): unknown {
286
- if (value === null || typeof value !== 'object') return value;
287
-
288
- const existing = seen.get(value);
289
- if (existing !== undefined) return existing;
290
-
291
- if (value instanceof Date) {
292
- const snapshot = new Date(value.getTime());
293
- seen.set(value, snapshot);
294
- return snapshot;
295
- }
296
-
297
- if (isFormData(value)) {
298
- const snapshot = new FormData();
299
- seen.set(value, snapshot);
300
- for (const [key, entry] of value.entries()) snapshot.append(key, entry);
301
- return snapshot;
302
- }
303
-
304
- if (Array.isArray(value)) {
305
- const snapshot = new Array<unknown>(value.length);
306
- seen.set(value, snapshot);
307
- for (let index = 0; index < value.length; index += 1) {
308
- if (index in value) snapshot[index] = snapshotPersistenceValue(readOwnValue(value, String(index)), seen);
309
- }
310
- return snapshot;
311
- }
312
-
313
- if (!isPlainObject(value)) return value;
314
-
315
- const snapshot = Object.create(Object.getPrototypeOf(value)) as Record<PropertyKey, unknown>;
316
- seen.set(value, snapshot);
317
- for (const key of Reflect.ownKeys(value)) {
318
- if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue;
319
- snapshot[key] = snapshotPersistenceValue(readOwnValue(value, key), seen);
320
- }
321
- return snapshot;
322
- }
323
-
324
- function createPersistenceSnapshot(source: TVariables): TVariables {
325
- return snapshotPersistenceValue(source, new WeakMap<object, unknown>()) as TVariables;
326
- }
327
-
328
- function enumerableOwnKeys(value: object): PropertyKey[] {
329
- return Reflect.ownKeys(value).filter((key) => Object.prototype.propertyIsEnumerable.call(value, key));
330
- }
331
-
332
- function persistenceValuesEqual(
333
- left: unknown,
334
- right: unknown,
335
- leftToRight: WeakMap<object, object> = new WeakMap<object, object>(),
336
- rightToLeft: WeakMap<object, object> = new WeakMap<object, object>(),
337
- ): boolean {
338
- if (Object.is(left, right)) return true;
339
- if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false;
340
-
341
- const leftIsDate = left instanceof Date;
342
- if (leftIsDate || right instanceof Date) {
343
- return leftIsDate && right instanceof Date && Object.is(left.getTime(), right.getTime());
344
- }
345
-
346
- const leftIsFormData = isFormData(left);
347
- if (leftIsFormData || isFormData(right)) {
348
- if (!leftIsFormData || !isFormData(right)) return false;
349
- const leftEntries = Array.from(left.entries());
350
- const rightEntries = Array.from(right.entries());
351
- if (leftEntries.length !== rightEntries.length) return false;
352
- return leftEntries.every(([leftKey, leftValue], index) => {
353
- const rightEntry = rightEntries[index];
354
- return rightEntry !== undefined
355
- && leftKey === rightEntry[0]
356
- && persistenceValuesEqual(leftValue, rightEntry[1], leftToRight, rightToLeft);
357
- });
358
- }
359
-
360
- const leftIsArray = Array.isArray(left);
361
- if (leftIsArray !== Array.isArray(right)) return false;
362
- if (!leftIsArray && (!isPlainObject(left) || !isPlainObject(right))) return false;
363
- if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false;
364
-
365
- const previousRight = leftToRight.get(left);
366
- if (previousRight !== undefined) return previousRight === right;
367
- const previousLeft = rightToLeft.get(right);
368
- if (previousLeft !== undefined) return previousLeft === left;
369
- leftToRight.set(left, right);
370
- rightToLeft.set(right, left);
371
-
372
- if (leftIsArray && left.length !== (right as unknown[]).length) return false;
373
-
374
- const leftKeys = enumerableOwnKeys(left);
375
- const rightKeys = enumerableOwnKeys(right);
376
- if (leftKeys.length !== rightKeys.length) return false;
377
-
378
- for (const key of leftKeys) {
379
- if (!Object.prototype.hasOwnProperty.call(right, key)) return false;
380
- if (!persistenceValuesEqual(readOwnValue(left, key), readOwnValue(right, key), leftToRight, rightToLeft)) return false;
381
- }
382
- return true;
383
- }
384
-
385
- function reconcilePersistedSnapshot(sourceSnapshot: TVariables, persistedSnapshot: TVariables = sourceSnapshot) {
386
- const nextInitialValues = createPersistenceSnapshot(persistedSnapshot);
387
- let nextTainted = { ...tainted };
388
-
389
- for (const field of Object.keys(sourceSnapshot)) {
390
- if (persistenceValuesEqual(readOwnValue(values, field), readOwnValue(sourceSnapshot, field))) {
391
- const { [field]: _persistedField, ...remainingTainted } = nextTainted;
392
- nextTainted = remainingTainted;
393
- } else {
394
- nextTainted[field] = true;
395
- }
396
- }
397
-
398
- initialValues = nextInitialValues;
399
- tainted = nextTainted;
400
- }
401
-
402
- // ─── Errors ─────────────────────────────────────────────────────
403
- let errors = $state<Record<string, string>>({});
404
-
405
- function setFieldError(field: string, message: string) { errors = { ...errors, [field]: message }; }
406
- function clearErrors() { errors = {}; }
407
- function clearFieldError(field: string) {
408
- const { [field]: _, ...next } = errors;
409
- errors = next;
410
- }
411
-
412
- function doValidateField(field: string): string | null {
413
- const value = values[field];
414
- if (options.validateField) {
415
- const msg = options.validateField(field, value, values);
416
- if (msg) { setFieldError(field, msg); return msg; }
417
- else { clearFieldError(field); }
418
- }
419
- return null;
420
- }
421
-
422
- function runValidation(): boolean {
423
- clearErrors();
424
- const validate = options.validate;
425
- if (validate) {
426
- const result = validate(values);
427
- if (result && Object.keys(result).length > 0) {
428
- errors = result;
429
- return false;
430
- }
431
- }
432
- return true;
433
- }
434
-
435
- // ─── Reset ──────────────────────────────────────────────────────
436
- function reset() {
437
- values = createPersistenceSnapshot(initialValues);
438
- tainted = {};
439
- clearErrors();
440
- queryInitializedId = currentId;
441
- }
442
-
443
- // ─── Server error handling ──────────────────────────────────────
444
-
445
- function handleHttpError(error: Error) {
446
- checkError(error, adminContext);
447
- if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
448
- for (const [field, messages] of Object.entries(error.errors)) {
449
- const msg = Array.isArray(messages) ? messages[0] : messages;
450
- setFieldError(field, msg);
451
- }
452
- if (errorNotification !== false) sendNotification({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (error.message || i18n.t('common.operationFailed')) });
453
- } else {
454
- if (errorNotification !== false) sendNotification({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (i18n.t('common.operationFailed') + ': ' + error.message) });
455
- }
456
- }
457
-
458
- // ─── Query (edit/clone mode) ────────────────────────────────────
459
- // Always create the query — use `enabled` to conditionally activate.
460
- // This ensures setAction('edit') at runtime will trigger a fetch.
461
- const query = createQuery(() => ({
462
- queryKey: adminContext.queryKeys(resource, options.dataProviderName).data.one(resource, currentId ?? '', {
463
- meta: queryMeta,
464
- }),
465
- queryFn: async () => {
466
- const result = await provider.getOne<BaseRecord>({ resource, id: currentId as string, meta: queryMeta });
467
- return result;
468
- },
469
- enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone' || action === 'show') && currentId != null,
470
- staleTime: queryOptions?.staleTime,
471
- }));
472
-
473
- // Auto-populate values from query data
474
- let queryInitializedId: string | number | undefined = undefined;
475
- {
476
- $effect.pre(() => {
477
- // Discard previous fetch lock and default when switched strictly back to create
478
- if (action === 'create' && queryInitializedId !== undefined) {
479
- queryInitializedId = undefined;
480
- const def = (options.defaultValues ?? {}) as TVariables;
481
- values = createPersistenceSnapshot(def);
482
- initialValues = createPersistenceSnapshot(def);
483
- tainted = {};
484
- clearErrors();
485
- return;
486
- }
487
- if (queryInitializedId === currentId) return;
488
- const record = (query.data as { data: Record<string, unknown> } | undefined)?.data;
489
- const pk = adminContext.getResource(resource).primaryKey ?? 'id';
490
- if (record && String(record[pk]) === String(currentId)) {
491
- const cloneStripKeys = new Set(['id', '_id', pk, 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updated_by', 'created_by', 'updatedBy']);
492
- const targetData = action === 'clone'
493
- ? Object.fromEntries(Object.entries(record).filter(([k]) => !cloneStripKeys.has(k)))
494
- : record;
495
- // Merge: defaultValues < query data
496
- const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
497
- values = createPersistenceSnapshot(merged);
498
- initialValues = createPersistenceSnapshot(merged);
499
- tainted = {};
500
- clearErrors();
501
- queryInitializedId = currentId;
502
- }
503
- });
504
- }
505
-
506
- // ─── Mutations ──────────────────────────────────────────────────
507
- let redirectOverride: 'list' | 'edit' | 'show' | false | undefined;
508
- let manualSubmitInProgress = false;
509
- let manualSubmitIdentity: FormRecordIdentity | null = null;
510
-
511
- function isManualSubmitIdentityCurrent(): boolean {
512
- if (formDestroyed) return false;
513
- return !manualSubmitInProgress
514
- || manualSubmitIdentity == null
515
- || isRecordIdentityCurrent(manualSubmitIdentity);
516
- }
517
-
518
- const createMut = createMutation(() => ({
519
- ...options.createMutationOptions,
520
- mutationFn: (variables: unknown) => {
521
- const targetResource = resource;
522
- const targetMeta = mutationMeta;
523
- return provider.create<TData, TVariables>({ resource: targetResource, variables: variables as TVariables, meta: targetMeta });
524
- },
525
- onMutate: async () => {
526
- return { targetResource: resource };
527
- },
528
- onSuccess: (data: { data: TData }, _vars: unknown, context: unknown) => {
529
- const ctx = context as { targetResource?: string } | undefined;
530
- const res = ctx?.targetResource ?? resource;
531
- invalidateByScopes({
532
- queryClient,
533
- resource: res,
534
- scopes: invalidateScopes,
535
- defaults: ['list', 'many'],
536
- matcher: adminContext.queryKeyMatcher(res, options.dataProviderName),
537
- });
538
- if (!isManualSubmitIdentityCurrent()) return;
539
- if (successNotification !== false) sendNotification({ type: 'success', message: typeof successNotification === 'string' ? successNotification : i18n.t('common.createSuccess') });
540
- const pk = adminContext.getResource(res).primaryKey ?? 'id';
541
- const newId = (data.data as Record<string, unknown>)[pk];
542
- recordAudit({
543
- action: 'create',
544
- resource: res,
545
- recordId: String(newId),
546
- meta: adminContext.getProviderMeta(res),
547
- });
548
- try {
549
- const lp = adminContext.liveProvider;
550
- if (lp?.publish) lp.publish({ type: 'INSERT', resource: res, payload: { ids: newId != null ? [newId as string | number] : [] } });
551
- } catch { /* no live provider */ }
552
- onMutationSuccess?.(data);
553
- if (!manualSubmitInProgress && redirectOverride !== false) {
554
- if (newId != null) currentId = newId as string | number;
555
- doRedirect(redirectOverride ?? redirectDefault);
556
- } else if (manualSubmitInProgress && redirectOverride !== false && newId != null) {
557
- currentId = newId as string | number;
558
- }
559
- },
560
- onError: (error: Error) => {
561
- if (!isManualSubmitIdentityCurrent()) return;
562
- handleHttpError(error);
563
- onMutationError?.(error);
564
- },
565
- }));
566
-
567
- const updateMut = createMutation<{ data: TData }, unknown, TVariables>(() => ({
568
- ...options.updateMutationOptions,
569
- mutationFn: async (variables: TVariables) => {
570
- const targetId = currentId as string;
571
- const targetResource = resource;
572
- const targetMeta = mutationMeta;
573
- if (mutationMode === 'undoable') {
574
- await new Promise<void>((resolve, reject) => {
575
- toast.undoable(i18n.t('common.actionCanBeUndone'), undoableTimeout, () => {
576
- reject(new UndoError());
577
- }, resolve);
578
- });
579
- }
580
- return provider.update<TData, TVariables>({ resource: targetResource, id: targetId, variables, meta: targetMeta });
581
- },
582
- onMutate: async (variables: TVariables) => {
583
- if (mutationMode === 'pessimistic') return { targetId: currentId, targetResource: resource };
584
- const targetId = currentId;
585
- const targetResource = resource;
586
- const matcher = adminContext.queryKeyMatcher(resource, options.dataProviderName);
587
- const dataMatch = (queryKey: readonly unknown[], fields: { action?: string; id?: string | number } = {}) => queryKeyMatches(queryKey, {
588
- ...matcher,
589
- kind: 'data',
590
- resource,
591
- ...fields,
592
- });
593
- await queryClient.cancelQueries({ predicate: (q) => dataMatch(q.queryKey) });
594
- const previousQueries = queryClient.getQueriesData({ predicate: (q) => dataMatch(q.queryKey) });
595
- if (optimisticUpdateMap?.list !== false) {
596
- queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'list' }) }, (old: unknown) => {
597
- if (!old || typeof old !== 'object' || !('data' in old)) return old;
598
- const o = old as { data: Record<string, unknown>[] };
599
- const pk = adminContext.getResource(resource).primaryKey ?? 'id';
600
- return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? deepMerge(item, variables) : item) };
601
- });
602
- }
603
- if (optimisticUpdateMap?.detail !== false && targetId != null) {
604
- queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'one', id: targetId }) }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
605
- }
606
- return { previousQueries, targetId, targetResource };
607
- },
608
- onSuccess: (data: unknown, _vars: unknown, context: unknown) => {
609
- const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
610
- const targetId = ctx?.targetId ?? currentId;
611
- const res = ctx?.targetResource ?? resource;
612
- if (!isManualSubmitIdentityCurrent()) return;
613
- if (successNotification !== false) sendNotification({ type: 'success', message: typeof successNotification === 'string' ? successNotification : i18n.t('common.updateSuccess') });
614
- recordAudit({
615
- action: 'update',
616
- resource: res,
617
- recordId: String(targetId),
618
- meta: adminContext.getProviderMeta(res),
619
- });
620
- try {
621
- const lp = adminContext.liveProvider;
622
- if (lp?.publish) lp.publish({ type: 'UPDATE', resource: res, payload: { ids: targetId != null ? [targetId] : [] } });
623
- } catch { /* no live provider */ }
624
- onMutationSuccess?.(data);
625
- if (!manualSubmitInProgress && redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
626
- },
627
- onSettled: (_data: unknown, error: unknown, _vars: unknown, context: unknown) => {
628
- if (error instanceof UndoError) return;
629
- const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
630
- const targetId = ctx?.targetId ?? currentId;
631
- const res = ctx?.targetResource ?? resource;
632
- invalidateByScopes({
633
- queryClient,
634
- resource: res,
635
- scopes: invalidateScopes,
636
- defaults: ['list', 'many', 'detail'],
637
- id: targetId ?? undefined,
638
- matcher: adminContext.queryKeyMatcher(res, options.dataProviderName),
639
- });
640
- },
641
- onError: (error: unknown, _vars: unknown, context: unknown) => {
642
- const ctx = context as { previousQueries?: [readonly unknown[], unknown][] } | undefined;
643
- if (ctx?.previousQueries) {
644
- for (const [queryKey, data] of ctx.previousQueries) {
645
- queryClient.setQueryData(queryKey, data);
646
- }
647
- }
648
- if (error instanceof UndoError) return;
649
- if (!isManualSubmitIdentityCurrent()) return;
650
- handleHttpError(error as Error);
651
- onMutationError?.(error as Error);
652
- },
653
- }));
654
-
655
- function doRedirect(to: 'list' | 'edit' | 'show' | false) {
656
- const currentPath = adminContext.currentPath();
657
- const path = currentPath.split('?')[0];
658
- const parts = path.split('/');
659
- const resIdx = parts.lastIndexOf(String(resource));
660
- const base = resIdx >= 0 ? parts.slice(0, resIdx + 1).join('/') : `/${resource}`;
661
-
662
- if (to === 'list') void adminContext.navigate(appendListQueryFromPath(base, currentPath));
663
- else if (to === 'edit' && currentId) void adminContext.navigate(appendListQueryFromPath(`${base}/edit/${currentId}`, currentPath));
664
- else if (to === 'show' && currentId) void adminContext.navigate(appendListQueryFromPath(`${base}/show/${currentId}`, currentPath));
665
- }
666
-
667
- // ─── Submit ─────────────────────────────────────────────────────
668
- async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
669
- if (formDestroyed) return;
670
- const submitIdentity = captureRecordIdentity();
671
- if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
672
- while (autoSaveInProgress) {
673
- await new Promise(r => setTimeout(r, 50));
674
- if (formDestroyed) return;
675
- }
676
- if (!isRecordIdentityCurrent(submitIdentity)) return;
677
- if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
678
- autoSaveDirty = false;
679
- manualSubmitInProgress = true;
680
- manualSubmitIdentity = submitIdentity;
681
- redirectOverride = overrides?.redirect;
682
- try {
683
- if (onSubmitFn) {
684
- let cancelled = false;
685
- const result = await onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
686
- if (result === false || cancelled) return;
687
- }
688
-
689
- if (!isRecordIdentityCurrent(submitIdentity)) return;
690
-
691
- if (!runValidation()) { sendNotification({ type: 'warning', message: i18n.t('validation.required') }); return; }
692
-
693
- const submittedValues = createPersistenceSnapshot(values);
694
- if (action === 'create' || action === 'clone') await createMut.mutateAsync(submittedValues);
695
- else await updateMut.mutateAsync(submittedValues);
696
-
697
- if (!isRecordIdentityCurrent(submitIdentity)) return;
698
- reconcilePersistedSnapshot(submittedValues);
699
- if (!isTainted() && redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
700
- } catch {
701
- // Errors already handled by mutation's onError callback
702
- } finally {
703
- manualSubmitInProgress = false;
704
- manualSubmitIdentity = null;
705
- redirectOverride = undefined;
706
- const shouldAutoSave = !formDestroyed && autoSaveDirty && isTainted();
707
- autoSaveDirty = false;
708
- if (shouldAutoSave) deferAutoSaveTrigger();
709
- }
710
- }
711
-
712
- // ─── AutoSave ───────────────────────────────────────────────────
713
- let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
714
- let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
715
- let autoSaveStatusTimer: ReturnType<typeof setTimeout> | null = null;
716
- let autoSaveInProgress = $state(false);
717
- let autoSaveDirty = false;
718
- let lastAutoSaveData = $state<unknown>(null);
719
- let lastAutoSaveError = $state<unknown>(null);
720
-
721
- $effect(() => {
722
- return () => {
723
- formDestroyed = true;
724
- autoSaveDirty = false;
725
- if (autoSaveTimer) {
726
- clearTimeout(autoSaveTimer);
727
- autoSaveTimer = null;
728
- }
729
- if (autoSaveStatusTimer) {
730
- clearTimeout(autoSaveStatusTimer);
731
- autoSaveStatusTimer = null;
732
- }
733
- };
734
- });
735
-
736
- function deferAutoSaveTrigger() {
737
- if (formDestroyed) return;
738
- if (autoSaveTimer) clearTimeout(autoSaveTimer);
739
- autoSaveTimer = setTimeout(() => {
740
- autoSaveTimer = null;
741
- if (formDestroyed) return;
742
- triggerAutoSave();
743
- }, 0);
744
- }
745
-
746
- function triggerAutoSave() {
747
- if (formDestroyed) return;
748
- const currentAction = action;
749
- if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone' || currentId == null) return;
750
- if (autoSaveInProgress || manualSubmitInProgress || createMut.isPending || updateMut.isPending) {
751
- autoSaveDirty = true;
752
- return;
753
- }
754
- if (autoSaveTimer) clearTimeout(autoSaveTimer);
755
-
756
- const scheduledIdentity = captureRecordIdentity();
757
- const currentSnapshot = createPersistenceSnapshot(values);
758
- const safeId = scheduledIdentity.id;
759
- const safeResource = scheduledIdentity.resource;
760
- const safeMeta = mutationMeta;
761
- autoSaveTimer = setTimeout(async () => {
762
- autoSaveTimer = null;
763
- if (formDestroyed) return;
764
- if (!isRecordIdentityCurrent(scheduledIdentity)) return;
765
- if (autoSaveInProgress || manualSubmitInProgress || createMut.isPending || updateMut.isPending) {
766
- autoSaveDirty = true;
767
- return;
768
- }
769
- const finishedValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(currentSnapshot) : currentSnapshot;
770
- const submittedValues = createPersistenceSnapshot(finishedValues);
771
- if (autoSaveStatusTimer) {
772
- clearTimeout(autoSaveStatusTimer);
773
- autoSaveStatusTimer = null;
774
- }
775
- autoSaveStatus = 'saving';
776
- autoSaveInProgress = true;
777
- autoSaveDirty = false;
778
- try {
779
- await provider.update<TData, TVariables>({ resource: safeResource, id: safeId as string | number, variables: submittedValues, meta: safeMeta });
780
- if (formDestroyed) return;
781
- const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
782
- invalidateByScopes({
783
- queryClient,
784
- resource: safeResource,
785
- scopes,
786
- defaults: ['resourceAll'],
787
- id: safeId ?? undefined,
788
- matcher: adminContext.queryKeyMatcher(safeResource, options.dataProviderName),
789
- });
790
- if (!isRecordIdentityCurrent(scheduledIdentity)) return;
791
- autoSaveStatus = 'saved';
792
- lastAutoSaveData = submittedValues;
793
- lastAutoSaveError = null;
794
- reconcilePersistedSnapshot(currentSnapshot, submittedValues);
795
- autoSaveStatusTimer = setTimeout(() => {
796
- autoSaveStatusTimer = null;
797
- if (formDestroyed) return;
798
- autoSaveStatus = 'idle';
799
- }, 2000);
800
- } catch (e) {
801
- if (!isRecordIdentityCurrent(scheduledIdentity)) return;
802
- autoSaveStatus = 'error';
803
- lastAutoSaveError = e;
804
- checkError(e, adminContext);
805
- } finally {
806
- if (!formDestroyed) {
807
- autoSaveInProgress = false;
808
- const shouldAutoSave = autoSaveDirty && isTainted();
809
- autoSaveDirty = false;
810
- if (shouldAutoSave) triggerAutoSave();
811
- }
812
- }
813
- }, autoSaveOpts.debounce ?? 1000);
814
- }
815
-
816
- if (autoSaveOpts?.invalidateOnUnmount) {
817
- $effect(() => {
818
- return () => queryClient.invalidateQueries({
819
- predicate: (q) => queryKeyMatches(q.queryKey, {
820
- ...adminContext.queryKeyMatcher(resource, options.dataProviderName),
821
- kind: 'data',
822
- resource,
823
- }),
824
- });
825
- });
826
- }
827
-
828
- // ─── warnWhenUnsavedChanges ─────────────────────────────────────
829
- const shouldWarn = options.warnWhenUnsavedChanges ?? adminOptions.warnWhenUnsavedChanges;
830
- if (shouldWarn && typeof window !== 'undefined') {
831
- $effect(() => {
832
- if (!isTainted()) return;
833
- const handler = (e: BeforeUnloadEvent) => {
834
- e.preventDefault();
835
- e.returnValue = '';
836
- };
837
- window.addEventListener('beforeunload', handler);
838
- return () => window.removeEventListener('beforeunload', handler);
839
- });
840
- }
841
-
842
- // ─── Return ─────────────────────────────────────────────────────
843
- return {
844
- // Values
845
- get values() { return values; },
846
- setFieldValue,
847
- setValues,
848
-
849
- // Tainted
850
- get tainted() { return tainted; },
851
- isTainted,
852
- untaint: () => { tainted = {}; },
853
-
854
- // Errors
855
- get errors() { return errors; },
856
- setFieldError, clearFieldError, clearErrors,
857
- validateField: doValidateField,
858
-
859
- // Submission
860
- submit,
861
- reset,
862
-
863
- // State
864
- get loading() { return query.isLoading ?? false; },
865
- get submitting() { return createMut.isPending || updateMut.isPending; },
866
- get action() { return action; },
867
- get resource() { return resource; },
868
- get id() { return currentId; },
869
- get isDirty() { return Object.keys(tainted).length > 0; },
870
- setId,
871
- setAction,
872
- mutationMode,
873
- redirect: doRedirect,
874
-
875
- // AutoSave
876
- triggerAutoSave,
877
- get autoSave() {
878
- return {
879
- status: autoSaveStatus as 'idle' | 'saving' | 'saved' | 'error',
880
- data: lastAutoSaveData,
881
- error: lastAutoSaveError,
882
- };
883
- },
884
-
885
- // Raw escape hatches
886
- query,
887
- get mutation() { return action === 'edit' ? updateMut : createMut; },
888
- };
889
- }