@svadmin/core 0.0.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.svelte';
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
 
@@ -0,0 +1,266 @@
1
+ import { useQueryClient } from '@tanstack/svelte-query';
2
+ import { useParsed } from './useParsed';
3
+ import { getAdminOptions } from './options';
4
+ import { getDataProviderForResource } from './context.svelte';
5
+ import { createQuery, createMutation } from '@tanstack/svelte-query';
6
+ import { toast } from './toast.svelte';
7
+ import { t } from './i18n.svelte';
8
+ import { audit } from './audit';
9
+ import { navigate } from './router';
10
+ import { HttpError } from './types';
11
+ import type { BaseRecord, MutationMode, KnownResources } from './types';
12
+
13
+ export interface UseFormOptions<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> {
14
+ resource?: KnownResources;
15
+ action?: 'create' | 'edit' | 'clone';
16
+ id?: string | number;
17
+ redirect?: 'list' | 'edit' | 'show' | false;
18
+ successNotification?: string | false;
19
+ errorNotification?: string | false;
20
+ dataProviderName?: string;
21
+ onMutationSuccess?: (data: unknown) => void;
22
+ onMutationError?: (error: Error) => void;
23
+ meta?: Record<string, unknown>;
24
+ queryMeta?: Record<string, unknown>;
25
+ mutationMeta?: Record<string, unknown>;
26
+ disableServerSideValidation?: boolean;
27
+ validate?: (values: TVariables) => Record<string, string> | null;
28
+ autoSave?: {
29
+ enabled: boolean;
30
+ debounce?: number;
31
+ onFinish?: (values: TVariables) => TVariables;
32
+ invalidateOnUnmount?: boolean;
33
+ invalidates?: string[];
34
+ };
35
+ mutationMode?: MutationMode;
36
+ undoableTimeout?: number;
37
+ invalidates?: string[] | false;
38
+ optimisticUpdateMap?: {
39
+ list?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
40
+ detail?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
41
+ };
42
+ queryOptions?: { staleTime?: number; enabled?: boolean };
43
+ createMutationOptions?: Record<string, unknown>;
44
+ updateMutationOptions?: Record<string, unknown>;
45
+ warnWhenUnsavedChanges?: boolean;
46
+ }
47
+
48
+ export type UseFormResult<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> = {
49
+ readonly query: unknown;
50
+ readonly mutation: unknown;
51
+ readonly formLoading: boolean;
52
+ readonly errors: Record<string, string>;
53
+ setFieldError: (field: string, message: string) => void;
54
+ clearErrors: () => void;
55
+ clearFieldError: (field: string) => void;
56
+ onFinish: (values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) => Promise<void>;
57
+ onFinishAutoSave: (values: TVariables) => Promise<void>;
58
+ triggerAutoSave: (values: TVariables) => void;
59
+ readonly autoSaveStatus: 'idle' | 'saving' | 'saved' | 'error';
60
+ readonly resource: string;
61
+ readonly action: 'create' | 'edit' | 'clone';
62
+ readonly id: string | number | undefined;
63
+ setId: (newId: string | number) => void;
64
+ readonly mutationMode: MutationMode;
65
+ redirect: (to: 'list' | 'edit' | 'show' | false) => void;
66
+ readonly autoSaveProps: { status: 'idle' | 'loading' | 'success' | 'error'; data: unknown; error: unknown };
67
+ };
68
+
69
+ export function useForm<
70
+ TQueryFnData extends BaseRecord = BaseRecord,
71
+ TError = HttpError,
72
+ TVariables = Record<string, unknown>,
73
+ TData extends BaseRecord = TQueryFnData
74
+ >(options: UseFormOptions<TQueryFnData, TError, TVariables, TData> = {}): UseFormResult<TQueryFnData, TError, TVariables, TData> {
75
+ const queryClient = useQueryClient();
76
+ const parsed = useParsed();
77
+ const adminOptions = getAdminOptions();
78
+
79
+ const resource = options.resource ?? parsed.resource ?? '';
80
+ const action = options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create';
81
+ let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
82
+
83
+ const {
84
+ redirect: redirectDefault = 'list',
85
+ successNotification, errorNotification,
86
+ onMutationSuccess, onMutationError,
87
+ meta: hookMeta, queryMeta: hookQueryMeta, mutationMeta: hookMutationMeta,
88
+ disableServerSideValidation, validate, autoSave,
89
+ mutationMode = adminOptions.mutationMode ?? 'pessimistic',
90
+ undoableTimeout = adminOptions.undoableTimeout ?? 5000,
91
+ dataProviderName, invalidates: invalidateScopes,
92
+ optimisticUpdateMap,
93
+ queryOptions,
94
+ } = options;
95
+
96
+ const provider = getDataProviderForResource(resource, dataProviderName);
97
+ const parsedMeta = typeof window !== 'undefined' ? Object.fromEntries(new URLSearchParams(window.location.search).entries()) : {};
98
+ const queryMeta = { ...parsedMeta, ...hookMeta, ...hookQueryMeta };
99
+ const mutationMeta = { ...parsedMeta, ...hookMeta, ...hookMutationMeta };
100
+
101
+ function setId(newId: string | number) { currentId = newId; }
102
+
103
+ let errors = $state<Record<string, string>>({});
104
+ function setFieldError(field: string, message: string) { errors = { ...errors, [field]: message }; }
105
+ function clearErrors() { errors = {}; }
106
+ function clearFieldError(field: string) { delete errors[field]; }
107
+
108
+ function runValidation(values: TVariables): boolean {
109
+ clearErrors();
110
+ if (validate) {
111
+ const result = validate(values);
112
+ if (result && Object.keys(result).length > 0) {
113
+ errors = result;
114
+ return false;
115
+ }
116
+ }
117
+ return true;
118
+ }
119
+
120
+ function handleHttpError(error: Error) {
121
+ if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
122
+ for (const [field, messages] of Object.entries(error.errors)) {
123
+ const msg = Array.isArray(messages) ? messages[0] : messages;
124
+ setFieldError(field, msg);
125
+ }
126
+ if (errorNotification !== false) toast.error(errorNotification || error.message || t('common.operationFailed'));
127
+ } else {
128
+ if (errorNotification !== false) toast.error(errorNotification || t('common.operationFailed') + ': ' + error.message);
129
+ }
130
+ }
131
+
132
+ const query = (action === 'edit' || action === 'clone') && currentId != null
133
+ ? createQuery(() => ({
134
+ queryKey: [resource, 'one', currentId],
135
+ queryFn: async () => {
136
+ const result = await provider.getOne<TQueryFnData>({ resource, id: currentId!, meta: queryMeta });
137
+ return result.data;
138
+ },
139
+ enabled: (queryOptions?.enabled ?? true) && currentId != null,
140
+ staleTime: queryOptions?.staleTime,
141
+ }))
142
+ : null;
143
+
144
+ const createMut = createMutation(() => ({
145
+ ...options.createMutationOptions,
146
+ mutationFn: (variables: TVariables) => provider.create<TData, TVariables>({ resource, variables, meta: mutationMeta }),
147
+ onSuccess: (data: { data: TData }) => {
148
+ if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
149
+ if (successNotification !== false) toast.success(successNotification || t('common.createSuccess'));
150
+ audit({ action: 'create', resource, recordId: String(data.data.id) });
151
+ onMutationSuccess?.(data);
152
+ if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
153
+ },
154
+ onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
155
+ }));
156
+
157
+ const updateMut = createMutation(() => ({
158
+ ...options.updateMutationOptions,
159
+ mutationFn: (variables: TVariables) => provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta }),
160
+ onSuccess: (data: { data: TData }) => {
161
+ if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
162
+ if (successNotification !== false) toast.success(successNotification || t('common.updateSuccess'));
163
+ audit({ action: 'update', resource, recordId: String(currentId) });
164
+ onMutationSuccess?.(data);
165
+ if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
166
+ },
167
+ onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
168
+ }));
169
+
170
+ let redirectOverride: 'list' | 'edit' | 'show' | false | undefined;
171
+
172
+ function doRedirect(to: 'list' | 'edit' | 'show' | false) {
173
+ if (to === 'list') navigate(`/${resource}`);
174
+ else if (to === 'edit' && currentId) navigate(`/${resource}/edit/${currentId}`);
175
+ else if (to === 'show' && currentId) navigate(`/${resource}/show/${currentId}`);
176
+ }
177
+
178
+ async function onFinish(values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) {
179
+ if (!runValidation(values)) { toast.warning(t('validation.required')); return; }
180
+ redirectOverride = finishOptions?.redirect;
181
+ if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
182
+ else await updateMut.mutateAsync(values);
183
+ }
184
+
185
+ async function onFinishAutoSave(values: TVariables) {
186
+ if (action === 'create' || action === 'clone') return; // Only autosave on edit
187
+ redirectOverride = false; // Never redirect on autoSave
188
+ await updateMut.mutateAsync(values);
189
+ }
190
+
191
+ let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
192
+ let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
193
+
194
+ function triggerAutoSave(values: TVariables) {
195
+ if (!autoSave?.enabled || action === 'create') return;
196
+ if (autoSaveTimer) clearTimeout(autoSaveTimer);
197
+
198
+ autoSaveTimer = setTimeout(async () => {
199
+ const finalValues = autoSave.onFinish ? autoSave.onFinish(values) : values;
200
+ autoSaveStatus = 'saving';
201
+ try {
202
+ await provider.update<TData, TVariables>({ resource, id: currentId!, variables: finalValues, meta: mutationMeta });
203
+ const scopes = autoSave.invalidates ?? ['resourceAll'];
204
+ for (const scope of scopes) {
205
+ if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [resource] });
206
+ else if (scope === 'detail' && currentId) queryClient.invalidateQueries({ queryKey: [resource, 'one', currentId] });
207
+ else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [resource, 'list'] });
208
+ }
209
+ autoSaveStatus = 'saved';
210
+ lastAutoSaveData = undefined; // placeholder for returned data
211
+ lastAutoSaveError = null;
212
+ setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
213
+ } catch (e) {
214
+ autoSaveStatus = 'error';
215
+ lastAutoSaveError = e;
216
+ }
217
+ }, autoSave.debounce ?? 1000);
218
+ }
219
+
220
+ let lastAutoSaveData = $state<unknown>(null);
221
+ let lastAutoSaveError = $state<unknown>(null);
222
+
223
+ if (autoSave?.invalidateOnUnmount) {
224
+ $effect(() => {
225
+ return () => queryClient.invalidateQueries({ queryKey: [resource] });
226
+ });
227
+ }
228
+
229
+ // warnWhenUnsavedChanges
230
+ const shouldWarn = options.warnWhenUnsavedChanges ?? adminOptions.warnWhenUnsavedChanges;
231
+ if (shouldWarn && typeof window !== 'undefined') {
232
+ $effect(() => {
233
+ const handler = (e: BeforeUnloadEvent) => {
234
+ // Here we'd typically check a dirty state, but useForm itself doesn't track dirty fields.
235
+ // Usually the form library (svelte-forms etc) handles dirty state.
236
+ // Assuming we always warn if enabled for now.
237
+ e.preventDefault();
238
+ e.returnValue = '';
239
+ };
240
+ window.addEventListener('beforeunload', handler);
241
+ return () => window.removeEventListener('beforeunload', handler);
242
+ });
243
+ }
244
+
245
+ return {
246
+ query,
247
+ get formLoading() { return (query?.isLoading ?? false) || createMut.isPending || updateMut.isPending; },
248
+ mutation: action === 'edit' ? updateMut : createMut,
249
+ onFinish,
250
+ onFinishAutoSave,
251
+ get errors() { return errors; },
252
+ setFieldError, clearErrors, clearFieldError,
253
+ triggerAutoSave,
254
+ get autoSaveStatus() { return autoSaveStatus; },
255
+ resource, action,
256
+ get id() { return currentId; },
257
+ setId, mutationMode, redirect: doRedirect,
258
+ get autoSaveProps() {
259
+ return {
260
+ status: autoSaveStatus as 'idle' | 'loading' | 'success' | 'error',
261
+ data: lastAutoSaveData,
262
+ error: lastAutoSaveError,
263
+ };
264
+ },
265
+ };
266
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Refine v5 compatible utility helpers
2
+
3
+ import type { Filter, Sort, CrudOperator } from './types';
4
+ import { t } from './i18n.svelte';
5
+ import { getTextTransformers } from './options';
6
+
7
+ // ─── Table Helpers ────────────────────────────────────────────
8
+
9
+ /**
10
+ * Get the current filter value for a field from a filter array.
11
+ * Useful for setting default values in filter inputs.
12
+ */
13
+ export function getDefaultFilter(
14
+ columnName: string,
15
+ filters?: Filter[],
16
+ operatorType: CrudOperator = 'eq',
17
+ ): unknown | undefined {
18
+ if (!filters) return undefined;
19
+ const filter = filters.find((f) => {
20
+ if ('field' in f) {
21
+ return f.field === columnName && f.operator === operatorType;
22
+ }
23
+ return false;
24
+ });
25
+ return filter && 'value' in filter ? filter.value : undefined;
26
+ }
27
+
28
+ /**
29
+ * Get the current sort order for a field from a sorter array.
30
+ * Returns 'asc', 'desc', or undefined.
31
+ */
32
+ export function getDefaultSortOrder(
33
+ columnName: string,
34
+ sorters?: Sort[],
35
+ ): 'asc' | 'desc' | undefined {
36
+ if (!sorters) return undefined;
37
+ const sortItem = sorters.find((item) => item.field === columnName);
38
+ return sortItem?.order as 'asc' | 'desc' | undefined;
39
+ }
40
+
41
+ /**
42
+ * Merge two filter arrays, deduplicating by field+operator.
43
+ * Filters in `newFilters` take priority over `prevFilters`.
44
+ * `permanentFilter` items are always preserved.
45
+ * Filters with undefined values are removed from the result.
46
+ */
47
+ export function unionFilters(
48
+ permanentFilter: Filter[],
49
+ newFilters: Filter[],
50
+ prevFilters: Filter[] = [],
51
+ ): Filter[] {
52
+ const compareFilters = (a: Filter, b: Filter): boolean => {
53
+ if ('field' in a && 'field' in b) {
54
+ return a.field === b.field && a.operator === b.operator;
55
+ }
56
+ return false;
57
+ };
58
+
59
+ // Start with previous filters
60
+ let result = [...prevFilters];
61
+
62
+ // Apply new filters (override previous by field+operator)
63
+ for (const newFilter of newFilters) {
64
+ const idx = result.findIndex((f) => compareFilters(f, newFilter));
65
+ if (idx >= 0) {
66
+ result[idx] = newFilter;
67
+ } else {
68
+ result.push(newFilter);
69
+ }
70
+ }
71
+
72
+ // Ensure permanent filters are always present
73
+ for (const perm of permanentFilter) {
74
+ const idx = result.findIndex((f) => compareFilters(f, perm));
75
+ if (idx >= 0) {
76
+ result[idx] = perm;
77
+ } else {
78
+ result.push(perm);
79
+ }
80
+ }
81
+
82
+ // Remove filters with undefined values
83
+ return result.filter((f) => !('value' in f && f.value === undefined));
84
+ }
85
+
86
+ /**
87
+ * Merge two sorter arrays, deduplicating by field.
88
+ * Sorters in `newSorters` take priority.
89
+ */
90
+ export function unionSorters(
91
+ permanentSorters: Sort[],
92
+ newSorters: Sort[],
93
+ ): Sort[] {
94
+ const result = [...newSorters];
95
+
96
+ for (const perm of permanentSorters) {
97
+ if (!result.some((s) => s.field === perm.field)) {
98
+ result.push(perm);
99
+ }
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+ // ─── Upload Helpers ───────────────────────────────────────────
106
+
107
+ /**
108
+ * Convert a File (or file-like object) to a base64 data URL string.
109
+ * Useful for image uploads and previews.
110
+ */
111
+ export function file2Base64(file: File | { uid?: string } & Blob): Promise<string> {
112
+ return new Promise((resolve, reject) => {
113
+ const reader = new FileReader();
114
+ reader.readAsDataURL(file as Blob);
115
+ reader.addEventListener('load', () => {
116
+ resolve(reader.result as string);
117
+ });
118
+ reader.addEventListener('error', () => {
119
+ reject(reader.error);
120
+ });
121
+ });
122
+ }
123
+
124
+ // ─── Document Title Helper ────────────────────────────────────
125
+
126
+ /**
127
+ * Generate a default document title for a resource page.
128
+ * e.g., "Edit Post #5 | Admin" or "Posts | Admin"
129
+ */
130
+ export function generateDefaultDocumentTitle(options?: {
131
+ resource?: string;
132
+ action?: string;
133
+ id?: string | number;
134
+ suffix?: string;
135
+ }): string {
136
+ const { resource, action, id, suffix = ' | Admin' } = options ?? {};
137
+ const transformers = getTextTransformers();
138
+
139
+ if (!resource) {
140
+ return t('documentTitle.default', {}) || `Admin${suffix}`;
141
+ }
142
+
143
+ const resourceLabel = transformers.humanize
144
+ ? transformers.humanize(resource)
145
+ : resource.charAt(0).toUpperCase() + resource.slice(1);
146
+
147
+ const actionPrefixes: Record<string, string> = {
148
+ create: `${t('common.create', {}) || 'Create'} `,
149
+ clone: `#${id ?? ''} ${t('common.clone', {}) || 'Clone'} `,
150
+ edit: `#${id ?? ''} ${t('common.edit', {}) || 'Edit'} `,
151
+ show: `#${id ?? ''} ${t('common.detail', {}) || 'Show'} `,
152
+ list: '',
153
+ };
154
+
155
+ const prefix = action ? (actionPrefixes[action] ?? '') : '';
156
+ return `${prefix}${resourceLabel}${suffix}`;
157
+ }