@svadmin/core 0.22.0 → 0.23.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 +1 -1
- package/src/audit.ts +5 -0
- package/src/auth-hooks.svelte.ts +44 -34
- package/src/chatProvider.svelte.ts +8 -21
- package/src/context.svelte.ts +22 -2
- package/src/data-transfer.svelte.ts +66 -34
- package/src/form-hooks.svelte.ts +110 -42
- package/src/helpers-pure.ts +159 -0
- package/src/helpers.test.ts +177 -0
- package/src/helpers.ts +9 -118
- package/src/hook-utils.svelte.ts +27 -6
- package/src/hooks.svelte.ts +120 -18
- package/src/i18n.svelte.ts +41 -17
- package/src/index.ts +10 -3
- package/src/live-websocket.ts +20 -2
- package/src/live.svelte.ts +35 -19
- package/src/mutation-hooks.svelte.ts +162 -73
- package/src/notification.svelte.ts +9 -5
- package/src/options.svelte.ts +42 -19
- package/src/permissions.ts +4 -0
- package/src/query-hooks.svelte.ts +182 -100
- package/src/router-provider.ts +3 -1
- package/src/router.ts +23 -2
- package/src/routing-hooks.svelte.ts +42 -17
- package/src/table-hooks.svelte.ts +87 -34
- package/src/table-state.svelte.ts +2 -1
- package/src/theme.svelte.ts +27 -25
- package/src/toast.svelte.ts +22 -11
- package/src/types.ts +3 -1
- package/src/url-sync.ts +28 -16
- package/src/useCan.ts +6 -2
- package/src/useParsed.svelte.ts +4 -0
- package/src/useStepsForm.svelte.ts +31 -21
- package/src/utility-hooks.svelte.ts +14 -4
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
2
2
|
import { useParsed } from './useParsed.svelte';
|
|
3
3
|
import { getAdminOptions } from './options.svelte';
|
|
4
|
-
import { getDataProviderForResource, getResource } from './context.svelte';
|
|
4
|
+
import { getDataProviderForResource, getResource, getLiveProvider } from './context.svelte';
|
|
5
5
|
import { createQuery, createMutation } from '@tanstack/svelte-query';
|
|
6
6
|
import { notify } from './notification.svelte';
|
|
7
7
|
import { t } from './i18n.svelte';
|
|
8
8
|
import { audit } from './audit';
|
|
9
9
|
import { navigate, currentPath } from './router';
|
|
10
|
-
import { HttpError } from './types';
|
|
10
|
+
import { HttpError, UndoError } from './types';
|
|
11
11
|
import type { BaseRecord, MutationMode, KnownResources } from './types';
|
|
12
|
+
import { checkError } from './hook-utils.svelte';
|
|
13
|
+
import { toast } from './toast.svelte';
|
|
14
|
+
import { deepMerge, invalidateByScopes } from './mutation-hooks.svelte';
|
|
12
15
|
|
|
13
16
|
// ─── Types ───────────────────────────────────────────────────────────
|
|
14
17
|
|
|
@@ -18,7 +21,7 @@ export interface UseFormOptions<
|
|
|
18
21
|
TError = HttpError,
|
|
19
22
|
> {
|
|
20
23
|
resource?: KnownResources;
|
|
21
|
-
action?: 'create' | 'edit' | 'clone';
|
|
24
|
+
action?: 'create' | 'edit' | 'clone' | 'show';
|
|
22
25
|
id?: string | number;
|
|
23
26
|
redirect?: 'list' | 'edit' | 'show' | false;
|
|
24
27
|
|
|
@@ -41,7 +44,7 @@ export interface UseFormOptions<
|
|
|
41
44
|
/** Called every time a field value changes. */
|
|
42
45
|
onChange?: (event: { field: string; value: unknown; values: TVariables }) => void;
|
|
43
46
|
/** Called before mutation. Return false or call cancel() to prevent submission. */
|
|
44
|
-
onSubmit?: (ctx: { values: TVariables; action: 'create' | 'edit' | 'clone'; cancel: () => void }) => void | boolean;
|
|
47
|
+
onSubmit?: (ctx: { values: TVariables; action: 'create' | 'edit' | 'clone' | 'show'; cancel: () => void }) => void | boolean;
|
|
45
48
|
|
|
46
49
|
// ─── Meta ─────────────────────────────────────────────────────
|
|
47
50
|
meta?: Record<string, unknown>;
|
|
@@ -95,6 +98,7 @@ export interface UseFormReturn<
|
|
|
95
98
|
// ─── Tainted (dirty) ──────────────────────────────────────────
|
|
96
99
|
readonly tainted: Record<string, boolean>;
|
|
97
100
|
isTainted: (field?: string) => boolean;
|
|
101
|
+
untaint: () => void;
|
|
98
102
|
|
|
99
103
|
// ─── Errors ───────────────────────────────────────────────────
|
|
100
104
|
readonly errors: Record<string, string>;
|
|
@@ -113,12 +117,12 @@ export interface UseFormReturn<
|
|
|
113
117
|
// ─── State ──────────────────────────────────────────────────────
|
|
114
118
|
readonly loading: boolean;
|
|
115
119
|
readonly submitting: boolean;
|
|
116
|
-
readonly action: 'create' | 'edit' | 'clone';
|
|
120
|
+
readonly action: 'create' | 'edit' | 'clone' | 'show';
|
|
117
121
|
readonly resource: string;
|
|
118
122
|
readonly id: string | number | undefined;
|
|
119
123
|
readonly isDirty: boolean;
|
|
120
124
|
setId: (id: string | number | undefined) => void;
|
|
121
|
-
setAction: (action: 'create' | 'edit' | 'clone') => void;
|
|
125
|
+
setAction: (action: 'create' | 'edit' | 'clone' | 'show') => void;
|
|
122
126
|
mutationMode: MutationMode;
|
|
123
127
|
redirect: (to: 'list' | 'edit' | 'show' | false) => void;
|
|
124
128
|
|
|
@@ -143,7 +147,7 @@ export function useForm<
|
|
|
143
147
|
const adminOptions = getAdminOptions();
|
|
144
148
|
|
|
145
149
|
const resource = $derived(options.resource ?? parsed.resource ?? '');
|
|
146
|
-
let action = $state<'create' | 'edit' | 'clone'>(options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create');
|
|
150
|
+
let action = $state<'create' | 'edit' | 'clone' | 'show'>(options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone' | 'show') ?? 'create');
|
|
147
151
|
let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
|
|
148
152
|
|
|
149
153
|
const defaultRedirectOpt = $derived(action === 'clone' ? adminOptions.redirect?.afterClone : action === 'edit' ? adminOptions.redirect?.afterEdit : adminOptions.redirect?.afterCreate);
|
|
@@ -166,12 +170,11 @@ export function useForm<
|
|
|
166
170
|
const redirectDefault = $derived(redirectOption ?? defaultRedirectOpt ?? 'list');
|
|
167
171
|
|
|
168
172
|
const provider = $derived(getDataProviderForResource(resource, dataProviderName));
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const mutationMeta = { ...parsedMeta, ...hookMeta, ...hookMutationMeta };
|
|
173
|
+
const queryMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookQueryMeta });
|
|
174
|
+
const mutationMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookMutationMeta });
|
|
172
175
|
|
|
173
176
|
function setId(newId: string | number | undefined) { currentId = newId; }
|
|
174
|
-
function setAction(newAction: 'create' | 'edit' | 'clone') { action = newAction; }
|
|
177
|
+
function setAction(newAction: 'create' | 'edit' | 'clone' | 'show') { action = newAction; }
|
|
175
178
|
|
|
176
179
|
// ─── Form values (single source of truth) ───────────────────────
|
|
177
180
|
let values = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
|
|
@@ -243,10 +246,13 @@ export function useForm<
|
|
|
243
246
|
values = { ...initialValues } as TVariables;
|
|
244
247
|
tainted = {};
|
|
245
248
|
clearErrors();
|
|
249
|
+
queryInitializedId = undefined;
|
|
246
250
|
}
|
|
247
251
|
|
|
248
252
|
// ─── Server error handling ──────────────────────────────────────
|
|
253
|
+
|
|
249
254
|
function handleHttpError(error: Error) {
|
|
255
|
+
checkError(error);
|
|
250
256
|
if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
|
|
251
257
|
for (const [field, messages] of Object.entries(error.errors)) {
|
|
252
258
|
const msg = Array.isArray(messages) ? messages[0] : messages;
|
|
@@ -262,12 +268,12 @@ export function useForm<
|
|
|
262
268
|
// Always create the query — use `enabled` to conditionally activate.
|
|
263
269
|
// This ensures setAction('edit') at runtime will trigger a fetch.
|
|
264
270
|
const query = createQuery(() => ({
|
|
265
|
-
queryKey: [resource, 'one', currentId],
|
|
271
|
+
queryKey: [options.dataProviderName, resource, 'one', currentId, queryMeta],
|
|
266
272
|
queryFn: async () => {
|
|
267
273
|
const result = await provider.getOne<BaseRecord>({ resource, id: currentId!, meta: queryMeta });
|
|
268
|
-
return result
|
|
274
|
+
return result;
|
|
269
275
|
},
|
|
270
|
-
enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone') && currentId != null,
|
|
276
|
+
enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone' || action === 'show') && currentId != null,
|
|
271
277
|
staleTime: queryOptions?.staleTime,
|
|
272
278
|
}));
|
|
273
279
|
|
|
@@ -275,14 +281,24 @@ export function useForm<
|
|
|
275
281
|
let queryInitializedId: string | number | undefined = undefined;
|
|
276
282
|
{
|
|
277
283
|
$effect.pre(() => {
|
|
284
|
+
// Discard previous fetch lock and default when switched strictly back to create
|
|
285
|
+
if (action === 'create' && queryInitializedId !== undefined) {
|
|
286
|
+
queryInitializedId = undefined;
|
|
287
|
+
const def = (options.defaultValues ?? {}) as TVariables;
|
|
288
|
+
values = { ...def };
|
|
289
|
+
initialValues = { ...def };
|
|
290
|
+
tainted = {};
|
|
291
|
+
clearErrors();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
278
294
|
if (queryInitializedId === currentId) return;
|
|
279
|
-
const
|
|
295
|
+
const record = (query.data as { data: Record<string, unknown> } | undefined)?.data;
|
|
280
296
|
const pk = getResource(resource).primaryKey ?? 'id';
|
|
281
|
-
if (
|
|
282
|
-
|
|
297
|
+
if (record && String(record[pk]) === String(currentId)) {
|
|
298
|
+
const cloneStripKeys = new Set(['id', '_id', pk, 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updated_by', 'created_by', 'updatedBy']);
|
|
283
299
|
const targetData = action === 'clone'
|
|
284
|
-
? Object.fromEntries(Object.entries(
|
|
285
|
-
:
|
|
300
|
+
? Object.fromEntries(Object.entries(record).filter(([k]) => !cloneStripKeys.has(k)))
|
|
301
|
+
: record;
|
|
286
302
|
// Merge: defaultValues < query data
|
|
287
303
|
const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
|
|
288
304
|
values = merged;
|
|
@@ -299,13 +315,18 @@ export function useForm<
|
|
|
299
315
|
...options.createMutationOptions,
|
|
300
316
|
mutationFn: (variables: TVariables) => provider.create<TData, TVariables>({ resource, variables, meta: mutationMeta }),
|
|
301
317
|
onSuccess: (data: { data: TData }) => {
|
|
302
|
-
|
|
318
|
+
invalidateByScopes(queryClient, resource, invalidateScopes, ['list', 'many']);
|
|
303
319
|
if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
|
|
304
320
|
const pk = getResource(resource).primaryKey ?? 'id';
|
|
305
|
-
|
|
321
|
+
const newId = (data.data as Record<string, unknown>)[pk];
|
|
322
|
+
audit({ action: 'create', resource, recordId: String(newId) });
|
|
323
|
+
// Publish live event
|
|
324
|
+
try {
|
|
325
|
+
const lp = getLiveProvider();
|
|
326
|
+
if (lp?.publish) lp.publish({ type: 'INSERT', resource, payload: { ids: newId != null ? [newId as string | number] : [] } });
|
|
327
|
+
} catch { /* no live provider */ }
|
|
306
328
|
onMutationSuccess?.(data);
|
|
307
329
|
if (redirectOverride !== false) {
|
|
308
|
-
const newId = (data.data as Record<string, unknown>)[pk];
|
|
309
330
|
if (newId != null) currentId = newId as string | number;
|
|
310
331
|
doRedirect(redirectOverride ?? redirectDefault);
|
|
311
332
|
}
|
|
@@ -313,17 +334,60 @@ export function useForm<
|
|
|
313
334
|
onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
|
|
314
335
|
}));
|
|
315
336
|
|
|
316
|
-
const updateMut = createMutation(() => ({
|
|
337
|
+
const updateMut = createMutation<{ data: TData }, unknown, TVariables>(() => ({
|
|
317
338
|
...options.updateMutationOptions,
|
|
318
|
-
mutationFn: (variables: TVariables) =>
|
|
319
|
-
|
|
320
|
-
|
|
339
|
+
mutationFn: async (variables: TVariables) => {
|
|
340
|
+
if (mutationMode === 'undoable') {
|
|
341
|
+
await new Promise<void>((resolve, reject) => {
|
|
342
|
+
toast.undoable(t('common.actionCanBeUndone'), undoableTimeout, () => {
|
|
343
|
+
reject(new UndoError());
|
|
344
|
+
}, resolve);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta });
|
|
348
|
+
},
|
|
349
|
+
onMutate: async (variables: TVariables) => {
|
|
350
|
+
if (mutationMode === 'pessimistic') return;
|
|
351
|
+
await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resource });
|
|
352
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resource });
|
|
353
|
+
if (optimisticUpdateMap?.list !== false) {
|
|
354
|
+
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
355
|
+
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
356
|
+
const o = old as { data: Record<string, unknown>[] };
|
|
357
|
+
const pk = getResource(resource).primaryKey ?? 'id';
|
|
358
|
+
return { ...o, data: o.data.map((item) => String(item[pk]) === String(currentId) ? deepMerge(item, variables) : item) };
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (optimisticUpdateMap?.detail !== false && currentId != null) {
|
|
362
|
+
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === currentId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
|
|
363
|
+
}
|
|
364
|
+
return { previousQueries };
|
|
365
|
+
},
|
|
366
|
+
onSuccess: (data: unknown) => {
|
|
321
367
|
if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.updateSuccess') });
|
|
322
368
|
audit({ action: 'update', resource, recordId: String(currentId) });
|
|
369
|
+
try {
|
|
370
|
+
const lp = getLiveProvider();
|
|
371
|
+
if (lp?.publish) lp.publish({ type: 'UPDATE', resource, payload: { ids: currentId != null ? [currentId] : [] } });
|
|
372
|
+
} catch { /* no live provider */ }
|
|
323
373
|
onMutationSuccess?.(data);
|
|
324
374
|
if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
|
|
325
375
|
},
|
|
326
|
-
|
|
376
|
+
onSettled: (_data: unknown, error: unknown) => {
|
|
377
|
+
if (error instanceof UndoError) return;
|
|
378
|
+
invalidateByScopes(queryClient, resource, invalidateScopes, ['list', 'many', 'detail'], currentId ?? undefined);
|
|
379
|
+
},
|
|
380
|
+
onError: (error: unknown, _vars: unknown, context: unknown) => {
|
|
381
|
+
const ctx = context as { previousQueries?: [unknown, unknown][] } | undefined;
|
|
382
|
+
if (ctx?.previousQueries) {
|
|
383
|
+
for (const [queryKey, data] of ctx.previousQueries) {
|
|
384
|
+
queryClient.setQueryData(queryKey as string[], data);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (error instanceof UndoError) return;
|
|
388
|
+
handleHttpError(error as Error);
|
|
389
|
+
onMutationError?.(error as Error);
|
|
390
|
+
},
|
|
327
391
|
}));
|
|
328
392
|
|
|
329
393
|
function doRedirect(to: 'list' | 'edit' | 'show' | false) {
|
|
@@ -339,7 +403,7 @@ export function useForm<
|
|
|
339
403
|
|
|
340
404
|
// ─── Submit ─────────────────────────────────────────────────────
|
|
341
405
|
async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
|
|
342
|
-
|
|
406
|
+
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
343
407
|
if (onSubmitFn) {
|
|
344
408
|
let cancelled = false;
|
|
345
409
|
const result = onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
|
|
@@ -351,11 +415,9 @@ export function useForm<
|
|
|
351
415
|
try {
|
|
352
416
|
if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
|
|
353
417
|
else await updateMut.mutateAsync(values);
|
|
354
|
-
// Untaint on success
|
|
355
418
|
tainted = {};
|
|
356
|
-
} catch
|
|
357
|
-
// Errors
|
|
358
|
-
// We catch here to prevent unhandled promise rejections.
|
|
419
|
+
} catch {
|
|
420
|
+
// Errors already handled by mutation's onError callback
|
|
359
421
|
}
|
|
360
422
|
}
|
|
361
423
|
|
|
@@ -365,36 +427,41 @@ export function useForm<
|
|
|
365
427
|
let lastAutoSaveData = $state<unknown>(null);
|
|
366
428
|
let lastAutoSaveError = $state<unknown>(null);
|
|
367
429
|
|
|
430
|
+
$effect(() => {
|
|
431
|
+
return () => {
|
|
432
|
+
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
|
|
368
436
|
function triggerAutoSave() {
|
|
369
|
-
const currentAction = action;
|
|
370
|
-
if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone') return;
|
|
437
|
+
const currentAction = action;
|
|
438
|
+
if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone' || currentId == null) return;
|
|
439
|
+
if (createMut.isPending || updateMut.isPending) return;
|
|
371
440
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
372
441
|
|
|
442
|
+
const safeId = currentId;
|
|
373
443
|
autoSaveTimer = setTimeout(async () => {
|
|
374
444
|
const finalValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(values) : values;
|
|
375
445
|
autoSaveStatus = 'saving';
|
|
376
446
|
try {
|
|
377
|
-
await provider.update<TData, TVariables>({ resource, id:
|
|
447
|
+
await provider.update<TData, TVariables>({ resource, id: safeId as string | number, variables: finalValues, meta: mutationMeta });
|
|
378
448
|
const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
|
|
379
|
-
|
|
380
|
-
if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [resource] });
|
|
381
|
-
else if (scope === 'detail' && currentId) queryClient.invalidateQueries({ queryKey: [resource, 'one', currentId] });
|
|
382
|
-
else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [resource, 'list'] });
|
|
383
|
-
}
|
|
449
|
+
invalidateByScopes(queryClient, resource, scopes, ['resourceAll'], safeId ?? undefined);
|
|
384
450
|
autoSaveStatus = 'saved';
|
|
385
|
-
lastAutoSaveData =
|
|
451
|
+
lastAutoSaveData = finalValues;
|
|
386
452
|
lastAutoSaveError = null;
|
|
387
453
|
setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
|
|
388
454
|
} catch (e) {
|
|
389
455
|
autoSaveStatus = 'error';
|
|
390
456
|
lastAutoSaveError = e;
|
|
457
|
+
checkError(e);
|
|
391
458
|
}
|
|
392
459
|
}, autoSaveOpts.debounce ?? 1000);
|
|
393
460
|
}
|
|
394
461
|
|
|
395
462
|
if (autoSaveOpts?.invalidateOnUnmount) {
|
|
396
463
|
$effect(() => {
|
|
397
|
-
return () => queryClient.invalidateQueries({
|
|
464
|
+
return () => queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource });
|
|
398
465
|
});
|
|
399
466
|
}
|
|
400
467
|
|
|
@@ -422,6 +489,7 @@ export function useForm<
|
|
|
422
489
|
// Tainted
|
|
423
490
|
get tainted() { return tainted; },
|
|
424
491
|
isTainted,
|
|
492
|
+
untaint: () => { tainted = {}; },
|
|
425
493
|
|
|
426
494
|
// Errors
|
|
427
495
|
get errors() { return errors; },
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Pure utility functions — no Svelte runtime dependency
|
|
2
|
+
// Extracted to enable unit testing with bun test
|
|
3
|
+
|
|
4
|
+
import type { Filter, Sort, CrudOperator } from './types';
|
|
5
|
+
|
|
6
|
+
// ─── Table Helpers ────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the current filter value for a field from a filter array.
|
|
10
|
+
*/
|
|
11
|
+
export function getDefaultFilter(
|
|
12
|
+
columnName: string,
|
|
13
|
+
filters?: Filter[],
|
|
14
|
+
operatorType: CrudOperator = 'eq',
|
|
15
|
+
): unknown | undefined {
|
|
16
|
+
if (!filters) return undefined;
|
|
17
|
+
const filter = filters.find((f) => {
|
|
18
|
+
if ('field' in f) {
|
|
19
|
+
return f.field === columnName && f.operator === operatorType;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
});
|
|
23
|
+
return filter && 'value' in filter ? filter.value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the current sort order for a field from a sorter array.
|
|
28
|
+
*/
|
|
29
|
+
export function getDefaultSortOrder(
|
|
30
|
+
columnName: string,
|
|
31
|
+
sorters?: Sort[],
|
|
32
|
+
): 'asc' | 'desc' | undefined {
|
|
33
|
+
if (!sorters) return undefined;
|
|
34
|
+
const sortItem = sorters.find((item) => item.field === columnName);
|
|
35
|
+
return sortItem?.order as 'asc' | 'desc' | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Merge two filter arrays, deduplicating by field+operator.
|
|
40
|
+
*/
|
|
41
|
+
export function unionFilters(
|
|
42
|
+
permanentFilter: Filter[],
|
|
43
|
+
newFilters: Filter[],
|
|
44
|
+
prevFilters: Filter[] = [],
|
|
45
|
+
): Filter[] {
|
|
46
|
+
const compareFilters = (a: Filter, b: Filter): boolean => {
|
|
47
|
+
if ('field' in a && 'field' in b) {
|
|
48
|
+
return a.field === b.field && a.operator === b.operator;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let result = [...prevFilters];
|
|
54
|
+
|
|
55
|
+
for (const newFilter of newFilters) {
|
|
56
|
+
const idx = result.findIndex((f) => compareFilters(f, newFilter));
|
|
57
|
+
if (idx >= 0) {
|
|
58
|
+
result[idx] = newFilter;
|
|
59
|
+
} else {
|
|
60
|
+
result.push(newFilter);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const perm of permanentFilter) {
|
|
65
|
+
const idx = result.findIndex((f) => compareFilters(f, perm));
|
|
66
|
+
if (idx >= 0) {
|
|
67
|
+
result[idx] = perm;
|
|
68
|
+
} else {
|
|
69
|
+
result.push(perm);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result.filter((f) => !('value' in f && f.value === undefined));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Merge two sorter arrays, deduplicating by field.
|
|
78
|
+
*/
|
|
79
|
+
export function unionSorters(
|
|
80
|
+
permanentSorters: Sort[],
|
|
81
|
+
newSorters: Sort[],
|
|
82
|
+
): Sort[] {
|
|
83
|
+
const result = [...newSorters];
|
|
84
|
+
|
|
85
|
+
for (const perm of permanentSorters) {
|
|
86
|
+
if (!result.some((s) => s.field === perm.field)) {
|
|
87
|
+
result.push(perm);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Convert a File to a base64 data URL string.
|
|
96
|
+
*/
|
|
97
|
+
export function file2Base64(file: File | { uid?: string } & Blob): Promise<string> {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const reader = new FileReader();
|
|
100
|
+
reader.readAsDataURL(file as Blob);
|
|
101
|
+
reader.addEventListener('load', () => {
|
|
102
|
+
resolve(reader.result as string);
|
|
103
|
+
});
|
|
104
|
+
reader.addEventListener('error', () => {
|
|
105
|
+
reject(reader.error);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Parse full CSV text respecting quoted multi-line fields */
|
|
111
|
+
export function parseCSV(text: string): string[][] {
|
|
112
|
+
const rows: string[][] = [];
|
|
113
|
+
let currentRow: string[] = [];
|
|
114
|
+
let currentVal = '';
|
|
115
|
+
let inQuotes = false;
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < text.length; i++) {
|
|
118
|
+
const ch = text[i];
|
|
119
|
+
if (inQuotes) {
|
|
120
|
+
if (ch === '"') {
|
|
121
|
+
if (i + 1 < text.length && text[i + 1] === '"') {
|
|
122
|
+
currentVal += '"';
|
|
123
|
+
i++; // skip escaped quote
|
|
124
|
+
} else {
|
|
125
|
+
inQuotes = false;
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
currentVal += ch;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
if (ch === '"') {
|
|
132
|
+
inQuotes = true;
|
|
133
|
+
} else if (ch === ',') {
|
|
134
|
+
currentRow.push(currentVal.trim());
|
|
135
|
+
currentVal = '';
|
|
136
|
+
} else if (ch === '\n' || ch === '\r') {
|
|
137
|
+
currentRow.push(currentVal.trim());
|
|
138
|
+
if (currentRow.some(v => v !== '')) {
|
|
139
|
+
rows.push(currentRow);
|
|
140
|
+
}
|
|
141
|
+
currentRow = [];
|
|
142
|
+
currentVal = '';
|
|
143
|
+
if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
|
|
144
|
+
i++; // consume \n of \r\n
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
currentVal += ch;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (currentVal || currentRow.length > 0) {
|
|
153
|
+
currentRow.push(currentVal.trim());
|
|
154
|
+
if (currentRow.some(v => v !== '')) {
|
|
155
|
+
rows.push(currentRow);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Unit tests for helpers-pure.ts — pure functions, no Svelte runtime
|
|
2
|
+
import { describe, test, expect } from 'bun:test';
|
|
3
|
+
import {
|
|
4
|
+
getDefaultFilter, getDefaultSortOrder,
|
|
5
|
+
unionFilters, unionSorters,
|
|
6
|
+
parseCSV,
|
|
7
|
+
} from './helpers-pure';
|
|
8
|
+
import type { Filter, Sort } from './types';
|
|
9
|
+
|
|
10
|
+
// ─── getDefaultFilter ─────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe('getDefaultFilter', () => {
|
|
13
|
+
const filters: Filter[] = [
|
|
14
|
+
{ field: 'status', operator: 'eq', value: 'published' },
|
|
15
|
+
{ field: 'price', operator: 'gte', value: 100 },
|
|
16
|
+
{ field: 'title', operator: 'contains', value: 'hello' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
test('returns value for matching field+operator', () => {
|
|
20
|
+
expect(getDefaultFilter('status', filters)).toBe('published');
|
|
21
|
+
expect(getDefaultFilter('price', filters, 'gte')).toBe(100);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('returns undefined for non-matching field', () => {
|
|
25
|
+
expect(getDefaultFilter('nonexistent', filters)).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('returns undefined for wrong operator', () => {
|
|
29
|
+
expect(getDefaultFilter('status', filters, 'contains')).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('returns undefined for empty/undefined filters', () => {
|
|
33
|
+
expect(getDefaultFilter('status', [])).toBeUndefined();
|
|
34
|
+
expect(getDefaultFilter('status', undefined)).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ─── getDefaultSortOrder ──────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe('getDefaultSortOrder', () => {
|
|
41
|
+
const sorters: Sort[] = [
|
|
42
|
+
{ field: 'name', order: 'asc' },
|
|
43
|
+
{ field: 'createdAt', order: 'desc' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
test('returns order for matching field', () => {
|
|
47
|
+
expect(getDefaultSortOrder('name', sorters)).toBe('asc');
|
|
48
|
+
expect(getDefaultSortOrder('createdAt', sorters)).toBe('desc');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('returns undefined for non-matching field', () => {
|
|
52
|
+
expect(getDefaultSortOrder('unknown', sorters)).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns undefined for empty/undefined sorters', () => {
|
|
56
|
+
expect(getDefaultSortOrder('name', undefined)).toBeUndefined();
|
|
57
|
+
expect(getDefaultSortOrder('name', [])).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ─── unionFilters ─────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe('unionFilters', () => {
|
|
64
|
+
test('merges new filters over previous', () => {
|
|
65
|
+
const prev: Filter[] = [
|
|
66
|
+
{ field: 'status', operator: 'eq', value: 'draft' },
|
|
67
|
+
];
|
|
68
|
+
const next: Filter[] = [
|
|
69
|
+
{ field: 'status', operator: 'eq', value: 'published' },
|
|
70
|
+
];
|
|
71
|
+
const result = unionFilters([], next, prev);
|
|
72
|
+
expect(result).toHaveLength(1);
|
|
73
|
+
expect(result[0].value).toBe('published');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('permanent filters override everything', () => {
|
|
77
|
+
const permanent: Filter[] = [
|
|
78
|
+
{ field: 'org', operator: 'eq', value: 'acme' },
|
|
79
|
+
];
|
|
80
|
+
const next: Filter[] = [
|
|
81
|
+
{ field: 'org', operator: 'eq', value: 'other' },
|
|
82
|
+
];
|
|
83
|
+
const result = unionFilters(permanent, next, []);
|
|
84
|
+
expect(result).toHaveLength(1);
|
|
85
|
+
expect(result[0].value).toBe('acme');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('removes filters with undefined value', () => {
|
|
89
|
+
const prev: Filter[] = [
|
|
90
|
+
{ field: 'status', operator: 'eq', value: 'draft' },
|
|
91
|
+
];
|
|
92
|
+
const next: Filter[] = [
|
|
93
|
+
{ field: 'status', operator: 'eq', value: undefined },
|
|
94
|
+
];
|
|
95
|
+
const result = unionFilters([], next, prev);
|
|
96
|
+
expect(result).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('adds new filters not in previous', () => {
|
|
100
|
+
const prev: Filter[] = [
|
|
101
|
+
{ field: 'status', operator: 'eq', value: 'draft' },
|
|
102
|
+
];
|
|
103
|
+
const next: Filter[] = [
|
|
104
|
+
{ field: 'category', operator: 'eq', value: 'tech' },
|
|
105
|
+
];
|
|
106
|
+
const result = unionFilters([], next, prev);
|
|
107
|
+
expect(result).toHaveLength(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('handles empty arrays', () => {
|
|
111
|
+
expect(unionFilters([], [], [])).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ─── unionSorters ─────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe('unionSorters', () => {
|
|
118
|
+
test('new sorters take priority', () => {
|
|
119
|
+
const permanent: Sort[] = [{ field: 'id', order: 'asc' }];
|
|
120
|
+
const newSorters: Sort[] = [{ field: 'name', order: 'desc' }];
|
|
121
|
+
const result = unionSorters(permanent, newSorters);
|
|
122
|
+
expect(result).toHaveLength(2);
|
|
123
|
+
expect(result[0].field).toBe('name');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('permanent sorters are not duplicated', () => {
|
|
127
|
+
const permanent: Sort[] = [{ field: 'name', order: 'asc' }];
|
|
128
|
+
const newSorters: Sort[] = [{ field: 'name', order: 'desc' }];
|
|
129
|
+
const result = unionSorters(permanent, newSorters);
|
|
130
|
+
expect(result).toHaveLength(1);
|
|
131
|
+
expect(result[0].order).toBe('desc');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('empty arrays', () => {
|
|
135
|
+
expect(unionSorters([], [])).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── parseCSV ─────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('parseCSV', () => {
|
|
142
|
+
test('parses simple CSV', () => {
|
|
143
|
+
const rows = parseCSV('name,age\nAlice,30\nBob,25');
|
|
144
|
+
expect(rows).toHaveLength(3);
|
|
145
|
+
expect(rows[0]).toEqual(['name', 'age']);
|
|
146
|
+
expect(rows[1]).toEqual(['Alice', '30']);
|
|
147
|
+
expect(rows[2]).toEqual(['Bob', '25']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('handles quoted fields', () => {
|
|
151
|
+
const rows = parseCSV('name,bio\nAlice,"Hello, World"');
|
|
152
|
+
expect(rows).toHaveLength(2);
|
|
153
|
+
expect(rows[1][1]).toBe('Hello, World');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('handles escaped quotes', () => {
|
|
157
|
+
const rows = parseCSV('val\n"He said ""hello"""');
|
|
158
|
+
expect(rows).toHaveLength(2);
|
|
159
|
+
expect(rows[1][0]).toBe('He said "hello"');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('handles CRLF line endings', () => {
|
|
163
|
+
const rows = parseCSV('a,b\r\n1,2\r\n3,4');
|
|
164
|
+
expect(rows).toHaveLength(3);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('skips empty rows', () => {
|
|
168
|
+
const rows = parseCSV('a\n\nb\n');
|
|
169
|
+
expect(rows).toHaveLength(2);
|
|
170
|
+
expect(rows[0]).toEqual(['a']);
|
|
171
|
+
expect(rows[1]).toEqual(['b']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('handles empty input', () => {
|
|
175
|
+
expect(parseCSV('')).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
});
|