@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.
@@ -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
- >(options: UseTableOptions<TQueryFnData, TError, TSearchVariables> = {}) {
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
- const paginationMode = options.pagination?.mode ?? 'server';
36
- const sortersMode = options.sorters?.mode ?? 'server';
37
- const filtersMode = options.filters?.mode ?? 'server';
38
- const filterDefaultBehavior = options.filters?.defaultBehavior ?? 'replace';
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 = options.sorters?.permanent ?? [];
41
- const permanentFilters = options.filters?.permanent ?? [];
45
+ const permanentSorters = $derived(getOptions().sorters?.permanent ?? []);
46
+ const permanentFilters = $derived(getOptions().filters?.permanent ?? []);
42
47
 
43
- let initPagination = options.pagination ?? { current: 1, pageSize: 10 };
44
- let initSorters = options.sorters?.initial ?? [];
45
- let initFilters = options.filters?.initial ?? [];
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
- resource,
78
- get pagination() { return queryPagination; },
79
- get sorters() { return querySorters; },
80
- get filters() { return queryFilters; },
81
- meta,
82
- dataProviderName,
83
- ...restOptions
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
- const idx = merged.findIndex(f => f.field === nf.field && f.operator === nf.operator);
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 !== 'client') return [];
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 : currentSorters;
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 va = (a as Record<string, unknown>)[field];
166
- const vb = (b as Record<string, unknown>)[field];
167
- if (va == null) return 1;
168
- if (vb == null) return -1;
169
- const cmp = va < vb ? -1 : va > vb ? 1 : 0;
170
- return order === 'desc' ? -cmp : cmp;
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
- const idx = merged.findIndex(f => f.field === nf.field && f.operator === nf.operator);
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;
@@ -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' | 'zinc';
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.577 0.245 27.325)',
138
- '--primary-foreground': 'oklch(0.985 0 0)',
139
- '--ring': 'oklch(0.577 0.245 27.325)',
140
- '--sidebar-primary': 'oklch(0.577 0.245 27.325)',
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.577 0.245 27.325)',
143
- '--chart-1': 'oklch(0.577 0.245 27.325)',
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.646 0.222 41.116)',
161
- '--primary-foreground': 'oklch(0.985 0 0)',
162
- '--ring': 'oklch(0.646 0.222 41.116)',
163
- '--sidebar-primary': 'oklch(0.646 0.222 41.116)',
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.646 0.222 41.116)',
166
- '--chart-1': 'oklch(0.646 0.222 41.116)',
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.541 0.281 293.009)',
184
- '--primary-foreground': 'oklch(0.985 0 0)',
185
- '--ring': 'oklch(0.541 0.281 293.009)',
186
- '--sidebar-primary': 'oklch(0.541 0.281 293.009)',
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.541 0.281 293.009)',
189
- '--chart-1': 'oklch(0.541 0.281 293.009)',
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 }[] = $state(Object.values(builtinPresets).map(p => ({
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,
@@ -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 Filter {
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) state.page = parseInt(page, 10);
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) state.pageSize = parseInt(pageSize, 10);
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 (state.page && state.page > 1) params['page'] = String(state.page);
55
- else delete params['page'];
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 (state.pageSize && state.pageSize !== 10) params['pageSize'] = String(state.pageSize);
58
- else delete params['pageSize'];
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 (state.sortField) params['sort'] = state.sortField;
61
- else delete params['sort'];
64
+ if ('sortField' in state) {
65
+ if (state.sortField) params['sort'] = state.sortField;
66
+ else delete params['sort'];
67
+ }
62
68
 
63
- if (state.sortOrder) params['order'] = state.sortOrder;
64
- else delete params['order'];
69
+ if ('sortOrder' in state) {
70
+ if (state.sortOrder) params['order'] = state.sortOrder;
71
+ else delete params['order'];
72
+ }
65
73
 
66
- if (state.search) params['q'] = state.search;
67
- else delete params['q'];
74
+ if ('search' in state) {
75
+ if (state.search) params['q'] = state.search;
76
+ else delete params['q'];
77
+ }
68
78
 
69
- if (state.filters && state.filters.length > 0) {
70
- params['filters'] = JSON.stringify(state.filters);
71
- } else {
72
- delete params['filters'];
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() { return (query.data as CanResult | undefined)?.can ?? true; },
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
  };
@@ -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
- currentStep: number;
21
- gotoStep: (step: number) => void;
22
- next: () => void;
23
- prev: () => void;
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 step navigation state (currentStep, next, prev, gotoStep).
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 next() {
44
- gotoStep(currentStep + 1);
51
+ function nextStep() {
52
+ if (currentStep < totalSteps - 1) gotoStep(currentStep + 1);
45
53
  }
46
54
 
47
- function prev() {
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
- Object.defineProperty(result, 'currentStep', {
57
- get: () => currentStep,
58
- enumerable: true,
59
- configurable: true
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.gotoStep = gotoStep;
63
- result.next = next;
64
- result.prev = prev;
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({ queryKey: [formState.resource] });
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
- list.push({ label: res.label || res.name, link: `/${res.name}`, icon: res.icon });
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') });