@happyvertical/smrt-content 0.43.3 → 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 -0
- 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 +1580 -281
- package/dist/svelte/components/ContentList.svelte.d.ts +54 -2
- package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.d.ts +306 -0
- package/dist/svelte/content-list-controller.d.ts.map +1 -0
- package/dist/svelte/content-list-controller.js +921 -0
- 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 +55 -0
- package/dist/svelte/i18n.contribution.d.ts.map +1 -1
- package/dist/svelte/i18n.contribution.js +57 -0
- package/dist/svelte/index.d.ts +5 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +10 -0
- package/package.json +16 -15
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared content-list data adapter.
|
|
3
|
+
*
|
|
4
|
+
* Every ContentList presentation (grid, detailed, compact) and every
|
|
5
|
+
* agent-addressable data surface resolves rows, columns, filters, sorting, and
|
|
6
|
+
* per-row action eligibility here, so switching presentation can never change
|
|
7
|
+
* which rows exist or what may be done to them.
|
|
8
|
+
*
|
|
9
|
+
* The module owns no transport, no DOM, and no routing: callers supply the
|
|
10
|
+
* content array, a `getViewHref` resolver, and a `DataTableController` that
|
|
11
|
+
* holds the serializable view state.
|
|
12
|
+
*
|
|
13
|
+
* Query modes are `manual`: this adapter, not the renderer, applies search,
|
|
14
|
+
* filters, sorting, and paging, so a card view and the compact table can never
|
|
15
|
+
* disagree about the visible rows. #2452 replaces the local implementation of
|
|
16
|
+
* that transform with a server query behind the same contract.
|
|
17
|
+
*/
|
|
18
|
+
import { compareDataTableRowIds, createDataTableController, defaultSort, getNestedValue, } from '@happyvertical/smrt-ui/data';
|
|
19
|
+
/** Stable surface identity for the default mounted content list. */
|
|
20
|
+
export const CONTENT_LIST_SURFACE_ID = 'content-list';
|
|
21
|
+
/** Descriptor and view-state schema version owned by this adapter. */
|
|
22
|
+
export const CONTENT_LIST_SCHEMA_VERSION = 1;
|
|
23
|
+
/** Row identity column. Selection and expansion address rows by this value. */
|
|
24
|
+
export const CONTENT_LIST_ROW_KEY = 'id';
|
|
25
|
+
/** Stable filter ids dispatched by the toolbar and accepted from a surface. */
|
|
26
|
+
export const CONTENT_LIST_TYPE_FILTER_ID = 'type';
|
|
27
|
+
export const CONTENT_LIST_STATUS_FILTER_ID = 'status';
|
|
28
|
+
/** Prefix for rows the source array could not identify durably. */
|
|
29
|
+
const UNIDENTIFIED_ROW_PREFIX = 'content-list:unidentified:';
|
|
30
|
+
/** Columns rendered by the compact table and published to a data surface. */
|
|
31
|
+
export const CONTENT_LIST_VISIBLE_COLUMN_IDS = [
|
|
32
|
+
'type',
|
|
33
|
+
'title',
|
|
34
|
+
'author',
|
|
35
|
+
'status',
|
|
36
|
+
'state',
|
|
37
|
+
'publish',
|
|
38
|
+
'updated',
|
|
39
|
+
'site',
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* `description` is searched but never rendered or published: it participates in
|
|
43
|
+
* local search so the rebuilt list keeps the legacy search reach.
|
|
44
|
+
*/
|
|
45
|
+
export const CONTENT_LIST_HIDDEN_COLUMN_IDS = [
|
|
46
|
+
'description',
|
|
47
|
+
];
|
|
48
|
+
export const CONTENT_LIST_COLUMN_IDS = [
|
|
49
|
+
...CONTENT_LIST_VISIBLE_COLUMN_IDS,
|
|
50
|
+
...CONTENT_LIST_HIDDEN_COLUMN_IDS,
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Structural columns the compact table owns. They carry no query capability and
|
|
54
|
+
* are never published to a data surface, but the controller still has to know
|
|
55
|
+
* them: column order is reconciled from its known column ids, so leaving them
|
|
56
|
+
* out would push selection and actions behind every data column.
|
|
57
|
+
*/
|
|
58
|
+
export const CONTENT_LIST_SELECTION_COLUMN_ID = 'select';
|
|
59
|
+
export const CONTENT_LIST_ACTIONS_COLUMN_ID = 'actions';
|
|
60
|
+
/** Every column of the compact table, in render order. */
|
|
61
|
+
export const CONTENT_LIST_TABLE_COLUMN_IDS = [
|
|
62
|
+
CONTENT_LIST_SELECTION_COLUMN_ID,
|
|
63
|
+
...CONTENT_LIST_COLUMN_IDS,
|
|
64
|
+
CONTENT_LIST_ACTIONS_COLUMN_ID,
|
|
65
|
+
];
|
|
66
|
+
const DEFAULT_COLUMN_LABELS = {
|
|
67
|
+
type: 'Type',
|
|
68
|
+
title: 'Title',
|
|
69
|
+
author: 'Author',
|
|
70
|
+
status: 'Status',
|
|
71
|
+
state: 'State',
|
|
72
|
+
publish: 'Publish',
|
|
73
|
+
updated: 'Updated',
|
|
74
|
+
site: 'Site',
|
|
75
|
+
description: 'Description',
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* The `ContentData` field each published column reads. Column ids are stable
|
|
79
|
+
* public identifiers and do not always match the field name, so the mapping is
|
|
80
|
+
* explicit — advertising a field that does not exist would mislead an adapter
|
|
81
|
+
* that maps a descriptor onto the model. `site` is derived from `url`/`source`
|
|
82
|
+
* and therefore names no single field.
|
|
83
|
+
*/
|
|
84
|
+
const CONTENT_LIST_COLUMN_FIELD_NAMES = {
|
|
85
|
+
type: 'type',
|
|
86
|
+
title: 'title',
|
|
87
|
+
author: 'author',
|
|
88
|
+
status: 'status',
|
|
89
|
+
state: 'state',
|
|
90
|
+
publish: 'publish_date',
|
|
91
|
+
updated: 'updatedAt',
|
|
92
|
+
};
|
|
93
|
+
const DEFAULT_ACTION_LABELS = {
|
|
94
|
+
view: 'View',
|
|
95
|
+
edit: 'Edit',
|
|
96
|
+
delete: 'Delete',
|
|
97
|
+
};
|
|
98
|
+
const DEFAULT_SURFACE_LIMITS = {
|
|
99
|
+
maxQueryRows: 200,
|
|
100
|
+
maxQueryBytes: 50_000,
|
|
101
|
+
maxSelectionSize: 200,
|
|
102
|
+
};
|
|
103
|
+
/** Local table commands a mounted content list accepts from a data surface. */
|
|
104
|
+
const CONTENT_LIST_CONTROLS = [
|
|
105
|
+
{ id: 'set-search', label: 'Search contents' },
|
|
106
|
+
{ id: 'set-filters', label: 'Filter contents' },
|
|
107
|
+
{ id: 'set-sorting', label: 'Sort contents' },
|
|
108
|
+
{ id: 'toggle-sorting', label: 'Toggle column sorting' },
|
|
109
|
+
{ id: 'set-page', label: 'Change page' },
|
|
110
|
+
{ id: 'set-page-size', label: 'Change page size' },
|
|
111
|
+
{ id: 'set-selected-rows', label: 'Replace the row selection' },
|
|
112
|
+
{ id: 'toggle-row-selection', label: 'Toggle one row selection' },
|
|
113
|
+
{ id: 'reset', label: 'Reset the list view' },
|
|
114
|
+
{ id: 'focus', label: 'Focus the list' },
|
|
115
|
+
{ id: 'reveal', label: 'Scroll the list into view' },
|
|
116
|
+
{ id: 'highlight', label: 'Highlight the list' },
|
|
117
|
+
];
|
|
118
|
+
function getTextValue(value) {
|
|
119
|
+
return typeof value === 'string' ? value : '';
|
|
120
|
+
}
|
|
121
|
+
/** Normalizes a content type for filtering; unknown values become `content`. */
|
|
122
|
+
export function normalizeContentType(value) {
|
|
123
|
+
return getTextValue(value).trim().toLowerCase() || 'content';
|
|
124
|
+
}
|
|
125
|
+
/** Normalizes a status/state token for filtering and badge variants. */
|
|
126
|
+
export function normalizeContentToken(value) {
|
|
127
|
+
return getTextValue(value).trim().toLowerCase();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The type tokens the toolbar select offers.
|
|
131
|
+
*
|
|
132
|
+
* `Content.type` is freeform, so this is a display vocabulary rather than the
|
|
133
|
+
* model's domain: a value outside it is still a valid filter, and the list
|
|
134
|
+
* surfaces it rather than hiding it (see `ContentList`).
|
|
135
|
+
*/
|
|
136
|
+
export const CONTENT_LIST_TYPE_OPTIONS = [
|
|
137
|
+
'article',
|
|
138
|
+
'document',
|
|
139
|
+
'mirror',
|
|
140
|
+
];
|
|
141
|
+
/**
|
|
142
|
+
* The status tokens the toolbar select offers.
|
|
143
|
+
*
|
|
144
|
+
* `Content.status` is `published | draft | review | archived | deleted`.
|
|
145
|
+
* `review` is offered because it is a real, reachable state that governance
|
|
146
|
+
* puts content into; omitting it meant `?status=review` restored a live
|
|
147
|
+
* predicate the toolbar could not show.
|
|
148
|
+
*
|
|
149
|
+
* `deleted` is deliberately NOT offered: it is the trash lifecycle, which is
|
|
150
|
+
* #2454's, and exposing it here would imply a restore/purge affordance this
|
|
151
|
+
* list does not have.
|
|
152
|
+
*/
|
|
153
|
+
export const CONTENT_LIST_STATUS_OPTIONS = [
|
|
154
|
+
'published',
|
|
155
|
+
'draft',
|
|
156
|
+
'review',
|
|
157
|
+
'archived',
|
|
158
|
+
];
|
|
159
|
+
/**
|
|
160
|
+
* Resolves the normalized type a `type` prop locks the list to, or `null` when
|
|
161
|
+
* the list is unlocked. Shared so the lock effect and the initial restore
|
|
162
|
+
* cannot disagree about what "locked" means.
|
|
163
|
+
*/
|
|
164
|
+
export function normalizeContentListTypeLock(value) {
|
|
165
|
+
return value?.trim() ? normalizeContentType(value) : null;
|
|
166
|
+
}
|
|
167
|
+
export function contentTypeLabel(value) {
|
|
168
|
+
switch (normalizeContentType(value)) {
|
|
169
|
+
case 'article':
|
|
170
|
+
return 'Article';
|
|
171
|
+
case 'mirror':
|
|
172
|
+
return 'Mirror';
|
|
173
|
+
case 'document':
|
|
174
|
+
return 'Document';
|
|
175
|
+
default:
|
|
176
|
+
return 'Content';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Badge variant for a status; unrecognized statuses degrade to `unknown`. */
|
|
180
|
+
export function contentStatusVariant(value) {
|
|
181
|
+
switch (normalizeContentToken(value)) {
|
|
182
|
+
case 'published':
|
|
183
|
+
return 'published';
|
|
184
|
+
case 'draft':
|
|
185
|
+
return 'draft';
|
|
186
|
+
case 'archived':
|
|
187
|
+
return 'archived';
|
|
188
|
+
default:
|
|
189
|
+
return 'unknown';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Badge variant for a workflow state; unrecognized states degrade to `unknown`. */
|
|
193
|
+
export function contentStateVariant(value) {
|
|
194
|
+
switch (normalizeContentToken(value)) {
|
|
195
|
+
case 'highlighted':
|
|
196
|
+
return 'highlighted';
|
|
197
|
+
case 'active':
|
|
198
|
+
return 'active';
|
|
199
|
+
case 'deprecated':
|
|
200
|
+
return 'deprecated';
|
|
201
|
+
default:
|
|
202
|
+
return 'unknown';
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function hostnameOf(url) {
|
|
206
|
+
try {
|
|
207
|
+
return new URL(url).hostname;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function resolveSite(content) {
|
|
214
|
+
const url = getTextValue(content.url);
|
|
215
|
+
if (url) {
|
|
216
|
+
const hostname = hostnameOf(url);
|
|
217
|
+
if (hostname)
|
|
218
|
+
return hostname;
|
|
219
|
+
}
|
|
220
|
+
return getTextValue(content.source);
|
|
221
|
+
}
|
|
222
|
+
/** Renders an ISO timestamp as a stable calendar date, or echoes free text. */
|
|
223
|
+
export function formatContentListDate(value) {
|
|
224
|
+
const text = getTextValue(value);
|
|
225
|
+
if (!text)
|
|
226
|
+
return '';
|
|
227
|
+
const parsed = new Date(text);
|
|
228
|
+
return Number.isNaN(parsed.getTime())
|
|
229
|
+
? text
|
|
230
|
+
: parsed.toISOString().slice(0, 10);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Resolves the rows every presentation renders.
|
|
234
|
+
*
|
|
235
|
+
* A content without a durable id — or one repeating an id an earlier row
|
|
236
|
+
* already claimed — still renders, keyed by its position, but is marked
|
|
237
|
+
* unidentified so it never enters a selection that outlives the current order.
|
|
238
|
+
*/
|
|
239
|
+
export function toContentListRows(contents) {
|
|
240
|
+
const claimed = new Set();
|
|
241
|
+
return contents.map((content, index) => {
|
|
242
|
+
const declaredId = getTextValue(content.id);
|
|
243
|
+
const identified = declaredId.length > 0 && !claimed.has(declaredId);
|
|
244
|
+
if (identified)
|
|
245
|
+
claimed.add(declaredId);
|
|
246
|
+
const type = normalizeContentType(content.type);
|
|
247
|
+
const status = normalizeContentToken(content.status);
|
|
248
|
+
const state = normalizeContentToken(content.state);
|
|
249
|
+
const publish = getTextValue(content.publish_date);
|
|
250
|
+
const updated = getTextValue(content.updatedAt);
|
|
251
|
+
return {
|
|
252
|
+
id: identified ? declaredId : `${UNIDENTIFIED_ROW_PREFIX}${index}`,
|
|
253
|
+
identified,
|
|
254
|
+
content,
|
|
255
|
+
type,
|
|
256
|
+
typeLabel: contentTypeLabel(content.type),
|
|
257
|
+
title: getTextValue(content.title) || 'Untitled content',
|
|
258
|
+
description: getTextValue(content.description),
|
|
259
|
+
author: getTextValue(content.author),
|
|
260
|
+
status,
|
|
261
|
+
statusLabel: getTextValue(content.status),
|
|
262
|
+
state,
|
|
263
|
+
stateLabel: getTextValue(content.state),
|
|
264
|
+
publish,
|
|
265
|
+
publishLabel: formatContentListDate(publish),
|
|
266
|
+
updated,
|
|
267
|
+
updatedLabel: formatContentListDate(updated),
|
|
268
|
+
site: resolveSite(content),
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** Row ids that may take part in selection. Unidentified rows are excluded. */
|
|
273
|
+
export function selectableContentListRowIds(rows) {
|
|
274
|
+
return rows.filter((row) => row.identified).map((row) => row.id);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Resolves a selection to rows. Unidentified rows are dropped rather than
|
|
278
|
+
* throwing, so one malformed content can never break a bulk workflow (#2453).
|
|
279
|
+
*/
|
|
280
|
+
export function resolveSelectedContentListRows(rows, state) {
|
|
281
|
+
const selected = new Set(state.selectedRowIds.map((rowId) => String(rowId)));
|
|
282
|
+
return rows.filter((row) => row.identified && selected.has(String(row.id)));
|
|
283
|
+
}
|
|
284
|
+
/** The durable contents behind the current selection. */
|
|
285
|
+
export function resolveSelectedContents(rows, state) {
|
|
286
|
+
return resolveSelectedContentListRows(rows, state).map((row) => row.content);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Column metadata shared by the compact table and the local query helpers.
|
|
290
|
+
* Callers add cell snippets; they must not change ids, accessors, or the
|
|
291
|
+
* searchable/filterable/sortable flags the descriptor is derived from.
|
|
292
|
+
*/
|
|
293
|
+
export function buildContentListColumns(labels = {}) {
|
|
294
|
+
const label = (id) => labels[id] ?? DEFAULT_COLUMN_LABELS[id];
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
id: 'type',
|
|
298
|
+
label: label('type'),
|
|
299
|
+
accessor: 'type',
|
|
300
|
+
sortable: true,
|
|
301
|
+
searchable: false,
|
|
302
|
+
width: '8rem',
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: 'title',
|
|
306
|
+
label: label('title'),
|
|
307
|
+
accessor: 'title',
|
|
308
|
+
sortable: true,
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: 'author',
|
|
312
|
+
label: label('author'),
|
|
313
|
+
accessor: 'author',
|
|
314
|
+
sortable: true,
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: 'status',
|
|
318
|
+
label: label('status'),
|
|
319
|
+
accessor: 'status',
|
|
320
|
+
sortable: true,
|
|
321
|
+
searchable: false,
|
|
322
|
+
role: 'status',
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: 'state',
|
|
326
|
+
label: label('state'),
|
|
327
|
+
accessor: 'state',
|
|
328
|
+
sortable: true,
|
|
329
|
+
searchable: false,
|
|
330
|
+
role: 'status',
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'publish',
|
|
334
|
+
label: label('publish'),
|
|
335
|
+
accessor: 'publish',
|
|
336
|
+
sortable: true,
|
|
337
|
+
searchable: false,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: 'updated',
|
|
341
|
+
label: label('updated'),
|
|
342
|
+
accessor: 'updated',
|
|
343
|
+
sortable: true,
|
|
344
|
+
searchable: false,
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: 'site',
|
|
348
|
+
label: label('site'),
|
|
349
|
+
accessor: 'site',
|
|
350
|
+
sortable: true,
|
|
351
|
+
searchable: false,
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: 'description',
|
|
355
|
+
label: label('description'),
|
|
356
|
+
accessor: 'description',
|
|
357
|
+
sortable: false,
|
|
358
|
+
filterable: false,
|
|
359
|
+
hidden: true,
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Columns whose stored values are lowercase tokens rather than free text.
|
|
365
|
+
*
|
|
366
|
+
* Only these may have their case normalized by a filter: their domain is a
|
|
367
|
+
* fixed vocabulary the model writes in lower case, so folding the operator's
|
|
368
|
+
* input to match it is a correction. Every other column holds text a person
|
|
369
|
+
* typed.
|
|
370
|
+
*/
|
|
371
|
+
export const CONTENT_LIST_TOKEN_COLUMN_IDS = [
|
|
372
|
+
'type',
|
|
373
|
+
'status',
|
|
374
|
+
'state',
|
|
375
|
+
];
|
|
376
|
+
const TOKEN_COLUMNS = new Set(CONTENT_LIST_TOKEN_COLUMN_IDS);
|
|
377
|
+
/**
|
|
378
|
+
* One normalizer per filter column, so a filter built by the toolbar, by the
|
|
379
|
+
* `type` lock, and by a restored view all compare equal.
|
|
380
|
+
*
|
|
381
|
+
* CASE IS PRESERVED FOR FREE TEXT. This helper was written for #2451, when
|
|
382
|
+
* every comparison happened in the browser and lowercasing everything was
|
|
383
|
+
* harmless. Under #2452 a stored filter value becomes a server-side `eq` or
|
|
384
|
+
* `like` predicate compared against the STORED text, so lowercasing `NASA`
|
|
385
|
+
* would send `%nasa%` and miss `NASA Update` on a case-sensitive backend
|
|
386
|
+
* (PostgreSQL, DuckDB). Local matching is unaffected either way: the local
|
|
387
|
+
* evaluator compares through `textValue()`, which lower-cases BOTH sides at
|
|
388
|
+
* compare time, so a case-preserving stored value still matches
|
|
389
|
+
* case-insensitively there.
|
|
390
|
+
*/
|
|
391
|
+
export function normalizeContentListFilterValue(columnId, value) {
|
|
392
|
+
if (columnId === CONTENT_LIST_TYPE_FILTER_ID)
|
|
393
|
+
return normalizeContentType(value);
|
|
394
|
+
if (TOKEN_COLUMNS.has(columnId))
|
|
395
|
+
return normalizeContentToken(value);
|
|
396
|
+
return value.trim();
|
|
397
|
+
}
|
|
398
|
+
/** Builds the declarative filter set for the two toolbar filters. */
|
|
399
|
+
export function contentListFilters(values) {
|
|
400
|
+
const filters = [];
|
|
401
|
+
if (values.type?.trim()) {
|
|
402
|
+
filters.push({
|
|
403
|
+
columnId: CONTENT_LIST_TYPE_FILTER_ID,
|
|
404
|
+
operator: 'equals',
|
|
405
|
+
value: normalizeContentListFilterValue(CONTENT_LIST_TYPE_FILTER_ID, values.type),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (values.status?.trim()) {
|
|
409
|
+
filters.push({
|
|
410
|
+
columnId: CONTENT_LIST_STATUS_FILTER_ID,
|
|
411
|
+
operator: 'equals',
|
|
412
|
+
value: normalizeContentListFilterValue(CONTENT_LIST_STATUS_FILTER_ID, values.status),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return filters;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Reads one filter's value, or `null` when the filter is not applied.
|
|
419
|
+
*
|
|
420
|
+
* Value-only, and therefore NOT enough to drive a single-select toolbar
|
|
421
|
+
* control: it reports the same string for `equals 'draft'` and
|
|
422
|
+
* `notEquals 'draft'`. Use {@link readContentListSelectFilter} for anything
|
|
423
|
+
* that displays the filter to an operator.
|
|
424
|
+
*/
|
|
425
|
+
export function readContentListFilter(state, columnId) {
|
|
426
|
+
const filter = state.filters.find((candidate) => candidate.columnId === columnId);
|
|
427
|
+
return typeof filter?.value === 'string' ? filter.value : null;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The value a toolbar select carries while a live filter cannot be represented
|
|
431
|
+
* as one of its options. It is only ever set programmatically on a disabled
|
|
432
|
+
* option, so it can never be submitted; `applyContentListFilter` would replace
|
|
433
|
+
* every filter on the column anyway.
|
|
434
|
+
*
|
|
435
|
+
* The prefix is U+001F rather than U+0000 deliberately. The HTML tokenizer
|
|
436
|
+
* rewrites a NUL inside an attribute value to U+FFFD, so a server-rendered
|
|
437
|
+
* option would come back with a different value than the select was given and
|
|
438
|
+
* hydrate to no selection at all — exactly the state this sentinel exists to
|
|
439
|
+
* prevent. U+001F passes through attribute parsing unchanged.
|
|
440
|
+
*
|
|
441
|
+
* A crafted filter value could still equal it — the normalizer only trims and
|
|
442
|
+
* lower-cases — and that is harmless: the representable and unrepresentable
|
|
443
|
+
* paths are mutually exclusive, and the representable one renders the value as
|
|
444
|
+
* a real, enabled option carrying the same value.
|
|
445
|
+
*/
|
|
446
|
+
export const CONTENT_LIST_UNREPRESENTABLE_OPTION = '\u001Funrepresentable';
|
|
447
|
+
function describeContentListFilter(filter) {
|
|
448
|
+
if (filter.value === undefined)
|
|
449
|
+
return filter.operator;
|
|
450
|
+
const value = Array.isArray(filter.value)
|
|
451
|
+
? filter.value.map(String).join(', ')
|
|
452
|
+
: String(filter.value);
|
|
453
|
+
return `${filter.operator} ${value}`;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Resolves what a toolbar select may show for one column.
|
|
457
|
+
*
|
|
458
|
+
* A select offers a single `equals` value, but the filter vocabulary a link or
|
|
459
|
+
* a saved view can restore is much wider. `?status.in=draft,review` and
|
|
460
|
+
* `?status.isNull=1` constrain the query while a value-only read reports
|
|
461
|
+
* nothing, and `?status.notEquals=draft` reports `draft` — the exact inverse of
|
|
462
|
+
* what is being applied. This is the seam that keeps the control from
|
|
463
|
+
* misstating the query: either it can show the predicate exactly, or the caller
|
|
464
|
+
* is told it cannot and reports it.
|
|
465
|
+
*/
|
|
466
|
+
export function readContentListSelectFilter(state, columnId) {
|
|
467
|
+
const applied = state.filters.filter((candidate) => candidate.columnId === columnId);
|
|
468
|
+
if (applied.length === 0) {
|
|
469
|
+
return { value: '', representable: true, detail: null };
|
|
470
|
+
}
|
|
471
|
+
const unrepresentable = () => ({
|
|
472
|
+
value: '',
|
|
473
|
+
representable: false,
|
|
474
|
+
detail: applied.map(describeContentListFilter).join('; '),
|
|
475
|
+
});
|
|
476
|
+
if (applied.length > 1)
|
|
477
|
+
return unrepresentable();
|
|
478
|
+
const [filter] = applied;
|
|
479
|
+
if (filter.operator !== 'equals' || typeof filter.value !== 'string') {
|
|
480
|
+
return unrepresentable();
|
|
481
|
+
}
|
|
482
|
+
return { value: filter.value, representable: true, detail: null };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* True when a column is filtered to exactly the given value and nothing else.
|
|
486
|
+
*
|
|
487
|
+
* A locked filter has to be checked as a whole rather than by reading one
|
|
488
|
+
* value: a `notEquals` on the locked value, or a second filter on the same
|
|
489
|
+
* column, would otherwise satisfy a value-only comparison while selecting rows
|
|
490
|
+
* the lock is meant to exclude.
|
|
491
|
+
*/
|
|
492
|
+
export function isContentListFilterExactly(state, columnId, value) {
|
|
493
|
+
const applied = state.filters.filter((filter) => filter.columnId === columnId);
|
|
494
|
+
return (applied.length === 1 &&
|
|
495
|
+
applied[0].operator === 'equals' &&
|
|
496
|
+
applied[0].value === normalizeContentListFilterValue(columnId, value));
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Replaces one filter while preserving the others, so locking the type filter
|
|
500
|
+
* never discards a status the operator chose.
|
|
501
|
+
*
|
|
502
|
+
* A blank value clears the filter: normalizing whitespace into an `equals ''`
|
|
503
|
+
* filter would silently exclude every row instead.
|
|
504
|
+
*/
|
|
505
|
+
export function applyContentListFilter(controller, columnId, value) {
|
|
506
|
+
const current = controller
|
|
507
|
+
.getState()
|
|
508
|
+
.filters.filter((filter) => filter.columnId !== columnId);
|
|
509
|
+
const requested = typeof value === 'string' ? value.trim() : '';
|
|
510
|
+
const next = requested
|
|
511
|
+
? [
|
|
512
|
+
...current,
|
|
513
|
+
{
|
|
514
|
+
columnId,
|
|
515
|
+
operator: 'equals',
|
|
516
|
+
value: normalizeContentListFilterValue(columnId, requested),
|
|
517
|
+
},
|
|
518
|
+
]
|
|
519
|
+
: current;
|
|
520
|
+
controller.dispatch({ type: 'setFilters', filters: next });
|
|
521
|
+
}
|
|
522
|
+
export function createContentListController(options = {}) {
|
|
523
|
+
return createDataTableController({
|
|
524
|
+
columnIds: CONTENT_LIST_TABLE_COLUMN_IDS,
|
|
525
|
+
hiddenColumnIds: CONTENT_LIST_HIDDEN_COLUMN_IDS,
|
|
526
|
+
// This adapter owns the transform in every presentation, so the renderer
|
|
527
|
+
// must not apply a second, subtly different pass over the same rows.
|
|
528
|
+
modes: { filtering: 'manual', sorting: 'manual', pagination: 'manual' },
|
|
529
|
+
initialState: {
|
|
530
|
+
search: options.search ?? '',
|
|
531
|
+
filters: contentListFilters({
|
|
532
|
+
type: options.type ?? null,
|
|
533
|
+
status: options.status ?? null,
|
|
534
|
+
}),
|
|
535
|
+
sorting: options.sorting ? [...options.sorting] : [],
|
|
536
|
+
pageSize: options.pageSize ?? null,
|
|
537
|
+
},
|
|
538
|
+
onStateChange: options.onStateChange,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function textValue(value) {
|
|
542
|
+
if (value === null || value === undefined)
|
|
543
|
+
return '';
|
|
544
|
+
if (typeof value === 'string')
|
|
545
|
+
return value.toLowerCase();
|
|
546
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
547
|
+
return String(value).toLowerCase();
|
|
548
|
+
}
|
|
549
|
+
return JSON.stringify(value)?.toLowerCase() ?? '';
|
|
550
|
+
}
|
|
551
|
+
function sameFilterValue(left, right) {
|
|
552
|
+
if (left === right)
|
|
553
|
+
return true;
|
|
554
|
+
if (left === null ||
|
|
555
|
+
left === undefined ||
|
|
556
|
+
right === null ||
|
|
557
|
+
right === undefined) {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
return textValue(left) === textValue(right);
|
|
561
|
+
}
|
|
562
|
+
function compareFilterValues(left, right) {
|
|
563
|
+
if (typeof left === 'number' && typeof right === 'number') {
|
|
564
|
+
return left === right ? 0 : left < right ? -1 : 1;
|
|
565
|
+
}
|
|
566
|
+
const leftText = textValue(left);
|
|
567
|
+
const rightText = textValue(right);
|
|
568
|
+
return leftText === rightText ? 0 : leftText < rightText ? -1 : 1;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* True when the content behind a row carries no value at all for a column.
|
|
572
|
+
*
|
|
573
|
+
* `ContentListRow` flattens every field to display text, so an absent value and
|
|
574
|
+
* an empty one both read as `''` — which made every ordered comparison treat
|
|
575
|
+
* "no author" as the smallest possible author, and made `isNull` match nothing.
|
|
576
|
+
* The original `ContentData` still distinguishes them, so the null-sensitive
|
|
577
|
+
* operators consult it. A derived column such as `site` has no single source
|
|
578
|
+
* field and is never absent.
|
|
579
|
+
*/
|
|
580
|
+
function isAbsentContentValue(row, column) {
|
|
581
|
+
const fieldName = CONTENT_LIST_COLUMN_FIELD_NAMES[column.id];
|
|
582
|
+
if (!fieldName)
|
|
583
|
+
return false;
|
|
584
|
+
const source = row.content[fieldName];
|
|
585
|
+
return source === null || source === undefined;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Columns whose flattened text is a LABEL when the content carries no value:
|
|
589
|
+
* `type` reads `content` and `title` reads `Untitled content`. Every other
|
|
590
|
+
* column flattens to empty text, which is what the stored column holds.
|
|
591
|
+
*/
|
|
592
|
+
const CONTENT_LIST_LABEL_FALLBACK_COLUMNS = new Set(['type', 'title']);
|
|
593
|
+
/**
|
|
594
|
+
* The value a local comparison reads for one column.
|
|
595
|
+
*
|
|
596
|
+
* A fallback label is presentation, not data. Comparing it makes
|
|
597
|
+
* `?type=content` match every untyped row locally and none server-side, and
|
|
598
|
+
* makes a search for `untitled` match rows whose title is simply missing — the
|
|
599
|
+
* same link returning different data depending on how the host configured the
|
|
600
|
+
* list. For those two columns the comparison reads what is stored: `null` when
|
|
601
|
+
* the content has no value at all (which compares false against everything,
|
|
602
|
+
* exactly as SQL does), empty text when the value is genuinely blank, and
|
|
603
|
+
* otherwise the flattened text, which is already faithful.
|
|
604
|
+
*/
|
|
605
|
+
function comparisonValue(row, column) {
|
|
606
|
+
const value = getNestedValue(row, String(column.accessor ?? column.id));
|
|
607
|
+
if (!CONTENT_LIST_LABEL_FALLBACK_COLUMNS.has(column.id))
|
|
608
|
+
return value;
|
|
609
|
+
const fieldName = CONTENT_LIST_COLUMN_FIELD_NAMES[column.id];
|
|
610
|
+
const source = fieldName
|
|
611
|
+
? row.content[fieldName]
|
|
612
|
+
: undefined;
|
|
613
|
+
if (source === null || source === undefined)
|
|
614
|
+
return null;
|
|
615
|
+
if (typeof source === 'string' && source.trim() === '')
|
|
616
|
+
return '';
|
|
617
|
+
return value;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* The single declarative-filter evaluator. It follows DataTable's operator
|
|
621
|
+
* semantics so a persisted or agent-issued filter behaves the same here as it
|
|
622
|
+
* would in a locally filtered table.
|
|
623
|
+
*
|
|
624
|
+
* The null-sensitive operators (`gt`/`gte`/`lt`/`lte`, `isNull`/`isNotNull`)
|
|
625
|
+
* additionally agree with SQL, and no comparison reads a display fallback, so
|
|
626
|
+
* the same shared link means the same thing on a client-array list and a
|
|
627
|
+
* server-backed one (#2452).
|
|
628
|
+
*/
|
|
629
|
+
/**
|
|
630
|
+
* What an ABSENT value does to each operator, as the executor's SQL decides it.
|
|
631
|
+
*
|
|
632
|
+
* The flattened row reads an absent value as empty text, so comparing it as
|
|
633
|
+
* text answers a question about `''` rather than about absence — and the two
|
|
634
|
+
* differ for every operator once a BLANK comparand is involved. `author equals
|
|
635
|
+
* ''` matches an absent row as text and no row in SQL; `author notEquals ''`
|
|
636
|
+
* does the reverse. Deciding from the operator alone removes the text
|
|
637
|
+
* comparison from the picture entirely.
|
|
638
|
+
*
|
|
639
|
+
* The table is the SQL the executor emits (see `conditionToDnf`), not raw
|
|
640
|
+
* three-valued logic: `ne`/`notIn` union `IS NULL` unless a `null` is listed,
|
|
641
|
+
* which is why they read the way they do here.
|
|
642
|
+
*/
|
|
643
|
+
function matchesAbsentContentValue(filter) {
|
|
644
|
+
const listsNull = Array.isArray(filter.value) && filter.value.some((entry) => entry === null);
|
|
645
|
+
switch (filter.operator) {
|
|
646
|
+
// `eq v` is `= v`, true only when the caller named absence itself.
|
|
647
|
+
case 'equals':
|
|
648
|
+
return filter.value === null;
|
|
649
|
+
// `ne v` is `IS NULL OR <> v`; `ne null` is `IS NOT NULL`.
|
|
650
|
+
case 'notEquals':
|
|
651
|
+
return filter.value !== null;
|
|
652
|
+
// `in` matches an absent row only when the list carries a `null`.
|
|
653
|
+
case 'in':
|
|
654
|
+
return listsNull;
|
|
655
|
+
// `notIn` unions `IS NULL` unless a `null` is listed, which excludes it.
|
|
656
|
+
case 'notIn':
|
|
657
|
+
return !listsNull;
|
|
658
|
+
case 'isNull':
|
|
659
|
+
return true;
|
|
660
|
+
case 'isNotNull':
|
|
661
|
+
return false;
|
|
662
|
+
// `like` and every ordered comparison are UNKNOWN for NULL, so no row with
|
|
663
|
+
// no value takes part in one.
|
|
664
|
+
case 'contains':
|
|
665
|
+
case 'startsWith':
|
|
666
|
+
case 'endsWith':
|
|
667
|
+
case 'gt':
|
|
668
|
+
case 'gte':
|
|
669
|
+
case 'lt':
|
|
670
|
+
case 'lte':
|
|
671
|
+
return false;
|
|
672
|
+
// `notContains` has no server form — the executor refuses a negated
|
|
673
|
+
// `like` and the translator drops it — so there is nothing to align to.
|
|
674
|
+
// Keep the local set-complement reading.
|
|
675
|
+
case 'notContains':
|
|
676
|
+
return true;
|
|
677
|
+
default:
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function matchesContentListFilter(row, column, filter) {
|
|
682
|
+
if (column.filterable === false)
|
|
683
|
+
return true;
|
|
684
|
+
// Absence is decided before any text comparison, because the flattened row
|
|
685
|
+
// cannot represent it. The arms below stay correct for a value that is
|
|
686
|
+
// present; none of them can see an absent one.
|
|
687
|
+
if (isAbsentContentValue(row, column)) {
|
|
688
|
+
return matchesAbsentContentValue(filter);
|
|
689
|
+
}
|
|
690
|
+
const value = comparisonValue(row, column);
|
|
691
|
+
const expected = filter.value;
|
|
692
|
+
const valueText = textValue(value);
|
|
693
|
+
const expectedText = textValue(expected);
|
|
694
|
+
// A literal `null` in a filter names ABSENCE, and the flattened row cannot
|
|
695
|
+
// express it — an absent author reads as empty text, which is not the same
|
|
696
|
+
// thing. `in`/`notIn` lists reach here from a data-surface `set-filters`
|
|
697
|
+
// command, and the executor lowers a listed null to `IS NULL` / `IS NOT
|
|
698
|
+
// NULL`; matching that here is what keeps the two modes agreeing.
|
|
699
|
+
const matchesEntry = (entry) => entry === null
|
|
700
|
+
? isAbsentContentValue(row, column)
|
|
701
|
+
: sameFilterValue(value, entry);
|
|
702
|
+
switch (filter.operator) {
|
|
703
|
+
case 'equals':
|
|
704
|
+
return matchesEntry(expected);
|
|
705
|
+
case 'notEquals':
|
|
706
|
+
return !matchesEntry(expected);
|
|
707
|
+
case 'contains':
|
|
708
|
+
return valueText.includes(expectedText);
|
|
709
|
+
case 'notContains':
|
|
710
|
+
return !valueText.includes(expectedText);
|
|
711
|
+
case 'startsWith':
|
|
712
|
+
return valueText.startsWith(expectedText);
|
|
713
|
+
case 'endsWith':
|
|
714
|
+
return valueText.endsWith(expectedText);
|
|
715
|
+
case 'in':
|
|
716
|
+
return Array.isArray(expected) && expected.some(matchesEntry);
|
|
717
|
+
case 'notIn':
|
|
718
|
+
return Array.isArray(expected) && !expected.some(matchesEntry);
|
|
719
|
+
case 'gt':
|
|
720
|
+
case 'gte':
|
|
721
|
+
case 'lt':
|
|
722
|
+
case 'lte': {
|
|
723
|
+
// An absent value takes part in no ordered comparison, exactly as SQL
|
|
724
|
+
// yields UNKNOWN for NULL. Without this the flattened empty string sorts
|
|
725
|
+
// below everything, so `publish_date lt X` would match every row that was
|
|
726
|
+
// never published — and disagree with the server for the same link.
|
|
727
|
+
if (isAbsentContentValue(row, column))
|
|
728
|
+
return false;
|
|
729
|
+
const comparison = compareFilterValues(value, expected);
|
|
730
|
+
if (filter.operator === 'gt')
|
|
731
|
+
return comparison > 0;
|
|
732
|
+
if (filter.operator === 'gte')
|
|
733
|
+
return comparison >= 0;
|
|
734
|
+
if (filter.operator === 'lt')
|
|
735
|
+
return comparison < 0;
|
|
736
|
+
return comparison <= 0;
|
|
737
|
+
}
|
|
738
|
+
case 'isNull':
|
|
739
|
+
return (isAbsentContentValue(row, column) ||
|
|
740
|
+
value === null ||
|
|
741
|
+
value === undefined);
|
|
742
|
+
case 'isNotNull':
|
|
743
|
+
return !(isAbsentContentValue(row, column) ||
|
|
744
|
+
value === null ||
|
|
745
|
+
value === undefined);
|
|
746
|
+
default:
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Applies search, declarative filters, and sorting once for every
|
|
752
|
+
* presentation. Pagination stays separate so a caller can report the unpaged
|
|
753
|
+
* result count (DataTable's `totalRows`) alongside the current page.
|
|
754
|
+
*/
|
|
755
|
+
export function selectContentListRows(rows, state, columns = buildContentListColumns()) {
|
|
756
|
+
const search = state.search.trim().toLowerCase();
|
|
757
|
+
const filtered = rows.filter((row) => {
|
|
758
|
+
if (search &&
|
|
759
|
+
!columns.some((column) => column.searchable !== false &&
|
|
760
|
+
textValue(comparisonValue(row, column)).includes(search))) {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
return state.filters.every((filter) => {
|
|
764
|
+
const column = columns.find((candidate) => candidate.id === filter.columnId);
|
|
765
|
+
return Boolean(column && matchesContentListFilter(row, column, filter));
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
if (state.sorting.length === 0)
|
|
769
|
+
return filtered;
|
|
770
|
+
return filtered.slice().sort((left, right) => {
|
|
771
|
+
for (const rule of state.sorting) {
|
|
772
|
+
const column = columns.find((candidate) => candidate.id === rule.columnId);
|
|
773
|
+
if (!column)
|
|
774
|
+
continue;
|
|
775
|
+
// Absent values sort LAST ascending and FIRST descending. Without this
|
|
776
|
+
// an absent value flattens to empty text and sorts first ascending —
|
|
777
|
+
// which silently changes which rows land on page one when the same list
|
|
778
|
+
// is served from the query endpoint. See `agents/content-list.md`: the
|
|
779
|
+
// placement matches the SQL standard and the PostgreSQL/DuckDB default,
|
|
780
|
+
// and no portable way exists to state it in an `orderBy` term.
|
|
781
|
+
const leftAbsent = isAbsentContentValue(left, column);
|
|
782
|
+
const rightAbsent = isAbsentContentValue(right, column);
|
|
783
|
+
if (leftAbsent !== rightAbsent) {
|
|
784
|
+
const absentAfter = leftAbsent ? 1 : -1;
|
|
785
|
+
return rule.direction === 'desc' ? -absentAfter : absentAfter;
|
|
786
|
+
}
|
|
787
|
+
const result = defaultSort(left, right, String(column.accessor ?? column.id), rule.direction);
|
|
788
|
+
if (result !== 0)
|
|
789
|
+
return result;
|
|
790
|
+
}
|
|
791
|
+
return compareDataTableRowIds(left.id, right.id);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
/** Slices the current page. An unset page size keeps the whole result. */
|
|
795
|
+
export function paginateContentListRows(rows, state) {
|
|
796
|
+
if (!state.pageSize)
|
|
797
|
+
return [...rows];
|
|
798
|
+
const start = (state.page - 1) * state.pageSize;
|
|
799
|
+
return rows.slice(start, start + state.pageSize);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Resolves the host-owned view link. An unknown subtype — or a resolver that
|
|
803
|
+
* throws on one — degrades to plain text rather than a dead link.
|
|
804
|
+
*/
|
|
805
|
+
export function resolveContentHref(content, getViewHref) {
|
|
806
|
+
if (!getViewHref)
|
|
807
|
+
return null;
|
|
808
|
+
try {
|
|
809
|
+
const href = getViewHref(content);
|
|
810
|
+
return typeof href === 'string' && href.length > 0 ? href : null;
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Per-row action eligibility, used identically by all three presentations.
|
|
818
|
+
* `view` requires a resolvable href, so unpublished content never renders one.
|
|
819
|
+
*/
|
|
820
|
+
export function contentListRowActions(row, options = {}) {
|
|
821
|
+
const actions = [];
|
|
822
|
+
if (resolveContentHref(row.content, options.getViewHref))
|
|
823
|
+
actions.push('view');
|
|
824
|
+
if (options.canEdit !== false)
|
|
825
|
+
actions.push('edit');
|
|
826
|
+
if (options.canDelete !== false)
|
|
827
|
+
actions.push('delete');
|
|
828
|
+
return actions;
|
|
829
|
+
}
|
|
830
|
+
function surfaceColumn(id, labels, order, column) {
|
|
831
|
+
const capabilities = [
|
|
832
|
+
'read',
|
|
833
|
+
'filter',
|
|
834
|
+
'sort',
|
|
835
|
+
'project',
|
|
836
|
+
];
|
|
837
|
+
if (column.searchable !== false)
|
|
838
|
+
capabilities.push('search');
|
|
839
|
+
const fieldName = CONTENT_LIST_COLUMN_FIELD_NAMES[id];
|
|
840
|
+
return {
|
|
841
|
+
id,
|
|
842
|
+
label: labels[id] ?? DEFAULT_COLUMN_LABELS[id],
|
|
843
|
+
capabilities,
|
|
844
|
+
...(fieldName ? { fieldName } : {}),
|
|
845
|
+
visibility: 'basic',
|
|
846
|
+
order,
|
|
847
|
+
role: column.role === 'status' ? 'status' : 'data',
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Builds the discovery contract for a mounted content list. Only rendered
|
|
852
|
+
* columns are published: the search-only `description` column stays private.
|
|
853
|
+
*/
|
|
854
|
+
export function buildContentListSurfaceDescriptor(options = {}) {
|
|
855
|
+
const columnLabels = options.columnLabels ?? {};
|
|
856
|
+
const actionLabels = options.actionLabels ?? {};
|
|
857
|
+
const columns = buildContentListColumns(columnLabels);
|
|
858
|
+
const visibleColumns = CONTENT_LIST_VISIBLE_COLUMN_IDS.map((id, index) => {
|
|
859
|
+
const column = columns.find((candidate) => candidate.id === id);
|
|
860
|
+
if (!column) {
|
|
861
|
+
throw new Error(`Missing content list column definition: ${id}`);
|
|
862
|
+
}
|
|
863
|
+
return surfaceColumn(id, columnLabels, index, column);
|
|
864
|
+
});
|
|
865
|
+
// The row-key column must be declared even though the table renders identity
|
|
866
|
+
// through Svelte keys rather than a visible column.
|
|
867
|
+
const rowKeyColumn = {
|
|
868
|
+
id: CONTENT_LIST_ROW_KEY,
|
|
869
|
+
label: options.rowKeyLabel ?? 'Content id',
|
|
870
|
+
capabilities: ['read', 'project'],
|
|
871
|
+
fieldName: CONTENT_LIST_ROW_KEY,
|
|
872
|
+
role: 'row-key',
|
|
873
|
+
};
|
|
874
|
+
const columnIds = visibleColumns.map((column) => column.id);
|
|
875
|
+
const searchableColumnIds = visibleColumns
|
|
876
|
+
.filter((column) => column.capabilities.includes('search'))
|
|
877
|
+
.map((column) => column.id);
|
|
878
|
+
const actions = [
|
|
879
|
+
{
|
|
880
|
+
id: 'view',
|
|
881
|
+
label: actionLabels.view ?? DEFAULT_ACTION_LABELS.view,
|
|
882
|
+
selectionScopes: ['explicit-ids'],
|
|
883
|
+
columnIds: ['title'],
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
id: 'edit',
|
|
887
|
+
label: actionLabels.edit ?? DEFAULT_ACTION_LABELS.edit,
|
|
888
|
+
selectionScopes: ['explicit-ids'],
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
id: 'delete',
|
|
892
|
+
label: actionLabels.delete ?? DEFAULT_ACTION_LABELS.delete,
|
|
893
|
+
sensitivity: 'sensitive',
|
|
894
|
+
selectionScopes: ['explicit-ids', 'current-page'],
|
|
895
|
+
requiresConfirmation: true,
|
|
896
|
+
},
|
|
897
|
+
];
|
|
898
|
+
return {
|
|
899
|
+
version: 1,
|
|
900
|
+
identity: {
|
|
901
|
+
surfaceId: options.surfaceId ?? CONTENT_LIST_SURFACE_ID,
|
|
902
|
+
kind: 'table',
|
|
903
|
+
...(options.subject ? { subject: options.subject } : {}),
|
|
904
|
+
},
|
|
905
|
+
schemaVersion: CONTENT_LIST_SCHEMA_VERSION,
|
|
906
|
+
label: options.label ?? 'Contents',
|
|
907
|
+
...(options.description ? { description: options.description } : {}),
|
|
908
|
+
rowKey: CONTENT_LIST_ROW_KEY,
|
|
909
|
+
columns: [rowKeyColumn, ...visibleColumns],
|
|
910
|
+
query: {
|
|
911
|
+
modes: ['rows', 'count'],
|
|
912
|
+
projectableColumnIds: [CONTENT_LIST_ROW_KEY, ...columnIds],
|
|
913
|
+
searchableColumnIds,
|
|
914
|
+
filterableColumnIds: columnIds,
|
|
915
|
+
sortableColumnIds: columnIds,
|
|
916
|
+
},
|
|
917
|
+
controls: CONTENT_LIST_CONTROLS.map((control) => ({ ...control })),
|
|
918
|
+
actions,
|
|
919
|
+
limits: { ...DEFAULT_SURFACE_LIMITS, ...options.limits },
|
|
920
|
+
};
|
|
921
|
+
}
|