@svadmin/core 0.0.3 → 0.3.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/package.json +4 -1
- package/src/audit.ts +37 -0
- package/src/auth-hooks.svelte.ts +293 -0
- package/src/auth-hooks.test.ts +205 -0
- package/src/context.ts +77 -15
- package/src/data-transfer.test.ts +118 -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 +278 -496
- package/src/http-error.test.ts +85 -0
- package/src/i18n.svelte.ts +24 -4
- package/src/i18n.test.ts +90 -0
- package/src/index.ts +51 -8
- package/src/inferencer-openapi.test.ts +192 -0
- package/src/inferencer-openapi.ts +290 -0
- package/src/inferencer.test.ts +189 -0
- package/src/inferencer.ts +247 -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/permissions.test.ts +55 -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-provider.test.ts +71 -0
- package/src/router-provider.ts +100 -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/toast.svelte.ts +8 -4
- package/src/types.ts +124 -47
- package/src/unsaved-changes.svelte.ts +62 -0
- package/src/url-sync.ts +15 -0
- package/src/useParsed.ts +110 -0
- package/src/useStepsForm.svelte.ts +71 -0
- package/src/utility-hooks.svelte.ts +161 -0
package/src/useParsed.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// useParsed — parse current URL hash into structured route info
|
|
2
|
+
|
|
3
|
+
import { currentPath } from './router';
|
|
4
|
+
import { getResources } from './context';
|
|
5
|
+
|
|
6
|
+
interface ParsedRoute {
|
|
7
|
+
resource?: string;
|
|
8
|
+
action?: 'list' | 'create' | 'edit' | 'show';
|
|
9
|
+
id?: string;
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse the current hash URL into a structured route object.
|
|
15
|
+
* Returns reactive properties that update when the URL changes.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* const parsed = useParsed();
|
|
19
|
+
* // parsed.resource === 'posts'
|
|
20
|
+
* // parsed.action === 'edit'
|
|
21
|
+
* // parsed.id === '123'
|
|
22
|
+
*/
|
|
23
|
+
export function useParsed(): ParsedRoute {
|
|
24
|
+
const path = $derived(currentPath());
|
|
25
|
+
const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
|
|
26
|
+
|
|
27
|
+
const parsed = $derived.by(() => {
|
|
28
|
+
const p = path;
|
|
29
|
+
const result: ParsedRoute = { params: {} };
|
|
30
|
+
|
|
31
|
+
// Parse query params from hash
|
|
32
|
+
const [pathname, queryString] = p.split('?');
|
|
33
|
+
if (queryString) {
|
|
34
|
+
const sp = new URLSearchParams(queryString);
|
|
35
|
+
for (const [k, v] of sp.entries()) {
|
|
36
|
+
result.params[k] = v;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
41
|
+
if (segments.length === 0) return result;
|
|
42
|
+
|
|
43
|
+
// Find the rightmost segment that matches a known resource name
|
|
44
|
+
const resourceNames = resources.map(r => r.name);
|
|
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
|
|
83
|
+
result.resource = segments[0];
|
|
84
|
+
|
|
85
|
+
if (segments.length === 1) {
|
|
86
|
+
result.action = 'list';
|
|
87
|
+
} else if (segments[1] === 'create') {
|
|
88
|
+
result.action = 'create';
|
|
89
|
+
} else if (segments[1] === 'edit' && segments[2]) {
|
|
90
|
+
result.action = 'edit';
|
|
91
|
+
result.id = segments[2];
|
|
92
|
+
} else if (segments[1] === 'show' && segments[2]) {
|
|
93
|
+
result.action = 'show';
|
|
94
|
+
result.id = segments[2];
|
|
95
|
+
} else if (segments[1]) {
|
|
96
|
+
result.action = 'show';
|
|
97
|
+
result.id = segments[1];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
get resource() { return parsed.resource; },
|
|
106
|
+
get action() { return parsed.action; },
|
|
107
|
+
get id() { return parsed.id; },
|
|
108
|
+
get params() { return parsed.params; },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -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 any): 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';
|
|
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
|
+
}
|