@svadmin/core 0.22.1 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/audit.ts +5 -0
- package/src/auth-hooks.svelte.ts +34 -24
- package/src/chatProvider.svelte.ts +8 -21
- package/src/context.svelte.ts +20 -0
- package/src/data-transfer.svelte.ts +13 -10
- package/src/form-hooks.svelte.ts +103 -43
- package/src/helpers-pure.ts +159 -0
- package/src/helpers.test.ts +177 -0
- package/src/helpers.ts +9 -118
- package/src/hook-utils.svelte.ts +27 -6
- package/src/hooks.svelte.ts +100 -17
- package/src/i18n.svelte.ts +23 -17
- package/src/index.ts +11 -4
- package/src/live.svelte.ts +35 -19
- package/src/mutation-hooks.svelte.ts +162 -73
- package/src/notification.svelte.ts +9 -5
- package/src/options.svelte.ts +42 -19
- package/src/permissions.ts +4 -0
- package/src/query-hooks.svelte.ts +70 -20
- package/src/router-provider.ts +3 -1
- package/src/router.ts +23 -2
- package/src/routing-hooks.svelte.ts +37 -12
- package/src/table-hooks.svelte.ts +61 -15
- package/src/table-state.svelte.ts +2 -1
- package/src/theme.svelte.ts +30 -24
- package/src/toast.svelte.ts +22 -11
- package/src/types.ts +3 -1
- package/src/url-sync.ts +28 -16
- package/src/utility-hooks.svelte.ts +14 -4
package/package.json
CHANGED
package/src/audit.ts
CHANGED
|
@@ -45,6 +45,11 @@ export function getAuditLogProvider(): AuditLogProvider | null {
|
|
|
45
45
|
return auditLogProvider;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export function resetAuditLogProvider(): void {
|
|
49
|
+
auditLogProvider = null;
|
|
50
|
+
handler = (entry) => { console.info('[audit]', entry.action, entry.resource, entry.recordId); };
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
export function audit(entry: Omit<AuditEntry, 'timestamp'>): void {
|
|
49
54
|
const fullEntry: AuditEntry = { ...entry, timestamp: new Date().toISOString() };
|
|
50
55
|
try {
|
package/src/auth-hooks.svelte.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { AuthActionResult, CheckResult, Identity, AuthProvider } from './ty
|
|
|
13
13
|
interface CreateAuthMutationOptions {
|
|
14
14
|
method: keyof AuthProvider;
|
|
15
15
|
successMessage?: string | null;
|
|
16
|
-
errorMessage?: string;
|
|
16
|
+
errorMessage?: string | false;
|
|
17
17
|
onSuccess?: (result: AuthActionResult) => void;
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -33,13 +33,13 @@ function createAuthMutation(options: CreateAuthMutationOptions) {
|
|
|
33
33
|
if (options.successMessage) notify({ type: 'success', message: options.successMessage });
|
|
34
34
|
if (options.onSuccess) options.onSuccess(result);
|
|
35
35
|
} else {
|
|
36
|
-
const msg = result.error?.message ?? options.errorMessage
|
|
37
|
-
notify({ type: 'error', message: msg });
|
|
36
|
+
const msg = result.error?.message ?? (typeof options.errorMessage === 'string' ? options.errorMessage : t('common.operationFailed'));
|
|
37
|
+
if (options.errorMessage !== false) notify({ type: 'error', message: msg });
|
|
38
38
|
}
|
|
39
39
|
return result;
|
|
40
40
|
} catch (err) {
|
|
41
|
-
const msg = err instanceof Error ? err.message : (options.errorMessage
|
|
42
|
-
notify({ type: 'error', message: msg });
|
|
41
|
+
const msg = err instanceof Error ? err.message : (typeof options.errorMessage === 'string' ? options.errorMessage : t('common.operationFailed'));
|
|
42
|
+
if (options.errorMessage !== false) notify({ type: 'error', message: msg });
|
|
43
43
|
return { success: false, error: { message: msg } };
|
|
44
44
|
} finally {
|
|
45
45
|
isLoading = false;
|
|
@@ -54,11 +54,11 @@ function createAuthMutation(options: CreateAuthMutationOptions) {
|
|
|
54
54
|
|
|
55
55
|
// ─── useLogin ─────────────────────────────────────────────────
|
|
56
56
|
|
|
57
|
-
export function useLogin() {
|
|
57
|
+
export function useLogin(opts?: { errorMessage?: string | false }) {
|
|
58
58
|
return createAuthMutation({
|
|
59
59
|
method: 'login',
|
|
60
60
|
successMessage: t('common.operationSuccess'),
|
|
61
|
-
errorMessage: t('common.loginFailed'),
|
|
61
|
+
errorMessage: opts?.errorMessage ?? t('common.loginFailed'),
|
|
62
62
|
onSuccess: (result) => { if (result.redirectTo) navigate(result.redirectTo); }
|
|
63
63
|
});
|
|
64
64
|
}
|
|
@@ -105,7 +105,7 @@ export function useUpdatePassword() {
|
|
|
105
105
|
// ─── useGetIdentity ──────────────────────────────────────────
|
|
106
106
|
|
|
107
107
|
export function useGetIdentity() {
|
|
108
|
-
const provider = getAuthProvider();
|
|
108
|
+
const provider = getAuthProvider({ optional: true });
|
|
109
109
|
let data = $state<Identity | null>(null);
|
|
110
110
|
let isLoading = $state(true);
|
|
111
111
|
let error = $state<Error | null>(null);
|
|
@@ -138,33 +138,43 @@ export function useGetIdentity() {
|
|
|
138
138
|
// ─── useIsAuthenticated ──────────────────────────────────────
|
|
139
139
|
|
|
140
140
|
export function useIsAuthenticated() {
|
|
141
|
-
const provider = getAuthProvider();
|
|
141
|
+
const provider = getAuthProvider({ optional: true });
|
|
142
142
|
let isAuthenticated = $state(false);
|
|
143
143
|
let isLoading = $state(true);
|
|
144
|
+
let checkResult = $state<CheckResult | null>(null);
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
isLoading = false;
|
|
153
|
-
});
|
|
154
|
-
} else {
|
|
146
|
+
function check() {
|
|
147
|
+
if (!provider) {
|
|
148
|
+
isAuthenticated = true;
|
|
149
|
+
isLoading = false;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (typeof window === 'undefined') {
|
|
155
153
|
// In SSR we assume false to prevent hydration mismatch before check
|
|
156
154
|
isAuthenticated = false;
|
|
157
155
|
isLoading = false;
|
|
156
|
+
return;
|
|
158
157
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
isLoading = true;
|
|
159
|
+
provider.check().then((result: CheckResult) => {
|
|
160
|
+
isAuthenticated = result.authenticated;
|
|
161
|
+
checkResult = result;
|
|
162
|
+
isLoading = false;
|
|
163
|
+
}).catch(() => {
|
|
164
|
+
isAuthenticated = false;
|
|
165
|
+
checkResult = { authenticated: false };
|
|
166
|
+
isLoading = false;
|
|
167
|
+
});
|
|
163
168
|
}
|
|
164
169
|
|
|
170
|
+
// Initial check
|
|
171
|
+
check();
|
|
172
|
+
|
|
165
173
|
return {
|
|
166
174
|
get isAuthenticated() { return isAuthenticated; },
|
|
167
175
|
get isLoading() { return isLoading; },
|
|
176
|
+
get data() { return checkResult; },
|
|
177
|
+
refetch: check,
|
|
168
178
|
};
|
|
169
179
|
}
|
|
170
180
|
|
|
@@ -176,7 +186,7 @@ export function useIsAuthenticated() {
|
|
|
176
186
|
* If it returns { redirectTo }, navigates there.
|
|
177
187
|
*/
|
|
178
188
|
export function useOnError() {
|
|
179
|
-
const provider = getAuthProvider();
|
|
189
|
+
const provider = getAuthProvider({ optional: true });
|
|
180
190
|
|
|
181
191
|
async function mutate(error: unknown) {
|
|
182
192
|
if (!provider?.onError) {
|
|
@@ -186,36 +186,23 @@ export function getAgentProvider(): AgentProvider | null {
|
|
|
186
186
|
interface ApprovalCallback {
|
|
187
187
|
(approved: boolean): void;
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
interface ApprovalEntry { id: string; callback: ApprovalCallback }
|
|
190
|
+
let pendingApprovals = $state<ApprovalEntry[]>([]);
|
|
190
191
|
|
|
191
|
-
/**
|
|
192
|
-
* Register a pending approval request. Called internally by the framework
|
|
193
|
-
* when an `approval_request` event is received from the agent.
|
|
194
|
-
*/
|
|
195
192
|
export function registerApproval(id: string, callback: ApprovalCallback): void {
|
|
196
|
-
pendingApprovals
|
|
193
|
+
pendingApprovals = [...pendingApprovals, { id, callback }];
|
|
197
194
|
}
|
|
198
195
|
|
|
199
|
-
/**
|
|
200
|
-
* Approve or reject a pending tool call.
|
|
201
|
-
*
|
|
202
|
-
* @example
|
|
203
|
-
* ```svelte
|
|
204
|
-
* <button onclick={() => resolveApproval('req-1', true)}>Approve</button>
|
|
205
|
-
* <button onclick={() => resolveApproval('req-1', false)}>Reject</button>
|
|
206
|
-
* ```
|
|
207
|
-
*/
|
|
208
196
|
export function resolveApproval(id: string, approved: boolean): boolean {
|
|
209
|
-
const
|
|
210
|
-
if (!
|
|
211
|
-
|
|
212
|
-
pendingApprovals.
|
|
197
|
+
const entry = pendingApprovals.find(e => e.id === id);
|
|
198
|
+
if (!entry) return false;
|
|
199
|
+
entry.callback(approved);
|
|
200
|
+
pendingApprovals = pendingApprovals.filter(e => e.id !== id);
|
|
213
201
|
return true;
|
|
214
202
|
}
|
|
215
203
|
|
|
216
|
-
/** Check if there are any pending approval requests. */
|
|
217
204
|
export function hasPendingApprovals(): boolean {
|
|
218
|
-
return pendingApprovals.
|
|
205
|
+
return pendingApprovals.length > 0;
|
|
219
206
|
}
|
|
220
207
|
|
|
221
208
|
// ─── Chat Context singleton ────────────────────────────────────
|
package/src/context.svelte.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
|
|
5
5
|
import type { RouterProvider } from './router-provider';
|
|
6
|
+
import { resetNotificationProvider } from './notification.svelte';
|
|
7
|
+
import { resetAuditLogProvider } from './audit';
|
|
8
|
+
import { resetAccessControlProvider } from './permissions';
|
|
9
|
+
import { resetAdminOptions } from './options.svelte';
|
|
10
|
+
import { resetI18n } from './i18n.svelte';
|
|
6
11
|
|
|
7
12
|
// ─── DataProvider (supports single or multiple) ─────────────────
|
|
8
13
|
|
|
@@ -122,3 +127,18 @@ export function setLiveProvider(provider: import('./live.svelte').LiveProvider):
|
|
|
122
127
|
export function getLiveProvider(): import('./live.svelte').LiveProvider | undefined {
|
|
123
128
|
return liveProviderState;
|
|
124
129
|
}
|
|
130
|
+
|
|
131
|
+
// ─── Reset — for testing / HMR ─────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export function resetContext(): void {
|
|
134
|
+
providers = null;
|
|
135
|
+
authProvider = null;
|
|
136
|
+
resources = [];
|
|
137
|
+
routerProvider = undefined;
|
|
138
|
+
liveProviderState = undefined;
|
|
139
|
+
resetNotificationProvider();
|
|
140
|
+
resetAuditLogProvider();
|
|
141
|
+
resetAccessControlProvider();
|
|
142
|
+
resetAdminOptions();
|
|
143
|
+
resetI18n();
|
|
144
|
+
}
|
|
@@ -57,14 +57,13 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
57
57
|
|
|
58
58
|
if (options.download !== false && typeof document !== 'undefined') {
|
|
59
59
|
const fields = Object.keys(mapped[0]);
|
|
60
|
-
const
|
|
60
|
+
const escapeCsvField = (s: string) => s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r') ? `"${s.replace(/"/g, '""')}"` : s;
|
|
61
|
+
const header = fields.map(escapeCsvField).join(',');
|
|
61
62
|
const rows = mapped.map(record =>
|
|
62
63
|
fields.map(f => {
|
|
63
64
|
const val = record[f];
|
|
64
|
-
const str = val === null || val === undefined ? '' : String(val);
|
|
65
|
-
return
|
|
66
|
-
? `"${str.replace(/"/g, '""')}"`
|
|
67
|
-
: str;
|
|
65
|
+
const str = val === null || val === undefined ? '' : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
66
|
+
return escapeCsvField(str);
|
|
68
67
|
}).join(',')
|
|
69
68
|
);
|
|
70
69
|
|
|
@@ -200,6 +199,7 @@ function parseCSV(text: string): string[][] {
|
|
|
200
199
|
let currentRow: string[] = [];
|
|
201
200
|
let currentVal = '';
|
|
202
201
|
let inQuotes = false;
|
|
202
|
+
let wasQuoted = false;
|
|
203
203
|
|
|
204
204
|
for (let i = 0; i < text.length; i++) {
|
|
205
205
|
const ch = text[i];
|
|
@@ -207,7 +207,7 @@ function parseCSV(text: string): string[][] {
|
|
|
207
207
|
if (ch === '"') {
|
|
208
208
|
if (i + 1 < text.length && text[i + 1] === '"') {
|
|
209
209
|
currentVal += '"';
|
|
210
|
-
i++;
|
|
210
|
+
i++;
|
|
211
211
|
} else {
|
|
212
212
|
inQuotes = false;
|
|
213
213
|
}
|
|
@@ -217,18 +217,21 @@ function parseCSV(text: string): string[][] {
|
|
|
217
217
|
} else {
|
|
218
218
|
if (ch === '"') {
|
|
219
219
|
inQuotes = true;
|
|
220
|
+
wasQuoted = true;
|
|
220
221
|
} else if (ch === ',') {
|
|
221
|
-
currentRow.push(currentVal.trim());
|
|
222
|
+
currentRow.push(wasQuoted ? currentVal : currentVal.trim());
|
|
222
223
|
currentVal = '';
|
|
224
|
+
wasQuoted = false;
|
|
223
225
|
} else if (ch === '\n' || ch === '\r') {
|
|
224
|
-
currentRow.push(currentVal.trim());
|
|
226
|
+
currentRow.push(wasQuoted ? currentVal : currentVal.trim());
|
|
225
227
|
if (currentRow.some(v => v !== '')) {
|
|
226
228
|
rows.push(currentRow);
|
|
227
229
|
}
|
|
228
230
|
currentRow = [];
|
|
229
231
|
currentVal = '';
|
|
232
|
+
wasQuoted = false;
|
|
230
233
|
if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
|
|
231
|
-
i++;
|
|
234
|
+
i++;
|
|
232
235
|
}
|
|
233
236
|
} else {
|
|
234
237
|
currentVal += ch;
|
|
@@ -237,7 +240,7 @@ function parseCSV(text: string): string[][] {
|
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
if (currentVal || currentRow.length > 0) {
|
|
240
|
-
currentRow.push(currentVal.trim());
|
|
243
|
+
currentRow.push(wasQuoted ? currentVal : currentVal.trim());
|
|
241
244
|
if (currentRow.some(v => v !== '')) {
|
|
242
245
|
rows.push(currentRow);
|
|
243
246
|
}
|
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
2
2
|
import { useParsed } from './useParsed.svelte';
|
|
3
3
|
import { getAdminOptions } from './options.svelte';
|
|
4
|
-
import { getDataProviderForResource, getResource } from './context.svelte';
|
|
4
|
+
import { getDataProviderForResource, getResource, getLiveProvider } from './context.svelte';
|
|
5
5
|
import { createQuery, createMutation } from '@tanstack/svelte-query';
|
|
6
6
|
import { notify } from './notification.svelte';
|
|
7
7
|
import { t } from './i18n.svelte';
|
|
8
8
|
import { audit } from './audit';
|
|
9
9
|
import { navigate, currentPath } from './router';
|
|
10
|
-
import { HttpError } from './types';
|
|
10
|
+
import { HttpError, UndoError } from './types';
|
|
11
11
|
import type { BaseRecord, MutationMode, KnownResources } from './types';
|
|
12
|
+
import { checkError } from './hook-utils.svelte';
|
|
13
|
+
import { toast } from './toast.svelte';
|
|
14
|
+
import { deepMerge, invalidateByScopes } from './mutation-hooks.svelte';
|
|
12
15
|
|
|
13
16
|
// ─── Types ───────────────────────────────────────────────────────────
|
|
14
17
|
|
|
@@ -18,7 +21,7 @@ export interface UseFormOptions<
|
|
|
18
21
|
TError = HttpError,
|
|
19
22
|
> {
|
|
20
23
|
resource?: KnownResources;
|
|
21
|
-
action?: 'create' | 'edit' | 'clone';
|
|
24
|
+
action?: 'create' | 'edit' | 'clone' | 'show';
|
|
22
25
|
id?: string | number;
|
|
23
26
|
redirect?: 'list' | 'edit' | 'show' | false;
|
|
24
27
|
|
|
@@ -41,7 +44,7 @@ export interface UseFormOptions<
|
|
|
41
44
|
/** Called every time a field value changes. */
|
|
42
45
|
onChange?: (event: { field: string; value: unknown; values: TVariables }) => void;
|
|
43
46
|
/** Called before mutation. Return false or call cancel() to prevent submission. */
|
|
44
|
-
onSubmit?: (ctx: { values: TVariables; action: 'create' | 'edit' | 'clone'; cancel: () => void }) => void | boolean;
|
|
47
|
+
onSubmit?: (ctx: { values: TVariables; action: 'create' | 'edit' | 'clone' | 'show'; cancel: () => void }) => void | boolean;
|
|
45
48
|
|
|
46
49
|
// ─── Meta ─────────────────────────────────────────────────────
|
|
47
50
|
meta?: Record<string, unknown>;
|
|
@@ -95,6 +98,7 @@ export interface UseFormReturn<
|
|
|
95
98
|
// ─── Tainted (dirty) ──────────────────────────────────────────
|
|
96
99
|
readonly tainted: Record<string, boolean>;
|
|
97
100
|
isTainted: (field?: string) => boolean;
|
|
101
|
+
untaint: () => void;
|
|
98
102
|
|
|
99
103
|
// ─── Errors ───────────────────────────────────────────────────
|
|
100
104
|
readonly errors: Record<string, string>;
|
|
@@ -113,12 +117,12 @@ export interface UseFormReturn<
|
|
|
113
117
|
// ─── State ──────────────────────────────────────────────────────
|
|
114
118
|
readonly loading: boolean;
|
|
115
119
|
readonly submitting: boolean;
|
|
116
|
-
readonly action: 'create' | 'edit' | 'clone';
|
|
120
|
+
readonly action: 'create' | 'edit' | 'clone' | 'show';
|
|
117
121
|
readonly resource: string;
|
|
118
122
|
readonly id: string | number | undefined;
|
|
119
123
|
readonly isDirty: boolean;
|
|
120
124
|
setId: (id: string | number | undefined) => void;
|
|
121
|
-
setAction: (action: 'create' | 'edit' | 'clone') => void;
|
|
125
|
+
setAction: (action: 'create' | 'edit' | 'clone' | 'show') => void;
|
|
122
126
|
mutationMode: MutationMode;
|
|
123
127
|
redirect: (to: 'list' | 'edit' | 'show' | false) => void;
|
|
124
128
|
|
|
@@ -143,7 +147,7 @@ export function useForm<
|
|
|
143
147
|
const adminOptions = getAdminOptions();
|
|
144
148
|
|
|
145
149
|
const resource = $derived(options.resource ?? parsed.resource ?? '');
|
|
146
|
-
let action = $state<'create' | 'edit' | 'clone'>(options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create');
|
|
150
|
+
let action = $state<'create' | 'edit' | 'clone' | 'show'>(options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone' | 'show') ?? 'create');
|
|
147
151
|
let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
|
|
148
152
|
|
|
149
153
|
const defaultRedirectOpt = $derived(action === 'clone' ? adminOptions.redirect?.afterClone : action === 'edit' ? adminOptions.redirect?.afterEdit : adminOptions.redirect?.afterCreate);
|
|
@@ -166,12 +170,11 @@ export function useForm<
|
|
|
166
170
|
const redirectDefault = $derived(redirectOption ?? defaultRedirectOpt ?? 'list');
|
|
167
171
|
|
|
168
172
|
const provider = $derived(getDataProviderForResource(resource, dataProviderName));
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const mutationMeta = { ...parsedMeta, ...hookMeta, ...hookMutationMeta };
|
|
173
|
+
const queryMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookQueryMeta });
|
|
174
|
+
const mutationMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookMutationMeta });
|
|
172
175
|
|
|
173
176
|
function setId(newId: string | number | undefined) { currentId = newId; }
|
|
174
|
-
function setAction(newAction: 'create' | 'edit' | 'clone') { action = newAction; }
|
|
177
|
+
function setAction(newAction: 'create' | 'edit' | 'clone' | 'show') { action = newAction; }
|
|
175
178
|
|
|
176
179
|
// ─── Form values (single source of truth) ───────────────────────
|
|
177
180
|
let values = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
|
|
@@ -243,10 +246,13 @@ export function useForm<
|
|
|
243
246
|
values = { ...initialValues } as TVariables;
|
|
244
247
|
tainted = {};
|
|
245
248
|
clearErrors();
|
|
249
|
+
queryInitializedId = undefined;
|
|
246
250
|
}
|
|
247
251
|
|
|
248
252
|
// ─── Server error handling ──────────────────────────────────────
|
|
253
|
+
|
|
249
254
|
function handleHttpError(error: Error) {
|
|
255
|
+
checkError(error);
|
|
250
256
|
if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
|
|
251
257
|
for (const [field, messages] of Object.entries(error.errors)) {
|
|
252
258
|
const msg = Array.isArray(messages) ? messages[0] : messages;
|
|
@@ -262,12 +268,12 @@ export function useForm<
|
|
|
262
268
|
// Always create the query — use `enabled` to conditionally activate.
|
|
263
269
|
// This ensures setAction('edit') at runtime will trigger a fetch.
|
|
264
270
|
const query = createQuery(() => ({
|
|
265
|
-
queryKey: [resource, 'one', currentId],
|
|
271
|
+
queryKey: [options.dataProviderName, resource, 'one', currentId, queryMeta],
|
|
266
272
|
queryFn: async () => {
|
|
267
273
|
const result = await provider.getOne<BaseRecord>({ resource, id: currentId!, meta: queryMeta });
|
|
268
|
-
return result
|
|
274
|
+
return result;
|
|
269
275
|
},
|
|
270
|
-
enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone') && currentId != null,
|
|
276
|
+
enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone' || action === 'show') && currentId != null,
|
|
271
277
|
staleTime: queryOptions?.staleTime,
|
|
272
278
|
}));
|
|
273
279
|
|
|
@@ -276,21 +282,23 @@ export function useForm<
|
|
|
276
282
|
{
|
|
277
283
|
$effect.pre(() => {
|
|
278
284
|
// Discard previous fetch lock and default when switched strictly back to create
|
|
279
|
-
if (action === 'create' &&
|
|
285
|
+
if (action === 'create' && queryInitializedId !== undefined) {
|
|
280
286
|
queryInitializedId = undefined;
|
|
281
287
|
const def = (options.defaultValues ?? {}) as TVariables;
|
|
282
288
|
values = { ...def };
|
|
283
289
|
initialValues = { ...def };
|
|
290
|
+
tainted = {};
|
|
291
|
+
clearErrors();
|
|
284
292
|
return;
|
|
285
293
|
}
|
|
286
294
|
if (queryInitializedId === currentId) return;
|
|
287
|
-
const
|
|
295
|
+
const record = (query.data as { data: Record<string, unknown> } | undefined)?.data;
|
|
288
296
|
const pk = getResource(resource).primaryKey ?? 'id';
|
|
289
|
-
if (
|
|
290
|
-
|
|
297
|
+
if (record && String(record[pk]) === String(currentId)) {
|
|
298
|
+
const cloneStripKeys = new Set(['id', '_id', pk, 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updated_by', 'created_by', 'updatedBy']);
|
|
291
299
|
const targetData = action === 'clone'
|
|
292
|
-
? Object.fromEntries(Object.entries(
|
|
293
|
-
:
|
|
300
|
+
? Object.fromEntries(Object.entries(record).filter(([k]) => !cloneStripKeys.has(k)))
|
|
301
|
+
: record;
|
|
294
302
|
// Merge: defaultValues < query data
|
|
295
303
|
const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
|
|
296
304
|
values = merged;
|
|
@@ -307,13 +315,18 @@ export function useForm<
|
|
|
307
315
|
...options.createMutationOptions,
|
|
308
316
|
mutationFn: (variables: TVariables) => provider.create<TData, TVariables>({ resource, variables, meta: mutationMeta }),
|
|
309
317
|
onSuccess: (data: { data: TData }) => {
|
|
310
|
-
|
|
318
|
+
invalidateByScopes(queryClient, resource, invalidateScopes, ['list', 'many']);
|
|
311
319
|
if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
|
|
312
320
|
const pk = getResource(resource).primaryKey ?? 'id';
|
|
313
|
-
|
|
321
|
+
const newId = (data.data as Record<string, unknown>)[pk];
|
|
322
|
+
audit({ action: 'create', resource, recordId: String(newId) });
|
|
323
|
+
// Publish live event
|
|
324
|
+
try {
|
|
325
|
+
const lp = getLiveProvider();
|
|
326
|
+
if (lp?.publish) lp.publish({ type: 'INSERT', resource, payload: { ids: newId != null ? [newId as string | number] : [] } });
|
|
327
|
+
} catch { /* no live provider */ }
|
|
314
328
|
onMutationSuccess?.(data);
|
|
315
329
|
if (redirectOverride !== false) {
|
|
316
|
-
const newId = (data.data as Record<string, unknown>)[pk];
|
|
317
330
|
if (newId != null) currentId = newId as string | number;
|
|
318
331
|
doRedirect(redirectOverride ?? redirectDefault);
|
|
319
332
|
}
|
|
@@ -321,17 +334,60 @@ export function useForm<
|
|
|
321
334
|
onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
|
|
322
335
|
}));
|
|
323
336
|
|
|
324
|
-
const updateMut = createMutation(() => ({
|
|
337
|
+
const updateMut = createMutation<{ data: TData }, unknown, TVariables>(() => ({
|
|
325
338
|
...options.updateMutationOptions,
|
|
326
|
-
mutationFn: (variables: TVariables) =>
|
|
327
|
-
|
|
328
|
-
|
|
339
|
+
mutationFn: async (variables: TVariables) => {
|
|
340
|
+
if (mutationMode === 'undoable') {
|
|
341
|
+
await new Promise<void>((resolve, reject) => {
|
|
342
|
+
toast.undoable(t('common.actionCanBeUndone'), undoableTimeout, () => {
|
|
343
|
+
reject(new UndoError());
|
|
344
|
+
}, resolve);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta });
|
|
348
|
+
},
|
|
349
|
+
onMutate: async (variables: TVariables) => {
|
|
350
|
+
if (mutationMode === 'pessimistic') return;
|
|
351
|
+
await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resource });
|
|
352
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resource });
|
|
353
|
+
if (optimisticUpdateMap?.list !== false) {
|
|
354
|
+
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
355
|
+
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
356
|
+
const o = old as { data: Record<string, unknown>[] };
|
|
357
|
+
const pk = getResource(resource).primaryKey ?? 'id';
|
|
358
|
+
return { ...o, data: o.data.map((item) => String(item[pk]) === String(currentId) ? deepMerge(item, variables) : item) };
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (optimisticUpdateMap?.detail !== false && currentId != null) {
|
|
362
|
+
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === currentId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
|
|
363
|
+
}
|
|
364
|
+
return { previousQueries };
|
|
365
|
+
},
|
|
366
|
+
onSuccess: (data: unknown) => {
|
|
329
367
|
if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.updateSuccess') });
|
|
330
368
|
audit({ action: 'update', resource, recordId: String(currentId) });
|
|
369
|
+
try {
|
|
370
|
+
const lp = getLiveProvider();
|
|
371
|
+
if (lp?.publish) lp.publish({ type: 'UPDATE', resource, payload: { ids: currentId != null ? [currentId] : [] } });
|
|
372
|
+
} catch { /* no live provider */ }
|
|
331
373
|
onMutationSuccess?.(data);
|
|
332
374
|
if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
|
|
333
375
|
},
|
|
334
|
-
|
|
376
|
+
onSettled: (_data: unknown, error: unknown) => {
|
|
377
|
+
if (error instanceof UndoError) return;
|
|
378
|
+
invalidateByScopes(queryClient, resource, invalidateScopes, ['list', 'many', 'detail'], currentId ?? undefined);
|
|
379
|
+
},
|
|
380
|
+
onError: (error: unknown, _vars: unknown, context: unknown) => {
|
|
381
|
+
const ctx = context as { previousQueries?: [unknown, unknown][] } | undefined;
|
|
382
|
+
if (ctx?.previousQueries) {
|
|
383
|
+
for (const [queryKey, data] of ctx.previousQueries) {
|
|
384
|
+
queryClient.setQueryData(queryKey as string[], data);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (error instanceof UndoError) return;
|
|
388
|
+
handleHttpError(error as Error);
|
|
389
|
+
onMutationError?.(error as Error);
|
|
390
|
+
},
|
|
335
391
|
}));
|
|
336
392
|
|
|
337
393
|
function doRedirect(to: 'list' | 'edit' | 'show' | false) {
|
|
@@ -347,7 +403,7 @@ export function useForm<
|
|
|
347
403
|
|
|
348
404
|
// ─── Submit ─────────────────────────────────────────────────────
|
|
349
405
|
async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
|
|
350
|
-
|
|
406
|
+
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
351
407
|
if (onSubmitFn) {
|
|
352
408
|
let cancelled = false;
|
|
353
409
|
const result = onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
|
|
@@ -359,11 +415,9 @@ export function useForm<
|
|
|
359
415
|
try {
|
|
360
416
|
if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
|
|
361
417
|
else await updateMut.mutateAsync(values);
|
|
362
|
-
// Untaint on success
|
|
363
418
|
tainted = {};
|
|
364
|
-
} catch
|
|
365
|
-
// Errors
|
|
366
|
-
// We catch here to prevent unhandled promise rejections.
|
|
419
|
+
} catch {
|
|
420
|
+
// Errors already handled by mutation's onError callback
|
|
367
421
|
}
|
|
368
422
|
}
|
|
369
423
|
|
|
@@ -373,36 +427,41 @@ export function useForm<
|
|
|
373
427
|
let lastAutoSaveData = $state<unknown>(null);
|
|
374
428
|
let lastAutoSaveError = $state<unknown>(null);
|
|
375
429
|
|
|
430
|
+
$effect(() => {
|
|
431
|
+
return () => {
|
|
432
|
+
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
|
|
376
436
|
function triggerAutoSave() {
|
|
377
|
-
const currentAction = action;
|
|
378
|
-
if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone') return;
|
|
437
|
+
const currentAction = action;
|
|
438
|
+
if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone' || currentId == null) return;
|
|
439
|
+
if (createMut.isPending || updateMut.isPending) return;
|
|
379
440
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
380
441
|
|
|
442
|
+
const safeId = currentId;
|
|
381
443
|
autoSaveTimer = setTimeout(async () => {
|
|
382
444
|
const finalValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(values) : values;
|
|
383
445
|
autoSaveStatus = 'saving';
|
|
384
446
|
try {
|
|
385
|
-
await provider.update<TData, TVariables>({ resource, id:
|
|
447
|
+
await provider.update<TData, TVariables>({ resource, id: safeId as string | number, variables: finalValues, meta: mutationMeta });
|
|
386
448
|
const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
|
|
387
|
-
|
|
388
|
-
if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [resource] });
|
|
389
|
-
else if (scope === 'detail' && currentId) queryClient.invalidateQueries({ queryKey: [resource, 'one', currentId] });
|
|
390
|
-
else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [resource, 'list'] });
|
|
391
|
-
}
|
|
449
|
+
invalidateByScopes(queryClient, resource, scopes, ['resourceAll'], safeId ?? undefined);
|
|
392
450
|
autoSaveStatus = 'saved';
|
|
393
|
-
lastAutoSaveData =
|
|
451
|
+
lastAutoSaveData = finalValues;
|
|
394
452
|
lastAutoSaveError = null;
|
|
395
453
|
setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
|
|
396
454
|
} catch (e) {
|
|
397
455
|
autoSaveStatus = 'error';
|
|
398
456
|
lastAutoSaveError = e;
|
|
457
|
+
checkError(e);
|
|
399
458
|
}
|
|
400
459
|
}, autoSaveOpts.debounce ?? 1000);
|
|
401
460
|
}
|
|
402
461
|
|
|
403
462
|
if (autoSaveOpts?.invalidateOnUnmount) {
|
|
404
463
|
$effect(() => {
|
|
405
|
-
return () => queryClient.invalidateQueries({
|
|
464
|
+
return () => queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource });
|
|
406
465
|
});
|
|
407
466
|
}
|
|
408
467
|
|
|
@@ -430,6 +489,7 @@ export function useForm<
|
|
|
430
489
|
// Tainted
|
|
431
490
|
get tainted() { return tainted; },
|
|
432
491
|
isTainted,
|
|
492
|
+
untaint: () => { tainted = {}; },
|
|
433
493
|
|
|
434
494
|
// Errors
|
|
435
495
|
get errors() { return errors; },
|