@svadmin/core 0.50.0 → 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.
- package/package.json +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -776
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +56 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -30
- package/src/inferencer-graphql.ts +7 -6
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -288
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +216 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +88 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +193 -149
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +55 -108
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
- package/src/schema-form.ts +0 -31
package/src/form-hooks.svelte.ts
DELETED
|
@@ -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 ({ signal }) => {
|
|
466
|
-
const result = await provider.getOne<BaseRecord>({ resource, id: currentId as string, meta: queryMeta, signal });
|
|
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
|
-
}
|