@svadmin/core 0.0.6 → 0.4.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 +7 -1
- package/src/audit.ts +37 -0
- package/src/auth-hooks.svelte.ts +2 -3
- package/src/auth-hooks.test.ts +205 -0
- package/src/context.svelte.ts +123 -0
- package/src/data-transfer.ts +148 -70
- package/src/form-hooks.svelte.ts +266 -0
- package/src/helpers.ts +157 -0
- package/src/hook-utils.svelte.ts +121 -0
- package/src/hooks.svelte.ts +277 -530
- package/src/http-error.test.ts +85 -0
- package/src/i18n.svelte.ts +87 -1
- package/src/index.ts +42 -9
- package/src/inferencer-openapi.test.ts +192 -0
- package/src/inferencer-openapi.ts +290 -0
- package/src/live-sse.ts +146 -0
- package/src/live-websocket.ts +141 -0
- package/src/live.test.ts +134 -0
- package/src/live.ts +60 -8
- package/src/mutation-hooks.svelte.ts +301 -0
- package/src/options.ts +95 -0
- package/src/query-hooks.svelte.ts +213 -0
- package/src/query-keys.ts +22 -0
- package/src/resource-types.test.ts +79 -0
- package/src/router.ts +28 -4
- package/src/routing-hooks.svelte.ts +99 -0
- package/src/table-hooks.svelte.ts +178 -0
- package/src/table-state.svelte.ts +243 -0
- package/src/toast.svelte.ts +28 -8
- package/src/types.ts +129 -47
- package/src/unsaved-changes.svelte.ts +62 -0
- package/src/url-sync.ts +15 -0
- package/src/useParsed.ts +40 -4
- package/src/useStepsForm.svelte.ts +71 -0
- package/src/utility-hooks.svelte.ts +161 -0
- package/src/context.ts +0 -61
package/src/data-transfer.ts
CHANGED
|
@@ -1,108 +1,186 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
1
|
+
import { getDataProviderForResource } from './context.svelte';
|
|
2
|
+
import { useParsed } from './useParsed';
|
|
3
|
+
import type { GetListResult, Sort, Filter, BaseRecord } from './types';
|
|
4
|
+
|
|
5
|
+
export interface UseExportOptions<TData extends BaseRecord = BaseRecord> {
|
|
6
|
+
resource?: string;
|
|
7
|
+
mapData?: (item: TData) => Record<string, unknown>;
|
|
8
|
+
sorters?: Sort[];
|
|
9
|
+
filters?: Filter[];
|
|
10
|
+
maxItemCount?: number;
|
|
11
|
+
pageSize?: number;
|
|
12
|
+
download?: boolean;
|
|
13
|
+
meta?: Record<string, unknown>;
|
|
14
|
+
dataProviderName?: string;
|
|
15
|
+
onError?: (error: Error) => void;
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
|
-
* useExport — export
|
|
19
|
+
* useExport — export records from a resource as CSV download
|
|
20
|
+
* Supports mapData, sorters, filters, maxItemCount, pageSize
|
|
8
21
|
*/
|
|
9
|
-
export function useExport(
|
|
10
|
-
|
|
22
|
+
export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExportOptions<TData> = {}) {
|
|
23
|
+
const parsed = useParsed();
|
|
24
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
25
|
+
let isLoading = $state(false);
|
|
11
26
|
|
|
12
|
-
async function
|
|
13
|
-
const provider =
|
|
14
|
-
|
|
27
|
+
async function triggerExport() {
|
|
28
|
+
const provider = getDataProviderForResource(resource, options.dataProviderName);
|
|
29
|
+
isLoading = true;
|
|
15
30
|
|
|
16
31
|
try {
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
32
|
+
const batchSize = options.pageSize ?? 20;
|
|
33
|
+
const maxItems = options.maxItemCount ?? Infinity;
|
|
34
|
+
let allRecords: TData[] = [];
|
|
35
|
+
let page = 1;
|
|
36
|
+
|
|
37
|
+
while (allRecords.length < maxItems) {
|
|
38
|
+
const result = await provider.getList<TData>({
|
|
39
|
+
resource,
|
|
40
|
+
pagination: { current: page, pageSize: batchSize },
|
|
41
|
+
sorters: options.sorters,
|
|
42
|
+
filters: options.filters,
|
|
43
|
+
meta: options.meta,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
allRecords = [...allRecords, ...result.data];
|
|
47
|
+
if (result.data.length < batchSize || allRecords.length >= result.total) break;
|
|
48
|
+
page++;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (maxItems !== Infinity) allRecords = allRecords.slice(0, maxItems);
|
|
52
|
+
if (allRecords.length === 0) return allRecords;
|
|
53
|
+
|
|
54
|
+
const mapped = options.mapData
|
|
55
|
+
? allRecords.map(options.mapData)
|
|
56
|
+
: allRecords.map(r => r as unknown as Record<string, unknown>);
|
|
57
|
+
|
|
58
|
+
if (options.download !== false) {
|
|
59
|
+
const fields = Object.keys(mapped[0]);
|
|
60
|
+
const header = fields.join(',');
|
|
61
|
+
const rows = mapped.map(record =>
|
|
62
|
+
fields.map(f => {
|
|
63
|
+
const val = record[f];
|
|
64
|
+
const str = val === null || val === undefined ? '' : String(val);
|
|
65
|
+
return str.includes(',') || str.includes('"') || str.includes('\n')
|
|
66
|
+
? `"${str.replace(/"/g, '""')}"`
|
|
67
|
+
: str;
|
|
68
|
+
}).join(',')
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const csv = [header, ...rows].join('\n');
|
|
72
|
+
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
73
|
+
const url = URL.createObjectURL(blob);
|
|
74
|
+
const a = document.createElement('a');
|
|
75
|
+
a.href = url;
|
|
76
|
+
a.download = `${resource}_export.csv`;
|
|
77
|
+
a.click();
|
|
78
|
+
URL.revokeObjectURL(url);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return allRecords;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
options.onError?.(error as Error);
|
|
84
|
+
return [];
|
|
49
85
|
} finally {
|
|
50
|
-
|
|
86
|
+
isLoading = false;
|
|
51
87
|
}
|
|
52
88
|
}
|
|
53
89
|
|
|
54
90
|
return {
|
|
55
|
-
|
|
56
|
-
get
|
|
91
|
+
triggerExport,
|
|
92
|
+
get isLoading() { return isLoading; },
|
|
57
93
|
};
|
|
58
94
|
}
|
|
59
95
|
|
|
96
|
+
export interface UseImportOptions<TData = Record<string, unknown>> {
|
|
97
|
+
resource?: string;
|
|
98
|
+
mapData?: (item: Record<string, unknown>) => TData;
|
|
99
|
+
batchSize?: number;
|
|
100
|
+
onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
|
|
101
|
+
onProgress?: (info: { totalAmount: number; processedAmount: number }) => void;
|
|
102
|
+
meta?: Record<string, unknown>;
|
|
103
|
+
dataProviderName?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
60
106
|
/**
|
|
61
107
|
* useImport — import records from a CSV file
|
|
108
|
+
* Supports mapData, batchSize, onFinish, onProgress, meta
|
|
62
109
|
*/
|
|
63
|
-
export function useImport(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
110
|
+
export function useImport<TData = Record<string, unknown>>(options: UseImportOptions<TData> = {}) {
|
|
111
|
+
const parsed = useParsed();
|
|
112
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
113
|
+
let isLoading = $state(false);
|
|
114
|
+
let mutationResult = $state<{ succeeded: unknown[]; errored: { request: unknown; error: unknown }[] } | null>(null);
|
|
115
|
+
|
|
116
|
+
async function handleChange(info: { file: File }) {
|
|
117
|
+
const provider = getDataProviderForResource(resource, options.dataProviderName);
|
|
118
|
+
isLoading = true;
|
|
119
|
+
const succeeded: unknown[] = [];
|
|
120
|
+
const errored: { request: unknown; error: unknown }[] = [];
|
|
72
121
|
|
|
73
122
|
try {
|
|
74
|
-
const text = await file.text();
|
|
123
|
+
const text = await info.file.text();
|
|
75
124
|
const lines = text.split('\n').filter(l => l.trim() !== '');
|
|
76
|
-
if (lines.length < 2) return
|
|
125
|
+
if (lines.length < 2) return;
|
|
77
126
|
|
|
78
127
|
const headers = parseCSVLine(lines[0]);
|
|
128
|
+
const records: Record<string, unknown>[] = [];
|
|
79
129
|
|
|
80
130
|
for (let i = 1; i < lines.length; i++) {
|
|
81
131
|
const values = parseCSVLine(lines[i]);
|
|
82
132
|
const record: Record<string, unknown> = {};
|
|
83
|
-
headers.forEach((h, idx) => {
|
|
84
|
-
|
|
85
|
-
|
|
133
|
+
headers.forEach((h, idx) => { record[h] = values[idx] ?? ''; });
|
|
134
|
+
records.push(options.mapData ? (options.mapData(record) as unknown as Record<string, unknown>) : record);
|
|
135
|
+
}
|
|
86
136
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
137
|
+
const batchSize = options.batchSize ?? 1;
|
|
138
|
+
let processed = 0;
|
|
139
|
+
|
|
140
|
+
if (batchSize === 1) {
|
|
141
|
+
for (const record of records) {
|
|
142
|
+
try {
|
|
143
|
+
const res = await provider.create({ resource, variables: record, meta: options.meta });
|
|
144
|
+
succeeded.push(res);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
errored.push({ request: record, error });
|
|
147
|
+
}
|
|
148
|
+
processed++;
|
|
149
|
+
options.onProgress?.({ totalAmount: records.length, processedAmount: processed });
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
for (let i = 0; i < records.length; i += batchSize) {
|
|
153
|
+
const batch = records.slice(i, i + batchSize);
|
|
154
|
+
try {
|
|
155
|
+
if (provider.createMany) {
|
|
156
|
+
const res = await provider.createMany({ resource, variables: batch, meta: options.meta });
|
|
157
|
+
succeeded.push(res);
|
|
158
|
+
} else {
|
|
159
|
+
for (const record of batch) {
|
|
160
|
+
const res = await provider.create({ resource, variables: record, meta: options.meta });
|
|
161
|
+
succeeded.push(res);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
errored.push({ request: batch, error });
|
|
166
|
+
}
|
|
167
|
+
processed += batch.length;
|
|
168
|
+
options.onProgress?.({ totalAmount: records.length, processedAmount: processed });
|
|
92
169
|
}
|
|
93
170
|
}
|
|
94
171
|
|
|
95
|
-
|
|
96
|
-
|
|
172
|
+
mutationResult = { succeeded, errored };
|
|
173
|
+
options.onFinish?.({ succeeded, errored });
|
|
97
174
|
} finally {
|
|
98
|
-
|
|
175
|
+
isLoading = false;
|
|
99
176
|
}
|
|
100
177
|
}
|
|
101
178
|
|
|
102
179
|
return {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
get
|
|
180
|
+
inputProps: { type: 'file' as const, accept: '.csv' },
|
|
181
|
+
handleChange,
|
|
182
|
+
get isLoading() { return isLoading; },
|
|
183
|
+
get mutationResult() { return mutationResult; },
|
|
106
184
|
};
|
|
107
185
|
}
|
|
108
186
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/svelte-query';
|
|
2
|
+
import { useParsed } from './useParsed';
|
|
3
|
+
import { getAdminOptions } from './options';
|
|
4
|
+
import { getDataProviderForResource } from './context.svelte';
|
|
5
|
+
import { createQuery, createMutation } from '@tanstack/svelte-query';
|
|
6
|
+
import { toast } from './toast.svelte';
|
|
7
|
+
import { t } from './i18n.svelte';
|
|
8
|
+
import { audit } from './audit';
|
|
9
|
+
import { navigate } from './router';
|
|
10
|
+
import { HttpError } from './types';
|
|
11
|
+
import type { BaseRecord, MutationMode, KnownResources } from './types';
|
|
12
|
+
|
|
13
|
+
export interface UseFormOptions<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> {
|
|
14
|
+
resource?: KnownResources;
|
|
15
|
+
action?: 'create' | 'edit' | 'clone';
|
|
16
|
+
id?: string | number;
|
|
17
|
+
redirect?: 'list' | 'edit' | 'show' | false;
|
|
18
|
+
successNotification?: string | false;
|
|
19
|
+
errorNotification?: string | false;
|
|
20
|
+
dataProviderName?: string;
|
|
21
|
+
onMutationSuccess?: (data: unknown) => void;
|
|
22
|
+
onMutationError?: (error: Error) => void;
|
|
23
|
+
meta?: Record<string, unknown>;
|
|
24
|
+
queryMeta?: Record<string, unknown>;
|
|
25
|
+
mutationMeta?: Record<string, unknown>;
|
|
26
|
+
disableServerSideValidation?: boolean;
|
|
27
|
+
validate?: (values: TVariables) => Record<string, string> | null;
|
|
28
|
+
autoSave?: {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
debounce?: number;
|
|
31
|
+
onFinish?: (values: TVariables) => TVariables;
|
|
32
|
+
invalidateOnUnmount?: boolean;
|
|
33
|
+
invalidates?: string[];
|
|
34
|
+
};
|
|
35
|
+
mutationMode?: MutationMode;
|
|
36
|
+
undoableTimeout?: number;
|
|
37
|
+
invalidates?: string[] | false;
|
|
38
|
+
optimisticUpdateMap?: {
|
|
39
|
+
list?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
|
|
40
|
+
detail?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
|
|
41
|
+
};
|
|
42
|
+
queryOptions?: { staleTime?: number; enabled?: boolean };
|
|
43
|
+
createMutationOptions?: Record<string, unknown>;
|
|
44
|
+
updateMutationOptions?: Record<string, unknown>;
|
|
45
|
+
warnWhenUnsavedChanges?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type UseFormResult<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> = {
|
|
49
|
+
readonly query: unknown;
|
|
50
|
+
readonly mutation: unknown;
|
|
51
|
+
readonly formLoading: boolean;
|
|
52
|
+
readonly errors: Record<string, string>;
|
|
53
|
+
setFieldError: (field: string, message: string) => void;
|
|
54
|
+
clearErrors: () => void;
|
|
55
|
+
clearFieldError: (field: string) => void;
|
|
56
|
+
onFinish: (values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) => Promise<void>;
|
|
57
|
+
onFinishAutoSave: (values: TVariables) => Promise<void>;
|
|
58
|
+
triggerAutoSave: (values: TVariables) => void;
|
|
59
|
+
readonly autoSaveStatus: 'idle' | 'saving' | 'saved' | 'error';
|
|
60
|
+
readonly resource: string;
|
|
61
|
+
readonly action: 'create' | 'edit' | 'clone';
|
|
62
|
+
readonly id: string | number | undefined;
|
|
63
|
+
setId: (newId: string | number) => void;
|
|
64
|
+
readonly mutationMode: MutationMode;
|
|
65
|
+
redirect: (to: 'list' | 'edit' | 'show' | false) => void;
|
|
66
|
+
readonly autoSaveProps: { status: 'idle' | 'loading' | 'success' | 'error'; data: unknown; error: unknown };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function useForm<
|
|
70
|
+
TQueryFnData extends BaseRecord = BaseRecord,
|
|
71
|
+
TError = HttpError,
|
|
72
|
+
TVariables = Record<string, unknown>,
|
|
73
|
+
TData extends BaseRecord = TQueryFnData
|
|
74
|
+
>(options: UseFormOptions<TQueryFnData, TError, TVariables, TData> = {}): UseFormResult<TQueryFnData, TError, TVariables, TData> {
|
|
75
|
+
const queryClient = useQueryClient();
|
|
76
|
+
const parsed = useParsed();
|
|
77
|
+
const adminOptions = getAdminOptions();
|
|
78
|
+
|
|
79
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
80
|
+
const action = options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create';
|
|
81
|
+
let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
|
|
82
|
+
|
|
83
|
+
const {
|
|
84
|
+
redirect: redirectDefault = 'list',
|
|
85
|
+
successNotification, errorNotification,
|
|
86
|
+
onMutationSuccess, onMutationError,
|
|
87
|
+
meta: hookMeta, queryMeta: hookQueryMeta, mutationMeta: hookMutationMeta,
|
|
88
|
+
disableServerSideValidation, validate, autoSave,
|
|
89
|
+
mutationMode = adminOptions.mutationMode ?? 'pessimistic',
|
|
90
|
+
undoableTimeout = adminOptions.undoableTimeout ?? 5000,
|
|
91
|
+
dataProviderName, invalidates: invalidateScopes,
|
|
92
|
+
optimisticUpdateMap,
|
|
93
|
+
queryOptions,
|
|
94
|
+
} = options;
|
|
95
|
+
|
|
96
|
+
const provider = getDataProviderForResource(resource, dataProviderName);
|
|
97
|
+
const parsedMeta = typeof window !== 'undefined' ? Object.fromEntries(new URLSearchParams(window.location.search).entries()) : {};
|
|
98
|
+
const queryMeta = { ...parsedMeta, ...hookMeta, ...hookQueryMeta };
|
|
99
|
+
const mutationMeta = { ...parsedMeta, ...hookMeta, ...hookMutationMeta };
|
|
100
|
+
|
|
101
|
+
function setId(newId: string | number) { currentId = newId; }
|
|
102
|
+
|
|
103
|
+
let errors = $state<Record<string, string>>({});
|
|
104
|
+
function setFieldError(field: string, message: string) { errors = { ...errors, [field]: message }; }
|
|
105
|
+
function clearErrors() { errors = {}; }
|
|
106
|
+
function clearFieldError(field: string) { delete errors[field]; }
|
|
107
|
+
|
|
108
|
+
function runValidation(values: TVariables): boolean {
|
|
109
|
+
clearErrors();
|
|
110
|
+
if (validate) {
|
|
111
|
+
const result = validate(values);
|
|
112
|
+
if (result && Object.keys(result).length > 0) {
|
|
113
|
+
errors = result;
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleHttpError(error: Error) {
|
|
121
|
+
if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
|
|
122
|
+
for (const [field, messages] of Object.entries(error.errors)) {
|
|
123
|
+
const msg = Array.isArray(messages) ? messages[0] : messages;
|
|
124
|
+
setFieldError(field, msg);
|
|
125
|
+
}
|
|
126
|
+
if (errorNotification !== false) toast.error(errorNotification || error.message || t('common.operationFailed'));
|
|
127
|
+
} else {
|
|
128
|
+
if (errorNotification !== false) toast.error(errorNotification || t('common.operationFailed') + ': ' + error.message);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const query = (action === 'edit' || action === 'clone') && currentId != null
|
|
133
|
+
? createQuery(() => ({
|
|
134
|
+
queryKey: [resource, 'one', currentId],
|
|
135
|
+
queryFn: async () => {
|
|
136
|
+
const result = await provider.getOne<TQueryFnData>({ resource, id: currentId!, meta: queryMeta });
|
|
137
|
+
return result.data;
|
|
138
|
+
},
|
|
139
|
+
enabled: (queryOptions?.enabled ?? true) && currentId != null,
|
|
140
|
+
staleTime: queryOptions?.staleTime,
|
|
141
|
+
}))
|
|
142
|
+
: null;
|
|
143
|
+
|
|
144
|
+
const createMut = createMutation(() => ({
|
|
145
|
+
...options.createMutationOptions,
|
|
146
|
+
mutationFn: (variables: TVariables) => provider.create<TData, TVariables>({ resource, variables, meta: mutationMeta }),
|
|
147
|
+
onSuccess: (data: { data: TData }) => {
|
|
148
|
+
if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
|
|
149
|
+
if (successNotification !== false) toast.success(successNotification || t('common.createSuccess'));
|
|
150
|
+
audit({ action: 'create', resource, recordId: String(data.data.id) });
|
|
151
|
+
onMutationSuccess?.(data);
|
|
152
|
+
if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
|
|
153
|
+
},
|
|
154
|
+
onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const updateMut = createMutation(() => ({
|
|
158
|
+
...options.updateMutationOptions,
|
|
159
|
+
mutationFn: (variables: TVariables) => provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta }),
|
|
160
|
+
onSuccess: (data: { data: TData }) => {
|
|
161
|
+
if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
|
|
162
|
+
if (successNotification !== false) toast.success(successNotification || t('common.updateSuccess'));
|
|
163
|
+
audit({ action: 'update', resource, recordId: String(currentId) });
|
|
164
|
+
onMutationSuccess?.(data);
|
|
165
|
+
if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
|
|
166
|
+
},
|
|
167
|
+
onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
let redirectOverride: 'list' | 'edit' | 'show' | false | undefined;
|
|
171
|
+
|
|
172
|
+
function doRedirect(to: 'list' | 'edit' | 'show' | false) {
|
|
173
|
+
if (to === 'list') navigate(`/${resource}`);
|
|
174
|
+
else if (to === 'edit' && currentId) navigate(`/${resource}/edit/${currentId}`);
|
|
175
|
+
else if (to === 'show' && currentId) navigate(`/${resource}/show/${currentId}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function onFinish(values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) {
|
|
179
|
+
if (!runValidation(values)) { toast.warning(t('validation.required')); return; }
|
|
180
|
+
redirectOverride = finishOptions?.redirect;
|
|
181
|
+
if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
|
|
182
|
+
else await updateMut.mutateAsync(values);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function onFinishAutoSave(values: TVariables) {
|
|
186
|
+
if (action === 'create' || action === 'clone') return; // Only autosave on edit
|
|
187
|
+
redirectOverride = false; // Never redirect on autoSave
|
|
188
|
+
await updateMut.mutateAsync(values);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
192
|
+
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
193
|
+
|
|
194
|
+
function triggerAutoSave(values: TVariables) {
|
|
195
|
+
if (!autoSave?.enabled || action === 'create') return;
|
|
196
|
+
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
197
|
+
|
|
198
|
+
autoSaveTimer = setTimeout(async () => {
|
|
199
|
+
const finalValues = autoSave.onFinish ? autoSave.onFinish(values) : values;
|
|
200
|
+
autoSaveStatus = 'saving';
|
|
201
|
+
try {
|
|
202
|
+
await provider.update<TData, TVariables>({ resource, id: currentId!, variables: finalValues, meta: mutationMeta });
|
|
203
|
+
const scopes = autoSave.invalidates ?? ['resourceAll'];
|
|
204
|
+
for (const scope of scopes) {
|
|
205
|
+
if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [resource] });
|
|
206
|
+
else if (scope === 'detail' && currentId) queryClient.invalidateQueries({ queryKey: [resource, 'one', currentId] });
|
|
207
|
+
else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [resource, 'list'] });
|
|
208
|
+
}
|
|
209
|
+
autoSaveStatus = 'saved';
|
|
210
|
+
lastAutoSaveData = undefined; // placeholder for returned data
|
|
211
|
+
lastAutoSaveError = null;
|
|
212
|
+
setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
autoSaveStatus = 'error';
|
|
215
|
+
lastAutoSaveError = e;
|
|
216
|
+
}
|
|
217
|
+
}, autoSave.debounce ?? 1000);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let lastAutoSaveData = $state<unknown>(null);
|
|
221
|
+
let lastAutoSaveError = $state<unknown>(null);
|
|
222
|
+
|
|
223
|
+
if (autoSave?.invalidateOnUnmount) {
|
|
224
|
+
$effect(() => {
|
|
225
|
+
return () => queryClient.invalidateQueries({ queryKey: [resource] });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// warnWhenUnsavedChanges
|
|
230
|
+
const shouldWarn = options.warnWhenUnsavedChanges ?? adminOptions.warnWhenUnsavedChanges;
|
|
231
|
+
if (shouldWarn && typeof window !== 'undefined') {
|
|
232
|
+
$effect(() => {
|
|
233
|
+
const handler = (e: BeforeUnloadEvent) => {
|
|
234
|
+
// Here we'd typically check a dirty state, but useForm itself doesn't track dirty fields.
|
|
235
|
+
// Usually the form library (svelte-forms etc) handles dirty state.
|
|
236
|
+
// Assuming we always warn if enabled for now.
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
e.returnValue = '';
|
|
239
|
+
};
|
|
240
|
+
window.addEventListener('beforeunload', handler);
|
|
241
|
+
return () => window.removeEventListener('beforeunload', handler);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
query,
|
|
247
|
+
get formLoading() { return (query?.isLoading ?? false) || createMut.isPending || updateMut.isPending; },
|
|
248
|
+
mutation: action === 'edit' ? updateMut : createMut,
|
|
249
|
+
onFinish,
|
|
250
|
+
onFinishAutoSave,
|
|
251
|
+
get errors() { return errors; },
|
|
252
|
+
setFieldError, clearErrors, clearFieldError,
|
|
253
|
+
triggerAutoSave,
|
|
254
|
+
get autoSaveStatus() { return autoSaveStatus; },
|
|
255
|
+
resource, action,
|
|
256
|
+
get id() { return currentId; },
|
|
257
|
+
setId, mutationMode, redirect: doRedirect,
|
|
258
|
+
get autoSaveProps() {
|
|
259
|
+
return {
|
|
260
|
+
status: autoSaveStatus as 'idle' | 'loading' | 'success' | 'error',
|
|
261
|
+
data: lastAutoSaveData,
|
|
262
|
+
error: lastAutoSaveError,
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Refine v5 compatible utility helpers
|
|
2
|
+
|
|
3
|
+
import type { Filter, Sort, CrudOperator } from './types';
|
|
4
|
+
import { t } from './i18n.svelte';
|
|
5
|
+
import { getTextTransformers } from './options';
|
|
6
|
+
|
|
7
|
+
// ─── Table Helpers ────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the current filter value for a field from a filter array.
|
|
11
|
+
* Useful for setting default values in filter inputs.
|
|
12
|
+
*/
|
|
13
|
+
export function getDefaultFilter(
|
|
14
|
+
columnName: string,
|
|
15
|
+
filters?: Filter[],
|
|
16
|
+
operatorType: CrudOperator = 'eq',
|
|
17
|
+
): unknown | undefined {
|
|
18
|
+
if (!filters) return undefined;
|
|
19
|
+
const filter = filters.find((f) => {
|
|
20
|
+
if ('field' in f) {
|
|
21
|
+
return f.field === columnName && f.operator === operatorType;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
});
|
|
25
|
+
return filter && 'value' in filter ? filter.value : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the current sort order for a field from a sorter array.
|
|
30
|
+
* Returns 'asc', 'desc', or undefined.
|
|
31
|
+
*/
|
|
32
|
+
export function getDefaultSortOrder(
|
|
33
|
+
columnName: string,
|
|
34
|
+
sorters?: Sort[],
|
|
35
|
+
): 'asc' | 'desc' | undefined {
|
|
36
|
+
if (!sorters) return undefined;
|
|
37
|
+
const sortItem = sorters.find((item) => item.field === columnName);
|
|
38
|
+
return sortItem?.order as 'asc' | 'desc' | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Merge two filter arrays, deduplicating by field+operator.
|
|
43
|
+
* Filters in `newFilters` take priority over `prevFilters`.
|
|
44
|
+
* `permanentFilter` items are always preserved.
|
|
45
|
+
* Filters with undefined values are removed from the result.
|
|
46
|
+
*/
|
|
47
|
+
export function unionFilters(
|
|
48
|
+
permanentFilter: Filter[],
|
|
49
|
+
newFilters: Filter[],
|
|
50
|
+
prevFilters: Filter[] = [],
|
|
51
|
+
): Filter[] {
|
|
52
|
+
const compareFilters = (a: Filter, b: Filter): boolean => {
|
|
53
|
+
if ('field' in a && 'field' in b) {
|
|
54
|
+
return a.field === b.field && a.operator === b.operator;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Start with previous filters
|
|
60
|
+
let result = [...prevFilters];
|
|
61
|
+
|
|
62
|
+
// Apply new filters (override previous by field+operator)
|
|
63
|
+
for (const newFilter of newFilters) {
|
|
64
|
+
const idx = result.findIndex((f) => compareFilters(f, newFilter));
|
|
65
|
+
if (idx >= 0) {
|
|
66
|
+
result[idx] = newFilter;
|
|
67
|
+
} else {
|
|
68
|
+
result.push(newFilter);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Ensure permanent filters are always present
|
|
73
|
+
for (const perm of permanentFilter) {
|
|
74
|
+
const idx = result.findIndex((f) => compareFilters(f, perm));
|
|
75
|
+
if (idx >= 0) {
|
|
76
|
+
result[idx] = perm;
|
|
77
|
+
} else {
|
|
78
|
+
result.push(perm);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Remove filters with undefined values
|
|
83
|
+
return result.filter((f) => !('value' in f && f.value === undefined));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Merge two sorter arrays, deduplicating by field.
|
|
88
|
+
* Sorters in `newSorters` take priority.
|
|
89
|
+
*/
|
|
90
|
+
export function unionSorters(
|
|
91
|
+
permanentSorters: Sort[],
|
|
92
|
+
newSorters: Sort[],
|
|
93
|
+
): Sort[] {
|
|
94
|
+
const result = [...newSorters];
|
|
95
|
+
|
|
96
|
+
for (const perm of permanentSorters) {
|
|
97
|
+
if (!result.some((s) => s.field === perm.field)) {
|
|
98
|
+
result.push(perm);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Upload Helpers ───────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convert a File (or file-like object) to a base64 data URL string.
|
|
109
|
+
* Useful for image uploads and previews.
|
|
110
|
+
*/
|
|
111
|
+
export function file2Base64(file: File | { uid?: string } & Blob): Promise<string> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const reader = new FileReader();
|
|
114
|
+
reader.readAsDataURL(file as Blob);
|
|
115
|
+
reader.addEventListener('load', () => {
|
|
116
|
+
resolve(reader.result as string);
|
|
117
|
+
});
|
|
118
|
+
reader.addEventListener('error', () => {
|
|
119
|
+
reject(reader.error);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Document Title Helper ────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generate a default document title for a resource page.
|
|
128
|
+
* e.g., "Edit Post #5 | Admin" or "Posts | Admin"
|
|
129
|
+
*/
|
|
130
|
+
export function generateDefaultDocumentTitle(options?: {
|
|
131
|
+
resource?: string;
|
|
132
|
+
action?: string;
|
|
133
|
+
id?: string | number;
|
|
134
|
+
suffix?: string;
|
|
135
|
+
}): string {
|
|
136
|
+
const { resource, action, id, suffix = ' | Admin' } = options ?? {};
|
|
137
|
+
const transformers = getTextTransformers();
|
|
138
|
+
|
|
139
|
+
if (!resource) {
|
|
140
|
+
return t('documentTitle.default', {}) || `Admin${suffix}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const resourceLabel = transformers.humanize
|
|
144
|
+
? transformers.humanize(resource)
|
|
145
|
+
: resource.charAt(0).toUpperCase() + resource.slice(1);
|
|
146
|
+
|
|
147
|
+
const actionPrefixes: Record<string, string> = {
|
|
148
|
+
create: `${t('common.create', {}) || 'Create'} `,
|
|
149
|
+
clone: `#${id ?? ''} ${t('common.clone', {}) || 'Clone'} `,
|
|
150
|
+
edit: `#${id ?? ''} ${t('common.edit', {}) || 'Edit'} `,
|
|
151
|
+
show: `#${id ?? ''} ${t('common.detail', {}) || 'Show'} `,
|
|
152
|
+
list: '',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const prefix = action ? (actionPrefixes[action] ?? '') : '';
|
|
156
|
+
return `${prefix}${resourceLabel}${suffix}`;
|
|
157
|
+
}
|