@svadmin/core 0.0.6 → 0.4.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/src/url-sync.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  // URL state sync — sync pagination/sort/filters with hash params
2
2
 
3
+ import type { Filter } from './types';
4
+
3
5
  export interface URLState {
4
6
  page?: number;
5
7
  pageSize?: number;
6
8
  sortField?: string;
7
9
  sortOrder?: 'asc' | 'desc';
8
10
  search?: string;
11
+ filters?: Filter[];
9
12
  }
10
13
 
11
14
  export function readURLState(): URLState {
15
+ if (typeof window === 'undefined') return {};
12
16
  const hash = window.location.hash;
13
17
  const queryIdx = hash.indexOf('?');
14
18
  if (queryIdx === -1) return {};
@@ -31,10 +35,18 @@ export function readURLState(): URLState {
31
35
  const search = params.get('q');
32
36
  if (search) state.search = search;
33
37
 
38
+ const filtersRaw = params.get('filters');
39
+ if (filtersRaw) {
40
+ try {
41
+ state.filters = JSON.parse(filtersRaw);
42
+ } catch { /* ignore invalid json */ }
43
+ }
44
+
34
45
  return state;
35
46
  }
36
47
 
37
48
  export function writeURLState(state: URLState): void {
49
+ if (typeof window === 'undefined') return;
38
50
  const hash = window.location.hash;
39
51
  const pathIdx = hash.indexOf('?');
40
52
  const basePath = pathIdx === -1 ? hash : hash.slice(0, pathIdx);
@@ -45,6 +57,9 @@ export function writeURLState(state: URLState): void {
45
57
  if (state.sortField) params.set('sort', state.sortField);
46
58
  if (state.sortOrder) params.set('order', state.sortOrder);
47
59
  if (state.search) params.set('q', state.search);
60
+ if (state.filters && state.filters.length > 0) {
61
+ params.set('filters', JSON.stringify(state.filters));
62
+ }
48
63
 
49
64
  const qs = params.toString();
50
65
  const newHash = qs ? `${basePath}?${qs}` : basePath;
package/src/useParsed.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // useParsed — parse current URL hash into structured route info
2
2
 
3
3
  import { currentPath } from './router';
4
- import { getResources } from './context';
4
+ import { getResources } from './context.svelte';
5
5
 
6
6
  interface ParsedRoute {
7
7
  resource?: string;
@@ -40,9 +40,46 @@ export function useParsed(): ParsedRoute {
40
40
  const segments = pathname.split('/').filter(Boolean);
41
41
  if (segments.length === 0) return result;
42
42
 
43
- // First segment is typically the resource name
43
+ // Find the rightmost segment that matches a known resource name
44
44
  const resourceNames = resources.map(r => r.name);
45
- if (resourceNames.includes(segments[0])) {
45
+ let resourceIndex = -1;
46
+ for (let i = segments.length - 1; i >= 0; i--) {
47
+ if (resourceNames.includes(segments[i])) {
48
+ resourceIndex = i;
49
+ break;
50
+ }
51
+ }
52
+
53
+ if (resourceIndex !== -1) {
54
+ result.resource = segments[resourceIndex];
55
+
56
+ // Add parent path params based on `<parentResource>/<parentId>` structure
57
+ for (let i = 0; i < resourceIndex; i += 2) {
58
+ if (segments[i] && segments[i + 1]) {
59
+ const parentName = segments[i];
60
+ const singular = parentName.endsWith('s') ? parentName.slice(0, -1) : parentName;
61
+ result.params[`${singular}Id`] = segments[i + 1];
62
+ }
63
+ }
64
+
65
+ const restSegments = segments.slice(resourceIndex + 1);
66
+ if (restSegments.length === 0) {
67
+ result.action = 'list';
68
+ } else if (restSegments[0] === 'create') {
69
+ result.action = 'create';
70
+ } else if (restSegments[0] === 'edit' && restSegments[1]) {
71
+ result.action = 'edit';
72
+ result.id = restSegments[1];
73
+ } else if (restSegments[0] === 'show' && restSegments[1]) {
74
+ result.action = 'show';
75
+ result.id = restSegments[1];
76
+ } else if (restSegments[0]) {
77
+ // Legacy: /:resource/:id
78
+ result.action = 'show';
79
+ result.id = restSegments[0];
80
+ }
81
+ } else {
82
+ // Fallback if resource definitions are not loaded yet
46
83
  result.resource = segments[0];
47
84
 
48
85
  if (segments.length === 1) {
@@ -56,7 +93,6 @@ export function useParsed(): ParsedRoute {
56
93
  result.action = 'show';
57
94
  result.id = segments[2];
58
95
  } else if (segments[1]) {
59
- // Legacy: /:resource/:id
60
96
  result.action = 'show';
61
97
  result.id = segments[1];
62
98
  }
@@ -0,0 +1,71 @@
1
+ import { useForm } from './form-hooks.svelte';
2
+ import type { UseFormOptions, UseFormResult } from './form-hooks.svelte';
3
+ import type { BaseRecord, HttpError } from './types';
4
+
5
+ export interface UseStepsFormOptions<
6
+ TQueryFnData extends BaseRecord = BaseRecord,
7
+ TError = HttpError,
8
+ TVariables = Record<string, unknown>,
9
+ TData extends BaseRecord = TQueryFnData
10
+ > extends UseFormOptions<TQueryFnData, TError, TVariables, TData> {
11
+ /** Default step index. Starts at 0. */
12
+ defaultStep?: number;
13
+ /** Validate the form when navigating back? */
14
+ isBackValidate?: boolean;
15
+ }
16
+
17
+ export interface UseStepsFormResult<
18
+ TQueryFnData extends BaseRecord = BaseRecord,
19
+ TError = HttpError,
20
+ TVariables = Record<string, unknown>,
21
+ TData extends BaseRecord = TQueryFnData
22
+ > extends UseFormResult<TQueryFnData, TError, TVariables, TData> {
23
+ currentStep: number;
24
+ gotoStep: (step: number) => void;
25
+ next: () => void;
26
+ prev: () => void;
27
+ }
28
+
29
+ /**
30
+ * useStepsForm
31
+ * Extends `useForm` with step navigation state (currentStep, next, prev, gotoStep).
32
+ */
33
+ export function useStepsForm<
34
+ TQueryFnData extends BaseRecord = BaseRecord,
35
+ TError = HttpError,
36
+ TVariables = Record<string, unknown>,
37
+ TData extends BaseRecord = TQueryFnData
38
+ >(options: UseStepsFormOptions<TQueryFnData, TError, TVariables, TData> = {} as UseStepsFormOptions<TQueryFnData, TError, TVariables, TData>): UseStepsFormResult<TQueryFnData, TError, TVariables, TData> {
39
+ const form = useForm<TQueryFnData, TError, TVariables, TData>(options);
40
+
41
+ let currentStep = $state(options.defaultStep ?? 0);
42
+
43
+ function gotoStep(step: number) {
44
+ currentStep = Math.max(0, step);
45
+ }
46
+
47
+ function next() {
48
+ gotoStep(currentStep + 1);
49
+ }
50
+
51
+ function prev() {
52
+ if (currentStep > 0) {
53
+ gotoStep(currentStep - 1);
54
+ }
55
+ }
56
+
57
+ // Inject stepping features into the returned useForm object while preserving its reactive getters.
58
+ const result = form as unknown as UseStepsFormResult<TQueryFnData, TError, TVariables, TData>;
59
+
60
+ Object.defineProperty(result, 'currentStep', {
61
+ get: () => currentStep,
62
+ enumerable: true,
63
+ configurable: true
64
+ });
65
+
66
+ result.gotoStep = gotoStep;
67
+ result.next = next;
68
+ result.prev = prev;
69
+
70
+ return result;
71
+ }
@@ -0,0 +1,161 @@
1
+ import { useForm, type UseFormOptions } from './form-hooks.svelte';
2
+ import { useQueryClient } from '@tanstack/svelte-query';
3
+ import { useParsed } from './useParsed';
4
+ import { getResource, getResources } from './context.svelte';
5
+ import { getAdminOptions } from './options';
6
+ import { navigate } from './router';
7
+ import { t } from './i18n.svelte';
8
+ import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
9
+ import { useList, type UseListOptions } from './query-hooks.svelte';
10
+ import { toast } from './toast.svelte';
11
+
12
+ // ─── useModal ─────────────────────────────────────────────────
13
+ export function useModal(options?: { defaultVisible?: boolean }) {
14
+ let visible = $state(options?.defaultVisible ?? false);
15
+ return {
16
+ get visible() { return visible; },
17
+ show() { visible = true; },
18
+ close() { visible = false; },
19
+ };
20
+ }
21
+
22
+ // ─── useModalForm ─────────────────────────────────────────────
23
+
24
+ export interface UseModalFormOptions<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> extends UseFormOptions<TQueryFnData, TError, TVariables, TData> {
25
+ defaultVisible?: boolean;
26
+ autoSave?: UseFormOptions<TQueryFnData, TError, TVariables, TData>['autoSave'] & {
27
+ invalidateOnClose?: boolean;
28
+ };
29
+ }
30
+
31
+ export function useModalForm<
32
+ TQueryFnData extends BaseRecord = BaseRecord,
33
+ TError = HttpError,
34
+ TVariables = Record<string, unknown>,
35
+ TData extends BaseRecord = TQueryFnData
36
+ >(options: UseModalFormOptions<TQueryFnData, TError, TVariables, TData> = {}) {
37
+ const queryClient = useQueryClient();
38
+ const formState = useForm(options);
39
+ let visible = $state(options.defaultVisible ?? false);
40
+
41
+ function show(id?: string | number) {
42
+ if (id !== undefined) formState.setId(id);
43
+ visible = true;
44
+ }
45
+
46
+ function close() {
47
+ visible = false;
48
+ formState.clearErrors();
49
+ if (options.autoSave?.invalidateOnClose) {
50
+ queryClient.invalidateQueries({ queryKey: [formState.resource] });
51
+ }
52
+ }
53
+
54
+ return {
55
+ get visible() { return visible; },
56
+ show,
57
+ close,
58
+ get modalProps() { return { visible, close }; },
59
+ formState,
60
+ };
61
+ }
62
+
63
+ export const useDrawerForm = useModalForm; // Alias for UI variations
64
+
65
+ // ─── useMenu ──────────────────────────────────────────────────
66
+
67
+ export interface MenuConfig {
68
+ name: string;
69
+ route: string;
70
+ label: string;
71
+ icon?: string;
72
+ parentName?: string;
73
+ order?: number;
74
+ }
75
+
76
+ export function useMenu() {
77
+ const adminOptions = getAdminOptions();
78
+ const resources = getResources();
79
+ const parsed = useParsed();
80
+
81
+ const menuItems = $derived.by<MenuConfig[]>(() => {
82
+ let items: MenuConfig[] = resources
83
+ .filter((r: ResourceDefinition) => r.showInMenu !== false)
84
+ .map((r: ResourceDefinition): MenuConfig => ({
85
+ name: r.name,
86
+ route: `/${r.name}`,
87
+ label: r.label || r.name, // Usually handled by i18n label mapping
88
+ icon: r.icon,
89
+ parentName: r.parentName,
90
+ order: r.menuOrder ?? 999,
91
+ }));
92
+
93
+ if (adminOptions.menuItems) items = adminOptions.menuItems(items);
94
+ items.sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
95
+ return items;
96
+ });
97
+
98
+ return {
99
+ get menuItems() { return menuItems; },
100
+ get selectedKey() { return parsed.resource ?? '/'; },
101
+ };
102
+ }
103
+
104
+ // ─── useBreadcrumb ────────────────────────────────────────────
105
+
106
+ export interface BreadcrumbItem {
107
+ label: string;
108
+ link?: string;
109
+ icon?: string;
110
+ }
111
+
112
+ export function useBreadcrumb() {
113
+ const parsed = useParsed();
114
+
115
+ const items = $derived.by<BreadcrumbItem[]>(() => {
116
+ const list: BreadcrumbItem[] = [];
117
+ if (!parsed.resource) return list;
118
+
119
+ try {
120
+ const res = getResource(parsed.resource);
121
+ list.push({ label: res.label || res.name, link: `/${res.name}`, icon: res.icon });
122
+
123
+ if (parsed.action === 'create') {
124
+ list.push({ label: t('common.create') });
125
+ } else if (parsed.action === 'edit' && parsed.id) {
126
+ list.push({ label: t('common.edit') });
127
+ list.push({ label: String(parsed.id) });
128
+ } else if (parsed.action === 'show' && parsed.id) {
129
+ list.push({ label: t('common.show') });
130
+ list.push({ label: String(parsed.id) });
131
+ }
132
+ } catch {
133
+ // Ignored
134
+ }
135
+ return list;
136
+ });
137
+
138
+ return { get items() { return items; } };
139
+ }
140
+
141
+ // ─── useRelation ───────────────────────────────────────────
142
+
143
+ export interface UseRelationOptions<TData extends BaseRecord = BaseRecord, TError = HttpError> {
144
+ resource: KnownResources;
145
+ id?: string | number;
146
+ pagination?: { current?: number; pageSize?: number };
147
+ sorters?: { field: string; order: 'asc' | 'desc' }[];
148
+ filters?: Filter[];
149
+ queryOptions?: { enabled?: boolean; staleTime?: number };
150
+ }
151
+
152
+ export function useRelation<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseRelationOptions<TData, TError>) {
153
+ return useList<TData, TError>({
154
+ ...options
155
+ });
156
+ }
157
+
158
+ // ─── useNotification ────────────────────────────────────────
159
+ export function useNotification() {
160
+ return { open: toast.info, success: toast.success, error: toast.error, warning: toast.warning };
161
+ }
package/src/context.ts DELETED
@@ -1,61 +0,0 @@
1
- // Svelte context — provides DataProvider, AuthProvider, and Resources to all components
2
-
3
- import { getContext, setContext } from 'svelte';
4
- import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
5
- import type { RouterProvider } from './router-provider';
6
-
7
- const DATA_PROVIDER_KEY = Symbol('data-provider');
8
- const AUTH_PROVIDER_KEY = Symbol('auth-provider');
9
- const RESOURCES_KEY = Symbol('resources');
10
- const ROUTER_PROVIDER_KEY = Symbol('router-provider');
11
-
12
- // ─── Setters (called once in App.svelte) ────────────────────────
13
-
14
- export function setDataProvider(provider: DataProvider): void {
15
- setContext(DATA_PROVIDER_KEY, provider);
16
- }
17
-
18
- export function setAuthProvider(provider: AuthProvider): void {
19
- setContext(AUTH_PROVIDER_KEY, provider);
20
- }
21
-
22
- export function setResources(resources: ResourceDefinition[]): void {
23
- setContext(RESOURCES_KEY, resources);
24
- }
25
-
26
- // ─── Getters (called from any child component) ──────────────────
27
-
28
- export function getDataProvider(): DataProvider {
29
- const provider = getContext<DataProvider>(DATA_PROVIDER_KEY);
30
- if (!provider) throw new Error('DataProvider not found. Did you call setDataProvider in App.svelte?');
31
- return provider;
32
- }
33
-
34
- export function getAuthProvider(): AuthProvider {
35
- const provider = getContext<AuthProvider>(AUTH_PROVIDER_KEY);
36
- if (!provider) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
37
- return provider;
38
- }
39
-
40
- export function getResources(): ResourceDefinition[] {
41
- const resources = getContext<ResourceDefinition[]>(RESOURCES_KEY);
42
- if (!resources) throw new Error('Resources not found. Did you call setResources in App.svelte?');
43
- return resources;
44
- }
45
-
46
- export function getResource(name: string): ResourceDefinition {
47
- const resources = getResources();
48
- const resource = resources.find(r => r.name === name);
49
- if (!resource) throw new Error(`Resource "${name}" not found in resource definitions.`);
50
- return resource;
51
- }
52
-
53
- // ─── Router Provider ────────────────────────────────────────────
54
-
55
- export function setRouterProvider(provider: RouterProvider): void {
56
- setContext(ROUTER_PROVIDER_KEY, provider);
57
- }
58
-
59
- export function getRouterProvider(): RouterProvider | undefined {
60
- return getContext<RouterProvider>(ROUTER_PROVIDER_KEY);
61
- }