@byline/host-tanstack-start 3.12.2 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * TreeListView — built-in list view for `tree: true` collections. Reuses the
3
+ * collection-list head/stats look; adds depth indentation, a branch glyph, and
4
+ * an "unplaced" group separator.
5
+ *
6
+ * Override handles:
7
+ * .byline-coll-list-head / -title / -stats — shared with the table list
8
+ * .byline-tree-list-table-wrap — table wrapper
9
+ * .byline-tree-list-title / -branch — indented title cell
10
+ * .byline-tree-list-group — "Unplaced" separator row
11
+ */
12
+
13
+ .head,
14
+ :global(.byline-coll-list-head) {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 0.75rem;
18
+ }
19
+
20
+ .title,
21
+ :global(.byline-coll-list-title) {
22
+ margin: 0;
23
+ padding-bottom: 2px;
24
+ }
25
+
26
+ .stats,
27
+ :global(.byline-coll-list-stats) {
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ margin-right: auto;
32
+ margin-bottom: -4px;
33
+ height: 28px;
34
+ min-width: 28px;
35
+ padding: 5px 6px;
36
+ white-space: nowrap;
37
+ font-size: 0.875rem;
38
+ line-height: 0;
39
+ background-color: var(--gray-25);
40
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
41
+ border-radius: 0.375rem;
42
+ }
43
+
44
+ :is([data-theme="dark"], :global(.dark)) .stats,
45
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-coll-list-stats) {
46
+ background-color: var(--canvas-700);
47
+ }
48
+
49
+ .tableWrap,
50
+ :global(.byline-tree-list-table-wrap) {
51
+ margin-top: 0.75rem;
52
+ margin-bottom: 0.75rem;
53
+ }
54
+
55
+ .titleCell,
56
+ :global(.byline-tree-list-title) {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 0.4rem;
60
+ }
61
+
62
+ .branch,
63
+ :global(.byline-tree-list-branch) {
64
+ color: var(--gray-400);
65
+ font-family: var(--font-mono, monospace);
66
+ user-select: none;
67
+ }
68
+
69
+ .groupRow,
70
+ :global(.byline-tree-list-group) {
71
+ background-color: var(--gray-25);
72
+ }
73
+
74
+ :is([data-theme="dark"], :global(.dark)) .groupRow,
75
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-tree-list-group) {
76
+ background-color: var(--canvas-700);
77
+ }
78
+
79
+ .groupCell {
80
+ font-size: 0.75rem;
81
+ font-weight: 600;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.04em;
84
+ color: var(--gray-500);
85
+ }
86
+
87
+ .cellRight {
88
+ text-align: right;
89
+ }
90
+
91
+ .cellCenter {
92
+ text-align: center;
93
+ }
94
+
95
+ /* ── Drag-to-reorder / re-parent ────────────────────────────────────── */
96
+
97
+ .dragCell {
98
+ width: 34px;
99
+ padding-inline: 0.25rem;
100
+ text-align: center;
101
+ vertical-align: middle;
102
+ }
103
+
104
+ .dragHandle {
105
+ display: inline-flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ padding: 4px;
109
+ color: var(--gray-400);
110
+ background: none;
111
+ border: none;
112
+ border-radius: 4px;
113
+ cursor: grab;
114
+ touch-action: none;
115
+ }
116
+
117
+ .dragHandle:hover {
118
+ color: var(--gray-600);
119
+ background-color: var(--gray-50);
120
+ }
121
+
122
+ .dragHandle:active {
123
+ cursor: grabbing;
124
+ }
125
+
126
+ :is([data-theme="dark"], :global(.dark)) .dragHandle:hover {
127
+ color: var(--gray-200);
128
+ background-color: var(--canvas-700);
129
+ }
130
+
131
+ /* The row being dragged — highlight + a left rule that reads as "this is the
132
+ * grabbed node at its projected depth". */
133
+ .draggingRow {
134
+ background-color: var(--gray-25);
135
+ box-shadow: inset 2px 0 0 var(--theme-400, var(--gray-400));
136
+ }
137
+
138
+ :is([data-theme="dark"], :global(.dark)) .draggingRow {
139
+ background-color: var(--canvas-700);
140
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type React from 'react'
10
+ import { useEffect, useMemo, useState } from 'react'
11
+ import { useRouter } from '@tanstack/react-router'
12
+
13
+ import { renderFormatted, StatusBadge } from '@byline/admin/react'
14
+ import type { ColumnDefinition, WorkflowStatus } from '@byline/core'
15
+ import { useTranslation } from '@byline/i18n/react'
16
+ import {
17
+ Container,
18
+ GripperVerticalIcon,
19
+ IconButton,
20
+ PlusIcon,
21
+ Section,
22
+ Table,
23
+ useToastManager,
24
+ } from '@byline/ui/react'
25
+ import {
26
+ DndContext,
27
+ type DragEndEvent,
28
+ type DragMoveEvent,
29
+ type DragOverEvent,
30
+ type DragStartEvent,
31
+ KeyboardSensor,
32
+ PointerSensor,
33
+ useSensor,
34
+ useSensors,
35
+ } from '@dnd-kit/core'
36
+ import {
37
+ SortableContext,
38
+ sortableKeyboardCoordinates,
39
+ useSortable,
40
+ verticalListSortingStrategy,
41
+ } from '@dnd-kit/sortable'
42
+ import cx from 'classnames'
43
+
44
+ import { Link } from '../chrome/loose-router.js'
45
+ import { TableHeadingCellSortable } from '../chrome/th-sortable.js'
46
+ import { formatNumber } from '../chrome/utils.js'
47
+ import styles from './tree-list.module.css'
48
+ import { applyProjection, getTreeProjection } from './tree-list-projection.js'
49
+ import type { CollectionTreeRow } from '../../server-fns/collections/tree.js'
50
+
51
+ /** px per depth level — must match `--byline-tree-indent` in the CSS and the
52
+ * unit of the drag offset fed to the projection. */
53
+ const INDENT = 24
54
+
55
+ export type TreeMoveFn = (params: {
56
+ documentId: string
57
+ parentDocumentId: string | null
58
+ beforeDocumentId: string | null
59
+ afterDocumentId: string | null
60
+ }) => Promise<unknown>
61
+
62
+ // biome-ignore lint/suspicious/noExplicitAny: tree rows carry heterogeneous fields
63
+ function getColumnValue(row: CollectionTreeRow, fieldName: string): any {
64
+ if (row.fields && fieldName in row.fields) return row.fields[fieldName]
65
+ return (row as Record<string, any>)[fieldName]
66
+ }
67
+
68
+ /**
69
+ * One draggable `<tr>` in the tree. Renders directly (not via `Table.Row`) so it
70
+ * can take dnd-kit's function `ref`. Indentation uses the *projected* depth for
71
+ * the active row mid-drag, so the row visibly shifts level as it is dragged
72
+ * horizontally.
73
+ */
74
+ function SortableTreeRow({
75
+ id,
76
+ depth,
77
+ dragging,
78
+ dragHandleLabel,
79
+ children,
80
+ }: {
81
+ id: string
82
+ depth: number
83
+ dragging: boolean
84
+ dragHandleLabel: string
85
+ children: React.ReactNode
86
+ }) {
87
+ const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
88
+ id,
89
+ })
90
+ const style: React.CSSProperties = {
91
+ transform: transform ? `translate3d(0, ${transform.y}px, 0)` : undefined,
92
+ transition,
93
+ position: 'relative',
94
+ zIndex: isDragging ? 10 : 'auto',
95
+ opacity: isDragging ? 0.5 : 1,
96
+ }
97
+ return (
98
+ <tr
99
+ ref={setNodeRef}
100
+ className={cx('byline-table-row', { [styles.draggingRow]: dragging })}
101
+ style={style}
102
+ >
103
+ <td className={cx('byline-tree-list-drag-cell', styles.dragCell)}>
104
+ <button
105
+ type="button"
106
+ className={cx('byline-tree-list-drag-handle', styles.dragHandle)}
107
+ aria-label={dragHandleLabel}
108
+ {...attributes}
109
+ {...listeners}
110
+ >
111
+ <GripperVerticalIcon />
112
+ </button>
113
+ </td>
114
+ {children}
115
+ </tr>
116
+ )
117
+ }
118
+
119
+ export const TreeListView = ({
120
+ rows,
121
+ columns,
122
+ workflowStatuses,
123
+ useAsTitle,
124
+ collection,
125
+ collectionLabels,
126
+ onMove,
127
+ }: {
128
+ rows: CollectionTreeRow[]
129
+ columns: ColumnDefinition[]
130
+ workflowStatuses?: WorkflowStatus[]
131
+ useAsTitle?: string
132
+ collection: string
133
+ collectionLabels: { singular: string; plural: string }
134
+ /** When provided, the placed tree becomes drag-to-reorder / re-parent. */
135
+ onMove?: TreeMoveFn
136
+ }) => {
137
+ const { t } = useTranslation('byline-admin')
138
+ const router = useRouter()
139
+ const toastManager = useToastManager()
140
+
141
+ const unplaced = useMemo(() => rows.filter((r) => r.unplaced), [rows])
142
+
143
+ // Local mirror of the placed tree so drag can paint optimistically before the
144
+ // server roundtrip; resync from props when fresh loader data arrives and no
145
+ // move is mid-flight (clobbering an in-flight optimistic state flashes the row
146
+ // back). Mirrors the flat-list reorder pattern.
147
+ const [localPlaced, setLocalPlaced] = useState(() => rows.filter((r) => !r.unplaced))
148
+ const [isMoving, setIsMoving] = useState(false)
149
+ useEffect(() => {
150
+ if (!isMoving) setLocalPlaced(rows.filter((r) => !r.unplaced))
151
+ }, [rows, isMoving])
152
+
153
+ const [activeId, setActiveId] = useState<string | null>(null)
154
+ const [overId, setOverId] = useState<string | null>(null)
155
+ const [offsetLeft, setOffsetLeft] = useState(0)
156
+
157
+ const sensors = useSensors(
158
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
159
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
160
+ )
161
+
162
+ // Live projection while dragging — drives the active row's indentation.
163
+ const projection = useMemo(() => {
164
+ if (activeId == null || overId == null) return null
165
+ return getTreeProjection(localPlaced, activeId, overId, offsetLeft, INDENT)
166
+ }, [activeId, overId, offsetLeft, localPlaced])
167
+
168
+ const resetDnd = () => {
169
+ setActiveId(null)
170
+ setOverId(null)
171
+ setOffsetLeft(0)
172
+ }
173
+
174
+ const handleDragStart = ({ active }: DragStartEvent) => setActiveId(String(active.id))
175
+ const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x)
176
+ const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null)
177
+
178
+ const handleDragEnd = async ({ active, over }: DragEndEvent) => {
179
+ const proj =
180
+ over != null
181
+ ? getTreeProjection(localPlaced, String(active.id), String(over.id), offsetLeft, INDENT)
182
+ : null
183
+ resetDnd()
184
+ if (!onMove || proj == null) return
185
+
186
+ const documentId = String(active.id)
187
+ const next = applyProjection(localPlaced, documentId, proj)
188
+ const current = localPlaced
189
+ const unchanged =
190
+ next.map((r) => r.id).join() === current.map((r) => r.id).join() &&
191
+ next.find((r) => r.id === documentId)?.parentId ===
192
+ current.find((r) => r.id === documentId)?.parentId
193
+ if (unchanged) return
194
+
195
+ setLocalPlaced(next)
196
+ setIsMoving(true)
197
+ try {
198
+ await onMove({
199
+ documentId,
200
+ parentDocumentId: proj.parentId,
201
+ beforeDocumentId: proj.beforeId,
202
+ afterDocumentId: proj.afterId,
203
+ })
204
+ await router.invalidate()
205
+ } catch {
206
+ setLocalPlaced(current)
207
+ toastManager.add({
208
+ title: t('collections.list.reorderFailedToast'),
209
+ description: t('collections.list.reorderFailedDescription'),
210
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
211
+ })
212
+ } finally {
213
+ setIsMoving(false)
214
+ }
215
+ }
216
+
217
+ const renderCells = (row: CollectionTreeRow, depth: number) =>
218
+ columns.map((column) => {
219
+ const fieldName = column.fieldName as string
220
+ const isTitle = useAsTitle != null && fieldName === useAsTitle
221
+ return (
222
+ <Table.Cell
223
+ key={fieldName}
224
+ className={cx({
225
+ [styles.cellRight]: column.align === 'right',
226
+ [styles.cellCenter]: column.align === 'center',
227
+ })}
228
+ >
229
+ {isTitle ? (
230
+ <span
231
+ className={cx('byline-tree-list-title', styles.titleCell)}
232
+ style={{ paddingInlineStart: `${depth * INDENT}px` }}
233
+ >
234
+ {depth > 0 && (
235
+ <span aria-hidden="true" className={cx('byline-tree-list-branch', styles.branch)}>
236
+ └─
237
+ </span>
238
+ )}
239
+ <Link
240
+ to={'/admin/collections/$collection/$id' as never}
241
+ params={{ collection, id: row.id }}
242
+ >
243
+ {column.formatter
244
+ ? renderFormatted(getColumnValue(row, fieldName), row, column.formatter)
245
+ : (getColumnValue(row, fieldName) ?? row.path ?? '------')}
246
+ </Link>
247
+ </span>
248
+ ) : column.formatter ? (
249
+ renderFormatted(getColumnValue(row, fieldName), row, column.formatter)
250
+ ) : fieldName === 'status' && workflowStatuses ? (
251
+ <StatusBadge status={row.status} workflowStatuses={workflowStatuses} />
252
+ ) : (
253
+ String(getColumnValue(row, fieldName) ?? '')
254
+ )}
255
+ </Table.Cell>
256
+ )
257
+ })
258
+
259
+ const dndEnabled = onMove != null
260
+ const colSpanLead = dndEnabled ? 1 : 0
261
+
262
+ return (
263
+ <Section>
264
+ <Container>
265
+ <div className={cx('byline-coll-list-head', styles.head)}>
266
+ <h1 className={cx('byline-coll-list-title', styles.title)}>{collectionLabels.plural}</h1>
267
+ <span className={cx('byline-coll-list-stats', styles.stats)}>
268
+ {formatNumber(rows.length, 0)}
269
+ </span>
270
+ <IconButton
271
+ aria-label={t('collections.list.createAriaLabel')}
272
+ render={
273
+ <Link to={'/admin/collections/$collection/create' as never} params={{ collection }} />
274
+ }
275
+ >
276
+ <PlusIcon height="18px" width="18px" svgClassName="stroke-white" />
277
+ </IconButton>
278
+ </div>
279
+
280
+ <Table.Container className={cx('byline-tree-list-table-wrap', styles.tableWrap)}>
281
+ <DndContext
282
+ sensors={sensors}
283
+ onDragStart={handleDragStart}
284
+ onDragMove={handleDragMove}
285
+ onDragOver={handleDragOver}
286
+ onDragEnd={handleDragEnd}
287
+ onDragCancel={resetDnd}
288
+ >
289
+ <Table>
290
+ <Table.Header>
291
+ <Table.Row>
292
+ {dndEnabled && (
293
+ <th scope="col" className={cx('byline-tree-list-drag-cell', styles.dragCell)} />
294
+ )}
295
+ {columns.map((column) => (
296
+ <TableHeadingCellSortable
297
+ key={String(column.fieldName)}
298
+ fieldName={String(column.fieldName)}
299
+ label={column.label}
300
+ sortable={false}
301
+ scope="col"
302
+ align={column.align}
303
+ className={column.className}
304
+ />
305
+ ))}
306
+ </Table.Row>
307
+ </Table.Header>
308
+ <Table.Body>
309
+ <SortableContext
310
+ items={localPlaced.map((r) => r.id)}
311
+ strategy={verticalListSortingStrategy}
312
+ >
313
+ {localPlaced.map((row) => {
314
+ const depth = row.id === activeId && projection ? projection.depth : row.depth
315
+ return dndEnabled ? (
316
+ <SortableTreeRow
317
+ key={row.id}
318
+ id={row.id}
319
+ depth={depth}
320
+ dragging={row.id === activeId}
321
+ dragHandleLabel={t('collections.list.dragHandleAriaLabel')}
322
+ >
323
+ {renderCells(row, depth)}
324
+ </SortableTreeRow>
325
+ ) : (
326
+ <Table.Row key={row.id}>{renderCells(row, row.depth)}</Table.Row>
327
+ )
328
+ })}
329
+ </SortableContext>
330
+
331
+ {unplaced.length > 0 && (
332
+ <Table.Row className={cx('byline-tree-list-group', styles.groupRow)}>
333
+ <Table.Cell className={styles.groupCell} colSpan={colSpanLead + 1}>
334
+ {t('treeListView.unplacedHeading')}
335
+ </Table.Cell>
336
+ {columns.slice(1).map((column) => (
337
+ <Table.Cell key={String(column.fieldName)} />
338
+ ))}
339
+ </Table.Row>
340
+ )}
341
+ {unplaced.map((row) => (
342
+ <Table.Row key={row.id}>
343
+ {dndEnabled && <Table.Cell className={styles.dragCell} />}
344
+ {renderCells(row, 0)}
345
+ </Table.Row>
346
+ ))}
347
+ </Table.Body>
348
+ </Table>
349
+ </DndContext>
350
+ </Table.Container>
351
+ </Container>
352
+ </Section>
353
+ )
354
+ }
@@ -19,10 +19,20 @@
19
19
  import type {
20
20
  BylineFieldServices,
21
21
  GetCollectionDocumentsFn,
22
+ GetTreeAncestorsFn,
23
+ GetTreeParentFn,
24
+ PlaceTreeNodeFn,
25
+ RemoveFromTreeFn,
22
26
  UploadFieldFn,
23
27
  } from '@byline/admin/react'
24
28
 
25
29
  import { getCollectionDocuments as serverGetCollectionDocuments } from '../server-fns/collections/list.js'
30
+ import {
31
+ getTreeAncestors as serverGetTreeAncestors,
32
+ getTreeParent as serverGetTreeParent,
33
+ placeTreeNode as serverPlaceTreeNode,
34
+ removeFromTree as serverRemoveFromTree,
35
+ } from '../server-fns/collections/tree.js'
26
36
  import { uploadField as serverUploadField } from '../server-fns/collections/upload.js'
27
37
 
28
38
  const getCollectionDocuments: GetCollectionDocumentsFn = ({ collection, params }) =>
@@ -33,7 +43,26 @@ const getCollectionDocuments: GetCollectionDocumentsFn = ({ collection, params }
33
43
  const uploadField: UploadFieldFn = (collection, formData, createDocument) =>
34
44
  serverUploadField(collection, formData, createDocument)
35
45
 
46
+ const placeTreeNode: PlaceTreeNodeFn = async (input) => {
47
+ const { orderKey } = await serverPlaceTreeNode({ data: input })
48
+ return { orderKey }
49
+ }
50
+
51
+ const removeFromTree: RemoveFromTreeFn = async (input) => {
52
+ await serverRemoveFromTree({ data: input })
53
+ }
54
+
55
+ const getTreeAncestors: GetTreeAncestorsFn = (input) =>
56
+ serverGetTreeAncestors({ data: input }) as ReturnType<GetTreeAncestorsFn>
57
+
58
+ const getTreeParent: GetTreeParentFn = (input) =>
59
+ serverGetTreeParent({ data: input }) as ReturnType<GetTreeParentFn>
60
+
36
61
  export const bylineFieldServices: BylineFieldServices = {
37
62
  getCollectionDocuments,
38
63
  uploadField,
64
+ placeTreeNode,
65
+ removeFromTree,
66
+ getTreeAncestors,
67
+ getTreeParent,
39
68
  }
@@ -22,10 +22,14 @@ import { z } from 'zod'
22
22
  import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
23
23
  import { useNavigate } from '../admin-shell/chrome/loose-router.js'
24
24
  import { ListView } from '../admin-shell/collections/list.js'
25
+ import { TreeListView } from '../admin-shell/collections/tree-list.js'
25
26
  import {
26
27
  getCollectionDocuments,
28
+ getCollectionTree,
29
+ placeTreeNode,
27
30
  reorderCollectionDocument,
28
31
  } from '../server-fns/collections/index.js'
32
+ import type { CollectionTreeRow } from '../server-fns/collections/tree.js'
29
33
 
30
34
  const searchSchema = z.object({
31
35
  page: z.coerce.number().min(1).optional(),
@@ -71,6 +75,15 @@ export function createCollectionListRoute(path: string) {
71
75
  throw notFound()
72
76
  }
73
77
 
78
+ // `tree: true` collections use the built-in tree list view: load the whole
79
+ // tree (ordered, depth-tagged rows + unplaced docs) rather than a
80
+ // paginated single-collection page.
81
+ if (collectionDef.tree === true) {
82
+ return await getCollectionTree({
83
+ data: { collection: params.collection, locale: deps.locale },
84
+ })
85
+ }
86
+
74
87
  // Derive the field names the list view needs from the admin column config.
75
88
  // This lets findDocuments query only the relevant store tables.
76
89
  const adminConfig = getCollectionAdminConfig(params.collection)
@@ -157,7 +170,32 @@ export function createCollectionListRoute(path: string) {
157
170
  },
158
171
  ]}
159
172
  />
160
- {CustomListView ? (
173
+ {collectionDef.tree === true ? (
174
+ <TreeListView
175
+ rows={(data as { rows: CollectionTreeRow[] }).rows}
176
+ columns={columns}
177
+ workflowStatuses={workflowStatuses}
178
+ useAsTitle={collectionDef.useAsTitle}
179
+ collection={collection}
180
+ collectionLabels={data.included.collection.labels}
181
+ onMove={async ({
182
+ documentId,
183
+ parentDocumentId,
184
+ beforeDocumentId,
185
+ afterDocumentId,
186
+ }) => {
187
+ await placeTreeNode({
188
+ data: {
189
+ collection,
190
+ documentId,
191
+ parentDocumentId,
192
+ beforeDocumentId,
193
+ afterDocumentId,
194
+ },
195
+ })
196
+ }}
197
+ />
198
+ ) : CustomListView ? (
161
199
  <CustomListView data={data} workflowStatuses={workflowStatuses} />
162
200
  ) : (
163
201
  <ListView
@@ -18,5 +18,6 @@ export * from './reorder'
18
18
  export * from './restore-version'
19
19
  export * from './stats'
20
20
  export * from './status'
21
+ export * from './tree'
21
22
  export * from './update'
22
23
  export * from './upload'