@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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
|
|
2
|
+
import { getAdminOptions } from './options';
|
|
3
|
+
import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
|
|
4
|
+
import { useParsed } from './useParsed';
|
|
5
|
+
import {
|
|
6
|
+
createOvertimeTracker,
|
|
7
|
+
createLiveSubscription,
|
|
8
|
+
fireSuccessNotification,
|
|
9
|
+
fireErrorNotification,
|
|
10
|
+
} from './hook-utils.svelte';
|
|
11
|
+
import type { NotificationConfig, OvertimeOptions, LiveSubscriptionParams } from './hook-utils.svelte';
|
|
12
|
+
import type {
|
|
13
|
+
GetListParams, GetListResult, Pagination, Sort, Filter, BaseRecord, HttpError,
|
|
14
|
+
GetOneParams, GetOneResult, GetManyParams, GetManyResult, CustomParams, CustomResult,
|
|
15
|
+
KnownResources,
|
|
16
|
+
} from './types';
|
|
17
|
+
import type { LiveMode, LiveEvent } from './live';
|
|
18
|
+
|
|
19
|
+
// ─── useList ───────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface UseListOptions<TData extends BaseRecord = BaseRecord, TError = HttpError> {
|
|
22
|
+
resource?: KnownResources;
|
|
23
|
+
pagination?: Pagination;
|
|
24
|
+
sorters?: Sort[];
|
|
25
|
+
filters?: Filter[];
|
|
26
|
+
meta?: Record<string, unknown>;
|
|
27
|
+
dataProviderName?: string;
|
|
28
|
+
queryOptions?: { staleTime?: number; enabled?: boolean; cacheTime?: number; refetchOnWindowFocus?: boolean };
|
|
29
|
+
successNotification?: NotificationConfig;
|
|
30
|
+
errorNotification?: NotificationConfig;
|
|
31
|
+
liveMode?: LiveMode;
|
|
32
|
+
onLiveEvent?: (event: LiveEvent) => void;
|
|
33
|
+
liveParams?: Record<string, unknown>;
|
|
34
|
+
overtimeOptions?: OvertimeOptions;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useList<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseListOptions<TData, TError> = {}) {
|
|
38
|
+
const parsed = useParsed();
|
|
39
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
40
|
+
const adminOptions = getAdminOptions();
|
|
41
|
+
const provider = getDataProviderForResource(resource, options.dataProviderName);
|
|
42
|
+
|
|
43
|
+
const queryOptions = options.queryOptions;
|
|
44
|
+
|
|
45
|
+
const query = createQuery<GetListResult<TData>, TError>(() => ({
|
|
46
|
+
queryKey: [resource, 'list', options.pagination, options.sorters, options.filters, options.meta],
|
|
47
|
+
queryFn: async () => {
|
|
48
|
+
const result = await provider.getList<TData>({
|
|
49
|
+
resource,
|
|
50
|
+
pagination: options.pagination,
|
|
51
|
+
sorters: options.sorters,
|
|
52
|
+
filters: options.filters,
|
|
53
|
+
meta: options.meta,
|
|
54
|
+
});
|
|
55
|
+
return result;
|
|
56
|
+
},
|
|
57
|
+
enabled: queryOptions?.enabled ?? true,
|
|
58
|
+
staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
|
|
59
|
+
gcTime: queryOptions?.cacheTime ?? adminOptions.reactQuery?.cacheTime,
|
|
60
|
+
refetchOnWindowFocus: queryOptions?.refetchOnWindowFocus ?? adminOptions.reactQuery?.refetchOnWindowFocus,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
|
|
64
|
+
|
|
65
|
+
createLiveSubscription({
|
|
66
|
+
resource,
|
|
67
|
+
liveProvider: getLiveProvider(),
|
|
68
|
+
liveMode: options.liveMode ?? adminOptions.liveMode,
|
|
69
|
+
onLiveEvent: (e) => {
|
|
70
|
+
options.onLiveEvent?.(e);
|
|
71
|
+
adminOptions.onLiveEvent?.(e);
|
|
72
|
+
},
|
|
73
|
+
liveParams: options.liveParams,
|
|
74
|
+
enabled: queryOptions?.enabled ?? true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
$effect(() => {
|
|
78
|
+
if (query.isSuccess) {
|
|
79
|
+
fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
|
|
80
|
+
} else if (query.isError) {
|
|
81
|
+
fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
query,
|
|
87
|
+
get overtime() { return overtime; }
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── useOne ────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export interface UseOneOptions<TData extends BaseRecord = BaseRecord, TError = HttpError> {
|
|
94
|
+
resource?: KnownResources;
|
|
95
|
+
id?: string | number;
|
|
96
|
+
meta?: Record<string, unknown>;
|
|
97
|
+
dataProviderName?: string;
|
|
98
|
+
queryOptions?: { staleTime?: number; enabled?: boolean; cacheTime?: number; refetchOnWindowFocus?: boolean };
|
|
99
|
+
successNotification?: NotificationConfig;
|
|
100
|
+
errorNotification?: NotificationConfig;
|
|
101
|
+
liveMode?: LiveMode;
|
|
102
|
+
onLiveEvent?: (event: LiveEvent) => void;
|
|
103
|
+
liveParams?: Record<string, unknown>;
|
|
104
|
+
overtimeOptions?: OvertimeOptions;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseOneOptions<TData, TError> = {}) {
|
|
108
|
+
const parsed = useParsed();
|
|
109
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
110
|
+
const id = options.id ?? parsed.id;
|
|
111
|
+
const adminOptions = getAdminOptions();
|
|
112
|
+
const provider = getDataProviderForResource(resource, options.dataProviderName);
|
|
113
|
+
|
|
114
|
+
const queryOptions = options.queryOptions;
|
|
115
|
+
|
|
116
|
+
const query = createQuery<GetOneResult<TData>, TError>(() => ({
|
|
117
|
+
queryKey: [resource, 'one', id, options.meta],
|
|
118
|
+
queryFn: async () => {
|
|
119
|
+
if (id == null) throw new Error('useOne requires an id');
|
|
120
|
+
const result = await provider.getOne<TData>({
|
|
121
|
+
resource,
|
|
122
|
+
id,
|
|
123
|
+
meta: options.meta,
|
|
124
|
+
});
|
|
125
|
+
return result;
|
|
126
|
+
},
|
|
127
|
+
enabled: (queryOptions?.enabled ?? true) && id != null,
|
|
128
|
+
staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
|
|
129
|
+
gcTime: queryOptions?.cacheTime ?? adminOptions.reactQuery?.cacheTime,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
|
|
133
|
+
|
|
134
|
+
$effect(() => {
|
|
135
|
+
if (query.isSuccess && options.successNotification) {
|
|
136
|
+
fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
|
|
137
|
+
} else if (query.isError) {
|
|
138
|
+
fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return { query, get overtime() { return overtime; } };
|
|
143
|
+
}
|
|
144
|
+
// ─── useShow ──────────────────────────────────────────────────
|
|
145
|
+
export function useShow<TData extends BaseRecord = BaseRecord, TError = HttpError>(
|
|
146
|
+
options: UseOneOptions<TData, TError> = {}
|
|
147
|
+
) {
|
|
148
|
+
const parsed = useParsed();
|
|
149
|
+
let showId = $state<string | number | undefined>(options.id ?? parsed.id);
|
|
150
|
+
|
|
151
|
+
function setShowId(id: string | number) { showId = id; }
|
|
152
|
+
|
|
153
|
+
const result = useOne<TData, TError>({
|
|
154
|
+
...options,
|
|
155
|
+
get id() { return showId; },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
get query() { return result.query; },
|
|
160
|
+
get showId() { return showId; },
|
|
161
|
+
setShowId,
|
|
162
|
+
get overtime() { return result.overtime; },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── useMany ───────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export interface UseManyOptions<TData extends BaseRecord = BaseRecord, TError = HttpError> {
|
|
169
|
+
resource: KnownResources;
|
|
170
|
+
ids: (string | number)[];
|
|
171
|
+
meta?: Record<string, unknown>;
|
|
172
|
+
dataProviderName?: string;
|
|
173
|
+
queryOptions?: { staleTime?: number; enabled?: boolean; cacheTime?: number; refetchOnWindowFocus?: boolean };
|
|
174
|
+
successNotification?: NotificationConfig;
|
|
175
|
+
errorNotification?: NotificationConfig;
|
|
176
|
+
liveMode?: LiveMode;
|
|
177
|
+
onLiveEvent?: (event: LiveEvent) => void;
|
|
178
|
+
liveParams?: Record<string, unknown>;
|
|
179
|
+
overtimeOptions?: OvertimeOptions;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseManyOptions<TData, TError>) {
|
|
183
|
+
const { resource, ids, meta, dataProviderName, queryOptions } = options;
|
|
184
|
+
const adminOptions = getAdminOptions();
|
|
185
|
+
const provider = getDataProviderForResource(resource, dataProviderName);
|
|
186
|
+
|
|
187
|
+
const query = createQuery<GetManyResult<TData>, TError>(() => ({
|
|
188
|
+
queryKey: [resource, 'many', ids, meta],
|
|
189
|
+
queryFn: async () => {
|
|
190
|
+
if (!ids.length) return { data: [] };
|
|
191
|
+
if (provider.getMany) {
|
|
192
|
+
return provider.getMany<TData>({ resource, ids, meta });
|
|
193
|
+
}
|
|
194
|
+
const results = await Promise.all(ids.map(id => provider.getOne<TData>({ resource, id, meta })));
|
|
195
|
+
return { data: results.map(r => r.data) };
|
|
196
|
+
},
|
|
197
|
+
enabled: (queryOptions?.enabled ?? true) && ids.length > 0,
|
|
198
|
+
staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
|
|
202
|
+
|
|
203
|
+
$effect(() => {
|
|
204
|
+
if (query.isSuccess && options.successNotification) fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
|
|
205
|
+
else if (query.isError) fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { query, get overtime() { return overtime; } };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function useApiUrl(dataProviderName?: string): string {
|
|
212
|
+
return getDataProvider(dataProviderName).getApiUrl();
|
|
213
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const keys = () => {
|
|
2
|
+
let _data = 'default';
|
|
3
|
+
let _resource = '';
|
|
4
|
+
let _action = '';
|
|
5
|
+
let _params: unknown = undefined;
|
|
6
|
+
|
|
7
|
+
const builder = {
|
|
8
|
+
data: (name: string) => { _data = name; return builder; },
|
|
9
|
+
resource: (name: string) => { _resource = name; return builder; },
|
|
10
|
+
action: (name: string) => { _action = name; return builder; },
|
|
11
|
+
params: (params: unknown) => { _params = params; return builder; },
|
|
12
|
+
get: (): unknown[] => {
|
|
13
|
+
const parts: unknown[] = ['data', _data];
|
|
14
|
+
if (_resource) parts.push(_resource);
|
|
15
|
+
if (_action) parts.push(_action);
|
|
16
|
+
if (_params !== undefined) parts.push(_params);
|
|
17
|
+
return parts;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return builder;
|
|
22
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Resource Type Registry — type-level tests
|
|
2
|
+
import { describe, test, expect } from 'bun:test';
|
|
3
|
+
import type { ResourceTypeMap, KnownResources, InferData } from './types';
|
|
4
|
+
|
|
5
|
+
describe('Resource Type Registry', () => {
|
|
6
|
+
test('ResourceTypeMap is an empty interface by default', () => {
|
|
7
|
+
// When no declaration merging is used, keyof ResourceTypeMap = never
|
|
8
|
+
type Keys = keyof ResourceTypeMap;
|
|
9
|
+
// This compiles — verifying the interface exists and is empty
|
|
10
|
+
const _check: Keys extends never ? true : false = true;
|
|
11
|
+
expect(_check).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('KnownResources falls back to string when ResourceTypeMap is empty', () => {
|
|
15
|
+
// When ResourceTypeMap is empty, KnownResources should be string
|
|
16
|
+
const resource: KnownResources = 'anything';
|
|
17
|
+
expect(typeof resource).toBe('string');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('InferData falls back to Record<string, unknown> for unknown resources', () => {
|
|
21
|
+
// For any resource not in ResourceTypeMap, InferData should return Record<string, unknown>
|
|
22
|
+
type Result = InferData<'nonexistent'>;
|
|
23
|
+
const data: Result = { arbitrary: 'value', number: 42 };
|
|
24
|
+
expect(data.arbitrary).toBe('value');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('type utilities are structurally sound', () => {
|
|
28
|
+
// Simulate what happens with declaration merging by testing the conditional types
|
|
29
|
+
// When R extends keyof ResourceTypeMap (which is never when empty),
|
|
30
|
+
// InferData should return Record<string, unknown>
|
|
31
|
+
type TestInfer = InferData<'users'>;
|
|
32
|
+
const obj: TestInfer = { id: '1', name: 'test' };
|
|
33
|
+
expect(obj).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('KnownResources accepts any string when ResourceTypeMap is empty', () => {
|
|
37
|
+
// This verifies backward compatibility — all strings are valid resource names
|
|
38
|
+
const resources: KnownResources[] = ['users', 'posts', 'categories', 'anything'];
|
|
39
|
+
expect(resources).toHaveLength(4);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ─── Declaration Merging Example (compile-time only) ──────────
|
|
44
|
+
// The following block verifies that declaration merging works correctly.
|
|
45
|
+
// It's wrapped in a function so it doesn't affect runtime.
|
|
46
|
+
|
|
47
|
+
function _typeTests() {
|
|
48
|
+
// Simulate declaration merging
|
|
49
|
+
interface TestResourceMap {
|
|
50
|
+
users: { id: string; name: string; email: string };
|
|
51
|
+
posts: { id: string; title: string; content: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Test conditional type behavior with a populated map
|
|
55
|
+
type TestKnown<M> = keyof M extends never ? string : Extract<keyof M, string>;
|
|
56
|
+
type TestInfer<R extends string, M> = R extends keyof M ? M[R] : Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
// When map is populated, resources are constrained
|
|
59
|
+
type Resources = TestKnown<TestResourceMap>;
|
|
60
|
+
const _r1: Resources = 'users';
|
|
61
|
+
const _r2: Resources = 'posts';
|
|
62
|
+
// @ts-expect-error — 'invalid' is not in TestResourceMap
|
|
63
|
+
const _r3: Resources = 'invalid';
|
|
64
|
+
|
|
65
|
+
// InferData resolves correctly
|
|
66
|
+
type UserData = TestInfer<'users', TestResourceMap>;
|
|
67
|
+
const _user: UserData = { id: '1', name: 'Alice', email: 'a@b.com' };
|
|
68
|
+
|
|
69
|
+
type PostData = TestInfer<'posts', TestResourceMap>;
|
|
70
|
+
const _post: PostData = { id: '1', title: 'Hello', content: 'World' };
|
|
71
|
+
|
|
72
|
+
// Unknown resource falls back
|
|
73
|
+
type UnknownData = TestInfer<'unknown', TestResourceMap>;
|
|
74
|
+
const _unknown: UnknownData = { any: 'thing' };
|
|
75
|
+
|
|
76
|
+
void _r1; void _r2; void _r3; void _user; void _post; void _unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
void _typeTests;
|
package/src/router.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Router — path matching + navigation (supports hash & history via RouterProvider)
|
|
2
|
+
|
|
3
|
+
import type { RouterProvider } from './router-provider';
|
|
2
4
|
|
|
3
5
|
interface RouteMatch {
|
|
4
6
|
route: string;
|
|
5
7
|
params: Record<string, string>;
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
// Store a reference to the active RouterProvider (set by router-state.svelte.ts)
|
|
11
|
+
let _routerProvider: RouterProvider | undefined;
|
|
12
|
+
|
|
13
|
+
export function setActiveRouterProvider(provider: RouterProvider | undefined) {
|
|
14
|
+
_routerProvider = provider;
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
// Parse route pattern like /products/edit/:id into regex
|
|
9
18
|
function compileRoute(pattern: string): { regex: RegExp; keys: string[] } {
|
|
10
19
|
const keys: string[] = [];
|
|
@@ -17,11 +26,15 @@ function compileRoute(pattern: string): { regex: RegExp; keys: string[] } {
|
|
|
17
26
|
return { regex: new RegExp(`^${regexStr}$`), keys };
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Match a pathname against a list of route patterns.
|
|
31
|
+
* Accepts both hash paths (#/foo) and plain paths (/foo).
|
|
32
|
+
*/
|
|
20
33
|
export function matchRoute(
|
|
21
|
-
|
|
34
|
+
pathname: string,
|
|
22
35
|
routes: string[]
|
|
23
36
|
): RouteMatch | null {
|
|
24
|
-
const path =
|
|
37
|
+
const path = pathname.replace(/^#/, '').split('?')[0] || '/';
|
|
25
38
|
|
|
26
39
|
for (const pattern of routes) {
|
|
27
40
|
const { regex, keys } = compileRoute(pattern);
|
|
@@ -37,10 +50,21 @@ export function matchRoute(
|
|
|
37
50
|
return null;
|
|
38
51
|
}
|
|
39
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Navigate to a path. Uses RouterProvider if available, falls back to hash.
|
|
55
|
+
*/
|
|
40
56
|
export function navigate(path: string): void {
|
|
41
|
-
|
|
57
|
+
if (_routerProvider) {
|
|
58
|
+
_routerProvider.go({ to: path });
|
|
59
|
+
} else {
|
|
60
|
+
window.location.hash = path.startsWith('#') ? path : path;
|
|
61
|
+
}
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
export function currentPath(): string {
|
|
65
|
+
if (_routerProvider) {
|
|
66
|
+
const parsed = _routerProvider.parse();
|
|
67
|
+
return parsed.pathname || '/';
|
|
68
|
+
}
|
|
45
69
|
return window.location.hash.replace(/^#/, '') || '/';
|
|
46
70
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { currentPath, navigate } from './router';
|
|
2
|
+
import { getResource, getResources, getRouterProvider } from './context.svelte';
|
|
3
|
+
import { useParsed } from './useParsed';
|
|
4
|
+
|
|
5
|
+
// ─── routing-hooks.svelte.ts ───────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export function useGetToPath() {
|
|
8
|
+
const routerProvider = getRouterProvider();
|
|
9
|
+
|
|
10
|
+
return (options: { resource?: string; action?: 'list' | 'create' | 'edit' | 'show'; id?: string | number; meta?: Record<string, unknown> }) => {
|
|
11
|
+
const { resource, action, id } = options;
|
|
12
|
+
if (!resource) return '/';
|
|
13
|
+
let path = `/${resource}`;
|
|
14
|
+
if (action === 'create') path += '/create';
|
|
15
|
+
else if (action === 'edit' && id) path += `/edit/${id}`;
|
|
16
|
+
else if (action === 'show' && id) path += `/show/${id}`;
|
|
17
|
+
|
|
18
|
+
// Apply router mapping if the router provider defines custom path transformers
|
|
19
|
+
if (routerProvider?.go) {
|
|
20
|
+
// SvelteKit path generation can be injected here.
|
|
21
|
+
}
|
|
22
|
+
return path;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useGo() {
|
|
27
|
+
const getToPath = useGetToPath();
|
|
28
|
+
const routerProvider = getRouterProvider();
|
|
29
|
+
|
|
30
|
+
return (options: { to?: string; query?: Record<string, unknown>; type?: 'push' | 'replace'; resource?: string; action?: 'list' | 'create' | 'edit' | 'show' | 'clone'; id?: string | number }) => {
|
|
31
|
+
let targetUrl = options.to;
|
|
32
|
+
if (!targetUrl && options.resource) {
|
|
33
|
+
targetUrl = getToPath({ resource: options.resource, action: options.action === 'clone' ? 'edit' : options.action, id: options.id });
|
|
34
|
+
}
|
|
35
|
+
if (!targetUrl) targetUrl = '/';
|
|
36
|
+
|
|
37
|
+
if (options.query && Object.keys(options.query).length > 0) {
|
|
38
|
+
const qs = new URLSearchParams(options.query as Record<string, string>).toString();
|
|
39
|
+
targetUrl += `?${qs}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (routerProvider?.go) {
|
|
43
|
+
routerProvider.go({ to: targetUrl, type: options.type });
|
|
44
|
+
} else {
|
|
45
|
+
// @ts-expect-error SvelteKit / internal navigate might only take 1 arg or options obj
|
|
46
|
+
navigate(targetUrl, options.type === 'replace' ? { replaceState: true } : undefined);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useBack() {
|
|
52
|
+
const routerProvider = getRouterProvider();
|
|
53
|
+
return () => {
|
|
54
|
+
if (routerProvider?.back) {
|
|
55
|
+
routerProvider.back();
|
|
56
|
+
} else if (typeof window !== 'undefined') {
|
|
57
|
+
window.history.back();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* useLink replaces standard <a> tags with Svelte routing-aware links
|
|
64
|
+
* (to avoid full page reloads in SSR apps)
|
|
65
|
+
*/
|
|
66
|
+
export function useLink() {
|
|
67
|
+
const routerProvider = getRouterProvider() as Record<string, unknown> | undefined;
|
|
68
|
+
return (routerProvider?.Link as string) ?? 'a';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useResource(resourceName?: string) {
|
|
72
|
+
const parsed = useParsed();
|
|
73
|
+
const targetResource = resourceName ?? parsed.resource;
|
|
74
|
+
if (!targetResource) {
|
|
75
|
+
return { resource: undefined, resources: getResources() };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const resource = getResource(targetResource);
|
|
79
|
+
return { resource, resources: getResources() };
|
|
80
|
+
} catch {
|
|
81
|
+
return { resource: undefined, resources: getResources() };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useNavigation() {
|
|
86
|
+
const go = useGo();
|
|
87
|
+
const parsed = useParsed();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
create: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'create' }),
|
|
91
|
+
edit: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'edit', id: id ?? parsed.id }),
|
|
92
|
+
clone: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'clone', id: id ?? parsed.id }),
|
|
93
|
+
show: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'show', id: id ?? parsed.id }),
|
|
94
|
+
list: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resource, action: 'list' }),
|
|
95
|
+
push: (url: string) => go({ to: url, type: 'push' }),
|
|
96
|
+
replace: (url: string) => go({ to: url, type: 'replace' }),
|
|
97
|
+
goBack: useBack(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useParsed } from './useParsed';
|
|
2
|
+
import { useList } from './query-hooks.svelte';
|
|
3
|
+
import { readURLState, writeURLState } from './url-sync';
|
|
4
|
+
import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources, GetListResult } from './types';
|
|
5
|
+
import type { UseListOptions } from './query-hooks.svelte';
|
|
6
|
+
|
|
7
|
+
export type FilterSetMode = 'merge' | 'replace';
|
|
8
|
+
|
|
9
|
+
export interface UseTableOptions<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TSearchVariables = Record<string, unknown>> extends Omit<UseListOptions<TQueryFnData, TError>, 'pagination' | 'sorters' | 'filters'> {
|
|
10
|
+
resource?: KnownResources;
|
|
11
|
+
pagination?: Pagination;
|
|
12
|
+
sorters?: {
|
|
13
|
+
initial?: Sort[];
|
|
14
|
+
permanent?: Sort[];
|
|
15
|
+
mode?: 'off' | 'server';
|
|
16
|
+
};
|
|
17
|
+
filters?: {
|
|
18
|
+
initial?: Filter[];
|
|
19
|
+
permanent?: Filter[];
|
|
20
|
+
mode?: 'off' | 'server';
|
|
21
|
+
defaultBehavior?: FilterSetMode;
|
|
22
|
+
};
|
|
23
|
+
syncWithLocation?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useTable<
|
|
27
|
+
TQueryFnData extends BaseRecord = BaseRecord,
|
|
28
|
+
TError = HttpError,
|
|
29
|
+
TSearchVariables = Record<string, unknown>
|
|
30
|
+
>(options: UseTableOptions<TQueryFnData, TError, TSearchVariables> = {}) {
|
|
31
|
+
const parsed = useParsed();
|
|
32
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
33
|
+
const { meta, syncWithLocation = false, dataProviderName, pagination: _p, sorters: _s, filters: _f, ...restOptions } = options;
|
|
34
|
+
|
|
35
|
+
const paginationMode = options.pagination?.mode ?? 'server';
|
|
36
|
+
const sortersMode = options.sorters?.mode ?? 'server';
|
|
37
|
+
const filtersMode = options.filters?.mode ?? 'server';
|
|
38
|
+
const filterDefaultBehavior = options.filters?.defaultBehavior ?? 'replace';
|
|
39
|
+
|
|
40
|
+
const permanentSorters = options.sorters?.permanent ?? [];
|
|
41
|
+
const permanentFilters = options.filters?.permanent ?? [];
|
|
42
|
+
|
|
43
|
+
let initPagination = options.pagination ?? { current: 1, pageSize: 10 };
|
|
44
|
+
let initSorters = options.sorters?.initial ?? [];
|
|
45
|
+
let initFilters = options.filters?.initial ?? [];
|
|
46
|
+
|
|
47
|
+
if (syncWithLocation && typeof window !== 'undefined') {
|
|
48
|
+
const urlState = readURLState();
|
|
49
|
+
if (urlState.page || urlState.pageSize) {
|
|
50
|
+
initPagination = {
|
|
51
|
+
current: urlState.page ?? initPagination.current,
|
|
52
|
+
pageSize: urlState.pageSize ?? initPagination.pageSize,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (urlState.sortField) {
|
|
56
|
+
initSorters = [{ field: urlState.sortField, order: (urlState.sortOrder as 'asc' | 'desc') ?? 'asc' }];
|
|
57
|
+
}
|
|
58
|
+
if (urlState.filters) {
|
|
59
|
+
initFilters = urlState.filters;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let pagination = $state<Pagination>(initPagination);
|
|
64
|
+
let currentSorters = $state<Sort[]>(initSorters);
|
|
65
|
+
let currentFilters = $state<Filter[]>(initFilters);
|
|
66
|
+
|
|
67
|
+
const effectiveSorters = $derived([...currentSorters, ...permanentSorters]);
|
|
68
|
+
const effectiveFilters = $derived([...currentFilters, ...permanentFilters]);
|
|
69
|
+
|
|
70
|
+
const querySorters = $derived(sortersMode === 'server' ? effectiveSorters : []);
|
|
71
|
+
const queryFilters = $derived(filtersMode === 'server' ? effectiveFilters : []);
|
|
72
|
+
const queryPagination = paginationMode === 'server'
|
|
73
|
+
? pagination
|
|
74
|
+
: { current: 1, pageSize: 999999, mode: paginationMode as 'client' | 'off' };
|
|
75
|
+
|
|
76
|
+
const tableQueryInfo = useList<TQueryFnData, TError>({
|
|
77
|
+
resource,
|
|
78
|
+
pagination: queryPagination,
|
|
79
|
+
sorters: querySorters,
|
|
80
|
+
filters: queryFilters,
|
|
81
|
+
meta,
|
|
82
|
+
dataProviderName,
|
|
83
|
+
...restOptions
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const query = tableQueryInfo.query;
|
|
87
|
+
const overtime = tableQueryInfo.overtime;
|
|
88
|
+
|
|
89
|
+
function setSorters(newSorters: Sort[]) { currentSorters = newSorters; }
|
|
90
|
+
function setFilters(newFilters: Filter[], mode?: FilterSetMode) {
|
|
91
|
+
const behavior = mode ?? filterDefaultBehavior;
|
|
92
|
+
if (behavior === 'merge') {
|
|
93
|
+
const merged = [...currentFilters];
|
|
94
|
+
for (const nf of newFilters) {
|
|
95
|
+
const idx = merged.findIndex(f => f.field === nf.field && f.operator === nf.operator);
|
|
96
|
+
if (idx >= 0) {
|
|
97
|
+
if (nf.value === undefined || nf.value === null || nf.value === '') merged.splice(idx, 1);
|
|
98
|
+
else merged[idx] = nf;
|
|
99
|
+
} else if (nf.value !== undefined && nf.value !== null && nf.value !== '') {
|
|
100
|
+
merged.push(nf);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
currentFilters = merged;
|
|
104
|
+
} else {
|
|
105
|
+
currentFilters = newFilters;
|
|
106
|
+
}
|
|
107
|
+
pagination = { ...pagination, current: 1 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setPage(page: number) { pagination = { ...pagination, current: page }; }
|
|
111
|
+
function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
|
|
112
|
+
|
|
113
|
+
if (syncWithLocation && typeof window !== 'undefined') {
|
|
114
|
+
$effect(() => {
|
|
115
|
+
writeURLState({
|
|
116
|
+
page: pagination.current,
|
|
117
|
+
pageSize: pagination.pageSize,
|
|
118
|
+
sortField: effectiveSorters[0]?.field,
|
|
119
|
+
sortOrder: effectiveSorters[0]?.order,
|
|
120
|
+
filters: effectiveFilters,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createLinkForSyncWithLocation(): string {
|
|
126
|
+
const params = new URLSearchParams();
|
|
127
|
+
if (pagination.current) params.set('page', String(pagination.current));
|
|
128
|
+
if (pagination.pageSize) params.set('pageSize', String(pagination.pageSize));
|
|
129
|
+
if (effectiveSorters[0]) {
|
|
130
|
+
params.set('sortField', effectiveSorters[0].field);
|
|
131
|
+
params.set('sortOrder', effectiveSorters[0].order);
|
|
132
|
+
}
|
|
133
|
+
if (effectiveFilters.length > 0) {
|
|
134
|
+
params.set('filters', JSON.stringify(effectiveFilters));
|
|
135
|
+
}
|
|
136
|
+
return `?${params.toString()}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
query,
|
|
141
|
+
tableQuery: query,
|
|
142
|
+
get overtime() { return overtime; },
|
|
143
|
+
get pagination() { return pagination; },
|
|
144
|
+
get sorters() { return currentSorters; },
|
|
145
|
+
get filters() { return currentFilters; },
|
|
146
|
+
setSorters, setFilters, setPage, setPageSize,
|
|
147
|
+
setCurrentPage: setPage,
|
|
148
|
+
createLinkForSyncWithLocation,
|
|
149
|
+
get totalPages() {
|
|
150
|
+
// ts-expect-error reactive access
|
|
151
|
+
const total = (query.data as GetListResult<TQueryFnData> | undefined)?.total ?? 0;
|
|
152
|
+
return Math.ceil(total / (pagination.pageSize ?? 10));
|
|
153
|
+
},
|
|
154
|
+
get current() { return pagination.current ?? 1; },
|
|
155
|
+
get pageSize() { return pagination.pageSize ?? 10; },
|
|
156
|
+
get pageCount() { return Math.ceil(((query.data as GetListResult<TQueryFnData> | undefined)?.total ?? 0) / (pagination.pageSize ?? 10)); },
|
|
157
|
+
get clientData(): TQueryFnData[] {
|
|
158
|
+
if (paginationMode !== 'client') return [];
|
|
159
|
+
const allData = ((query.data as GetListResult<TQueryFnData> | undefined)?.data ?? []) as TQueryFnData[];
|
|
160
|
+
const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
|
|
161
|
+
const end = start + (pagination.pageSize ?? 10);
|
|
162
|
+
let sorted = [...allData];
|
|
163
|
+
const activeSorters = sortersMode === 'off' ? effectiveSorters : currentSorters;
|
|
164
|
+
if (activeSorters.length > 0) {
|
|
165
|
+
const { field, order } = activeSorters[0];
|
|
166
|
+
sorted.sort((a, b) => {
|
|
167
|
+
const va = (a as Record<string, unknown>)[field];
|
|
168
|
+
const vb = (b as Record<string, unknown>)[field];
|
|
169
|
+
if (va == null) return 1;
|
|
170
|
+
if (vb == null) return -1;
|
|
171
|
+
const cmp = va < vb ? -1 : va > vb ? 1 : 0;
|
|
172
|
+
return order === 'desc' ? -cmp : cmp;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return sorted.slice(start, end);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|