@byline/host-tanstack-start 4.14.0 → 4.15.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 (39) hide show
  1. package/dist/admin-shell/chrome/admin-layout_module.css +1 -1
  2. package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +3 -1
  3. package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -2
  4. package/dist/admin-shell/chrome/admin-menu-drawer_module.css +7 -6
  5. package/dist/admin-shell/chrome/dashboard.js +2 -2
  6. package/dist/admin-shell/collections/edit.js +142 -2
  7. package/dist/admin-shell/scheduled-publications/list.d.ts +4 -0
  8. package/dist/admin-shell/scheduled-publications/list.js +295 -0
  9. package/dist/admin-shell/scheduled-publications/list.module.js +25 -0
  10. package/dist/admin-shell/scheduled-publications/list_module.css +97 -0
  11. package/dist/routes/create-admin-layout-route.js +8 -3
  12. package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
  13. package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
  14. package/dist/routes/index.d.ts +1 -0
  15. package/dist/routes/index.js +1 -0
  16. package/dist/server-fns/collections/get.d.ts +5 -0
  17. package/dist/server-fns/collections/get.js +19 -3
  18. package/dist/server-fns/collections/index.d.ts +1 -0
  19. package/dist/server-fns/collections/index.js +1 -0
  20. package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
  21. package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
  22. package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
  23. package/dist/server-fns/collections/scheduled-publication.js +157 -0
  24. package/package.json +8 -8
  25. package/src/admin-shell/chrome/admin-layout.module.css +6 -1
  26. package/src/admin-shell/chrome/admin-menu-drawer.module.css +19 -6
  27. package/src/admin-shell/chrome/admin-menu-drawer.tsx +23 -1
  28. package/src/admin-shell/chrome/dashboard.tsx +11 -4
  29. package/src/admin-shell/collections/edit.tsx +140 -1
  30. package/src/admin-shell/scheduled-publications/list.module.css +105 -0
  31. package/src/admin-shell/scheduled-publications/list.tsx +292 -0
  32. package/src/routes/create-admin-layout-route.tsx +8 -3
  33. package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
  34. package/src/routes/index.ts +1 -0
  35. package/src/server-fns/collections/get.ts +30 -1
  36. package/src/server-fns/collections/index.ts +1 -0
  37. package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
  38. package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
  39. package/src/server-fns/collections/scheduled-publication.ts +270 -0
@@ -47,10 +47,16 @@
47
47
  overflow: hidden;
48
48
  }
49
49
 
50
+ /* Pinned under the fixed app bar (45px) for the whole scroll of the page.
51
+ `align-self: flex-start` keeps the sticky box from being stretched to the
52
+ full height of the flex row — a stretched sticky item has no room to move
53
+ and never pins. */
50
54
  .asideDesktop,
51
55
  :global(.byline-admin-menu-drawer-aside-desktop) {
52
- position: relative;
53
- align-self: stretch;
56
+ position: sticky;
57
+ top: 45px;
58
+ align-self: flex-start;
59
+ height: calc(100vh - 45px);
54
60
  flex-shrink: 0;
55
61
  }
56
62
 
@@ -64,12 +70,13 @@
64
70
  width: 60px;
65
71
  }
66
72
 
73
+ /* The aside owns the viewport-height box; the nav just fills it and scrolls
74
+ internally when the menu is longer than the drawer. */
67
75
  .nav,
68
76
  :global(.byline-admin-menu-drawer) {
69
- position: sticky;
70
- top: 0;
77
+ flex: 1 1 auto;
78
+ min-height: 0;
71
79
  margin: 0;
72
- max-height: calc(100vh - 60px);
73
80
  overflow-y: auto;
74
81
  }
75
82
 
@@ -96,9 +103,15 @@
96
103
  width: 24px;
97
104
  }
98
105
 
106
+ /* A real line-height, not 0. Zero leaves every line box with no height, which
107
+ a single-line label survives — the flex row centres it anyway — but a label
108
+ long enough to wrap does not: both lines land in the same place and render
109
+ on top of each other. "Scheduled Publications" is the first label in this
110
+ menu long enough to reach that. Non-compact rows have no fixed height, so
111
+ the row simply grows to fit a second line. */
99
112
  .nav ul :global(li.menu-item .label),
100
113
  :global(.byline-admin-menu-drawer) ul :global(li.menu-item .label) {
101
- line-height: 0;
114
+ line-height: 1.2;
102
115
  }
103
116
 
104
117
  .nav ul :global(li.menu-item.compact .label),
@@ -13,9 +13,11 @@ import { ADMIN_ACTIVITY_ABILITIES } from '@byline/admin/admin-activity'
13
13
  import { ADMIN_PERMISSIONS_ABILITIES } from '@byline/admin/admin-permissions'
14
14
  import { ADMIN_ROLES_ABILITIES } from '@byline/admin/admin-roles'
15
15
  import { ADMIN_USERS_ABILITIES } from '@byline/admin/admin-users'
16
+ import { collectionAbilityKey, getAdminConfig } from '@byline/core'
16
17
  import { useTranslation } from '@byline/i18n/react'
17
18
  import {
18
19
  ActivityIcon,
20
+ CalendarIcon,
19
21
  HomeIcon,
20
22
  RolesIcon,
21
23
  SettingsSlidersIcon,
@@ -75,7 +77,11 @@ function MenuItem({ to, label, icon, pathname, compact }: MenuItemProps) {
75
77
  )
76
78
  }
77
79
 
78
- export function AdminMenuDrawer(): React.JSX.Element | null {
80
+ export function AdminMenuDrawer({
81
+ scheduledPublicationEnabled = false,
82
+ }: {
83
+ scheduledPublicationEnabled?: boolean
84
+ }): React.JSX.Element | null {
79
85
  const pathname = useRouterState({ select: (s) => s.location.pathname })
80
86
  const { mobile, drawerOpen, closeDrawer } = useAdminMenu()
81
87
  const { t } = useTranslation('byline-admin')
@@ -90,6 +96,13 @@ export function AdminMenuDrawer(): React.JSX.Element | null {
90
96
  const canReadRoles = has(ADMIN_ROLES_ABILITIES.read)
91
97
  const canReadPermissions = has(ADMIN_PERMISSIONS_ABILITIES.read)
92
98
  const canReadActivity = has(ADMIN_ACTIVITY_ABILITIES.read)
99
+ const canSeeScheduledPublications =
100
+ scheduledPublicationEnabled &&
101
+ getAdminConfig().collections.some(
102
+ (collection) =>
103
+ has(collectionAbilityKey(collection.path, 'changeStatus')) &&
104
+ has(collectionAbilityKey(collection.path, 'publish'))
105
+ )
93
106
  const showAdminSection = canReadUsers || canReadRoles || canReadPermissions || canReadActivity
94
107
 
95
108
  const handlers = useSwipeable({
@@ -130,6 +143,15 @@ export function AdminMenuDrawer(): React.JSX.Element | null {
130
143
  pathname={pathname}
131
144
  compact={compact}
132
145
  />
146
+ {canSeeScheduledPublications && (
147
+ <MenuItem
148
+ to={getAdminRoutePath('scheduled-publications')}
149
+ label={t('chrome.menu.scheduledPublications')}
150
+ icon={<CalendarIcon width="18px" height="18px" />}
151
+ pathname={pathname}
152
+ compact={compact}
153
+ />
154
+ )}
133
155
  {showAdminSection && (
134
156
  <>
135
157
  <li className="menu-separator" />
@@ -72,7 +72,12 @@ function CollectionCard({
72
72
  stats: CollectionStatusCount[] | undefined
73
73
  }) {
74
74
  const { t } = useTranslation('byline-admin')
75
- const total = stats?.reduce((sum, s) => sum + s.count, 0) ?? 0
75
+ // `stats` is undefined when the collection opted out of counts (no
76
+ // `showStats: true` on the definition) — the dashboard loader only fetches
77
+ // them for collections that opted in. Falling back to a total of zero there
78
+ // would read as "this collection is empty", so the count is omitted along
79
+ // with the status tiles.
80
+ const total = stats !== undefined ? stats.reduce((sum, s) => sum + s.count, 0) : undefined
76
81
  const workflowStatuses = getWorkflowStatuses(collection)
77
82
 
78
83
  return (
@@ -88,9 +93,11 @@ function CollectionCard({
88
93
  <span className={cx('byline-dashboard-title-text', styles.titleText)}>
89
94
  {collection.labels.plural}
90
95
  </span>
91
- <span className={cx('muted byline-dashboard-title-meta', styles.titleMeta)}>
92
- {t('dashboard.totalCount', { count: total })}
93
- </span>
96
+ {total !== undefined && (
97
+ <span className={cx('muted byline-dashboard-title-meta', styles.titleMeta)}>
98
+ {t('dashboard.totalCount', { count: total })}
99
+ </span>
100
+ )}
94
101
  </Card.Title>
95
102
  <Card.Description className="muted">
96
103
  {t('dashboard.collectionDescription', { label: collection.labels.plural })}
@@ -10,7 +10,12 @@ import { useState } from 'react'
10
10
 
11
11
  import { FormRenderer } from '@byline/admin/react'
12
12
  import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
13
- import { getDefaultStatus, getWorkflowStatuses } from '@byline/core'
13
+ import {
14
+ getDefaultStatus,
15
+ getWorkflow,
16
+ getWorkflowStatuses,
17
+ validateStatusTransition,
18
+ } from '@byline/core'
14
19
  import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
15
20
  import { useTranslation } from '@byline/i18n/react'
16
21
  import { Container, Section, useToastManager } from '@byline/ui/react'
@@ -18,11 +23,14 @@ import { Container, Section, useToastManager } from '@byline/ui/react'
18
23
  import { getAdminRoutePath } from '../../routes/admin-path.js'
19
24
  import { clearListReturnState } from '../../routes/list-return-storage.js'
20
25
  import {
26
+ cancelCollectionDocumentScheduledPublish,
27
+ confirmCollectionDocumentScheduledPublish,
21
28
  copyDocumentToLocale,
22
29
  deleteDocument,
23
30
  deleteDocumentLocale,
24
31
  duplicateCollectionDocument,
25
32
  hasDeleteSideEffectFailures,
33
+ scheduleCollectionDocumentPublish,
26
34
  unpublishDocument,
27
35
  updateCollectionDocumentSystemFields,
28
36
  updateCollectionDocumentWithPatches,
@@ -31,6 +39,7 @@ import {
31
39
  import { useNavigate } from '../chrome/loose-router.js'
32
40
  import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
33
41
  import { ViewMenu } from './view-menu.js'
42
+ import type { SerializedDocumentPublishSchedule } from '../../server-fns/collections/index.js'
34
43
  import type { ContentLocaleOption } from './view-menu.js'
35
44
 
36
45
  type EditState = {
@@ -76,6 +85,30 @@ export const EditView = ({
76
85
  currentIndex !== -1 && currentIndex < workflowStatuses.length - 1
77
86
  ? workflowStatuses[currentIndex + 1]
78
87
  : undefined
88
+ const scheduledPublicationEnabled =
89
+ // biome-ignore lint/suspicious/noExplicitAny: host-only loader metadata
90
+ (initialData as any)?._scheduledPublicationEnabled === true
91
+ const canSchedulePublication =
92
+ // biome-ignore lint/suspicious/noExplicitAny: host-only loader metadata
93
+ (initialData as any)?._canSchedulePublication === true
94
+ // biome-ignore lint/suspicious/noExplicitAny: host-only loader metadata
95
+ const scheduledPublication = ((initialData as any)?._scheduledPublish ??
96
+ null) as SerializedDocumentPublishSchedule | null
97
+ const publishTransitionValid =
98
+ currentStatus !== 'published' &&
99
+ getWorkflowStatuses(collectionDefinition).length > 1 &&
100
+ validateStatusTransition(getWorkflow(collectionDefinition), currentStatus, 'published').valid
101
+ const canArmSchedule =
102
+ scheduledPublicationEnabled && canSchedulePublication && publishTransitionValid
103
+
104
+ const notifyScheduleSuspended = () => {
105
+ if (scheduledPublication?.state !== 'armed') return
106
+ toastManager.add({
107
+ title: t('scheduledPublication.toast.suspendedTitle'),
108
+ description: t('scheduledPublication.toast.suspendedDescription'),
109
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
110
+ })
111
+ }
79
112
 
80
113
  const handleLocaleChange = (newLocale: string) => {
81
114
  navigate({
@@ -127,6 +160,96 @@ export const EditView = ({
127
160
  }
128
161
  }
129
162
 
163
+ const reloadDocument = () => {
164
+ navigate({
165
+ to: getAdminRoutePath('collections', '$collection', '$id'),
166
+ params: { collection: path, id: String(initialData.id) },
167
+ search: (prev: Record<string, unknown>) => ({ ...prev }),
168
+ })
169
+ }
170
+
171
+ const handleSchedulePublication = async ({ publishAt }: { publishAt: string }) => {
172
+ try {
173
+ await scheduleCollectionDocumentPublish({
174
+ data: {
175
+ collection: path,
176
+ id: String(initialData.id),
177
+ publishAt,
178
+ expectedVersionId: String(initialData.versionId),
179
+ },
180
+ })
181
+ toastManager.add({
182
+ title: t('scheduledPublication.toast.scheduledTitle'),
183
+ description: t('scheduledPublication.toast.scheduledDescription'),
184
+ data: { intent: 'success', iconType: 'success', icon: true, close: true },
185
+ })
186
+ reloadDocument()
187
+ } catch (err) {
188
+ toastManager.add({
189
+ title: t('scheduledPublication.toast.failedTitle'),
190
+ description: (err as Error).message,
191
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
192
+ })
193
+ throw err
194
+ }
195
+ }
196
+
197
+ const handleConfirmScheduledPublication = async () => {
198
+ try {
199
+ await confirmCollectionDocumentScheduledPublish({
200
+ data: {
201
+ collection: path,
202
+ id: String(initialData.id),
203
+ expectedVersionId: String(initialData.versionId),
204
+ },
205
+ })
206
+ toastManager.add({
207
+ title: t('scheduledPublication.toast.confirmedTitle'),
208
+ description: t('scheduledPublication.toast.confirmedDescription'),
209
+ data: { intent: 'success', iconType: 'success', icon: true, close: true },
210
+ })
211
+ reloadDocument()
212
+ } catch (err) {
213
+ toastManager.add({
214
+ title: t('scheduledPublication.toast.failedTitle'),
215
+ description: (err as Error).message,
216
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
217
+ })
218
+ throw err
219
+ }
220
+ }
221
+
222
+ const handleCancelScheduledPublication = async () => {
223
+ try {
224
+ const result = await cancelCollectionDocumentScheduledPublish({
225
+ data: { collection: path, id: String(initialData.id) },
226
+ })
227
+ const cancelled = result.status === 'cancelled'
228
+ toastManager.add({
229
+ title: cancelled
230
+ ? t('scheduledPublication.toast.cancelledTitle')
231
+ : t('scheduledPublication.toast.cancelLostTitle'),
232
+ description: cancelled
233
+ ? t('scheduledPublication.toast.cancelledDescription')
234
+ : t('scheduledPublication.toast.cancelLostDescription'),
235
+ data: {
236
+ intent: cancelled ? 'success' : 'warning',
237
+ iconType: cancelled ? 'success' : 'warning',
238
+ icon: true,
239
+ close: true,
240
+ },
241
+ })
242
+ reloadDocument()
243
+ } catch (err) {
244
+ toastManager.add({
245
+ title: t('scheduledPublication.toast.failedTitle'),
246
+ description: (err as Error).message,
247
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
248
+ })
249
+ throw err
250
+ }
251
+ }
252
+
130
253
  // Published version metadata — attached by getCollectionDocument when a
131
254
  // published version exists behind the current draft.
132
255
  // biome-ignore lint/suspicious/noExplicitAny: storage shape
@@ -269,6 +392,7 @@ export const EditView = ({
269
392
  close: true,
270
393
  },
271
394
  })
395
+ notifyScheduleSuspended()
272
396
  setEditState({
273
397
  status: 'success',
274
398
  message: t('collections.edit.copiedSuccessMessage', {
@@ -327,6 +451,7 @@ export const EditView = ({
327
451
  close: true,
328
452
  },
329
453
  })
454
+ notifyScheduleSuspended()
330
455
  setEditState({ status: 'success', message: description })
331
456
  // The deleted locale may be the one being viewed — land on the default
332
457
  // locale (which always survives) so the loader re-fetches a valid view.
@@ -457,6 +582,10 @@ export const EditView = ({
457
582
  })
458
583
  }
459
584
 
585
+ if (contentDirty && scheduledPublication?.state === 'armed') {
586
+ notifyScheduleSuspended()
587
+ }
588
+
460
589
  const description = t('collections.edit.updatedDescription', { label: singularLower })
461
590
  toastManager.add({
462
591
  title: t('collections.edit.updateTitle', { label: singular }),
@@ -531,6 +660,16 @@ export const EditView = ({
531
660
  }
532
661
  onStatusChange={handleStatusChange}
533
662
  onUnpublish={publishedVersion ? handleUnpublish : undefined}
663
+ scheduledPublication={scheduledPublication}
664
+ onSchedulePublication={canArmSchedule ? handleSchedulePublication : undefined}
665
+ onConfirmScheduledPublication={
666
+ canArmSchedule && scheduledPublication?.state === 'needs_reconfirm'
667
+ ? handleConfirmScheduledPublication
668
+ : undefined
669
+ }
670
+ onCancelScheduledPublication={
671
+ scheduledPublication != null ? handleCancelScheduledPublication : undefined
672
+ }
534
673
  onDelete={handleDelete}
535
674
  onDuplicate={handleDuplicate}
536
675
  onCopyToLocale={handleCopyToLocale}
@@ -0,0 +1,105 @@
1
+ .head {
2
+ margin-bottom: var(--spacing-16);
3
+ }
4
+
5
+ .title {
6
+ margin-bottom: var(--spacing-4);
7
+ }
8
+
9
+ .intro {
10
+ margin: 0;
11
+ color: var(--gray-500);
12
+ }
13
+
14
+ .health {
15
+ margin-bottom: var(--spacing-16);
16
+ }
17
+
18
+ /* Filters and pagination are separate jobs and used at different moments, so
19
+ they sit at opposite ends of one row rather than running together — the
20
+ pager previously read as a third filter control. */
21
+ .controls {
22
+ display: flex;
23
+ flex-wrap: wrap;
24
+ align-items: flex-end;
25
+ justify-content: space-between;
26
+ gap: var(--spacing-16);
27
+ margin-bottom: var(--spacing-4);
28
+ }
29
+
30
+ .filters-help {
31
+ margin: 0 0 var(--spacing-16);
32
+ color: var(--gray-500);
33
+ font-size: var(--font-size-sm);
34
+ }
35
+
36
+ .filters {
37
+ display: flex;
38
+ flex-wrap: wrap;
39
+ align-items: flex-end;
40
+ gap: var(--spacing-12);
41
+ }
42
+
43
+ .filter {
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: var(--spacing-4);
47
+ min-width: 12rem;
48
+ }
49
+
50
+ .filter-authorizer :global(.byline-input-wrapper) {
51
+ min-width: 22rem;
52
+ }
53
+
54
+ /* Sits on the control baseline, not the label baseline, so the buttons line up
55
+ with the inputs rather than floating against their labels. */
56
+ .filter-actions {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: var(--spacing-8);
60
+ padding-bottom: 0.1rem;
61
+ }
62
+
63
+ .tableWrap {
64
+ margin-top: 0.75rem;
65
+ overflow-x: auto;
66
+ }
67
+
68
+ .collection,
69
+ .zone,
70
+ .error {
71
+ display: block;
72
+ margin-top: var(--spacing-4);
73
+ color: var(--gray-500);
74
+ font-size: var(--font-size-xs);
75
+ }
76
+
77
+ .instant {
78
+ white-space: nowrap;
79
+ }
80
+
81
+ /* The whole point of this column is offboarding — an operator pastes an
82
+ account id in and compares. Tabular figures make mismatches visible. */
83
+ .authorizer {
84
+ font-family: var(--font-family-mono, monospace);
85
+ font-size: var(--font-size-xs);
86
+ overflow-wrap: anywhere;
87
+ }
88
+
89
+ .error {
90
+ max-width: 24rem;
91
+ color: var(--red-600);
92
+ white-space: normal;
93
+ }
94
+
95
+ .empty {
96
+ padding: var(--spacing-32) 0;
97
+ color: var(--gray-500);
98
+ text-align: center;
99
+ }
100
+
101
+ .pagerBottom {
102
+ display: flex;
103
+ justify-content: flex-end;
104
+ margin-top: var(--spacing-16);
105
+ }