@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
package/src/context.ts CHANGED
@@ -2,31 +2,77 @@
2
2
 
3
3
  import { getContext, setContext } from 'svelte';
4
4
  import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
5
+ import type { RouterProvider } from './router-provider';
5
6
 
6
7
  const DATA_PROVIDER_KEY = Symbol('data-provider');
7
8
  const AUTH_PROVIDER_KEY = Symbol('auth-provider');
8
9
  const RESOURCES_KEY = Symbol('resources');
10
+ const ROUTER_PROVIDER_KEY = Symbol('router-provider');
9
11
 
10
- // ─── Setters (called once in App.svelte) ────────────────────────
12
+ // ─── DataProvider (supports single or multiple) ─────────────────
11
13
 
12
- export function setDataProvider(provider: DataProvider): void {
13
- setContext(DATA_PROVIDER_KEY, provider);
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
+ }
14
27
  }
15
28
 
16
- export function setAuthProvider(provider: AuthProvider): void {
17
- setContext(AUTH_PROVIDER_KEY, provider);
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;
18
38
  }
19
39
 
20
- export function setResources(resources: ResourceDefinition[]): void {
21
- setContext(RESOURCES_KEY, resources);
40
+ export function getDataProviderNames(): string[] {
41
+ const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
42
+ return providers ? Object.keys(providers) : [];
22
43
  }
23
44
 
24
- // ─── Getters (called from any child component) ──────────────────
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
+ }
25
62
 
26
- export function getDataProvider(): DataProvider {
27
- const provider = getContext<DataProvider>(DATA_PROVIDER_KEY);
28
- if (!provider) throw new Error('DataProvider not found. Did you call setDataProvider in App.svelte?');
29
- return provider;
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);
30
76
  }
31
77
 
32
78
  export function getAuthProvider(): AuthProvider {
@@ -35,15 +81,31 @@ export function getAuthProvider(): AuthProvider {
35
81
  return provider;
36
82
  }
37
83
 
84
+ // ─── Resources ──────────────────────────────────────────────────
85
+
86
+ export function setResources(resources: ResourceDefinition[]): void {
87
+ setContext(RESOURCES_KEY, resources);
88
+ }
89
+
38
90
  export function getResources(): ResourceDefinition[] {
39
91
  const resources = getContext<ResourceDefinition[]>(RESOURCES_KEY);
40
92
  if (!resources) throw new Error('Resources not found. Did you call setResources in App.svelte?');
41
93
  return resources;
42
94
  }
43
95
 
44
- export function getResource(name: string): ResourceDefinition {
96
+ export function getResource(nameOrIdentifier: string): ResourceDefinition {
45
97
  const resources = getResources();
46
- const resource = resources.find(r => r.name === name);
47
- if (!resource) throw new Error(`Resource "${name}" not found in resource definitions.`);
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.`);
48
100
  return resource;
49
101
  }
102
+
103
+ // ─── Router Provider ────────────────────────────────────────────
104
+
105
+ export function setRouterProvider(provider: RouterProvider): void {
106
+ setContext(ROUTER_PROVIDER_KEY, provider);
107
+ }
108
+
109
+ export function getRouterProvider(): RouterProvider | undefined {
110
+ return getContext<RouterProvider>(ROUTER_PROVIDER_KEY);
111
+ }
@@ -0,0 +1,118 @@
1
+ // Unit tests for CSV parsing and data-transfer utilities
2
+ import { describe, test, expect } from 'bun:test';
3
+
4
+ // We test parseCSVLine directly by re-implementing the exported logic
5
+ // since the function is not exported. We test the CSV format contract.
6
+
7
+ function parseCSVLine(line: string): string[] {
8
+ const result: string[] = [];
9
+ let current = '';
10
+ let inQuotes = false;
11
+
12
+ for (let i = 0; i < line.length; i++) {
13
+ const ch = line[i];
14
+ if (inQuotes) {
15
+ if (ch === '"' && line[i + 1] === '"') {
16
+ current += '"';
17
+ i++;
18
+ } else if (ch === '"') {
19
+ inQuotes = false;
20
+ } else {
21
+ current += ch;
22
+ }
23
+ } else {
24
+ if (ch === '"') {
25
+ inQuotes = true;
26
+ } else if (ch === ',') {
27
+ result.push(current.trim());
28
+ current = '';
29
+ } else {
30
+ current += ch;
31
+ }
32
+ }
33
+ }
34
+ result.push(current.trim());
35
+ return result;
36
+ }
37
+
38
+ describe('parseCSVLine', () => {
39
+ test('simple comma-separated values', () => {
40
+ expect(parseCSVLine('a,b,c')).toEqual(['a', 'b', 'c']);
41
+ });
42
+
43
+ test('values with whitespace are trimmed', () => {
44
+ expect(parseCSVLine(' hello , world , test ')).toEqual(['hello', 'world', 'test']);
45
+ });
46
+
47
+ test('quoted values preserve commas', () => {
48
+ expect(parseCSVLine('"hello, world",b,c')).toEqual(['hello, world', 'b', 'c']);
49
+ });
50
+
51
+ test('escaped quotes inside quoted values', () => {
52
+ expect(parseCSVLine('"say ""hello""",b')).toEqual(['say "hello"', 'b']);
53
+ });
54
+
55
+ test('empty values', () => {
56
+ expect(parseCSVLine('a,,c')).toEqual(['a', '', 'c']);
57
+ });
58
+
59
+ test('single value', () => {
60
+ expect(parseCSVLine('hello')).toEqual(['hello']);
61
+ });
62
+
63
+ test('empty line', () => {
64
+ expect(parseCSVLine('')).toEqual(['']);
65
+ });
66
+
67
+ test('quoted value with newline character', () => {
68
+ expect(parseCSVLine('"line1\nline2",b')).toEqual(['line1\nline2', 'b']);
69
+ });
70
+
71
+ test('mixed quoted and unquoted', () => {
72
+ expect(parseCSVLine('1,"John Doe","admin@test.com",true')).toEqual([
73
+ '1', 'John Doe', 'admin@test.com', 'true'
74
+ ]);
75
+ });
76
+ });
77
+
78
+ // CSV row formatting (escape logic from useExport)
79
+ function escapeCSVField(val: unknown): string {
80
+ const str = val === null || val === undefined ? '' : String(val);
81
+ return str.includes(',') || str.includes('"') || str.includes('\n')
82
+ ? `"${str.replace(/"/g, '""')}"`
83
+ : str;
84
+ }
85
+
86
+ describe('escapeCSVField', () => {
87
+ test('simple string passes through', () => {
88
+ expect(escapeCSVField('hello')).toBe('hello');
89
+ });
90
+
91
+ test('null becomes empty string', () => {
92
+ expect(escapeCSVField(null)).toBe('');
93
+ });
94
+
95
+ test('undefined becomes empty string', () => {
96
+ expect(escapeCSVField(undefined)).toBe('');
97
+ });
98
+
99
+ test('string with comma is quoted', () => {
100
+ expect(escapeCSVField('hello, world')).toBe('"hello, world"');
101
+ });
102
+
103
+ test('string with double quote is escaped', () => {
104
+ expect(escapeCSVField('say "hi"')).toBe('"say ""hi"""');
105
+ });
106
+
107
+ test('string with newline is quoted', () => {
108
+ expect(escapeCSVField('line1\nline2')).toBe('"line1\nline2"');
109
+ });
110
+
111
+ test('number is converted to string', () => {
112
+ expect(escapeCSVField(42)).toBe('42');
113
+ });
114
+
115
+ test('boolean is converted to string', () => {
116
+ expect(escapeCSVField(true)).toBe('true');
117
+ });
118
+ });
@@ -1,108 +1,186 @@
1
- // Data transfer utilities — CSV export/import
2
-
3
- import { getDataProvider } from './context';
4
- import type { GetListResult } from './types';
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 all records from a resource as CSV download
19
+ * useExport — export records from a resource as CSV download
20
+ * Supports mapData, sorters, filters, maxItemCount, pageSize
8
21
  */
9
- export function useExport(resource: string) {
10
- let isExporting = $state(false);
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 exportCSV(opts?: { filename?: string; fields?: string[] }) {
13
- const provider = getDataProvider();
14
- isExporting = true;
27
+ async function triggerExport() {
28
+ const provider = getDataProviderForResource(resource, options.dataProviderName);
29
+ isLoading = true;
15
30
 
16
31
  try {
17
- // Fetch all records (up to 10000)
18
- const result: GetListResult = await provider.getList({
19
- resource,
20
- pagination: { current: 1, pageSize: 10000 },
21
- });
22
-
23
- if (result.data.length === 0) return;
24
-
25
- const records = result.data as Record<string, unknown>[];
26
- const fields = opts?.fields ?? Object.keys(records[0]);
27
-
28
- // Build CSV
29
- const header = fields.join(',');
30
- const rows = records.map(record =>
31
- fields.map(f => {
32
- const val = record[f];
33
- const str = val === null || val === undefined ? '' : String(val);
34
- // Escape commas and quotes
35
- return str.includes(',') || str.includes('"') || str.includes('\n')
36
- ? `"${str.replace(/"/g, '""')}"`
37
- : str;
38
- }).join(',')
39
- );
40
-
41
- const csv = [header, ...rows].join('\n');
42
- const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
43
- const url = URL.createObjectURL(blob);
44
- const a = document.createElement('a');
45
- a.href = url;
46
- a.download = opts?.filename ?? `${resource}_export.csv`;
47
- a.click();
48
- URL.revokeObjectURL(url);
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
- isExporting = false;
86
+ isLoading = false;
51
87
  }
52
88
  }
53
89
 
54
90
  return {
55
- exportCSV,
56
- get isExporting() { return isExporting; },
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(resource: string) {
64
- let isImporting = $state(false);
65
- let importResult = $state<{ success: number; failed: number } | null>(null);
66
-
67
- async function importCSV(file: File): Promise<{ success: number; failed: number }> {
68
- const provider = getDataProvider();
69
- isImporting = true;
70
- let success = 0;
71
- let failed = 0;
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 { success: 0, failed: 0 };
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
- record[h] = values[idx] ?? '';
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
- try {
88
- await provider.create({ resource, variables: record });
89
- success++;
90
- } catch {
91
- failed++;
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
- importResult = { success, failed };
96
- return { success, failed };
172
+ mutationResult = { succeeded, errored };
173
+ options.onFinish?.({ succeeded, errored });
97
174
  } finally {
98
- isImporting = false;
175
+ isLoading = false;
99
176
  }
100
177
  }
101
178
 
102
179
  return {
103
- importCSV,
104
- get isImporting() { return isImporting; },
105
- get importResult() { return importResult; },
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