@svadmin/ui 0.57.0 → 0.59.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/dist/components/AICommandBar.svelte +34 -10
- package/dist/components/AdminApp.svelte +16 -5
- package/dist/components/AutoForm.svelte +4 -4
- package/dist/components/AutoTable.svelte +884 -217
- package/dist/components/AutoTable.svelte.d.ts +1 -1
- package/dist/components/CommandPalette.svelte +40 -17
- package/dist/components/ConfirmDialog.svelte +4 -3
- package/dist/components/ConfirmDialog.svelte.d.ts +1 -0
- package/dist/components/CopilotPanel.svelte +3 -3
- package/dist/components/DraggableHeader.svelte +15 -36
- package/dist/components/DraggableHeader.svelte.d.ts +13 -4
- package/dist/components/EditPage.svelte +4 -4
- package/dist/components/EmptyState.svelte +4 -2
- package/dist/components/Layout.svelte +8 -1
- package/dist/components/ListPage.svelte +4 -12
- package/dist/components/ListPage.svelte.d.ts +1 -1
- package/dist/components/RecordDetailDrawer.svelte +121 -0
- package/dist/components/RecordDetailDrawer.svelte.d.ts +9 -0
- package/dist/components/ResourceAccessGuard.svelte +68 -0
- package/dist/components/ResourceAccessGuard.svelte.d.ts +12 -0
- package/dist/components/ResourceOperationsPage.svelte +8 -3
- package/dist/components/ShowPage.svelte +5 -6
- package/dist/components/StatsCard.svelte +5 -3
- package/dist/components/TaskQueueDrawer.svelte +10 -1
- package/dist/components/TenantSwitcher.svelte +1 -0
- package/dist/components/Toast.svelte +35 -1
- package/dist/components/UndoableNotification.svelte +12 -6
- package/dist/components/UndoableNotification.svelte.d.ts +2 -0
- package/dist/components/buttons/CloneButton.svelte +9 -2
- package/dist/components/buttons/CreateButton.svelte +9 -2
- package/dist/components/buttons/DeleteButton.svelte +9 -2
- package/dist/components/buttons/EditButton.svelte +9 -2
- package/dist/components/buttons/ListButton.svelte +1 -1
- package/dist/components/buttons/ShowButton.svelte +9 -2
- package/dist/components/saved-list-views.d.ts +35 -0
- package/dist/components/saved-list-views.js +168 -0
- package/dist/components/ui/input/input.svelte +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onDestroy, untrack } from 'svelte';
|
|
3
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
3
4
|
import { cn } from '../utils.js';
|
|
4
5
|
import {
|
|
5
6
|
createTable,
|
|
@@ -16,25 +17,24 @@
|
|
|
16
17
|
type ColumnVisibilityState,
|
|
17
18
|
type ExpandedState,
|
|
18
19
|
} from '@tanstack/svelte-table';
|
|
19
|
-
import type { Column, Header, TableFeatures } from '@tanstack/table-core';
|
|
20
|
+
import type { Column, Header, Row, TableFeatures } from '@tanstack/table-core';
|
|
20
21
|
import { createAtom, useSelector } from '@tanstack/svelte-store';
|
|
21
22
|
import {
|
|
22
23
|
column_getCanSort,
|
|
23
24
|
column_getIsSorted,
|
|
24
25
|
column_toggleSorting,
|
|
25
|
-
column_toggleVisibility,
|
|
26
26
|
header_getSize,
|
|
27
|
+
table_getHeaderGroups,
|
|
27
28
|
table_getRowModel,
|
|
28
29
|
table_getAllLeafColumns,
|
|
29
30
|
table_getIsAllRowsSelected,
|
|
30
31
|
table_toggleAllRowsSelected,
|
|
31
|
-
row_getIsSelected,
|
|
32
32
|
row_toggleSelected,
|
|
33
33
|
row_getVisibleCells,
|
|
34
34
|
cell_getValue,
|
|
35
35
|
} from '@tanstack/table-core/static-functions';
|
|
36
36
|
|
|
37
|
-
import { captureAdminContext, useList, useDelete, useDeleteMany, getResource,
|
|
37
|
+
import { captureAdminContext, DeleteManyPartialError, getAdminOptions, useList, useDelete, useDeleteMany, getResource, useNavigation, useParsed } from '@svadmin/core';
|
|
38
38
|
import type {
|
|
39
39
|
BaseRecord,
|
|
40
40
|
FieldDefinition,
|
|
@@ -46,6 +46,24 @@
|
|
|
46
46
|
import { useCan } from '@svadmin/core';
|
|
47
47
|
import { readURLState, writeURLState } from '@svadmin/core';
|
|
48
48
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
49
|
+
import {
|
|
50
|
+
activeSavedListViewStorageKey,
|
|
51
|
+
canMigrateLegacyListPreferences,
|
|
52
|
+
cloneSavedListViewState,
|
|
53
|
+
columnOrderStorageKey,
|
|
54
|
+
columnVisibilityStorageKey,
|
|
55
|
+
legacyActiveSavedListViewStorageKey,
|
|
56
|
+
legacyColumnOrderStorageKey,
|
|
57
|
+
legacyColumnVisibilityStorageKey,
|
|
58
|
+
legacySavedListViewsStorageKey,
|
|
59
|
+
listPreferenceScopeId,
|
|
60
|
+
readSavedListViews,
|
|
61
|
+
savedListViewsStorageKey,
|
|
62
|
+
serializeSavedListViews,
|
|
63
|
+
type ListPreferenceScope,
|
|
64
|
+
type SavedListView,
|
|
65
|
+
type SavedListViewState,
|
|
66
|
+
} from './saved-list-views.js';
|
|
49
67
|
|
|
50
68
|
import { fade } from 'svelte/transition';
|
|
51
69
|
import { Button } from './ui/button/index.js';
|
|
@@ -58,15 +76,19 @@
|
|
|
58
76
|
import * as DropdownMenu from './ui/dropdown-menu/index.js';
|
|
59
77
|
import * as PaginationUI from './ui/pagination/index.js';
|
|
60
78
|
import * as ContextMenu from './ui/context-menu/index.js';
|
|
79
|
+
import * as Select from './ui/select/index.js';
|
|
61
80
|
import {
|
|
62
81
|
Plus, Pencil, Trash2,
|
|
63
82
|
Search, Download, ChevronDown, ChevronUp, SlidersHorizontal, Filter as FilterIcon,
|
|
64
|
-
Eye, Copy, RefreshCw, Rows, X
|
|
83
|
+
Eye, Copy, RefreshCw, Rows, X, Bookmark, Check
|
|
65
84
|
} from '@lucide/svelte';
|
|
66
85
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
86
|
+
import RecordDetailDrawer from './RecordDetailDrawer.svelte';
|
|
87
|
+
import CanAccess from './CanAccess.svelte';
|
|
67
88
|
import TooltipButton from './TooltipButton.svelte';
|
|
68
89
|
import InlineEdit from './InlineEdit.svelte';
|
|
69
90
|
import DraggableHeader from './DraggableHeader.svelte';
|
|
91
|
+
import DataState from './content/DataState.svelte';
|
|
70
92
|
import type { Snippet } from 'svelte';
|
|
71
93
|
|
|
72
94
|
const i18n = useTranslation();
|
|
@@ -98,7 +120,7 @@
|
|
|
98
120
|
/** Externally controlled sorters */
|
|
99
121
|
sorters?: Sort[];
|
|
100
122
|
/** Custom batch actions to render when rows are selected */
|
|
101
|
-
batchActions?: Snippet<[{ selectedIds: string[] }]>;
|
|
123
|
+
batchActions?: Snippet<[{ selectedIds: (string | number)[] }]>;
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
let {
|
|
@@ -124,30 +146,203 @@
|
|
|
124
146
|
let densityOverride = $state<'compact' | 'comfortable' | undefined>(undefined);
|
|
125
147
|
const currentDensity = $derived(densityOverride ?? density);
|
|
126
148
|
const adminContext = captureAdminContext();
|
|
149
|
+
const adminOptions = getAdminOptions();
|
|
150
|
+
const mutationMode = $derived(adminOptions.mutationMode ?? 'pessimistic');
|
|
151
|
+
const parsed = useParsed();
|
|
152
|
+
const navigation = useNavigation();
|
|
127
153
|
|
|
128
154
|
const resource = $derived(getResource(resourceName));
|
|
129
155
|
const primaryKey = $derived(resource.primaryKey ?? 'id');
|
|
156
|
+
const listPreferenceScope = $derived.by(() => {
|
|
157
|
+
const matcher = adminContext.queryKeyMatcher(resourceName);
|
|
158
|
+
return {
|
|
159
|
+
resourceName,
|
|
160
|
+
providerName: matcher.provider ?? 'default',
|
|
161
|
+
tenantIdentity: matcher.tenant,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function readLocalPreference(key: string): string | null {
|
|
166
|
+
if (typeof window === 'undefined') return null;
|
|
167
|
+
try {
|
|
168
|
+
return localStorage.getItem(key);
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readScopedPreference(scope: ListPreferenceScope, scopedKey: string, legacyKey: string): string | null {
|
|
175
|
+
const scoped = readLocalPreference(scopedKey);
|
|
176
|
+
if (scoped !== null || !canMigrateLegacyListPreferences(scope)) return scoped;
|
|
177
|
+
const legacy = readLocalPreference(legacyKey);
|
|
178
|
+
if (legacy === null || typeof window === 'undefined') return null;
|
|
179
|
+
try {
|
|
180
|
+
localStorage.setItem(scopedKey, legacy);
|
|
181
|
+
localStorage.removeItem(legacyKey);
|
|
182
|
+
} catch { /* keep the legacy value readable when migration cannot persist */ }
|
|
183
|
+
return legacy;
|
|
184
|
+
}
|
|
130
185
|
|
|
131
186
|
// ─── URL state + server-side state ────────────────────────────
|
|
132
187
|
const urlState = readURLState(adminContext);
|
|
133
188
|
|
|
189
|
+
const savedViewColumnIds = new Set([
|
|
190
|
+
...untrack(() => resource.fields.map((field) => field.key)),
|
|
191
|
+
'_select', '_expand', '_actions',
|
|
192
|
+
]);
|
|
193
|
+
const storedSavedViews = untrack(() => readSavedListViews(
|
|
194
|
+
readScopedPreference(
|
|
195
|
+
listPreferenceScope,
|
|
196
|
+
savedListViewsStorageKey(listPreferenceScope),
|
|
197
|
+
legacySavedListViewsStorageKey(resourceName),
|
|
198
|
+
),
|
|
199
|
+
savedViewColumnIds,
|
|
200
|
+
));
|
|
201
|
+
let savedViews = $state<SavedListView[]>(storedSavedViews);
|
|
202
|
+
const storedActiveSavedViewId = untrack(() => {
|
|
203
|
+
const candidate = readScopedPreference(
|
|
204
|
+
listPreferenceScope,
|
|
205
|
+
activeSavedListViewStorageKey(listPreferenceScope),
|
|
206
|
+
legacyActiveSavedListViewStorageKey(resourceName),
|
|
207
|
+
);
|
|
208
|
+
return storedSavedViews.some((view) => view.id === candidate) ? candidate ?? undefined : undefined;
|
|
209
|
+
});
|
|
210
|
+
let savedViewName = $state('');
|
|
211
|
+
let savedViewsOpen = $state(false);
|
|
212
|
+
|
|
213
|
+
const hasExplicitURLState = untrack(() => (
|
|
214
|
+
urlState.page !== undefined
|
|
215
|
+
|| urlState.pageSize !== undefined
|
|
216
|
+
|| urlState.sortField !== undefined
|
|
217
|
+
|| urlState.search !== undefined
|
|
218
|
+
|| urlState.filters !== undefined
|
|
219
|
+
));
|
|
220
|
+
let activeSavedViewId = $state<string | undefined>(hasExplicitURLState ? undefined : storedActiveSavedViewId);
|
|
221
|
+
const activeSavedViewName = $derived(savedViews.find((view) => view.id === activeSavedViewId)?.name);
|
|
222
|
+
const initialSavedView = untrack(() => (
|
|
223
|
+
!hasExplicitURLState && storedActiveSavedViewId
|
|
224
|
+
? storedSavedViews.find((view) => view.id === storedActiveSavedViewId)
|
|
225
|
+
: undefined
|
|
226
|
+
));
|
|
227
|
+
const initialViewState = initialSavedView?.state;
|
|
228
|
+
|
|
229
|
+
// The table can stay mounted while the surrounding tenant/provider changes.
|
|
230
|
+
// Keep persistence effects paused until the new scope has been loaded.
|
|
231
|
+
let loadedPreferenceScopeId = $state(untrack(() => listPreferenceScopeId(listPreferenceScope)));
|
|
232
|
+
|
|
233
|
+
function readColumnVisibilityPreference(scope: ListPreferenceScope): ColumnVisibilityState {
|
|
234
|
+
const stored = readScopedPreference(
|
|
235
|
+
scope,
|
|
236
|
+
columnVisibilityStorageKey(scope),
|
|
237
|
+
legacyColumnVisibilityStorageKey(resourceName),
|
|
238
|
+
);
|
|
239
|
+
if (stored) {
|
|
240
|
+
try {
|
|
241
|
+
const parsed: unknown = JSON.parse(stored);
|
|
242
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
243
|
+
const visibility: ColumnVisibilityState = {};
|
|
244
|
+
for (const [columnId, visible] of Object.entries(parsed as Record<string, unknown>)) {
|
|
245
|
+
if (savedViewColumnIds.has(columnId) && typeof visible === 'boolean') {
|
|
246
|
+
visibility[columnId] = visible;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return visibility;
|
|
250
|
+
}
|
|
251
|
+
} catch { /* fall through to resource defaults */ }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const visibility: ColumnVisibilityState = {};
|
|
255
|
+
for (const field of resource.fields) {
|
|
256
|
+
if (field.showInList === false) visibility[field.key] = false;
|
|
257
|
+
}
|
|
258
|
+
return visibility;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readColumnOrderPreference(scope: ListPreferenceScope): string[] {
|
|
262
|
+
const stored = readScopedPreference(
|
|
263
|
+
scope,
|
|
264
|
+
columnOrderStorageKey(scope),
|
|
265
|
+
legacyColumnOrderStorageKey(resourceName),
|
|
266
|
+
);
|
|
267
|
+
if (!stored) return [];
|
|
268
|
+
try {
|
|
269
|
+
const parsed: unknown = JSON.parse(stored);
|
|
270
|
+
return Array.isArray(parsed)
|
|
271
|
+
? [...new Set(parsed.filter((columnId): columnId is string => (
|
|
272
|
+
typeof columnId === 'string' && savedViewColumnIds.has(columnId)
|
|
273
|
+
)))]
|
|
274
|
+
: [];
|
|
275
|
+
} catch {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function readScopedSavedViewPreferences(scope: ListPreferenceScope): {
|
|
281
|
+
savedViews: SavedListView[];
|
|
282
|
+
activeSavedViewId?: string;
|
|
283
|
+
activeSavedView?: SavedListView;
|
|
284
|
+
columnVisibility: ColumnVisibilityState;
|
|
285
|
+
columnOrder: string[];
|
|
286
|
+
} {
|
|
287
|
+
const scopedSavedViews = readSavedListViews(
|
|
288
|
+
readScopedPreference(
|
|
289
|
+
scope,
|
|
290
|
+
savedListViewsStorageKey(scope),
|
|
291
|
+
legacySavedListViewsStorageKey(resourceName),
|
|
292
|
+
),
|
|
293
|
+
savedViewColumnIds,
|
|
294
|
+
);
|
|
295
|
+
const candidate = readScopedPreference(
|
|
296
|
+
scope,
|
|
297
|
+
activeSavedListViewStorageKey(scope),
|
|
298
|
+
legacyActiveSavedListViewStorageKey(resourceName),
|
|
299
|
+
);
|
|
300
|
+
const activeSavedView = scopedSavedViews.find((view) => view.id === candidate);
|
|
301
|
+
return {
|
|
302
|
+
savedViews: scopedSavedViews,
|
|
303
|
+
activeSavedViewId: activeSavedView?.id,
|
|
304
|
+
activeSavedView,
|
|
305
|
+
columnVisibility: readColumnVisibilityPreference(scope),
|
|
306
|
+
columnOrder: readColumnOrderPreference(scope),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
134
310
|
// Snapshot resource values for initial state (untrack to avoid reactive tracking)
|
|
135
|
-
const storedPageSize =
|
|
311
|
+
const storedPageSize = parseInt(readLocalPreference('svadmin-default-page-size') ?? '', 10);
|
|
136
312
|
const initPageSize = untrack(() => resource.pageSize ?? (isNaN(storedPageSize) ? 10 : storedPageSize));
|
|
137
313
|
const initDefaultSort = untrack(() => resource.defaultSort);
|
|
138
314
|
|
|
139
315
|
let pagination = $state<PaginationState>(untrack(() => externalPagination ?? {
|
|
140
|
-
current: urlState.page ?? 1,
|
|
141
|
-
pageSize: urlState.pageSize ?? initPageSize,
|
|
316
|
+
current: urlState.page ?? initialViewState?.pagination.current ?? 1,
|
|
317
|
+
pageSize: urlState.pageSize ?? initialViewState?.pagination.pageSize ?? initPageSize,
|
|
142
318
|
}));
|
|
143
319
|
let sorters = $state<Sort[]>(untrack(() =>
|
|
144
320
|
externalSorters ??
|
|
145
321
|
(urlState.sortField
|
|
146
322
|
? [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }]
|
|
147
|
-
: initDefaultSort ? [initDefaultSort] : []))
|
|
323
|
+
: initialViewState?.sorters ?? (initDefaultSort ? [initDefaultSort] : [])))
|
|
148
324
|
);
|
|
149
|
-
|
|
150
|
-
|
|
325
|
+
const initialURLFilters = untrack(() => urlState.filters ?? initialViewState?.filters ?? []);
|
|
326
|
+
const editableFilterKeys = new Set(untrack(() => resource.fields.filter((field) => field.filterable).map((field) => field.key)));
|
|
327
|
+
const editableFilterCounts: Record<string, number> = {};
|
|
328
|
+
for (const filter of initialURLFilters) {
|
|
329
|
+
if ('field' in filter && filter.operator === 'contains' && typeof filter.value === 'string' && editableFilterKeys.has(filter.field)) {
|
|
330
|
+
editableFilterCounts[filter.field] = (editableFilterCounts[filter.field] ?? 0) + 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const initialFilterValues: Record<string, string> = {};
|
|
334
|
+
for (const filter of initialURLFilters) {
|
|
335
|
+
if (
|
|
336
|
+
'field' in filter
|
|
337
|
+
&& filter.operator === 'contains'
|
|
338
|
+
&& typeof filter.value === 'string'
|
|
339
|
+
&& editableFilterCounts[filter.field] === 1
|
|
340
|
+
) {
|
|
341
|
+
initialFilterValues[filter.field] = filter.value;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
let filters = $state<Filter[]>(initialURLFilters);
|
|
345
|
+
let searchText = $state(urlState.search ?? initialViewState?.search ?? '');
|
|
151
346
|
let appliedSearchText = $state(untrack(() => searchText));
|
|
152
347
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
153
348
|
|
|
@@ -156,6 +351,7 @@
|
|
|
156
351
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
|
157
352
|
searchDebounceTimer = setTimeout(() => {
|
|
158
353
|
appliedSearchText = searchText;
|
|
354
|
+
markSavedViewDirty();
|
|
159
355
|
pagination = { ...pagination, current: 1 };
|
|
160
356
|
searchDebounceTimer = undefined;
|
|
161
357
|
}, 300);
|
|
@@ -178,24 +374,23 @@
|
|
|
178
374
|
}
|
|
179
375
|
});
|
|
180
376
|
|
|
181
|
-
// URL sync
|
|
182
|
-
$effect(() => {
|
|
183
|
-
writeURLState({
|
|
184
|
-
page: pagination.current,
|
|
185
|
-
pageSize: pagination.pageSize,
|
|
186
|
-
sortField: sorters[0]?.field,
|
|
187
|
-
sortOrder: sorters[0]?.order,
|
|
188
|
-
search: appliedSearchText || undefined,
|
|
189
|
-
}, adminContext);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
377
|
// ─── Build active filters with search ─────────────────────────
|
|
193
378
|
const searchableFields = $derived(resource.fields.filter(f => f.searchable));
|
|
194
379
|
const filterableFields = $derived(resource.fields.filter(f => f.filterable));
|
|
195
|
-
let filterValues = $state<Record<string, string>>(
|
|
196
|
-
const
|
|
380
|
+
let filterValues = $state<Record<string, string>>(initialFilterValues);
|
|
381
|
+
const locationFilters = $derived<Filter[]>(filters);
|
|
382
|
+
const activeFilterCount = $derived(locationFilters.length);
|
|
383
|
+
const activeFilterItems = $derived.by(() => {
|
|
384
|
+
const labels = new Map(resource.fields.map((field) => [field.key, field.label]));
|
|
385
|
+
return locationFilters.map((filter, index) => {
|
|
386
|
+
if ('field' in filter) {
|
|
387
|
+
return { index, label: `${labels.get(filter.field) ?? filter.field}: ${String(filter.value)}` };
|
|
388
|
+
}
|
|
389
|
+
return { index, label: filter.operator };
|
|
390
|
+
});
|
|
391
|
+
});
|
|
197
392
|
const activeFilters = $derived.by(() => {
|
|
198
|
-
const result: Filter[] = [...
|
|
393
|
+
const result: Filter[] = [...locationFilters];
|
|
199
394
|
if (appliedSearchText.trim() && searchableFields.length > 0) {
|
|
200
395
|
if (searchableFields.length === 1) {
|
|
201
396
|
result.push({ field: searchableFields[0].key, operator: 'contains', value: appliedSearchText });
|
|
@@ -211,17 +406,61 @@
|
|
|
211
406
|
result.push(searchFilter);
|
|
212
407
|
}
|
|
213
408
|
}
|
|
214
|
-
for (const [key, value] of Object.entries(filterValues)) {
|
|
215
|
-
if (value.trim()) {
|
|
216
|
-
result.push({ field: key, operator: 'contains', value: value.trim() });
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
409
|
return result;
|
|
220
410
|
});
|
|
221
411
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
412
|
+
// URL sync
|
|
413
|
+
$effect(() => {
|
|
414
|
+
writeURLState({
|
|
415
|
+
page: pagination.current,
|
|
416
|
+
pageSize: pagination.pageSize,
|
|
417
|
+
sortField: sorters[0]?.field,
|
|
418
|
+
sortOrder: sorters[0]?.order,
|
|
419
|
+
search: appliedSearchText || undefined,
|
|
420
|
+
filters: locationFilters,
|
|
421
|
+
}, adminContext);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
function clearFilters(): void {
|
|
425
|
+
markSavedViewDirty();
|
|
426
|
+
filters = [];
|
|
427
|
+
filterValues = {};
|
|
428
|
+
pagination = { ...pagination, current: 1 };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function setFilterValue(field: string, value: string): void {
|
|
432
|
+
markSavedViewDirty();
|
|
433
|
+
const trimmedValue = value.trim();
|
|
434
|
+
const nextFilter = { field, operator: 'contains' as const, value: trimmedValue };
|
|
435
|
+
let replaced = false;
|
|
436
|
+
filters = filters.flatMap((filter) => {
|
|
437
|
+
if (!('field' in filter) || filter.field !== field || filter.operator !== 'contains') return [filter];
|
|
438
|
+
if (!trimmedValue || replaced) return [];
|
|
439
|
+
replaced = true;
|
|
440
|
+
return [nextFilter];
|
|
441
|
+
});
|
|
442
|
+
if (trimmedValue && !replaced) filters = [...filters, nextFilter];
|
|
443
|
+
filterValues[field] = value;
|
|
444
|
+
pagination = { ...pagination, current: 1 };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function removeActiveFilter(index: number): void {
|
|
448
|
+
markSavedViewDirty();
|
|
449
|
+
const filter = locationFilters[index];
|
|
450
|
+
if (!filter) return;
|
|
451
|
+
const nextFilters = filters.filter((_, filterIndex) => filterIndex !== index);
|
|
452
|
+
filters = nextFilters;
|
|
453
|
+
if ('field' in filter && filter.operator === 'contains') {
|
|
454
|
+
const remaining = nextFilters.filter((candidate) => (
|
|
455
|
+
'field' in candidate
|
|
456
|
+
&& candidate.field === filter.field
|
|
457
|
+
&& candidate.operator === 'contains'
|
|
458
|
+
&& typeof candidate.value === 'string'
|
|
459
|
+
));
|
|
460
|
+
filterValues[filter.field] = remaining.length === 1 && 'field' in remaining[0]
|
|
461
|
+
? String(remaining[0].value)
|
|
462
|
+
: '';
|
|
463
|
+
}
|
|
225
464
|
pagination = { ...pagination, current: 1 };
|
|
226
465
|
}
|
|
227
466
|
|
|
@@ -260,12 +499,6 @@
|
|
|
260
499
|
})));
|
|
261
500
|
const queryFilters = $derived<Filter[]>(activeFilters.map(cloneFilter));
|
|
262
501
|
|
|
263
|
-
$effect(() => {
|
|
264
|
-
if (Object.values(filterValues).some(v => v.trim())) {
|
|
265
|
-
pagination = { ...pagination, current: 1 };
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
|
|
269
502
|
// ─── Data fetching ────────────────────────────────────────────
|
|
270
503
|
const listResult = useList({
|
|
271
504
|
get resource() { return resourceName; },
|
|
@@ -276,47 +509,48 @@
|
|
|
276
509
|
const query = listResult;
|
|
277
510
|
const deleteResult = useDelete({ get resource() { return resourceName; } });
|
|
278
511
|
const deleteMutation = deleteResult.mutation;
|
|
279
|
-
|
|
512
|
+
let batchOperationCount = $state(0);
|
|
513
|
+
const deleteManyResult = useDeleteMany({
|
|
514
|
+
get resource() { return resourceName; },
|
|
515
|
+
successNotification: () => ({
|
|
516
|
+
message: i18n.t('common.batchDeleteSuccess', { count: batchOperationCount }),
|
|
517
|
+
}),
|
|
518
|
+
errorNotification: (error) => ({
|
|
519
|
+
type: 'error',
|
|
520
|
+
message: error instanceof DeleteManyPartialError
|
|
521
|
+
? i18n.t('common.batchDeletePartialFail', { failed: error.failedIds.length, total: batchOperationCount })
|
|
522
|
+
: i18n.t('common.batchDeleteFailed', { count: batchOperationCount }),
|
|
523
|
+
}),
|
|
524
|
+
});
|
|
280
525
|
const deleteManyMutation = deleteManyResult.mutation;
|
|
281
526
|
|
|
282
527
|
// ─── Permissions ──────────────────────────────────────────────
|
|
283
528
|
const acEnabled = $derived(!!adminContext.accessControlProvider);
|
|
284
529
|
const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
|
|
285
|
-
const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
|
|
286
|
-
const canShowPerm = useCan(() => ({ resource: resourceName, action: 'show', queryOptions: { enabled: acEnabled } }));
|
|
287
|
-
const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
|
|
288
530
|
const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
|
|
289
|
-
const canCreate = $derived(
|
|
290
|
-
const canEdit = $derived(
|
|
291
|
-
const canShow = $derived(
|
|
292
|
-
const canDelete = $derived(
|
|
531
|
+
const canCreate = $derived(resource.canCreate !== false && (!acEnabled || canCreatePerm.allowed));
|
|
532
|
+
const canEdit = $derived(resource.canEdit !== false);
|
|
533
|
+
const canShow = $derived(resource.canShow !== false);
|
|
534
|
+
const canDelete = $derived(resource.canDelete !== false);
|
|
293
535
|
const canExport = $derived(canExportPerm.allowed);
|
|
294
536
|
|
|
295
537
|
// ─── TanStack Table state ────────────────────────────────────
|
|
296
538
|
const initialSorting = untrack<SortingState>(() =>
|
|
297
539
|
sorters.map(s => ({ id: s.field, desc: s.order === 'desc' }))
|
|
298
540
|
);
|
|
299
|
-
const initialColumnVisibility = (() =>
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (typeof window !== 'undefined') {
|
|
303
|
-
try {
|
|
304
|
-
const stored = localStorage.getItem(storageKey);
|
|
305
|
-
if (stored) return JSON.parse(stored);
|
|
306
|
-
} catch { /* ignore */ }
|
|
307
|
-
}
|
|
308
|
-
// Fall back to resource field definitions
|
|
309
|
-
const vis: ColumnVisibilityState = {};
|
|
310
|
-
for (const f of resource.fields) {
|
|
311
|
-
if (f.showInList === false) vis[f.key] = false;
|
|
312
|
-
}
|
|
313
|
-
return vis;
|
|
314
|
-
})();
|
|
541
|
+
const initialColumnVisibility = untrack(() => (
|
|
542
|
+
initialViewState?.columnVisibility ?? readColumnVisibilityPreference(listPreferenceScope)
|
|
543
|
+
));
|
|
315
544
|
const sortingAtom = createAtom(initialSorting);
|
|
316
545
|
const columnVisibilityAtom = createAtom(initialColumnVisibility);
|
|
317
546
|
const rowSelectionAtom = createAtom({} as RowSelectionState);
|
|
318
547
|
const expandedAtom = createAtom({} as ExpandedState);
|
|
319
|
-
const
|
|
548
|
+
const initialColumnOrder = untrack(() => (
|
|
549
|
+
initialViewState?.columnOrder?.length
|
|
550
|
+
? initialViewState.columnOrder
|
|
551
|
+
: readColumnOrderPreference(listPreferenceScope)
|
|
552
|
+
));
|
|
553
|
+
const columnOrderAtom = createAtom(initialColumnOrder);
|
|
320
554
|
const features = tableFeatures({
|
|
321
555
|
columnOrderingFeature,
|
|
322
556
|
columnSizingFeature,
|
|
@@ -331,12 +565,36 @@
|
|
|
331
565
|
const tableRowSelection = useSelector(rowSelectionAtom);
|
|
332
566
|
const tableExpanded = useSelector(expandedAtom);
|
|
333
567
|
const tableColumnOrder = useSelector(columnOrderAtom);
|
|
568
|
+
const selectedIdValueByKey = new SvelteMap<string, string | number>();
|
|
569
|
+
|
|
570
|
+
function preferenceScopeIsLoaded(): boolean {
|
|
571
|
+
return loadedPreferenceScopeId === listPreferenceScopeId(listPreferenceScope);
|
|
572
|
+
}
|
|
334
573
|
|
|
335
574
|
function rowIsExpanded(rowId: string): boolean {
|
|
336
575
|
const expanded = tableExpanded.current;
|
|
337
576
|
return expanded === true || expanded[rowId] === true;
|
|
338
577
|
}
|
|
339
578
|
|
|
579
|
+
function rowIsSelected(rowId: string): boolean {
|
|
580
|
+
return tableRowSelection.current[rowId] === true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function rowIdValue(row: Row<TableFeatures, BaseRecord>): string | number {
|
|
584
|
+
const value = row.original[primaryKey];
|
|
585
|
+
return typeof value === 'string' || typeof value === 'number' ? value : row.id;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function toggleRowSelection(row: Row<TableFeatures, BaseRecord>): void {
|
|
589
|
+
const wasSelected = rowIsSelected(row.id);
|
|
590
|
+
if (!wasSelected) {
|
|
591
|
+
selectedIdValueByKey.set(row.id, rowIdValue(row));
|
|
592
|
+
} else if (wasSelected) {
|
|
593
|
+
selectedIdValueByKey.delete(row.id);
|
|
594
|
+
}
|
|
595
|
+
row_toggleSelected(row);
|
|
596
|
+
}
|
|
597
|
+
|
|
340
598
|
$effect(() => {
|
|
341
599
|
const nextSorters: Sort[] = tableSorting.current.map((sorter) => ({
|
|
342
600
|
field: sorter.id,
|
|
@@ -350,7 +608,8 @@
|
|
|
350
608
|
|
|
351
609
|
// Persist column visibility to localStorage
|
|
352
610
|
$effect(() => {
|
|
353
|
-
|
|
611
|
+
if (!preferenceScopeIsLoaded()) return;
|
|
612
|
+
const storageKey = columnVisibilityStorageKey(listPreferenceScope);
|
|
354
613
|
if (typeof window !== 'undefined') {
|
|
355
614
|
try {
|
|
356
615
|
localStorage.setItem(storageKey, JSON.stringify(tableColumnVisibility.current));
|
|
@@ -358,6 +617,176 @@
|
|
|
358
617
|
}
|
|
359
618
|
});
|
|
360
619
|
|
|
620
|
+
$effect(() => {
|
|
621
|
+
if (!preferenceScopeIsLoaded()) return;
|
|
622
|
+
const ids = tableColumnOrder.current;
|
|
623
|
+
persistColumnOrder(ids);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
function persistSavedViews(): void {
|
|
627
|
+
if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
|
|
628
|
+
try {
|
|
629
|
+
localStorage.setItem(savedListViewsStorageKey(listPreferenceScope), serializeSavedListViews(savedViews));
|
|
630
|
+
} catch { /* ignore quota errors */ }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function persistActiveSavedView(): void {
|
|
634
|
+
if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
|
|
635
|
+
try {
|
|
636
|
+
const key = activeSavedListViewStorageKey(listPreferenceScope);
|
|
637
|
+
if (activeSavedViewId) localStorage.setItem(key, activeSavedViewId);
|
|
638
|
+
else localStorage.removeItem(key);
|
|
639
|
+
} catch { /* ignore quota errors */ }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function markSavedViewDirty(): void {
|
|
643
|
+
if (!activeSavedViewId) return;
|
|
644
|
+
activeSavedViewId = undefined;
|
|
645
|
+
persistActiveSavedView();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getCurrentSavedViewState(): SavedListViewState {
|
|
649
|
+
return {
|
|
650
|
+
search: appliedSearchText,
|
|
651
|
+
filters: locationFilters.map(cloneFilter),
|
|
652
|
+
sorters: sorters.map((sorter) => ({ ...sorter })),
|
|
653
|
+
pagination: {
|
|
654
|
+
current: pagination.current ?? 1,
|
|
655
|
+
pageSize: pagination.pageSize ?? initPageSize,
|
|
656
|
+
},
|
|
657
|
+
columnVisibility: { ...tableColumnVisibility.current },
|
|
658
|
+
columnOrder: [...tableColumnOrder.current],
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function applySavedView(view: SavedListView): void {
|
|
663
|
+
if (searchDebounceTimer) {
|
|
664
|
+
clearTimeout(searchDebounceTimer);
|
|
665
|
+
searchDebounceTimer = undefined;
|
|
666
|
+
}
|
|
667
|
+
const state = cloneSavedListViewState(view.state);
|
|
668
|
+
activeSavedViewId = view.id;
|
|
669
|
+
persistActiveSavedView();
|
|
670
|
+
searchText = state.search;
|
|
671
|
+
appliedSearchText = state.search;
|
|
672
|
+
filters = state.filters;
|
|
673
|
+
const restoredFilterCounts: Record<string, number> = {};
|
|
674
|
+
for (const filter of state.filters) {
|
|
675
|
+
if ('field' in filter && filter.operator === 'contains' && typeof filter.value === 'string' && editableFilterKeys.has(filter.field)) {
|
|
676
|
+
restoredFilterCounts[filter.field] = (restoredFilterCounts[filter.field] ?? 0) + 1;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
filterValues = {};
|
|
680
|
+
for (const filter of state.filters) {
|
|
681
|
+
if (
|
|
682
|
+
'field' in filter
|
|
683
|
+
&& filter.operator === 'contains'
|
|
684
|
+
&& typeof filter.value === 'string'
|
|
685
|
+
&& restoredFilterCounts[filter.field] === 1
|
|
686
|
+
) {
|
|
687
|
+
filterValues[filter.field] = filter.value;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
pagination = { ...pagination, ...state.pagination };
|
|
691
|
+
sorters = state.sorters;
|
|
692
|
+
sortingAtom.set(state.sorters.map((sorter) => ({ id: sorter.field, desc: sorter.order === 'desc' })));
|
|
693
|
+
columnVisibilityAtom.set(state.columnVisibility);
|
|
694
|
+
columnOrderAtom.set(state.columnOrder);
|
|
695
|
+
persistColumnOrder(state.columnOrder);
|
|
696
|
+
rowSelectionAtom.set({});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function resetToDefaultListState(): void {
|
|
700
|
+
if (searchDebounceTimer) {
|
|
701
|
+
clearTimeout(searchDebounceTimer);
|
|
702
|
+
searchDebounceTimer = undefined;
|
|
703
|
+
}
|
|
704
|
+
searchText = '';
|
|
705
|
+
appliedSearchText = '';
|
|
706
|
+
filters = [];
|
|
707
|
+
filterValues = {};
|
|
708
|
+
if (!externalPagination) {
|
|
709
|
+
pagination = { current: 1, pageSize: initPageSize };
|
|
710
|
+
}
|
|
711
|
+
if (!externalSorters) {
|
|
712
|
+
const defaultSorters = initDefaultSort ? [{ ...initDefaultSort }] : [];
|
|
713
|
+
sorters = defaultSorters;
|
|
714
|
+
sortingAtom.set(defaultSorters.map((sorter) => ({ id: sorter.field, desc: sorter.order === 'desc' })));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
$effect(() => {
|
|
719
|
+
const scope = listPreferenceScope;
|
|
720
|
+
const scopeId = listPreferenceScopeId(scope);
|
|
721
|
+
if (scopeId === loadedPreferenceScopeId) return;
|
|
722
|
+
|
|
723
|
+
const preferences = readScopedSavedViewPreferences(scope);
|
|
724
|
+
savedViews = preferences.savedViews;
|
|
725
|
+
activeSavedViewId = preferences.activeSavedViewId;
|
|
726
|
+
|
|
727
|
+
if (!hasExplicitURLState && preferences.activeSavedView) {
|
|
728
|
+
applySavedView(preferences.activeSavedView);
|
|
729
|
+
} else {
|
|
730
|
+
if (!hasExplicitURLState) resetToDefaultListState();
|
|
731
|
+
columnVisibilityAtom.set(preferences.columnVisibility);
|
|
732
|
+
columnOrderAtom.set(preferences.columnOrder);
|
|
733
|
+
rowSelectionAtom.set({});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
loadedPreferenceScopeId = scopeId;
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
function saveCurrentView(): void {
|
|
740
|
+
const name = savedViewName.trim().slice(0, 60);
|
|
741
|
+
if (!name) return;
|
|
742
|
+
if (searchDebounceTimer) {
|
|
743
|
+
clearTimeout(searchDebounceTimer);
|
|
744
|
+
searchDebounceTimer = undefined;
|
|
745
|
+
appliedSearchText = searchText;
|
|
746
|
+
}
|
|
747
|
+
const existing = savedViews.find((view) => view.name.toLocaleLowerCase() === name.toLocaleLowerCase());
|
|
748
|
+
const view: SavedListView = {
|
|
749
|
+
id: existing?.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
750
|
+
name,
|
|
751
|
+
state: getCurrentSavedViewState(),
|
|
752
|
+
};
|
|
753
|
+
savedViews = existing
|
|
754
|
+
? savedViews.map((candidate) => candidate.id === existing.id ? view : candidate)
|
|
755
|
+
: [view, ...savedViews].slice(0, 25);
|
|
756
|
+
activeSavedViewId = view.id;
|
|
757
|
+
savedViewName = '';
|
|
758
|
+
persistSavedViews();
|
|
759
|
+
persistActiveSavedView();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function deleteSavedView(id: string): void {
|
|
763
|
+
savedViews = savedViews.filter((view) => view.id !== id);
|
|
764
|
+
if (activeSavedViewId === id) activeSavedViewId = undefined;
|
|
765
|
+
persistSavedViews();
|
|
766
|
+
persistActiveSavedView();
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function setColumnVisibility(columnId: string, visible: boolean): void {
|
|
770
|
+
markSavedViewDirty();
|
|
771
|
+
columnVisibilityAtom.set({ ...tableColumnVisibility.current, [columnId]: visible });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function persistColumnOrder(ids: string[]): void {
|
|
775
|
+
if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
|
|
776
|
+
try { localStorage.setItem(columnOrderStorageKey(listPreferenceScope), JSON.stringify(ids)); } catch { /* ignore */ }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function setColumnOrder(newOrder: Array<{ id: string }>): void {
|
|
780
|
+
markSavedViewDirty();
|
|
781
|
+
const ids = newOrder.map((column) => column.id);
|
|
782
|
+
columnOrderAtom.set(ids);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function toggleColumnSort(column: Column<TableFeatures, BaseRecord, unknown>): void {
|
|
786
|
+
markSavedViewDirty();
|
|
787
|
+
column_toggleSorting(column);
|
|
788
|
+
}
|
|
789
|
+
|
|
361
790
|
// Sync external sorters ? local sorting state (controlled mode only)
|
|
362
791
|
$effect(() => {
|
|
363
792
|
if (!externalSorters) return;
|
|
@@ -431,9 +860,14 @@
|
|
|
431
860
|
get columns() { return orderedColumns; },
|
|
432
861
|
manualSorting: true,
|
|
433
862
|
getRowId: (row: BaseRecord) => String(row[primaryKey]),
|
|
863
|
+
state: {
|
|
864
|
+
get sorting() { return tableSorting.current; },
|
|
865
|
+
get columnVisibility() { return tableColumnVisibility.current; },
|
|
866
|
+
},
|
|
867
|
+
onSortingChange: (updater) => {
|
|
868
|
+
sortingAtom.set(typeof updater === 'function' ? updater(tableSorting.current) : updater);
|
|
869
|
+
},
|
|
434
870
|
atoms: {
|
|
435
|
-
sorting: sortingAtom,
|
|
436
|
-
columnVisibility: columnVisibilityAtom,
|
|
437
871
|
rowSelection: rowSelectionAtom,
|
|
438
872
|
expanded: expandedAtom,
|
|
439
873
|
},
|
|
@@ -444,14 +878,38 @@
|
|
|
444
878
|
},
|
|
445
879
|
);
|
|
446
880
|
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
881
|
+
function toggleAllRowsSelection(): void {
|
|
882
|
+
const willSelect = !table_getIsAllRowsSelected(tbl);
|
|
883
|
+
for (const row of tableView.rows) {
|
|
884
|
+
if (willSelect) {
|
|
885
|
+
selectedIdValueByKey.set(row.id, rowIdValue(row));
|
|
886
|
+
} else if (!willSelect) {
|
|
887
|
+
selectedIdValueByKey.delete(row.id);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
table_toggleAllRowsSelected(tbl);
|
|
891
|
+
}
|
|
453
892
|
|
|
454
|
-
const
|
|
893
|
+
const selectedIds = $derived(
|
|
894
|
+
Object.keys(tableRowSelection.current).map((key) => selectedIdValueByKey.get(key) ?? key)
|
|
895
|
+
);
|
|
896
|
+
const selectedCount = $derived(selectedIds.length);
|
|
897
|
+
const batchDeletePerm = useCan(() => ({
|
|
898
|
+
resource: resourceName,
|
|
899
|
+
action: 'delete',
|
|
900
|
+
params: { ids: selectedIds },
|
|
901
|
+
queryOptions: { enabled: acEnabled && canDelete && selectedIds.length > 0 },
|
|
902
|
+
}));
|
|
903
|
+
const canBatchDelete = $derived(
|
|
904
|
+
canDelete && (!acEnabled || (!batchDeletePerm.isLoading && batchDeletePerm.allowed))
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
function clearSelection(): void {
|
|
908
|
+
if (!deleteManyMutation.isPending) {
|
|
909
|
+
selectedIdValueByKey.clear();
|
|
910
|
+
rowSelectionAtom.set({});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
455
913
|
|
|
456
914
|
function isColumnVisible(columnId: string): boolean {
|
|
457
915
|
return tableColumnVisibility.current[columnId] !== false;
|
|
@@ -460,11 +918,13 @@
|
|
|
460
918
|
const tableView = $derived.by(() => {
|
|
461
919
|
void tableSorting.current;
|
|
462
920
|
void tableColumnVisibility.current;
|
|
921
|
+
void orderedColumns;
|
|
463
922
|
void tableRowSelection.current;
|
|
464
923
|
void tableExpanded.current;
|
|
465
924
|
void tableColumnOrder.current;
|
|
925
|
+
const headerGroups = table_getHeaderGroups(tbl);
|
|
466
926
|
return {
|
|
467
|
-
headerGroups
|
|
927
|
+
headerGroups,
|
|
468
928
|
rows: table_getRowModel(tbl).rows,
|
|
469
929
|
};
|
|
470
930
|
});
|
|
@@ -512,38 +972,79 @@
|
|
|
512
972
|
let confirmOpen = $state(false);
|
|
513
973
|
let confirmMessage = $state('');
|
|
514
974
|
let confirmAction = $state<() => void>(() => {});
|
|
975
|
+
let confirmPending = $state(false);
|
|
976
|
+
let detailOpenedInHistory = $state(false);
|
|
977
|
+
const detailRecordId = $derived(parsed.params.detail);
|
|
978
|
+
const detailOpen = $derived(detailRecordId != null);
|
|
515
979
|
|
|
516
980
|
function confirmDelete(id: string | number) {
|
|
517
981
|
confirmMessage = i18n.t('common.deleteConfirm');
|
|
518
982
|
confirmAction = async () => {
|
|
519
|
-
|
|
520
|
-
confirmOpen = false;
|
|
983
|
+
confirmPending = true;
|
|
984
|
+
if (mutationMode !== 'pessimistic') confirmOpen = false;
|
|
985
|
+
try {
|
|
986
|
+
await deleteMutation.mutateAsync({
|
|
987
|
+
id,
|
|
988
|
+
resource: resourceName,
|
|
989
|
+
onCancel: () => { confirmOpen = false; },
|
|
990
|
+
});
|
|
991
|
+
confirmOpen = false;
|
|
992
|
+
} catch {
|
|
993
|
+
// useDelete owns error feedback and restores optimistic cache state.
|
|
994
|
+
} finally {
|
|
995
|
+
confirmPending = false;
|
|
996
|
+
}
|
|
521
997
|
};
|
|
522
998
|
confirmOpen = true;
|
|
523
999
|
}
|
|
524
1000
|
|
|
525
1001
|
function confirmBatchDelete() {
|
|
526
|
-
const ids =
|
|
1002
|
+
const ids = [...selectedIds];
|
|
1003
|
+
if (ids.length === 0 || deleteManyMutation.isPending) return;
|
|
1004
|
+
batchOperationCount = ids.length;
|
|
527
1005
|
confirmMessage = i18n.t('common.batchDeleteConfirm', { count: ids.length });
|
|
528
1006
|
confirmAction = async () => {
|
|
1007
|
+
confirmPending = true;
|
|
1008
|
+
if (mutationMode !== 'pessimistic') confirmOpen = false;
|
|
529
1009
|
try {
|
|
530
|
-
await deleteManyMutation.mutateAsync({
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
);
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
)
|
|
1010
|
+
await deleteManyMutation.mutateAsync({
|
|
1011
|
+
ids,
|
|
1012
|
+
resource: resourceName,
|
|
1013
|
+
onCancel: () => { confirmOpen = false; },
|
|
1014
|
+
});
|
|
1015
|
+
selectedIdValueByKey.clear();
|
|
1016
|
+
rowSelectionAtom.set({});
|
|
1017
|
+
confirmOpen = false;
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
if (error instanceof DeleteManyPartialError) {
|
|
1020
|
+
const failedSelection: RowSelectionState = {};
|
|
1021
|
+
for (const id of error.failedIds) failedSelection[String(id)] = true;
|
|
1022
|
+
for (const id of error.succeededIds) selectedIdValueByKey.delete(String(id));
|
|
1023
|
+
rowSelectionAtom.set(failedSelection);
|
|
1024
|
+
}
|
|
1025
|
+
// useDeleteMany owns the error notification; keep failed selection for retry.
|
|
1026
|
+
} finally {
|
|
1027
|
+
confirmPending = false;
|
|
540
1028
|
}
|
|
541
|
-
rowSelectionAtom.set({});
|
|
542
|
-
confirmOpen = false;
|
|
543
1029
|
};
|
|
544
1030
|
confirmOpen = true;
|
|
545
1031
|
}
|
|
546
1032
|
|
|
1033
|
+
function openDetail(id: string | number): void {
|
|
1034
|
+
detailOpenedInHistory = true;
|
|
1035
|
+
writeURLState({ detailId: String(id) }, adminContext, 'push');
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function closeDetail(): void {
|
|
1039
|
+
if (detailOpenedInHistory && adminContext.routerProvider) {
|
|
1040
|
+
detailOpenedInHistory = false;
|
|
1041
|
+
adminContext.routerProvider.back();
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
detailOpenedInHistory = false;
|
|
1045
|
+
writeURLState({ detailId: undefined }, adminContext);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
547
1048
|
// ─── CSV Export ───────────────────────────────────────────────
|
|
548
1049
|
function exportCSV() {
|
|
549
1050
|
const data = query.data?.data ?? [];
|
|
@@ -567,6 +1068,7 @@
|
|
|
567
1068
|
}
|
|
568
1069
|
|
|
569
1070
|
function goToPage(page: number) {
|
|
1071
|
+
markSavedViewDirty();
|
|
570
1072
|
pagination = { ...pagination, current: page };
|
|
571
1073
|
}
|
|
572
1074
|
</script>
|
|
@@ -579,15 +1081,15 @@
|
|
|
579
1081
|
<div class="flex flex-wrap items-center gap-2">
|
|
580
1082
|
{#if canExport}
|
|
581
1083
|
<Button variant="outline" size="sm" onclick={exportCSV}>
|
|
582
|
-
<Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t(
|
|
1084
|
+
<Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.export")}
|
|
583
1085
|
</Button>
|
|
584
1086
|
{/if}
|
|
585
1087
|
{#if headerActions}
|
|
586
1088
|
{@render headerActions()}
|
|
587
1089
|
{/if}
|
|
588
1090
|
{#if canCreate}
|
|
589
|
-
<Button onclick={() =>
|
|
590
|
-
<Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t(
|
|
1091
|
+
<Button onclick={() => navigation.create(resourceName)}>
|
|
1092
|
+
<Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.create")}
|
|
591
1093
|
</Button>
|
|
592
1094
|
{/if}
|
|
593
1095
|
</div>
|
|
@@ -596,31 +1098,40 @@
|
|
|
596
1098
|
|
|
597
1099
|
<!-- Selection Banner (Enterprise Batch Actions) -->
|
|
598
1100
|
{#if selectedCount > 0}
|
|
599
|
-
<div
|
|
600
|
-
|
|
601
|
-
|
|
1101
|
+
<div
|
|
1102
|
+
class="flex flex-col gap-3 border border-primary/20 bg-primary/10 px-3.5 py-2 rounded-lg text-sm text-foreground animate-in fade-in duration-200 sm:flex-row sm:items-center sm:justify-between"
|
|
1103
|
+
aria-label={i18n.t("common.selectedCount", { count: selectedCount })}
|
|
1104
|
+
data-svadmin-batch-toolbar
|
|
1105
|
+
>
|
|
1106
|
+
<div class="flex min-w-0 items-center gap-2 text-sm">
|
|
1107
|
+
<span class="font-medium text-foreground">{i18n.t("common.selectedCount", { count: selectedCount })}</span>
|
|
1108
|
+
{#if deleteManyMutation.isPending}
|
|
1109
|
+
<span class="text-muted-foreground" role="status">{i18n.t("common.processing")}</span>
|
|
1110
|
+
{/if}
|
|
602
1111
|
</div>
|
|
603
|
-
<div class="flex items-center gap-2">
|
|
604
|
-
{#if
|
|
1112
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
1113
|
+
{#if batchActions}
|
|
1114
|
+
{@render batchActions({ selectedIds })}
|
|
1115
|
+
{/if}
|
|
1116
|
+
{#if canBatchDelete}
|
|
605
1117
|
<Button
|
|
606
1118
|
variant="destructive"
|
|
607
1119
|
size="sm"
|
|
608
1120
|
class="h-8 text-xs whitespace-nowrap"
|
|
1121
|
+
disabled={deleteManyMutation.isPending}
|
|
609
1122
|
onclick={confirmBatchDelete}
|
|
610
1123
|
>
|
|
611
|
-
<Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t(
|
|
1124
|
+
<Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t("common.batchDelete", { count: selectedCount })}
|
|
612
1125
|
</Button>
|
|
613
1126
|
{/if}
|
|
614
|
-
{#if batchActions}
|
|
615
|
-
{@render batchActions({ selectedIds: Object.keys(tableRowSelection.current) })}
|
|
616
|
-
{/if}
|
|
617
1127
|
<Button
|
|
618
1128
|
variant="ghost"
|
|
619
1129
|
size="sm"
|
|
620
1130
|
class="h-8 text-xs text-muted-foreground hover:text-foreground"
|
|
621
|
-
|
|
1131
|
+
disabled={deleteManyMutation.isPending}
|
|
1132
|
+
onclick={clearSelection}
|
|
622
1133
|
>
|
|
623
|
-
{i18n.t(
|
|
1134
|
+
{i18n.t("common.clearSelection")}
|
|
624
1135
|
</Button>
|
|
625
1136
|
</div>
|
|
626
1137
|
</div>
|
|
@@ -637,7 +1148,7 @@
|
|
|
637
1148
|
type="text"
|
|
638
1149
|
value={searchText}
|
|
639
1150
|
oninput={scheduleSearch}
|
|
640
|
-
placeholder={i18n.t(
|
|
1151
|
+
placeholder={i18n.t("common.search")}
|
|
641
1152
|
class="pl-9 h-9 text-sm"
|
|
642
1153
|
/>
|
|
643
1154
|
</div>
|
|
@@ -649,7 +1160,7 @@
|
|
|
649
1160
|
{#snippet child({ props })}
|
|
650
1161
|
<Button variant="outline" size="sm" class="h-9 px-3" {...props}>
|
|
651
1162
|
<FilterIcon class="h-4 w-4" data-icon="inline-start" />
|
|
652
|
-
{i18n.t(
|
|
1163
|
+
{i18n.t("common.filter")}
|
|
653
1164
|
{#if activeFilterCount > 0}
|
|
654
1165
|
<Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
|
|
655
1166
|
{/if}
|
|
@@ -658,28 +1169,28 @@
|
|
|
658
1169
|
</Popover.Trigger>
|
|
659
1170
|
<Popover.Content class="w-80">
|
|
660
1171
|
<div class="space-y-3">
|
|
661
|
-
<h4 class="font-medium text-sm">{i18n.t(
|
|
1172
|
+
<h4 class="font-medium text-sm">{i18n.t("common.filter")}</h4>
|
|
662
1173
|
{#each filterableFields as field, _i (_i)}
|
|
663
1174
|
<div class="space-y-1">
|
|
664
1175
|
<label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
|
|
665
|
-
{#if field.type ===
|
|
666
|
-
<
|
|
1176
|
+
{#if field.type === "select" && field.options}
|
|
1177
|
+
<Select.Root
|
|
667
1178
|
id="filter-{field.key}"
|
|
668
|
-
class="h-9 text-sm
|
|
669
|
-
value={filterValues[field.key] ??
|
|
670
|
-
onchange={(e) =>
|
|
1179
|
+
class="h-9 text-sm"
|
|
1180
|
+
value={filterValues[field.key] ?? ""}
|
|
1181
|
+
onchange={(e) => setFilterValue(field.key, (e.currentTarget as HTMLSelectElement).value)}
|
|
671
1182
|
>
|
|
672
|
-
<option value="">{i18n.t(
|
|
1183
|
+
<option value="">{i18n.t("common.all")}</option>
|
|
673
1184
|
{#each field.options as opt, _i (_i)}
|
|
674
1185
|
<option value={opt.value}>{opt.label}</option>
|
|
675
1186
|
{/each}
|
|
676
|
-
</
|
|
1187
|
+
</Select.Root>
|
|
677
1188
|
{:else}
|
|
678
1189
|
<Input
|
|
679
1190
|
id="filter-{field.key}"
|
|
680
1191
|
type="text"
|
|
681
|
-
value={filterValues[field.key] ??
|
|
682
|
-
oninput={(e) =>
|
|
1192
|
+
value={filterValues[field.key] ?? ""}
|
|
1193
|
+
oninput={(e) => setFilterValue(field.key, e.currentTarget.value)}
|
|
683
1194
|
placeholder={field.label}
|
|
684
1195
|
class="h-9 text-sm"
|
|
685
1196
|
/>
|
|
@@ -688,13 +1199,10 @@
|
|
|
688
1199
|
{/each}
|
|
689
1200
|
<div class="flex gap-2 pt-2">
|
|
690
1201
|
<Button size="sm" class="flex-1" onclick={() => { pagination = { ...pagination, current: 1 }; }}>
|
|
691
|
-
{i18n.t(
|
|
1202
|
+
{i18n.t("common.confirm")}
|
|
692
1203
|
</Button>
|
|
693
|
-
<Button variant="outline" size="sm" onclick={
|
|
694
|
-
|
|
695
|
-
pagination = { ...pagination, current: 1 };
|
|
696
|
-
}}>
|
|
697
|
-
{i18n.t('common.reset')}
|
|
1204
|
+
<Button variant="outline" size="sm" onclick={clearFilters}>
|
|
1205
|
+
{i18n.t("common.reset")}
|
|
698
1206
|
</Button>
|
|
699
1207
|
</div>
|
|
700
1208
|
</div>
|
|
@@ -703,10 +1211,10 @@
|
|
|
703
1211
|
{/if}
|
|
704
1212
|
</div>
|
|
705
1213
|
|
|
706
|
-
<!-- Right: Table Controls (Density, Columns, Refresh, Export if !showHeader) -->
|
|
1214
|
+
<!-- Right: Table Controls (Density, Columns, Saved Views, Refresh, Export if !showHeader) -->
|
|
707
1215
|
<div class="flex items-center gap-1.5 shrink-0">
|
|
708
1216
|
{#if !showHeader && canExport}
|
|
709
|
-
<TooltipButton tooltip={i18n.t(
|
|
1217
|
+
<TooltipButton tooltip={i18n.t("common.export")} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
|
|
710
1218
|
<Download class="h-4 w-4" />
|
|
711
1219
|
</TooltipButton>
|
|
712
1220
|
{/if}
|
|
@@ -715,17 +1223,17 @@
|
|
|
715
1223
|
<DropdownMenu.Root>
|
|
716
1224
|
<DropdownMenu.Trigger>
|
|
717
1225
|
{#snippet child({ props })}
|
|
718
|
-
<TooltipButton tooltip={i18n.t(
|
|
1226
|
+
<TooltipButton tooltip={i18n.t("common.density")} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
|
|
719
1227
|
<Rows class="h-4 w-4" />
|
|
720
1228
|
</TooltipButton>
|
|
721
1229
|
{/snippet}
|
|
722
1230
|
</DropdownMenu.Trigger>
|
|
723
1231
|
<DropdownMenu.Content align="end" class="w-36">
|
|
724
|
-
<DropdownMenu.Item onclick={() => { densityOverride =
|
|
725
|
-
{i18n.t(
|
|
1232
|
+
<DropdownMenu.Item onclick={() => { densityOverride = "comfortable"; }} class={currentDensity === "comfortable" ? "font-semibold text-primary" : ""}>
|
|
1233
|
+
{i18n.t("common.densityComfortable")}
|
|
726
1234
|
</DropdownMenu.Item>
|
|
727
|
-
<DropdownMenu.Item onclick={() => { densityOverride =
|
|
728
|
-
{i18n.t(
|
|
1235
|
+
<DropdownMenu.Item onclick={() => { densityOverride = "compact"; }} class={currentDensity === "compact" ? "font-semibold text-primary" : ""}>
|
|
1236
|
+
{i18n.t("common.densityCompact")}
|
|
729
1237
|
</DropdownMenu.Item>
|
|
730
1238
|
</DropdownMenu.Content>
|
|
731
1239
|
</DropdownMenu.Root>
|
|
@@ -735,16 +1243,16 @@
|
|
|
735
1243
|
<DropdownMenu.Root>
|
|
736
1244
|
<DropdownMenu.Trigger>
|
|
737
1245
|
{#snippet child({ props })}
|
|
738
|
-
<TooltipButton tooltip={i18n.t(
|
|
1246
|
+
<TooltipButton tooltip={i18n.t("common.columns")} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
|
|
739
1247
|
<SlidersHorizontal class="h-4 w-4" />
|
|
740
1248
|
</TooltipButton>
|
|
741
1249
|
{/snippet}
|
|
742
1250
|
</DropdownMenu.Trigger>
|
|
743
1251
|
<DropdownMenu.Content align="end" class="w-48">
|
|
744
|
-
{#each table_getAllLeafColumns(tbl).filter((column
|
|
1252
|
+
{#each table_getAllLeafColumns(tbl).filter((column) => !column.id.startsWith("_")) as column, _i (_i)}
|
|
745
1253
|
<DropdownMenu.CheckboxItem
|
|
746
1254
|
checked={tableColumnVisibility.current[column.id] ?? true}
|
|
747
|
-
onCheckedChange={(v) =>
|
|
1255
|
+
onCheckedChange={(v) => setColumnVisibility(column.id, !!v)}
|
|
748
1256
|
>
|
|
749
1257
|
{visibleFields.find(f => f.key === column.id)?.label ?? column.id}
|
|
750
1258
|
</DropdownMenu.CheckboxItem>
|
|
@@ -752,49 +1260,134 @@
|
|
|
752
1260
|
</DropdownMenu.Content>
|
|
753
1261
|
</DropdownMenu.Root>
|
|
754
1262
|
|
|
1263
|
+
<!-- Saved Views -->
|
|
1264
|
+
<Popover.Root bind:open={savedViewsOpen}>
|
|
1265
|
+
<Popover.Trigger>
|
|
1266
|
+
{#snippet child({ props })}
|
|
1267
|
+
<Button variant="outline" size="sm" class="h-9 px-2.5" {...props} aria-label={i18n.t("common.savedViews")}>
|
|
1268
|
+
<Bookmark class="h-4 w-4" data-icon="inline-start" /> {activeSavedViewName ?? i18n.t("common.savedViews")}
|
|
1269
|
+
</Button>
|
|
1270
|
+
{/snippet}
|
|
1271
|
+
</Popover.Trigger>
|
|
1272
|
+
<Popover.Content align="end" class="w-80">
|
|
1273
|
+
<div class="space-y-3">
|
|
1274
|
+
<div>
|
|
1275
|
+
<h4 class="font-medium text-sm">{i18n.t("common.savedViews")}</h4>
|
|
1276
|
+
<p class="mt-1 text-xs text-muted-foreground">{i18n.t("common.savedViewsHint")}</p>
|
|
1277
|
+
</div>
|
|
1278
|
+
<div class="space-y-1">
|
|
1279
|
+
<label class="text-xs text-muted-foreground" for="saved-list-view">{i18n.t("common.currentView")}</label>
|
|
1280
|
+
<Select.Root
|
|
1281
|
+
id="saved-list-view"
|
|
1282
|
+
class="h-9 w-full"
|
|
1283
|
+
value={activeSavedViewId ?? ""}
|
|
1284
|
+
onchange={(event) => {
|
|
1285
|
+
const id = (event.currentTarget as HTMLSelectElement).value;
|
|
1286
|
+
const view = savedViews.find((candidate) => candidate.id === id);
|
|
1287
|
+
if (view) applySavedView(view);
|
|
1288
|
+
else {
|
|
1289
|
+
activeSavedViewId = undefined;
|
|
1290
|
+
persistActiveSavedView();
|
|
1291
|
+
}
|
|
1292
|
+
}}
|
|
1293
|
+
>
|
|
1294
|
+
<option value="">{i18n.t("common.currentView")}</option>
|
|
1295
|
+
{#each savedViews as view (view.id)}
|
|
1296
|
+
<option value={view.id}>{view.name}</option>
|
|
1297
|
+
{/each}
|
|
1298
|
+
</Select.Root>
|
|
1299
|
+
</div>
|
|
1300
|
+
<div class="flex gap-2">
|
|
1301
|
+
<Input
|
|
1302
|
+
aria-label={i18n.t("common.viewName")}
|
|
1303
|
+
placeholder={i18n.t("common.viewName")}
|
|
1304
|
+
maxlength={60}
|
|
1305
|
+
bind:value={savedViewName}
|
|
1306
|
+
class="h-9"
|
|
1307
|
+
/>
|
|
1308
|
+
<Button size="sm" class="shrink-0" disabled={!savedViewName.trim()} onclick={saveCurrentView}>
|
|
1309
|
+
<Check class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.saveView")}
|
|
1310
|
+
</Button>
|
|
1311
|
+
</div>
|
|
1312
|
+
{#if savedViews.length > 0}
|
|
1313
|
+
<div class="space-y-1 border-t border-border pt-2">
|
|
1314
|
+
{#each savedViews as view (view.id)}
|
|
1315
|
+
<div class="flex items-center justify-between gap-2 text-sm">
|
|
1316
|
+
<span class="min-w-0 truncate">{view.name}</span>
|
|
1317
|
+
<button
|
|
1318
|
+
type="button"
|
|
1319
|
+
class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1320
|
+
aria-label="{i18n.t("common.delete")} {view.name}"
|
|
1321
|
+
title="{i18n.t("common.delete")} {view.name}"
|
|
1322
|
+
onclick={() => deleteSavedView(view.id)}
|
|
1323
|
+
>
|
|
1324
|
+
<Trash2 class="size-3.5" />
|
|
1325
|
+
</button>
|
|
1326
|
+
</div>
|
|
1327
|
+
{/each}
|
|
1328
|
+
</div>
|
|
1329
|
+
{/if}
|
|
1330
|
+
</div>
|
|
1331
|
+
</Popover.Content>
|
|
1332
|
+
</Popover.Root>
|
|
1333
|
+
|
|
755
1334
|
{#if showRefresh}
|
|
756
1335
|
<TooltipButton
|
|
757
|
-
tooltip={i18n.t(
|
|
1336
|
+
tooltip={i18n.t("common.refresh")}
|
|
758
1337
|
variant="outline"
|
|
759
1338
|
size="sm"
|
|
760
1339
|
class="h-9 px-2.5"
|
|
761
1340
|
onclick={() => listResult.refetch()}
|
|
762
1341
|
>
|
|
763
|
-
<RefreshCw class="h-4 w-4 {query.isFetching ?
|
|
1342
|
+
<RefreshCw class="h-4 w-4 {query.isFetching ? "animate-spin" : ""}" />
|
|
764
1343
|
</TooltipButton>
|
|
765
1344
|
{/if}
|
|
1345
|
+
|
|
1346
|
+
{#if !showHeader && canCreate}
|
|
1347
|
+
<Button onclick={() => navigation.create(resourceName)}>
|
|
1348
|
+
<Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.create")}
|
|
1349
|
+
</Button>
|
|
1350
|
+
{/if}
|
|
766
1351
|
</div>
|
|
767
1352
|
</div>
|
|
768
1353
|
|
|
769
1354
|
<!-- Active Filter Tags -->
|
|
770
|
-
{#if showFilterTags && (appliedSearchText.trim() ||
|
|
771
|
-
<div
|
|
1355
|
+
{#if showFilterTags && ((appliedSearchText || searchText).trim() || activeFilterItems.length > 0)}
|
|
1356
|
+
<div
|
|
1357
|
+
class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground pt-0.5 border-y border-border py-2"
|
|
1358
|
+
aria-label={i18n.t('common.filterTags')}
|
|
1359
|
+
data-svadmin-active-filters
|
|
1360
|
+
>
|
|
772
1361
|
<span class="font-medium text-foreground">{i18n.t('common.filterTags')}:</span>
|
|
773
|
-
{#if appliedSearchText.trim()}
|
|
1362
|
+
{#if (appliedSearchText || searchText).trim()}
|
|
774
1363
|
<Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
|
|
775
|
-
<span>{i18n.t('common.search')}: "{appliedSearchText}"</span>
|
|
776
|
-
<button
|
|
777
|
-
|
|
1364
|
+
<span>{i18n.t('common.search')}: "{(appliedSearchText || searchText).trim()}"</span>
|
|
1365
|
+
<button
|
|
1366
|
+
type="button"
|
|
1367
|
+
class="hover:text-foreground cursor-pointer inline-flex size-4 items-center justify-center rounded-sm"
|
|
1368
|
+
aria-label="{i18n.t('common.clear')}: {i18n.t('common.search')}"
|
|
1369
|
+
title="{i18n.t('common.clear')}: {i18n.t('common.search')}"
|
|
1370
|
+
onclick={() => { searchText = ""; appliedSearchText = ""; pagination = { ...pagination, current: 1 }; }}
|
|
1371
|
+
>
|
|
1372
|
+
<X class="size-3" />
|
|
778
1373
|
</button>
|
|
779
1374
|
</Badge>
|
|
780
1375
|
{/if}
|
|
781
|
-
{#each
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
<
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1376
|
+
{#each activeFilterItems as item (`${item.label}-${item.index}`)}
|
|
1377
|
+
<Badge variant="outline" class="max-w-full gap-1 pr-1 font-normal">
|
|
1378
|
+
<span class="truncate">{item.label}</span>
|
|
1379
|
+
<button
|
|
1380
|
+
type="button"
|
|
1381
|
+
class="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
|
|
1382
|
+
aria-label="{i18n.t('common.clear')}: {item.label}"
|
|
1383
|
+
title="{i18n.t('common.clear')}: {item.label}"
|
|
1384
|
+
onclick={() => removeActiveFilter(item.index)}
|
|
1385
|
+
>
|
|
1386
|
+
<X class="size-3" />
|
|
1387
|
+
</button>
|
|
1388
|
+
</Badge>
|
|
791
1389
|
{/each}
|
|
792
|
-
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" onclick={
|
|
793
|
-
searchText = '';
|
|
794
|
-
appliedSearchText = '';
|
|
795
|
-
filterValues = {};
|
|
796
|
-
pagination = { ...pagination, current: 1 };
|
|
797
|
-
}}>
|
|
1390
|
+
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" onclick={clearFilters}>
|
|
798
1391
|
{i18n.t('common.clearAllFilters')}
|
|
799
1392
|
</Button>
|
|
800
1393
|
</div>
|
|
@@ -818,9 +1411,11 @@
|
|
|
818
1411
|
{/each}
|
|
819
1412
|
</div>
|
|
820
1413
|
{:else if query.error}
|
|
821
|
-
<
|
|
822
|
-
|
|
823
|
-
|
|
1414
|
+
<DataState
|
|
1415
|
+
state="error"
|
|
1416
|
+
description={i18n.t('common.loadFailed', { message: (query.error as Error).message })}
|
|
1417
|
+
retry={() => listResult.refetch()}
|
|
1418
|
+
/>
|
|
824
1419
|
{:else}
|
|
825
1420
|
<div in:fade={{ duration: 150 }}>
|
|
826
1421
|
<!-- Desktop Table (hidden on mobile) -->
|
|
@@ -831,8 +1426,7 @@
|
|
|
831
1426
|
{@const visibleHeaders = headerGroup.headers.filter((header: Header<TableFeatures, BaseRecord, unknown>) => isColumnVisible(header.column.id))}
|
|
832
1427
|
<DraggableHeader
|
|
833
1428
|
columns={visibleHeaders.map((header) => ({ id: header.column.id, header }))}
|
|
834
|
-
|
|
835
|
-
onReorder={(newOrder) => columnOrderAtom.set(newOrder.map((column) => column.id))}
|
|
1429
|
+
onReorder={setColumnOrder}
|
|
836
1430
|
>
|
|
837
1431
|
{#snippet header(col, _index, dragProps)}
|
|
838
1432
|
{@const header = col.header as typeof headerGroup.headers[0]}
|
|
@@ -842,10 +1436,21 @@
|
|
|
842
1436
|
style={header_getSize(header) != null && header_getSize(header) !== 150 ? `width:${header_getSize(header)}px` : undefined}
|
|
843
1437
|
>
|
|
844
1438
|
{#if header.id === '_select'}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1439
|
+
{#if batchActions}
|
|
1440
|
+
<Checkbox
|
|
1441
|
+
aria-label={i18n.t('common.selectAll')}
|
|
1442
|
+
checked={table_getIsAllRowsSelected(tbl)}
|
|
1443
|
+
onCheckedChange={toggleAllRowsSelection}
|
|
1444
|
+
/>
|
|
1445
|
+
{:else}
|
|
1446
|
+
<CanAccess resource={resourceName} action="delete" params={{ ids: tableView.rows.map(rowIdValue) }}>
|
|
1447
|
+
<Checkbox
|
|
1448
|
+
aria-label={i18n.t('common.selectAll')}
|
|
1449
|
+
checked={table_getIsAllRowsSelected(tbl)}
|
|
1450
|
+
onCheckedChange={toggleAllRowsSelection}
|
|
1451
|
+
/>
|
|
1452
|
+
</CanAccess>
|
|
1453
|
+
{/if}
|
|
849
1454
|
{:else if header.id === '_expand'}
|
|
850
1455
|
<!-- empty -->
|
|
851
1456
|
{:else if header.id === '_actions'}
|
|
@@ -855,7 +1460,7 @@
|
|
|
855
1460
|
variant="ghost"
|
|
856
1461
|
size="sm"
|
|
857
1462
|
class="flex items-center gap-1 hover:text-foreground -ml-2 h-auto py-1 px-2 uppercase tracking-wide text-[0.7rem] font-semibold"
|
|
858
|
-
onclick={() =>
|
|
1463
|
+
onclick={() => toggleColumnSort(header.column)}
|
|
859
1464
|
>
|
|
860
1465
|
{visibleFields.find(f => f.key === header.id)?.label ?? header.id}
|
|
861
1466
|
<span class="text-xs opacity-50">
|
|
@@ -874,21 +1479,32 @@
|
|
|
874
1479
|
{/each}
|
|
875
1480
|
</Table.Header>
|
|
876
1481
|
<Table.Body>
|
|
877
|
-
{#each tableView.rows as row
|
|
1482
|
+
{#each tableView.rows as row (row.id)}
|
|
878
1483
|
{@const record = row.original}
|
|
879
1484
|
{@const id = record[primaryKey] as string | number}
|
|
880
1485
|
{@const visibleCells = row_getVisibleCells(row).filter((cell) => isColumnVisible(cell.column.id))}
|
|
881
1486
|
<ContextMenu.Root>
|
|
882
1487
|
<ContextMenu.Trigger>
|
|
883
1488
|
{#snippet child({ props })}
|
|
884
|
-
<Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {
|
|
1489
|
+
<Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {rowIsSelected(row.id) ? 'bg-primary/5' : 'hover:bg-muted/20'}">
|
|
885
1490
|
{#each visibleCells as cell, _i (_i)}
|
|
886
1491
|
<Table.Cell>
|
|
887
1492
|
{#if cell.column.id === '_select'}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1493
|
+
{#if batchActions}
|
|
1494
|
+
<Checkbox
|
|
1495
|
+
aria-label={i18n.t('common.selectRow', { id })}
|
|
1496
|
+
checked={rowIsSelected(row.id)}
|
|
1497
|
+
onCheckedChange={() => toggleRowSelection(row)}
|
|
1498
|
+
/>
|
|
1499
|
+
{:else}
|
|
1500
|
+
<CanAccess resource={resourceName} action="delete" params={{ id }}>
|
|
1501
|
+
<Checkbox
|
|
1502
|
+
aria-label={i18n.t('common.selectRow', { id })}
|
|
1503
|
+
checked={rowIsSelected(row.id)}
|
|
1504
|
+
onCheckedChange={() => toggleRowSelection(row)}
|
|
1505
|
+
/>
|
|
1506
|
+
</CanAccess>
|
|
1507
|
+
{/if}
|
|
892
1508
|
{:else if cell.column.id === '_expand'}
|
|
893
1509
|
<TooltipButton tooltip={rowIsExpanded(row.id) ? i18n.t('common.collapse') : i18n.t('common.expand')} variant="ghost" size="icon" class="h-7 w-7" onclick={() => toggleRowExpanded(row.id)}>
|
|
894
1510
|
{#if rowIsExpanded(row.id)}
|
|
@@ -902,15 +1518,26 @@
|
|
|
902
1518
|
{#if rowActions}
|
|
903
1519
|
{@render rowActions({ record, id })}
|
|
904
1520
|
{:else}
|
|
1521
|
+
{#if canShow}
|
|
1522
|
+
<CanAccess resource={resourceName} action="show" params={{ id }}>
|
|
1523
|
+
<TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => openDetail(id)}>
|
|
1524
|
+
<Eye class="h-4 w-4" />
|
|
1525
|
+
</TooltipButton>
|
|
1526
|
+
</CanAccess>
|
|
1527
|
+
{/if}
|
|
905
1528
|
{#if canEdit}
|
|
906
|
-
<
|
|
907
|
-
<
|
|
908
|
-
|
|
1529
|
+
<CanAccess resource={resourceName} action="edit" params={{ id }}>
|
|
1530
|
+
<TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => navigation.edit(resourceName, id)}>
|
|
1531
|
+
<Pencil class="h-4 w-4" />
|
|
1532
|
+
</TooltipButton>
|
|
1533
|
+
</CanAccess>
|
|
909
1534
|
{/if}
|
|
910
1535
|
{#if canDelete}
|
|
911
|
-
<
|
|
912
|
-
<
|
|
913
|
-
|
|
1536
|
+
<CanAccess resource={resourceName} action="delete" params={{ id }}>
|
|
1537
|
+
<TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
|
|
1538
|
+
<Trash2 class="h-4 w-4" />
|
|
1539
|
+
</TooltipButton>
|
|
1540
|
+
</CanAccess>
|
|
914
1541
|
{/if}
|
|
915
1542
|
{/if}
|
|
916
1543
|
</div>
|
|
@@ -934,13 +1561,18 @@
|
|
|
934
1561
|
{@const opt = field.options.find(o => o.value === cell_getValue(cell))}
|
|
935
1562
|
<Badge variant="outline">{opt?.label ?? cell_getValue(cell) ?? '—'}</Badge>
|
|
936
1563
|
{:else if canEdit && field && ['text', 'number', 'email', 'url'].includes(field.type)}
|
|
937
|
-
<
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1564
|
+
<CanAccess resource={resourceName} action="edit" params={{ id }}>
|
|
1565
|
+
<InlineEdit
|
|
1566
|
+
{resourceName}
|
|
1567
|
+
recordId={id}
|
|
1568
|
+
{field}
|
|
1569
|
+
value={cell_getValue(cell)}
|
|
1570
|
+
onSave={() => listResult.refetch()}
|
|
1571
|
+
/>
|
|
1572
|
+
{#snippet fallback()}
|
|
1573
|
+
<span class="block truncate" title={String(cell_getValue(cell) ?? '—')}>{cell_getValue(cell) ?? '—'}</span>
|
|
1574
|
+
{/snippet}
|
|
1575
|
+
</CanAccess>
|
|
944
1576
|
{:else if field?.key === primaryKey}
|
|
945
1577
|
<span class="block truncate font-mono text-xs" title={String(cell_getValue(cell) ?? '—')}>{cell_getValue(cell) ?? '—'}</span>
|
|
946
1578
|
{:else}
|
|
@@ -954,23 +1586,29 @@
|
|
|
954
1586
|
</ContextMenu.Trigger>
|
|
955
1587
|
<ContextMenu.Content class="w-48">
|
|
956
1588
|
{#if canEdit}
|
|
957
|
-
<
|
|
958
|
-
<
|
|
959
|
-
|
|
1589
|
+
<CanAccess resource={resourceName} action="edit" params={{ id }}>
|
|
1590
|
+
<ContextMenu.Item onclick={() => navigation.edit(resourceName, id)} class="gap-2">
|
|
1591
|
+
<Pencil class="h-4 w-4" /> {i18n.t('common.edit')}
|
|
1592
|
+
</ContextMenu.Item>
|
|
1593
|
+
</CanAccess>
|
|
960
1594
|
{/if}
|
|
961
1595
|
{#if canShow}
|
|
962
|
-
<
|
|
963
|
-
<
|
|
964
|
-
|
|
1596
|
+
<CanAccess resource={resourceName} action="show" params={{ id }}>
|
|
1597
|
+
<ContextMenu.Item onclick={() => openDetail(id)} class="gap-2">
|
|
1598
|
+
<Eye class="h-4 w-4" /> {i18n.t('common.detail')}
|
|
1599
|
+
</ContextMenu.Item>
|
|
1600
|
+
</CanAccess>
|
|
965
1601
|
{/if}
|
|
966
1602
|
<ContextMenu.Item onclick={() => navigator.clipboard?.writeText(String(id))} class="gap-2">
|
|
967
1603
|
<Copy class="h-4 w-4" /> {i18n.t('common.copyId')}
|
|
968
1604
|
</ContextMenu.Item>
|
|
969
1605
|
{#if canDelete}
|
|
970
|
-
<
|
|
971
|
-
|
|
972
|
-
<
|
|
973
|
-
|
|
1606
|
+
<CanAccess resource={resourceName} action="delete" params={{ id }}>
|
|
1607
|
+
<ContextMenu.Separator />
|
|
1608
|
+
<ContextMenu.Item onclick={() => confirmDelete(id)} class="gap-2 text-destructive">
|
|
1609
|
+
<Trash2 class="h-4 w-4" /> {i18n.t('common.delete')}
|
|
1610
|
+
</ContextMenu.Item>
|
|
1611
|
+
</CanAccess>
|
|
974
1612
|
{/if}
|
|
975
1613
|
</ContextMenu.Content>
|
|
976
1614
|
</ContextMenu.Root>
|
|
@@ -994,7 +1632,7 @@
|
|
|
994
1632
|
<p class="text-sm font-medium text-muted-foreground mb-1">{i18n.t('common.noData')}</p>
|
|
995
1633
|
<p class="text-xs text-muted-foreground/60 mb-4">{i18n.t('common.noDataHint')}</p>
|
|
996
1634
|
{#if canCreate}
|
|
997
|
-
<Button variant="outline" size="sm" class="gap-2" onclick={() =>
|
|
1635
|
+
<Button variant="outline" size="sm" class="gap-2" onclick={() => navigation.create(resourceName)}>
|
|
998
1636
|
<Plus class="h-3.5 w-3.5" />
|
|
999
1637
|
{i18n.t('common.create')}
|
|
1000
1638
|
</Button>
|
|
@@ -1014,16 +1652,27 @@
|
|
|
1014
1652
|
{@const record = row.original}
|
|
1015
1653
|
{@const id = record[primaryKey] as string | number}
|
|
1016
1654
|
<div
|
|
1017
|
-
class="rounded-[20px] shadow-[0_4px_20px_rgb(0,0,0,0.03)] ring-1 ring-border/20 bg-card p-5 transition-all {
|
|
1655
|
+
class="rounded-[20px] shadow-[0_4px_20px_rgb(0,0,0,0.03)] ring-1 ring-border/20 bg-card p-5 transition-all {rowIsSelected(row.id) ? 'ring-2 ring-primary/50 bg-primary/5' : ''}"
|
|
1018
1656
|
>
|
|
1019
1657
|
<!-- Card header: ID + select + actions -->
|
|
1020
1658
|
<div class="flex items-center justify-between mb-3">
|
|
1021
1659
|
<div class="flex items-center gap-2">
|
|
1022
1660
|
{#if selectable && (canDelete || batchActions)}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1661
|
+
{#if batchActions}
|
|
1662
|
+
<Checkbox
|
|
1663
|
+
aria-label={i18n.t('common.selectRow', { id })}
|
|
1664
|
+
checked={rowIsSelected(row.id)}
|
|
1665
|
+
onCheckedChange={() => toggleRowSelection(row)}
|
|
1666
|
+
/>
|
|
1667
|
+
{:else}
|
|
1668
|
+
<CanAccess resource={resourceName} action="delete" params={{ id }}>
|
|
1669
|
+
<Checkbox
|
|
1670
|
+
aria-label={i18n.t('common.selectRow', { id })}
|
|
1671
|
+
checked={rowIsSelected(row.id)}
|
|
1672
|
+
onCheckedChange={() => toggleRowSelection(row)}
|
|
1673
|
+
/>
|
|
1674
|
+
</CanAccess>
|
|
1675
|
+
{/if}
|
|
1027
1676
|
{/if}
|
|
1028
1677
|
<span class="text-xs font-mono text-muted-foreground">#{id}</span>
|
|
1029
1678
|
</div>
|
|
@@ -1032,26 +1681,32 @@
|
|
|
1032
1681
|
{@render rowActions({ record, id })}
|
|
1033
1682
|
{:else}
|
|
1034
1683
|
{#if canEdit}
|
|
1035
|
-
<
|
|
1036
|
-
<
|
|
1037
|
-
|
|
1684
|
+
<CanAccess resource={resourceName} action="edit" params={{ id }}>
|
|
1685
|
+
<TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => navigation.edit(resourceName, id)}>
|
|
1686
|
+
<Pencil class="h-4 w-4" />
|
|
1687
|
+
</TooltipButton>
|
|
1688
|
+
</CanAccess>
|
|
1038
1689
|
{/if}
|
|
1039
1690
|
{#if canShow}
|
|
1040
|
-
<
|
|
1041
|
-
<
|
|
1042
|
-
|
|
1691
|
+
<CanAccess resource={resourceName} action="show" params={{ id }}>
|
|
1692
|
+
<TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => openDetail(id)}>
|
|
1693
|
+
<Eye class="h-4 w-4" />
|
|
1694
|
+
</TooltipButton>
|
|
1695
|
+
</CanAccess>
|
|
1043
1696
|
{/if}
|
|
1044
1697
|
{#if canDelete}
|
|
1045
|
-
<
|
|
1046
|
-
<
|
|
1047
|
-
|
|
1698
|
+
<CanAccess resource={resourceName} action="delete" params={{ id }}>
|
|
1699
|
+
<TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
|
|
1700
|
+
<Trash2 class="h-4 w-4" />
|
|
1701
|
+
</TooltipButton>
|
|
1702
|
+
</CanAccess>
|
|
1048
1703
|
{/if}
|
|
1049
1704
|
{/if}
|
|
1050
1705
|
</div>
|
|
1051
1706
|
</div>
|
|
1052
1707
|
<!-- Card fields -->
|
|
1053
1708
|
<div class="space-y-2">
|
|
1054
|
-
{#each visibleFields.filter((field) => isColumnVisible(field.key)).slice(0, 6) as field
|
|
1709
|
+
{#each visibleFields.filter((field) => isColumnVisible(field.key)).slice(0, 6) as field (field.key)}
|
|
1055
1710
|
{@const value = record[field.key]}
|
|
1056
1711
|
<div class="flex items-start justify-between gap-4">
|
|
1057
1712
|
<span class="text-xs font-medium text-muted-foreground shrink-0">{field.label}</span>
|
|
@@ -1086,7 +1741,7 @@
|
|
|
1086
1741
|
{#if emptyState}
|
|
1087
1742
|
{@render emptyState()}
|
|
1088
1743
|
{:else}
|
|
1089
|
-
|
|
1744
|
+
<DataState state="empty" class="border-0 bg-transparent px-2 py-4" />
|
|
1090
1745
|
{/if}
|
|
1091
1746
|
</div>
|
|
1092
1747
|
{/each}
|
|
@@ -1100,12 +1755,14 @@
|
|
|
1100
1755
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 px-1 py-2 text-sm text-muted-foreground">
|
|
1101
1756
|
<div class="flex shrink-0 items-center gap-3 leading-8">
|
|
1102
1757
|
<span class="shrink-0 whitespace-nowrap">{i18n.t('common.total', { total: query.data?.total ?? 0 })}</span>
|
|
1103
|
-
<
|
|
1104
|
-
|
|
1758
|
+
<Select.Root
|
|
1759
|
+
aria-label={i18n.t('common.perPage')}
|
|
1760
|
+
class="h-8 w-[78px] shrink-0"
|
|
1105
1761
|
value={String(pagination.pageSize ?? 10)}
|
|
1106
1762
|
onchange={(e) => {
|
|
1107
|
-
const size = Number((e.
|
|
1763
|
+
const size = Number((e.currentTarget as HTMLSelectElement).value);
|
|
1108
1764
|
if (!isNaN(size)) {
|
|
1765
|
+
markSavedViewDirty();
|
|
1109
1766
|
pagination = { ...pagination, pageSize: size, current: 1 };
|
|
1110
1767
|
}
|
|
1111
1768
|
}}
|
|
@@ -1114,7 +1771,7 @@
|
|
|
1114
1771
|
<option value="20">20</option>
|
|
1115
1772
|
<option value="50">50</option>
|
|
1116
1773
|
<option value="100">100</option>
|
|
1117
|
-
</
|
|
1774
|
+
</Select.Root>
|
|
1118
1775
|
</div>
|
|
1119
1776
|
<PaginationUI.Root>
|
|
1120
1777
|
<PaginationUI.Content>
|
|
@@ -1154,6 +1811,16 @@
|
|
|
1154
1811
|
open={confirmOpen}
|
|
1155
1812
|
message={confirmMessage}
|
|
1156
1813
|
confirmText={i18n.t('common.delete')}
|
|
1814
|
+
confirming={confirmPending}
|
|
1157
1815
|
onconfirm={confirmAction}
|
|
1158
1816
|
oncancel={() => { confirmOpen = false; }}
|
|
1159
1817
|
/>
|
|
1818
|
+
|
|
1819
|
+
{#if detailRecordId != null}
|
|
1820
|
+
<RecordDetailDrawer
|
|
1821
|
+
resourceName={resourceName}
|
|
1822
|
+
open={detailOpen}
|
|
1823
|
+
recordId={detailRecordId}
|
|
1824
|
+
onClose={closeDetail}
|
|
1825
|
+
/>
|
|
1826
|
+
{/if}
|