@byline/host-tanstack-start 4.14.1 → 4.16.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 (69) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +4 -1
  2. package/dist/admin-shell/chrome/admin-menu-drawer.js +26 -2
  3. package/dist/admin-shell/chrome/admin-menu-drawer_module.css +1 -1
  4. package/dist/admin-shell/collections/edit.js +142 -2
  5. package/dist/admin-shell/scheduled-publications/list.d.ts +4 -0
  6. package/dist/admin-shell/scheduled-publications/list.js +295 -0
  7. package/dist/admin-shell/scheduled-publications/list.module.js +25 -0
  8. package/dist/admin-shell/scheduled-publications/list_module.css +97 -0
  9. package/dist/integrations/analytics-agent.d.ts +20 -0
  10. package/dist/integrations/analytics-agent.js +57 -0
  11. package/dist/integrations/analytics-events.d.ts +51 -0
  12. package/dist/integrations/analytics-events.js +129 -0
  13. package/dist/integrations/serve-analytics-agent.d.ts +8 -0
  14. package/dist/integrations/serve-analytics-agent.js +11 -0
  15. package/dist/routes/analytics-range.d.ts +12 -0
  16. package/dist/routes/analytics-range.js +35 -0
  17. package/dist/routes/create-admin-analytics-route.d.ts +8 -0
  18. package/dist/routes/create-admin-analytics-route.js +108 -0
  19. package/dist/routes/create-admin-layout-route.js +15 -4
  20. package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
  21. package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
  22. package/dist/routes/index.d.ts +2 -0
  23. package/dist/routes/index.js +2 -0
  24. package/dist/server-fns/analytics/index.d.ts +9 -0
  25. package/dist/server-fns/analytics/index.js +2 -0
  26. package/dist/server-fns/analytics/maintenance.d.ts +22 -0
  27. package/dist/server-fns/analytics/maintenance.js +42 -0
  28. package/dist/server-fns/analytics/queries.d.ts +25 -0
  29. package/dist/server-fns/analytics/queries.js +54 -0
  30. package/dist/server-fns/collections/get.d.ts +5 -0
  31. package/dist/server-fns/collections/get.js +19 -3
  32. package/dist/server-fns/collections/index.d.ts +1 -0
  33. package/dist/server-fns/collections/index.js +1 -0
  34. package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
  35. package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
  36. package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
  37. package/dist/server-fns/collections/scheduled-publication.js +157 -0
  38. package/dist/vite/analytics-dev.d.ts +22 -0
  39. package/dist/vite/analytics-dev.js +28 -0
  40. package/dist/vite/index.d.ts +1 -0
  41. package/dist/vite/index.js +1 -0
  42. package/package.json +14 -8
  43. package/src/admin-shell/chrome/admin-menu-drawer.module.css +7 -1
  44. package/src/admin-shell/chrome/admin-menu-drawer.tsx +37 -1
  45. package/src/admin-shell/collections/edit.tsx +140 -1
  46. package/src/admin-shell/scheduled-publications/list.module.css +105 -0
  47. package/src/admin-shell/scheduled-publications/list.tsx +292 -0
  48. package/src/integrations/analytics-agent.tsx +91 -0
  49. package/src/integrations/analytics-events.test.node.ts +245 -0
  50. package/src/integrations/analytics-events.ts +224 -0
  51. package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
  52. package/src/integrations/serve-analytics-agent.ts +19 -0
  53. package/src/routes/analytics-range.ts +55 -0
  54. package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
  55. package/src/routes/create-admin-analytics-route.tsx +109 -0
  56. package/src/routes/create-admin-layout-route.tsx +19 -7
  57. package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
  58. package/src/routes/index.ts +2 -0
  59. package/src/server-fns/analytics/index.ts +23 -0
  60. package/src/server-fns/analytics/maintenance.ts +54 -0
  61. package/src/server-fns/analytics/queries.ts +86 -0
  62. package/src/server-fns/collections/get.ts +30 -1
  63. package/src/server-fns/collections/index.ts +1 -0
  64. package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
  65. package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
  66. package/src/server-fns/collections/scheduled-publication.ts +270 -0
  67. package/src/vite/analytics-dev.test.node.ts +126 -0
  68. package/src/vite/analytics-dev.ts +56 -0
  69. package/src/vite/index.ts +1 -0
@@ -103,9 +103,15 @@
103
103
  width: 24px;
104
104
  }
105
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. */
106
112
  .nav ul :global(li.menu-item .label),
107
113
  :global(.byline-admin-menu-drawer) ul :global(li.menu-item .label) {
108
- line-height: 0;
114
+ line-height: 1.2;
109
115
  }
110
116
 
111
117
  .nav ul :global(li.menu-item.compact .label),
@@ -13,9 +13,13 @@ 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 { ANALYTICS_ABILITIES } from '@byline/admin/analytics'
17
+ import { collectionAbilityKey, getAdminConfig } from '@byline/core'
16
18
  import { useTranslation } from '@byline/i18n/react'
17
19
  import {
18
20
  ActivityIcon,
21
+ AnalyticsIcon,
22
+ CalendarIcon,
19
23
  HomeIcon,
20
24
  RolesIcon,
21
25
  SettingsSlidersIcon,
@@ -75,7 +79,13 @@ function MenuItem({ to, label, icon, pathname, compact }: MenuItemProps) {
75
79
  )
76
80
  }
77
81
 
78
- export function AdminMenuDrawer(): React.JSX.Element | null {
82
+ export function AdminMenuDrawer({
83
+ analyticsEnabled = false,
84
+ scheduledPublicationEnabled = false,
85
+ }: {
86
+ analyticsEnabled?: boolean
87
+ scheduledPublicationEnabled?: boolean
88
+ }): React.JSX.Element | null {
79
89
  const pathname = useRouterState({ select: (s) => s.location.pathname })
80
90
  const { mobile, drawerOpen, closeDrawer } = useAdminMenu()
81
91
  const { t } = useTranslation('byline-admin')
@@ -90,6 +100,14 @@ export function AdminMenuDrawer(): React.JSX.Element | null {
90
100
  const canReadRoles = has(ADMIN_ROLES_ABILITIES.read)
91
101
  const canReadPermissions = has(ADMIN_PERMISSIONS_ABILITIES.read)
92
102
  const canReadActivity = has(ADMIN_ACTIVITY_ABILITIES.read)
103
+ const canReadAnalytics = analyticsEnabled && has(ANALYTICS_ABILITIES.read)
104
+ const canSeeScheduledPublications =
105
+ scheduledPublicationEnabled &&
106
+ getAdminConfig().collections.some(
107
+ (collection) =>
108
+ has(collectionAbilityKey(collection.path, 'changeStatus')) &&
109
+ has(collectionAbilityKey(collection.path, 'publish'))
110
+ )
93
111
  const showAdminSection = canReadUsers || canReadRoles || canReadPermissions || canReadActivity
94
112
 
95
113
  const handlers = useSwipeable({
@@ -130,6 +148,24 @@ export function AdminMenuDrawer(): React.JSX.Element | null {
130
148
  pathname={pathname}
131
149
  compact={compact}
132
150
  />
151
+ {canSeeScheduledPublications && (
152
+ <MenuItem
153
+ to={getAdminRoutePath('scheduled-publications')}
154
+ label={t('chrome.menu.scheduledPublications')}
155
+ icon={<CalendarIcon width="18px" height="18px" />}
156
+ pathname={pathname}
157
+ compact={compact}
158
+ />
159
+ )}
160
+ {canReadAnalytics && (
161
+ <MenuItem
162
+ to={getAdminRoutePath('analytics')}
163
+ label={t('chrome.menu.analytics')}
164
+ icon={<AnalyticsIcon width="20px" height="20px" />}
165
+ pathname={pathname}
166
+ compact={compact}
167
+ />
168
+ )}
133
169
  {showAdminSection && (
134
170
  <>
135
171
  <li className="menu-separator" />
@@ -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
+ }
@@ -0,0 +1,292 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useMemo, useState } from 'react'
12
+ import { useRouterState } from '@tanstack/react-router'
13
+
14
+ import { deriveScheduledPublicationState } from '@byline/admin/react'
15
+ import { useTranslation } from '@byline/i18n/react'
16
+ import {
17
+ Alert,
18
+ Badge,
19
+ Button,
20
+ Container,
21
+ Section,
22
+ Select,
23
+ Table,
24
+ useToastManager,
25
+ } from '@byline/ui/react'
26
+ import cx from 'clsx'
27
+
28
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
29
+ import { cancelCollectionDocumentScheduledPublish } from '../../server-fns/collections/index.js'
30
+ import { Link, useNavigate } from '../chrome/loose-router.js'
31
+ import { RouterPager } from '../chrome/router-pager.js'
32
+ import styles from './list.module.css'
33
+ import type { ScheduledPublicationListResponse } from '../../server-fns/collections/index.js'
34
+
35
+ /**
36
+ * Badge intent per state. Armed is informational; the two states that mean
37
+ * automatic publication is not going to happen unaided are warnings.
38
+ */
39
+ const STATE_INTENT = {
40
+ none: 'noeffect',
41
+ armed: 'info',
42
+ needs_reconfirm: 'warning',
43
+ overdue: 'warning',
44
+ } as const
45
+
46
+ export function ScheduledPublicationsView({ data }: { data: ScheduledPublicationListResponse }) {
47
+ const { t, locale } = useTranslation('byline-admin')
48
+ // One clock reading for the whole render, so every row's armed/overdue
49
+ // boundary is decided against the same instant.
50
+ const now = Date.now()
51
+ const timeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', [])
52
+ const instantFormat = useMemo(
53
+ () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone }),
54
+ [locale, timeZone]
55
+ )
56
+ const navigate = useNavigate()
57
+ const toastManager = useToastManager()
58
+ const location = useRouterState({ select: (state) => state.location })
59
+ const search = location.search as {
60
+ state?: 'armed' | 'needs_reconfirm'
61
+ page?: number
62
+ }
63
+ const [cancelling, setCancelling] = useState<string | null>(null)
64
+
65
+ const applySearch = (patch: Record<string, unknown>) => {
66
+ navigate({
67
+ to: getAdminRoutePath('scheduled-publications'),
68
+ search: { ...(location.search as Record<string, unknown>), ...patch, page: undefined },
69
+ })
70
+ }
71
+
72
+ const cancel = async (collection: string, documentId: string) => {
73
+ setCancelling(documentId)
74
+ try {
75
+ const result = await cancelCollectionDocumentScheduledPublish({
76
+ data: { collection, id: documentId },
77
+ })
78
+ const cancelled = result.status === 'cancelled'
79
+ toastManager.add({
80
+ title: cancelled
81
+ ? t('scheduledPublication.toast.cancelledTitle')
82
+ : t('scheduledPublication.toast.cancelLostTitle'),
83
+ description: cancelled
84
+ ? t('scheduledPublication.toast.cancelledDescription')
85
+ : t('scheduledPublication.toast.cancelLostDescription'),
86
+ data: {
87
+ intent: cancelled ? 'success' : 'warning',
88
+ iconType: cancelled ? 'success' : 'warning',
89
+ icon: true,
90
+ close: true,
91
+ },
92
+ })
93
+ navigate({
94
+ to: getAdminRoutePath('scheduled-publications'),
95
+ search: location.search as Record<string, unknown>,
96
+ })
97
+ } catch (error) {
98
+ toastManager.add({
99
+ title: t('scheduledPublication.toast.failedTitle'),
100
+ description: (error as Error).message,
101
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
102
+ })
103
+ } finally {
104
+ setCancelling(null)
105
+ }
106
+ }
107
+
108
+ const unhealthy = data.runtime.health?.status !== 'healthy'
109
+
110
+ return (
111
+ <Section>
112
+ <Container>
113
+ <div className={cx('byline-scheduled-list-head', styles.head)}>
114
+ <h1 className={styles.title}>{t('scheduledPublication.list.title')}</h1>
115
+ <p className={styles.intro}>{t('scheduledPublication.list.intro')}</p>
116
+ </div>
117
+
118
+ {unhealthy && (
119
+ <Alert
120
+ className={styles.health}
121
+ intent="warning"
122
+ icon={true}
123
+ close={false}
124
+ title={t('scheduledPublication.list.schedulerWarningTitle')}
125
+ >
126
+ {t('scheduledPublication.list.schedulerWarningBody', {
127
+ status: data.runtime.health?.status ?? 'not_started',
128
+ })}
129
+ </Alert>
130
+ )}
131
+
132
+ <div className={styles.controls}>
133
+ <div className={styles.filters}>
134
+ <div className={styles.filter}>
135
+ <Select<string>
136
+ id="scheduled-publication-state"
137
+ name="scheduled-publication-state"
138
+ ariaLabel={t('scheduledPublication.list.filters.state')}
139
+ size="sm"
140
+ value={search.state ?? '_all'}
141
+ items={[
142
+ { value: '_all', label: t('scheduledPublication.list.filters.allStates') },
143
+ { value: 'armed', label: t('scheduledPublication.list.states.armed') },
144
+ {
145
+ value: 'needs_reconfirm',
146
+ label: t('scheduledPublication.list.states.needsReconfirm'),
147
+ },
148
+ ]}
149
+ onValueChange={(value) =>
150
+ applySearch({ state: value === '_all' ? undefined : value })
151
+ }
152
+ />
153
+ </div>
154
+ </div>
155
+ <RouterPager
156
+ page={data.meta.page}
157
+ count={data.meta.totalPages}
158
+ showFirstButton
159
+ showLastButton
160
+ componentName="scheduledPublicationsPagerTop"
161
+ aria-label={t('scheduledPublication.list.pagerAriaLabel')}
162
+ />
163
+ </div>
164
+
165
+ {data.schedules.length === 0 ? (
166
+ <p className={styles.empty}>{t('scheduledPublication.list.empty')}</p>
167
+ ) : (
168
+ <Table.Container className={styles.tableWrap}>
169
+ <Table>
170
+ <Table.Header>
171
+ <Table.Row>
172
+ <Table.HeadingCell scope="col">
173
+ {t('scheduledPublication.list.columns.document')}
174
+ </Table.HeadingCell>
175
+ <Table.HeadingCell scope="col">
176
+ {t('scheduledPublication.list.columns.publishAt')}
177
+ </Table.HeadingCell>
178
+ <Table.HeadingCell scope="col">
179
+ {t('scheduledPublication.list.columns.state')}
180
+ </Table.HeadingCell>
181
+ <Table.HeadingCell scope="col">
182
+ {t('scheduledPublication.list.columns.authorizer')}
183
+ </Table.HeadingCell>
184
+ <Table.HeadingCell scope="col">
185
+ {t('scheduledPublication.list.columns.attempt')}
186
+ </Table.HeadingCell>
187
+ <Table.HeadingCell scope="col">
188
+ {t('scheduledPublication.list.columns.actions')}
189
+ </Table.HeadingCell>
190
+ </Table.Row>
191
+ </Table.Header>
192
+ <Table.Body>
193
+ {data.schedules.map((schedule) => {
194
+ // Same derivation the document editor uses, so a row here and
195
+ // the document it links to can never disagree about whether a
196
+ // schedule has gone overdue.
197
+ const state = deriveScheduledPublicationState(
198
+ schedule,
199
+ { canSchedule: false, canConfirm: false, canCancel: true },
200
+ now
201
+ )
202
+ const publishAt = new Date(schedule.publishAt)
203
+ return (
204
+ <Table.Row key={schedule.documentId}>
205
+ <Table.Cell>
206
+ <Link
207
+ to={getAdminRoutePath('collections', '$collection', '$id')}
208
+ params={{
209
+ collection: schedule.collectionPath,
210
+ id: schedule.documentId,
211
+ }}
212
+ >
213
+ {schedule.documentPath || schedule.documentId}
214
+ </Link>
215
+ <span className={styles.collection}>{schedule.collectionLabel}</span>
216
+ </Table.Cell>
217
+ <Table.Cell>
218
+ <time className={styles.instant} dateTime={publishAt.toISOString()}>
219
+ {instantFormat.format(publishAt)}
220
+ </time>
221
+ <span className={styles.zone}>{timeZone}</span>
222
+ </Table.Cell>
223
+ <Table.Cell>
224
+ <Badge intent={STATE_INTENT[state.kind]}>
225
+ {state.kind === 'needs_reconfirm'
226
+ ? t('scheduledPublication.list.states.needsReconfirm')
227
+ : state.kind === 'overdue'
228
+ ? t('scheduledPublication.list.states.overdue')
229
+ : t('scheduledPublication.list.states.armed')}
230
+ </Badge>
231
+ </Table.Cell>
232
+ <Table.Cell>
233
+ {schedule.lastAuthorizedByName != null ? (
234
+ <span>{schedule.lastAuthorizedByName}</span>
235
+ ) : schedule.lastAuthorizedBy != null ? (
236
+ // The account is gone — the schedule outlives it by
237
+ // design — so the id is all there is left to identify
238
+ // who authorized this.
239
+ <span
240
+ className={styles.authorizer}
241
+ title={t('scheduledPublication.list.authorizerMissing')}
242
+ >
243
+ {schedule.lastAuthorizedBy}
244
+ </span>
245
+ ) : (
246
+ <span>—</span>
247
+ )}
248
+ </Table.Cell>
249
+ <Table.Cell>
250
+ <span>{schedule.attemptCount}</span>
251
+ {schedule.lastError != null && (
252
+ <span className={styles.error}>{schedule.lastError}</span>
253
+ )}
254
+ </Table.Cell>
255
+ <Table.Cell>
256
+ <Button
257
+ size="xs"
258
+ type="button"
259
+ intent="danger"
260
+ disabled={cancelling === schedule.documentId}
261
+ // The column header already says Actions and the row
262
+ // is a schedule, so the short label carries here —
263
+ // unlike the editor's menu, where "Cancel schedule"
264
+ // has to distinguish itself from cancelling an edit.
265
+ aria-label={t('scheduledPublication.actions.cancel')}
266
+ onClick={() => cancel(schedule.collectionPath, schedule.documentId)}
267
+ >
268
+ {t('common.actions.cancel')}
269
+ </Button>
270
+ </Table.Cell>
271
+ </Table.Row>
272
+ )
273
+ })}
274
+ </Table.Body>
275
+ </Table>
276
+ </Table.Container>
277
+ )}
278
+
279
+ <div className={styles.pagerBottom}>
280
+ <RouterPager
281
+ page={data.meta.page}
282
+ count={data.meta.totalPages}
283
+ showFirstButton
284
+ showLastButton
285
+ componentName="scheduledPublicationsPagerBottom"
286
+ aria-label={t('scheduledPublication.list.pagerAriaLabel')}
287
+ />
288
+ </div>
289
+ </Container>
290
+ </Section>
291
+ )
292
+ }