@svadmin/core 0.31.0 → 0.32.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 +3 -3
- package/src/auth-hooks.svelte.ts +42 -25
- package/src/context.svelte.ts +185 -46
- package/src/data-transfer.svelte.ts +5 -3
- package/src/form-hooks.svelte.ts +322 -58
- package/src/helpers.ts +11 -7
- package/src/hook-utils.svelte.ts +13 -12
- package/src/hooks.svelte.ts +29 -23
- package/src/http.ts +114 -33
- package/src/i18n.svelte.ts +181 -35
- package/src/index.ts +7 -6
- package/src/mutation-hooks.svelte.ts +27 -16
- package/src/query-hooks.svelte.ts +16 -13
- package/src/router.ts +28 -8
- package/src/routing-hooks.svelte.ts +13 -18
- package/src/table-hooks.svelte.ts +5 -3
- package/src/table-state.svelte.ts +6 -3
- package/src/task-hooks.svelte.ts +27 -14
- package/src/theme.svelte.ts +248 -154
- package/src/toast.svelte.ts +1 -2
- package/src/types.ts +26 -4
- package/src/unsaved-changes.svelte.ts +4 -38
- package/src/url-sync.ts +47 -8
- package/src/useParsed.svelte.ts +16 -11
- package/src/utility-hooks.svelte.ts +8 -7
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
2
1
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
3
2
|
import { useParsed } from './useParsed.svelte';
|
|
4
3
|
import { getAdminOptions } from './options.svelte';
|
|
5
|
-
import {
|
|
4
|
+
import { captureAdminContext } from './context.svelte';
|
|
6
5
|
import { createQuery, createMutation } from '@tanstack/svelte-query';
|
|
7
6
|
import { notify } from './notification.svelte';
|
|
8
|
-
import {
|
|
7
|
+
import { useTranslation } from './i18n.svelte';
|
|
9
8
|
import { audit } from './audit';
|
|
10
|
-
import { navigate, currentPath } from './router';
|
|
11
9
|
import { HttpError, UndoError } from './types';
|
|
12
10
|
import type { BaseRecord, MutationMode, KnownResources } from './types';
|
|
13
11
|
import { checkError } from './hook-utils.svelte';
|
|
@@ -136,6 +134,13 @@ export interface UseFormReturn<
|
|
|
136
134
|
readonly mutation: unknown;
|
|
137
135
|
}
|
|
138
136
|
|
|
137
|
+
interface FormRecordIdentity {
|
|
138
|
+
generation: number;
|
|
139
|
+
resource: string;
|
|
140
|
+
action: 'create' | 'edit' | 'clone' | 'show';
|
|
141
|
+
id: string | number | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
// ─── Implementation ──────────────────────────────────────────────────
|
|
140
145
|
|
|
141
146
|
export function useForm<
|
|
@@ -143,6 +148,8 @@ export function useForm<
|
|
|
143
148
|
TData extends BaseRecord = BaseRecord,
|
|
144
149
|
TError = HttpError,
|
|
145
150
|
>(options: UseFormOptions<TVariables, TData, TError> = {} as UseFormOptions<TVariables, TData, TError>): UseFormReturn<TVariables, TData> {
|
|
151
|
+
const adminContext = captureAdminContext();
|
|
152
|
+
const i18n = useTranslation();
|
|
146
153
|
const queryClient = useQueryClient();
|
|
147
154
|
const parsed = useParsed();
|
|
148
155
|
const adminOptions = getAdminOptions();
|
|
@@ -170,16 +177,58 @@ export function useForm<
|
|
|
170
177
|
// updates when `action` changes at runtime (e.g., via `setAction`).
|
|
171
178
|
const redirectDefault = $derived(redirectOption ?? defaultRedirectOpt ?? 'list');
|
|
172
179
|
|
|
173
|
-
const provider = $derived(getDataProviderForResource(resource, dataProviderName));
|
|
180
|
+
const provider = $derived(adminContext.getDataProviderForResource(resource, dataProviderName));
|
|
174
181
|
const queryMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookQueryMeta });
|
|
175
182
|
const mutationMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookMutationMeta });
|
|
176
183
|
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
let recordGeneration = 0;
|
|
185
|
+
let formDestroyed = false;
|
|
186
|
+
|
|
187
|
+
function captureRecordIdentity(): FormRecordIdentity {
|
|
188
|
+
return { generation: recordGeneration, resource, action, id: currentId };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isRecordIdentityCurrent(identity: FormRecordIdentity): boolean {
|
|
192
|
+
return !formDestroyed
|
|
193
|
+
&& identity.generation === recordGeneration
|
|
194
|
+
&& identity.resource === resource
|
|
195
|
+
&& identity.action === action;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function invalidateRecordIdentity() {
|
|
199
|
+
recordGeneration += 1;
|
|
200
|
+
if (autoSaveTimer) {
|
|
201
|
+
clearTimeout(autoSaveTimer);
|
|
202
|
+
autoSaveTimer = null;
|
|
203
|
+
}
|
|
204
|
+
if (autoSaveStatusTimer) {
|
|
205
|
+
clearTimeout(autoSaveStatusTimer);
|
|
206
|
+
autoSaveStatusTimer = null;
|
|
207
|
+
}
|
|
208
|
+
autoSaveDirty = false;
|
|
209
|
+
autoSaveStatus = 'idle';
|
|
210
|
+
lastAutoSaveData = null;
|
|
211
|
+
lastAutoSaveError = null;
|
|
212
|
+
tainted = {};
|
|
213
|
+
clearErrors();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function setId(newId: string | number | undefined) {
|
|
217
|
+
if (Object.is(currentId, newId)) return;
|
|
218
|
+
invalidateRecordIdentity();
|
|
219
|
+
currentId = newId;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function setAction(newAction: 'create' | 'edit' | 'clone' | 'show') {
|
|
223
|
+
if (action === newAction) return;
|
|
224
|
+
invalidateRecordIdentity();
|
|
225
|
+
action = newAction;
|
|
226
|
+
}
|
|
179
227
|
|
|
180
228
|
// ─── Form values (single source of truth) ───────────────────────
|
|
181
|
-
|
|
182
|
-
let
|
|
229
|
+
const defaultValuesSource = (options.defaultValues ?? {}) as TVariables;
|
|
230
|
+
let values = $state<TVariables>(createPersistenceSnapshot(defaultValuesSource));
|
|
231
|
+
let initialValues = createPersistenceSnapshot(defaultValuesSource);
|
|
183
232
|
|
|
184
233
|
function setFieldValue(field: string, value: unknown, opts?: { taint?: boolean }) {
|
|
185
234
|
values = { ...values, [field]: value } as TVariables;
|
|
@@ -188,6 +237,7 @@ export function useForm<
|
|
|
188
237
|
// Clear field error on change
|
|
189
238
|
if (errors[field]) clearFieldError(field);
|
|
190
239
|
onChangeFn?.({ field, value, values });
|
|
240
|
+
triggerAutoSave();
|
|
191
241
|
}
|
|
192
242
|
|
|
193
243
|
function setValues(newValues: Partial<TVariables>, opts?: { taint?: boolean }) {
|
|
@@ -198,6 +248,7 @@ export function useForm<
|
|
|
198
248
|
for (const key of Object.keys(newValues)) newTainted[key] = true;
|
|
199
249
|
tainted = newTainted;
|
|
200
250
|
}
|
|
251
|
+
triggerAutoSave();
|
|
201
252
|
}
|
|
202
253
|
|
|
203
254
|
// ─── Tainted (dirty) state ──────────────────────────────────────
|
|
@@ -208,6 +259,138 @@ export function useForm<
|
|
|
208
259
|
return Object.values(tainted).some(Boolean);
|
|
209
260
|
}
|
|
210
261
|
|
|
262
|
+
function isPlainObject(value: object): boolean {
|
|
263
|
+
const prototype = Object.getPrototypeOf(value);
|
|
264
|
+
return prototype === Object.prototype || prototype === null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isFormData(value: object): value is FormData {
|
|
268
|
+
return typeof FormData !== 'undefined' && value instanceof FormData;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function readOwnValue(value: object, key: PropertyKey): unknown {
|
|
272
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
|
|
273
|
+
if (descriptor === undefined) return undefined;
|
|
274
|
+
return 'value' in descriptor ? descriptor.value : Reflect.get(value, key);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function snapshotPersistenceValue(value: unknown, seen: WeakMap<object, unknown>): unknown {
|
|
278
|
+
if (value === null || typeof value !== 'object') return value;
|
|
279
|
+
|
|
280
|
+
const existing = seen.get(value);
|
|
281
|
+
if (existing !== undefined) return existing;
|
|
282
|
+
|
|
283
|
+
if (value instanceof Date) {
|
|
284
|
+
const snapshot = new Date(value.getTime());
|
|
285
|
+
seen.set(value, snapshot);
|
|
286
|
+
return snapshot;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (isFormData(value)) {
|
|
290
|
+
const snapshot = new FormData();
|
|
291
|
+
seen.set(value, snapshot);
|
|
292
|
+
for (const [key, entry] of value.entries()) snapshot.append(key, entry);
|
|
293
|
+
return snapshot;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (Array.isArray(value)) {
|
|
297
|
+
const snapshot = new Array<unknown>(value.length);
|
|
298
|
+
seen.set(value, snapshot);
|
|
299
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
300
|
+
if (index in value) snapshot[index] = snapshotPersistenceValue(readOwnValue(value, String(index)), seen);
|
|
301
|
+
}
|
|
302
|
+
return snapshot;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!isPlainObject(value)) return value;
|
|
306
|
+
|
|
307
|
+
const snapshot = Object.create(Object.getPrototypeOf(value)) as Record<PropertyKey, unknown>;
|
|
308
|
+
seen.set(value, snapshot);
|
|
309
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
310
|
+
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue;
|
|
311
|
+
snapshot[key] = snapshotPersistenceValue(readOwnValue(value, key), seen);
|
|
312
|
+
}
|
|
313
|
+
return snapshot;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function createPersistenceSnapshot(source: TVariables): TVariables {
|
|
317
|
+
return snapshotPersistenceValue(source, new WeakMap<object, unknown>()) as TVariables;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function enumerableOwnKeys(value: object): PropertyKey[] {
|
|
321
|
+
return Reflect.ownKeys(value).filter((key) => Object.prototype.propertyIsEnumerable.call(value, key));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function persistenceValuesEqual(
|
|
325
|
+
left: unknown,
|
|
326
|
+
right: unknown,
|
|
327
|
+
leftToRight: WeakMap<object, object> = new WeakMap<object, object>(),
|
|
328
|
+
rightToLeft: WeakMap<object, object> = new WeakMap<object, object>(),
|
|
329
|
+
): boolean {
|
|
330
|
+
if (Object.is(left, right)) return true;
|
|
331
|
+
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false;
|
|
332
|
+
|
|
333
|
+
const leftIsDate = left instanceof Date;
|
|
334
|
+
if (leftIsDate || right instanceof Date) {
|
|
335
|
+
return leftIsDate && right instanceof Date && Object.is(left.getTime(), right.getTime());
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const leftIsFormData = isFormData(left);
|
|
339
|
+
if (leftIsFormData || isFormData(right)) {
|
|
340
|
+
if (!leftIsFormData || !isFormData(right)) return false;
|
|
341
|
+
const leftEntries = Array.from(left.entries());
|
|
342
|
+
const rightEntries = Array.from(right.entries());
|
|
343
|
+
if (leftEntries.length !== rightEntries.length) return false;
|
|
344
|
+
return leftEntries.every(([leftKey, leftValue], index) => {
|
|
345
|
+
const rightEntry = rightEntries[index];
|
|
346
|
+
return rightEntry !== undefined
|
|
347
|
+
&& leftKey === rightEntry[0]
|
|
348
|
+
&& persistenceValuesEqual(leftValue, rightEntry[1], leftToRight, rightToLeft);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const leftIsArray = Array.isArray(left);
|
|
353
|
+
if (leftIsArray !== Array.isArray(right)) return false;
|
|
354
|
+
if (!leftIsArray && (!isPlainObject(left) || !isPlainObject(right))) return false;
|
|
355
|
+
if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false;
|
|
356
|
+
|
|
357
|
+
const previousRight = leftToRight.get(left);
|
|
358
|
+
if (previousRight !== undefined) return previousRight === right;
|
|
359
|
+
const previousLeft = rightToLeft.get(right);
|
|
360
|
+
if (previousLeft !== undefined) return previousLeft === left;
|
|
361
|
+
leftToRight.set(left, right);
|
|
362
|
+
rightToLeft.set(right, left);
|
|
363
|
+
|
|
364
|
+
if (leftIsArray && left.length !== (right as unknown[]).length) return false;
|
|
365
|
+
|
|
366
|
+
const leftKeys = enumerableOwnKeys(left);
|
|
367
|
+
const rightKeys = enumerableOwnKeys(right);
|
|
368
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
369
|
+
|
|
370
|
+
for (const key of leftKeys) {
|
|
371
|
+
if (!Object.prototype.hasOwnProperty.call(right, key)) return false;
|
|
372
|
+
if (!persistenceValuesEqual(readOwnValue(left, key), readOwnValue(right, key), leftToRight, rightToLeft)) return false;
|
|
373
|
+
}
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function reconcilePersistedSnapshot(sourceSnapshot: TVariables, persistedSnapshot: TVariables = sourceSnapshot) {
|
|
378
|
+
const nextInitialValues = createPersistenceSnapshot(persistedSnapshot);
|
|
379
|
+
let nextTainted = { ...tainted };
|
|
380
|
+
|
|
381
|
+
for (const field of Object.keys(sourceSnapshot)) {
|
|
382
|
+
if (persistenceValuesEqual(readOwnValue(values, field), readOwnValue(sourceSnapshot, field))) {
|
|
383
|
+
const { [field]: _persistedField, ...remainingTainted } = nextTainted;
|
|
384
|
+
nextTainted = remainingTainted;
|
|
385
|
+
} else {
|
|
386
|
+
nextTainted[field] = true;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
initialValues = nextInitialValues;
|
|
391
|
+
tainted = nextTainted;
|
|
392
|
+
}
|
|
393
|
+
|
|
211
394
|
// ─── Errors ─────────────────────────────────────────────────────
|
|
212
395
|
let errors = $state<Record<string, string>>({});
|
|
213
396
|
|
|
@@ -243,7 +426,7 @@ export function useForm<
|
|
|
243
426
|
|
|
244
427
|
// ─── Reset ──────────────────────────────────────────────────────
|
|
245
428
|
function reset() {
|
|
246
|
-
values =
|
|
429
|
+
values = createPersistenceSnapshot(initialValues);
|
|
247
430
|
tainted = {};
|
|
248
431
|
clearErrors();
|
|
249
432
|
queryInitializedId = currentId;
|
|
@@ -252,15 +435,15 @@ export function useForm<
|
|
|
252
435
|
// ─── Server error handling ──────────────────────────────────────
|
|
253
436
|
|
|
254
437
|
function handleHttpError(error: Error) {
|
|
255
|
-
checkError(error);
|
|
438
|
+
checkError(error, adminContext);
|
|
256
439
|
if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
|
|
257
440
|
for (const [field, messages] of Object.entries(error.errors)) {
|
|
258
441
|
const msg = Array.isArray(messages) ? messages[0] : messages;
|
|
259
442
|
setFieldError(field, msg);
|
|
260
443
|
}
|
|
261
|
-
if (errorNotification !== false) notify({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (error.message || t('common.operationFailed')) });
|
|
444
|
+
if (errorNotification !== false) notify({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (error.message || i18n.t('common.operationFailed')) });
|
|
262
445
|
} else {
|
|
263
|
-
if (errorNotification !== false) notify({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (t('common.operationFailed') + ': ' + error.message) });
|
|
446
|
+
if (errorNotification !== false) notify({ type: 'error', message: typeof errorNotification === 'string' ? errorNotification : (i18n.t('common.operationFailed') + ': ' + error.message) });
|
|
264
447
|
}
|
|
265
448
|
}
|
|
266
449
|
|
|
@@ -285,15 +468,15 @@ export function useForm<
|
|
|
285
468
|
if (action === 'create' && queryInitializedId !== undefined) {
|
|
286
469
|
queryInitializedId = undefined;
|
|
287
470
|
const def = (options.defaultValues ?? {}) as TVariables;
|
|
288
|
-
values =
|
|
289
|
-
initialValues =
|
|
471
|
+
values = createPersistenceSnapshot(def);
|
|
472
|
+
initialValues = createPersistenceSnapshot(def);
|
|
290
473
|
tainted = {};
|
|
291
474
|
clearErrors();
|
|
292
475
|
return;
|
|
293
476
|
}
|
|
294
477
|
if (queryInitializedId === currentId) return;
|
|
295
478
|
const record = (query.data as { data: Record<string, unknown> } | undefined)?.data;
|
|
296
|
-
const pk = getResource(resource).primaryKey ?? 'id';
|
|
479
|
+
const pk = adminContext.getResource(resource).primaryKey ?? 'id';
|
|
297
480
|
if (record && String(record[pk]) === String(currentId)) {
|
|
298
481
|
const cloneStripKeys = new Set(['id', '_id', pk, 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updated_by', 'created_by', 'updatedBy']);
|
|
299
482
|
const targetData = action === 'clone'
|
|
@@ -301,8 +484,10 @@ export function useForm<
|
|
|
301
484
|
: record;
|
|
302
485
|
// Merge: defaultValues < query data
|
|
303
486
|
const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
|
|
304
|
-
values = merged;
|
|
305
|
-
initialValues =
|
|
487
|
+
values = createPersistenceSnapshot(merged);
|
|
488
|
+
initialValues = createPersistenceSnapshot(merged);
|
|
489
|
+
tainted = {};
|
|
490
|
+
clearErrors();
|
|
306
491
|
queryInitializedId = currentId;
|
|
307
492
|
}
|
|
308
493
|
});
|
|
@@ -310,6 +495,15 @@ export function useForm<
|
|
|
310
495
|
|
|
311
496
|
// ─── Mutations ──────────────────────────────────────────────────
|
|
312
497
|
let redirectOverride: 'list' | 'edit' | 'show' | false | undefined;
|
|
498
|
+
let manualSubmitInProgress = false;
|
|
499
|
+
let manualSubmitIdentity: FormRecordIdentity | null = null;
|
|
500
|
+
|
|
501
|
+
function isManualSubmitIdentityCurrent(): boolean {
|
|
502
|
+
if (formDestroyed) return false;
|
|
503
|
+
return !manualSubmitInProgress
|
|
504
|
+
|| manualSubmitIdentity == null
|
|
505
|
+
|| isRecordIdentityCurrent(manualSubmitIdentity);
|
|
506
|
+
}
|
|
313
507
|
|
|
314
508
|
const createMut = createMutation(() => ({
|
|
315
509
|
...options.createMutationOptions,
|
|
@@ -325,21 +519,28 @@ export function useForm<
|
|
|
325
519
|
const ctx = context as { targetResource?: string } | undefined;
|
|
326
520
|
const res = ctx?.targetResource ?? resource;
|
|
327
521
|
invalidateByScopes(queryClient, res, invalidateScopes, ['list', 'many'], undefined, options.dataProviderName);
|
|
328
|
-
if (
|
|
329
|
-
|
|
522
|
+
if (!isManualSubmitIdentityCurrent()) return;
|
|
523
|
+
if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : i18n.t('common.createSuccess') });
|
|
524
|
+
const pk = adminContext.getResource(res).primaryKey ?? 'id';
|
|
330
525
|
const newId = (data.data as Record<string, unknown>)[pk];
|
|
331
526
|
audit({ action: 'create', resource: res, recordId: String(newId) });
|
|
332
527
|
try {
|
|
333
|
-
const lp =
|
|
528
|
+
const lp = adminContext.liveProvider;
|
|
334
529
|
if (lp?.publish) lp.publish({ type: 'INSERT', resource: res, payload: { ids: newId != null ? [newId as string | number] : [] } });
|
|
335
530
|
} catch { /* no live provider */ }
|
|
336
531
|
onMutationSuccess?.(data);
|
|
337
|
-
if (redirectOverride !== false) {
|
|
532
|
+
if (!manualSubmitInProgress && redirectOverride !== false) {
|
|
338
533
|
if (newId != null) currentId = newId as string | number;
|
|
339
534
|
doRedirect(redirectOverride ?? redirectDefault);
|
|
535
|
+
} else if (manualSubmitInProgress && redirectOverride !== false && newId != null) {
|
|
536
|
+
currentId = newId as string | number;
|
|
340
537
|
}
|
|
341
538
|
},
|
|
342
|
-
onError: (error: Error) => {
|
|
539
|
+
onError: (error: Error) => {
|
|
540
|
+
if (!isManualSubmitIdentityCurrent()) return;
|
|
541
|
+
handleHttpError(error);
|
|
542
|
+
onMutationError?.(error);
|
|
543
|
+
},
|
|
343
544
|
}));
|
|
344
545
|
|
|
345
546
|
const updateMut = createMutation<{ data: TData }, unknown, TVariables>(() => ({
|
|
@@ -350,7 +551,7 @@ export function useForm<
|
|
|
350
551
|
const targetMeta = mutationMeta;
|
|
351
552
|
if (mutationMode === 'undoable') {
|
|
352
553
|
await new Promise<void>((resolve, reject) => {
|
|
353
|
-
toast.undoable(t('common.actionCanBeUndone'), undoableTimeout, () => {
|
|
554
|
+
toast.undoable(i18n.t('common.actionCanBeUndone'), undoableTimeout, () => {
|
|
354
555
|
reject(new UndoError());
|
|
355
556
|
}, resolve);
|
|
356
557
|
});
|
|
@@ -369,7 +570,7 @@ export function useForm<
|
|
|
369
570
|
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
370
571
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
371
572
|
const o = old as { data: Record<string, unknown>[] };
|
|
372
|
-
const pk = getResource(resource).primaryKey ?? 'id';
|
|
573
|
+
const pk = adminContext.getResource(resource).primaryKey ?? 'id';
|
|
373
574
|
return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? deepMerge(item, variables) : item) };
|
|
374
575
|
});
|
|
375
576
|
}
|
|
@@ -382,14 +583,15 @@ export function useForm<
|
|
|
382
583
|
const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
|
|
383
584
|
const targetId = ctx?.targetId ?? currentId;
|
|
384
585
|
const res = ctx?.targetResource ?? resource;
|
|
385
|
-
if (
|
|
586
|
+
if (!isManualSubmitIdentityCurrent()) return;
|
|
587
|
+
if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : i18n.t('common.updateSuccess') });
|
|
386
588
|
audit({ action: 'update', resource: res, recordId: String(targetId) });
|
|
387
589
|
try {
|
|
388
|
-
const lp =
|
|
590
|
+
const lp = adminContext.liveProvider;
|
|
389
591
|
if (lp?.publish) lp.publish({ type: 'UPDATE', resource: res, payload: { ids: targetId != null ? [targetId] : [] } });
|
|
390
592
|
} catch { /* no live provider */ }
|
|
391
593
|
onMutationSuccess?.(data);
|
|
392
|
-
if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
|
|
594
|
+
if (!manualSubmitInProgress && redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
|
|
393
595
|
},
|
|
394
596
|
onSettled: (_data: unknown, error: unknown, _vars: unknown, context: unknown) => {
|
|
395
597
|
if (error instanceof UndoError) return;
|
|
@@ -406,44 +608,65 @@ export function useForm<
|
|
|
406
608
|
}
|
|
407
609
|
}
|
|
408
610
|
if (error instanceof UndoError) return;
|
|
611
|
+
if (!isManualSubmitIdentityCurrent()) return;
|
|
409
612
|
handleHttpError(error as Error);
|
|
410
613
|
onMutationError?.(error as Error);
|
|
411
614
|
},
|
|
412
615
|
}));
|
|
413
616
|
|
|
414
617
|
function doRedirect(to: 'list' | 'edit' | 'show' | false) {
|
|
415
|
-
const path = currentPath().split('?')[0];
|
|
618
|
+
const path = adminContext.currentPath().split('?')[0];
|
|
416
619
|
const parts = path.split('/');
|
|
417
620
|
const resIdx = parts.lastIndexOf(String(resource));
|
|
418
621
|
const base = resIdx >= 0 ? parts.slice(0, resIdx + 1).join('/') : `/${resource}`;
|
|
419
622
|
|
|
420
|
-
if (to === 'list') navigate(base);
|
|
421
|
-
else if (to === 'edit' && currentId) navigate(`${base}/edit/${currentId}`);
|
|
422
|
-
else if (to === 'show' && currentId) navigate(`${base}/show/${currentId}`);
|
|
623
|
+
if (to === 'list') void adminContext.navigate(base);
|
|
624
|
+
else if (to === 'edit' && currentId) void adminContext.navigate(`${base}/edit/${currentId}`);
|
|
625
|
+
else if (to === 'show' && currentId) void adminContext.navigate(`${base}/show/${currentId}`);
|
|
423
626
|
}
|
|
424
627
|
|
|
425
628
|
// ─── Submit ─────────────────────────────────────────────────────
|
|
426
629
|
async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
|
|
630
|
+
if (formDestroyed) return;
|
|
631
|
+
const submitIdentity = captureRecordIdentity();
|
|
427
632
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
428
633
|
while (autoSaveInProgress) {
|
|
429
634
|
await new Promise(r => setTimeout(r, 50));
|
|
635
|
+
if (formDestroyed) return;
|
|
430
636
|
}
|
|
637
|
+
if (!isRecordIdentityCurrent(submitIdentity)) return;
|
|
431
638
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
432
639
|
autoSaveDirty = false;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const result = await onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
|
|
436
|
-
if (result === false || cancelled) return;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (!runValidation()) { notify({ type: 'warning', message: t('validation.required') }); return; }
|
|
640
|
+
manualSubmitInProgress = true;
|
|
641
|
+
manualSubmitIdentity = submitIdentity;
|
|
440
642
|
redirectOverride = overrides?.redirect;
|
|
441
643
|
try {
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
|
|
644
|
+
if (onSubmitFn) {
|
|
645
|
+
let cancelled = false;
|
|
646
|
+
const result = await onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
|
|
647
|
+
if (result === false || cancelled) return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (!isRecordIdentityCurrent(submitIdentity)) return;
|
|
651
|
+
|
|
652
|
+
if (!runValidation()) { notify({ type: 'warning', message: i18n.t('validation.required') }); return; }
|
|
653
|
+
|
|
654
|
+
const submittedValues = createPersistenceSnapshot(values);
|
|
655
|
+
if (action === 'create' || action === 'clone') await createMut.mutateAsync(submittedValues);
|
|
656
|
+
else await updateMut.mutateAsync(submittedValues);
|
|
657
|
+
|
|
658
|
+
if (!isRecordIdentityCurrent(submitIdentity)) return;
|
|
659
|
+
reconcilePersistedSnapshot(submittedValues);
|
|
660
|
+
if (!isTainted() && redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
|
|
445
661
|
} catch {
|
|
446
662
|
// Errors already handled by mutation's onError callback
|
|
663
|
+
} finally {
|
|
664
|
+
manualSubmitInProgress = false;
|
|
665
|
+
manualSubmitIdentity = null;
|
|
666
|
+
redirectOverride = undefined;
|
|
667
|
+
const shouldAutoSave = !formDestroyed && autoSaveDirty && isTainted();
|
|
668
|
+
autoSaveDirty = false;
|
|
669
|
+
if (shouldAutoSave) deferAutoSaveTrigger();
|
|
447
670
|
}
|
|
448
671
|
}
|
|
449
672
|
|
|
@@ -458,47 +681,88 @@ export function useForm<
|
|
|
458
681
|
|
|
459
682
|
$effect(() => {
|
|
460
683
|
return () => {
|
|
461
|
-
|
|
462
|
-
|
|
684
|
+
formDestroyed = true;
|
|
685
|
+
autoSaveDirty = false;
|
|
686
|
+
if (autoSaveTimer) {
|
|
687
|
+
clearTimeout(autoSaveTimer);
|
|
688
|
+
autoSaveTimer = null;
|
|
689
|
+
}
|
|
690
|
+
if (autoSaveStatusTimer) {
|
|
691
|
+
clearTimeout(autoSaveStatusTimer);
|
|
692
|
+
autoSaveStatusTimer = null;
|
|
693
|
+
}
|
|
463
694
|
};
|
|
464
695
|
});
|
|
465
696
|
|
|
697
|
+
function deferAutoSaveTrigger() {
|
|
698
|
+
if (formDestroyed) return;
|
|
699
|
+
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
700
|
+
autoSaveTimer = setTimeout(() => {
|
|
701
|
+
autoSaveTimer = null;
|
|
702
|
+
if (formDestroyed) return;
|
|
703
|
+
triggerAutoSave();
|
|
704
|
+
}, 0);
|
|
705
|
+
}
|
|
706
|
+
|
|
466
707
|
function triggerAutoSave() {
|
|
708
|
+
if (formDestroyed) return;
|
|
467
709
|
const currentAction = action;
|
|
468
710
|
if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone' || currentId == null) return;
|
|
469
|
-
if (createMut.isPending || updateMut.isPending)
|
|
711
|
+
if (autoSaveInProgress || manualSubmitInProgress || createMut.isPending || updateMut.isPending) {
|
|
712
|
+
autoSaveDirty = true;
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
470
715
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
471
716
|
|
|
472
|
-
const
|
|
473
|
-
const
|
|
717
|
+
const scheduledIdentity = captureRecordIdentity();
|
|
718
|
+
const currentSnapshot = createPersistenceSnapshot(values);
|
|
719
|
+
const safeId = scheduledIdentity.id;
|
|
720
|
+
const safeResource = scheduledIdentity.resource;
|
|
474
721
|
const safeMeta = mutationMeta;
|
|
475
722
|
autoSaveTimer = setTimeout(async () => {
|
|
476
|
-
|
|
723
|
+
autoSaveTimer = null;
|
|
724
|
+
if (formDestroyed) return;
|
|
725
|
+
if (!isRecordIdentityCurrent(scheduledIdentity)) return;
|
|
726
|
+
if (autoSaveInProgress || manualSubmitInProgress || createMut.isPending || updateMut.isPending) {
|
|
477
727
|
autoSaveDirty = true;
|
|
478
728
|
return;
|
|
479
729
|
}
|
|
480
|
-
const
|
|
730
|
+
const finishedValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(currentSnapshot) : currentSnapshot;
|
|
731
|
+
const submittedValues = createPersistenceSnapshot(finishedValues);
|
|
732
|
+
if (autoSaveStatusTimer) {
|
|
733
|
+
clearTimeout(autoSaveStatusTimer);
|
|
734
|
+
autoSaveStatusTimer = null;
|
|
735
|
+
}
|
|
481
736
|
autoSaveStatus = 'saving';
|
|
482
737
|
autoSaveInProgress = true;
|
|
483
738
|
autoSaveDirty = false;
|
|
484
739
|
try {
|
|
485
|
-
await provider.update<TData, TVariables>({ resource: safeResource, id: safeId as string | number, variables:
|
|
740
|
+
await provider.update<TData, TVariables>({ resource: safeResource, id: safeId as string | number, variables: submittedValues, meta: safeMeta });
|
|
741
|
+
if (formDestroyed) return;
|
|
486
742
|
const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
|
|
487
743
|
invalidateByScopes(queryClient, safeResource, scopes, ['resourceAll'], safeId ?? undefined, options.dataProviderName);
|
|
744
|
+
if (!isRecordIdentityCurrent(scheduledIdentity)) return;
|
|
488
745
|
autoSaveStatus = 'saved';
|
|
489
|
-
lastAutoSaveData =
|
|
746
|
+
lastAutoSaveData = submittedValues;
|
|
490
747
|
lastAutoSaveError = null;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
748
|
+
reconcilePersistedSnapshot(currentSnapshot, submittedValues);
|
|
749
|
+
autoSaveStatusTimer = setTimeout(() => {
|
|
750
|
+
autoSaveStatusTimer = null;
|
|
751
|
+
if (formDestroyed) return;
|
|
752
|
+
autoSaveStatus = 'idle';
|
|
753
|
+
}, 2000);
|
|
495
754
|
} catch (e) {
|
|
755
|
+
if (!isRecordIdentityCurrent(scheduledIdentity)) return;
|
|
496
756
|
autoSaveStatus = 'error';
|
|
497
757
|
lastAutoSaveError = e;
|
|
498
|
-
checkError(e);
|
|
758
|
+
checkError(e, adminContext);
|
|
499
759
|
} finally {
|
|
500
|
-
|
|
501
|
-
|
|
760
|
+
if (!formDestroyed) {
|
|
761
|
+
autoSaveInProgress = false;
|
|
762
|
+
const shouldAutoSave = autoSaveDirty && isTainted();
|
|
763
|
+
autoSaveDirty = false;
|
|
764
|
+
if (shouldAutoSave) triggerAutoSave();
|
|
765
|
+
}
|
|
502
766
|
}
|
|
503
767
|
}, autoSaveOpts.debounce ?? 1000);
|
|
504
768
|
}
|
package/src/helpers.ts
CHANGED
|
@@ -75,8 +75,12 @@ export function deriveValidator(
|
|
|
75
75
|
only?: string[];
|
|
76
76
|
/** Skip these field keys. */
|
|
77
77
|
except?: string[];
|
|
78
|
+
/** Captured tree-local translator for validation that runs after component initialization. */
|
|
79
|
+
translate?: typeof t;
|
|
78
80
|
}
|
|
79
81
|
): (values: Record<string, unknown>) => Record<string, string> | null {
|
|
82
|
+
const translate = options?.translate ?? t;
|
|
83
|
+
|
|
80
84
|
return (values: Record<string, unknown>) => {
|
|
81
85
|
const errors: Record<string, string> = {};
|
|
82
86
|
const only = options?.only ? new Set(options.only) : null;
|
|
@@ -91,12 +95,12 @@ export function deriveValidator(
|
|
|
91
95
|
// Required check
|
|
92
96
|
if (field.required) {
|
|
93
97
|
if (value === undefined || value === null || value === '') {
|
|
94
|
-
errors[field.key] =
|
|
98
|
+
errors[field.key] = translate('validation.required');
|
|
95
99
|
continue;
|
|
96
100
|
}
|
|
97
101
|
// Empty array check for multi-value fields
|
|
98
102
|
if (Array.isArray(value) && value.length === 0 && (field.type === 'multiselect' || field.type === 'tags')) {
|
|
99
|
-
errors[field.key] =
|
|
103
|
+
errors[field.key] = translate('validation.required');
|
|
100
104
|
continue;
|
|
101
105
|
}
|
|
102
106
|
}
|
|
@@ -109,7 +113,7 @@ export function deriveValidator(
|
|
|
109
113
|
case 'email': {
|
|
110
114
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
111
115
|
if (typeof value === 'string' && !emailRegex.test(value)) {
|
|
112
|
-
errors[field.key] =
|
|
116
|
+
errors[field.key] = translate('validation.invalidEmail');
|
|
113
117
|
}
|
|
114
118
|
break;
|
|
115
119
|
}
|
|
@@ -117,17 +121,17 @@ export function deriveValidator(
|
|
|
117
121
|
try {
|
|
118
122
|
new URL(value as string);
|
|
119
123
|
} catch {
|
|
120
|
-
errors[field.key] =
|
|
124
|
+
errors[field.key] = translate('validation.invalidUrl');
|
|
121
125
|
}
|
|
122
126
|
break;
|
|
123
127
|
}
|
|
124
128
|
case 'number': {
|
|
125
129
|
if (typeof value === 'string' && isNaN(Number(value))) {
|
|
126
|
-
errors[field.key] =
|
|
130
|
+
errors[field.key] = translate('validation.invalidNumber');
|
|
127
131
|
} else if (typeof value === 'number' && isNaN(value)) {
|
|
128
|
-
errors[field.key] =
|
|
132
|
+
errors[field.key] = translate('validation.invalidNumber');
|
|
129
133
|
} else if (typeof value !== 'string' && typeof value !== 'number') {
|
|
130
|
-
errors[field.key] =
|
|
134
|
+
errors[field.key] = translate('validation.invalidNumber');
|
|
131
135
|
}
|
|
132
136
|
break;
|
|
133
137
|
}
|