@svadmin/core 0.30.0 → 0.32.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/src/types.ts CHANGED
@@ -1,18 +1,40 @@
1
1
  // Core type definitions — DataProvider + AuthProvider + Providers
2
2
 
3
+ import type { Component } from 'svelte';
4
+
3
5
  // ─── HttpError ─────────────────────────────────────────────────
4
6
 
5
7
  export type ValidationErrors = Record<string, string | string[]>;
6
8
 
9
+ export interface HttpErrorOptions {
10
+ code?: string;
11
+ details?: unknown;
12
+ body?: unknown;
13
+ cause?: unknown;
14
+ }
15
+
7
16
  export class HttpError extends Error {
8
17
  statusCode: number;
9
18
  errors?: ValidationErrors;
10
-
11
- constructor(message: string, statusCode: number, errors?: ValidationErrors) {
12
- super(message);
19
+ code?: string;
20
+ details?: unknown;
21
+ body?: unknown;
22
+ override cause?: unknown;
23
+
24
+ constructor(
25
+ message: string,
26
+ statusCode: number,
27
+ errors?: ValidationErrors,
28
+ options: HttpErrorOptions = {},
29
+ ) {
30
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
13
31
  this.name = 'HttpError';
14
32
  this.statusCode = statusCode;
15
33
  this.errors = errors;
34
+ this.code = options.code;
35
+ this.details = options.details;
36
+ this.body = options.body;
37
+ this.cause = options.cause;
16
38
  }
17
39
  }
18
40
 
@@ -72,6 +94,7 @@ export interface GetListParams {
72
94
  export interface GetListResult<TData extends BaseRecord = BaseRecord> {
73
95
  data: TData[];
74
96
  total: number;
97
+ [key: string]: unknown;
75
98
  }
76
99
 
77
100
  export interface GetOneParams {
@@ -315,6 +338,8 @@ export interface AuthProvider {
315
338
  register?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
316
339
  forgotPassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
317
340
  updatePassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
341
+ /** Update current user's identity fields such as name or email. */
342
+ updateIdentity?: (params: Partial<Identity> & Record<string, unknown>) => Promise<AuthActionResult>;
318
343
  /** Update user profile (name, avatar, etc.) */
319
344
  updateProfile?: (params: { name?: string; avatar?: string | File; [key: string]: unknown }) => Promise<AuthActionResult>;
320
345
  /** Get list of system roles for RBAC mapping */
@@ -382,7 +407,7 @@ export interface MenuItem {
382
407
  /** Display label. Falls back to `t('menu.${name}')` if omitted */
383
408
  label?: string;
384
409
  /** Icon name string (maps to lucide icon set) or Svelte component */
385
- icon?: string | any;
410
+ icon?: string | Component;
386
411
  /** Navigation path — omit for parent-only menu nodes */
387
412
  href?: string;
388
413
  /** Open in new tab (useful for external links) */
@@ -1,8 +1,7 @@
1
- /* eslint-disable svelte/prefer-svelte-reactivity */
2
1
  // UnsavedChangesNotifier — guards against accidental navigation with dirty forms
3
2
  // Supports: browser tab close, hash-based SPA routing, and framework router interception
4
3
 
5
- import { t } from './i18n.svelte';
4
+ import { useTranslation } from './i18n.svelte';
6
5
  import { syncGlobalPath } from './useParsed.svelte';
7
6
 
8
7
  let dirty = $state(false);
@@ -15,46 +14,12 @@ export function resetUnsavedChanges(): void {
15
14
  dirty = false;
16
15
  _unsavedChangesCleanup?.();
17
16
  _unsavedChangesCleanup = null;
18
- __navigationGuard = null;
19
17
  }
20
18
 
21
19
  export function getUnsavedChanges(): boolean {
22
20
  return dirty;
23
21
  }
24
22
 
25
- /**
26
- * Navigation interceptor callback type.
27
- * Frameworks like SvelteKit can register a cancel function here.
28
- * Return `true` to allow navigation, `false` to block it.
29
- */
30
- interface NavigationGuard {
31
- (opts: { cancel: () => void }): void;
32
- }
33
-
34
- let __navigationGuard: NavigationGuard | null = null;
35
-
36
- /**
37
- * Register a framework-level navigation guard.
38
- *
39
- * In SvelteKit, call this from your root layout:
40
- * ```ts
41
- * import { beforeNavigate } from '$app/navigation';
42
- * import { registerNavigationGuard } from '@svadmin/core';
43
- *
44
- * registerNavigationGuard(({ cancel }) => {
45
- * beforeNavigate(({ cancel: kitCancel }) => {
46
- * cancel = kitCancel;
47
- * });
48
- * });
49
- * ```
50
- *
51
- * Or more idiomatically, use the built-in `initUnsavedChangesNotifier`
52
- * and pass a `beforeNavigate` callback.
53
- */
54
- export function registerNavigationGuard(guard: NavigationGuard): void {
55
- __navigationGuard = guard;
56
- }
57
-
58
23
  /**
59
24
  * Initialize the UnsavedChangesNotifier.
60
25
  *
@@ -82,6 +47,7 @@ export function initUnsavedChangesNotifier(options?: {
82
47
  beforeNavigate?: (callback: (nav: { cancel: () => void }) => void) => void;
83
48
  }) {
84
49
  if (typeof window === 'undefined') return;
50
+ const i18n = useTranslation();
85
51
  _unsavedChangesCleanup?.();
86
52
 
87
53
  const onBeforeUnload = (e: BeforeUnloadEvent) => {
@@ -94,7 +60,7 @@ export function initUnsavedChangesNotifier(options?: {
94
60
  if (options?.beforeNavigate) {
95
61
  options.beforeNavigate(({ cancel }) => {
96
62
  if (dirty) {
97
- if (!confirm(t('common.unsavedChanges'))) {
63
+ if (!confirm(i18n.t('common.unsavedChanges'))) {
98
64
  cancel();
99
65
  } else {
100
66
  dirty = false;
@@ -105,7 +71,7 @@ export function initUnsavedChangesNotifier(options?: {
105
71
 
106
72
  const onHashChange = (e: HashChangeEvent) => {
107
73
  if (!dirty) return;
108
- if (!confirm(t('common.unsavedChanges'))) {
74
+ if (!confirm(i18n.t('common.unsavedChanges'))) {
109
75
  if (e.oldURL) {
110
76
  history.replaceState(null, '', new URL(e.oldURL).hash || '#');
111
77
  syncGlobalPath();
package/src/url-sync.ts CHANGED
@@ -1,7 +1,44 @@
1
1
  import type { Filter } from './types';
2
- import { getRouterProvider } from './context.svelte';
2
+ import { captureAdminContext } from './context.svelte';
3
+ import type { AdminContextAccessor } from './context.svelte';
3
4
  import { getAdminOptions } from './options.svelte';
4
5
 
6
+ const CRUD_OPERATORS = new Set([
7
+ 'eq', 'ne', 'lt', 'gt', 'lte', 'gte',
8
+ 'contains', 'ncontains', 'startswith', 'endswith',
9
+ 'in', 'nin', 'null', 'nnull', 'between', 'nbetween',
10
+ ]);
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function isFilter(value: unknown, depth: number, budget: { remaining: number }): value is Filter {
17
+ if (depth > 20 || budget.remaining-- <= 0 || !isRecord(value)) return false;
18
+
19
+ if (typeof value.field === 'string') {
20
+ return value.field.trim().length > 0
21
+ && typeof value.operator === 'string'
22
+ && CRUD_OPERATORS.has(value.operator)
23
+ && 'value' in value;
24
+ }
25
+
26
+ return (value.operator === 'and' || value.operator === 'or')
27
+ && Array.isArray(value.value)
28
+ && value.value.every((entry) => isFilter(entry, depth + 1, budget));
29
+ }
30
+
31
+ function parseFilters(value: string): Filter[] | undefined {
32
+ try {
33
+ const parsed: unknown = JSON.parse(value);
34
+ if (!Array.isArray(parsed)) return undefined;
35
+ const budget = { remaining: 200 };
36
+ return parsed.every((entry) => isFilter(entry, 0, budget)) ? parsed : undefined;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
5
42
  export interface URLState {
6
43
  page?: number;
7
44
  pageSize?: number;
@@ -11,9 +48,9 @@ export interface URLState {
11
48
  filters?: Filter[];
12
49
  }
13
50
 
14
- export function readURLState(): URLState {
51
+ export function readURLState(adminContext: AdminContextAccessor = captureAdminContext()): URLState {
15
52
  if (typeof window === 'undefined') return {};
16
- const rp = getRouterProvider();
53
+ const rp = adminContext.routerProvider;
17
54
  if (!rp) return {};
18
55
 
19
56
  const { params } = rp.parse();
@@ -36,17 +73,19 @@ export function readURLState(): URLState {
36
73
 
37
74
  const filtersRaw = params['filters'];
38
75
  if (filtersRaw) {
39
- try {
40
- state.filters = JSON.parse(filtersRaw);
41
- } catch { /* ignore invalid json */ }
76
+ const filters = parseFilters(filtersRaw);
77
+ if (filters) state.filters = filters;
42
78
  }
43
79
 
44
80
  return state;
45
81
  }
46
82
 
47
- export function writeURLState(state: URLState): void {
83
+ export function writeURLState(
84
+ state: URLState,
85
+ adminContext: AdminContextAccessor = captureAdminContext(),
86
+ ): void {
48
87
  if (typeof window === 'undefined') return;
49
- const rp = getRouterProvider();
88
+ const rp = adminContext.routerProvider;
50
89
  if (!rp) return;
51
90
 
52
91
  const current = rp.parse();
package/src/useCan.ts CHANGED
@@ -8,6 +8,7 @@ export interface UseCanOptions {
8
8
  resource: string;
9
9
  action: Action;
10
10
  params?: Record<string, unknown>;
11
+ meta?: Record<string, unknown>;
11
12
  queryOptions?: {
12
13
  enabled?: boolean;
13
14
  staleTime?: number;
@@ -34,8 +35,8 @@ export function useCan(options: () => UseCanOptions): UseCanResult {
34
35
  const query = createQuery<CanResult>(() => {
35
36
  const opts = options();
36
37
  return {
37
- queryKey: ['useCan', opts.resource, opts.action, opts.params] as const,
38
- queryFn: () => canAccessAsync(opts.resource, opts.action, opts.params),
38
+ queryKey: ['useCan', opts.resource, opts.action, opts.params, opts.meta] as const,
39
+ queryFn: () => canAccessAsync(opts.resource, opts.action, opts.params, opts.meta),
39
40
  enabled: opts.queryOptions?.enabled ?? true,
40
41
  staleTime: opts.queryOptions?.staleTime ?? 5 * 60 * 1000,
41
42
  };
@@ -1,34 +1,45 @@
1
- /* eslint-disable svelte/prefer-svelte-reactivity */
2
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
2
  // useParsed — parse current URL hash into structured route info
4
3
 
5
- import { currentPath, registerRouterSync } from './router';
6
- import { getResources } from './context.svelte';
4
+ import { registerRouterSync } from './router';
5
+ import { captureAdminContext } from './context.svelte';
7
6
 
8
7
  interface ParsedRoute {
9
8
  resource?: string;
10
9
  resourcePath?: string;
11
10
  action?: 'list' | 'create' | 'edit' | 'show' | 'clone';
12
11
  id?: string;
12
+ /** Query params parsed from the URL search string (e.g. ?page=2). */
13
13
  params: Record<string, string>;
14
+ /** Parent path params derived from nested routes (e.g. /teams/123/users -> teamId). */
15
+ parentParams: Record<string, string>;
14
16
  }
15
17
 
16
- let globalPath = $state('/');
18
+ let routeVersion = $state(0);
17
19
 
18
20
  export function resetGlobalPath(): void {
19
- globalPath = typeof window !== 'undefined' ? currentPath() : '/';
21
+ routeVersion++;
20
22
  }
21
23
  export function syncGlobalPath(): void {
22
- if (typeof window !== 'undefined') globalPath = currentPath();
24
+ routeVersion++;
23
25
  }
26
+
27
+ /** 简单的英文名词单数化,处理常见复数模式(-ies, -ses, -es, -s) */
28
+ function singularize(word: string): string {
29
+ if (word.endsWith('ies') && word.length > 3) return word.slice(0, -3) + 'y';
30
+ if (word.endsWith('ses') && word.length > 3) return word.slice(0, -2);
31
+ if (word.endsWith('es') && word.length > 2) return word.slice(0, -2);
32
+ if (word.endsWith('s') && word.length > 1) return word.slice(0, -1);
33
+ return word;
34
+ }
35
+
24
36
  registerRouterSync(syncGlobalPath);
25
37
 
26
38
  if (typeof window !== 'undefined') {
27
- globalPath = currentPath();
28
39
  if (!(window as any).__svadminParsedInit) {
29
40
  (window as any).__svadminParsedInit = true;
30
- window.addEventListener('hashchange', () => { globalPath = currentPath(); });
31
- window.addEventListener('popstate', () => { globalPath = currentPath(); });
41
+ window.addEventListener('hashchange', syncGlobalPath);
42
+ window.addEventListener('popstate', syncGlobalPath);
32
43
  }
33
44
  }
34
45
 
@@ -41,13 +52,22 @@ if (typeof window !== 'undefined') {
41
52
  * // parsed.resource === 'posts'
42
53
  * // parsed.action === 'edit'
43
54
  * // parsed.id === '123'
55
+ * // parsed.params contains query params only
56
+ * // parsed.parentParams contains nested route params such as teamId
44
57
  */
45
58
  export function useParsed(): ParsedRoute {
46
- const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
59
+ const adminContext = captureAdminContext();
60
+ const resources = $derived(adminContext.resources);
61
+ const activePath = $derived.by(() => {
62
+ // Any router navigation increments routeVersion. Each tree then asks its
63
+ // captured provider for its own path instead of consuming a global router.
64
+ void routeVersion;
65
+ return adminContext.currentPath();
66
+ });
47
67
 
48
68
  const parsed = $derived.by(() => {
49
- const p = globalPath;
50
- const result: ParsedRoute = { params: {} };
69
+ const p = activePath;
70
+ const result: ParsedRoute = { params: {}, parentParams: {} };
51
71
 
52
72
  // Parse query params from hash
53
73
  const [pathname, queryString] = p.split('?');
@@ -75,12 +95,15 @@ export function useParsed(): ParsedRoute {
75
95
  result.resource = segments[resourceIndex];
76
96
  result.resourcePath = segments.slice(0, resourceIndex + 1).join('/');
77
97
 
78
- // Add parent path params based on `<parentResource>/<parentId>` structure
98
+ // Add parent path params based on `<parentResource>/<parentId>` structure.
99
+ // These are kept separate from query params so that downstream filter
100
+ // injection only applies to path-derived parent params, never to
101
+ // arbitrary query params like ?tenantId=1.
79
102
  for (let i = 0; i < resourceIndex; i += 2) {
80
103
  if (segments[i] && segments[i + 1]) {
81
104
  const parentName = segments[i];
82
- const singular = parentName.endsWith('s') ? parentName.slice(0, -1) : parentName;
83
- result.params[`${singular}Id`] = segments[i + 1];
105
+ const singular = singularize(parentName);
106
+ result.parentParams[`${singular}Id`] = segments[i + 1];
84
107
  }
85
108
  }
86
109
 
@@ -136,5 +159,6 @@ export function useParsed(): ParsedRoute {
136
159
  get action() { return parsed.action; },
137
160
  get id() { return parsed.id; },
138
161
  get params() { return parsed.params; },
162
+ get parentParams() { return parsed.parentParams; },
139
163
  };
140
164
  }
@@ -1,13 +1,14 @@
1
- /* eslint-disable svelte/prefer-svelte-reactivity */
2
- import { useForm, type UseFormOptions, type UseFormReturn } from './form-hooks.svelte';
1
+ import { useForm, type UseFormOptions } from './form-hooks.svelte';
3
2
  import { useQueryClient } from '@tanstack/svelte-query';
4
3
  import { useParsed } from './useParsed.svelte';
5
- import { getResource, getResources } from './context.svelte';
4
+ import { captureAdminContext } from './context.svelte';
6
5
  import { getAdminOptions } from './options.svelte';
7
6
  import { t } from './i18n.svelte';
8
7
  import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
9
- import { useList, type UseListOptions } from './query-hooks.svelte';
8
+ import { useList } from './query-hooks.svelte';
10
9
  import { toast } from './toast.svelte';
10
+ import type { UseSelectOptions } from './hooks.svelte';
11
+ import { useSelect as useSelectImpl } from './hooks.svelte';
11
12
 
12
13
  // ─── useModal ─────────────────────────────────────────────────
13
14
  export function useModal(options?: { defaultVisible?: boolean }) {
@@ -87,11 +88,12 @@ export interface MenuConfig {
87
88
  }
88
89
 
89
90
  export function useMenu() {
91
+ const adminContext = captureAdminContext();
90
92
  const parsed = useParsed();
91
93
 
92
94
  const menuItems = $derived.by<MenuConfig[]>(() => {
93
95
  const adminOptions = getAdminOptions();
94
- const resources = getResources();
96
+ const resources = adminContext.resources;
95
97
  let items: MenuConfig[] = resources
96
98
  .filter((r: ResourceDefinition) => r.showInMenu !== false)
97
99
  .map((r: ResourceDefinition): MenuConfig => ({
@@ -123,6 +125,7 @@ export interface BreadcrumbItem {
123
125
  }
124
126
 
125
127
  export function useBreadcrumb() {
128
+ const adminContext = captureAdminContext();
126
129
  const parsed = useParsed();
127
130
 
128
131
  const items = $derived.by<BreadcrumbItem[]>(() => {
@@ -130,7 +133,7 @@ export function useBreadcrumb() {
130
133
  if (!parsed.resource) return list;
131
134
 
132
135
  try {
133
- const res = getResource(parsed.resource);
136
+ const res = adminContext.getResource(parsed.resource);
134
137
  const chain: { label: string; link: string; icon?: string }[] = [];
135
138
  let current: typeof res | undefined = res;
136
139
  const visited = new Set<string>();
@@ -138,7 +141,7 @@ export function useBreadcrumb() {
138
141
  if (visited.has(current.name)) break;
139
142
  visited.add(current.name);
140
143
  chain.unshift({ label: current.label || current.name, link: `/${current.name}`, icon: current.icon });
141
- current = current.parentName ? getResource(current.parentName) : undefined;
144
+ current = current.parentName ? adminContext.getResource(current.parentName) : undefined;
142
145
  }
143
146
  list.push(...chain);
144
147
 
@@ -181,3 +184,112 @@ export function useRelation<TData extends BaseRecord = BaseRecord, TError = Http
181
184
  export function useNotification() {
182
185
  return { open: toast.info, success: toast.success, error: toast.error, warning: toast.warning };
183
186
  }
187
+
188
+ // ─── Field-specific hooks (refine-compatible) ─────────────────────
189
+ // useCheckboxGroup / useRadioGroup / useAutocomplete are convenience
190
+ // wrappers around useSelect for common form field patterns.
191
+
192
+ export interface UseCheckboxGroupOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'optionLabel' | 'optionValue' | 'defaultValue'> {
193
+ /** Field on the record used as the option label (default: 'title') */
194
+ optionLabel?: string | ((item: TData) => string);
195
+ /** Field on the record used as the checkbox value (default: 'id') */
196
+ optionValue?: string | ((item: TData) => string | number);
197
+ /** Currently selected values — controlled mode */
198
+ defaultValue?: (string | number)[];
199
+ }
200
+
201
+ /**
202
+ * useCheckboxGroup — multi-select field state backed by a resource query.
203
+ * Provides a list of checkbox options plus helpers to toggle individual values.
204
+ */
205
+ export function useCheckboxGroup<TData extends BaseRecord = BaseRecord>(options: UseCheckboxGroupOptions<TData>) {
206
+ const select = useSelectImpl<TData>(options);
207
+ let selected = $state<(string | number)[]>(options.defaultValue ?? []);
208
+
209
+ function toggle(value: string | number) {
210
+ const idx = selected.findIndex(v => String(v) === String(value));
211
+ if (idx >= 0) {
212
+ selected = selected.filter(v => String(v) !== String(value));
213
+ } else {
214
+ selected = [...selected, value];
215
+ }
216
+ }
217
+
218
+ function isSelected(value: string | number): boolean {
219
+ return selected.some(v => String(v) === String(value));
220
+ }
221
+
222
+ return {
223
+ query: select.query,
224
+ get options() { return select.options; },
225
+ onSearchChange: select.onSearchChange,
226
+ get selected() { return selected; },
227
+ toggle,
228
+ isSelected,
229
+ setSelected: (values: (string | number)[]) => { selected = values; },
230
+ };
231
+ }
232
+
233
+ export interface UseRadioGroupOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'optionLabel' | 'optionValue' | 'defaultValue'> {
234
+ optionLabel?: string | ((item: TData) => string);
235
+ optionValue?: string | ((item: TData) => string | number);
236
+ defaultValue?: string | number;
237
+ }
238
+
239
+ /**
240
+ * useRadioGroup — single-select field state backed by a resource query.
241
+ * Provides a list of radio options plus a setter for the currently selected value.
242
+ */
243
+ export function useRadioGroup<TData extends BaseRecord = BaseRecord>(options: UseRadioGroupOptions<TData>) {
244
+ const { defaultValue: _dv, ...selectOpts } = options;
245
+ // useSelect 的 defaultValue 是数组类型;透传单值以便远程选项回填当前已选值
246
+ const select = useSelectImpl<TData>({
247
+ ...selectOpts,
248
+ defaultValue: _dv !== undefined ? [_dv] : [],
249
+ });
250
+ let value = $state<string | number | undefined>(_dv);
251
+
252
+ return {
253
+ query: select.query,
254
+ get options() { return select.options; },
255
+ onSearchChange: select.onSearchChange,
256
+ get value() { return value; },
257
+ setValue: (v: string | number) => { value = v; },
258
+ };
259
+ }
260
+
261
+ export interface UseAutocompleteOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'onSearch' | 'defaultValue'> {
262
+ optionLabel?: string | ((item: TData) => string);
263
+ optionValue?: string | ((item: TData) => string | number);
264
+ /** Field name to search against (default: optionLabel) */
265
+ searchField?: string;
266
+ defaultValue?: string | number;
267
+ }
268
+
269
+ /**
270
+ * useAutocomplete — typeahead search field backed by a resource query.
271
+ * Builds a 'contains' filter on the search field and debounces input.
272
+ */
273
+ export function useAutocomplete<TData extends BaseRecord = BaseRecord>(options: UseAutocompleteOptions<TData>) {
274
+ const { searchField, defaultValue: _dv, ...selectOpts } = options;
275
+ const labelField = typeof options.optionLabel === 'string' ? options.optionLabel : 'title';
276
+
277
+ const select = useSelectImpl<TData>({
278
+ ...selectOpts,
279
+ defaultValue: _dv !== undefined ? [_dv] : [],
280
+ onSearch: (term) => [{
281
+ field: searchField ?? labelField,
282
+ operator: 'contains' as const,
283
+ value: term,
284
+ }],
285
+ });
286
+ let value = $state<string | number | undefined>(_dv);
287
+
288
+ return {
289
+ query: select.query,
290
+ get options() { return select.options; },
291
+ onSearchChange: select.onSearchChange,
292
+ get value() { return value; },
293
+ setValue: (v: string | number) => { value = v; },
294
+ };
295
+ }