@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.
@@ -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 parsedMeta = useParsed().params || {};
170
- const queryMeta = { ...parsedMeta, ...hookMeta, ...hookQueryMeta };
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.data;
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 data = query.data as Record<string, unknown> | undefined;
295
+ const record = (query.data as { data: Record<string, unknown> } | undefined)?.data;
280
296
  const pk = getResource(resource).primaryKey ?? 'id';
281
- if (data && (currentId == null || String(data[pk]) === String(currentId) || !data[pk])) {
282
- // In clone mode, strip out identifiers
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(data).filter(([k]) => k !== 'id' && k !== '_id' && k !== pk))
285
- : data;
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
- if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
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
- audit({ action: 'create', resource, recordId: String((data.data as Record<string, unknown>)[pk]) });
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) => provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta }),
319
- onSuccess: (data: { data: TData }) => {
320
- if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
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
- onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
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
- // onSubmit pre-hook
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 (error) {
357
- // Errors are already handled by the mutation's onError callback.
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; // read $state reactively at call time
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: currentId!, variables: finalValues, meta: mutationMeta });
447
+ await provider.update<TData, TVariables>({ resource, id: safeId as string | number, variables: finalValues, meta: mutationMeta });
378
448
  const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
379
- for (const scope of scopes) {
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 = undefined;
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({ queryKey: [resource] });
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
+ });