@happyvertical/smrt-content 0.43.4 → 0.43.5
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/AGENTS.md +9 -86
- package/agents/content-list.md +869 -0
- package/dist/content-query.d.ts +310 -0
- package/dist/content-query.d.ts.map +1 -0
- package/dist/contents.d.ts +22 -0
- package/dist/contents.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +672 -4
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +22 -2
- package/dist/smrt-knowledge.json +38 -5
- package/dist/svelte/components/ContentList.svelte +941 -30
- package/dist/svelte/components/ContentList.svelte.d.ts +44 -1
- package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.d.ts +98 -1
- package/dist/svelte/content-list-controller.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.js +290 -19
- package/dist/svelte/content-list-query.d.ts +498 -0
- package/dist/svelte/content-list-query.d.ts.map +1 -0
- package/dist/svelte/content-list-query.js +1294 -0
- package/dist/svelte/content-list-saved-views.d.ts +172 -0
- package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
- package/dist/svelte/content-list-saved-views.js +298 -0
- package/dist/svelte/content-list-url-state.d.ts +211 -0
- package/dist/svelte/content-list-url-state.d.ts.map +1 -0
- package/dist/svelte/content-list-url-state.js +856 -0
- package/dist/svelte/i18n.contribution.d.ts +24 -0
- package/dist/svelte/i18n.contribution.d.ts.map +1 -1
- package/dist/svelte/i18n.contribution.js +26 -0
- package/dist/svelte/index.d.ts +5 -1
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +8 -1
- package/package.json +16 -15
|
@@ -1,5 +1,41 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { DataTableViewState as ContentListDataTableViewState } from '@happyvertical/smrt-ui/data';
|
|
3
|
+
import type { ContentListUrlStateOptions } from '../content-list-url-state.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Optional, router-agnostic URL state (#2452).
|
|
7
|
+
*
|
|
8
|
+
* `ContentList` never imports a router: it reads `params` once during
|
|
9
|
+
* initialization and hands the merged parameters back through `onChange`, so a
|
|
10
|
+
* SvelteKit host calls `replaceState`, a hash router rewrites the fragment, and
|
|
11
|
+
* a test passes a plain `URLSearchParams`.
|
|
12
|
+
*/
|
|
13
|
+
export interface ContentListUrlStateBinding {
|
|
14
|
+
/**
|
|
15
|
+
* Query parameters to restore the view from. Read once, at initialization —
|
|
16
|
+
* later navigation is the host's to drive (re-key the component to re-read).
|
|
17
|
+
*/
|
|
18
|
+
params?: URLSearchParams | string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Receives the full merged parameter set — every foreign parameter
|
|
21
|
+
* preserved — whenever the query-affecting state changes. Never called for
|
|
22
|
+
* the initial restore.
|
|
23
|
+
*/
|
|
24
|
+
onChange?: (
|
|
25
|
+
params: URLSearchParams,
|
|
26
|
+
state: ContentListDataTableViewState,
|
|
27
|
+
) => void;
|
|
28
|
+
/** Prefix and default page size, forwarded to the URL-state module. */
|
|
29
|
+
options?: ContentListUrlStateOptions;
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
1
33
|
<script lang="ts">
|
|
2
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
DataTable,
|
|
36
|
+
type DataTableColumn,
|
|
37
|
+
type DataTableViewState,
|
|
38
|
+
} from '@happyvertical/smrt-ui/data';
|
|
3
39
|
import { ConfirmDialog } from '@happyvertical/smrt-ui/feedback';
|
|
4
40
|
import { Checkbox, Input, Select } from '@happyvertical/smrt-ui/forms';
|
|
5
41
|
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
@@ -23,23 +59,65 @@ import {
|
|
|
23
59
|
contentStateVariant,
|
|
24
60
|
contentStatusVariant,
|
|
25
61
|
createContentListController,
|
|
62
|
+
CONTENT_LIST_STATUS_OPTIONS,
|
|
63
|
+
CONTENT_LIST_TYPE_OPTIONS,
|
|
64
|
+
CONTENT_LIST_UNREPRESENTABLE_OPTION,
|
|
65
|
+
type ContentListSelectFilterState,
|
|
26
66
|
isContentListFilterExactly,
|
|
27
|
-
|
|
67
|
+
normalizeContentListTypeLock,
|
|
28
68
|
paginateContentListRows,
|
|
29
|
-
|
|
69
|
+
readContentListSelectFilter,
|
|
30
70
|
resolveContentHref,
|
|
31
71
|
selectableContentListRowIds,
|
|
32
72
|
selectContentListRows,
|
|
33
73
|
toContentListRows,
|
|
34
74
|
} from '../content-list-controller.js';
|
|
75
|
+
import {
|
|
76
|
+
CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE,
|
|
77
|
+
type ContentListQueryDrop,
|
|
78
|
+
type ContentListQueryDropReason,
|
|
79
|
+
type ContentListQueryNotices,
|
|
80
|
+
type ContentListQueryRequestOptions,
|
|
81
|
+
type ContentListQuerySource,
|
|
82
|
+
contentListQueryErrorMessage,
|
|
83
|
+
contentListQueryExactTotal,
|
|
84
|
+
contentListQueryRowsToContents,
|
|
85
|
+
contentListQueryTotalValue,
|
|
86
|
+
CONTENT_LIST_QUERY_MAX_OFFSET,
|
|
87
|
+
contentListViewStateToDataQueryRequest,
|
|
88
|
+
readContentListQueryNotices,
|
|
89
|
+
resolveContentListMaxPageSize,
|
|
90
|
+
} from '../content-list-query.js';
|
|
91
|
+
import {
|
|
92
|
+
type ContentListSavedView,
|
|
93
|
+
type ContentListSavedViewStore,
|
|
94
|
+
restoreContentListSavedView,
|
|
95
|
+
toContentListSavedViewInput,
|
|
96
|
+
} from '../content-list-saved-views.js';
|
|
97
|
+
import {
|
|
98
|
+
applyContentListViewState,
|
|
99
|
+
type ContentListStateDrop,
|
|
100
|
+
type ContentListStateDropReason,
|
|
101
|
+
type ContentListStateValidationOptions,
|
|
102
|
+
mergeContentListViewStateIntoSearchParams,
|
|
103
|
+
readContentListViewStateFromSearchParams,
|
|
104
|
+
} from '../content-list-url-state.js';
|
|
35
105
|
import { M } from '../i18n.contribution.js';
|
|
36
106
|
import ImageThumbnail from './ImageThumbnail.svelte';
|
|
37
107
|
|
|
38
108
|
const { t } = useI18n();
|
|
39
109
|
|
|
110
|
+
/** One reported refusal, from a restore or from the query translation. */
|
|
111
|
+
type ContentListDropNotice = ContentListStateDrop | ContentListQueryDrop;
|
|
112
|
+
|
|
40
113
|
interface Props {
|
|
41
114
|
apiBaseUrl?: string;
|
|
42
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Client-side rows. Ignored when `query` is supplied — the server then owns
|
|
117
|
+
* filtering, sorting, and paging, and these rows would be a second, disagreeing
|
|
118
|
+
* source of truth.
|
|
119
|
+
*/
|
|
120
|
+
contents?: ContentData[];
|
|
43
121
|
type?: string;
|
|
44
122
|
defaultViewMode?: ContentListViewMode;
|
|
45
123
|
onEdit: (content: ContentData) => void;
|
|
@@ -55,11 +133,22 @@ interface Props {
|
|
|
55
133
|
onRetry?: () => void;
|
|
56
134
|
/** Opt-in agent addressability. Non-table presentations land with #2456. */
|
|
57
135
|
dataSurface?: ContentListDataSurface;
|
|
136
|
+
/**
|
|
137
|
+
* Opt-in server-backed rows (#2452). `bind()` is called exactly once, during
|
|
138
|
+
* initialization, so a `remoteQuery(...)` binding is disposed with this
|
|
139
|
+
* component. Supplying it switches the list into server mode: `contents` is
|
|
140
|
+
* ignored and the local select/paginate transform never runs.
|
|
141
|
+
*/
|
|
142
|
+
query?: ContentListQuerySource;
|
|
143
|
+
/** Opt-in shareable URL state. The host owns navigation. */
|
|
144
|
+
urlState?: ContentListUrlStateBinding;
|
|
145
|
+
/** Opt-in saved views. `createContentListSavedViewStore()` is the default store. */
|
|
146
|
+
savedViews?: ContentListSavedViewStore;
|
|
58
147
|
}
|
|
59
148
|
|
|
60
149
|
let {
|
|
61
150
|
apiBaseUrl = '/api/v1',
|
|
62
|
-
contents,
|
|
151
|
+
contents = [],
|
|
63
152
|
type = undefined,
|
|
64
153
|
defaultViewMode = 'grid',
|
|
65
154
|
onEdit,
|
|
@@ -71,19 +160,175 @@ let {
|
|
|
71
160
|
error = null,
|
|
72
161
|
onRetry = undefined,
|
|
73
162
|
dataSurface = undefined,
|
|
163
|
+
query = undefined,
|
|
164
|
+
urlState = undefined,
|
|
165
|
+
savedViews = undefined,
|
|
74
166
|
}: Props = $props();
|
|
75
167
|
|
|
168
|
+
const initialQuery = untrack(() => query);
|
|
169
|
+
|
|
76
170
|
// One controller owns search, filters, sorting, paging, and selection for
|
|
77
171
|
// every presentation. The view mode lives beside it, so switching presentation
|
|
78
172
|
// never touches query or selection state.
|
|
79
173
|
// The seed is intentionally the initial `type`; the effect below keeps the
|
|
80
174
|
// locked filter in sync afterwards.
|
|
175
|
+
const initialUrlState = untrack(() => urlState);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* THE page-size ceiling. Resolved once, from every configured limit, and used
|
|
179
|
+
* by the controller seed, the URL sanitizer, the saved-view sanitizer and the
|
|
180
|
+
* translator — so the size the UI pages by and the size the server applies are
|
|
181
|
+
* the same number by construction rather than by coincidence.
|
|
182
|
+
*
|
|
183
|
+
* Every candidate narrows (`Math.min`, inside `resolveContentListMaxPageSize`):
|
|
184
|
+
* a host that sets `query.request.maxPageSize` as a server row budget must not
|
|
185
|
+
* have it discarded because a looser `urlState.options.maxPageSize` also exists.
|
|
186
|
+
*/
|
|
187
|
+
const maxPageSize = resolveContentListMaxPageSize(
|
|
188
|
+
initialUrlState?.options?.maxPageSize,
|
|
189
|
+
initialQuery?.request?.maxPageSize,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The page size a server-backed list runs at, clamped to that ceiling.
|
|
194
|
+
*
|
|
195
|
+
* `null` (unpaginated) is only expressible locally: the query endpoint always
|
|
196
|
+
* applies a limit, so an unpaginated server list would render one silent page
|
|
197
|
+
* with no controls. This value is the seed, the URL layer's notion of the
|
|
198
|
+
* default (so a link without `size` restores it rather than wiping it), and the
|
|
199
|
+
* translator's fallback — one number in all three places. Clamping the seed
|
|
200
|
+
* matters on its own: a `defaultPageSize` above the ceiling would seed a page
|
|
201
|
+
* size the request then silently reduces, and `totalPages` would compute 1.
|
|
202
|
+
*/
|
|
203
|
+
const serverPageSize = initialQuery
|
|
204
|
+
? Math.min(
|
|
205
|
+
Math.max(
|
|
206
|
+
1,
|
|
207
|
+
Math.floor(
|
|
208
|
+
initialQuery.request?.defaultPageSize ??
|
|
209
|
+
CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE,
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
maxPageSize,
|
|
213
|
+
)
|
|
214
|
+
: null;
|
|
215
|
+
|
|
216
|
+
/** Validation options shared by the URL and saved-view restore paths. */
|
|
217
|
+
const restoreOptions: ContentListStateValidationOptions = { maxPageSize };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* URL options with the server page size filled in as the default, so a link
|
|
221
|
+
* that omits `size` restores the seed instead of the local `null`, and a link
|
|
222
|
+
* this list writes omits `size` while the list is at its default.
|
|
223
|
+
*/
|
|
224
|
+
const urlStateOptions: ContentListUrlStateOptions = {
|
|
225
|
+
...initialUrlState?.options,
|
|
226
|
+
maxPageSize,
|
|
227
|
+
...(serverPageSize !== null &&
|
|
228
|
+
initialUrlState?.options?.defaultPageSize === undefined
|
|
229
|
+
? { defaultPageSize: serverPageSize }
|
|
230
|
+
: {}),
|
|
231
|
+
};
|
|
232
|
+
|
|
81
233
|
const controller = createContentListController({
|
|
82
234
|
type: untrack(() => type),
|
|
235
|
+
// Local mode keeps the historical unpaginated list.
|
|
236
|
+
...(serverPageSize === null ? {} : { pageSize: serverPageSize }),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
/** Everything a restore or a translation refused, surfaced as one notice. */
|
|
240
|
+
let restoreDrops = $state<ContentListDropNotice[]>([]);
|
|
241
|
+
let queryDrops = $state<ContentListQueryDrop[]>([]);
|
|
242
|
+
/**
|
|
243
|
+
* A capped-offset redirect, held apart from `queryDrops` so it survives the
|
|
244
|
+
* corrective re-translation that immediately follows it.
|
|
245
|
+
*/
|
|
246
|
+
let pageCapDrop = $state<ContentListQueryDrop | null>(null);
|
|
247
|
+
/** Which page the last offset cap redirected to. Named in the notice. */
|
|
248
|
+
let pageCapCorrectedTo = $state<number | undefined>(undefined);
|
|
249
|
+
let dismissedDropKey = $state('');
|
|
250
|
+
let resultNotices = $state<ContentListQueryNotices>({
|
|
251
|
+
truncated: false,
|
|
252
|
+
warnings: [],
|
|
83
253
|
});
|
|
254
|
+
/**
|
|
255
|
+
* The query signature the binding's rows and total currently describe.
|
|
256
|
+
*
|
|
257
|
+
* A total is only authoritative for the query that produced it. `remoteQuery`
|
|
258
|
+
* keeps serving the PREVIOUS query's total while a new request is in flight, so
|
|
259
|
+
* a saved view or a programmatic patch that changes the query AND restores a
|
|
260
|
+
* page past the old query's last one would have that page clamped away before
|
|
261
|
+
* its own total ever arrived.
|
|
262
|
+
*/
|
|
263
|
+
let settledSignature = $state<string | undefined>(undefined);
|
|
264
|
+
|
|
265
|
+
function toSearchParams(
|
|
266
|
+
input: URLSearchParams | string | null | undefined,
|
|
267
|
+
): URLSearchParams {
|
|
268
|
+
if (!input) return new URLSearchParams();
|
|
269
|
+
return new URLSearchParams(input);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The URL restore runs before the first snapshot is taken, so the initial
|
|
273
|
+
// render is already the restored view rather than a flash of the default one.
|
|
274
|
+
// `applyContentListViewState` merges over current state instead of dispatching
|
|
275
|
+
// `setSearch`/`setFilters`, which would reset the restored page.
|
|
276
|
+
// An empty query string is a valid binding, not an absent one: the reader
|
|
277
|
+
// still applies the configured defaults, so a page opened at a bare path must
|
|
278
|
+
// restore the same way one opened with parameters does.
|
|
279
|
+
if (initialUrlState?.params !== undefined && initialUrlState?.params !== null) {
|
|
280
|
+
const reading = readContentListViewStateFromSearchParams(
|
|
281
|
+
toSearchParams(initialUrlState.params),
|
|
282
|
+
urlStateOptions,
|
|
283
|
+
);
|
|
284
|
+
// A restore REPLACES the filter set, including the type filter the controller
|
|
285
|
+
// was seeded with. On a locked list the lock effect would then re-apply it
|
|
286
|
+
// through `setFilters`, which resets paging — silently discarding a `?page=`
|
|
287
|
+
// the same link just restored. Folding the lock into the restored patch keeps
|
|
288
|
+
// it to one `replaceState`, and leaves the effect's first run a no-op.
|
|
289
|
+
const initialLockedType = normalizeContentListTypeLock(untrack(() => type));
|
|
290
|
+
const patch =
|
|
291
|
+
initialLockedType === null
|
|
292
|
+
? reading.state
|
|
293
|
+
: {
|
|
294
|
+
...reading.state,
|
|
295
|
+
filters: [
|
|
296
|
+
...(reading.state.filters ?? []).filter(
|
|
297
|
+
(filter) => filter.columnId !== CONTENT_LIST_TYPE_FILTER_ID,
|
|
298
|
+
),
|
|
299
|
+
{
|
|
300
|
+
columnId: CONTENT_LIST_TYPE_FILTER_ID,
|
|
301
|
+
operator: 'equals' as const,
|
|
302
|
+
value: initialLockedType,
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
};
|
|
306
|
+
applyContentListViewState(controller, patch, restoreOptions);
|
|
307
|
+
restoreDrops = reading.dropped;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The server-query binding, created once. `bind()` runs inside this
|
|
312
|
+
* component's initialization, so a binding that registers an `$effect` teardown
|
|
313
|
+
* (as `remoteQuery` does) is disposed when this component is.
|
|
314
|
+
*/
|
|
315
|
+
const queryBinding = initialQuery?.bind();
|
|
316
|
+
const queryRequestOptions: ContentListQueryRequestOptions | undefined =
|
|
317
|
+
initialQuery
|
|
318
|
+
? {
|
|
319
|
+
...initialQuery.request,
|
|
320
|
+
maxPageSize,
|
|
321
|
+
...(serverPageSize === null ? {} : { defaultPageSize: serverPageSize }),
|
|
322
|
+
}
|
|
323
|
+
: undefined;
|
|
324
|
+
const serverBacked = queryBinding !== undefined;
|
|
325
|
+
|
|
84
326
|
let snapshot = $state(controller.snapshot());
|
|
85
327
|
let viewMode: ContentListViewMode = $state(untrack(() => defaultViewMode));
|
|
86
328
|
let pendingDelete = $state<ContentListRow | null>(null);
|
|
329
|
+
let savedViewList = $state<ContentListSavedView[]>([]);
|
|
330
|
+
let selectedSavedViewId = $state('');
|
|
331
|
+
let savedViewName = $state('');
|
|
87
332
|
|
|
88
333
|
$effect(() =>
|
|
89
334
|
controller.subscribe((transition) => {
|
|
@@ -93,23 +338,70 @@ $effect(() =>
|
|
|
93
338
|
|
|
94
339
|
const tableState = $derived(snapshot.state);
|
|
95
340
|
|
|
341
|
+
// In server mode the controller's page size must be a number the request can
|
|
342
|
+
// actually carry, or `totalPages` and `showPagination` describe a page the
|
|
343
|
+
// server never returned and rows are stranded behind a plausible-looking single
|
|
344
|
+
// page. Two ways in, both enforced against LIVE state because a saved view, a
|
|
345
|
+
// link, and a data-surface `set-page-size` all arrive after mount:
|
|
346
|
+
//
|
|
347
|
+
// null → unpaginated, which the endpoint cannot express;
|
|
348
|
+
// > maxPageSize → the translator clamps the request, so leaving the
|
|
349
|
+
// controller above the ceiling makes the two disagree.
|
|
350
|
+
//
|
|
351
|
+
// `setPageSize` also resets the page, which is correct: a different page size
|
|
352
|
+
// means the old page number addresses different rows.
|
|
353
|
+
$effect(() => {
|
|
354
|
+
if (serverPageSize === null) return;
|
|
355
|
+
const current = tableState.pageSize;
|
|
356
|
+
if (current !== null && current <= maxPageSize) return;
|
|
357
|
+
const next = current === null ? serverPageSize : maxPageSize;
|
|
358
|
+
const reason: ContentListQueryDropReason =
|
|
359
|
+
current === null ? 'unpaginated-unsupported' : 'out-of-range';
|
|
360
|
+
untrack(() => {
|
|
361
|
+
controller.dispatch({ type: 'setPageSize', pageSize: next });
|
|
362
|
+
restoreDrops = [
|
|
363
|
+
...restoreDrops.filter(
|
|
364
|
+
(drop) =>
|
|
365
|
+
drop.scope !== 'pageSize' ||
|
|
366
|
+
(drop.reason !== 'unpaginated-unsupported' &&
|
|
367
|
+
drop.reason !== 'out-of-range'),
|
|
368
|
+
),
|
|
369
|
+
{
|
|
370
|
+
scope: 'pageSize',
|
|
371
|
+
reason,
|
|
372
|
+
...(current === null ? {} : { detail: String(current) }),
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
96
378
|
/** The normalized type the `type` prop locks the list to, if any. */
|
|
97
|
-
const lockedType = $derived(
|
|
379
|
+
const lockedType = $derived(normalizeContentListTypeLock(type));
|
|
98
380
|
|
|
99
381
|
// A `type` prop locks the type filter, exactly as the legacy select did. The
|
|
100
382
|
// lock is enforced against the live state, not only against the prop, because a
|
|
101
383
|
// data-surface `set-filters` or `reset` command can otherwise replace or clear
|
|
102
384
|
// it. The equality guard keeps the effect from dispatching in a loop.
|
|
385
|
+
// Tracks the previous prop so an unlocked list can tell "the lock was just
|
|
386
|
+
// removed" from "there was never a lock". A plain binding, so writing it here
|
|
387
|
+
// cannot re-trigger the effect.
|
|
388
|
+
let previousLockedType: string | null = untrack(() => lockedType);
|
|
389
|
+
|
|
103
390
|
$effect(() => {
|
|
104
391
|
const locked = lockedType;
|
|
105
392
|
if (locked === null) {
|
|
106
|
-
|
|
107
|
-
|
|
393
|
+
const lockWasRemoved = previousLockedType !== null;
|
|
394
|
+
previousLockedType = null;
|
|
395
|
+
// Unlocked: the toolbar select owns the filter. Clear it only when the prop
|
|
396
|
+
// actually went away, because clearing on every run would also discard a
|
|
397
|
+
// type filter restored from a link or a saved view.
|
|
398
|
+
if (!lockWasRemoved) return;
|
|
108
399
|
untrack(() =>
|
|
109
400
|
applyContentListFilter(controller, CONTENT_LIST_TYPE_FILTER_ID, null),
|
|
110
401
|
);
|
|
111
402
|
return;
|
|
112
403
|
}
|
|
404
|
+
previousLockedType = locked;
|
|
113
405
|
if (
|
|
114
406
|
isContentListFilterExactly(tableState, CONTENT_LIST_TYPE_FILTER_ID, locked)
|
|
115
407
|
)
|
|
@@ -131,31 +423,256 @@ const columnLabels = $derived({
|
|
|
131
423
|
});
|
|
132
424
|
|
|
133
425
|
const queryColumns = $derived(buildContentListColumns(columnLabels));
|
|
134
|
-
const
|
|
426
|
+
const sourceContents = $derived(
|
|
427
|
+
queryBinding ? contentListQueryRowsToContents(queryBinding.rows) : contents,
|
|
428
|
+
);
|
|
429
|
+
const rows = $derived(toContentListRows(sourceContents));
|
|
430
|
+
// In server mode the returned rows ARE the answer: the server already applied
|
|
431
|
+
// the search, the filters, the sort, and the page. Running the local transform
|
|
432
|
+
// over them again would re-filter with subtly different semantics (untrimmed
|
|
433
|
+
// search, case-insensitive comparison, a `site` predicate the server never saw)
|
|
434
|
+
// and could hide rows the server deliberately returned.
|
|
135
435
|
const queryRows = $derived(
|
|
136
|
-
selectContentListRows(rows, tableState, queryColumns),
|
|
436
|
+
serverBacked ? rows : selectContentListRows(rows, tableState, queryColumns),
|
|
437
|
+
);
|
|
438
|
+
const pageRows = $derived(
|
|
439
|
+
serverBacked ? rows : paginateContentListRows(queryRows, tableState),
|
|
440
|
+
);
|
|
441
|
+
/** Server row count for the whole query, not just the rendered page. */
|
|
442
|
+
const serverTotal = $derived(
|
|
443
|
+
queryBinding ? contentListQueryTotalValue(queryBinding.total) : undefined,
|
|
444
|
+
);
|
|
445
|
+
const totalRowCount = $derived(
|
|
446
|
+
serverBacked ? (serverTotal ?? rows.length) : queryRows.length,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The row count the current page may be clamped against, or `undefined` when
|
|
451
|
+
* no authoritative count exists.
|
|
452
|
+
*
|
|
453
|
+
* Clamping MOVES the operator, so every input has to be judged on whether it is
|
|
454
|
+
* exactly right — twice now a clamp has acted on a number that was not the
|
|
455
|
+
* total. The complete set:
|
|
456
|
+
*
|
|
457
|
+
* | Input | Authoritative? |
|
|
458
|
+
* |---|---|
|
|
459
|
+
* | local mode row count | yes — the supplied array IS the whole result set |
|
|
460
|
+
* | server total, `exact` | yes |
|
|
461
|
+
* | server total, `estimated` | NO — clamping on an approximation can hide a page that really exists |
|
|
462
|
+
* | server total, `unavailable` | NO — the count is unknown, and `rows.length` is the page, not the total |
|
|
463
|
+
* | no response yet for this query | NO — a page restored from a link must survive until its own count arrives |
|
|
464
|
+
* | a settled response for a DIFFERENT query | NO — the binding still holds the previous query's total while a new request is in flight |
|
|
465
|
+
* | a page-size change | n/a — `setPageSize` resets the page itself, so it can never strand an out-of-range one |
|
|
466
|
+
*
|
|
467
|
+
* `pageableRowCount` deliberately keeps using the looser
|
|
468
|
+
* `contentListQueryTotalValue`: an estimate is fine for SHOWING a pager, and
|
|
469
|
+
* over-offering a page is visible and self-correcting where hiding one is not.
|
|
470
|
+
*/
|
|
471
|
+
const clampableRowCount = $derived(
|
|
472
|
+
serverBacked
|
|
473
|
+
? contentListQueryExactTotal(queryBinding?.total)
|
|
474
|
+
: queryRows.length,
|
|
137
475
|
);
|
|
138
|
-
const pageRows = $derived(paginateContentListRows(queryRows, tableState));
|
|
139
476
|
|
|
140
477
|
// The adapter owns filtering, sorting, and paging, so the controller's page has
|
|
141
|
-
// to be clamped against the
|
|
478
|
+
// to be clamped against the result count rather than DataTable's. In server
|
|
479
|
+
// mode that count is the server's total — clamping against the page length
|
|
480
|
+
// would collapse every list to a single page.
|
|
142
481
|
$effect(() => {
|
|
143
|
-
const totalRows =
|
|
482
|
+
const totalRows = clampableRowCount;
|
|
483
|
+
// Both signatures are read as dependencies on purpose: the effect must re-run
|
|
484
|
+
// when the SETTLED query changes even if the new query's count happens to
|
|
485
|
+
// equal the old one's, or a stale page survives on a coincidence.
|
|
486
|
+
const settled = settledSignature;
|
|
487
|
+
const signature = querySignature;
|
|
488
|
+
if (totalRows === undefined) return;
|
|
489
|
+
if (serverBacked && settled !== signature) return;
|
|
490
|
+
// Deliberately the TRUE total, not `pageableRowCount`: clamping to the
|
|
491
|
+
// reachable ceiling here would silently move a crafted `?page=9000` before
|
|
492
|
+
// the query effect ever sees it, and the operator would never be told why
|
|
493
|
+
// they landed somewhere else.
|
|
144
494
|
untrack(() => controller.clampPage(totalRows));
|
|
145
495
|
});
|
|
146
496
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
497
|
+
/**
|
|
498
|
+
* The last page a server query can actually fetch.
|
|
499
|
+
*
|
|
500
|
+
* Offset paging stops at `CONTENT_LIST_QUERY_MAX_OFFSET`, so on a very large
|
|
501
|
+
* list the arithmetic total implies pages the endpoint will never return.
|
|
502
|
+
* Advertising them is worse than not offering them: every click past the
|
|
503
|
+
* boundary silently lands back on the same page.
|
|
504
|
+
*/
|
|
505
|
+
const maxReachablePage = $derived(
|
|
506
|
+
serverBacked && tableState.pageSize
|
|
507
|
+
? Math.floor(CONTENT_LIST_QUERY_MAX_OFFSET / tableState.pageSize) + 1
|
|
508
|
+
: Number.POSITIVE_INFINITY,
|
|
509
|
+
);
|
|
510
|
+
/**
|
|
511
|
+
* The row count the pagers may page over. DataTable derives its own page count
|
|
512
|
+
* from `totalRows` and takes no ceiling, so the ceiling has to be applied to
|
|
513
|
+
* the number handed to it. `clampPage` deliberately keeps using the true total
|
|
514
|
+
* (see below).
|
|
515
|
+
*/
|
|
516
|
+
const pageableRowCount = $derived(
|
|
517
|
+
tableState.pageSize
|
|
518
|
+
? Math.min(totalRowCount, maxReachablePage * tableState.pageSize)
|
|
519
|
+
: totalRowCount,
|
|
520
|
+
);
|
|
521
|
+
// EVERY presentation renders its own page controls, compact included, and
|
|
522
|
+
// `totalRows` is deliberately never handed to DataTable.
|
|
523
|
+
//
|
|
524
|
+
// DataTable runs its own `clampPage(totalRows)` effect against the SAME
|
|
525
|
+
// controller, with no authority rule and no notion of which query a total
|
|
526
|
+
// belongs to. One prop cannot serve both purposes: it drives that clamp AND
|
|
527
|
+
// DataTable's pager, so any total authoritative enough to clamp against is also
|
|
528
|
+
// the only total the pager can show, and vice versa. Passing an
|
|
529
|
+
// authoritative-only total silences the clamp correctly but leaves compact with
|
|
530
|
+
// no pager on an `estimated` total while the card modes still show one — the
|
|
531
|
+
// two modes would then disagree about which pages exist, which is a worse bug
|
|
532
|
+
// than the one being fixed.
|
|
533
|
+
//
|
|
534
|
+
// So ContentList owns paging outright: one clamp (the effect above, with the
|
|
535
|
+
// authority rule) and one pager (below, driven by `pageableRowCount`, which
|
|
536
|
+
// accepts an estimate because SHOWING a page is a different question from
|
|
537
|
+
// MOVING the operator). The same reasoning already made the selection column
|
|
538
|
+
// content-owned in compact mode.
|
|
151
539
|
const totalPages = $derived(
|
|
152
540
|
tableState.pageSize
|
|
153
|
-
? Math.max(1, Math.ceil(
|
|
541
|
+
? Math.max(1, Math.ceil(pageableRowCount / tableState.pageSize))
|
|
154
542
|
: 1,
|
|
155
543
|
);
|
|
156
544
|
const showPagination = $derived(Boolean(tableState.pageSize) && totalPages > 1);
|
|
545
|
+
const queryErrorMessage = $derived(
|
|
546
|
+
queryBinding ? contentListQueryErrorMessage(queryBinding.error) : null,
|
|
547
|
+
);
|
|
548
|
+
/** A host-supplied error still wins: it describes the surrounding page load. */
|
|
549
|
+
const activeError = $derived(error ?? queryErrorMessage);
|
|
550
|
+
const isLoading = $derived(loading || (queryBinding?.loading ?? false));
|
|
157
551
|
/** Rows are already rendered, so a load is a refresh rather than a first fill. */
|
|
158
|
-
const refreshing = $derived(
|
|
552
|
+
const refreshing = $derived(
|
|
553
|
+
(queryBinding?.refreshing ?? false) || (isLoading && pageRows.length > 0),
|
|
554
|
+
);
|
|
555
|
+
/**
|
|
556
|
+
* A retry re-reads the same query, so its answer replaces the rendered rows —
|
|
557
|
+
* and therefore has to replace the completeness flags that describe them too.
|
|
558
|
+
* Discarding the envelope here is what left a "rows are missing" notice
|
|
559
|
+
* standing over a page that came back complete (and the inverse after a
|
|
560
|
+
* transient error). The signature does not change on a retry, so the query
|
|
561
|
+
* effect never re-runs and this is the only place that can refresh them.
|
|
562
|
+
*/
|
|
563
|
+
function retryQuery(): void {
|
|
564
|
+
if (!queryBinding) return;
|
|
565
|
+
const signature = executedSignature;
|
|
566
|
+
void queryBinding
|
|
567
|
+
.retry()
|
|
568
|
+
.then((result) => {
|
|
569
|
+
// `retry()` resolves undefined when there is no request to repeat, and a
|
|
570
|
+
// newer query may have superseded this one while it was in flight.
|
|
571
|
+
if (result === undefined || executedSignature !== signature) return;
|
|
572
|
+
resultNotices = readContentListQueryNotices(result);
|
|
573
|
+
})
|
|
574
|
+
.catch(() => undefined);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const retryHandler = $derived(
|
|
578
|
+
onRetry ?? (queryBinding ? retryQuery : undefined),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* A signature of exactly the query-affecting state. Selection and expansion are
|
|
583
|
+
* excluded on purpose: checking a row must not re-run the server query. The
|
|
584
|
+
* value is a primitive, so an unrelated transition that leaves the query
|
|
585
|
+
* unchanged does not propagate.
|
|
586
|
+
*/
|
|
587
|
+
const querySignature = $derived(
|
|
588
|
+
JSON.stringify([
|
|
589
|
+
tableState.search,
|
|
590
|
+
tableState.filters,
|
|
591
|
+
tableState.sorting,
|
|
592
|
+
tableState.page,
|
|
593
|
+
tableState.pageSize,
|
|
594
|
+
]),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
let executedSignature: string | undefined;
|
|
598
|
+
let publishedUrlSignature: string | undefined;
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
$effect(() => {
|
|
602
|
+
const signature = querySignature;
|
|
603
|
+
untrack(() => {
|
|
604
|
+
const state = controller.getState();
|
|
605
|
+
if (queryBinding && signature !== executedSignature) {
|
|
606
|
+
executedSignature = signature;
|
|
607
|
+
const translated = contentListViewStateToDataQueryRequest(
|
|
608
|
+
state,
|
|
609
|
+
queryRequestOptions,
|
|
610
|
+
);
|
|
611
|
+
// The page-cap drop is held separately because it must OUTLIVE this
|
|
612
|
+
// translation: the corrective dispatch below re-enters this effect, and
|
|
613
|
+
// the second translation caps nothing, so a drop stored here would be
|
|
614
|
+
// overwritten in the same flush and the redirect would be silent.
|
|
615
|
+
queryDrops = translated.dropped.filter((drop) => drop.scope !== 'page');
|
|
616
|
+
if (translated.effectivePage !== state.page) {
|
|
617
|
+
// The offset had to be capped. Move the controller's page marker to the
|
|
618
|
+
// page the request actually reads, or the UI labels this answer with a
|
|
619
|
+
// page number the server never saw. The dispatch re-enters this effect
|
|
620
|
+
// with the corrected signature, which then executes.
|
|
621
|
+
pageCapDrop = {
|
|
622
|
+
scope: 'page',
|
|
623
|
+
reason: 'out-of-range',
|
|
624
|
+
detail: String(state.page),
|
|
625
|
+
};
|
|
626
|
+
pageCapCorrectedTo = translated.effectivePage;
|
|
627
|
+
controller.dispatch({
|
|
628
|
+
type: 'setPage',
|
|
629
|
+
page: translated.effectivePage,
|
|
630
|
+
});
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// The redirect notice stands until the operator moves off the page they
|
|
634
|
+
// were redirected to; clearing it on the corrective re-run would be the
|
|
635
|
+
// same silence by a different route.
|
|
636
|
+
if (pageCapDrop !== null && state.page !== pageCapCorrectedTo) {
|
|
637
|
+
pageCapDrop = null;
|
|
638
|
+
pageCapCorrectedTo = undefined;
|
|
639
|
+
}
|
|
640
|
+
// The binding owns cancellation of a superseded run and already reflects
|
|
641
|
+
// a failure in its error state, so a rejection here is not also an
|
|
642
|
+
// unhandled one. The resolved envelope carries the server's completeness
|
|
643
|
+
// flags, which the binding itself does not expose.
|
|
644
|
+
void queryBinding
|
|
645
|
+
.execute(translated.request)
|
|
646
|
+
.then((result) => {
|
|
647
|
+
// A newer query may have superseded this one while it was in flight.
|
|
648
|
+
if (executedSignature !== signature) return;
|
|
649
|
+
// The binding's rows and total now describe THIS query, so the page
|
|
650
|
+
// may be clamped against them.
|
|
651
|
+
settledSignature = signature;
|
|
652
|
+
resultNotices = readContentListQueryNotices(result);
|
|
653
|
+
})
|
|
654
|
+
.catch(() => undefined);
|
|
655
|
+
}
|
|
656
|
+
if (publishedUrlSignature === undefined) {
|
|
657
|
+
// The first pass adopts whatever the restore produced without pushing it
|
|
658
|
+
// back at the host as a navigation.
|
|
659
|
+
publishedUrlSignature = signature;
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (signature === publishedUrlSignature) return;
|
|
663
|
+
publishedUrlSignature = signature;
|
|
664
|
+
const binding = urlState;
|
|
665
|
+
if (!binding?.onChange) return;
|
|
666
|
+
binding.onChange(
|
|
667
|
+
mergeContentListViewStateIntoSearchParams(
|
|
668
|
+
toSearchParams(binding.params),
|
|
669
|
+
state,
|
|
670
|
+
urlStateOptions,
|
|
671
|
+
),
|
|
672
|
+
state as DataTableViewState,
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
159
676
|
|
|
160
677
|
const selectedRowKeys = $derived(
|
|
161
678
|
new Set(tableState.selectedRowIds.map((rowId) => String(rowId))),
|
|
@@ -175,13 +692,27 @@ const somePageSelected = $derived(
|
|
|
175
692
|
);
|
|
176
693
|
const selectedCount = $derived(tableState.selectedRowIds.length);
|
|
177
694
|
|
|
695
|
+
/** Synthetic ids the adapter minted for rows that carry no durable identity. */
|
|
696
|
+
const unidentifiedRowKeys = $derived(
|
|
697
|
+
new Set(
|
|
698
|
+
rows.filter((row) => !row.identified).map((row) => String(row.id)),
|
|
699
|
+
),
|
|
700
|
+
);
|
|
701
|
+
|
|
178
702
|
// DataTable's own selection column and data-surface commands can both introduce
|
|
179
703
|
// ids for rows that carry no durable identity. Normalizing here covers every
|
|
180
704
|
// path at once; re-dispatching only on a real difference keeps it settling.
|
|
705
|
+
//
|
|
706
|
+
// In server mode `rows` is only the current page, so membership cannot be the
|
|
707
|
+
// test: it would silently clear the whole selection on every page change. Only
|
|
708
|
+
// the synthetic ids are stripped there, which keeps a selection addressable
|
|
709
|
+
// across pages while still refusing an unaddressable row.
|
|
181
710
|
$effect(() => {
|
|
182
711
|
const selected = tableState.selectedRowIds;
|
|
183
712
|
const durable = selected.filter((rowId) =>
|
|
184
|
-
|
|
713
|
+
serverBacked
|
|
714
|
+
? !unidentifiedRowKeys.has(String(rowId))
|
|
715
|
+
: identifiedRowKeys.has(String(rowId)),
|
|
185
716
|
);
|
|
186
717
|
if (durable.length === selected.length) return;
|
|
187
718
|
untrack(() =>
|
|
@@ -189,13 +720,105 @@ $effect(() => {
|
|
|
189
720
|
);
|
|
190
721
|
});
|
|
191
722
|
|
|
723
|
+
/**
|
|
724
|
+
* What each toolbar select may display, and whether it can display the live
|
|
725
|
+
* predicate at all.
|
|
726
|
+
*
|
|
727
|
+
* INVARIANT: the select's displayed state either matches the live predicate
|
|
728
|
+
* exactly, or the operator is told it does not. Three states, and all three are
|
|
729
|
+
* reachable from a shared link:
|
|
730
|
+
*
|
|
731
|
+
* - representable and inside the vocabulary — the select shows it, silently;
|
|
732
|
+
* - representable but outside the vocabulary (`?status=embargoed`, or a typo)
|
|
733
|
+
* — the value is rendered as an extra option AND reported, so an empty list
|
|
734
|
+
* always has an explanation;
|
|
735
|
+
* - not representable at all (`?status.in=draft,review`, `?status.isNull=1`,
|
|
736
|
+
* `?status.notEquals=draft`) — the select shows a disabled summary of the
|
|
737
|
+
* real predicate instead of a value it is not applying, and reports it.
|
|
738
|
+
* Choosing any real option replaces every filter on that column, so the
|
|
739
|
+
* operator is never stuck.
|
|
740
|
+
*/
|
|
741
|
+
const typeFilterState = $derived(
|
|
742
|
+
readContentListSelectFilter(tableState, CONTENT_LIST_TYPE_FILTER_ID),
|
|
743
|
+
);
|
|
744
|
+
const statusFilterState = $derived(
|
|
745
|
+
readContentListSelectFilter(tableState, CONTENT_LIST_STATUS_FILTER_ID),
|
|
746
|
+
);
|
|
192
747
|
const selectedType = $derived(
|
|
193
|
-
|
|
748
|
+
typeFilterState.representable
|
|
749
|
+
? typeFilterState.value
|
|
750
|
+
: CONTENT_LIST_UNREPRESENTABLE_OPTION,
|
|
194
751
|
);
|
|
195
752
|
const selectedStatus = $derived(
|
|
196
|
-
|
|
753
|
+
statusFilterState.representable
|
|
754
|
+
? statusFilterState.value
|
|
755
|
+
: CONTENT_LIST_UNREPRESENTABLE_OPTION,
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
/** A live, representable value the select has no option for. */
|
|
759
|
+
const unlistedType = $derived(
|
|
760
|
+
typeFilterState.representable &&
|
|
761
|
+
typeFilterState.value &&
|
|
762
|
+
!(CONTENT_LIST_TYPE_OPTIONS as readonly string[]).includes(
|
|
763
|
+
typeFilterState.value,
|
|
764
|
+
)
|
|
765
|
+
? typeFilterState.value
|
|
766
|
+
: null,
|
|
767
|
+
);
|
|
768
|
+
const unlistedStatus = $derived(
|
|
769
|
+
statusFilterState.representable &&
|
|
770
|
+
statusFilterState.value &&
|
|
771
|
+
!(CONTENT_LIST_STATUS_OPTIONS as readonly string[]).includes(
|
|
772
|
+
statusFilterState.value,
|
|
773
|
+
)
|
|
774
|
+
? statusFilterState.value
|
|
775
|
+
: null,
|
|
197
776
|
);
|
|
198
777
|
|
|
778
|
+
function columnFilterDrops(
|
|
779
|
+
columnId: string,
|
|
780
|
+
unlisted: string | null,
|
|
781
|
+
filterState: ContentListSelectFilterState,
|
|
782
|
+
): ContentListQueryDrop[] {
|
|
783
|
+
if (unlisted !== null) {
|
|
784
|
+
return [
|
|
785
|
+
{
|
|
786
|
+
scope: 'filter',
|
|
787
|
+
reason: 'unlisted-value',
|
|
788
|
+
columnId,
|
|
789
|
+
detail: unlisted,
|
|
790
|
+
},
|
|
791
|
+
];
|
|
792
|
+
}
|
|
793
|
+
if (!filterState.representable) {
|
|
794
|
+
return [
|
|
795
|
+
{
|
|
796
|
+
scope: 'filter',
|
|
797
|
+
reason: 'unrepresentable-filter',
|
|
798
|
+
columnId,
|
|
799
|
+
detail: filterState.detail ?? '',
|
|
800
|
+
},
|
|
801
|
+
];
|
|
802
|
+
}
|
|
803
|
+
return [];
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const unlistedValueDrops = $derived<ContentListQueryDrop[]>([
|
|
807
|
+
// A locked list renders no type select, so there is nothing to disagree with.
|
|
808
|
+
...(lockedType !== null
|
|
809
|
+
? []
|
|
810
|
+
: columnFilterDrops(
|
|
811
|
+
CONTENT_LIST_TYPE_FILTER_ID,
|
|
812
|
+
unlistedType,
|
|
813
|
+
typeFilterState,
|
|
814
|
+
)),
|
|
815
|
+
...columnFilterDrops(
|
|
816
|
+
CONTENT_LIST_STATUS_FILTER_ID,
|
|
817
|
+
unlistedStatus,
|
|
818
|
+
statusFilterState,
|
|
819
|
+
),
|
|
820
|
+
]);
|
|
821
|
+
|
|
199
822
|
const surfaceOptions = $derived(
|
|
200
823
|
dataSurface
|
|
201
824
|
? {
|
|
@@ -207,6 +830,169 @@ const surfaceOptions = $derived(
|
|
|
207
830
|
: undefined,
|
|
208
831
|
);
|
|
209
832
|
|
|
833
|
+
// ---------------------------------------------------------------------------
|
|
834
|
+
// Restore reporting
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
|
|
837
|
+
const dropNotices = $derived<ContentListDropNotice[]>([
|
|
838
|
+
...restoreDrops,
|
|
839
|
+
...queryDrops,
|
|
840
|
+
...unlistedValueDrops,
|
|
841
|
+
...(pageCapDrop === null ? [] : [pageCapDrop]),
|
|
842
|
+
]);
|
|
843
|
+
/**
|
|
844
|
+
* What the server said about the completeness of the answer.
|
|
845
|
+
*
|
|
846
|
+
* A binding may expose the flags directly; `remoteQuery` does not, so the
|
|
847
|
+
* component falls back to the envelope its own `execute` resolved. Either way
|
|
848
|
+
* the operator has to be told: the server drops trailing rows to fit its byte
|
|
849
|
+
* budget, and the next page is computed from `page * limit`, so those rows are
|
|
850
|
+
* skipped on the following page too.
|
|
851
|
+
*/
|
|
852
|
+
const queryTruncated = $derived(
|
|
853
|
+
queryBinding?.truncated ?? resultNotices.truncated,
|
|
854
|
+
);
|
|
855
|
+
const queryWarnings = $derived<ReadonlyArray<string>>(
|
|
856
|
+
queryBinding?.warnings ?? resultNotices.warnings,
|
|
857
|
+
);
|
|
858
|
+
/** Identity of the current set of refusals, so a dismissal is not permanent. */
|
|
859
|
+
const dropNoticeKey = $derived(
|
|
860
|
+
dropNotices.length > 0 || queryTruncated || queryWarnings.length > 0
|
|
861
|
+
? JSON.stringify([dropNotices, queryTruncated, queryWarnings])
|
|
862
|
+
: '',
|
|
863
|
+
);
|
|
864
|
+
const showDropNotice = $derived(
|
|
865
|
+
dropNoticeKey !== '' && dropNoticeKey !== dismissedDropKey,
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const DROP_REASON_MESSAGES: Record<
|
|
869
|
+
ContentListStateDropReason | ContentListQueryDropReason,
|
|
870
|
+
string
|
|
871
|
+
> = {
|
|
872
|
+
'unknown-column': M['content.content_list.drop_unknown_column'],
|
|
873
|
+
'hidden-column': M['content.content_list.drop_hidden_column'],
|
|
874
|
+
'structural-column': M['content.content_list.drop_structural_column'],
|
|
875
|
+
'no-server-field': M['content.content_list.drop_no_server_field'],
|
|
876
|
+
'unsupported-operator': M['content.content_list.drop_unsupported_operator'],
|
|
877
|
+
'unsupported-value': M['content.content_list.drop_unsupported_value'],
|
|
878
|
+
malformed: M['content.content_list.drop_malformed'],
|
|
879
|
+
'out-of-range': M['content.content_list.drop_out_of_range'],
|
|
880
|
+
'filter-widened': M['content.content_list.drop_filter_widened'],
|
|
881
|
+
'unlisted-value': M['content.content_list.drop_unlisted_value'],
|
|
882
|
+
'unrepresentable-filter':
|
|
883
|
+
M['content.content_list.drop_unrepresentable_filter'],
|
|
884
|
+
'unpaginated-unsupported': M['content.content_list.drop_unpaginated'],
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
function dropNoticeText(drop: ContentListDropNotice): string {
|
|
888
|
+
// A capped offset is a redirect, not merely a refused value: the operator
|
|
889
|
+
// needs to know which page they asked for and which one they are looking at.
|
|
890
|
+
if (drop.reason === 'unlisted-value') {
|
|
891
|
+
return t(M['content.content_list.drop_unlisted_value'], {
|
|
892
|
+
value: drop.detail ?? '',
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
if (drop.reason === 'unrepresentable-filter') {
|
|
896
|
+
return t(M['content.content_list.drop_unrepresentable_filter'], {
|
|
897
|
+
target: drop.columnId ?? drop.scope,
|
|
898
|
+
value: drop.detail ?? '',
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
if (drop.scope === 'page' && drop.reason === 'out-of-range') {
|
|
902
|
+
return t(M['content.content_list.drop_page_unreachable'], {
|
|
903
|
+
requested: drop.detail ?? '',
|
|
904
|
+
landed: String(pageCapCorrectedTo ?? ''),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return t(M['content.content_list.dropped_item'], {
|
|
908
|
+
target: drop.columnId ?? drop.scope,
|
|
909
|
+
reason: t(DROP_REASON_MESSAGES[drop.reason]),
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function dismissDropNotice() {
|
|
914
|
+
dismissedDropKey = dropNoticeKey;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
// Saved views
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
$effect(() => {
|
|
922
|
+
const store = savedViews;
|
|
923
|
+
if (!store) {
|
|
924
|
+
savedViewList = [];
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
let cancelled = false;
|
|
928
|
+
void store
|
|
929
|
+
.list()
|
|
930
|
+
.then((views) => {
|
|
931
|
+
if (!cancelled) savedViewList = views;
|
|
932
|
+
})
|
|
933
|
+
.catch(() => {
|
|
934
|
+
// An unreadable store is an empty store; the list still opens.
|
|
935
|
+
if (!cancelled) savedViewList = [];
|
|
936
|
+
});
|
|
937
|
+
return () => {
|
|
938
|
+
cancelled = true;
|
|
939
|
+
};
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
async function reloadSavedViews() {
|
|
943
|
+
if (!savedViews) return;
|
|
944
|
+
try {
|
|
945
|
+
savedViewList = await savedViews.list();
|
|
946
|
+
} catch {
|
|
947
|
+
savedViewList = [];
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function applySavedView(id: string) {
|
|
952
|
+
selectedSavedViewId = id;
|
|
953
|
+
const view = savedViewList.find((entry) => entry.id === id);
|
|
954
|
+
if (!view) return;
|
|
955
|
+
try {
|
|
956
|
+
// The same validator, AND the same limits, the URL path uses: a stored view
|
|
957
|
+
// must not be a way around a `maxPageSize` the host configured for links. A
|
|
958
|
+
// stale view restores its valid remainder rather than refusing to open.
|
|
959
|
+
const restoration = restoreContentListSavedView(view, restoreOptions);
|
|
960
|
+
applyContentListViewState(controller, restoration.state, restoreOptions);
|
|
961
|
+
restoreDrops = restoration.dropped;
|
|
962
|
+
} catch {
|
|
963
|
+
restoreDrops = [{ scope: 'state', reason: 'malformed' }];
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function saveCurrentView() {
|
|
968
|
+
const store = savedViews;
|
|
969
|
+
const name = savedViewName.trim();
|
|
970
|
+
if (!store || !name) return;
|
|
971
|
+
try {
|
|
972
|
+
const saved = await store.save(
|
|
973
|
+
toContentListSavedViewInput(name, controller.snapshot()),
|
|
974
|
+
);
|
|
975
|
+
savedViewName = '';
|
|
976
|
+
selectedSavedViewId = saved.id;
|
|
977
|
+
await reloadSavedViews();
|
|
978
|
+
} catch {
|
|
979
|
+
// Keep the operator's text so the save can be retried.
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function deleteSelectedView() {
|
|
984
|
+
const store = savedViews;
|
|
985
|
+
const id = selectedSavedViewId;
|
|
986
|
+
if (!store || !id) return;
|
|
987
|
+
try {
|
|
988
|
+
await store.delete(id);
|
|
989
|
+
selectedSavedViewId = '';
|
|
990
|
+
await reloadSavedViews();
|
|
991
|
+
} catch {
|
|
992
|
+
// Nothing to undo; the list is reloaded on the next mount.
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
210
996
|
function isSelected(row: ContentListRow): boolean {
|
|
211
997
|
return selectedRowKeys.has(String(row.id));
|
|
212
998
|
}
|
|
@@ -443,6 +1229,17 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
|
|
|
443
1229
|
<option value="article">{t(M['content.content_list.type_articles'])}</option>
|
|
444
1230
|
<option value="document">{t(M['content.content_list.type_documents'])}</option>
|
|
445
1231
|
<option value="mirror">{t(M['content.content_list.type_mirrors'])}</option>
|
|
1232
|
+
{#if unlistedType}
|
|
1233
|
+
<!-- A live filter the vocabulary does not cover; showing it is what
|
|
1234
|
+
keeps the toolbar honest about why the list may be empty. -->
|
|
1235
|
+
<option value={unlistedType}>{unlistedType}</option>
|
|
1236
|
+
{:else if !typeFilterState.representable}
|
|
1237
|
+
<!-- A live predicate no single option can express. Show the
|
|
1238
|
+
predicate rather than a value the query is not applying. -->
|
|
1239
|
+
<option value={CONTENT_LIST_UNREPRESENTABLE_OPTION} disabled>
|
|
1240
|
+
{typeFilterState.detail}
|
|
1241
|
+
</option>
|
|
1242
|
+
{/if}
|
|
446
1243
|
</Select>
|
|
447
1244
|
{/if}
|
|
448
1245
|
|
|
@@ -458,9 +1255,61 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
|
|
|
458
1255
|
<option value="">{t(M['content.content_list.all_statuses'])}</option>
|
|
459
1256
|
<option value="published">{t(M['content.content_list.status_published'])}</option>
|
|
460
1257
|
<option value="draft">{t(M['content.content_list.status_draft'])}</option>
|
|
1258
|
+
<option value="review">{t(M['content.content_list.status_review'])}</option>
|
|
461
1259
|
<option value="archived">{t(M['content.content_list.status_archived'])}</option>
|
|
1260
|
+
{#if unlistedStatus}
|
|
1261
|
+
<option value={unlistedStatus}>{unlistedStatus}</option>
|
|
1262
|
+
{:else if !statusFilterState.representable}
|
|
1263
|
+
<option value={CONTENT_LIST_UNREPRESENTABLE_OPTION} disabled>
|
|
1264
|
+
{statusFilterState.detail}
|
|
1265
|
+
</option>
|
|
1266
|
+
{/if}
|
|
462
1267
|
</Select>
|
|
463
1268
|
|
|
1269
|
+
{#if savedViews}
|
|
1270
|
+
<div class="saved-views" role="group" aria-label={t(M['content.content_list.saved_views'])}>
|
|
1271
|
+
<Select
|
|
1272
|
+
aria-label={t(M['content.content_list.saved_views'])}
|
|
1273
|
+
value={selectedSavedViewId}
|
|
1274
|
+
onchange={(event: Event) =>
|
|
1275
|
+
applySavedView((event.currentTarget as HTMLSelectElement).value)}
|
|
1276
|
+
>
|
|
1277
|
+
<option value="">{t(M['content.content_list.saved_view_none'])}</option>
|
|
1278
|
+
{#each savedViewList as view (view.id)}
|
|
1279
|
+
<option value={view.id}>{view.name}</option>
|
|
1280
|
+
{/each}
|
|
1281
|
+
</Select>
|
|
1282
|
+
<Input
|
|
1283
|
+
type="text"
|
|
1284
|
+
aria-label={t(M['content.content_list.saved_view_name'])}
|
|
1285
|
+
placeholder={t(M['content.content_list.saved_view_name_placeholder'])}
|
|
1286
|
+
value={savedViewName}
|
|
1287
|
+
oninput={(event: Event) => {
|
|
1288
|
+
savedViewName = (event.currentTarget as HTMLInputElement).value;
|
|
1289
|
+
}}
|
|
1290
|
+
/>
|
|
1291
|
+
<Button
|
|
1292
|
+
variant="ghost"
|
|
1293
|
+
size="sm"
|
|
1294
|
+
type="button"
|
|
1295
|
+
disabled={savedViewName.trim().length === 0}
|
|
1296
|
+
onclick={() => void saveCurrentView()}
|
|
1297
|
+
>
|
|
1298
|
+
{t(M['content.content_list.saved_view_save'])}
|
|
1299
|
+
</Button>
|
|
1300
|
+
{#if selectedSavedViewId}
|
|
1301
|
+
<Button
|
|
1302
|
+
variant="ghost"
|
|
1303
|
+
size="sm"
|
|
1304
|
+
type="button"
|
|
1305
|
+
onclick={() => void deleteSelectedView()}
|
|
1306
|
+
>
|
|
1307
|
+
{t(M['content.content_list.saved_view_delete'])}
|
|
1308
|
+
</Button>
|
|
1309
|
+
{/if}
|
|
1310
|
+
</div>
|
|
1311
|
+
{/if}
|
|
1312
|
+
|
|
464
1313
|
{#if controls}
|
|
465
1314
|
{@render controls()}
|
|
466
1315
|
{/if}
|
|
@@ -532,12 +1381,38 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
|
|
|
532
1381
|
</div>
|
|
533
1382
|
</div>
|
|
534
1383
|
|
|
535
|
-
{#if
|
|
1384
|
+
{#if showDropNotice}
|
|
1385
|
+
<div class="state-notice" role="status">
|
|
1386
|
+
<p class="state-notice__title">{t(M['content.content_list.dropped_title'])}</p>
|
|
1387
|
+
<ul class="state-notice__list">
|
|
1388
|
+
{#each dropNotices as drop, index (index)}
|
|
1389
|
+
<li>{dropNoticeText(drop)}</li>
|
|
1390
|
+
{/each}
|
|
1391
|
+
{#if queryTruncated}
|
|
1392
|
+
<li>{t(M['content.content_list.result_truncated'])}</li>
|
|
1393
|
+
{/if}
|
|
1394
|
+
{#each queryWarnings as warning, index (index)}
|
|
1395
|
+
<li>{warning}</li>
|
|
1396
|
+
{/each}
|
|
1397
|
+
</ul>
|
|
1398
|
+
<Button
|
|
1399
|
+
variant="ghost"
|
|
1400
|
+
size="sm"
|
|
1401
|
+
type="button"
|
|
1402
|
+
class="state-notice__dismiss"
|
|
1403
|
+
onclick={dismissDropNotice}
|
|
1404
|
+
>
|
|
1405
|
+
{t(M['content.content_list.dropped_dismiss'])}
|
|
1406
|
+
</Button>
|
|
1407
|
+
</div>
|
|
1408
|
+
{/if}
|
|
1409
|
+
|
|
1410
|
+
{#if activeError}
|
|
536
1411
|
<div class="state-panel state-panel--error" role="alert">
|
|
537
1412
|
<p class="state-panel__title">{t(M['content.content_list.error_title'])}</p>
|
|
538
|
-
<p class="state-panel__detail">{
|
|
539
|
-
{#if
|
|
540
|
-
<Button variant="ghost" type="button" class="retry-button" onclick={() =>
|
|
1413
|
+
<p class="state-panel__detail">{activeError}</p>
|
|
1414
|
+
{#if retryHandler}
|
|
1415
|
+
<Button variant="ghost" type="button" class="retry-button" onclick={() => retryHandler?.()}>
|
|
541
1416
|
{t(M['content.content_list.retry'])}
|
|
542
1417
|
</Button>
|
|
543
1418
|
{/if}
|
|
@@ -580,20 +1455,19 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
|
|
|
580
1455
|
<div class="content-table-wrapper">
|
|
581
1456
|
<DataTable
|
|
582
1457
|
data={pageRows}
|
|
583
|
-
totalRows={queryRows.length}
|
|
584
1458
|
columns={tableColumns}
|
|
585
1459
|
rowKey={CONTENT_LIST_ROW_KEY}
|
|
586
1460
|
{controller}
|
|
587
1461
|
sortable
|
|
588
1462
|
agentAddressable
|
|
589
|
-
{
|
|
1463
|
+
loading={isLoading}
|
|
590
1464
|
caption={t(M['content.content_list.table_caption'])}
|
|
591
1465
|
rowLabel={(row: ContentListRow) => row.title}
|
|
592
1466
|
dataSurface={surfaceOptions}
|
|
593
1467
|
empty={tableEmptyState}
|
|
594
1468
|
/>
|
|
595
1469
|
</div>
|
|
596
|
-
{:else if
|
|
1470
|
+
{:else if isLoading && pageRows.length === 0}
|
|
597
1471
|
<div class="state-panel" role="status">
|
|
598
1472
|
{t(M['content.content_list.loading'])}
|
|
599
1473
|
</div>
|
|
@@ -751,7 +1625,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
|
|
|
751
1625
|
</div>
|
|
752
1626
|
{/if}
|
|
753
1627
|
|
|
754
|
-
{#if showPagination
|
|
1628
|
+
{#if showPagination}
|
|
755
1629
|
<div class="content-pagination">
|
|
756
1630
|
<Pagination
|
|
757
1631
|
currentPage={tableState.page}
|
|
@@ -1253,6 +2127,43 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
|
|
|
1253
2127
|
background: var(--smrt-color-error-container);
|
|
1254
2128
|
}
|
|
1255
2129
|
|
|
2130
|
+
/* Saved views live beside the search and filters they name. */
|
|
2131
|
+
.saved-views {
|
|
2132
|
+
display: flex;
|
|
2133
|
+
gap: 0.5rem;
|
|
2134
|
+
align-items: center;
|
|
2135
|
+
flex-wrap: wrap;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/* Reports what a restored link, saved view, or server query discarded. */
|
|
2139
|
+
.state-notice {
|
|
2140
|
+
margin-bottom: 1rem;
|
|
2141
|
+
padding: 0.75rem 1rem;
|
|
2142
|
+
border-radius: 0.5rem;
|
|
2143
|
+
border: 1px solid var(--smrt-color-outline-variant);
|
|
2144
|
+
background: var(--smrt-color-surface-container-low);
|
|
2145
|
+
color: var(--smrt-color-on-surface-variant);
|
|
2146
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
.state-notice__title {
|
|
2150
|
+
margin: 0 0 0.35rem;
|
|
2151
|
+
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
2152
|
+
color: var(--smrt-color-on-surface);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
.state-notice__list {
|
|
2156
|
+
margin: 0 0 0.5rem;
|
|
2157
|
+
padding-left: 1.25rem;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
.state-notice :global(.state-notice__dismiss) {
|
|
2161
|
+
border: 1px solid var(--smrt-color-outline-variant);
|
|
2162
|
+
border-radius: 0.375rem;
|
|
2163
|
+
padding: 0.25rem 0.75rem;
|
|
2164
|
+
cursor: pointer;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
1256
2167
|
/* Shared empty, loading, and error presentation. */
|
|
1257
2168
|
.state-panel {
|
|
1258
2169
|
background: var(--smrt-color-surface);
|