@byline/host-tanstack-start 1.10.3 → 1.11.1

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.
@@ -6,13 +6,14 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import { useMemo, useState } from 'react'
10
- import { useRouterState } from '@tanstack/react-router'
9
+ import { useEffect, useMemo, useState } from 'react'
10
+ import { useRouter, useRouterState } from '@tanstack/react-router'
11
11
 
12
12
  import type { ColumnDefinition, WorkflowStatus } from '@byline/core'
13
13
  import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
14
14
  import {
15
15
  Container,
16
+ GripperVerticalIcon,
16
17
  IconButton,
17
18
  LoaderRing,
18
19
  PlusIcon,
@@ -22,15 +23,37 @@ import {
22
23
  Select,
23
24
  StatusBadge,
24
25
  Table,
26
+ useToastManager,
25
27
  } from '@byline/ui/react'
28
+ import {
29
+ DndContext,
30
+ type DragEndEvent,
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'
26
42
  import cx from 'classnames'
27
43
 
28
44
  import { Link, useNavigate } from '../chrome/loose-router.js'
29
45
  import { RouterPager } from '../chrome/router-pager.js'
46
+ import { SortAscendingIcon } from '../chrome/sort-icons.js'
30
47
  import { TableHeadingCellSortable } from '../chrome/th-sortable.js'
31
48
  import { formatNumber } from '../chrome/utils.js'
32
49
  import styles from './list.module.css'
33
50
 
51
+ type ReorderFn = (params: {
52
+ documentId: string
53
+ beforeDocumentId: string | null
54
+ afterDocumentId: string | null
55
+ }) => Promise<unknown>
56
+
34
57
  /**
35
58
  * Resolve a column value from a document, checking `fields` first (user-defined
36
59
  * collection fields) then the root (metadata like status, updated_at).
@@ -76,20 +99,145 @@ function padRows(value: number) {
76
99
  ))
77
100
  }
78
101
 
102
+ /**
103
+ * One `<tr>` participating in dnd-kit's vertical-list sort. Renders the row
104
+ * directly (not via `Table.Row`) because the row needs a function `ref`
105
+ * callback for dnd-kit; `Table.Row`'s typed `RefObject` prop wouldn't
106
+ * accept that.
107
+ */
108
+ function SortableTableRow({
109
+ id,
110
+ disabled,
111
+ children,
112
+ }: {
113
+ id: string
114
+ disabled: boolean
115
+ children: React.ReactNode
116
+ }) {
117
+ const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
118
+ id,
119
+ disabled,
120
+ })
121
+ const style: React.CSSProperties = {
122
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
123
+ transition,
124
+ position: 'relative',
125
+ zIndex: isDragging ? 10 : 'auto',
126
+ opacity: isDragging ? 0.6 : 1,
127
+ }
128
+ return (
129
+ <tr ref={setNodeRef} className="byline-table-row" style={style}>
130
+ <td className={cx('byline-coll-list-drag-cell', styles.dragCell)}>
131
+ <button
132
+ type="button"
133
+ className={cx('byline-coll-list-drag-handle', styles.dragHandle)}
134
+ aria-label={
135
+ disabled ? 'Drag disabled while filters or search are active' : 'Drag to reorder'
136
+ }
137
+ disabled={disabled}
138
+ {...attributes}
139
+ {...listeners}
140
+ >
141
+ <GripperVerticalIcon />
142
+ </button>
143
+ </td>
144
+ {children}
145
+ </tr>
146
+ )
147
+ }
148
+
79
149
  export const ListView = ({
80
150
  data,
81
151
  columns,
82
152
  workflowStatuses,
83
153
  useAsTitle,
154
+ orderable = false,
155
+ onReorder,
84
156
  }: {
85
157
  data: AnyCollectionSchemaTypes['ListType']
86
158
  columns: ColumnDefinition[]
87
159
  workflowStatuses?: WorkflowStatus[]
88
160
  useAsTitle?: string
161
+ /** When true, render a drag handle column and enable drag-to-reorder. */
162
+ orderable?: boolean
163
+ /** Persists a single-row reorder via the host's reorder server fn. */
164
+ onReorder?: ReorderFn
89
165
  }) => {
90
166
  const navigate = useNavigate()
167
+ const router = useRouter()
168
+ const toastManager = useToastManager()
91
169
  const location = useRouterState({ select: (s) => s.location })
92
170
 
171
+ // Local mirror of the loader docs so drag-and-drop can paint the new
172
+ // order optimistically before the server roundtrip completes. We resync
173
+ // from `data.docs` whenever fresh loader data arrives (after a
174
+ // `router.invalidate()`), unless a reorder is mid-flight — clobbering
175
+ // an in-flight optimistic state would flash the row back to its old
176
+ // position. See the dnd-kit + admin-roles list pattern for the same
177
+ // ordering invariant.
178
+ const [localDocs, setLocalDocs] = useState(data.docs)
179
+ const [isReordering, setIsReordering] = useState(false)
180
+ useEffect(() => {
181
+ if (!isReordering) {
182
+ setLocalDocs(data.docs)
183
+ }
184
+ }, [data.docs, isReordering])
185
+
186
+ // Drag is only meaningful in the canonical view: the default order_key
187
+ // sort, no search, no status filter. Otherwise the visible order isn't
188
+ // the stored order and "drop between A and B" maps onto the wrong
189
+ // neighbour ids.
190
+ const searchParams = location.search as {
191
+ order?: string
192
+ desc?: boolean
193
+ query?: string
194
+ status?: string
195
+ }
196
+ const isCanonicalView =
197
+ !searchParams.order && !searchParams.desc && !searchParams.query && !searchParams.status
198
+ const dragEnabled = orderable && isCanonicalView && !!onReorder
199
+
200
+ const sensors = useSensors(
201
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
202
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
203
+ )
204
+
205
+ const handleDragEnd = async (event: DragEndEvent) => {
206
+ const { active, over } = event
207
+ if (!over || active.id === over.id || !onReorder) return
208
+ const oldIndex = localDocs.findIndex((d) => d.id === active.id)
209
+ const newIndex = localDocs.findIndex((d) => d.id === over.id)
210
+ if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return
211
+
212
+ const previousDocs = localDocs
213
+ const next = [...localDocs]
214
+ const [moved] = next.splice(oldIndex, 1)
215
+ if (!moved) return
216
+ next.splice(newIndex, 0, moved)
217
+ setLocalDocs(next)
218
+ setIsReordering(true)
219
+
220
+ const before = next[newIndex - 1]?.id ?? null
221
+ const after = next[newIndex + 1]?.id ?? null
222
+ try {
223
+ await onReorder({
224
+ documentId: String(active.id),
225
+ beforeDocumentId: before,
226
+ afterDocumentId: after,
227
+ })
228
+ await router.invalidate()
229
+ } catch (_err) {
230
+ setLocalDocs(previousDocs)
231
+ toastManager.add({
232
+ title: 'Could not save the new order',
233
+ description: 'Please try again.',
234
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
235
+ })
236
+ } finally {
237
+ setIsReordering(false)
238
+ }
239
+ }
240
+
93
241
  // Memoized so Base UI's SelectRoot doesn't see a fresh items identity on
94
242
  // every render — a non-stable items array combined with a controlled value
95
243
  // trips an internal store sync loop (manifests as "Maximum update depth
@@ -204,77 +352,191 @@ export const ListView = ({
204
352
  />
205
353
  </div>
206
354
  <Table.Container className={cx('byline-coll-list-table-wrap', styles.tableWrap)}>
207
- <Table>
208
- <Table.Header>
209
- <Table.Row>
210
- {columns.map((column) => {
211
- return (
212
- <TableHeadingCellSortable
213
- key={String(column.fieldName)}
214
- fieldName={String(column.fieldName)}
215
- label={column.label}
216
- sortable={column.sortable}
217
- scope="col"
218
- align={column.align}
219
- className={column.className}
220
- />
221
- )
222
- })}
223
- </Table.Row>
224
- </Table.Header>
355
+ {orderable ? (
356
+ <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
357
+ <SortableContext
358
+ items={localDocs.map((d) => d.id)}
359
+ strategy={verticalListSortingStrategy}
360
+ >
361
+ <Table>
362
+ <Table.Header>
363
+ <Table.Row>
364
+ <th scope="col" className={cx('byline-coll-list-drag-cell', styles.dragCell)}>
365
+ <button
366
+ type="button"
367
+ className={cx(
368
+ 'byline-coll-list-order-header',
369
+ styles.orderHeader,
370
+ isCanonicalView && [
371
+ 'byline-coll-list-order-header-active',
372
+ styles.orderHeaderActive,
373
+ ]
374
+ )}
375
+ onClick={() => {
376
+ // Clear any non-canonical sort and search-related
377
+ // state so the visible order matches what drag
378
+ // operations will mutate. Default sort then falls
379
+ // back to `order_key asc` server-side.
380
+ const params = structuredClone(
381
+ location.search as Record<string, unknown>
382
+ )
383
+ delete params.page
384
+ delete params.order
385
+ delete params.desc
386
+ navigate({
387
+ to: location.pathname as never,
388
+ search: params,
389
+ })
390
+ }}
391
+ aria-label="Sort by manual order"
392
+ title="Sort by manual order"
393
+ >
394
+ <SortAscendingIcon />
395
+ </button>
396
+ </th>
397
+ {columns.map((column) => {
398
+ return (
399
+ <TableHeadingCellSortable
400
+ key={String(column.fieldName)}
401
+ fieldName={String(column.fieldName)}
402
+ label={column.label}
403
+ sortable={column.sortable}
404
+ scope="col"
405
+ align={column.align}
406
+ className={column.className}
407
+ />
408
+ )
409
+ })}
410
+ </Table.Row>
411
+ </Table.Header>
225
412
 
226
- <Table.Body>
227
- {data?.docs?.map((document) => {
228
- return (
229
- <Table.Row key={document.id}>
230
- {columns.map((column) => (
231
- <Table.Cell
232
- key={String(column.fieldName)}
233
- className={cx({
234
- 'byline-coll-list-cell-right': column.align === 'right',
235
- [styles.cellRight]: column.align === 'right',
236
- 'byline-coll-list-cell-center': column.align === 'center',
237
- [styles.cellCenter]: column.align === 'center',
238
- })}
239
- >
240
- {useAsTitle && column.fieldName === useAsTitle ? (
241
- <Link
242
- to={'/admin/collections/$collection/$id' as never}
243
- params={{
244
- collection: data.included.collection.path,
245
- id: document.id,
246
- }}
413
+ <Table.Body>
414
+ {localDocs.map((document) => (
415
+ <SortableTableRow key={document.id} id={document.id} disabled={!dragEnabled}>
416
+ {columns.map((column) => (
417
+ <Table.Cell
418
+ key={String(column.fieldName)}
419
+ className={cx({
420
+ 'byline-coll-list-cell-right': column.align === 'right',
421
+ [styles.cellRight]: column.align === 'right',
422
+ 'byline-coll-list-cell-center': column.align === 'center',
423
+ [styles.cellCenter]: column.align === 'center',
424
+ })}
247
425
  >
248
- {column.formatter
249
- ? renderFormatted(
250
- getColumnValue(document, column.fieldName as string),
251
- document,
252
- column.formatter
253
- )
254
- : (getColumnValue(document, column.fieldName as string) ?? '------')}
255
- </Link>
256
- ) : column.formatter ? (
257
- renderFormatted(
258
- getColumnValue(document, column.fieldName as string),
259
- document,
260
- column.formatter
261
- )
262
- ) : column.fieldName === 'status' && workflowStatuses ? (
263
- <StatusBadge
264
- status={document.status}
265
- workflowStatuses={workflowStatuses}
266
- hasPublishedVersion={document.hasPublishedVersion}
267
- />
268
- ) : (
269
- String(getColumnValue(document, column.fieldName as string) ?? '')
270
- )}
271
- </Table.Cell>
426
+ {useAsTitle && column.fieldName === useAsTitle ? (
427
+ <Link
428
+ to={'/admin/collections/$collection/$id' as never}
429
+ params={{
430
+ collection: data.included.collection.path,
431
+ id: document.id,
432
+ }}
433
+ >
434
+ {column.formatter
435
+ ? renderFormatted(
436
+ getColumnValue(document, column.fieldName as string),
437
+ document,
438
+ column.formatter
439
+ )
440
+ : (getColumnValue(document, column.fieldName as string) ??
441
+ '------')}
442
+ </Link>
443
+ ) : column.formatter ? (
444
+ renderFormatted(
445
+ getColumnValue(document, column.fieldName as string),
446
+ document,
447
+ column.formatter
448
+ )
449
+ ) : column.fieldName === 'status' && workflowStatuses ? (
450
+ <StatusBadge
451
+ status={document.status}
452
+ workflowStatuses={workflowStatuses}
453
+ hasPublishedVersion={document.hasPublishedVersion}
454
+ />
455
+ ) : (
456
+ String(getColumnValue(document, column.fieldName as string) ?? '')
457
+ )}
458
+ </Table.Cell>
459
+ ))}
460
+ </SortableTableRow>
272
461
  ))}
273
- </Table.Row>
274
- )
275
- })}
276
- </Table.Body>
277
- </Table>
462
+ </Table.Body>
463
+ </Table>
464
+ </SortableContext>
465
+ </DndContext>
466
+ ) : (
467
+ <Table>
468
+ <Table.Header>
469
+ <Table.Row>
470
+ {columns.map((column) => {
471
+ return (
472
+ <TableHeadingCellSortable
473
+ key={String(column.fieldName)}
474
+ fieldName={String(column.fieldName)}
475
+ label={column.label}
476
+ sortable={column.sortable}
477
+ scope="col"
478
+ align={column.align}
479
+ className={column.className}
480
+ />
481
+ )
482
+ })}
483
+ </Table.Row>
484
+ </Table.Header>
485
+
486
+ <Table.Body>
487
+ {data?.docs?.map((document) => {
488
+ return (
489
+ <Table.Row key={document.id}>
490
+ {columns.map((column) => (
491
+ <Table.Cell
492
+ key={String(column.fieldName)}
493
+ className={cx({
494
+ 'byline-coll-list-cell-right': column.align === 'right',
495
+ [styles.cellRight]: column.align === 'right',
496
+ 'byline-coll-list-cell-center': column.align === 'center',
497
+ [styles.cellCenter]: column.align === 'center',
498
+ })}
499
+ >
500
+ {useAsTitle && column.fieldName === useAsTitle ? (
501
+ <Link
502
+ to={'/admin/collections/$collection/$id' as never}
503
+ params={{
504
+ collection: data.included.collection.path,
505
+ id: document.id,
506
+ }}
507
+ >
508
+ {column.formatter
509
+ ? renderFormatted(
510
+ getColumnValue(document, column.fieldName as string),
511
+ document,
512
+ column.formatter
513
+ )
514
+ : (getColumnValue(document, column.fieldName as string) ??
515
+ '------')}
516
+ </Link>
517
+ ) : column.formatter ? (
518
+ renderFormatted(
519
+ getColumnValue(document, column.fieldName as string),
520
+ document,
521
+ column.formatter
522
+ )
523
+ ) : column.fieldName === 'status' && workflowStatuses ? (
524
+ <StatusBadge
525
+ status={document.status}
526
+ workflowStatuses={workflowStatuses}
527
+ hasPublishedVersion={document.hasPublishedVersion}
528
+ />
529
+ ) : (
530
+ String(getColumnValue(document, column.fieldName as string) ?? '')
531
+ )}
532
+ </Table.Cell>
533
+ ))}
534
+ </Table.Row>
535
+ )
536
+ })}
537
+ </Table.Body>
538
+ </Table>
539
+ )}
278
540
  {padRows(6 - (data?.docs?.length ?? 0))}
279
541
  </Table.Container>
280
542
  <div
@@ -21,7 +21,10 @@ import { z } from 'zod'
21
21
  import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
22
22
  import { useNavigate } from '../admin-shell/chrome/loose-router.js'
23
23
  import { ListView } from '../admin-shell/collections/list.js'
24
- import { getCollectionDocuments } from '../server-fns/collections/index.js'
24
+ import {
25
+ getCollectionDocuments,
26
+ reorderCollectionDocument,
27
+ } from '../server-fns/collections/index.js'
25
28
 
26
29
  const searchSchema = z.object({
27
30
  page: z.coerce.number().min(1).optional(),
@@ -164,6 +167,17 @@ export function createCollectionListRoute(path: string) {
164
167
  columns={columns}
165
168
  workflowStatuses={workflowStatuses}
166
169
  useAsTitle={collectionDef.useAsTitle}
170
+ orderable={adminConfig?.orderable === true}
171
+ onReorder={async ({ documentId, beforeDocumentId, afterDocumentId }) => {
172
+ await reorderCollectionDocument({
173
+ data: {
174
+ collection,
175
+ documentId,
176
+ beforeDocumentId,
177
+ afterDocumentId,
178
+ },
179
+ })
180
+ }}
167
181
  />
168
182
  )}
169
183
  </>
@@ -12,6 +12,7 @@ export * from './duplicate'
12
12
  export * from './get'
13
13
  export * from './history'
14
14
  export * from './list'
15
+ export * from './reorder'
15
16
  export * from './restore-version'
16
17
  export * from './stats'
17
18
  export * from './status'
@@ -10,6 +10,7 @@ import { createServerFn } from '@tanstack/react-start'
10
10
 
11
11
  import {
12
12
  ERR_NOT_FOUND,
13
+ getCollectionAdminConfig,
13
14
  getCollectionSchemasForPath,
14
15
  getLogger,
15
16
  getServerConfig,
@@ -65,9 +66,19 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
65
66
  if (params.status) where.status = params.status
66
67
  if (params.query) where.query = params.query
67
68
 
69
+ // Default sort for `orderable: true` collections is the fractional
70
+ // `order_key` ascending. Caller's explicit `params.order` always wins
71
+ // so the admin can re-sort by other columns without surprise.
72
+ const adminConfig = getCollectionAdminConfig(path)
73
+ const defaultSort: Record<string, 'asc' | 'desc'> | undefined =
74
+ adminConfig?.orderable === true ? { order_key: 'asc' } : undefined
75
+ const sortSpec: Record<string, 'asc' | 'desc'> | undefined = params.order
76
+ ? { [params.order]: params.desc === false ? 'asc' : 'desc' }
77
+ : defaultSort
78
+
68
79
  const result = await handle.find({
69
80
  where: Object.keys(where).length > 0 ? where : undefined,
70
- sort: params.order ? { [params.order]: params.desc === false ? 'asc' : 'desc' } : undefined,
81
+ sort: sortSpec,
71
82
  locale: params.locale ?? 'en',
72
83
  page: params.page,
73
84
  pageSize,