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