@svadmin/core 0.22.0 → 0.23.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 +1 -1
- package/src/audit.ts +5 -0
- package/src/auth-hooks.svelte.ts +44 -34
- package/src/chatProvider.svelte.ts +8 -21
- package/src/context.svelte.ts +22 -2
- package/src/data-transfer.svelte.ts +66 -34
- package/src/form-hooks.svelte.ts +110 -42
- 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 +120 -18
- package/src/i18n.svelte.ts +41 -17
- package/src/index.ts +10 -3
- package/src/live-websocket.ts +20 -2
- 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 +182 -100
- package/src/router-provider.ts +3 -1
- package/src/router.ts +23 -2
- package/src/routing-hooks.svelte.ts +42 -17
- package/src/table-hooks.svelte.ts +87 -34
- package/src/table-state.svelte.ts +2 -1
- package/src/theme.svelte.ts +27 -25
- package/src/toast.svelte.ts +22 -11
- package/src/types.ts +3 -1
- package/src/url-sync.ts +28 -16
- package/src/useCan.ts +6 -2
- package/src/useParsed.svelte.ts +4 -0
- package/src/useStepsForm.svelte.ts +31 -21
- package/src/utility-hooks.svelte.ts +14 -4
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useParsed } from './useParsed.svelte';
|
|
2
2
|
import { useList } from './query-hooks.svelte';
|
|
3
3
|
import { readURLState, writeURLState } from './url-sync';
|
|
4
|
+
import { getAdminOptions } from './options.svelte';
|
|
4
5
|
import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources, GetListResult } from './types';
|
|
5
|
-
import type { UseListOptions } from './query-hooks.svelte';
|
|
6
|
+
import type { UseListOptions, MaybeGetter } from './query-hooks.svelte';
|
|
6
7
|
|
|
7
8
|
export type FilterSetMode = 'merge' | 'replace';
|
|
8
9
|
|
|
@@ -27,22 +28,26 @@ export function useTable<
|
|
|
27
28
|
TQueryFnData extends BaseRecord = BaseRecord,
|
|
28
29
|
TError = HttpError,
|
|
29
30
|
TSearchVariables = Record<string, unknown>
|
|
30
|
-
>(
|
|
31
|
+
>(optionsOrGetter: MaybeGetter<UseTableOptions<TQueryFnData, TError, TSearchVariables>> = {}) {
|
|
32
|
+
const getOptions = () => typeof optionsOrGetter === 'function' ? optionsOrGetter() : optionsOrGetter;
|
|
31
33
|
const parsed = useParsed();
|
|
32
|
-
const resource = options.resource ?? parsed.resource ?? '';
|
|
33
|
-
const { meta, syncWithLocation = false, dataProviderName, pagination: _p, sorters: _s, filters: _f, ...restOptions } = options;
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
35
|
+
// Use a local derived to compute these since they are used to initialize state
|
|
36
|
+
// Even if options change, init values are only used once, which makes sense for initial state
|
|
37
|
+
const initialOpts = getOptions();
|
|
38
|
+
const syncWithLocation = initialOpts.syncWithLocation ?? getAdminOptions().syncWithLocation;
|
|
39
|
+
|
|
40
|
+
const paginationMode = $derived(getOptions().pagination?.mode ?? 'server');
|
|
41
|
+
const sortersMode = $derived(getOptions().sorters?.mode ?? 'server');
|
|
42
|
+
const filtersMode = $derived(getOptions().filters?.mode ?? 'server');
|
|
43
|
+
const filterDefaultBehavior = $derived(getOptions().filters?.defaultBehavior ?? 'replace');
|
|
39
44
|
|
|
40
|
-
const permanentSorters =
|
|
41
|
-
const permanentFilters =
|
|
45
|
+
const permanentSorters = $derived(getOptions().sorters?.permanent ?? []);
|
|
46
|
+
const permanentFilters = $derived(getOptions().filters?.permanent ?? []);
|
|
42
47
|
|
|
43
|
-
let initPagination =
|
|
44
|
-
let initSorters =
|
|
45
|
-
let initFilters =
|
|
48
|
+
let initPagination = initialOpts.pagination ?? { current: 1, pageSize: 10 };
|
|
49
|
+
let initSorters = initialOpts.sorters?.initial ?? [];
|
|
50
|
+
let initFilters = initialOpts.filters?.initial ?? [];
|
|
46
51
|
|
|
47
52
|
if (syncWithLocation && typeof window !== 'undefined') {
|
|
48
53
|
const urlState = readURLState();
|
|
@@ -73,26 +78,31 @@ export function useTable<
|
|
|
73
78
|
? pagination
|
|
74
79
|
: { current: 1, pageSize: 999999, mode: paginationMode as 'client' | 'off' });
|
|
75
80
|
|
|
76
|
-
const tableQueryInfo = useList<TQueryFnData, TError>({
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
const tableQueryInfo = useList<TQueryFnData, TError>(() => {
|
|
82
|
+
const currentOpts = getOptions();
|
|
83
|
+
const { meta, syncWithLocation, dataProviderName, pagination: _p, sorters: _s, filters: _f, ...restOptions } = currentOpts;
|
|
84
|
+
return {
|
|
85
|
+
resource: currentOpts.resource ?? parsed.resource ?? '',
|
|
86
|
+
pagination: queryPagination,
|
|
87
|
+
sorters: querySorters,
|
|
88
|
+
filters: queryFilters,
|
|
89
|
+
meta,
|
|
90
|
+
dataProviderName,
|
|
91
|
+
...restOptions
|
|
92
|
+
};
|
|
84
93
|
});
|
|
85
94
|
|
|
86
95
|
const query = tableQueryInfo;
|
|
87
96
|
const overtime = tableQueryInfo.overtime;
|
|
88
97
|
|
|
89
|
-
function setSorters(newSorters: Sort[]) { currentSorters = newSorters; }
|
|
98
|
+
function setSorters(newSorters: Sort[]) { currentSorters = newSorters; pagination = { ...pagination, current: 1 }; }
|
|
90
99
|
function setFilters(newFilters: Filter[], mode?: FilterSetMode) {
|
|
91
100
|
const behavior = mode ?? filterDefaultBehavior;
|
|
92
101
|
if (behavior === 'merge') {
|
|
93
102
|
const merged = [...currentFilters];
|
|
94
103
|
for (const nf of newFilters) {
|
|
95
|
-
|
|
104
|
+
if (!('field' in nf)) { merged.push(nf); continue; }
|
|
105
|
+
const idx = merged.findIndex(f => 'field' in f && f.field === nf.field && f.operator === nf.operator);
|
|
96
106
|
if (idx >= 0) {
|
|
97
107
|
if (nf.value === undefined || nf.value === null || nf.value === '') merged.splice(idx, 1);
|
|
98
108
|
else merged[idx] = nf;
|
|
@@ -120,6 +130,39 @@ export function useTable<
|
|
|
120
130
|
filters: currentFilters,
|
|
121
131
|
});
|
|
122
132
|
});
|
|
133
|
+
|
|
134
|
+
$effect(() => {
|
|
135
|
+
const handler = () => {
|
|
136
|
+
const urlState = readURLState();
|
|
137
|
+
const newPage = urlState.page ?? 1;
|
|
138
|
+
if (newPage !== pagination.current) {
|
|
139
|
+
pagination = { ...pagination, current: newPage };
|
|
140
|
+
}
|
|
141
|
+
const newPageSize = urlState.pageSize ?? initialOpts.pagination?.pageSize ?? getAdminOptions().defaultPageSize ?? 10;
|
|
142
|
+
if (newPageSize !== pagination.pageSize) {
|
|
143
|
+
pagination = { ...pagination, pageSize: newPageSize };
|
|
144
|
+
}
|
|
145
|
+
if (urlState.sortField) {
|
|
146
|
+
const newSorter: Sort = { field: urlState.sortField, order: (urlState.sortOrder as 'asc' | 'desc') ?? 'asc' };
|
|
147
|
+
if (JSON.stringify([newSorter]) !== JSON.stringify(currentSorters)) {
|
|
148
|
+
currentSorters = [newSorter];
|
|
149
|
+
}
|
|
150
|
+
} else if (currentSorters.length > 0) {
|
|
151
|
+
currentSorters = [];
|
|
152
|
+
}
|
|
153
|
+
if (urlState.filters && JSON.stringify(urlState.filters) !== JSON.stringify(currentFilters)) {
|
|
154
|
+
currentFilters = urlState.filters;
|
|
155
|
+
} else if (!urlState.filters && currentFilters.length > 0) {
|
|
156
|
+
currentFilters = [];
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
window.addEventListener('popstate', handler);
|
|
160
|
+
window.addEventListener('hashchange', handler);
|
|
161
|
+
return () => {
|
|
162
|
+
window.removeEventListener('popstate', handler);
|
|
163
|
+
window.removeEventListener('hashchange', handler);
|
|
164
|
+
};
|
|
165
|
+
});
|
|
123
166
|
}
|
|
124
167
|
|
|
125
168
|
function createLinkForSyncWithLocation(): string {
|
|
@@ -153,23 +196,33 @@ export function useTable<
|
|
|
153
196
|
get pageSize() { return pagination.pageSize ?? 10; },
|
|
154
197
|
get pageCount() { return Math.ceil(((query.data as GetListResult<TQueryFnData> | undefined)?.total ?? 0) / (pagination.pageSize ?? 10)); },
|
|
155
198
|
get clientData(): TQueryFnData[] {
|
|
156
|
-
if (paginationMode
|
|
199
|
+
if (paginationMode === 'server') return [];
|
|
157
200
|
const allData = ((query.data as GetListResult<TQueryFnData> | undefined)?.data ?? []) as TQueryFnData[];
|
|
158
|
-
const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
|
|
159
|
-
const end = start + (pagination.pageSize ?? 10);
|
|
160
201
|
let sorted = [...allData];
|
|
161
|
-
const activeSorters = sortersMode === 'off' ? effectiveSorters :
|
|
202
|
+
const activeSorters = sortersMode === 'off' ? effectiveSorters : [];
|
|
162
203
|
if (activeSorters.length > 0) {
|
|
163
|
-
const { field, order } = activeSorters[0];
|
|
164
204
|
sorted.sort((a, b) => {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
205
|
+
for (const { field, order } of activeSorters) {
|
|
206
|
+
const va = (a as Record<string, unknown>)[field];
|
|
207
|
+
const vb = (b as Record<string, unknown>)[field];
|
|
208
|
+
if (va == null && vb != null) return 1;
|
|
209
|
+
if (vb == null && va != null) return -1;
|
|
210
|
+
if (va == null && vb == null) continue;
|
|
211
|
+
|
|
212
|
+
let cmp = 0;
|
|
213
|
+
if (typeof va === 'number' && typeof vb === 'number') cmp = va - vb;
|
|
214
|
+
else if (va instanceof Date && vb instanceof Date) cmp = va.getTime() - vb.getTime();
|
|
215
|
+
else if (typeof va === 'string' && typeof vb === 'string' && !isNaN(Number(va)) && !isNaN(Number(vb))) cmp = Number(va) - Number(vb);
|
|
216
|
+
else cmp = String(va).localeCompare(String(vb));
|
|
217
|
+
|
|
218
|
+
if (cmp !== 0) return order === 'desc' ? -cmp : cmp;
|
|
219
|
+
}
|
|
220
|
+
return 0;
|
|
171
221
|
});
|
|
172
222
|
}
|
|
223
|
+
if (paginationMode === 'off') return sorted;
|
|
224
|
+
const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
|
|
225
|
+
const end = start + (pagination.pageSize ?? 10);
|
|
173
226
|
return sorted.slice(start, end);
|
|
174
227
|
},
|
|
175
228
|
};
|
|
@@ -200,7 +200,8 @@ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpErro
|
|
|
200
200
|
if (behavior === 'merge') {
|
|
201
201
|
const merged = [...this.currentFilters];
|
|
202
202
|
for (const nf of newFilters) {
|
|
203
|
-
|
|
203
|
+
if (!('field' in nf)) { merged.push(nf); continue; }
|
|
204
|
+
const idx = merged.findIndex(f => 'field' in f && f.field === nf.field && f.operator === nf.operator);
|
|
204
205
|
if (idx >= 0) {
|
|
205
206
|
if (nf.value === undefined || nf.value === null || nf.value === '') merged.splice(idx, 1);
|
|
206
207
|
else merged[idx] = nf;
|
package/src/theme.svelte.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// - 'dark-first': adds 'light' class for light mode (dark-first)
|
|
6
6
|
|
|
7
7
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
8
|
-
export type ColorTheme = 'blue' | 'green' | 'rose' | 'orange' | 'violet' | '
|
|
8
|
+
export type ColorTheme = 'blue' | 'green' | 'rose' | 'orange' | 'violet' | 'neutral';
|
|
9
9
|
|
|
10
10
|
/** Controls how the theme class is applied to <html> */
|
|
11
11
|
export type ThemeStrategy = 'standard' | 'dark-first';
|
|
@@ -134,13 +134,13 @@ export const builtinPresets: Record<string, ColorPreset> = $state({
|
|
|
134
134
|
'--chart-1': 'oklch(0.577 0.245 27.325)',
|
|
135
135
|
},
|
|
136
136
|
dark: {
|
|
137
|
-
'--primary': 'oklch(0.
|
|
138
|
-
'--primary-foreground': 'oklch(0.
|
|
139
|
-
'--ring': 'oklch(0.
|
|
140
|
-
'--sidebar-primary': 'oklch(0.
|
|
137
|
+
'--primary': 'oklch(0.685 0.22 27)',
|
|
138
|
+
'--primary-foreground': 'oklch(0.15 0.04 27)',
|
|
139
|
+
'--ring': 'oklch(0.55 0.18 27)',
|
|
140
|
+
'--sidebar-primary': 'oklch(0.685 0.22 27)',
|
|
141
141
|
'--sidebar-primary-foreground': 'oklch(0.985 0 0)',
|
|
142
|
-
'--sidebar-ring': 'oklch(0.
|
|
143
|
-
'--chart-1': 'oklch(0.
|
|
142
|
+
'--sidebar-ring': 'oklch(0.55 0.18 27)',
|
|
143
|
+
'--chart-1': 'oklch(0.685 0.22 27)',
|
|
144
144
|
},
|
|
145
145
|
},
|
|
146
146
|
orange: {
|
|
@@ -157,13 +157,13 @@ export const builtinPresets: Record<string, ColorPreset> = $state({
|
|
|
157
157
|
'--chart-1': 'oklch(0.646 0.222 41.116)',
|
|
158
158
|
},
|
|
159
159
|
dark: {
|
|
160
|
-
'--primary': 'oklch(0.
|
|
161
|
-
'--primary-foreground': 'oklch(0.
|
|
162
|
-
'--ring': 'oklch(0.
|
|
163
|
-
'--sidebar-primary': 'oklch(0.
|
|
160
|
+
'--primary': 'oklch(0.73 0.195 41)',
|
|
161
|
+
'--primary-foreground': 'oklch(0.15 0.04 41)',
|
|
162
|
+
'--ring': 'oklch(0.6 0.18 41)',
|
|
163
|
+
'--sidebar-primary': 'oklch(0.73 0.195 41)',
|
|
164
164
|
'--sidebar-primary-foreground': 'oklch(0.985 0 0)',
|
|
165
|
-
'--sidebar-ring': 'oklch(0.
|
|
166
|
-
'--chart-1': 'oklch(0.
|
|
165
|
+
'--sidebar-ring': 'oklch(0.6 0.18 41)',
|
|
166
|
+
'--chart-1': 'oklch(0.73 0.195 41)',
|
|
167
167
|
},
|
|
168
168
|
},
|
|
169
169
|
violet: {
|
|
@@ -180,13 +180,13 @@ export const builtinPresets: Record<string, ColorPreset> = $state({
|
|
|
180
180
|
'--chart-1': 'oklch(0.541 0.281 293.009)',
|
|
181
181
|
},
|
|
182
182
|
dark: {
|
|
183
|
-
'--primary': 'oklch(0.
|
|
184
|
-
'--primary-foreground': 'oklch(0.
|
|
185
|
-
'--ring': 'oklch(0.
|
|
186
|
-
'--sidebar-primary': 'oklch(0.
|
|
183
|
+
'--primary': 'oklch(0.65 0.25 293)',
|
|
184
|
+
'--primary-foreground': 'oklch(0.15 0.04 293)',
|
|
185
|
+
'--ring': 'oklch(0.52 0.2 293)',
|
|
186
|
+
'--sidebar-primary': 'oklch(0.65 0.25 293)',
|
|
187
187
|
'--sidebar-primary-foreground': 'oklch(0.985 0 0)',
|
|
188
|
-
'--sidebar-ring': 'oklch(0.
|
|
189
|
-
'--chart-1': 'oklch(0.
|
|
188
|
+
'--sidebar-ring': 'oklch(0.52 0.2 293)',
|
|
189
|
+
'--chart-1': 'oklch(0.65 0.25 293)',
|
|
190
190
|
},
|
|
191
191
|
},
|
|
192
192
|
});
|
|
@@ -194,11 +194,6 @@ export const builtinPresets: Record<string, ColorPreset> = $state({
|
|
|
194
194
|
/** Register a custom color preset. Overwrites any built-in preset with the same name. */
|
|
195
195
|
export function registerColorPreset(preset: ColorPreset): void {
|
|
196
196
|
builtinPresets[preset.name] = preset;
|
|
197
|
-
// Update colorThemes display list
|
|
198
|
-
const idx = colorThemes.findIndex(ct => ct.id === preset.name);
|
|
199
|
-
const entry = { id: preset.name as ColorTheme, label: preset.label, color: preset.color };
|
|
200
|
-
if (idx >= 0) colorThemes[idx] = entry;
|
|
201
|
-
else colorThemes.push(entry);
|
|
202
197
|
}
|
|
203
198
|
|
|
204
199
|
/** Resolve a preset by name or return the preset object directly. */
|
|
@@ -208,14 +203,21 @@ function resolvePreset(preset: ColorPreset | string): ColorPreset | undefined {
|
|
|
208
203
|
}
|
|
209
204
|
|
|
210
205
|
/** Apply a color preset's CSS variables based on current resolved theme. */
|
|
206
|
+
let activePresetVars: string[] = [];
|
|
207
|
+
|
|
211
208
|
function applyColorPreset(preset: ColorPreset): void {
|
|
212
209
|
if (typeof document === 'undefined') return;
|
|
213
210
|
const resolved = mode === 'system' ? getSystemPreference() : mode;
|
|
214
211
|
const vars = resolved === 'dark' ? preset.dark : preset.light;
|
|
215
212
|
const root = document.documentElement;
|
|
213
|
+
for (const key of activePresetVars) {
|
|
214
|
+
root.style.removeProperty(key);
|
|
215
|
+
}
|
|
216
|
+
activePresetVars = [];
|
|
216
217
|
for (const [key, value] of Object.entries(vars)) {
|
|
217
218
|
const cssVar = key.startsWith('--') ? key : `--${key}`;
|
|
218
219
|
root.style.setProperty(cssVar, value);
|
|
220
|
+
activePresetVars.push(cssVar);
|
|
219
221
|
}
|
|
220
222
|
}
|
|
221
223
|
|
|
@@ -292,7 +294,7 @@ export function clearCssOverrides(keys?: string[]): void {
|
|
|
292
294
|
}
|
|
293
295
|
|
|
294
296
|
// ── Color themes (display metadata, derived from presets) ──
|
|
295
|
-
export const colorThemes: { id: ColorTheme | string; label: string; color: string }[] = $
|
|
297
|
+
export const colorThemes: { id: ColorTheme | string; label: string; color: string }[] = $derived(Object.values(builtinPresets).map(p => ({
|
|
296
298
|
id: p.name as ColorTheme,
|
|
297
299
|
label: p.label,
|
|
298
300
|
color: p.color,
|
package/src/toast.svelte.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'undoable';
|
|
|
2
2
|
|
|
3
3
|
export interface ToastItem {
|
|
4
4
|
id: number;
|
|
5
|
+
key?: string;
|
|
5
6
|
type: ToastType;
|
|
6
7
|
message: string;
|
|
7
8
|
duration?: number;
|
|
@@ -10,18 +11,16 @@ export interface ToastItem {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
let nextId = 0;
|
|
14
|
+
const undoableTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
|
13
15
|
|
|
14
|
-
// Queue for simple toasts (consumed by UI Toast bridge)
|
|
15
16
|
let queue = $state<ToastItem[]>([]);
|
|
16
17
|
export function getToastQueue() { return queue; }
|
|
17
18
|
export function consumeToastQueue() { queue = []; }
|
|
18
19
|
|
|
19
|
-
// Promise Queue
|
|
20
20
|
let promiseQueue = $state<{ id: number; promise: Promise<any>; opts: { loading: string; success: string; error: string } }[]>([]);
|
|
21
21
|
export function getPromiseQueue() { return promiseQueue; }
|
|
22
22
|
export function consumePromiseQueue() { promiseQueue = []; }
|
|
23
23
|
|
|
24
|
-
// Legacy compatibility — kept for existing consumers (Undoable)
|
|
25
24
|
let toasts = $state<ToastItem[]>([]);
|
|
26
25
|
export function getToasts(): ToastItem[] { return toasts; }
|
|
27
26
|
|
|
@@ -29,29 +28,41 @@ export function addToast(
|
|
|
29
28
|
type: ToastType,
|
|
30
29
|
message: string,
|
|
31
30
|
duration = 3000,
|
|
32
|
-
options?: { onUndo?: () => void, onTimeout?: () => void }
|
|
31
|
+
options?: { key?: string, onUndo?: () => void, onTimeout?: () => void }
|
|
33
32
|
): void {
|
|
33
|
+
if (options?.key) {
|
|
34
|
+
if (queue.some(q => q.key === options.key) || toasts.some(t => t.key === options.key)) return;
|
|
35
|
+
}
|
|
34
36
|
if (type !== 'undoable') {
|
|
35
|
-
queue.push({ id: nextId++, type, message, duration });
|
|
37
|
+
queue.push({ id: nextId++, type, message, duration, key: options?.key });
|
|
36
38
|
return;
|
|
37
39
|
}
|
|
38
40
|
const id = nextId++;
|
|
39
41
|
toasts = [...toasts, { id, type, message, duration, ...options }];
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
undoableTimers.delete(id);
|
|
44
|
+
options?.onTimeout?.();
|
|
45
|
+
removeToast(id);
|
|
46
|
+
}, duration);
|
|
47
|
+
undoableTimers.set(id, timer);
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
export function removeToast(id: number): void {
|
|
51
|
+
const timer = undoableTimers.get(id);
|
|
52
|
+
if (timer) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
undoableTimers.delete(id);
|
|
55
|
+
}
|
|
43
56
|
toasts = toasts.filter(t => t.id !== id);
|
|
44
57
|
}
|
|
45
58
|
|
|
46
|
-
// Convenience methods — backward compatible
|
|
47
59
|
export const toast = {
|
|
48
|
-
success: (msg: string, duration?: number) => addToast('success', msg, duration),
|
|
49
|
-
error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
|
|
50
|
-
info: (msg: string, duration?: number) => addToast('info', msg, duration),
|
|
51
|
-
warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
|
|
60
|
+
success: (msg: string, duration?: number, opts?: { key?: string }) => addToast('success', msg, duration, opts),
|
|
61
|
+
error: (msg: string, duration?: number, opts?: { key?: string }) => addToast('error', msg, duration ?? 5000, opts),
|
|
62
|
+
info: (msg: string, duration?: number, opts?: { key?: string }) => addToast('info', msg, duration, opts),
|
|
63
|
+
warning: (msg: string, duration?: number, opts?: { key?: string }) => addToast('warning', msg, duration ?? 4000, opts),
|
|
52
64
|
undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
|
|
53
65
|
addToast('undoable', msg, duration, { onUndo, onTimeout }),
|
|
54
|
-
/** Promise toast — shows loading → success/error automatically */
|
|
55
66
|
promise: <T>(promise: Promise<T>, opts: { loading: string; success: string; error: string }) => {
|
|
56
67
|
promiseQueue.push({ id: nextId++, promise, opts });
|
|
57
68
|
return promise;
|
package/src/types.ts
CHANGED
|
@@ -48,12 +48,14 @@ export type CrudOperator =
|
|
|
48
48
|
| 'null' | 'nnull'
|
|
49
49
|
| 'between' | 'nbetween';
|
|
50
50
|
|
|
51
|
-
export interface
|
|
51
|
+
export interface FieldFilter {
|
|
52
52
|
field: string;
|
|
53
53
|
operator: CrudOperator;
|
|
54
54
|
value: unknown;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export type Filter = FieldFilter | LogicalFilter;
|
|
58
|
+
|
|
57
59
|
export interface LogicalFilter {
|
|
58
60
|
operator: 'or' | 'and';
|
|
59
61
|
value: Filter[];
|
package/src/url-sync.ts
CHANGED
|
@@ -19,10 +19,10 @@ export function readURLState(): URLState {
|
|
|
19
19
|
const state: URLState = {};
|
|
20
20
|
|
|
21
21
|
const page = params['page'];
|
|
22
|
-
if (page)
|
|
22
|
+
if (page) { const n = parseInt(page, 10); if (!isNaN(n) && n > 0) state.page = n; }
|
|
23
23
|
|
|
24
24
|
const pageSize = params['pageSize'];
|
|
25
|
-
if (pageSize)
|
|
25
|
+
if (pageSize) { const n = parseInt(pageSize, 10); if (!isNaN(n) && n > 0) state.pageSize = n; }
|
|
26
26
|
|
|
27
27
|
const sortField = params['sort'];
|
|
28
28
|
if (sortField) state.sortField = sortField;
|
|
@@ -51,25 +51,37 @@ export function writeURLState(state: URLState): void {
|
|
|
51
51
|
const current = rp.parse();
|
|
52
52
|
const params: Record<string, string> = { ...current.params };
|
|
53
53
|
|
|
54
|
-
if (
|
|
55
|
-
|
|
54
|
+
if ('page' in state) {
|
|
55
|
+
if (state.page && state.page > 1) params['page'] = String(state.page);
|
|
56
|
+
else delete params['page'];
|
|
57
|
+
}
|
|
56
58
|
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
+
if ('pageSize' in state) {
|
|
60
|
+
if (state.pageSize && state.pageSize !== 10) params['pageSize'] = String(state.pageSize);
|
|
61
|
+
else delete params['pageSize'];
|
|
62
|
+
}
|
|
59
63
|
|
|
60
|
-
if (
|
|
61
|
-
|
|
64
|
+
if ('sortField' in state) {
|
|
65
|
+
if (state.sortField) params['sort'] = state.sortField;
|
|
66
|
+
else delete params['sort'];
|
|
67
|
+
}
|
|
62
68
|
|
|
63
|
-
if (
|
|
64
|
-
|
|
69
|
+
if ('sortOrder' in state) {
|
|
70
|
+
if (state.sortOrder) params['order'] = state.sortOrder;
|
|
71
|
+
else delete params['order'];
|
|
72
|
+
}
|
|
65
73
|
|
|
66
|
-
if (
|
|
67
|
-
|
|
74
|
+
if ('search' in state) {
|
|
75
|
+
if (state.search) params['q'] = state.search;
|
|
76
|
+
else delete params['q'];
|
|
77
|
+
}
|
|
68
78
|
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
if ('filters' in state) {
|
|
80
|
+
if (state.filters && state.filters.length > 0) {
|
|
81
|
+
params['filters'] = JSON.stringify(state.filters);
|
|
82
|
+
} else {
|
|
83
|
+
delete params['filters'];
|
|
84
|
+
}
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
// Prevent redundant navigation if nothing actually changed
|
package/src/useCan.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// useCan — reactive permission check hook with TanStack Query integration
|
|
2
2
|
|
|
3
3
|
import { createQuery } from '@tanstack/svelte-query';
|
|
4
|
-
import { canAccessAsync } from './permissions';
|
|
4
|
+
import { canAccessAsync, getAccessControlProvider } from './permissions';
|
|
5
5
|
import type { Action, CanResult } from './permissions';
|
|
6
6
|
|
|
7
7
|
export interface UseCanOptions {
|
|
@@ -42,7 +42,11 @@ export function useCan(options: () => UseCanOptions): UseCanResult {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
return {
|
|
45
|
-
get allowed() {
|
|
45
|
+
get allowed() {
|
|
46
|
+
const p = getAccessControlProvider();
|
|
47
|
+
if (!p) return true;
|
|
48
|
+
return (query.data as CanResult | undefined)?.can ?? false;
|
|
49
|
+
},
|
|
46
50
|
get reason() { return (query.data as CanResult | undefined)?.reason; },
|
|
47
51
|
get isLoading() { return query.isLoading; },
|
|
48
52
|
};
|
package/src/useParsed.svelte.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getResources } from './context.svelte';
|
|
|
5
5
|
|
|
6
6
|
interface ParsedRoute {
|
|
7
7
|
resource?: string;
|
|
8
|
+
resourcePath?: string;
|
|
8
9
|
action?: 'list' | 'create' | 'edit' | 'show' | 'clone';
|
|
9
10
|
id?: string;
|
|
10
11
|
params: Record<string, string>;
|
|
@@ -60,6 +61,7 @@ export function useParsed(): ParsedRoute {
|
|
|
60
61
|
|
|
61
62
|
if (resourceIndex !== -1) {
|
|
62
63
|
result.resource = segments[resourceIndex];
|
|
64
|
+
result.resourcePath = segments.slice(0, resourceIndex + 1).join('/');
|
|
63
65
|
|
|
64
66
|
// Add parent path params based on `<parentResource>/<parentId>` structure
|
|
65
67
|
for (let i = 0; i < resourceIndex; i += 2) {
|
|
@@ -92,6 +94,7 @@ export function useParsed(): ParsedRoute {
|
|
|
92
94
|
} else {
|
|
93
95
|
// Fallback if resource definitions are not loaded yet
|
|
94
96
|
result.resource = segments[0];
|
|
97
|
+
result.resourcePath = segments[0];
|
|
95
98
|
|
|
96
99
|
if (segments.length === 1) {
|
|
97
100
|
result.action = 'list';
|
|
@@ -117,6 +120,7 @@ export function useParsed(): ParsedRoute {
|
|
|
117
120
|
|
|
118
121
|
return {
|
|
119
122
|
get resource() { return parsed.resource; },
|
|
123
|
+
get resourcePath() { return parsed.resourcePath; },
|
|
120
124
|
get action() { return parsed.action; },
|
|
121
125
|
get id() { return parsed.id; },
|
|
122
126
|
get params() { return parsed.params; },
|
|
@@ -7,6 +7,8 @@ export interface UseStepsFormOptions<
|
|
|
7
7
|
TData extends BaseRecord = BaseRecord,
|
|
8
8
|
TError = HttpError,
|
|
9
9
|
> extends UseFormOptions<TVariables, TData, TError> {
|
|
10
|
+
/** Total number of steps. Defaults to 1 */
|
|
11
|
+
stepsCount?: number;
|
|
10
12
|
/** Default step index. Starts at 0. */
|
|
11
13
|
defaultStep?: number;
|
|
12
14
|
/** Validate the form when navigating back? */
|
|
@@ -17,15 +19,20 @@ export interface UseStepsFormReturn<
|
|
|
17
19
|
TVariables extends Record<string, unknown> = Record<string, unknown>,
|
|
18
20
|
TData extends BaseRecord = BaseRecord,
|
|
19
21
|
> extends UseFormReturn<TVariables, TData> {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
steps: {
|
|
23
|
+
currentStep: number;
|
|
24
|
+
totalSteps: number;
|
|
25
|
+
canGoNext: boolean;
|
|
26
|
+
canGoPrev: boolean;
|
|
27
|
+
gotoStep: (step: number) => void;
|
|
28
|
+
nextStep: () => void;
|
|
29
|
+
prevStep: () => void;
|
|
30
|
+
};
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
34
|
* useStepsForm
|
|
28
|
-
* Extends `useForm` with
|
|
35
|
+
* Extends `useForm` with a nested `steps` object tracking navigation state.
|
|
29
36
|
*/
|
|
30
37
|
export function useStepsForm<
|
|
31
38
|
TVariables extends Record<string, unknown> = Record<string, unknown>,
|
|
@@ -35,33 +42,36 @@ export function useStepsForm<
|
|
|
35
42
|
const form = useForm<TVariables, TData, TError>(options);
|
|
36
43
|
|
|
37
44
|
let currentStep = $state(options.defaultStep ?? 0);
|
|
45
|
+
const totalSteps = options.stepsCount ?? 1;
|
|
38
46
|
|
|
39
47
|
function gotoStep(step: number) {
|
|
40
|
-
currentStep = Math.max(0, step);
|
|
48
|
+
currentStep = Math.max(0, Math.min(step, totalSteps - 1));
|
|
41
49
|
}
|
|
42
50
|
|
|
43
|
-
function
|
|
44
|
-
gotoStep(currentStep + 1);
|
|
51
|
+
function nextStep() {
|
|
52
|
+
if (currentStep < totalSteps - 1) gotoStep(currentStep + 1);
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
function
|
|
48
|
-
if (currentStep > 0)
|
|
49
|
-
gotoStep(currentStep - 1);
|
|
50
|
-
}
|
|
55
|
+
function prevStep() {
|
|
56
|
+
if (currentStep > 0) gotoStep(currentStep - 1);
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
// Inject stepping features into the returned useForm object while preserving its reactive getters.
|
|
54
59
|
const result = form as unknown as UseStepsFormReturn<TVariables, TData>;
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
get
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
const steps = {
|
|
62
|
+
get currentStep() { return currentStep; },
|
|
63
|
+
get totalSteps() { return totalSteps; },
|
|
64
|
+
get canGoNext() { return currentStep < totalSteps - 1; },
|
|
65
|
+
get canGoPrev() { return currentStep > 0; },
|
|
66
|
+
gotoStep,
|
|
67
|
+
nextStep,
|
|
68
|
+
prevStep,
|
|
69
|
+
};
|
|
61
70
|
|
|
62
|
-
result
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
Object.defineProperty(result, 'steps', {
|
|
72
|
+
get: () => steps,
|
|
73
|
+
enumerable: true
|
|
74
|
+
});
|
|
65
75
|
|
|
66
76
|
return result;
|
|
67
77
|
}
|
|
@@ -56,7 +56,8 @@ export function useModalForm<
|
|
|
56
56
|
visible = false;
|
|
57
57
|
formState.reset();
|
|
58
58
|
if (options.autoSave?.invalidateOnClose) {
|
|
59
|
-
queryClient.invalidateQueries({
|
|
59
|
+
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === formState.resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
|
|
60
|
+
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === formState.resource && q.queryKey[2] === 'many' });
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -83,11 +84,11 @@ export interface MenuConfig {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export function useMenu() {
|
|
86
|
-
const adminOptions = getAdminOptions();
|
|
87
|
-
const resources = getResources();
|
|
88
87
|
const parsed = useParsed();
|
|
89
88
|
|
|
90
89
|
const menuItems = $derived.by<MenuConfig[]>(() => {
|
|
90
|
+
const adminOptions = getAdminOptions();
|
|
91
|
+
const resources = getResources();
|
|
91
92
|
let items: MenuConfig[] = resources
|
|
92
93
|
.filter((r: ResourceDefinition) => r.showInMenu !== false)
|
|
93
94
|
.map((r: ResourceDefinition): MenuConfig => ({
|
|
@@ -127,7 +128,16 @@ export function useBreadcrumb() {
|
|
|
127
128
|
|
|
128
129
|
try {
|
|
129
130
|
const res = getResource(parsed.resource);
|
|
130
|
-
|
|
131
|
+
const chain: { label: string; link: string; icon?: string }[] = [];
|
|
132
|
+
let current: typeof res | undefined = res;
|
|
133
|
+
const visited = new Set<string>();
|
|
134
|
+
while (current) {
|
|
135
|
+
if (visited.has(current.name)) break;
|
|
136
|
+
visited.add(current.name);
|
|
137
|
+
chain.unshift({ label: current.label || current.name, link: `/${current.name}`, icon: current.icon });
|
|
138
|
+
current = current.parentName ? getResource(current.parentName) : undefined;
|
|
139
|
+
}
|
|
140
|
+
list.push(...chain);
|
|
131
141
|
|
|
132
142
|
if (parsed.action === 'create') {
|
|
133
143
|
list.push({ label: t('common.create') });
|