@svadmin/core 0.0.6 → 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.test.ts +205 -0
- package/src/context.ts +65 -15
- 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 +18 -0
- package/src/index.ts +39 -8
- 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/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 +39 -3
- package/src/useStepsForm.svelte.ts +71 -0
- package/src/utility-hooks.svelte.ts +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/core",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Core SDK — hooks, types, context, i18n, permissions, router",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -56,5 +56,8 @@
|
|
|
56
56
|
"homepage": "https://github.com/zuohuadong/svadmin#readme",
|
|
57
57
|
"bugs": {
|
|
58
58
|
"url": "https://github.com/zuohuadong/svadmin/issues"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/bun": "^1.3.11"
|
|
59
62
|
}
|
|
60
63
|
}
|
package/src/audit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Audit logging — record admin operations
|
|
2
|
+
// Full AuditLogProvider interface
|
|
2
3
|
|
|
3
4
|
export interface AuditEntry {
|
|
4
5
|
timestamp: string;
|
|
@@ -7,22 +8,58 @@ export interface AuditEntry {
|
|
|
7
8
|
recordId?: string | number;
|
|
8
9
|
userId?: string;
|
|
9
10
|
details?: Record<string, unknown>;
|
|
11
|
+
data?: Record<string, unknown>;
|
|
12
|
+
previousData?: Record<string, unknown>;
|
|
13
|
+
meta?: Record<string, unknown>;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export type AuditHandler = (entry: AuditEntry) => void | Promise<void>;
|
|
13
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Full AuditLogProvider interface.
|
|
20
|
+
* - create: log a new entry
|
|
21
|
+
* - get: retrieve entries for a resource
|
|
22
|
+
* - update: update an existing entry (e.g. mark as reviewed)
|
|
23
|
+
*/
|
|
24
|
+
export interface AuditLogProvider {
|
|
25
|
+
create: (params: { resource: string; action: string; data?: Record<string, unknown>; previousData?: Record<string, unknown>; meta?: Record<string, unknown> }) => Promise<AuditEntry>;
|
|
26
|
+
get: (params: { resource: string; action?: string; meta?: Record<string, unknown>; author?: Record<string, unknown> }) => Promise<AuditEntry[]>;
|
|
27
|
+
update: (params: { id: string | number; name: string; meta?: Record<string, unknown> }) => Promise<AuditEntry>;
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
let handler: AuditHandler = (entry) => {
|
|
15
31
|
console.info('[audit]', entry.action, entry.resource, entry.recordId);
|
|
16
32
|
};
|
|
17
33
|
|
|
34
|
+
let auditLogProvider: AuditLogProvider | null = null;
|
|
35
|
+
|
|
18
36
|
export function setAuditHandler(fn: AuditHandler): void {
|
|
19
37
|
handler = fn;
|
|
20
38
|
}
|
|
21
39
|
|
|
40
|
+
export function setAuditLogProvider(provider: AuditLogProvider): void {
|
|
41
|
+
auditLogProvider = provider;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getAuditLogProvider(): AuditLogProvider | null {
|
|
45
|
+
return auditLogProvider;
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
export function audit(entry: Omit<AuditEntry, 'timestamp'>): void {
|
|
23
49
|
const fullEntry: AuditEntry = { ...entry, timestamp: new Date().toISOString() };
|
|
24
50
|
try {
|
|
25
51
|
handler(fullEntry);
|
|
52
|
+
|
|
53
|
+
// Also log via provider if set
|
|
54
|
+
if (auditLogProvider) {
|
|
55
|
+
auditLogProvider.create({
|
|
56
|
+
resource: entry.resource ?? '',
|
|
57
|
+
action: entry.action,
|
|
58
|
+
data: entry.data ?? entry.details,
|
|
59
|
+
previousData: entry.previousData,
|
|
60
|
+
meta: entry.meta,
|
|
61
|
+
}).catch(e => console.error('[audit] provider create error:', e));
|
|
62
|
+
}
|
|
26
63
|
} catch (e) {
|
|
27
64
|
console.error('[audit] handler error:', e);
|
|
28
65
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Hooks — Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* These test the auth hook *logic* by mocking the Svelte context module
|
|
5
|
+
* so `getAuthProvider()` returns a mock provider without needing a real component tree.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
8
|
+
import type { AuthProvider, AuthActionResult, CheckResult, Identity } from './types';
|
|
9
|
+
|
|
10
|
+
// Build a mock AuthProvider factory
|
|
11
|
+
function createMockAuthProvider(overrides: Partial<AuthProvider> = {}): AuthProvider {
|
|
12
|
+
return {
|
|
13
|
+
login: mock(async () => ({ success: true, redirectTo: '/' } as AuthActionResult)),
|
|
14
|
+
logout: mock(async () => ({ success: true, redirectTo: '/login' } as AuthActionResult)),
|
|
15
|
+
check: mock(async () => ({ authenticated: true } as CheckResult)),
|
|
16
|
+
getIdentity: mock(async () => ({ id: '1', name: 'Admin', email: 'admin@test.com' } as Identity)),
|
|
17
|
+
register: mock(async () => ({ success: true } as AuthActionResult)),
|
|
18
|
+
forgotPassword: mock(async () => ({ success: true } as AuthActionResult)),
|
|
19
|
+
updatePassword: mock(async () => ({ success: true } as AuthActionResult)),
|
|
20
|
+
onError: mock(async () => ({ logout: false })),
|
|
21
|
+
getPermissions: mock(async () => ['admin', 'editor']),
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Tests for AuthProvider mock interface ───────────────────
|
|
27
|
+
|
|
28
|
+
describe('AuthProvider mock interface', () => {
|
|
29
|
+
test('login returns success', async () => {
|
|
30
|
+
const provider = createMockAuthProvider();
|
|
31
|
+
const result = await provider.login({ email: 'test@test.com', password: '123' });
|
|
32
|
+
expect(result.success).toBe(true);
|
|
33
|
+
expect(result.redirectTo).toBe('/');
|
|
34
|
+
expect(provider.login).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('login returns failure', async () => {
|
|
38
|
+
const provider = createMockAuthProvider({
|
|
39
|
+
login: mock(async () => ({ success: false, error: { message: 'Invalid credentials' } })),
|
|
40
|
+
});
|
|
41
|
+
const result = await provider.login({ email: 'bad', password: 'bad' });
|
|
42
|
+
expect(result.success).toBe(false);
|
|
43
|
+
expect(result.error?.message).toBe('Invalid credentials');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('logout returns success with redirect', async () => {
|
|
47
|
+
const provider = createMockAuthProvider();
|
|
48
|
+
const result = await provider.logout!();
|
|
49
|
+
expect(result.success).toBe(true);
|
|
50
|
+
expect(result.redirectTo).toBe('/login');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('check returns authenticated', async () => {
|
|
54
|
+
const provider = createMockAuthProvider();
|
|
55
|
+
const result = await provider.check();
|
|
56
|
+
expect(result.authenticated).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('check returns not authenticated', async () => {
|
|
60
|
+
const provider = createMockAuthProvider({
|
|
61
|
+
check: mock(async () => ({ authenticated: false, redirectTo: '/login' })),
|
|
62
|
+
});
|
|
63
|
+
const result = await provider.check();
|
|
64
|
+
expect(result.authenticated).toBe(false);
|
|
65
|
+
expect(result.redirectTo).toBe('/login');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('getIdentity returns user info', async () => {
|
|
69
|
+
const provider = createMockAuthProvider();
|
|
70
|
+
const identity = await provider.getIdentity();
|
|
71
|
+
expect(identity).not.toBeNull();
|
|
72
|
+
expect(identity!.name).toBe('Admin');
|
|
73
|
+
expect(identity!.email).toBe('admin@test.com');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('getIdentity returns null when not logged in', async () => {
|
|
77
|
+
const provider = createMockAuthProvider({
|
|
78
|
+
getIdentity: mock(async () => null),
|
|
79
|
+
});
|
|
80
|
+
const identity = await provider.getIdentity();
|
|
81
|
+
expect(identity).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('register returns success', async () => {
|
|
85
|
+
const provider = createMockAuthProvider();
|
|
86
|
+
const result = await provider.register!({ email: 'new@test.com', password: '123' });
|
|
87
|
+
expect(result.success).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('forgotPassword returns success', async () => {
|
|
91
|
+
const provider = createMockAuthProvider();
|
|
92
|
+
const result = await provider.forgotPassword!({ email: 'test@test.com' });
|
|
93
|
+
expect(result.success).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('updatePassword returns success', async () => {
|
|
97
|
+
const provider = createMockAuthProvider();
|
|
98
|
+
const result = await provider.updatePassword!({ password: 'newpass', confirmPassword: 'newpass' });
|
|
99
|
+
expect(result.success).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('onError with logout flag', async () => {
|
|
103
|
+
const provider = createMockAuthProvider({
|
|
104
|
+
onError: mock(async () => ({ logout: true, redirectTo: '/login' })),
|
|
105
|
+
});
|
|
106
|
+
const result = await provider.onError!(new Error('401'));
|
|
107
|
+
expect(result.logout).toBe(true);
|
|
108
|
+
expect(result.redirectTo).toBe('/login');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('onError without logout', async () => {
|
|
112
|
+
const provider = createMockAuthProvider({
|
|
113
|
+
onError: mock(async () => ({ logout: false, redirectTo: '/error' })),
|
|
114
|
+
});
|
|
115
|
+
const result = await provider.onError!(new Error('500'));
|
|
116
|
+
expect(result.logout).toBe(false);
|
|
117
|
+
expect(result.redirectTo).toBe('/error');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('getPermissions returns permissions array', async () => {
|
|
121
|
+
const provider = createMockAuthProvider();
|
|
122
|
+
const permissions = await provider.getPermissions!();
|
|
123
|
+
expect(permissions).toEqual(['admin', 'editor']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('getPermissions can return custom structure', async () => {
|
|
127
|
+
const provider = createMockAuthProvider({
|
|
128
|
+
getPermissions: mock(async () => ({ role: 'admin', canDelete: true })),
|
|
129
|
+
});
|
|
130
|
+
const permissions = await provider.getPermissions!() as { role: string; canDelete: boolean };
|
|
131
|
+
expect(permissions.role).toBe('admin');
|
|
132
|
+
expect(permissions.canDelete).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── Tests for auth logic patterns ──────────────────────────
|
|
137
|
+
|
|
138
|
+
describe('auth hook patterns', () => {
|
|
139
|
+
test('login flow: success triggers redirect', async () => {
|
|
140
|
+
const provider = createMockAuthProvider();
|
|
141
|
+
const result = await provider.login({ email: 'admin@test.com', password: 'pass' });
|
|
142
|
+
expect(result.success).toBe(true);
|
|
143
|
+
// In real hook: navigate(result.redirectTo)
|
|
144
|
+
expect(result.redirectTo).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('login flow: failure shows error', async () => {
|
|
148
|
+
const provider = createMockAuthProvider({
|
|
149
|
+
login: mock(async () => ({ success: false, error: { message: 'Bad password' } })),
|
|
150
|
+
});
|
|
151
|
+
const result = await provider.login({ email: 'admin@test.com', password: 'wrong' });
|
|
152
|
+
expect(result.success).toBe(false);
|
|
153
|
+
// In real hook: toast.error(result.error.message)
|
|
154
|
+
expect(result.error?.message).toBe('Bad password');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('login flow: exception produces error result', async () => {
|
|
158
|
+
const provider = createMockAuthProvider({
|
|
159
|
+
login: mock(async () => { throw new Error('Network Error'); }),
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
await provider.login({ email: 'test', password: 'test' });
|
|
163
|
+
expect(true).toBe(false); // should not reach
|
|
164
|
+
} catch (err) {
|
|
165
|
+
expect((err as Error).message).toBe('Network Error');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('onError flow: 401 triggers logout + redirect', async () => {
|
|
170
|
+
const provider = createMockAuthProvider({
|
|
171
|
+
onError: mock(async (err: unknown) => {
|
|
172
|
+
if (err instanceof Error && err.message.includes('401')) {
|
|
173
|
+
return { logout: true, redirectTo: '/login' };
|
|
174
|
+
}
|
|
175
|
+
return { logout: false };
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
const result = await provider.onError!(new Error('401 Unauthorized'));
|
|
179
|
+
expect(result.logout).toBe(true);
|
|
180
|
+
// In real hook: provider.logout() then navigate(result.redirectTo)
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('onError flow: 500 does not trigger logout', async () => {
|
|
184
|
+
const provider = createMockAuthProvider({
|
|
185
|
+
onError: mock(async (err: unknown) => {
|
|
186
|
+
if (err instanceof Error && err.message.includes('401')) {
|
|
187
|
+
return { logout: true, redirectTo: '/login' };
|
|
188
|
+
}
|
|
189
|
+
return { logout: false };
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
const result = await provider.onError!(new Error('500 Internal Server Error'));
|
|
193
|
+
expect(result.logout).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('check → getIdentity flow', async () => {
|
|
197
|
+
const provider = createMockAuthProvider();
|
|
198
|
+
const checkResult = await provider.check();
|
|
199
|
+
expect(checkResult.authenticated).toBe(true);
|
|
200
|
+
if (checkResult.authenticated) {
|
|
201
|
+
const identity = await provider.getIdentity();
|
|
202
|
+
expect(identity!.name).toBe('Admin');
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/context.ts
CHANGED
|
@@ -9,26 +9,70 @@ const AUTH_PROVIDER_KEY = Symbol('auth-provider');
|
|
|
9
9
|
const RESOURCES_KEY = Symbol('resources');
|
|
10
10
|
const ROUTER_PROVIDER_KEY = Symbol('router-provider');
|
|
11
11
|
|
|
12
|
-
// ───
|
|
12
|
+
// ─── DataProvider (supports single or multiple) ─────────────────
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Accepts either:
|
|
16
|
+
* - A single DataProvider (backwards compatible)
|
|
17
|
+
* - A record of named providers: { default: rest, cms: graphql }
|
|
18
|
+
*/
|
|
19
|
+
export type DataProviderInput = DataProvider | Record<string, DataProvider>;
|
|
20
|
+
|
|
21
|
+
export function setDataProvider(provider: DataProviderInput): void {
|
|
22
|
+
if (isDataProvider(provider)) {
|
|
23
|
+
setContext(DATA_PROVIDER_KEY, { default: provider } as Record<string, DataProvider>);
|
|
24
|
+
} else {
|
|
25
|
+
setContext(DATA_PROVIDER_KEY, provider as Record<string, DataProvider>);
|
|
26
|
+
}
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
export function
|
|
19
|
-
|
|
29
|
+
export function getDataProvider(name?: string): DataProvider {
|
|
30
|
+
const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
|
|
31
|
+
if (!providers) throw new Error('DataProvider not found. Did you call setDataProvider in App.svelte?');
|
|
32
|
+
const key = name ?? 'default';
|
|
33
|
+
const provider = providers[key];
|
|
34
|
+
if (!provider) {
|
|
35
|
+
throw new Error(`DataProvider "${key}" not found. Available: ${Object.keys(providers).join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
return provider;
|
|
20
38
|
}
|
|
21
39
|
|
|
22
|
-
export function
|
|
23
|
-
|
|
40
|
+
export function getDataProviderNames(): string[] {
|
|
41
|
+
const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
|
|
42
|
+
return providers ? Object.keys(providers) : [];
|
|
24
43
|
}
|
|
25
44
|
|
|
26
|
-
|
|
45
|
+
export function getDataProviderForResource(resourceName: string, overrideName?: string): DataProvider {
|
|
46
|
+
if (overrideName) {
|
|
47
|
+
try {
|
|
48
|
+
return getDataProvider(overrideName);
|
|
49
|
+
} catch {
|
|
50
|
+
// ignore
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const resource = getResource(resourceName);
|
|
55
|
+
const dpName = resource.meta?.dataProviderName as string | undefined;
|
|
56
|
+
if (dpName) return getDataProvider(dpName);
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore
|
|
59
|
+
}
|
|
60
|
+
return getDataProvider();
|
|
61
|
+
}
|
|
27
62
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
63
|
+
function isDataProvider(value: unknown): value is DataProvider {
|
|
64
|
+
return (
|
|
65
|
+
value !== null &&
|
|
66
|
+
typeof value === 'object' &&
|
|
67
|
+
'getList' in value &&
|
|
68
|
+
typeof (value as DataProvider).getList === 'function'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Auth Provider ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function setAuthProvider(provider: AuthProvider): void {
|
|
75
|
+
setContext(AUTH_PROVIDER_KEY, provider);
|
|
32
76
|
}
|
|
33
77
|
|
|
34
78
|
export function getAuthProvider(): AuthProvider {
|
|
@@ -37,16 +81,22 @@ export function getAuthProvider(): AuthProvider {
|
|
|
37
81
|
return provider;
|
|
38
82
|
}
|
|
39
83
|
|
|
84
|
+
// ─── Resources ──────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export function setResources(resources: ResourceDefinition[]): void {
|
|
87
|
+
setContext(RESOURCES_KEY, resources);
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
export function getResources(): ResourceDefinition[] {
|
|
41
91
|
const resources = getContext<ResourceDefinition[]>(RESOURCES_KEY);
|
|
42
92
|
if (!resources) throw new Error('Resources not found. Did you call setResources in App.svelte?');
|
|
43
93
|
return resources;
|
|
44
94
|
}
|
|
45
95
|
|
|
46
|
-
export function getResource(
|
|
96
|
+
export function getResource(nameOrIdentifier: string): ResourceDefinition {
|
|
47
97
|
const resources = getResources();
|
|
48
|
-
const resource = resources.find(r => r.name ===
|
|
49
|
-
if (!resource) throw new Error(`Resource "${
|
|
98
|
+
const resource = resources.find(r => r.identifier === nameOrIdentifier || r.name === nameOrIdentifier);
|
|
99
|
+
if (!resource) throw new Error(`Resource "${nameOrIdentifier}" not found in resource definitions.`);
|
|
50
100
|
return resource;
|
|
51
101
|
}
|
|
52
102
|
|
package/src/data-transfer.ts
CHANGED
|
@@ -1,108 +1,186 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
1
|
+
import { getDataProviderForResource } from './context';
|
|
2
|
+
import { useParsed } from './useParsed';
|
|
3
|
+
import type { GetListResult, Sort, Filter, BaseRecord } from './types';
|
|
4
|
+
|
|
5
|
+
export interface UseExportOptions<TData extends BaseRecord = BaseRecord> {
|
|
6
|
+
resource?: string;
|
|
7
|
+
mapData?: (item: TData) => Record<string, unknown>;
|
|
8
|
+
sorters?: Sort[];
|
|
9
|
+
filters?: Filter[];
|
|
10
|
+
maxItemCount?: number;
|
|
11
|
+
pageSize?: number;
|
|
12
|
+
download?: boolean;
|
|
13
|
+
meta?: Record<string, unknown>;
|
|
14
|
+
dataProviderName?: string;
|
|
15
|
+
onError?: (error: Error) => void;
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
|
-
* useExport — export
|
|
19
|
+
* useExport — export records from a resource as CSV download
|
|
20
|
+
* Supports mapData, sorters, filters, maxItemCount, pageSize
|
|
8
21
|
*/
|
|
9
|
-
export function useExport(
|
|
10
|
-
|
|
22
|
+
export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExportOptions<TData> = {}) {
|
|
23
|
+
const parsed = useParsed();
|
|
24
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
25
|
+
let isLoading = $state(false);
|
|
11
26
|
|
|
12
|
-
async function
|
|
13
|
-
const provider =
|
|
14
|
-
|
|
27
|
+
async function triggerExport() {
|
|
28
|
+
const provider = getDataProviderForResource(resource, options.dataProviderName);
|
|
29
|
+
isLoading = true;
|
|
15
30
|
|
|
16
31
|
try {
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
32
|
+
const batchSize = options.pageSize ?? 20;
|
|
33
|
+
const maxItems = options.maxItemCount ?? Infinity;
|
|
34
|
+
let allRecords: TData[] = [];
|
|
35
|
+
let page = 1;
|
|
36
|
+
|
|
37
|
+
while (allRecords.length < maxItems) {
|
|
38
|
+
const result = await provider.getList<TData>({
|
|
39
|
+
resource,
|
|
40
|
+
pagination: { current: page, pageSize: batchSize },
|
|
41
|
+
sorters: options.sorters,
|
|
42
|
+
filters: options.filters,
|
|
43
|
+
meta: options.meta,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
allRecords = [...allRecords, ...result.data];
|
|
47
|
+
if (result.data.length < batchSize || allRecords.length >= result.total) break;
|
|
48
|
+
page++;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (maxItems !== Infinity) allRecords = allRecords.slice(0, maxItems);
|
|
52
|
+
if (allRecords.length === 0) return allRecords;
|
|
53
|
+
|
|
54
|
+
const mapped = options.mapData
|
|
55
|
+
? allRecords.map(options.mapData)
|
|
56
|
+
: allRecords.map(r => r as unknown as Record<string, unknown>);
|
|
57
|
+
|
|
58
|
+
if (options.download !== false) {
|
|
59
|
+
const fields = Object.keys(mapped[0]);
|
|
60
|
+
const header = fields.join(',');
|
|
61
|
+
const rows = mapped.map(record =>
|
|
62
|
+
fields.map(f => {
|
|
63
|
+
const val = record[f];
|
|
64
|
+
const str = val === null || val === undefined ? '' : String(val);
|
|
65
|
+
return str.includes(',') || str.includes('"') || str.includes('\n')
|
|
66
|
+
? `"${str.replace(/"/g, '""')}"`
|
|
67
|
+
: str;
|
|
68
|
+
}).join(',')
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const csv = [header, ...rows].join('\n');
|
|
72
|
+
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
73
|
+
const url = URL.createObjectURL(blob);
|
|
74
|
+
const a = document.createElement('a');
|
|
75
|
+
a.href = url;
|
|
76
|
+
a.download = `${resource}_export.csv`;
|
|
77
|
+
a.click();
|
|
78
|
+
URL.revokeObjectURL(url);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return allRecords;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
options.onError?.(error as Error);
|
|
84
|
+
return [];
|
|
49
85
|
} finally {
|
|
50
|
-
|
|
86
|
+
isLoading = false;
|
|
51
87
|
}
|
|
52
88
|
}
|
|
53
89
|
|
|
54
90
|
return {
|
|
55
|
-
|
|
56
|
-
get
|
|
91
|
+
triggerExport,
|
|
92
|
+
get isLoading() { return isLoading; },
|
|
57
93
|
};
|
|
58
94
|
}
|
|
59
95
|
|
|
96
|
+
export interface UseImportOptions<TData = Record<string, unknown>> {
|
|
97
|
+
resource?: string;
|
|
98
|
+
mapData?: (item: Record<string, unknown>) => TData;
|
|
99
|
+
batchSize?: number;
|
|
100
|
+
onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
|
|
101
|
+
onProgress?: (info: { totalAmount: number; processedAmount: number }) => void;
|
|
102
|
+
meta?: Record<string, unknown>;
|
|
103
|
+
dataProviderName?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
60
106
|
/**
|
|
61
107
|
* useImport — import records from a CSV file
|
|
108
|
+
* Supports mapData, batchSize, onFinish, onProgress, meta
|
|
62
109
|
*/
|
|
63
|
-
export function useImport(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
110
|
+
export function useImport<TData = Record<string, unknown>>(options: UseImportOptions<TData> = {}) {
|
|
111
|
+
const parsed = useParsed();
|
|
112
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
113
|
+
let isLoading = $state(false);
|
|
114
|
+
let mutationResult = $state<{ succeeded: unknown[]; errored: { request: unknown; error: unknown }[] } | null>(null);
|
|
115
|
+
|
|
116
|
+
async function handleChange(info: { file: File }) {
|
|
117
|
+
const provider = getDataProviderForResource(resource, options.dataProviderName);
|
|
118
|
+
isLoading = true;
|
|
119
|
+
const succeeded: unknown[] = [];
|
|
120
|
+
const errored: { request: unknown; error: unknown }[] = [];
|
|
72
121
|
|
|
73
122
|
try {
|
|
74
|
-
const text = await file.text();
|
|
123
|
+
const text = await info.file.text();
|
|
75
124
|
const lines = text.split('\n').filter(l => l.trim() !== '');
|
|
76
|
-
if (lines.length < 2) return
|
|
125
|
+
if (lines.length < 2) return;
|
|
77
126
|
|
|
78
127
|
const headers = parseCSVLine(lines[0]);
|
|
128
|
+
const records: Record<string, unknown>[] = [];
|
|
79
129
|
|
|
80
130
|
for (let i = 1; i < lines.length; i++) {
|
|
81
131
|
const values = parseCSVLine(lines[i]);
|
|
82
132
|
const record: Record<string, unknown> = {};
|
|
83
|
-
headers.forEach((h, idx) => {
|
|
84
|
-
|
|
85
|
-
|
|
133
|
+
headers.forEach((h, idx) => { record[h] = values[idx] ?? ''; });
|
|
134
|
+
records.push(options.mapData ? (options.mapData(record) as unknown as Record<string, unknown>) : record);
|
|
135
|
+
}
|
|
86
136
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
137
|
+
const batchSize = options.batchSize ?? 1;
|
|
138
|
+
let processed = 0;
|
|
139
|
+
|
|
140
|
+
if (batchSize === 1) {
|
|
141
|
+
for (const record of records) {
|
|
142
|
+
try {
|
|
143
|
+
const res = await provider.create({ resource, variables: record, meta: options.meta });
|
|
144
|
+
succeeded.push(res);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
errored.push({ request: record, error });
|
|
147
|
+
}
|
|
148
|
+
processed++;
|
|
149
|
+
options.onProgress?.({ totalAmount: records.length, processedAmount: processed });
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
for (let i = 0; i < records.length; i += batchSize) {
|
|
153
|
+
const batch = records.slice(i, i + batchSize);
|
|
154
|
+
try {
|
|
155
|
+
if (provider.createMany) {
|
|
156
|
+
const res = await provider.createMany({ resource, variables: batch, meta: options.meta });
|
|
157
|
+
succeeded.push(res);
|
|
158
|
+
} else {
|
|
159
|
+
for (const record of batch) {
|
|
160
|
+
const res = await provider.create({ resource, variables: record, meta: options.meta });
|
|
161
|
+
succeeded.push(res);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
errored.push({ request: batch, error });
|
|
166
|
+
}
|
|
167
|
+
processed += batch.length;
|
|
168
|
+
options.onProgress?.({ totalAmount: records.length, processedAmount: processed });
|
|
92
169
|
}
|
|
93
170
|
}
|
|
94
171
|
|
|
95
|
-
|
|
96
|
-
|
|
172
|
+
mutationResult = { succeeded, errored };
|
|
173
|
+
options.onFinish?.({ succeeded, errored });
|
|
97
174
|
} finally {
|
|
98
|
-
|
|
175
|
+
isLoading = false;
|
|
99
176
|
}
|
|
100
177
|
}
|
|
101
178
|
|
|
102
179
|
return {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
get
|
|
180
|
+
inputProps: { type: 'file' as const, accept: '.csv' },
|
|
181
|
+
handleChange,
|
|
182
|
+
get isLoading() { return isLoading; },
|
|
183
|
+
get mutationResult() { return mutationResult; },
|
|
106
184
|
};
|
|
107
185
|
}
|
|
108
186
|
|