runeforge 0.0.55 → 0.0.56
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/LICENSE +21 -21
- package/README.md +1342 -1342
- package/dist/components/Avatar.svelte +31 -31
- package/dist/components/IconRenderer.svelte +22 -22
- package/dist/components/Modal.svelte +75 -75
- package/dist/components/common/Header.svelte +40 -40
- package/dist/components/crud/EmbeddedField.svelte +175 -175
- package/dist/components/crud/Field.svelte +469 -376
- package/dist/components/crud/GenericCRUD.svelte +426 -426
- package/dist/components/crud/SearchInput.svelte +53 -53
- package/dist/components/crud/columns/Avatar.svelte +15 -15
- package/dist/components/crud/columns/Icon.svelte +8 -8
- package/dist/components/crud/views/Create.svelte +232 -232
- package/dist/components/crud/views/Read.svelte +124 -124
- package/dist/components/crud/views/Update.svelte +208 -208
- package/dist/components/crud/views/list/List.svelte +291 -291
- package/dist/components/crud/views/list/Modals.svelte +56 -56
- package/dist/components/crud/views/list/Table.svelte +176 -176
- package/dist/components/crud/views/list/Toolbar.svelte +341 -341
- package/dist/components/form/Button.svelte +27 -27
- package/dist/components/form/Label.svelte +37 -37
- package/dist/components/form/MultiSelect.svelte +248 -248
- package/dist/components/form/PasswordInput.svelte +68 -68
- package/dist/components/form/Required.svelte +1 -1
- package/dist/components/form/Select.svelte +209 -209
- package/dist/components/form/Tree.svelte +62 -62
- package/dist/components/form/TreeNode.svelte +66 -66
- package/dist/components/navigation/Breadcrumbs.svelte +111 -111
- package/dist/components/table/ColumnFilter.svelte +168 -168
- package/dist/components/table/PaginatedTable.svelte +536 -536
- package/dist/components/table/Paginator.svelte +113 -113
- package/dist/components/table/SortHeader.svelte +43 -43
- package/dist/components/table/TableBody.svelte +150 -150
- package/dist/components/table/TableHeader.svelte +88 -88
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/types.d.ts +1 -0
- package/dist/icons/defaults/Clear.svelte +6 -6
- package/dist/icons/defaults/Create.svelte +6 -6
- package/dist/icons/defaults/Delete.svelte +6 -6
- package/dist/icons/defaults/Download.svelte +7 -7
- package/dist/icons/defaults/Edit.svelte +7 -7
- package/dist/icons/defaults/Filter.svelte +6 -6
- package/dist/icons/defaults/FilterActive.svelte +6 -6
- package/dist/icons/defaults/Folder.svelte +6 -6
- package/dist/icons/defaults/Grip.svelte +7 -7
- package/dist/icons/defaults/Home.svelte +6 -6
- package/dist/icons/defaults/PasswordHide.svelte +9 -9
- package/dist/icons/defaults/PasswordShow.svelte +7 -7
- package/dist/icons/defaults/SortAsc.svelte +6 -6
- package/dist/icons/defaults/SortDesc.svelte +6 -6
- package/dist/icons/defaults/SortNone.svelte +6 -6
- package/dist/icons/defaults/View.svelte +7 -7
- package/package.json +1 -1
|
@@ -1,291 +1,291 @@
|
|
|
1
|
-
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
|
-
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
-
import { invalidateAll } from '$app/navigation';
|
|
4
|
-
import Toolbar from './Toolbar.svelte';
|
|
5
|
-
import Table from './Table.svelte';
|
|
6
|
-
import Modals from './Modals.svelte';
|
|
7
|
-
import { computeReorderChanges } from '../../utils/reorder.js';
|
|
8
|
-
import type {
|
|
9
|
-
ActionConfiguration,
|
|
10
|
-
ColumnDefinition,
|
|
11
|
-
CustomAction,
|
|
12
|
-
CustomBulkAction,
|
|
13
|
-
ListActions,
|
|
14
|
-
ListConfig,
|
|
15
|
-
ViewBasedCustomBulkAction
|
|
16
|
-
} from '../../../../types/crud.js';
|
|
17
|
-
import type {
|
|
18
|
-
FilterSnapshot,
|
|
19
|
-
ServerPagination,
|
|
20
|
-
SortDirection,
|
|
21
|
-
TableQuery
|
|
22
|
-
} from '../../../../types/table.js';
|
|
23
|
-
import { getStrings } from '../../../../i18n/context.js';
|
|
24
|
-
|
|
25
|
-
const strings = getStrings();
|
|
26
|
-
|
|
27
|
-
function isViewBasedBulkAction<U extends object>(
|
|
28
|
-
action: CustomBulkAction<U>
|
|
29
|
-
): action is ViewBasedCustomBulkAction<U> {
|
|
30
|
-
return action.kind === 'view';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
let {
|
|
34
|
-
data = [] as T[],
|
|
35
|
-
labelOne = '',
|
|
36
|
-
labelMany = '',
|
|
37
|
-
icon,
|
|
38
|
-
pageSize = 10,
|
|
39
|
-
idKey = '_id',
|
|
40
|
-
creation = {} as ActionConfiguration<T>,
|
|
41
|
-
update = {} as ActionConfiguration<T>,
|
|
42
|
-
read = {} as ActionConfiguration<T>,
|
|
43
|
-
deletion = {} as ActionConfiguration<T>,
|
|
44
|
-
actions = {} as ListActions<T>,
|
|
45
|
-
config = {} as ListConfig<T>,
|
|
46
|
-
columns = [] as ColumnDefinition<T>[],
|
|
47
|
-
pagination = undefined as ServerPagination | undefined,
|
|
48
|
-
initialSort = undefined as { column: string; direction: SortDirection } | undefined,
|
|
49
|
-
initialFilters = undefined as Partial<FilterSnapshot> | undefined,
|
|
50
|
-
onPaginationChange = undefined as ((query: TableQuery) => void) | undefined,
|
|
51
|
-
onCreate,
|
|
52
|
-
onEdit,
|
|
53
|
-
onView,
|
|
54
|
-
onAction,
|
|
55
|
-
onBulkAction
|
|
56
|
-
}: {
|
|
57
|
-
data?: T[];
|
|
58
|
-
labelOne?: string;
|
|
59
|
-
labelMany?: string;
|
|
60
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
-
icon?: any;
|
|
62
|
-
pageSize?: number;
|
|
63
|
-
idKey?: string;
|
|
64
|
-
creation?: ActionConfiguration<T>;
|
|
65
|
-
update?: ActionConfiguration<T>;
|
|
66
|
-
read?: ActionConfiguration<T>;
|
|
67
|
-
deletion?: ActionConfiguration<T>;
|
|
68
|
-
actions?: ListActions<T>;
|
|
69
|
-
config?: ListConfig<T>;
|
|
70
|
-
columns?: ColumnDefinition<T>[];
|
|
71
|
-
pagination?: ServerPagination;
|
|
72
|
-
initialSort?: { column: string; direction: SortDirection };
|
|
73
|
-
initialFilters?: Partial<FilterSnapshot>;
|
|
74
|
-
onPaginationChange?: (query: TableQuery) => void;
|
|
75
|
-
onCreate?: () => void;
|
|
76
|
-
onEdit?: (item: T) => void;
|
|
77
|
-
onView?: (item: T) => void;
|
|
78
|
-
onAction?: (action: CustomAction<T>, item: T) => void;
|
|
79
|
-
onBulkAction?: (action: ViewBasedCustomBulkAction<T>, items: T[]) => void;
|
|
80
|
-
} = $props();
|
|
81
|
-
|
|
82
|
-
const customActions = $derived(actions.custom ?? []);
|
|
83
|
-
const bulkActions = $derived(actions.bulk ?? []);
|
|
84
|
-
const searchConfig = $derived(config.search);
|
|
85
|
-
const exportConfig = $derived(config.export);
|
|
86
|
-
const reorderConfig = $derived(config.reorder);
|
|
87
|
-
|
|
88
|
-
const allowRead = $derived(read.enabled ?? true);
|
|
89
|
-
const allowUpdate = $derived(update.enabled ?? true);
|
|
90
|
-
const allowDelete = $derived(deletion.enabled ?? true);
|
|
91
|
-
const allowCreate = $derived(creation.enabled ?? true);
|
|
92
|
-
const deleteLabel = $derived(deletion.label ?? strings.delete);
|
|
93
|
-
const updateLabel = $derived(update.label ?? strings.edit);
|
|
94
|
-
const readLabel = $derived(read.label ?? strings.view);
|
|
95
|
-
const allowSelection = $derived(
|
|
96
|
-
allowDelete || bulkActions.some((action) => !isViewBasedBulkAction(action))
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
// SvelteSet is already reactive on its own; passing it through Table's
|
|
100
|
-
// (and PaginatedTable's) own `bind:selected` makes svelte-check's
|
|
101
|
-
// non_reactive_update warning fire here too — a false positive, per the
|
|
102
|
-
// same reasoning as the "intentional one-time hydration" notes elsewhere
|
|
103
|
-
// in this codebase.
|
|
104
|
-
let selected = new SvelteSet<number>();
|
|
105
|
-
let pendingDeletion = $state<T[] | null>(null);
|
|
106
|
-
let pendingBulkAction = $state<{ action: CustomBulkAction<T>; items: T[] } | null>(null);
|
|
107
|
-
const selectedItems = $derived([...selected].map((i) => data[i]));
|
|
108
|
-
|
|
109
|
-
let visibleRows = $state<T[]>([]);
|
|
110
|
-
let exportQuery = $state<TableQuery | undefined>(undefined);
|
|
111
|
-
|
|
112
|
-
// `selected` holds indices into `data`; if `data` is swapped for a different
|
|
113
|
-
// slice (page/sort/filter change in server mode) stale indices could point
|
|
114
|
-
// at unrelated rows, so clear on any data reference change.
|
|
115
|
-
let lastData: T[] | undefined;
|
|
116
|
-
$effect(() => {
|
|
117
|
-
if (data !== lastData) {
|
|
118
|
-
selected.clear();
|
|
119
|
-
lastData = data;
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
async function runEndpointAction(
|
|
124
|
-
endpoint: string,
|
|
125
|
-
items: T[],
|
|
126
|
-
extraFields?: (item: T) => Record<string, string>
|
|
127
|
-
) {
|
|
128
|
-
await Promise.all(
|
|
129
|
-
items.map((item) => {
|
|
130
|
-
const fd = new FormData();
|
|
131
|
-
fd.set('id', String((item as Record<string, unknown>)[idKey] ?? ''));
|
|
132
|
-
for (const [k, v] of Object.entries(extraFields?.(item) ?? {})) fd.set(k, v);
|
|
133
|
-
return fetch(endpoint, { method: 'POST', body: fd });
|
|
134
|
-
})
|
|
135
|
-
);
|
|
136
|
-
await invalidateAll();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** One POST for every row that moved, instead of `runEndpointAction`'s one
|
|
140
|
-
* request per row — a drag that shifts N rows only settles once (on
|
|
141
|
-
* release), but N rows changing means N *parallel* requests fire at that
|
|
142
|
-
* moment, which reads a lot like "one per row crossed" from the network
|
|
143
|
-
* tab. FormData field `changes` carries a JSON array of `{id, value}`
|
|
144
|
-
* pairs, one per row whose `attribute` changed. */
|
|
145
|
-
async function runBatchEndpointAction(endpoint: string, items: T[], attribute: keyof T & string) {
|
|
146
|
-
const fd = new FormData();
|
|
147
|
-
fd.set(
|
|
148
|
-
'changes',
|
|
149
|
-
JSON.stringify(
|
|
150
|
-
items.map((item) => ({
|
|
151
|
-
id: String((item as Record<string, unknown>)[idKey] ?? ''),
|
|
152
|
-
value: (item as Record<string, unknown>)[attribute]
|
|
153
|
-
}))
|
|
154
|
-
)
|
|
155
|
-
);
|
|
156
|
-
await fetch(endpoint, { method: 'POST', body: fd });
|
|
157
|
-
await invalidateAll();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function runDeletion(items: T[]) {
|
|
161
|
-
if (deletion.endpoint) {
|
|
162
|
-
await runEndpointAction(deletion.endpoint, items);
|
|
163
|
-
} else {
|
|
164
|
-
await deletion.callback?.(items);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function requestDeletion(items: T[]) {
|
|
169
|
-
if (deletion.confirm) {
|
|
170
|
-
pendingDeletion = items;
|
|
171
|
-
} else {
|
|
172
|
-
runDeletion(items);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function handleDeleteSelected() {
|
|
177
|
-
const items = [...selected].map((i) => data[i]);
|
|
178
|
-
selected.clear();
|
|
179
|
-
requestDeletion(items);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async function confirmDeletion() {
|
|
183
|
-
if (pendingDeletion) {
|
|
184
|
-
await runDeletion(pendingDeletion);
|
|
185
|
-
pendingDeletion = null;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async function runBulkAction(action: CustomBulkAction<T>, items: T[]) {
|
|
190
|
-
if (isViewBasedBulkAction(action)) return;
|
|
191
|
-
await runEndpointAction(action.endpoint, items);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function requestBulkAction(action: CustomBulkAction<T>, items: T[]) {
|
|
195
|
-
if (!isViewBasedBulkAction(action) && action.confirm) {
|
|
196
|
-
pendingBulkAction = { action, items };
|
|
197
|
-
} else {
|
|
198
|
-
runBulkAction(action, items);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function handleBulkAction(action: CustomBulkAction<T>) {
|
|
203
|
-
const items = selectedItems;
|
|
204
|
-
if (isViewBasedBulkAction(action)) {
|
|
205
|
-
onBulkAction?.(action, items);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
selected.clear();
|
|
209
|
-
requestBulkAction(action, items);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function confirmBulkAction() {
|
|
213
|
-
if (pendingBulkAction) {
|
|
214
|
-
await runBulkAction(pendingBulkAction.action, pendingBulkAction.items);
|
|
215
|
-
pendingBulkAction = null;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async function handleReorder(rows: T[]) {
|
|
220
|
-
const cfg = reorderConfig;
|
|
221
|
-
if (!cfg) return;
|
|
222
|
-
const changed = computeReorderChanges(rows, cfg.attribute);
|
|
223
|
-
if (changed.length === 0) return;
|
|
224
|
-
if (cfg.endpoint) {
|
|
225
|
-
await runBatchEndpointAction(cfg.endpoint, changed, cfg.attribute);
|
|
226
|
-
} else {
|
|
227
|
-
await cfg.callback?.(changed);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
</script>
|
|
231
|
-
|
|
232
|
-
<div class="flex flex-col gap-6">
|
|
233
|
-
<Toolbar
|
|
234
|
-
{labelOne}
|
|
235
|
-
{labelMany}
|
|
236
|
-
{icon}
|
|
237
|
-
{creation}
|
|
238
|
-
{allowCreate}
|
|
239
|
-
{allowDelete}
|
|
240
|
-
{deleteLabel}
|
|
241
|
-
search={searchConfig}
|
|
242
|
-
{exportConfig}
|
|
243
|
-
{bulkActions}
|
|
244
|
-
{columns}
|
|
245
|
-
{pagination}
|
|
246
|
-
{visibleRows}
|
|
247
|
-
{exportQuery}
|
|
248
|
-
selectedCount={selected.size}
|
|
249
|
-
{selectedItems}
|
|
250
|
-
{onCreate}
|
|
251
|
-
onDeleteSelected={handleDeleteSelected}
|
|
252
|
-
onBulkAction={handleBulkAction}
|
|
253
|
-
/>
|
|
254
|
-
|
|
255
|
-
<Table
|
|
256
|
-
{data}
|
|
257
|
-
{columns}
|
|
258
|
-
{pageSize}
|
|
259
|
-
selectable={allowSelection}
|
|
260
|
-
bind:selected
|
|
261
|
-
{pagination}
|
|
262
|
-
{initialSort}
|
|
263
|
-
{initialFilters}
|
|
264
|
-
{onPaginationChange}
|
|
265
|
-
bind:visibleRows
|
|
266
|
-
bind:query={exportQuery}
|
|
267
|
-
{customActions}
|
|
268
|
-
{allowRead}
|
|
269
|
-
{allowUpdate}
|
|
270
|
-
{allowDelete}
|
|
271
|
-
{readLabel}
|
|
272
|
-
{updateLabel}
|
|
273
|
-
{deleteLabel}
|
|
274
|
-
reorder={reorderConfig}
|
|
275
|
-
{onView}
|
|
276
|
-
{onEdit}
|
|
277
|
-
{onAction}
|
|
278
|
-
onRequestDeletion={(item) => requestDeletion([item])}
|
|
279
|
-
onReorder={handleReorder}
|
|
280
|
-
/>
|
|
281
|
-
</div>
|
|
282
|
-
|
|
283
|
-
<Modals
|
|
284
|
-
{deleteLabel}
|
|
285
|
-
{pendingDeletion}
|
|
286
|
-
{pendingBulkAction}
|
|
287
|
-
onCancelDeletion={() => (pendingDeletion = null)}
|
|
288
|
-
onConfirmDeletion={confirmDeletion}
|
|
289
|
-
onCancelBulkAction={() => (pendingBulkAction = null)}
|
|
290
|
-
onConfirmBulkAction={confirmBulkAction}
|
|
291
|
-
/>
|
|
1
|
+
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
import { invalidateAll } from '$app/navigation';
|
|
4
|
+
import Toolbar from './Toolbar.svelte';
|
|
5
|
+
import Table from './Table.svelte';
|
|
6
|
+
import Modals from './Modals.svelte';
|
|
7
|
+
import { computeReorderChanges } from '../../utils/reorder.js';
|
|
8
|
+
import type {
|
|
9
|
+
ActionConfiguration,
|
|
10
|
+
ColumnDefinition,
|
|
11
|
+
CustomAction,
|
|
12
|
+
CustomBulkAction,
|
|
13
|
+
ListActions,
|
|
14
|
+
ListConfig,
|
|
15
|
+
ViewBasedCustomBulkAction
|
|
16
|
+
} from '../../../../types/crud.js';
|
|
17
|
+
import type {
|
|
18
|
+
FilterSnapshot,
|
|
19
|
+
ServerPagination,
|
|
20
|
+
SortDirection,
|
|
21
|
+
TableQuery
|
|
22
|
+
} from '../../../../types/table.js';
|
|
23
|
+
import { getStrings } from '../../../../i18n/context.js';
|
|
24
|
+
|
|
25
|
+
const strings = getStrings();
|
|
26
|
+
|
|
27
|
+
function isViewBasedBulkAction<U extends object>(
|
|
28
|
+
action: CustomBulkAction<U>
|
|
29
|
+
): action is ViewBasedCustomBulkAction<U> {
|
|
30
|
+
return action.kind === 'view';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
data = [] as T[],
|
|
35
|
+
labelOne = '',
|
|
36
|
+
labelMany = '',
|
|
37
|
+
icon,
|
|
38
|
+
pageSize = 10,
|
|
39
|
+
idKey = '_id',
|
|
40
|
+
creation = {} as ActionConfiguration<T>,
|
|
41
|
+
update = {} as ActionConfiguration<T>,
|
|
42
|
+
read = {} as ActionConfiguration<T>,
|
|
43
|
+
deletion = {} as ActionConfiguration<T>,
|
|
44
|
+
actions = {} as ListActions<T>,
|
|
45
|
+
config = {} as ListConfig<T>,
|
|
46
|
+
columns = [] as ColumnDefinition<T>[],
|
|
47
|
+
pagination = undefined as ServerPagination | undefined,
|
|
48
|
+
initialSort = undefined as { column: string; direction: SortDirection } | undefined,
|
|
49
|
+
initialFilters = undefined as Partial<FilterSnapshot> | undefined,
|
|
50
|
+
onPaginationChange = undefined as ((query: TableQuery) => void) | undefined,
|
|
51
|
+
onCreate,
|
|
52
|
+
onEdit,
|
|
53
|
+
onView,
|
|
54
|
+
onAction,
|
|
55
|
+
onBulkAction
|
|
56
|
+
}: {
|
|
57
|
+
data?: T[];
|
|
58
|
+
labelOne?: string;
|
|
59
|
+
labelMany?: string;
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
icon?: any;
|
|
62
|
+
pageSize?: number;
|
|
63
|
+
idKey?: string;
|
|
64
|
+
creation?: ActionConfiguration<T>;
|
|
65
|
+
update?: ActionConfiguration<T>;
|
|
66
|
+
read?: ActionConfiguration<T>;
|
|
67
|
+
deletion?: ActionConfiguration<T>;
|
|
68
|
+
actions?: ListActions<T>;
|
|
69
|
+
config?: ListConfig<T>;
|
|
70
|
+
columns?: ColumnDefinition<T>[];
|
|
71
|
+
pagination?: ServerPagination;
|
|
72
|
+
initialSort?: { column: string; direction: SortDirection };
|
|
73
|
+
initialFilters?: Partial<FilterSnapshot>;
|
|
74
|
+
onPaginationChange?: (query: TableQuery) => void;
|
|
75
|
+
onCreate?: () => void;
|
|
76
|
+
onEdit?: (item: T) => void;
|
|
77
|
+
onView?: (item: T) => void;
|
|
78
|
+
onAction?: (action: CustomAction<T>, item: T) => void;
|
|
79
|
+
onBulkAction?: (action: ViewBasedCustomBulkAction<T>, items: T[]) => void;
|
|
80
|
+
} = $props();
|
|
81
|
+
|
|
82
|
+
const customActions = $derived(actions.custom ?? []);
|
|
83
|
+
const bulkActions = $derived(actions.bulk ?? []);
|
|
84
|
+
const searchConfig = $derived(config.search);
|
|
85
|
+
const exportConfig = $derived(config.export);
|
|
86
|
+
const reorderConfig = $derived(config.reorder);
|
|
87
|
+
|
|
88
|
+
const allowRead = $derived(read.enabled ?? true);
|
|
89
|
+
const allowUpdate = $derived(update.enabled ?? true);
|
|
90
|
+
const allowDelete = $derived(deletion.enabled ?? true);
|
|
91
|
+
const allowCreate = $derived(creation.enabled ?? true);
|
|
92
|
+
const deleteLabel = $derived(deletion.label ?? strings.delete);
|
|
93
|
+
const updateLabel = $derived(update.label ?? strings.edit);
|
|
94
|
+
const readLabel = $derived(read.label ?? strings.view);
|
|
95
|
+
const allowSelection = $derived(
|
|
96
|
+
allowDelete || bulkActions.some((action) => !isViewBasedBulkAction(action))
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// SvelteSet is already reactive on its own; passing it through Table's
|
|
100
|
+
// (and PaginatedTable's) own `bind:selected` makes svelte-check's
|
|
101
|
+
// non_reactive_update warning fire here too — a false positive, per the
|
|
102
|
+
// same reasoning as the "intentional one-time hydration" notes elsewhere
|
|
103
|
+
// in this codebase.
|
|
104
|
+
let selected = new SvelteSet<number>();
|
|
105
|
+
let pendingDeletion = $state<T[] | null>(null);
|
|
106
|
+
let pendingBulkAction = $state<{ action: CustomBulkAction<T>; items: T[] } | null>(null);
|
|
107
|
+
const selectedItems = $derived([...selected].map((i) => data[i]));
|
|
108
|
+
|
|
109
|
+
let visibleRows = $state<T[]>([]);
|
|
110
|
+
let exportQuery = $state<TableQuery | undefined>(undefined);
|
|
111
|
+
|
|
112
|
+
// `selected` holds indices into `data`; if `data` is swapped for a different
|
|
113
|
+
// slice (page/sort/filter change in server mode) stale indices could point
|
|
114
|
+
// at unrelated rows, so clear on any data reference change.
|
|
115
|
+
let lastData: T[] | undefined;
|
|
116
|
+
$effect(() => {
|
|
117
|
+
if (data !== lastData) {
|
|
118
|
+
selected.clear();
|
|
119
|
+
lastData = data;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
async function runEndpointAction(
|
|
124
|
+
endpoint: string,
|
|
125
|
+
items: T[],
|
|
126
|
+
extraFields?: (item: T) => Record<string, string>
|
|
127
|
+
) {
|
|
128
|
+
await Promise.all(
|
|
129
|
+
items.map((item) => {
|
|
130
|
+
const fd = new FormData();
|
|
131
|
+
fd.set('id', String((item as Record<string, unknown>)[idKey] ?? ''));
|
|
132
|
+
for (const [k, v] of Object.entries(extraFields?.(item) ?? {})) fd.set(k, v);
|
|
133
|
+
return fetch(endpoint, { method: 'POST', body: fd });
|
|
134
|
+
})
|
|
135
|
+
);
|
|
136
|
+
await invalidateAll();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One POST for every row that moved, instead of `runEndpointAction`'s one
|
|
140
|
+
* request per row — a drag that shifts N rows only settles once (on
|
|
141
|
+
* release), but N rows changing means N *parallel* requests fire at that
|
|
142
|
+
* moment, which reads a lot like "one per row crossed" from the network
|
|
143
|
+
* tab. FormData field `changes` carries a JSON array of `{id, value}`
|
|
144
|
+
* pairs, one per row whose `attribute` changed. */
|
|
145
|
+
async function runBatchEndpointAction(endpoint: string, items: T[], attribute: keyof T & string) {
|
|
146
|
+
const fd = new FormData();
|
|
147
|
+
fd.set(
|
|
148
|
+
'changes',
|
|
149
|
+
JSON.stringify(
|
|
150
|
+
items.map((item) => ({
|
|
151
|
+
id: String((item as Record<string, unknown>)[idKey] ?? ''),
|
|
152
|
+
value: (item as Record<string, unknown>)[attribute]
|
|
153
|
+
}))
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
await fetch(endpoint, { method: 'POST', body: fd });
|
|
157
|
+
await invalidateAll();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runDeletion(items: T[]) {
|
|
161
|
+
if (deletion.endpoint) {
|
|
162
|
+
await runEndpointAction(deletion.endpoint, items);
|
|
163
|
+
} else {
|
|
164
|
+
await deletion.callback?.(items);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function requestDeletion(items: T[]) {
|
|
169
|
+
if (deletion.confirm) {
|
|
170
|
+
pendingDeletion = items;
|
|
171
|
+
} else {
|
|
172
|
+
runDeletion(items);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function handleDeleteSelected() {
|
|
177
|
+
const items = [...selected].map((i) => data[i]);
|
|
178
|
+
selected.clear();
|
|
179
|
+
requestDeletion(items);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function confirmDeletion() {
|
|
183
|
+
if (pendingDeletion) {
|
|
184
|
+
await runDeletion(pendingDeletion);
|
|
185
|
+
pendingDeletion = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function runBulkAction(action: CustomBulkAction<T>, items: T[]) {
|
|
190
|
+
if (isViewBasedBulkAction(action)) return;
|
|
191
|
+
await runEndpointAction(action.endpoint, items);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function requestBulkAction(action: CustomBulkAction<T>, items: T[]) {
|
|
195
|
+
if (!isViewBasedBulkAction(action) && action.confirm) {
|
|
196
|
+
pendingBulkAction = { action, items };
|
|
197
|
+
} else {
|
|
198
|
+
runBulkAction(action, items);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function handleBulkAction(action: CustomBulkAction<T>) {
|
|
203
|
+
const items = selectedItems;
|
|
204
|
+
if (isViewBasedBulkAction(action)) {
|
|
205
|
+
onBulkAction?.(action, items);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
selected.clear();
|
|
209
|
+
requestBulkAction(action, items);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function confirmBulkAction() {
|
|
213
|
+
if (pendingBulkAction) {
|
|
214
|
+
await runBulkAction(pendingBulkAction.action, pendingBulkAction.items);
|
|
215
|
+
pendingBulkAction = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handleReorder(rows: T[]) {
|
|
220
|
+
const cfg = reorderConfig;
|
|
221
|
+
if (!cfg) return;
|
|
222
|
+
const changed = computeReorderChanges(rows, cfg.attribute);
|
|
223
|
+
if (changed.length === 0) return;
|
|
224
|
+
if (cfg.endpoint) {
|
|
225
|
+
await runBatchEndpointAction(cfg.endpoint, changed, cfg.attribute);
|
|
226
|
+
} else {
|
|
227
|
+
await cfg.callback?.(changed);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
</script>
|
|
231
|
+
|
|
232
|
+
<div class="flex flex-col gap-6">
|
|
233
|
+
<Toolbar
|
|
234
|
+
{labelOne}
|
|
235
|
+
{labelMany}
|
|
236
|
+
{icon}
|
|
237
|
+
{creation}
|
|
238
|
+
{allowCreate}
|
|
239
|
+
{allowDelete}
|
|
240
|
+
{deleteLabel}
|
|
241
|
+
search={searchConfig}
|
|
242
|
+
{exportConfig}
|
|
243
|
+
{bulkActions}
|
|
244
|
+
{columns}
|
|
245
|
+
{pagination}
|
|
246
|
+
{visibleRows}
|
|
247
|
+
{exportQuery}
|
|
248
|
+
selectedCount={selected.size}
|
|
249
|
+
{selectedItems}
|
|
250
|
+
{onCreate}
|
|
251
|
+
onDeleteSelected={handleDeleteSelected}
|
|
252
|
+
onBulkAction={handleBulkAction}
|
|
253
|
+
/>
|
|
254
|
+
|
|
255
|
+
<Table
|
|
256
|
+
{data}
|
|
257
|
+
{columns}
|
|
258
|
+
{pageSize}
|
|
259
|
+
selectable={allowSelection}
|
|
260
|
+
bind:selected
|
|
261
|
+
{pagination}
|
|
262
|
+
{initialSort}
|
|
263
|
+
{initialFilters}
|
|
264
|
+
{onPaginationChange}
|
|
265
|
+
bind:visibleRows
|
|
266
|
+
bind:query={exportQuery}
|
|
267
|
+
{customActions}
|
|
268
|
+
{allowRead}
|
|
269
|
+
{allowUpdate}
|
|
270
|
+
{allowDelete}
|
|
271
|
+
{readLabel}
|
|
272
|
+
{updateLabel}
|
|
273
|
+
{deleteLabel}
|
|
274
|
+
reorder={reorderConfig}
|
|
275
|
+
{onView}
|
|
276
|
+
{onEdit}
|
|
277
|
+
{onAction}
|
|
278
|
+
onRequestDeletion={(item) => requestDeletion([item])}
|
|
279
|
+
onReorder={handleReorder}
|
|
280
|
+
/>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<Modals
|
|
284
|
+
{deleteLabel}
|
|
285
|
+
{pendingDeletion}
|
|
286
|
+
{pendingBulkAction}
|
|
287
|
+
onCancelDeletion={() => (pendingDeletion = null)}
|
|
288
|
+
onConfirmDeletion={confirmDeletion}
|
|
289
|
+
onCancelBulkAction={() => (pendingBulkAction = null)}
|
|
290
|
+
onConfirmBulkAction={confirmBulkAction}
|
|
291
|
+
/>
|