@svadmin/core 0.0.3 → 0.3.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 +4 -1
- package/src/audit.ts +37 -0
- package/src/auth-hooks.svelte.ts +293 -0
- package/src/auth-hooks.test.ts +205 -0
- package/src/context.ts +77 -15
- package/src/data-transfer.test.ts +118 -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 +278 -496
- package/src/http-error.test.ts +85 -0
- package/src/i18n.svelte.ts +24 -4
- package/src/i18n.test.ts +90 -0
- package/src/index.ts +51 -8
- package/src/inferencer-openapi.test.ts +192 -0
- package/src/inferencer-openapi.ts +290 -0
- package/src/inferencer.test.ts +189 -0
- package/src/inferencer.ts +247 -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/permissions.test.ts +55 -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-provider.test.ts +71 -0
- package/src/router-provider.ts +100 -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/toast.svelte.ts +8 -4
- package/src/types.ts +124 -47
- package/src/unsaved-changes.svelte.ts +62 -0
- package/src/url-sync.ts +15 -0
- package/src/useParsed.ts +110 -0
- package/src/useStepsForm.svelte.ts +71 -0
- package/src/utility-hooks.svelte.ts +161 -0
|
@@ -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';
|
|
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) { const next = { ...errors }; delete next[field]; errors = next; }
|
|
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 as any).mutateAsync(values);
|
|
182
|
+
else await (updateMut as any).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 as any).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 as any)?.isLoading ?? false) || (createMut as any).isPending || (updateMut as any).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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Hook Utilities — shared reactive helpers for all hooks
|
|
2
|
+
// Uses Svelte 5 runes ($state, $effect) for automatic lifecycle management
|
|
3
|
+
|
|
4
|
+
import { useQueryClient } from '@tanstack/svelte-query';
|
|
5
|
+
import type { LiveProvider, LiveEvent, LiveMode } from './live';
|
|
6
|
+
import { toast } from './toast.svelte';
|
|
7
|
+
|
|
8
|
+
// ─── Overtime Tracker ───────────────────────────────────────────
|
|
9
|
+
// Tracks elapsed time during loading, auto-cleans via $effect
|
|
10
|
+
|
|
11
|
+
export interface OvertimeOptions {
|
|
12
|
+
interval?: number; // ms, default 1000
|
|
13
|
+
onInterval?: (elapsedTime: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OvertimeResult {
|
|
17
|
+
readonly elapsedTime: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createOvertimeTracker(
|
|
21
|
+
isLoadingFn: () => boolean,
|
|
22
|
+
options: OvertimeOptions = {},
|
|
23
|
+
): OvertimeResult {
|
|
24
|
+
const { interval = 1000, onInterval } = options;
|
|
25
|
+
let elapsedTime = $state(0);
|
|
26
|
+
|
|
27
|
+
$effect(() => {
|
|
28
|
+
if (isLoadingFn()) {
|
|
29
|
+
elapsedTime = 0;
|
|
30
|
+
const timer = setInterval(() => {
|
|
31
|
+
elapsedTime += interval;
|
|
32
|
+
onInterval?.(elapsedTime);
|
|
33
|
+
}, interval);
|
|
34
|
+
return () => clearInterval(timer);
|
|
35
|
+
} else {
|
|
36
|
+
elapsedTime = 0;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
get elapsedTime() { return elapsedTime; },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Live Subscription ──────────────────────────────────────────
|
|
46
|
+
// Auto-subscribes to realtime events, invalidates queries on 'auto' mode
|
|
47
|
+
|
|
48
|
+
export interface LiveSubscriptionParams {
|
|
49
|
+
resource: string;
|
|
50
|
+
liveProvider?: LiveProvider;
|
|
51
|
+
liveMode?: LiveMode;
|
|
52
|
+
onLiveEvent?: (event: LiveEvent) => void;
|
|
53
|
+
liveParams?: Record<string, unknown>;
|
|
54
|
+
enabled?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createLiveSubscription(params: LiveSubscriptionParams): void {
|
|
58
|
+
const {
|
|
59
|
+
resource,
|
|
60
|
+
liveProvider,
|
|
61
|
+
liveMode = 'off',
|
|
62
|
+
onLiveEvent,
|
|
63
|
+
enabled = true,
|
|
64
|
+
} = params;
|
|
65
|
+
|
|
66
|
+
if (!liveProvider || liveMode === 'off' || !enabled) return;
|
|
67
|
+
|
|
68
|
+
const queryClient = useQueryClient();
|
|
69
|
+
|
|
70
|
+
$effect(() => {
|
|
71
|
+
const unsubscribe = liveProvider.subscribe({
|
|
72
|
+
resource,
|
|
73
|
+
callback: (event: LiveEvent) => {
|
|
74
|
+
onLiveEvent?.(event);
|
|
75
|
+
if (liveMode === 'auto') {
|
|
76
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return unsubscribe;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Notification Helpers ───────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export type NotificationConfig =
|
|
87
|
+
| string
|
|
88
|
+
| false
|
|
89
|
+
| ((data?: unknown, values?: unknown, resource?: string) => { message: string; description?: string; type?: 'success' | 'error' })
|
|
90
|
+
| undefined;
|
|
91
|
+
|
|
92
|
+
export function fireSuccessNotification(
|
|
93
|
+
config: NotificationConfig,
|
|
94
|
+
defaultMessage: string,
|
|
95
|
+
data?: unknown,
|
|
96
|
+
values?: unknown,
|
|
97
|
+
resource?: string,
|
|
98
|
+
): void {
|
|
99
|
+
if (config === false) return;
|
|
100
|
+
if (typeof config === 'function') {
|
|
101
|
+
const result = config(data, values, resource);
|
|
102
|
+
toast.success(result.message);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
toast.success(config || defaultMessage);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function fireErrorNotification(
|
|
109
|
+
config: NotificationConfig,
|
|
110
|
+
defaultMessage: string,
|
|
111
|
+
error?: unknown,
|
|
112
|
+
): void {
|
|
113
|
+
if (config === false) return;
|
|
114
|
+
if (typeof config === 'function') {
|
|
115
|
+
const result = config(error);
|
|
116
|
+
toast.error(result.message);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const errMsg = error instanceof Error ? error.message : String(error ?? '');
|
|
120
|
+
toast.error(config || `${defaultMessage}${errMsg ? ': ' + errMsg : ''}`);
|
|
121
|
+
}
|