@svadmin/core 0.22.0 → 0.22.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -242,6 +242,14 @@ export function usePermissions<T = unknown>() {
242
242
  fetch();
243
243
  }
244
244
 
245
+ const hasFn = (perm: string): boolean => {
246
+ if (!permissions) return false;
247
+ if (Array.isArray(permissions)) return permissions.includes(perm);
248
+ if (permissions instanceof Set) return (permissions as Set<string>).has(perm);
249
+ if (typeof permissions === 'object') return !!(permissions as Record<string, boolean>)[perm];
250
+ return false;
251
+ };
252
+
245
253
  return {
246
254
  get isLoading() { return isLoading; },
247
255
  get error() { return error; },
@@ -250,18 +258,10 @@ export function usePermissions<T = unknown>() {
250
258
  refetch: fetch,
251
259
 
252
260
  /** Check if a specific permission string exists */
253
- has(perm: string): boolean {
254
- if (!permissions) return false;
255
- if (Array.isArray(permissions)) return permissions.includes(perm);
256
- if (permissions instanceof Set) return (permissions as Set<string>).has(perm);
257
- if (typeof permissions === 'object') return !!(permissions as Record<string, boolean>)[perm];
258
- return false;
259
- },
261
+ has: hasFn,
260
262
 
261
263
  /** Check resource:action style permission */
262
- can(resource: string, action: string): boolean {
263
- return this.has(`${resource}:${action}`);
264
- },
264
+ can: (resource: string, action: string): boolean => hasFn(`${resource}:${action}`),
265
265
  };
266
266
  }
267
267
 
@@ -68,8 +68,8 @@ function isDataProvider(value: unknown): value is DataProvider {
68
68
 
69
69
  let authProvider = $state<AuthProvider | null>(null);
70
70
 
71
- export function setAuthProvider(provider: AuthProvider): void {
72
- authProvider = provider;
71
+ export function setAuthProvider(provider: AuthProvider | null | undefined): void {
72
+ authProvider = provider || null;
73
73
  }
74
74
 
75
75
  export function getAuthProvider(): AuthProvider;
@@ -120,15 +120,20 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
120
120
  const errored: { request: unknown; error: unknown }[] = [];
121
121
 
122
122
  try {
123
- const text = await info.file.text();
124
- const lines = text.split('\n').filter(l => l.trim() !== '');
125
- if (lines.length < 2) return;
123
+ let text = await info.file.text();
124
+ // Strip BOM
125
+ if (text.charCodeAt(0) === 0xFEFF) {
126
+ text = text.slice(1);
127
+ }
128
+
129
+ const rows = parseCSV(text);
130
+ if (rows.length < 2) return;
126
131
 
127
- const headers = parseCSVLine(lines[0]);
132
+ const headers = rows[0];
128
133
  const records: Record<string, unknown>[] = [];
129
134
 
130
- for (let i = 1; i < lines.length; i++) {
131
- const values = parseCSVLine(lines[i]);
135
+ for (let i = 1; i < rows.length; i++) {
136
+ const values = rows[i];
132
137
  const record: Record<string, unknown> = {};
133
138
  headers.forEach((h, idx) => { record[h] = values[idx] ?? ''; });
134
139
  records.push(options.mapData ? (options.mapData(record) as unknown as Record<string, unknown>) : record);
@@ -151,18 +156,23 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
151
156
  } else {
152
157
  for (let i = 0; i < records.length; i += batchSize) {
153
158
  const batch = records.slice(i, i + batchSize);
154
- try {
155
- if (provider.createMany) {
159
+ if (provider.createMany) {
160
+ try {
156
161
  const res = await provider.createMany({ resource, variables: batch, meta: options.meta });
157
162
  succeeded.push(res);
158
- } else {
159
- for (const record of batch) {
163
+ } catch (error) {
164
+ errored.push({ request: batch, error });
165
+ }
166
+ } else {
167
+ // Fallback: loop internal items inside batch securely instead of breaking whole batch
168
+ for (const record of batch) {
169
+ try {
160
170
  const res = await provider.create({ resource, variables: record, meta: options.meta });
161
171
  succeeded.push(res);
172
+ } catch (error) {
173
+ errored.push({ request: record, error });
162
174
  }
163
175
  }
164
- } catch (error) {
165
- errored.push({ request: batch, error });
166
176
  }
167
177
  processed += batch.length;
168
178
  options.onProgress?.({ totalAmount: records.length, processedAmount: processed });
@@ -184,34 +194,53 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
184
194
  };
185
195
  }
186
196
 
187
- /** Parse a single CSV line respecting quoted fields */
188
- function parseCSVLine(line: string): string[] {
189
- const result: string[] = [];
190
- let current = '';
197
+ /** Parse full CSV text respecting quoted multi-line fields */
198
+ function parseCSV(text: string): string[][] {
199
+ const rows: string[][] = [];
200
+ let currentRow: string[] = [];
201
+ let currentVal = '';
191
202
  let inQuotes = false;
192
203
 
193
- for (let i = 0; i < line.length; i++) {
194
- const ch = line[i];
204
+ for (let i = 0; i < text.length; i++) {
205
+ const ch = text[i];
195
206
  if (inQuotes) {
196
- if (ch === '"' && line[i + 1] === '"') {
197
- current += '"';
198
- i++;
199
- } else if (ch === '"') {
200
- inQuotes = false;
207
+ if (ch === '"') {
208
+ if (i + 1 < text.length && text[i + 1] === '"') {
209
+ currentVal += '"';
210
+ i++; // skip escaped quote
211
+ } else {
212
+ inQuotes = false;
213
+ }
201
214
  } else {
202
- current += ch;
215
+ currentVal += ch;
203
216
  }
204
217
  } else {
205
218
  if (ch === '"') {
206
219
  inQuotes = true;
207
220
  } else if (ch === ',') {
208
- result.push(current.trim());
209
- current = '';
221
+ currentRow.push(currentVal.trim());
222
+ currentVal = '';
223
+ } else if (ch === '\n' || ch === '\r') {
224
+ currentRow.push(currentVal.trim());
225
+ if (currentRow.some(v => v !== '')) {
226
+ rows.push(currentRow);
227
+ }
228
+ currentRow = [];
229
+ currentVal = '';
230
+ if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
231
+ i++; // consume \n of \r\n
232
+ }
210
233
  } else {
211
- current += ch;
234
+ currentVal += ch;
212
235
  }
213
236
  }
214
237
  }
215
- result.push(current.trim());
216
- return result;
238
+
239
+ if (currentVal || currentRow.length > 0) {
240
+ currentRow.push(currentVal.trim());
241
+ if (currentRow.some(v => v !== '')) {
242
+ rows.push(currentRow);
243
+ }
244
+ }
245
+ return rows;
217
246
  }
@@ -275,6 +275,14 @@ export function useForm<
275
275
  let queryInitializedId: string | number | undefined = undefined;
276
276
  {
277
277
  $effect.pre(() => {
278
+ // Discard previous fetch lock and default when switched strictly back to create
279
+ if (action === 'create' && currentId === undefined && queryInitializedId !== undefined) {
280
+ queryInitializedId = undefined;
281
+ const def = (options.defaultValues ?? {}) as TVariables;
282
+ values = { ...def };
283
+ initialValues = { ...def };
284
+ return;
285
+ }
278
286
  if (queryInitializedId === currentId) return;
279
287
  const data = query.data as Record<string, unknown> | undefined;
280
288
  const pk = getResource(resource).primaryKey ?? 'id';
@@ -23,10 +23,10 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
23
23
 
24
24
  import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
25
25
  import { getAdminOptions } from './options.svelte';
26
- import { getDataProviderForResource, getDataProvider } from './context.svelte';
26
+ import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
27
27
  import { useParsed } from './useParsed.svelte';
28
- import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
29
- import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
28
+ import { createOvertimeTracker, createLiveSubscription, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
29
+ import type { NotificationConfig, OvertimeOptions, LiveSubscriptionParams } from './hook-utils.svelte';
30
30
  import type { BaseRecord, HttpError, Pagination, Sort, Filter, DataProvider, KnownResources } from './types';
31
31
  import type { LiveMode, LiveEvent } from './live.svelte';
32
32
  import { useList } from './query-hooks.svelte';
@@ -52,13 +52,13 @@ export interface UseInfiniteListOptions<TData extends BaseRecord = BaseRecord, T
52
52
 
53
53
  export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseInfiniteListOptions<TData, TError> = {}) {
54
54
  const parsed = useParsed();
55
- const resource = options.resource ?? parsed.resource ?? '';
56
55
  const adminOptions = getAdminOptions();
57
56
 
58
57
  const query = createInfiniteQuery<{ data: TData[]; total: number }, TError>(() => {
58
+ const resource = options.resource ?? parsed.resource ?? '';
59
59
  const provider = getDataProviderForResource(resource, options.dataProviderName);
60
60
  return {
61
- queryKey: [resource, 'infiniteList', options.sorters, options.filters, options.meta],
61
+ queryKey: [resource, 'infiniteList', options.pagination?.pageSize, options.sorters, options.filters, options.meta],
62
62
  queryFn: async ({ pageParam = 1 }) => {
63
63
  const result = await provider.getList<TData>({
64
64
  resource,
@@ -82,6 +82,25 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
82
82
 
83
83
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
84
84
 
85
+ createLiveSubscription((): LiveSubscriptionParams => ({
86
+ resource: options.resource ?? parsed.resource ?? '',
87
+ liveProvider: getLiveProvider(),
88
+ liveMode: options.liveMode ?? adminOptions.liveMode,
89
+ onLiveEvent: (e: LiveEvent) => {
90
+ options.onLiveEvent?.(e);
91
+ adminOptions.onLiveEvent?.(e);
92
+ },
93
+ enabled: options.queryOptions?.enabled ?? true,
94
+ }));
95
+
96
+ $effect(() => {
97
+ if (query.isSuccess && options.successNotification) {
98
+ fireSuccessNotification(options.successNotification, '', query.data, undefined, options.resource ?? parsed.resource ?? '');
99
+ } else if (query.isError) {
100
+ fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
101
+ }
102
+ });
103
+
85
104
  return { query, get overtime() { return overtime; } };
86
105
  }
87
106
 
@@ -4,6 +4,15 @@
4
4
 
5
5
  const locales: Record<string, Record<string, string>> = {
6
6
  'zh-CN': {
7
+ 'common.list': '列表',
8
+ 'common.prev': '上一页',
9
+ 'common.pageOf': '第 {page} 页,共 {total} 页',
10
+ 'common.areYouSure': '确认操作?',
11
+ 'common.backToList': '返回列表',
12
+ 'common.refresh': '刷新',
13
+ 'common.importData': '导入数据',
14
+ 'common.upload': '上传',
15
+ 'common.returnHome': '返回首页',
7
16
  // Common
8
17
  'common.save': '保存',
9
18
  'common.cancel': '取消',
@@ -242,6 +251,15 @@ const locales: Record<string, Record<string, string>> = {
242
251
  'profile.updatePassword': '更新密码',
243
252
  },
244
253
  'en': {
254
+ 'common.list': 'List',
255
+ 'common.prev': 'Prev',
256
+ 'common.pageOf': 'Page {page} of {total}',
257
+ 'common.areYouSure': 'Are you sure?',
258
+ 'common.backToList': 'Back to List',
259
+ 'common.refresh': 'Refresh',
260
+ 'common.importData': 'Import data',
261
+ 'common.upload': 'Upload',
262
+ 'common.returnHome': 'Return to Home',
245
263
  // Common
246
264
  'common.save': 'Save',
247
265
  'common.cancel': 'Cancel',
@@ -27,6 +27,7 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
27
27
 
28
28
  type Callback = (event: LiveEvent) => void;
29
29
  const subscribers = new Map<string, Set<Callback>>();
30
+ const lastParams = new Map<string, Record<string, unknown> | undefined>();
30
31
  let ws: WebSocket | null = null;
31
32
  let reconnectAttempts = 0;
32
33
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -51,6 +52,14 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
51
52
  status = 'connected';
52
53
  reconnectAttempts = 0;
53
54
  options.onOpen?.();
55
+
56
+ // Resend subscriptions upon reconnects
57
+ for (const [res, callbacks] of subscribers.entries()) {
58
+ if (callbacks.size > 0) {
59
+ const liveParams = lastParams.get(res);
60
+ ws?.send(JSON.stringify({ type: 'SUBSCRIBE', resource: res, liveParams }));
61
+ }
62
+ }
54
63
  };
55
64
 
56
65
  ws.onmessage = (msgEvent) => {
@@ -107,22 +116,31 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
107
116
 
108
117
 
109
118
  return {
110
- subscribe({ resource, callback }) {
119
+ subscribe({ resource, liveParams, callback }) {
111
120
  if (!subscribers.has(resource)) {
112
121
  subscribers.set(resource, new Set());
113
122
  }
114
123
  subscribers.get(resource)!.add(callback);
124
+ lastParams.set(resource, liveParams);
115
125
 
116
126
  // Ensure connection is open
117
127
  if (status === 'disconnected' && typeof WebSocket !== 'undefined') {
118
128
  connect();
129
+ } else if (status === 'connected' && ws && ws.readyState === WebSocket.OPEN) {
130
+ // If already connected, dispatch subscription payload dynamically
131
+ ws.send(JSON.stringify({ type: 'SUBSCRIBE', resource, liveParams }));
119
132
  }
120
133
 
121
134
  return () => {
122
135
  const callbacks = subscribers.get(resource);
123
136
  if (callbacks) {
124
137
  callbacks.delete(callback);
125
- if (callbacks.size === 0) subscribers.delete(resource);
138
+ if (callbacks.size === 0) {
139
+ subscribers.delete(resource);
140
+ if (status === 'connected' && ws && ws.readyState === WebSocket.OPEN) {
141
+ ws.send(JSON.stringify({ type: 'UNSUBSCRIBE', resource }));
142
+ }
143
+ }
126
144
  }
127
145
  // Auto-disconnect if no subscribers left
128
146
  if (subscribers.size === 0) disconnect();
@@ -41,6 +41,8 @@ function extendQuery<Q extends object, E extends Record<string, unknown>>(
41
41
 
42
42
  // ─── useList ───────────────────────────────────────────────────
43
43
 
44
+ export type MaybeGetter<T> = T | (() => T);
45
+
44
46
  export interface UseListOptions<TData extends BaseRecord = BaseRecord, TError = HttpError> {
45
47
  resource?: KnownResources;
46
48
  pagination?: Pagination;
@@ -57,51 +59,58 @@ export interface UseListOptions<TData extends BaseRecord = BaseRecord, TError =
57
59
  overtimeOptions?: OvertimeOptions;
58
60
  }
59
61
 
60
- export function useList<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseListOptions<TData, TError> = {}) {
62
+ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpError>(optionsOrGetter: MaybeGetter<UseListOptions<TData, TError>> = {}) {
61
63
  const parsed = useParsed();
62
- const resource = options.resource ?? parsed.resource ?? '';
63
64
  const adminOptions = getAdminOptions();
64
- const provider = getDataProviderForResource(resource, options.dataProviderName);
65
-
66
- const queryOptions = options.queryOptions;
65
+
66
+ const getOptions = () => typeof optionsOrGetter === 'function' ? optionsOrGetter() : optionsOrGetter;
67
+ const getResource = () => getOptions().resource ?? parsed.resource ?? '';
67
68
 
68
- const query = createQuery<GetListResult<TData>, TError>(() => ({
69
- queryKey: [resource, 'list', options.pagination, options.sorters, options.filters, options.meta],
70
- queryFn: async () => {
71
- const result = await provider.getList<TData>({
69
+ const query = createQuery<GetListResult<TData>, TError>(() => {
70
+ const opts = getOptions();
71
+ const resource = getResource();
72
+ const provider = getDataProviderForResource(resource, opts.dataProviderName);
73
+ const queryOptions = opts.queryOptions;
74
+
75
+ return {
76
+ queryKey: [resource, 'list', opts.pagination, opts.sorters, opts.filters, opts.meta],
77
+ queryFn: async () => provider.getList<TData>({
72
78
  resource,
73
- pagination: options.pagination,
74
- sorters: options.sorters,
75
- filters: options.filters,
76
- meta: options.meta,
77
- });
78
- return result;
79
- },
80
- enabled: queryOptions?.enabled ?? true,
81
- staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
82
- gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
83
- refetchOnWindowFocus: queryOptions?.refetchOnWindowFocus ?? adminOptions.reactQuery?.refetchOnWindowFocus,
84
- }));
79
+ pagination: opts.pagination,
80
+ sorters: opts.sorters,
81
+ filters: opts.filters,
82
+ meta: opts.meta,
83
+ }),
84
+ enabled: queryOptions?.enabled ?? true,
85
+ staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
86
+ gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
87
+ refetchOnWindowFocus: queryOptions?.refetchOnWindowFocus ?? adminOptions.reactQuery?.refetchOnWindowFocus,
88
+ };
89
+ });
85
90
 
86
- const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
91
+ const overtime = createOvertimeTracker(() => query.isLoading, typeof optionsOrGetter === 'function' ? optionsOrGetter().overtimeOptions : optionsOrGetter.overtimeOptions ?? adminOptions.overtime);
87
92
 
88
- createLiveSubscription((): LiveSubscriptionParams => ({
89
- resource,
90
- liveProvider: getLiveProvider(),
91
- liveMode: options.liveMode ?? adminOptions.liveMode,
92
- onLiveEvent: (e: LiveEvent) => {
93
- options.onLiveEvent?.(e);
94
- adminOptions.onLiveEvent?.(e);
95
- },
96
- liveParams: options.liveParams,
97
- enabled: queryOptions?.enabled ?? true,
98
- }));
93
+ createLiveSubscription((): LiveSubscriptionParams => {
94
+ const opts = getOptions();
95
+ return {
96
+ resource: getResource(),
97
+ liveProvider: getLiveProvider(),
98
+ liveMode: opts.liveMode ?? adminOptions.liveMode,
99
+ onLiveEvent: (e: LiveEvent) => {
100
+ opts.onLiveEvent?.(e);
101
+ adminOptions.onLiveEvent?.(e);
102
+ },
103
+ liveParams: opts.liveParams,
104
+ enabled: opts.queryOptions?.enabled ?? true,
105
+ };
106
+ });
99
107
 
100
108
  $effect(() => {
101
- if (query.isSuccess && options.successNotification) {
102
- fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
109
+ const opts = getOptions();
110
+ if (query.isSuccess && opts.successNotification) {
111
+ fireSuccessNotification(opts.successNotification, '', query.data, undefined, getResource());
103
112
  } else if (query.isError) {
104
- fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
113
+ fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
105
114
  }
106
115
  });
107
116
 
@@ -124,38 +133,47 @@ export interface UseOneOptions<TData extends BaseRecord = BaseRecord, TError = H
124
133
  overtimeOptions?: OvertimeOptions;
125
134
  }
126
135
 
127
- export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseOneOptions<TData, TError> = {}) {
136
+ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError>(optionsOrGetter: MaybeGetter<UseOneOptions<TData, TError>> = {}) {
128
137
  const parsed = useParsed();
129
- const resource = options.resource ?? parsed.resource ?? '';
130
- const id = options.id ?? parsed.id;
131
138
  const adminOptions = getAdminOptions();
132
- const provider = getDataProviderForResource(resource, options.dataProviderName);
139
+
140
+ const getOptions = () => typeof optionsOrGetter === 'function' ? optionsOrGetter() : optionsOrGetter;
141
+ const getResource = () => getOptions().resource ?? parsed.resource ?? '';
142
+ const getId = () => getOptions().id ?? parsed.id;
133
143
 
134
- const queryOptions = options.queryOptions;
144
+ const query = createQuery<GetOneResult<TData>, TError>(() => {
145
+ const opts = getOptions();
146
+ const resource = getResource();
147
+ const id = getId();
148
+ const provider = getDataProviderForResource(resource, opts.dataProviderName);
149
+ const queryOptions = opts.queryOptions;
135
150
 
136
- const query = createQuery<GetOneResult<TData>, TError>(() => ({
137
- queryKey: [resource, 'one', id, options.meta],
138
- queryFn: async () => {
139
- if (id == null) throw new Error('useOne requires an id');
140
- const result = await provider.getOne<TData>({
141
- resource,
142
- id,
143
- meta: options.meta,
144
- });
145
- return result;
146
- },
147
- enabled: (queryOptions?.enabled ?? true) && id != null,
148
- staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
149
- gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
150
- }));
151
+ return {
152
+ queryKey: [resource, 'one', id, opts.meta],
153
+ queryFn: async () => {
154
+ if (id == null) throw new Error('useOne requires an id');
155
+ const result = await provider.getOne<TData>({
156
+ resource,
157
+ id,
158
+ meta: opts.meta,
159
+ });
160
+ return result;
161
+ },
162
+ enabled: (queryOptions?.enabled ?? true) && id != null,
163
+ staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
164
+ gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
165
+ refetchOnWindowFocus: queryOptions?.refetchOnWindowFocus ?? adminOptions.reactQuery?.refetchOnWindowFocus,
166
+ };
167
+ });
151
168
 
152
- const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
169
+ const overtime = createOvertimeTracker(() => query.isLoading, typeof optionsOrGetter === 'function' ? optionsOrGetter().overtimeOptions : optionsOrGetter.overtimeOptions ?? adminOptions.overtime);
153
170
 
154
171
  $effect(() => {
155
- if (query.isSuccess && options.successNotification) {
156
- fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
172
+ const opts = getOptions();
173
+ if (query.isSuccess && opts.successNotification) {
174
+ fireSuccessNotification(opts.successNotification, '', query.data, undefined, getResource());
157
175
  } else if (query.isError) {
158
- fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
176
+ fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
159
177
  }
160
178
  });
161
179
 
@@ -164,17 +182,23 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
164
182
 
165
183
  // ─── useShow ──────────────────────────────────────────────────
166
184
  export function useShow<TData extends BaseRecord = BaseRecord, TError = HttpError>(
167
- options: UseOneOptions<TData, TError> = {}
185
+ optionsOrGetter: MaybeGetter<UseOneOptions<TData, TError>> = {}
168
186
  ) {
169
187
  const parsed = useParsed();
170
- let showId = $state<string | number | undefined>(options.id ?? parsed.id);
188
+ const getOptions = () => typeof optionsOrGetter === 'function' ? optionsOrGetter() : optionsOrGetter;
189
+
190
+ let _showId = $state<string | number | undefined>(undefined);
191
+
192
+ // showId prioritizes explicit setShowId, then options.id, then URL id.
193
+ const showId = $derived(_showId ?? getOptions().id ?? parsed.id);
171
194
 
172
- function setShowId(id: string | number) { showId = id; }
195
+ function setShowId(id: string | number) { _showId = id; }
173
196
 
174
- const result = useOne<TData, TError>({
175
- ...options,
176
- get id() { return showId; },
177
- });
197
+ // We wrap it in a getter so useOne evaluates it dynamically.
198
+ const result = useOne<TData, TError>(() => ({
199
+ ...getOptions(),
200
+ id: showId,
201
+ }));
178
202
 
179
203
  return extendQuery(result, () => ({ showId, setShowId }));
180
204
  }
@@ -195,30 +219,38 @@ export interface UseManyOptions<TData extends BaseRecord = BaseRecord, TError =
195
219
  overtimeOptions?: OvertimeOptions;
196
220
  }
197
221
 
198
- export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseManyOptions<TData, TError>) {
199
- const { resource, ids, meta, dataProviderName, queryOptions } = options;
222
+ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(optionsOrGetter: MaybeGetter<UseManyOptions<TData, TError>>) {
200
223
  const adminOptions = getAdminOptions();
201
- const provider = getDataProviderForResource(resource, dataProviderName);
202
-
203
- const query = createQuery<GetManyResult<TData>, TError>(() => ({
204
- queryKey: [resource, 'many', ids, meta],
205
- queryFn: async () => {
206
- if (!ids.length) return { data: [] };
207
- if (provider.getMany) {
208
- return provider.getMany<TData>({ resource, ids, meta });
209
- }
210
- const results = await Promise.all(ids.map(id => provider.getOne<TData>({ resource, id, meta })));
211
- return { data: results.map(r => r.data) };
212
- },
213
- enabled: (queryOptions?.enabled ?? true) && ids.length > 0,
214
- staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
215
- }));
224
+ const getOptions = () => typeof optionsOrGetter === 'function' ? optionsOrGetter() : optionsOrGetter;
225
+
226
+ const query = createQuery<GetManyResult<TData>, TError>(() => {
227
+ const opts = getOptions();
228
+ const { resource, ids, meta, dataProviderName, queryOptions } = opts;
229
+ const provider = getDataProviderForResource(resource, dataProviderName);
230
+
231
+ return {
232
+ queryKey: [resource, 'many', ids, meta],
233
+ queryFn: async () => {
234
+ if (!ids.length) return { data: [] };
235
+ if (provider.getMany) {
236
+ return provider.getMany<TData>({ resource, ids, meta });
237
+ }
238
+ const results = await Promise.all(ids.map(id => provider.getOne<TData>({ resource, id, meta })));
239
+ return { data: results.map(r => r.data) };
240
+ },
241
+ enabled: (queryOptions?.enabled ?? true) && ids.length > 0,
242
+ staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
243
+ gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
244
+ refetchOnWindowFocus: queryOptions?.refetchOnWindowFocus ?? adminOptions.reactQuery?.refetchOnWindowFocus,
245
+ };
246
+ });
216
247
 
217
- const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
248
+ const overtime = createOvertimeTracker(() => query.isLoading, typeof optionsOrGetter === 'function' ? optionsOrGetter().overtimeOptions : optionsOrGetter.overtimeOptions ?? adminOptions.overtime);
218
249
 
219
250
  $effect(() => {
220
- if (query.isSuccess && options.successNotification) fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
221
- else if (query.isError) fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
251
+ const opts = getOptions();
252
+ if (query.isSuccess && opts.successNotification) fireSuccessNotification(opts.successNotification, '', query.data, undefined, opts.resource);
253
+ else if (query.isError) fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
222
254
  });
223
255
 
224
256
  return extendQuery(query, () => ({ overtime }));
@@ -87,11 +87,11 @@ export function useNavigation() {
87
87
  const parsed = useParsed();
88
88
 
89
89
  return {
90
- create: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'create' }),
91
- edit: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'edit', id: id ?? parsed.id }),
92
- clone: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'clone', id: id ?? parsed.id }),
93
- show: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'show', id: id ?? parsed.id }),
94
- list: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'list' }),
90
+ create: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'create' }),
91
+ edit: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'edit', id: id ?? parsed.id }),
92
+ clone: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'clone', id: id ?? parsed.id }),
93
+ show: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'show', id: id ?? parsed.id }),
94
+ list: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'list' }),
95
95
  push: (url: string) => go({ to: url, type: 'push' }),
96
96
  replace: (url: string) => go({ to: url, type: 'replace' }),
97
97
  goBack: useBack(),
@@ -2,7 +2,7 @@ import { useParsed } from './useParsed.svelte';
2
2
  import { useList } from './query-hooks.svelte';
3
3
  import { readURLState, writeURLState } from './url-sync';
4
4
  import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources, GetListResult } from './types';
5
- import type { UseListOptions } from './query-hooks.svelte';
5
+ import type { UseListOptions, MaybeGetter } from './query-hooks.svelte';
6
6
 
7
7
  export type FilterSetMode = 'merge' | 'replace';
8
8
 
@@ -27,24 +27,27 @@ export function useTable<
27
27
  TQueryFnData extends BaseRecord = BaseRecord,
28
28
  TError = HttpError,
29
29
  TSearchVariables = Record<string, unknown>
30
- >(options: UseTableOptions<TQueryFnData, TError, TSearchVariables> = {}) {
30
+ >(optionsOrGetter: MaybeGetter<UseTableOptions<TQueryFnData, TError, TSearchVariables>> = {}) {
31
+ const getOptions = () => typeof optionsOrGetter === 'function' ? optionsOrGetter() : optionsOrGetter;
31
32
  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
33
 
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';
34
+ // Use a local derived to compute these since they are used to initialize state
35
+ // Even if options change, init values are only used once, which makes sense for initial state
36
+ const initialOpts = getOptions();
37
+
38
+ const paginationMode = $derived(getOptions().pagination?.mode ?? 'server');
39
+ const sortersMode = $derived(getOptions().sorters?.mode ?? 'server');
40
+ const filtersMode = $derived(getOptions().filters?.mode ?? 'server');
41
+ const filterDefaultBehavior = $derived(getOptions().filters?.defaultBehavior ?? 'replace');
39
42
 
40
- const permanentSorters = options.sorters?.permanent ?? [];
41
- const permanentFilters = options.filters?.permanent ?? [];
43
+ const permanentSorters = $derived(getOptions().sorters?.permanent ?? []);
44
+ const permanentFilters = $derived(getOptions().filters?.permanent ?? []);
42
45
 
43
- let initPagination = options.pagination ?? { current: 1, pageSize: 10 };
44
- let initSorters = options.sorters?.initial ?? [];
45
- let initFilters = options.filters?.initial ?? [];
46
+ let initPagination = initialOpts.pagination ?? { current: 1, pageSize: 10 };
47
+ let initSorters = initialOpts.sorters?.initial ?? [];
48
+ let initFilters = initialOpts.filters?.initial ?? [];
46
49
 
47
- if (syncWithLocation && typeof window !== 'undefined') {
50
+ if (initialOpts.syncWithLocation && typeof window !== 'undefined') {
48
51
  const urlState = readURLState();
49
52
  if (urlState.page || urlState.pageSize) {
50
53
  initPagination = {
@@ -73,14 +76,18 @@ export function useTable<
73
76
  ? pagination
74
77
  : { current: 1, pageSize: 999999, mode: paginationMode as 'client' | 'off' });
75
78
 
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
79
+ const tableQueryInfo = useList<TQueryFnData, TError>(() => {
80
+ const currentOpts = getOptions();
81
+ const { meta, syncWithLocation, dataProviderName, pagination: _p, sorters: _s, filters: _f, ...restOptions } = currentOpts;
82
+ return {
83
+ resource: currentOpts.resource ?? parsed.resource ?? '',
84
+ pagination: queryPagination,
85
+ sorters: querySorters,
86
+ filters: queryFilters,
87
+ meta,
88
+ dataProviderName,
89
+ ...restOptions
90
+ };
84
91
  });
85
92
 
86
93
  const query = tableQueryInfo;
@@ -110,7 +117,7 @@ export function useTable<
110
117
  function setPage(page: number) { pagination = { ...pagination, current: page }; }
111
118
  function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
112
119
 
113
- if (syncWithLocation && typeof window !== 'undefined') {
120
+ if (initialOpts.syncWithLocation && typeof window !== 'undefined') {
114
121
  $effect(() => {
115
122
  writeURLState({
116
123
  page: pagination.current,
@@ -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';
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
  }