@byline/admin 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/abilities.js +2 -0
- package/dist/forms/document-actions.d.ts +16 -1
- package/dist/forms/document-actions.js +43 -1
- package/dist/forms/form-renderer.d.ts +6 -1
- package/dist/forms/form-renderer.js +39 -36
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +7 -1
- package/dist/forms/form-status-display.d.ts +10 -1
- package/dist/forms/form-status-display.js +4 -2
- package/dist/forms/scheduled-publication-control.d.ts +56 -0
- package/dist/forms/scheduled-publication-control.js +376 -0
- package/dist/forms/scheduled-publication-control.module.js +25 -0
- package/dist/forms/scheduled-publication-control_module.css +95 -0
- package/dist/forms/scheduled-publication-state.d.ts +83 -0
- package/dist/forms/scheduled-publication-state.js +41 -0
- package/dist/forms/scheduled-publication-state.test.node.d.ts +8 -0
- package/dist/forms/scheduled-publication-time.d.ts +57 -0
- package/dist/forms/scheduled-publication-time.js +113 -0
- package/dist/forms/scheduled-publication-time.test.node.d.ts +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/modules/analytics/abilities.d.ts +14 -0
- package/dist/modules/analytics/abilities.js +21 -0
- package/dist/modules/analytics/components/dashboard.d.ts +20 -0
- package/dist/modules/analytics/components/dashboard.js +308 -0
- package/dist/modules/analytics/components/dashboard.module.js +29 -0
- package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/dashboard_module.css +268 -0
- package/dist/modules/analytics/components/timeseries.d.ts +59 -0
- package/dist/modules/analytics/components/timeseries.js +253 -0
- package/dist/modules/analytics/components/timeseries.module.js +17 -0
- package/dist/modules/analytics/components/timeseries_module.css +126 -0
- package/dist/modules/analytics/index.d.ts +9 -0
- package/dist/modules/analytics/index.js +1 -0
- package/dist/modules/analytics/types.d.ts +20 -0
- package/dist/modules/analytics/types.js +1 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/package.json +23 -7
- package/src/abilities.ts +2 -0
- package/src/forms/document-actions.tsx +73 -0
- package/src/forms/form-renderer.module.css +15 -0
- package/src/forms/form-renderer.tsx +59 -64
- package/src/forms/form-status-display.tsx +12 -0
- package/src/forms/path-widget.test.tsx +20 -8
- package/src/forms/scheduled-publication-control.module.css +149 -0
- package/src/forms/scheduled-publication-control.tsx +590 -0
- package/src/forms/scheduled-publication-datepicker.test.tsx +89 -0
- package/src/forms/scheduled-publication-state.test.node.ts +190 -0
- package/src/forms/scheduled-publication-state.ts +146 -0
- package/src/forms/scheduled-publication-time.test.node.ts +86 -0
- package/src/forms/scheduled-publication-time.ts +218 -0
- package/src/index.ts +1 -0
- package/src/modules/analytics/abilities.ts +33 -0
- package/src/modules/analytics/components/dashboard.module.css +330 -0
- package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
- package/src/modules/analytics/components/dashboard.tsx +371 -0
- package/src/modules/analytics/components/timeseries.module.css +151 -0
- package/src/modules/analytics/components/timeseries.tsx +332 -0
- package/src/modules/analytics/index.ts +14 -0
- package/src/modules/analytics/types.ts +31 -0
- package/src/react.ts +3 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
.root-L2qL4D {
|
|
2
|
+
border-radius: .25rem;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: .5rem;
|
|
5
|
+
display: flex;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.root-L2qL4D:focus-visible {
|
|
9
|
+
outline: 2px solid var(--primary-500);
|
|
10
|
+
outline-offset: 4px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
:is(.chart-giYTrx, .byline-analytics-chart) {
|
|
14
|
+
width: 100%;
|
|
15
|
+
height: 11rem;
|
|
16
|
+
display: block;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.gridline-R_wAwP {
|
|
20
|
+
stroke: var(--gray-200);
|
|
21
|
+
stroke-width: 1px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.column-_Cw2Ua {
|
|
25
|
+
transition: opacity .12s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.views-WHbm44 {
|
|
29
|
+
fill: #38bdf8;
|
|
30
|
+
opacity: .38;
|
|
31
|
+
transition: opacity .12s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.columnActive-CwHwbo .views-WHbm44 {
|
|
35
|
+
opacity: .62;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.visitors-n6IL4B {
|
|
39
|
+
fill: #7c3aed;
|
|
40
|
+
opacity: .95;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.hit-ZxDHvk {
|
|
44
|
+
fill: #0000;
|
|
45
|
+
cursor: crosshair;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
:is(.axis-fBshTw, .byline-analytics-axis) {
|
|
49
|
+
font-variant-numeric: tabular-nums;
|
|
50
|
+
letter-spacing: .04em;
|
|
51
|
+
color: var(--gray-500);
|
|
52
|
+
justify-content: space-between;
|
|
53
|
+
font-size: .6875rem;
|
|
54
|
+
display: flex;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
:is(.readout-L9jEpz, .byline-analytics-readout) {
|
|
58
|
+
font-variant-numeric: tabular-nums;
|
|
59
|
+
min-height: 1.25rem;
|
|
60
|
+
color: var(--gray-500);
|
|
61
|
+
flex-wrap: wrap;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: .35rem .75rem;
|
|
64
|
+
margin: 0;
|
|
65
|
+
font-size: .75rem;
|
|
66
|
+
display: flex;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.readout-L9jEpz strong {
|
|
70
|
+
color: inherit;
|
|
71
|
+
font-weight: 600;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.swatchViews-OCeKNe, .swatchVisitors-O3BWz6, .swatchDownloads-SVo9mN {
|
|
75
|
+
border-radius: 1px;
|
|
76
|
+
width: .5rem;
|
|
77
|
+
height: .5rem;
|
|
78
|
+
margin-inline: .4rem -.4rem;
|
|
79
|
+
display: inline-block;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.swatchViews-OCeKNe {
|
|
83
|
+
opacity: .55;
|
|
84
|
+
background-color: #38bdf8;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.swatchVisitors-O3BWz6 {
|
|
88
|
+
background-color: #7c3aed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.swatchDownloads-SVo9mN {
|
|
92
|
+
background-color: #10b981;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
:is([data-theme="dark"], .dark) .gridline-R_wAwP {
|
|
96
|
+
stroke: var(--gray-700);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
:is(:is([data-theme="dark"], .dark) .axis-fBshTw, :is([data-theme="dark"], .dark) .byline-analytics-axis, :is([data-theme="dark"], .dark) .readout-L9jEpz, :is([data-theme="dark"], .dark) .byline-analytics-readout) {
|
|
100
|
+
color: var(--gray-400);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
:is([data-theme="dark"], .dark) .visitors-n6IL4B {
|
|
104
|
+
fill: #a78bfa;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
:is([data-theme="dark"], .dark) .swatchVisitors-O3BWz6 {
|
|
108
|
+
background-color: #a78bfa;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
:is([data-theme="dark"], .dark) .swatchDownloads-SVo9mN {
|
|
112
|
+
background-color: #34d399;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@media (prefers-reduced-motion: reduce) {
|
|
116
|
+
.column-_Cw2Ua, .views-WHbm44 {
|
|
117
|
+
transition: none;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@media (max-width: 720px) {
|
|
122
|
+
:is(.chart-giYTrx, .byline-analytics-chart) {
|
|
123
|
+
height: 9rem;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export { ANALYTICS_ABILITIES, type AnalyticsAbilityKey, registerAnalyticsAbilities, } from './abilities.js';
|
|
9
|
+
export type { AnalyticsDashboardData, AnalyticsDashboardPeriod } from './types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ANALYTICS_ABILITIES, registerAnalyticsAbilities } from "./abilities.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { AnalyticsCountryTotal, AnalyticsDateRange, AnalyticsPathTotal, AnalyticsRankedTotals, AnalyticsReferrerTotal, AnalyticsReportCoverage, AnalyticsSummary } from '@byline/analytics';
|
|
9
|
+
import type { AnalyticsDashboardPeriod as PortableAnalyticsDashboardPeriod } from '@byline/analytics/config';
|
|
10
|
+
export type AnalyticsDashboardPeriod = PortableAnalyticsDashboardPeriod;
|
|
11
|
+
export interface AnalyticsDashboardData {
|
|
12
|
+
range: AnalyticsDateRange;
|
|
13
|
+
coverage: AnalyticsReportCoverage;
|
|
14
|
+
summary: AnalyticsSummary;
|
|
15
|
+
pages: AnalyticsRankedTotals<AnalyticsPathTotal>;
|
|
16
|
+
downloads: AnalyticsRankedTotals<AnalyticsPathTotal>;
|
|
17
|
+
referrers: AnalyticsRankedTotals<AnalyticsReferrerTotal>;
|
|
18
|
+
/** Countries are unbounded by a top-N limit, so there is nothing to truncate. */
|
|
19
|
+
countries: AnalyticsCountryTotal[];
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/react.d.ts
CHANGED
|
@@ -59,6 +59,7 @@ export * from './forms/form-context.js';
|
|
|
59
59
|
export * from './forms/form-renderer.js';
|
|
60
60
|
export * from './forms/navigation-guard.js';
|
|
61
61
|
export * from './forms/path-widget.js';
|
|
62
|
+
export * from './forms/scheduled-publication-state.js';
|
|
62
63
|
export * from './lib/translate-validation-error.js';
|
|
63
64
|
export * from './presentation/group.js';
|
|
64
65
|
export * from './presentation/row.js';
|
package/dist/react.js
CHANGED
|
@@ -30,6 +30,7 @@ export * from "./forms/form-context.js";
|
|
|
30
30
|
export * from "./forms/form-renderer.js";
|
|
31
31
|
export * from "./forms/navigation-guard.js";
|
|
32
32
|
export * from "./forms/path-widget.js";
|
|
33
|
+
export * from "./forms/scheduled-publication-state.js";
|
|
33
34
|
export * from "./lib/translate-validation-error.js";
|
|
34
35
|
export * from "./presentation/group.js";
|
|
35
36
|
export * from "./presentation/row.js";
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.16.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -77,6 +77,16 @@
|
|
|
77
77
|
"import": "./dist/modules/admin-preferences/index.js",
|
|
78
78
|
"require": "./dist/modules/admin-preferences/index.js"
|
|
79
79
|
},
|
|
80
|
+
"./analytics": {
|
|
81
|
+
"types": "./dist/modules/analytics/index.d.ts",
|
|
82
|
+
"import": "./dist/modules/analytics/index.js",
|
|
83
|
+
"require": "./dist/modules/analytics/index.js"
|
|
84
|
+
},
|
|
85
|
+
"./analytics/components/dashboard": {
|
|
86
|
+
"types": "./dist/modules/analytics/components/dashboard.d.ts",
|
|
87
|
+
"import": "./dist/modules/analytics/components/dashboard.js",
|
|
88
|
+
"require": "./dist/modules/analytics/components/dashboard.js"
|
|
89
|
+
},
|
|
80
90
|
"./services": {
|
|
81
91
|
"types": "./dist/services/admin-services-context.d.ts",
|
|
82
92
|
"import": "./dist/services/admin-services-context.js",
|
|
@@ -173,10 +183,12 @@
|
|
|
173
183
|
"react-diff-viewer-continued": "^4.4.0",
|
|
174
184
|
"uuid": "^14.0.2",
|
|
175
185
|
"zod": "^4.4.3",
|
|
176
|
-
"@byline/
|
|
177
|
-
"@byline/
|
|
178
|
-
"@byline/
|
|
179
|
-
"@byline/
|
|
186
|
+
"@byline/analytics": "4.16.0",
|
|
187
|
+
"@byline/i18n": "4.16.0",
|
|
188
|
+
"@byline/core": "4.16.0",
|
|
189
|
+
"@byline/ui": "4.16.0",
|
|
190
|
+
"@byline/auth": "4.16.0",
|
|
191
|
+
"@byline/analytics-agent": "4.16.0"
|
|
180
192
|
},
|
|
181
193
|
"peerDependencies": {
|
|
182
194
|
"react": "^19.0.0",
|
|
@@ -189,6 +201,8 @@
|
|
|
189
201
|
"@types/node": "^26.2.0",
|
|
190
202
|
"@types/react": "19.2.18",
|
|
191
203
|
"@types/react-dom": "19.2.4",
|
|
204
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
205
|
+
"jsdom": "^30.0.1",
|
|
192
206
|
"react": "^19.2.8",
|
|
193
207
|
"react-dom": "^19.2.8",
|
|
194
208
|
"rimraf": "^6.1.3",
|
|
@@ -207,8 +221,10 @@
|
|
|
207
221
|
"build": "rslib build",
|
|
208
222
|
"clean": "node scripts/clean.js node_modules dist build .turbo",
|
|
209
223
|
"lint": "biome check --write --unsafe --diagnostic-level=error",
|
|
210
|
-
"test": "vitest run --mode=node",
|
|
224
|
+
"test": "vitest run --mode=jsdom && vitest run --mode=node",
|
|
211
225
|
"test:watch": "vitest --mode=node",
|
|
212
|
-
"typecheck": "tsc --noEmit"
|
|
226
|
+
"typecheck": "tsc --noEmit",
|
|
227
|
+
"test:jsdom": "vitest run --mode=jsdom",
|
|
228
|
+
"test:node": "vitest run --mode=node"
|
|
213
229
|
}
|
|
214
230
|
}
|
package/src/abilities.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { registerAdminActivityAbilities } from './modules/admin-activity/abiliti
|
|
|
12
12
|
import { registerAdminPermissionsAbilities } from './modules/admin-permissions/abilities.js'
|
|
13
13
|
import { registerAdminRolesAbilities } from './modules/admin-roles/abilities.js'
|
|
14
14
|
import { registerAdminUsersAbilities } from './modules/admin-users/abilities.js'
|
|
15
|
+
import { registerAnalyticsAbilities } from './modules/analytics/abilities.js'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Register every ability contributed by the admin subsystem.
|
|
@@ -30,5 +31,6 @@ export function registerAdminAbilities(registry: AbilityRegistry): void {
|
|
|
30
31
|
registerAdminRolesAbilities(registry)
|
|
31
32
|
registerAdminPermissionsAbilities(registry)
|
|
32
33
|
registerAdminActivityAbilities(registry)
|
|
34
|
+
registerAnalyticsAbilities(registry)
|
|
33
35
|
// registerAccountAbilities(registry) — added when that module lands
|
|
34
36
|
}
|
|
@@ -26,6 +26,7 @@ import cx from 'clsx'
|
|
|
26
26
|
|
|
27
27
|
import styles from './document-actions.module.css'
|
|
28
28
|
import type { PublishedVersionInfo } from './form-renderer'
|
|
29
|
+
import type { ScheduledPublicationState } from './scheduled-publication-state.js'
|
|
29
30
|
|
|
30
31
|
const DUPLICATE_TITLE_SUFFIX = ' (copy)'
|
|
31
32
|
|
|
@@ -53,6 +54,10 @@ export function DocumentActions({
|
|
|
53
54
|
onDeleteLocale,
|
|
54
55
|
defaultLocale,
|
|
55
56
|
availableLocales,
|
|
57
|
+
scheduledPublicationState,
|
|
58
|
+
onSchedulePublication,
|
|
59
|
+
onConfirmScheduledPublication,
|
|
60
|
+
onCancelScheduledPublication,
|
|
56
61
|
}: {
|
|
57
62
|
publishedVersion?: PublishedVersionInfo | null
|
|
58
63
|
onUnpublish?: () => Promise<void>
|
|
@@ -118,6 +123,20 @@ export function DocumentActions({
|
|
|
118
123
|
* minus the default locale and the `'all'` sentinel.
|
|
119
124
|
*/
|
|
120
125
|
availableLocales?: string[]
|
|
126
|
+
/**
|
|
127
|
+
* Derived presentation state for the document's pending publication
|
|
128
|
+
* schedule. Decides which of the scheduling menu items appear; omit it (or
|
|
129
|
+
* pass a `none` state with no capabilities) and the group is hidden
|
|
130
|
+
* entirely — which is what an ineligible document, a single-status
|
|
131
|
+
* workflow, or an actor missing either ability all reduce to.
|
|
132
|
+
*/
|
|
133
|
+
scheduledPublicationState?: ScheduledPublicationState
|
|
134
|
+
/** Opens the schedule / reschedule modal. Save-gated by the caller. */
|
|
135
|
+
onSchedulePublication?: () => void
|
|
136
|
+
/** Re-confirms a suspended schedule against the current version. Save-gated by the caller. */
|
|
137
|
+
onConfirmScheduledPublication?: () => void | Promise<void>
|
|
138
|
+
/** Withdraws the pending schedule. Deliberately not save-gated. */
|
|
139
|
+
onCancelScheduledPublication?: () => void | Promise<void>
|
|
121
140
|
}) {
|
|
122
141
|
const { t } = useTranslation('byline-admin')
|
|
123
142
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
@@ -151,6 +170,46 @@ export function DocumentActions({
|
|
|
151
170
|
const [deleteLocaleBusy, setDeleteLocaleBusy] = useState(false)
|
|
152
171
|
const [deleteTargetLocale, setDeleteTargetLocale] = useState<string>('')
|
|
153
172
|
|
|
173
|
+
// Scheduled-publication menu items. The derived state already accounts for
|
|
174
|
+
// eligibility and for the abilities the actor holds, so the only thing left
|
|
175
|
+
// here is to map the permitted operations onto entries. The group renders
|
|
176
|
+
// above the locale and duplicate items because scheduling acts on the
|
|
177
|
+
// document's lifecycle rather than on its copies.
|
|
178
|
+
const scheduling = scheduledPublicationState
|
|
179
|
+
const schedulingActions: Array<{ key: string; label: string; onSelect: () => void }> = []
|
|
180
|
+
if (scheduling != null) {
|
|
181
|
+
if (scheduling.actions.schedule && onSchedulePublication != null) {
|
|
182
|
+
schedulingActions.push({
|
|
183
|
+
key: 'schedule',
|
|
184
|
+
// Short form: the menu supplies the "this document" context that the
|
|
185
|
+
// modal's title and submit button have to spell out for themselves.
|
|
186
|
+
label: t('scheduledPublication.actions.scheduleMenuItem'),
|
|
187
|
+
onSelect: onSchedulePublication,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
if (scheduling.actions.confirm && onConfirmScheduledPublication != null) {
|
|
191
|
+
schedulingActions.push({
|
|
192
|
+
key: 'confirm',
|
|
193
|
+
label: t('scheduledPublication.actions.confirm'),
|
|
194
|
+
onSelect: () => void onConfirmScheduledPublication(),
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
if (scheduling.actions.reschedule && onSchedulePublication != null) {
|
|
198
|
+
schedulingActions.push({
|
|
199
|
+
key: 'reschedule',
|
|
200
|
+
label: t('scheduledPublication.actions.reschedule'),
|
|
201
|
+
onSelect: onSchedulePublication,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
if (scheduling.actions.cancel && onCancelScheduledPublication != null) {
|
|
205
|
+
schedulingActions.push({
|
|
206
|
+
key: 'cancel',
|
|
207
|
+
label: t('scheduledPublication.actions.cancel'),
|
|
208
|
+
onSelect: () => void onCancelScheduledPublication(),
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
154
213
|
const handleOnDelete = () => {
|
|
155
214
|
setShowDeleteConfirm(false)
|
|
156
215
|
if (onDelete) {
|
|
@@ -270,6 +329,20 @@ export function DocumentActions({
|
|
|
270
329
|
<DropdownComponent.Separator />
|
|
271
330
|
</>
|
|
272
331
|
)}*/}
|
|
332
|
+
{schedulingActions.length > 0 && (
|
|
333
|
+
<>
|
|
334
|
+
{schedulingActions.map((action) => (
|
|
335
|
+
<DropdownComponent.Item key={action.key} onClick={action.onSelect}>
|
|
336
|
+
<div className={cx('byline-form-actions-item', styles.item)}>
|
|
337
|
+
<span className={cx('byline-form-actions-item-text', styles['item-text'])}>
|
|
338
|
+
<button type="button">{action.label}</button>
|
|
339
|
+
</span>
|
|
340
|
+
</div>
|
|
341
|
+
</DropdownComponent.Item>
|
|
342
|
+
))}
|
|
343
|
+
<DropdownComponent.Separator />
|
|
344
|
+
</>
|
|
345
|
+
)}
|
|
273
346
|
{copyToLocaleAvailable && (
|
|
274
347
|
<DropdownComponent.Item onClick={handleOpenCopyToLocale}>
|
|
275
348
|
<div className={cx('byline-form-actions-item', styles.item)}>
|
|
@@ -101,6 +101,11 @@
|
|
|
101
101
|
justify-content: center;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
.status-details,
|
|
105
|
+
:global(.byline-form-status-details) {
|
|
106
|
+
min-width: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
104
109
|
.status-meta,
|
|
105
110
|
:global(.byline-form-status-meta) {
|
|
106
111
|
display: flex;
|
|
@@ -121,7 +126,16 @@
|
|
|
121
126
|
.status-meta,
|
|
122
127
|
:global(.byline-form-status-meta) {
|
|
123
128
|
flex-direction: row;
|
|
129
|
+
flex-wrap: wrap;
|
|
124
130
|
align-items: center;
|
|
131
|
+
/* Wrapping rather than truncating. Every cell here sets `min-width: 0` so
|
|
132
|
+
it can ellipsize, which works while the row holds three short facts and
|
|
133
|
+
fails once a fourth (a scheduled instant with its IANA zone) joins them —
|
|
134
|
+
the row starts shaving every cell at once, down to "Status: D…". Letting
|
|
135
|
+
it wrap keeps each fact whole and costs a line only when one is needed.
|
|
136
|
+
The row gap is explicit because the shared `gap` collapses the line-height
|
|
137
|
+
override above into an unreadably tight second row. */
|
|
138
|
+
row-gap: 0.35rem;
|
|
125
139
|
}
|
|
126
140
|
}
|
|
127
141
|
|
|
@@ -135,6 +149,7 @@
|
|
|
135
149
|
.status-cell,
|
|
136
150
|
:global(.byline-form-status-cell) {
|
|
137
151
|
display: flex;
|
|
152
|
+
flex-wrap: wrap;
|
|
138
153
|
align-items: center;
|
|
139
154
|
gap: 0.25rem;
|
|
140
155
|
min-width: 0;
|
|
@@ -38,6 +38,13 @@ import styles from './form-renderer.module.css'
|
|
|
38
38
|
import { FormStatusDisplay } from './form-status-display'
|
|
39
39
|
import { useNavigationGuardAdapter } from './navigation-guard'
|
|
40
40
|
import { PathWidget } from './path-widget'
|
|
41
|
+
import {
|
|
42
|
+
ScheduledPublicationCell,
|
|
43
|
+
type ScheduledPublicationInfo,
|
|
44
|
+
ScheduledPublicationNotice,
|
|
45
|
+
type SchedulePublicationInput,
|
|
46
|
+
useScheduledPublication,
|
|
47
|
+
} from './scheduled-publication-control'
|
|
41
48
|
import { computeStatusTransitions } from './status-transitions'
|
|
42
49
|
import { TreePlacementWidget } from './tree-placement-widget'
|
|
43
50
|
import { executeUploadsWithProgress } from './upload-executor'
|
|
@@ -79,6 +86,10 @@ export interface FormRendererProps {
|
|
|
79
86
|
onCancel: () => void
|
|
80
87
|
onStatusChange?: (nextStatus: string) => Promise<void>
|
|
81
88
|
onUnpublish?: () => Promise<void>
|
|
89
|
+
scheduledPublication?: ScheduledPublicationInfo | null
|
|
90
|
+
onSchedulePublication?: (input: SchedulePublicationInput) => Promise<void>
|
|
91
|
+
onConfirmScheduledPublication?: () => Promise<void>
|
|
92
|
+
onCancelScheduledPublication?: () => Promise<void>
|
|
82
93
|
onDelete?: () => Promise<void>
|
|
83
94
|
/**
|
|
84
95
|
* Called when the editor confirms the duplicate modal in
|
|
@@ -176,6 +187,10 @@ const FormContent = ({
|
|
|
176
187
|
onCancel,
|
|
177
188
|
onStatusChange,
|
|
178
189
|
onUnpublish,
|
|
190
|
+
scheduledPublication,
|
|
191
|
+
onSchedulePublication,
|
|
192
|
+
onConfirmScheduledPublication,
|
|
193
|
+
onCancelScheduledPublication,
|
|
179
194
|
onDelete,
|
|
180
195
|
onDuplicate,
|
|
181
196
|
onCopyToLocale,
|
|
@@ -241,6 +256,18 @@ const FormContent = ({
|
|
|
241
256
|
const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] =
|
|
242
257
|
useState<SystemFieldsSubmitPayload | null>(null)
|
|
243
258
|
const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
|
|
259
|
+
|
|
260
|
+
// Scheduled publication owns three placements — a status-bar cell, an
|
|
261
|
+
// escalated notice, and the schedule modal — so its state lives here and the
|
|
262
|
+
// surfaces are rendered where each belongs.
|
|
263
|
+
const scheduling = useScheduledPublication({
|
|
264
|
+
schedule: scheduledPublication ?? null,
|
|
265
|
+
onSchedule: onSchedulePublication,
|
|
266
|
+
onConfirm: onConfirmScheduledPublication,
|
|
267
|
+
onCancel: onCancelScheduledPublication,
|
|
268
|
+
hasUnsavedChanges: hasChanges,
|
|
269
|
+
onUnsavedChanges: () => setShowUnsavedModal(true),
|
|
270
|
+
})
|
|
244
271
|
const { uploadField } = useBylineFieldServices()
|
|
245
272
|
|
|
246
273
|
// Path-widget wiring. The live preview must use the installation's
|
|
@@ -563,12 +590,17 @@ const FormContent = ({
|
|
|
563
590
|
{headerSlot}
|
|
564
591
|
</div>
|
|
565
592
|
<div className={cx('byline-form-status-bar', styles['status-bar'])}>
|
|
566
|
-
<
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
593
|
+
<div className={cx('byline-form-status-details', styles['status-details'])}>
|
|
594
|
+
<FormStatusDisplay
|
|
595
|
+
initialData={initialData}
|
|
596
|
+
workflowStatuses={workflowStatuses}
|
|
597
|
+
publishedVersion={publishedVersion}
|
|
598
|
+
onUnpublish={onUnpublish}
|
|
599
|
+
afterStatusCells={
|
|
600
|
+
<ScheduledPublicationCell state={scheduling.state} timeZone={scheduling.timeZone} />
|
|
601
|
+
}
|
|
602
|
+
/>
|
|
603
|
+
</div>
|
|
572
604
|
<div className={cx('byline-form-actions', styles.actions)}>
|
|
573
605
|
<Button
|
|
574
606
|
className={cx('byline-form-actions-button', styles['actions-button'])}
|
|
@@ -667,9 +699,22 @@ const FormContent = ({
|
|
|
667
699
|
onDeleteLocale={onDeleteLocale}
|
|
668
700
|
defaultLocale={defaultLocale}
|
|
669
701
|
availableLocales={initialData?._availableVersionLocales as string[] | undefined}
|
|
702
|
+
scheduledPublicationState={scheduling.state}
|
|
703
|
+
onSchedulePublication={scheduling.openSchedule}
|
|
704
|
+
onConfirmScheduledPublication={scheduling.confirm}
|
|
705
|
+
onCancelScheduledPublication={scheduling.cancel}
|
|
670
706
|
/>
|
|
671
707
|
</div>
|
|
672
708
|
</div>
|
|
709
|
+
<ScheduledPublicationNotice
|
|
710
|
+
state={scheduling.state}
|
|
711
|
+
timeZone={scheduling.timeZone}
|
|
712
|
+
busy={scheduling.busy}
|
|
713
|
+
onConfirm={scheduling.confirm}
|
|
714
|
+
onReschedule={scheduling.openSchedule}
|
|
715
|
+
onCancel={scheduling.cancel}
|
|
716
|
+
/>
|
|
717
|
+
{scheduling.modal}
|
|
673
718
|
{restoreWarnings && restoreWarnings.length > 0 && (
|
|
674
719
|
<Alert
|
|
675
720
|
className="m-0 mt-4"
|
|
@@ -740,36 +785,9 @@ const FormContent = ({
|
|
|
740
785
|
)
|
|
741
786
|
}
|
|
742
787
|
|
|
743
|
-
export const FormRenderer = ({
|
|
744
|
-
mode,
|
|
745
|
-
|
|
746
|
-
onSubmit,
|
|
747
|
-
onCancel,
|
|
748
|
-
onStatusChange,
|
|
749
|
-
onUnpublish,
|
|
750
|
-
onDelete,
|
|
751
|
-
onDuplicate,
|
|
752
|
-
onCopyToLocale,
|
|
753
|
-
onDeleteLocale,
|
|
754
|
-
contentLocales,
|
|
755
|
-
nextStatus,
|
|
756
|
-
workflowStatuses,
|
|
757
|
-
publishedVersion,
|
|
758
|
-
initialData,
|
|
759
|
-
adminConfig,
|
|
760
|
-
useAsTitle,
|
|
761
|
-
useAsPath,
|
|
762
|
-
advertiseLocales,
|
|
763
|
-
tree,
|
|
764
|
-
headingLabel,
|
|
765
|
-
headerSlot,
|
|
766
|
-
collectionPath,
|
|
767
|
-
initialLocale,
|
|
768
|
-
onLocaleChange,
|
|
769
|
-
defaultLocale,
|
|
770
|
-
useNavigationGuard,
|
|
771
|
-
restoreWarnings,
|
|
772
|
-
}: FormRendererProps) => {
|
|
788
|
+
export const FormRenderer = (props: FormRendererProps) => {
|
|
789
|
+
const { mode, initialData, initialLocale, collectionPath } = props
|
|
790
|
+
|
|
773
791
|
// Persists per-tab-set active tab across locale-change remounts of FormContent.
|
|
774
792
|
// useRef so mutations never trigger a re-render of FormRenderer itself.
|
|
775
793
|
const savedTabsRef = useRef<Record<string, string>>({})
|
|
@@ -781,35 +799,12 @@ export const FormRenderer = ({
|
|
|
781
799
|
documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
|
|
782
800
|
collectionPath={collectionPath ?? null}
|
|
783
801
|
>
|
|
802
|
+
{/* Forwarded wholesale. An enumerated prop list here silently dropped
|
|
803
|
+
every prop added to FormRendererProps but not copied down — which is
|
|
804
|
+
how the scheduled-publication handlers reached FormContent as
|
|
805
|
+
`undefined` and the control never rendered. */}
|
|
784
806
|
<FormContent
|
|
785
|
-
|
|
786
|
-
fields={fields}
|
|
787
|
-
onSubmit={onSubmit}
|
|
788
|
-
onCancel={onCancel}
|
|
789
|
-
onStatusChange={onStatusChange}
|
|
790
|
-
onUnpublish={onUnpublish}
|
|
791
|
-
onDelete={onDelete}
|
|
792
|
-
onDuplicate={onDuplicate}
|
|
793
|
-
onCopyToLocale={onCopyToLocale}
|
|
794
|
-
onDeleteLocale={onDeleteLocale}
|
|
795
|
-
contentLocales={contentLocales}
|
|
796
|
-
nextStatus={nextStatus}
|
|
797
|
-
workflowStatuses={workflowStatuses}
|
|
798
|
-
publishedVersion={publishedVersion}
|
|
799
|
-
initialData={initialData}
|
|
800
|
-
adminConfig={adminConfig}
|
|
801
|
-
useAsTitle={useAsTitle}
|
|
802
|
-
useAsPath={useAsPath}
|
|
803
|
-
advertiseLocales={advertiseLocales}
|
|
804
|
-
tree={tree}
|
|
805
|
-
headingLabel={headingLabel}
|
|
806
|
-
headerSlot={headerSlot}
|
|
807
|
-
collectionPath={collectionPath}
|
|
808
|
-
initialLocale={initialLocale}
|
|
809
|
-
onLocaleChange={onLocaleChange}
|
|
810
|
-
defaultLocale={defaultLocale}
|
|
811
|
-
useNavigationGuard={useNavigationGuard}
|
|
812
|
-
restoreWarnings={restoreWarnings}
|
|
807
|
+
{...props}
|
|
813
808
|
_activeTabBySet={savedTabsRef.current}
|
|
814
809
|
_onTabChange={(tabSetName, tabName) => {
|
|
815
810
|
savedTabsRef.current = { ...savedTabsRef.current, [tabSetName]: tabName }
|
|
@@ -27,11 +27,21 @@ export const FormStatusDisplay = ({
|
|
|
27
27
|
workflowStatuses,
|
|
28
28
|
publishedVersion,
|
|
29
29
|
onUnpublish,
|
|
30
|
+
afterStatusCells,
|
|
30
31
|
}: {
|
|
31
32
|
initialData?: Record<string, any>
|
|
32
33
|
workflowStatuses?: WorkflowStatus[]
|
|
33
34
|
publishedVersion?: PublishedVersionInfo | null
|
|
34
35
|
onUnpublish?: () => Promise<void>
|
|
36
|
+
/**
|
|
37
|
+
* Rendered inside the status cell, directly after the status value, so it
|
|
38
|
+
* reads as a continuation of the same sentence: "Status: Draft — Scheduled
|
|
39
|
+
* for …". Scheduled publication uses this because an armed schedule is a
|
|
40
|
+
* statement about the document's lifecycle, not a timestamp to file beside
|
|
41
|
+
* Created. Falls back to standing alone when the workflow is single-status
|
|
42
|
+
* and no status cell is drawn.
|
|
43
|
+
*/
|
|
44
|
+
afterStatusCells?: React.ReactNode
|
|
35
45
|
}) => {
|
|
36
46
|
const { t } = useTranslation('byline-admin')
|
|
37
47
|
const statusCode = initialData?.status
|
|
@@ -51,8 +61,10 @@ export const FormStatusDisplay = ({
|
|
|
51
61
|
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
52
62
|
{statusLabel}
|
|
53
63
|
</span>
|
|
64
|
+
{afterStatusCells}
|
|
54
65
|
</div>
|
|
55
66
|
)}
|
|
67
|
+
{!showStatusCell && afterStatusCells}
|
|
56
68
|
|
|
57
69
|
{initialData?.updatedAt != null && (
|
|
58
70
|
<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { act } from 'react'
|
|
10
10
|
|
|
11
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
12
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
11
13
|
import { createRoot, type Root } from 'react-dom/client'
|
|
12
14
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
13
15
|
|
|
@@ -112,15 +114,25 @@ describe('PathWidget', () => {
|
|
|
112
114
|
) => {
|
|
113
115
|
act(() => {
|
|
114
116
|
root.render(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
// The real English admin bundle rather than a stubbed `t`, so the
|
|
118
|
+
// assertions below check the strings an editor actually sees. A stub
|
|
119
|
+
// would let a key be renamed or deleted without the test noticing.
|
|
120
|
+
<I18nProvider
|
|
121
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
122
|
+
activeLocale="en"
|
|
118
123
|
defaultLocale="en"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
125
|
+
>
|
|
126
|
+
<PathWidget
|
|
127
|
+
useAsPath={props.useAsPath ?? 'title'}
|
|
128
|
+
collectionPath="pages"
|
|
129
|
+
defaultLocale="en"
|
|
130
|
+
activeLocale={props.activeLocale ?? 'en'}
|
|
131
|
+
mode={props.mode ?? 'create'}
|
|
132
|
+
slugifier={props.slugifier}
|
|
133
|
+
sourceLocked={props.sourceLocked}
|
|
134
|
+
/>
|
|
135
|
+
</I18nProvider>
|
|
124
136
|
)
|
|
125
137
|
})
|
|
126
138
|
}
|