@byline/host-tanstack-start 3.21.0 → 4.0.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.
Files changed (134) hide show
  1. package/dist/admin-shell/admin-activity/list.js +15 -5
  2. package/dist/admin-shell/admin-roles/delete.js +2 -1
  3. package/dist/admin-shell/admin-roles/list.js +2 -1
  4. package/dist/admin-shell/admin-users/delete.js +2 -1
  5. package/dist/admin-shell/admin-users/list.js +4 -7
  6. package/dist/admin-shell/chrome/admin-app-bar.js +2 -1
  7. package/dist/admin-shell/chrome/admin-menu-drawer.js +9 -8
  8. package/dist/admin-shell/chrome/admin-menu-provider.js +2 -1
  9. package/dist/admin-shell/chrome/dashboard.js +4 -3
  10. package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -2
  11. package/dist/admin-shell/chrome/sign-in-page.js +2 -2
  12. package/dist/admin-shell/collections/create.js +4 -3
  13. package/dist/admin-shell/collections/document-history.js +6 -1
  14. package/dist/admin-shell/collections/edit.js +18 -14
  15. package/dist/admin-shell/collections/history.js +5 -4
  16. package/dist/admin-shell/collections/list.js +8 -7
  17. package/dist/admin-shell/collections/restore-version-modal.js +2 -1
  18. package/dist/admin-shell/collections/tree-list.js +53 -17
  19. package/dist/admin-shell/collections/view-menu.js +9 -8
  20. package/dist/auth/auth-context.d.ts +13 -0
  21. package/dist/auth/auth-context.js +4 -0
  22. package/dist/auth/request-scope.d.ts +16 -0
  23. package/dist/auth/request-scope.js +24 -0
  24. package/dist/integrations/abilities.js +8 -7
  25. package/dist/integrations/byline-public-client.d.ts +38 -0
  26. package/dist/integrations/byline-public-client.js +8 -4
  27. package/dist/integrations/byline-viewer-client.d.ts +6 -0
  28. package/dist/integrations/byline-viewer-client.js +22 -17
  29. package/dist/integrations/start-errors.js +2 -1
  30. package/dist/routes/admin-path.d.ts +22 -0
  31. package/dist/routes/admin-path.js +35 -0
  32. package/dist/routes/create-admin-account-route.js +3 -2
  33. package/dist/routes/create-admin-activity-route.js +3 -2
  34. package/dist/routes/create-admin-dashboard-route.js +2 -1
  35. package/dist/routes/create-admin-layout-route.d.ts +1 -1
  36. package/dist/routes/create-admin-layout-route.js +3 -1
  37. package/dist/routes/create-admin-permissions-route.js +3 -2
  38. package/dist/routes/create-admin-role-edit-route.js +4 -3
  39. package/dist/routes/create-admin-roles-list-route.js +3 -2
  40. package/dist/routes/create-admin-user-edit-route.js +4 -3
  41. package/dist/routes/create-admin-users-list-route.js +3 -2
  42. package/dist/routes/create-collection-api-route.js +5 -4
  43. package/dist/routes/create-collection-create-route.js +4 -3
  44. package/dist/routes/create-collection-edit-route.js +4 -3
  45. package/dist/routes/create-collection-history-route.js +5 -4
  46. package/dist/routes/create-collection-list-route.js +3 -2
  47. package/dist/routes/create-sign-in-route.js +3 -2
  48. package/dist/routes/index.d.ts +3 -0
  49. package/dist/routes/index.js +2 -0
  50. package/dist/routes/sign-in-path.d.ts +13 -0
  51. package/dist/routes/sign-in-path.js +19 -0
  52. package/dist/server-fns/collections/delete-outcome.d.ts +30 -0
  53. package/dist/server-fns/collections/delete-outcome.js +34 -0
  54. package/dist/server-fns/collections/delete.d.ts +1 -4
  55. package/dist/server-fns/collections/delete.js +2 -4
  56. package/dist/server-fns/collections/index.d.ts +1 -0
  57. package/dist/server-fns/collections/index.js +1 -0
  58. package/dist/server-fns/collections/tree-mutation.d.ts +46 -0
  59. package/dist/server-fns/collections/tree-mutation.js +109 -0
  60. package/dist/server-fns/collections/tree-read.d.ts +6 -0
  61. package/dist/server-fns/collections/tree-read.js +15 -0
  62. package/dist/server-fns/collections/tree.d.ts +5 -4
  63. package/dist/server-fns/collections/tree.js +15 -12
  64. package/dist/server-fns/collections/update.js +2 -1
  65. package/dist/vite/client-hook-boundary.d.ts +19 -0
  66. package/dist/vite/client-hook-boundary.js +20 -0
  67. package/dist/vite/index.d.ts +8 -0
  68. package/dist/vite/index.js +1 -0
  69. package/package.json +15 -9
  70. package/src/admin-shell/admin-activity/list.tsx +13 -3
  71. package/src/admin-shell/admin-roles/delete.tsx +2 -1
  72. package/src/admin-shell/admin-roles/list.tsx +2 -1
  73. package/src/admin-shell/admin-users/delete.tsx +3 -2
  74. package/src/admin-shell/admin-users/list.tsx +9 -8
  75. package/src/admin-shell/chrome/admin-app-bar.tsx +2 -1
  76. package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -8
  77. package/src/admin-shell/chrome/admin-menu-provider.tsx +2 -1
  78. package/src/admin-shell/chrome/dashboard.tsx +4 -3
  79. package/src/admin-shell/chrome/sign-in-page.tsx +3 -3
  80. package/src/admin-shell/collections/create.tsx +4 -3
  81. package/src/admin-shell/collections/document-history.tsx +5 -0
  82. package/src/admin-shell/collections/edit.tsx +20 -13
  83. package/src/admin-shell/collections/history.tsx +6 -5
  84. package/src/admin-shell/collections/list.tsx +8 -7
  85. package/src/admin-shell/collections/restore-version-modal.tsx +2 -1
  86. package/src/admin-shell/collections/tree-list.tsx +78 -22
  87. package/src/admin-shell/collections/view-menu.tsx +9 -8
  88. package/src/auth/auth-context.test.node.ts +87 -0
  89. package/src/auth/auth-context.ts +18 -0
  90. package/src/auth/request-scope.test.node.ts +93 -0
  91. package/src/auth/request-scope.ts +62 -0
  92. package/src/integrations/abilities.tsx +14 -4
  93. package/src/integrations/byline-client.ts +6 -4
  94. package/src/integrations/byline-public-client.ts +18 -4
  95. package/src/integrations/byline-viewer-client.ts +30 -18
  96. package/src/integrations/request-context-contract.test.node.ts +131 -0
  97. package/src/integrations/request-context-factories.test.node.ts +140 -0
  98. package/src/integrations/start-errors.test.node.ts +22 -0
  99. package/src/integrations/start-errors.ts +1 -0
  100. package/src/routes/admin-path.test.node.ts +129 -0
  101. package/src/routes/admin-path.ts +66 -0
  102. package/src/routes/auth-routes.test.node.ts +39 -0
  103. package/src/routes/create-admin-account-route.tsx +3 -2
  104. package/src/routes/create-admin-activity-route.tsx +3 -2
  105. package/src/routes/create-admin-dashboard-route.tsx +2 -1
  106. package/src/routes/create-admin-layout-route.tsx +4 -4
  107. package/src/routes/create-admin-permissions-route.tsx +3 -2
  108. package/src/routes/create-admin-role-edit-route.tsx +7 -3
  109. package/src/routes/create-admin-roles-list-route.tsx +3 -2
  110. package/src/routes/create-admin-user-edit-route.tsx +7 -3
  111. package/src/routes/create-admin-users-list-route.tsx +3 -2
  112. package/src/routes/create-collection-api-route.tsx +8 -4
  113. package/src/routes/create-collection-create-route.tsx +7 -3
  114. package/src/routes/create-collection-edit-route.tsx +7 -3
  115. package/src/routes/create-collection-history-route.tsx +8 -4
  116. package/src/routes/create-collection-list-route.tsx +3 -2
  117. package/src/routes/create-sign-in-route.tsx +9 -3
  118. package/src/routes/index.ts +11 -0
  119. package/src/routes/sign-in-path.test.node.ts +79 -0
  120. package/src/routes/sign-in-path.ts +36 -0
  121. package/src/server-fns/collections/delete-outcome.test.node.ts +72 -0
  122. package/src/server-fns/collections/delete-outcome.ts +80 -0
  123. package/src/server-fns/collections/delete.ts +2 -1
  124. package/src/server-fns/collections/get.ts +2 -0
  125. package/src/server-fns/collections/index.ts +1 -0
  126. package/src/server-fns/collections/tree-mutation.test.node.ts +225 -0
  127. package/src/server-fns/collections/tree-mutation.ts +165 -0
  128. package/src/server-fns/collections/tree-read.test.node.ts +46 -0
  129. package/src/server-fns/collections/tree-read.ts +26 -0
  130. package/src/server-fns/collections/tree.ts +26 -10
  131. package/src/server-fns/collections/update.ts +3 -0
  132. package/src/vite/client-hook-boundary.test.node.ts +50 -0
  133. package/src/vite/client-hook-boundary.ts +45 -0
  134. package/src/vite/index.ts +9 -0
@@ -12,6 +12,7 @@ import type React from 'react'
12
12
  import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
13
13
  import { useRouterState } from '@tanstack/react-router'
14
14
 
15
+ import { isAdminRoutePathActive } from '../../routes/admin-path.js'
15
16
  import { useMediaQuery } from './use-media-query.js'
16
17
 
17
18
  interface AdminMenuContextType {
@@ -37,7 +38,7 @@ interface AdminMenuProviderProps {
37
38
  * deliberate open stays open.
38
39
  */
39
40
  function isWideRoute(pathname: string): boolean {
40
- return pathname.startsWith('/admin/collections')
41
+ return isAdminRoutePathActive(pathname, 'collections')
41
42
  }
42
43
 
43
44
  export function AdminMenuProvider({ children }: AdminMenuProviderProps): React.JSX.Element {
@@ -12,6 +12,7 @@ import { useTranslation } from '@byline/i18n/react'
12
12
  import { Card, Container, Section } from '@byline/ui/react'
13
13
  import cx from 'classnames'
14
14
 
15
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
15
16
  import styles from './dashboard.module.css'
16
17
  import { Link } from './loose-router.js'
17
18
  import type { CollectionStatusCount } from '../../server-fns/collections/index.js'
@@ -42,7 +43,7 @@ function StatTile({
42
43
  const modifier = tileModifier(ws.name)
43
44
  return (
44
45
  <Link
45
- to={'/admin/collections/$collection' as never}
46
+ to={getAdminRoutePath('collections', '$collection')}
46
47
  params={{ collection: collectionPath }}
47
48
  search={{ status: ws.name }}
48
49
  className={cx('byline-dashboard-stat-tile', styles.statTile, modifier.global, modifier.local)}
@@ -77,7 +78,7 @@ export function AdminDashboard({ statsMap }: AdminDashboardProps) {
77
78
  return (
78
79
  <Card key={collection.path}>
79
80
  <Link
80
- to={'/admin/collections/$collection' as never}
81
+ to={getAdminRoutePath('collections', '$collection')}
81
82
  params={{ collection: collection.path }}
82
83
  className={cx('byline-dashboard-card-link', styles.cardLink)}
83
84
  >
@@ -114,7 +115,7 @@ export function AdminDashboard({ statsMap }: AdminDashboardProps) {
114
115
  </div>
115
116
  ) : (
116
117
  <Link
117
- to={'/admin/collections/$collection' as never}
118
+ to={getAdminRoutePath('collections', '$collection')}
118
119
  params={{ collection: collection.path }}
119
120
  className={cx('byline-dashboard-empty-link', styles.emptyLink)}
120
121
  >
@@ -19,7 +19,7 @@ import { setInterfaceLocaleFn } from '../../server-fns/i18n/index.js'
19
19
  import styles from './sign-in-page.module.css'
20
20
 
21
21
  interface SignInPageProps {
22
- callbackUrl?: string
22
+ redirectTo: string
23
23
  activeLocale: LocaleCode
24
24
  }
25
25
 
@@ -44,7 +44,7 @@ interface SignInPageProps {
44
44
  * the form's action row can render a plain "Home" link beside the submit
45
45
  * button.
46
46
  */
47
- export function SignInPage({ callbackUrl, activeLocale }: SignInPageProps) {
47
+ export function SignInPage({ redirectTo, activeLocale }: SignInPageProps) {
48
48
  const { i18n, serverURL } = getClientConfig()
49
49
  const localeDefinitions = buildLocaleDefinitions(
50
50
  i18n.interface.locales,
@@ -68,7 +68,7 @@ export function SignInPage({ callbackUrl, activeLocale }: SignInPageProps) {
68
68
  <LanguageMenu />
69
69
  </div>
70
70
  <div className={cx('byline-sign-in-page-inner', styles.inner)}>
71
- <SignInForm callbackUrl={callbackUrl} homeUrl={serverURL} />
71
+ <SignInForm redirectTo={redirectTo} homeUrl={serverURL} />
72
72
  </div>
73
73
  </main>
74
74
  </BylineAdminServicesProvider>
@@ -13,6 +13,7 @@ import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
13
13
  import { useTranslation } from '@byline/i18n/react'
14
14
  import { Container, Section, useToastManager } from '@byline/ui/react'
15
15
 
16
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
16
17
  import { createCollectionDocument } from '../../server-fns/collections/index.js'
17
18
  import { useNavigate } from '../chrome/loose-router.js'
18
19
  import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
@@ -66,13 +67,13 @@ export const CreateView = ({
66
67
  // doesn't return the new document's id.
67
68
  if (result?.documentId) {
68
69
  navigate({
69
- to: '/admin/collections/$collection/$id' as never,
70
+ to: getAdminRoutePath('collections', '$collection', '$id'),
70
71
  params: { collection: path, id: result.documentId } as never,
71
72
  search: { action: 'created' },
72
73
  })
73
74
  } else {
74
75
  navigate({
75
- to: '/admin/collections/$collection' as never,
76
+ to: getAdminRoutePath('collections', '$collection'),
76
77
  params: { collection: path },
77
78
  search: { action: 'created' },
78
79
  })
@@ -117,7 +118,7 @@ export const CreateView = ({
117
118
  useNavigationGuard={useTanStackNavigationGuard}
118
119
  onCancel={() =>
119
120
  navigate({
120
- to: '/admin/collections/$collection' as never,
121
+ to: getAdminRoutePath('collections', '$collection'),
121
122
  params: { collection: path },
122
123
  })
123
124
  }
@@ -52,12 +52,17 @@ const ACTION_KEYS: Record<string, string> = {
52
52
  'document.locales.changed': 'collections.documentHistory.actionLocalesChanged',
53
53
  'document.status.changed': 'collections.documentHistory.actionStatusChanged',
54
54
  'document.deleted': 'collections.documentHistory.actionDeleted',
55
+ 'document.tree.placed': 'collections.documentHistory.actionTreePlaced',
56
+ 'document.tree.reparented': 'collections.documentHistory.actionTreeReparented',
57
+ 'document.tree.reordered': 'collections.documentHistory.actionTreeReordered',
58
+ 'document.tree.removed': 'collections.documentHistory.actionTreeRemoved',
55
59
  }
56
60
 
57
61
  /** Render an audit before/after value inline: arrays comma-join, nullish → em-dash. */
58
62
  function formatAuditValue(value: unknown): string {
59
63
  if (value == null) return '—'
60
64
  if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '—'
65
+ if (typeof value === 'object') return JSON.stringify(value)
61
66
  return String(value)
62
67
  }
63
68
 
@@ -15,11 +15,13 @@ import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
15
15
  import { useTranslation } from '@byline/i18n/react'
16
16
  import { Container, Section, useToastManager } from '@byline/ui/react'
17
17
 
18
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
18
19
  import {
19
20
  copyDocumentToLocale,
20
21
  deleteDocument,
21
22
  deleteDocumentLocale,
22
23
  duplicateCollectionDocument,
24
+ hasDeleteSideEffectFailures,
23
25
  unpublishDocument,
24
26
  updateCollectionDocumentSystemFields,
25
27
  updateCollectionDocumentWithPatches,
@@ -73,7 +75,7 @@ export const EditView = ({
73
75
 
74
76
  const handleLocaleChange = (newLocale: string) => {
75
77
  navigate({
76
- to: '/admin/collections/$collection/$id' as never,
78
+ to: getAdminRoutePath('collections', '$collection', '$id'),
77
79
  params: { collection: path, id: String(initialData.id) },
78
80
  search: { locale: newLocale },
79
81
  })
@@ -98,7 +100,7 @@ export const EditView = ({
98
100
  setEditState({ status: 'success', message: description })
99
101
  // Refresh the page to reflect the new status.
100
102
  navigate({
101
- to: '/admin/collections/$collection/$id' as never,
103
+ to: getAdminRoutePath('collections', '$collection', '$id'),
102
104
  params: { collection: path, id: String(initialData.id) },
103
105
  search: (prev: Record<string, unknown>) => ({ ...prev }),
104
106
  })
@@ -149,7 +151,7 @@ export const EditView = ({
149
151
  })
150
152
  setEditState({ status: 'success', message: description })
151
153
  navigate({
152
- to: '/admin/collections/$collection/$id' as never,
154
+ to: getAdminRoutePath('collections', '$collection', '$id'),
153
155
  params: { collection: path, id: String(initialData.id) },
154
156
  search: (prev: Record<string, unknown>) => ({ ...prev }),
155
157
  })
@@ -196,7 +198,7 @@ export const EditView = ({
196
198
  })
197
199
  // Navigate to the new document's edit view.
198
200
  navigate({
199
- to: '/admin/collections/$collection/$id' as never,
201
+ to: getAdminRoutePath('collections', '$collection', '$id'),
200
202
  params: { collection: path, id: result.documentId },
201
203
  })
202
204
  } catch (err) {
@@ -270,7 +272,7 @@ export const EditView = ({
270
272
  // Switch the form to the target locale so the editor sees the
271
273
  // copied content immediately.
272
274
  navigate({
273
- to: '/admin/collections/$collection/$id' as never,
275
+ to: getAdminRoutePath('collections', '$collection', '$id'),
274
276
  params: { collection: path, id: String(initialData.id) },
275
277
  search: { locale: targetLocale },
276
278
  })
@@ -322,7 +324,7 @@ export const EditView = ({
322
324
  // The deleted locale may be the one being viewed — land on the default
323
325
  // locale (which always survives) so the loader re-fetches a valid view.
324
326
  navigate({
325
- to: '/admin/collections/$collection/$id' as never,
327
+ to: getAdminRoutePath('collections', '$collection', '$id'),
326
328
  params: { collection: path, id: String(initialData.id) },
327
329
  search: { locale: defaultContentLocale },
328
330
  })
@@ -347,14 +349,19 @@ export const EditView = ({
347
349
 
348
350
  const handleDelete = async () => {
349
351
  try {
350
- await deleteDocument({ data: { collection: path, id: String(initialData.id) } })
351
- const description = t('collections.edit.deletedDescription', { label: singular })
352
+ const result = await deleteDocument({
353
+ data: { collection: path, id: String(initialData.id) },
354
+ })
355
+ const hasSideEffectFailures = hasDeleteSideEffectFailures(result)
356
+ const description = hasSideEffectFailures
357
+ ? t('collections.edit.deletedWithWarningsDescription', { label: singular })
358
+ : t('collections.edit.deletedDescription', { label: singular })
352
359
  toastManager.add({
353
360
  title: t('collections.edit.deleteTitle', { label: singular }),
354
361
  description,
355
362
  data: {
356
- intent: 'success',
357
- iconType: 'success',
363
+ intent: hasSideEffectFailures ? 'warning' : 'success',
364
+ iconType: hasSideEffectFailures ? 'warning' : 'success',
358
365
  icon: true,
359
366
  close: true,
360
367
  },
@@ -362,7 +369,7 @@ export const EditView = ({
362
369
  setEditState({ status: 'success', message: description })
363
370
  // Navigate back to the collection list after deletion.
364
371
  navigate({
365
- to: '/admin/collections/$collection' as never,
372
+ to: getAdminRoutePath('collections', '$collection'),
366
373
  params: { collection: path },
367
374
  })
368
375
  } catch (err) {
@@ -457,7 +464,7 @@ export const EditView = ({
457
464
  // The new version will have a fresh version ID, draft status, and
458
465
  // updated publishedVersion metadata.
459
466
  navigate({
460
- to: '/admin/collections/$collection/$id' as never,
467
+ to: getAdminRoutePath('collections', '$collection', '$id'),
461
468
  params: { collection: path, id: String(initialData.id) },
462
469
  search: (prev: Record<string, unknown>) => ({ ...prev }),
463
470
  })
@@ -524,7 +531,7 @@ export const EditView = ({
524
531
  workflowStatuses={workflowStatuses}
525
532
  onCancel={() =>
526
533
  navigate({
527
- to: '/admin/collections/$collection' as never,
534
+ to: getAdminRoutePath('collections', '$collection'),
528
535
  params: { collection: path },
529
536
  })
530
537
  }
@@ -26,6 +26,7 @@ import {
26
26
  } from '@byline/ui/react'
27
27
  import cx from 'classnames'
28
28
 
29
+ import { getAdminRouteId, getAdminRoutePath } from '../../routes/admin-path.js'
29
30
  import { Link, useNavigate } from '../chrome/loose-router.js'
30
31
  import { RouterPager } from '../chrome/router-pager.js'
31
32
  import { TableHeadingCellSortable } from '../chrome/th-sortable.js'
@@ -139,8 +140,8 @@ export const HistoryView = ({
139
140
  defaultContentLocale: string
140
141
  }) => {
141
142
  const { id, collection } = useParams({
142
- from: '/_byline/admin/collections/$collection/$id/history',
143
- })
143
+ from: getAdminRouteId('collections', '$collection', '$id', 'history') as never,
144
+ }) as { id: string; collection: string }
144
145
  const navigate = useNavigate()
145
146
  const { getCollectionDocumentVersion } = useBylineAdminServices()
146
147
  const { t } = useTranslation('byline-admin')
@@ -181,7 +182,7 @@ export const HistoryView = ({
181
182
  delete params.tab
182
183
  }
183
184
  navigate({
184
- to: '/admin/collections/$collection/$id/history' as never,
185
+ to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
185
186
  params: { collection, id } as never,
186
187
  search: params,
187
188
  })
@@ -193,7 +194,7 @@ export const HistoryView = ({
193
194
  delete params.page
194
195
  params.page_size = Number.parseInt(value, 10)
195
196
  navigate({
196
- to: '/admin/collections/$collection/$id/history' as never,
197
+ to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
197
198
  params: { collection, id } as never,
198
199
  search: params,
199
200
  })
@@ -382,7 +383,7 @@ export const HistoryView = ({
382
383
  </button>
383
384
  ) : (
384
385
  <Link
385
- to={'/admin/collections/$collection/$id' as never}
386
+ to={getAdminRoutePath('collections', '$collection', '$id')}
386
387
  params={{
387
388
  collection,
388
389
  id: document.id,
@@ -43,6 +43,7 @@ import {
43
43
  } from '@dnd-kit/sortable'
44
44
  import cx from 'classnames'
45
45
 
46
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
46
47
  import { Link, useNavigate } from '../chrome/loose-router.js'
47
48
  import { RouterPager } from '../chrome/router-pager.js'
48
49
  import { SortAscendingIcon } from '../chrome/sort-icons.js'
@@ -274,7 +275,7 @@ export const ListView = ({
274
275
  delete params.page
275
276
  params.query = query
276
277
  navigate({
277
- to: '/admin/collections/$collection' as never,
278
+ to: getAdminRoutePath('collections', '$collection'),
278
279
  params: { collection: data.included.collection.path },
279
280
  search: params,
280
281
  })
@@ -286,7 +287,7 @@ export const ListView = ({
286
287
  delete params.page
287
288
  delete params.query
288
289
  navigate({
289
- to: '/admin/collections/$collection' as never,
290
+ to: getAdminRoutePath('collections', '$collection'),
290
291
  params: { collection: data.included.collection.path },
291
292
  search: params,
292
293
  })
@@ -302,7 +303,7 @@ export const ListView = ({
302
303
  params.status = value
303
304
  }
304
305
  navigate({
305
- to: '/admin/collections/$collection' as never,
306
+ to: getAdminRoutePath('collections', '$collection'),
306
307
  params: { collection: data.included.collection.path },
307
308
  search: params,
308
309
  })
@@ -314,7 +315,7 @@ export const ListView = ({
314
315
  delete params.page
315
316
  params.page_size = Number.parseInt(value, 10)
316
317
  navigate({
317
- to: '/admin/collections/$collection' as never,
318
+ to: getAdminRoutePath('collections', '$collection'),
318
319
  params: { collection: data.included.collection.path },
319
320
  search: params,
320
321
  })
@@ -336,7 +337,7 @@ export const ListView = ({
336
337
  aria-label={t('collections.list.createAriaLabel')}
337
338
  render={
338
339
  <Link
339
- to={'/admin/collections/$collection/create' as never}
340
+ to={getAdminRoutePath('collections', '$collection', 'create')}
340
341
  params={{ collection: data.included.collection.path }}
341
342
  />
342
343
  }
@@ -454,7 +455,7 @@ export const ListView = ({
454
455
  >
455
456
  {useAsTitle && column.fieldName === useAsTitle ? (
456
457
  <Link
457
- to={'/admin/collections/$collection/$id' as never}
458
+ to={getAdminRoutePath('collections', '$collection', '$id')}
458
459
  params={{
459
460
  collection: data.included.collection.path,
460
461
  id: document.id,
@@ -530,7 +531,7 @@ export const ListView = ({
530
531
  >
531
532
  {useAsTitle && column.fieldName === useAsTitle ? (
532
533
  <Link
533
- to={'/admin/collections/$collection/$id' as never}
534
+ to={getAdminRoutePath('collections', '$collection', '$id')}
534
535
  params={{
535
536
  collection: data.included.collection.path,
536
537
  id: document.id,
@@ -24,6 +24,7 @@ import { useTranslation } from '@byline/i18n/react'
24
24
  import { Alert, Button, LoaderEllipsis, Modal } from '@byline/ui/react'
25
25
  import cx from 'classnames'
26
26
 
27
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
27
28
  import { restoreDocumentVersion } from '../../server-fns/collections/index.js'
28
29
  import { useNavigate } from '../chrome/loose-router.js'
29
30
  import styles from './restore-version-modal.module.css'
@@ -62,7 +63,7 @@ export function RestoreVersionModal({
62
63
  onClose()
63
64
  await router.invalidate()
64
65
  navigate({
65
- to: '/admin/collections/$collection/$id' as never,
66
+ to: getAdminRoutePath('collections', '$collection', '$id'),
66
67
  params: { collection, id: documentId },
67
68
  })
68
69
  } catch (err) {
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type React from 'react'
10
- import { useEffect, useMemo, useState } from 'react'
10
+ import { useEffect, useMemo, useRef, useState } from 'react'
11
11
  import { useRouter } from '@tanstack/react-router'
12
12
 
13
13
  import { renderFormatted, StatusBadge } from '@byline/admin/react'
@@ -41,6 +41,12 @@ import {
41
41
  } from '@dnd-kit/sortable'
42
42
  import cx from 'classnames'
43
43
 
44
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
45
+ import {
46
+ createAdminTreeMoveController,
47
+ getAdminTreeMoveUiEffects,
48
+ shouldSyncAdminTreeRows,
49
+ } from '../../server-fns/collections/tree-mutation.js'
44
50
  import { Link } from '../chrome/loose-router.js'
45
51
  import { TableHeadingCellSortable } from '../chrome/th-sortable.js'
46
52
  import { formatNumber } from '../chrome/utils.js'
@@ -59,7 +65,6 @@ export type TreeMoveFn = (params: {
59
65
  afterDocumentId: string | null
60
66
  }) => Promise<unknown>
61
67
 
62
- // biome-ignore lint/suspicious/noExplicitAny: tree rows carry heterogeneous fields
63
68
  function getColumnValue(row: CollectionTreeRow, fieldName: string): any {
64
69
  if (row.fields && fieldName in row.fields) return row.fields[fieldName]
65
70
  return (row as Record<string, any>)[fieldName]
@@ -73,19 +78,20 @@ function getColumnValue(row: CollectionTreeRow, fieldName: string): any {
73
78
  */
74
79
  function SortableTreeRow({
75
80
  id,
76
- depth,
77
81
  dragging,
82
+ disabled,
78
83
  dragHandleLabel,
79
84
  children,
80
85
  }: {
81
86
  id: string
82
- depth: number
83
87
  dragging: boolean
88
+ disabled: boolean
84
89
  dragHandleLabel: string
85
90
  children: React.ReactNode
86
91
  }) {
87
92
  const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
88
93
  id,
94
+ disabled,
89
95
  })
90
96
  const style: React.CSSProperties = {
91
97
  transform: transform ? `translate3d(0, ${transform.y}px, 0)` : undefined,
@@ -103,6 +109,7 @@ function SortableTreeRow({
103
109
  <td className={cx('byline-tree-list-drag-cell', styles.dragCell)}>
104
110
  <button
105
111
  type="button"
112
+ disabled={disabled}
106
113
  className={cx('byline-tree-list-drag-handle', styles.dragHandle)}
107
114
  aria-label={dragHandleLabel}
108
115
  {...attributes}
@@ -149,8 +156,12 @@ export const TreeListView = ({
149
156
  // back). Mirrors the flat-list reorder pattern.
150
157
  const [localPlaced, setLocalPlaced] = useState(() => rows.filter((r) => !r.unplaced))
151
158
  const [isMoving, setIsMoving] = useState(false)
159
+ const [moveController] = useState(() => createAdminTreeMoveController())
160
+ const preserveOptimisticForRows = useRef<CollectionTreeRow[] | null>(null)
152
161
  useEffect(() => {
153
- if (!isMoving) setLocalPlaced(rows.filter((r) => !r.unplaced))
162
+ if (!shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)) return
163
+ preserveOptimisticForRows.current = null
164
+ setLocalPlaced(rows.filter((r) => !r.unplaced))
154
165
  }, [rows, isMoving])
155
166
 
156
167
  const [activeId, setActiveId] = useState<string | null>(null)
@@ -174,11 +185,17 @@ export const TreeListView = ({
174
185
  setOffsetLeft(0)
175
186
  }
176
187
 
177
- const handleDragStart = ({ active }: DragStartEvent) => setActiveId(String(active.id))
188
+ const handleDragStart = ({ active }: DragStartEvent) => {
189
+ if (!moveController.isMoving()) setActiveId(String(active.id))
190
+ }
178
191
  const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x)
179
192
  const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null)
180
193
 
181
194
  const handleDragEnd = async ({ active, over }: DragEndEvent) => {
195
+ if (moveController.isMoving()) {
196
+ resetDnd()
197
+ return
198
+ }
182
199
  const proj =
183
200
  over != null
184
201
  ? getTreeProjection(localPlaced, String(active.id), String(over.id), offsetLeft, INDENT)
@@ -197,24 +214,60 @@ export const TreeListView = ({
197
214
 
198
215
  setLocalPlaced(next)
199
216
  setIsMoving(true)
200
- try {
201
- await onMove({
202
- documentId,
203
- parentDocumentId: proj.parentId,
204
- beforeDocumentId: proj.beforeId,
205
- afterDocumentId: proj.afterId,
206
- })
207
- await router.invalidate()
208
- } catch {
217
+ const outcome = await moveController.execute(
218
+ () =>
219
+ onMove({
220
+ documentId,
221
+ parentDocumentId: proj.parentId,
222
+ beforeDocumentId: proj.beforeId,
223
+ afterDocumentId: proj.afterId,
224
+ }),
225
+ () => router.invalidate()
226
+ )
227
+
228
+ if (outcome.status === 'suppressed') {
209
229
  setLocalPlaced(current)
230
+ setIsMoving(moveController.isMoving())
231
+ return
232
+ }
233
+
234
+ const effects = getAdminTreeMoveUiEffects(outcome)
235
+ if (effects.rollback) setLocalPlaced(current)
236
+
237
+ if (effects.structuralFailure != null) {
238
+ const conflict = effects.structuralFailure === 'conflict'
210
239
  toastManager.add({
211
- title: t('collections.list.reorderFailedToast'),
212
- description: t('collections.list.reorderFailedDescription'),
240
+ title: t(
241
+ conflict ? 'collections.list.treeConflictToast' : 'collections.list.reorderFailedToast'
242
+ ),
243
+ description: t(
244
+ conflict
245
+ ? 'collections.list.treeConflictDescription'
246
+ : 'collections.list.reorderFailedDescription'
247
+ ),
213
248
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
214
249
  })
215
- } finally {
216
- setIsMoving(false)
217
250
  }
251
+
252
+ if (effects.committedHookWarning) {
253
+ toastManager.add({
254
+ title: t('collections.list.treeHookFailedToast'),
255
+ description: t('collections.list.treeHookFailedDescription'),
256
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
257
+ })
258
+ }
259
+
260
+ if (effects.refreshWarning) {
261
+ toastManager.add({
262
+ title: t('collections.list.treeRefreshFailedToast'),
263
+ description: t('collections.list.treeRefreshFailedDescription'),
264
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
265
+ })
266
+ }
267
+
268
+ if (effects.preserveOptimistic) preserveOptimisticForRows.current = rows
269
+
270
+ setIsMoving(false)
218
271
  }
219
272
 
220
273
  const renderCells = (row: CollectionTreeRow, depth: number) =>
@@ -240,7 +293,7 @@ export const TreeListView = ({
240
293
  </span>
241
294
  )}
242
295
  <Link
243
- to={'/admin/collections/$collection/$id' as never}
296
+ to={getAdminRoutePath('collections', '$collection', '$id')}
244
297
  params={{ collection, id: row.id }}
245
298
  >
246
299
  {column.formatter
@@ -277,7 +330,10 @@ export const TreeListView = ({
277
330
  <IconButton
278
331
  aria-label={t('collections.list.createAriaLabel')}
279
332
  render={
280
- <Link to={'/admin/collections/$collection/create' as never} params={{ collection }} />
333
+ <Link
334
+ to={getAdminRoutePath('collections', '$collection', 'create')}
335
+ params={{ collection }}
336
+ />
281
337
  }
282
338
  >
283
339
  <PlusIcon height="18px" width="18px" svgClassName="stroke-white" />
@@ -323,8 +379,8 @@ export const TreeListView = ({
323
379
  <SortableTreeRow
324
380
  key={row.id}
325
381
  id={row.id}
326
- depth={depth}
327
382
  dragging={row.id === activeId}
383
+ disabled={isMoving}
328
384
  dragHandleLabel={t('collections.list.dragHandleAriaLabel')}
329
385
  >
330
386
  {renderCells(row, depth)}
@@ -13,6 +13,7 @@ import { useTranslation } from '@byline/i18n/react'
13
13
  import { Button, HistoryIcon, IconButton, Label, Select } from '@byline/ui/react'
14
14
  import cx from 'classnames'
15
15
 
16
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
16
17
  import { useNavigate } from '../chrome/loose-router.js'
17
18
  import { PreviewLink } from './preview-link.js'
18
19
  import styles from './view-menu.module.css'
@@ -86,7 +87,7 @@ export const ViewMenu = ({
86
87
  useEffect(() => {
87
88
  if (activeView === 'edit' && locale === 'all') {
88
89
  navigate({
89
- to: '/admin/collections/$collection/$id' as never,
90
+ to: getAdminRoutePath('collections', '$collection', '$id'),
90
91
  params: { collection, id: documentId },
91
92
  search: (prev: Record<string, unknown>) => ({
92
93
  ...prev,
@@ -103,19 +104,19 @@ export const ViewMenu = ({
103
104
  const search = (prev: Record<string, unknown>) => ({ ...prev, locale: value })
104
105
  if (activeView === 'api') {
105
106
  navigate({
106
- to: '/admin/collections/$collection/$id/api' as never,
107
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
107
108
  params: { collection, id: documentId },
108
109
  search: search as never,
109
110
  })
110
111
  } else if (activeView === 'history') {
111
112
  navigate({
112
- to: '/admin/collections/$collection/$id/history' as never,
113
+ to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
113
114
  params: { collection, id: documentId },
114
115
  search: search as never,
115
116
  })
116
117
  } else {
117
118
  navigate({
118
- to: '/admin/collections/$collection/$id' as never,
119
+ to: getAdminRoutePath('collections', '$collection', '$id'),
119
120
  params: { collection, id: documentId },
120
121
  search: search as never,
121
122
  })
@@ -127,7 +128,7 @@ export const ViewMenu = ({
127
128
  const n = Number.parseInt(value, 10)
128
129
  const nextDepth = Number.isFinite(n) ? Math.max(0, Math.min(3, n)) : 0
129
130
  navigate({
130
- to: '/admin/collections/$collection/$id/api' as never,
131
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
131
132
  params: { collection, id: documentId },
132
133
  // Store 0 as undefined so the URL stays clean when the user picks
133
134
  // "no populate" — avoids `?depth=0` in bookmarks / share links.
@@ -201,7 +202,7 @@ export const ViewMenu = ({
201
202
  variant={activeView === 'history' ? 'filled' : 'text'}
202
203
  onClick={() =>
203
204
  navigate({
204
- to: '/admin/collections/$collection/$id/history' as never,
205
+ to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
205
206
  params: { collection, id: documentId },
206
207
  search: locale ? { locale } : {},
207
208
  })
@@ -215,7 +216,7 @@ export const ViewMenu = ({
215
216
  className={cx('byline-view-menu-button', styles.button)}
216
217
  onClick={() =>
217
218
  navigate({
218
- to: '/admin/collections/$collection/$id' as never,
219
+ to: getAdminRoutePath('collections', '$collection', '$id'),
219
220
  params: { collection, id: documentId },
220
221
  search: locale ? { locale } : {},
221
222
  })
@@ -229,7 +230,7 @@ export const ViewMenu = ({
229
230
  className={cx('byline-view-menu-button', styles.button)}
230
231
  onClick={() =>
231
232
  navigate({
232
- to: '/admin/collections/$collection/$id/api' as never,
233
+ to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
233
234
  params: { collection, id: documentId },
234
235
  search: locale ? { locale } : {},
235
236
  })