@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.
Files changed (41) hide show
  1. package/package.json +4 -1
  2. package/src/audit.ts +37 -0
  3. package/src/auth-hooks.svelte.ts +293 -0
  4. package/src/auth-hooks.test.ts +205 -0
  5. package/src/context.ts +77 -15
  6. package/src/data-transfer.test.ts +118 -0
  7. package/src/data-transfer.ts +148 -70
  8. package/src/form-hooks.svelte.ts +266 -0
  9. package/src/helpers.ts +157 -0
  10. package/src/hook-utils.svelte.ts +121 -0
  11. package/src/hooks.svelte.ts +278 -496
  12. package/src/http-error.test.ts +85 -0
  13. package/src/i18n.svelte.ts +24 -4
  14. package/src/i18n.test.ts +90 -0
  15. package/src/index.ts +51 -8
  16. package/src/inferencer-openapi.test.ts +192 -0
  17. package/src/inferencer-openapi.ts +290 -0
  18. package/src/inferencer.test.ts +189 -0
  19. package/src/inferencer.ts +247 -0
  20. package/src/live-sse.ts +146 -0
  21. package/src/live-websocket.ts +141 -0
  22. package/src/live.test.ts +134 -0
  23. package/src/live.ts +60 -8
  24. package/src/mutation-hooks.svelte.ts +301 -0
  25. package/src/options.ts +95 -0
  26. package/src/permissions.test.ts +55 -0
  27. package/src/query-hooks.svelte.ts +213 -0
  28. package/src/query-keys.ts +22 -0
  29. package/src/resource-types.test.ts +79 -0
  30. package/src/router-provider.test.ts +71 -0
  31. package/src/router-provider.ts +100 -0
  32. package/src/router.ts +28 -4
  33. package/src/routing-hooks.svelte.ts +99 -0
  34. package/src/table-hooks.svelte.ts +178 -0
  35. package/src/toast.svelte.ts +8 -4
  36. package/src/types.ts +124 -47
  37. package/src/unsaved-changes.svelte.ts +62 -0
  38. package/src/url-sync.ts +15 -0
  39. package/src/useParsed.ts +110 -0
  40. package/src/useStepsForm.svelte.ts +71 -0
  41. package/src/utility-hooks.svelte.ts +161 -0
@@ -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
+ }