@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.
- package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +4 -1
- package/dist/admin-shell/chrome/admin-menu-drawer.js +26 -2
- package/dist/admin-shell/chrome/admin-menu-drawer_module.css +1 -1
- package/dist/admin-shell/collections/edit.js +142 -2
- package/dist/admin-shell/scheduled-publications/list.d.ts +4 -0
- package/dist/admin-shell/scheduled-publications/list.js +295 -0
- package/dist/admin-shell/scheduled-publications/list.module.js +25 -0
- package/dist/admin-shell/scheduled-publications/list_module.css +97 -0
- package/dist/integrations/analytics-agent.d.ts +20 -0
- package/dist/integrations/analytics-agent.js +57 -0
- package/dist/integrations/analytics-events.d.ts +51 -0
- package/dist/integrations/analytics-events.js +129 -0
- package/dist/integrations/serve-analytics-agent.d.ts +8 -0
- package/dist/integrations/serve-analytics-agent.js +11 -0
- package/dist/routes/analytics-range.d.ts +12 -0
- package/dist/routes/analytics-range.js +35 -0
- package/dist/routes/create-admin-analytics-route.d.ts +8 -0
- package/dist/routes/create-admin-analytics-route.js +108 -0
- package/dist/routes/create-admin-layout-route.js +15 -4
- package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
- package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +2 -0
- package/dist/server-fns/analytics/index.d.ts +9 -0
- package/dist/server-fns/analytics/index.js +2 -0
- package/dist/server-fns/analytics/maintenance.d.ts +22 -0
- package/dist/server-fns/analytics/maintenance.js +42 -0
- package/dist/server-fns/analytics/queries.d.ts +25 -0
- package/dist/server-fns/analytics/queries.js +54 -0
- package/dist/server-fns/collections/get.d.ts +5 -0
- package/dist/server-fns/collections/get.js +19 -3
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
- package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
- package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
- package/dist/server-fns/collections/scheduled-publication.js +157 -0
- package/dist/vite/analytics-dev.d.ts +22 -0
- package/dist/vite/analytics-dev.js +28 -0
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.js +1 -0
- package/package.json +14 -8
- package/src/admin-shell/chrome/admin-menu-drawer.module.css +7 -1
- package/src/admin-shell/chrome/admin-menu-drawer.tsx +37 -1
- package/src/admin-shell/collections/edit.tsx +140 -1
- package/src/admin-shell/scheduled-publications/list.module.css +105 -0
- package/src/admin-shell/scheduled-publications/list.tsx +292 -0
- package/src/integrations/analytics-agent.tsx +91 -0
- package/src/integrations/analytics-events.test.node.ts +245 -0
- package/src/integrations/analytics-events.ts +224 -0
- package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
- package/src/integrations/serve-analytics-agent.ts +19 -0
- package/src/routes/analytics-range.ts +55 -0
- package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
- package/src/routes/create-admin-analytics-route.tsx +109 -0
- package/src/routes/create-admin-layout-route.tsx +19 -7
- package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
- package/src/routes/index.ts +2 -0
- package/src/server-fns/analytics/index.ts +23 -0
- package/src/server-fns/analytics/maintenance.ts +54 -0
- package/src/server-fns/analytics/queries.ts +86 -0
- package/src/server-fns/collections/get.ts +30 -1
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
- package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
- package/src/server-fns/collections/scheduled-publication.ts +270 -0
- package/src/vite/analytics-dev.test.node.ts +126 -0
- package/src/vite/analytics-dev.ts +56 -0
- 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:
|
|
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(
|
|
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 {
|
|
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
|
+
}
|