@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/package.json +7 -1
- package/src/audit.ts +37 -0
- package/src/auth-hooks.svelte.ts +2 -3
- package/src/auth-hooks.test.ts +205 -0
- package/src/context.svelte.ts +123 -0
- package/src/data-transfer.ts +148 -70
- package/src/form-hooks.svelte.ts +266 -0
- package/src/helpers.ts +157 -0
- package/src/hook-utils.svelte.ts +121 -0
- package/src/hooks.svelte.ts +277 -530
- package/src/http-error.test.ts +85 -0
- package/src/i18n.svelte.ts +87 -1
- package/src/index.ts +42 -9
- package/src/inferencer-openapi.test.ts +192 -0
- package/src/inferencer-openapi.ts +290 -0
- package/src/live-sse.ts +146 -0
- package/src/live-websocket.ts +141 -0
- package/src/live.test.ts +134 -0
- package/src/live.ts +60 -8
- package/src/mutation-hooks.svelte.ts +301 -0
- package/src/options.ts +95 -0
- package/src/query-hooks.svelte.ts +213 -0
- package/src/query-keys.ts +22 -0
- package/src/resource-types.test.ts +79 -0
- package/src/router.ts +28 -4
- package/src/routing-hooks.svelte.ts +99 -0
- package/src/table-hooks.svelte.ts +178 -0
- package/src/table-state.svelte.ts +243 -0
- package/src/toast.svelte.ts +28 -8
- package/src/types.ts +129 -47
- package/src/unsaved-changes.svelte.ts +62 -0
- package/src/url-sync.ts +15 -0
- package/src/useParsed.ts +40 -4
- package/src/useStepsForm.svelte.ts +71 -0
- package/src/utility-hooks.svelte.ts +161 -0
- package/src/context.ts +0 -61
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
|
-
//
|
|
43
|
+
// Find the rightmost segment that matches a known resource name
|
|
44
44
|
const resourceNames = resources.map(r => r.name);
|
|
45
|
-
|
|
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
|
-
}
|