@byline/admin 4.15.0 → 4.17.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 (51) hide show
  1. package/dist/abilities.js +2 -0
  2. package/dist/fields/relation/relation-field.js +3 -2
  3. package/dist/fields/relation/relation-many-field.js +3 -2
  4. package/dist/fields/relation/relation-picker.d.ts +2 -2
  5. package/dist/fields/relation/relation-summary.d.ts +2 -2
  6. package/dist/forms/document-actions.js +30 -25
  7. package/dist/forms/form-renderer.d.ts +17 -3
  8. package/dist/forms/form-renderer.js +18 -9
  9. package/dist/forms/tree-placement-widget.js +3 -2
  10. package/dist/forms/use-form-layout.d.ts +3 -3
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/modules/analytics/abilities.d.ts +14 -0
  14. package/dist/modules/analytics/abilities.js +21 -0
  15. package/dist/modules/analytics/components/dashboard.d.ts +20 -0
  16. package/dist/modules/analytics/components/dashboard.js +308 -0
  17. package/dist/modules/analytics/components/dashboard.module.js +29 -0
  18. package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
  19. package/dist/modules/analytics/components/dashboard_module.css +268 -0
  20. package/dist/modules/analytics/components/timeseries.d.ts +59 -0
  21. package/dist/modules/analytics/components/timeseries.js +253 -0
  22. package/dist/modules/analytics/components/timeseries.module.js +17 -0
  23. package/dist/modules/analytics/components/timeseries_module.css +126 -0
  24. package/dist/modules/analytics/index.d.ts +9 -0
  25. package/dist/modules/analytics/index.js +1 -0
  26. package/dist/modules/analytics/types.d.ts +20 -0
  27. package/dist/modules/analytics/types.js +1 -0
  28. package/package.json +21 -11
  29. package/src/abilities.ts +2 -0
  30. package/src/fields/relation/relation-field.tsx +5 -3
  31. package/src/fields/relation/relation-many-field.tsx +5 -3
  32. package/src/fields/relation/relation-picker.tsx +2 -2
  33. package/src/fields/relation/relation-summary.tsx +2 -2
  34. package/src/forms/document-actions.test.tsx +157 -0
  35. package/src/forms/document-actions.tsx +93 -74
  36. package/src/forms/form-renderer-capabilities.test.tsx +155 -0
  37. package/src/forms/form-renderer-submit.test.tsx +194 -0
  38. package/src/forms/form-renderer.tsx +59 -25
  39. package/src/forms/tree-placement-widget.tsx +3 -2
  40. package/src/forms/use-form-layout.ts +3 -3
  41. package/src/index.ts +1 -0
  42. package/src/modules/analytics/abilities.ts +33 -0
  43. package/src/modules/analytics/components/dashboard.module.css +330 -0
  44. package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
  45. package/src/modules/analytics/components/dashboard.tsx +371 -0
  46. package/src/modules/analytics/components/timeseries.module.css +151 -0
  47. package/src/modules/analytics/components/timeseries.tsx +332 -0
  48. package/src/modules/analytics/index.ts +14 -0
  49. package/src/modules/analytics/types.ts +31 -0
  50. package/dist/forms/__probe-nested.test.node.d.ts +0 -1
  51. package/dist/forms/__probe-two.test.node.d.ts +0 -1
@@ -0,0 +1,371 @@
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 type React from 'react'
12
+ import { useEffect, useMemo, useState } from 'react'
13
+
14
+ import {
15
+ ANALYTICS_DASHBOARD_PERIODS,
16
+ ANALYTICS_OVERFLOW_KEY,
17
+ isAnalyticsDashboardPeriod,
18
+ } from '@byline/analytics/config'
19
+ import { ANALYTICS_IGNORE_STORAGE_KEY } from '@byline/analytics-agent'
20
+ import { useTranslation } from '@byline/i18n/react'
21
+ import { Button, Card, Container, Section, Select } from '@byline/ui/react'
22
+ import cx from 'clsx'
23
+
24
+ import styles from './dashboard.module.css'
25
+ import { AnalyticsTimeseries, resolveAnalyticsChartGranularity } from './timeseries.js'
26
+ import type { AnalyticsDashboardData, AnalyticsDashboardPeriod } from '../types.js'
27
+
28
+ export interface AnalyticsDashboardProps {
29
+ data: AnalyticsDashboardData
30
+ period: AnalyticsDashboardPeriod
31
+ onPeriodChange(period: AnalyticsDashboardPeriod): void
32
+ }
33
+
34
+ export function AnalyticsDashboard({
35
+ data,
36
+ period,
37
+ onPeriodChange,
38
+ }: AnalyticsDashboardProps): React.JSX.Element {
39
+ const { locale, t } = useTranslation('byline-admin')
40
+ const [excluded, setExcluded] = useState(false)
41
+ const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale])
42
+
43
+ useEffect(() => {
44
+ try {
45
+ setExcluded(localStorage.getItem(ANALYTICS_IGNORE_STORAGE_KEY) != null)
46
+ } catch {
47
+ setExcluded(false)
48
+ }
49
+ }, [])
50
+
51
+ const toggleExclusion = () => {
52
+ try {
53
+ if (excluded) localStorage.removeItem(ANALYTICS_IGNORE_STORAGE_KEY)
54
+ else localStorage.setItem(ANALYTICS_IGNORE_STORAGE_KEY, '1')
55
+ setExcluded(!excluded)
56
+ } catch {
57
+ // A blocked storage surface leaves the current collection behavior unchanged.
58
+ }
59
+ }
60
+
61
+ const periodItems = ANALYTICS_DASHBOARD_PERIODS.map((value) => ({
62
+ value: String(value),
63
+ label:
64
+ typeof value === 'number'
65
+ ? t('analytics.period.days', { count: value })
66
+ : t(`analytics.period.${value}`),
67
+ }))
68
+
69
+ const { views, visitors, downloads } = data.summary
70
+ const days = data.summary.timeseries.length
71
+ const chartGranularity = resolveAnalyticsChartGranularity(period, days)
72
+
73
+ return (
74
+ <Section>
75
+ <Container>
76
+ <header className={cx('byline-analytics-header', styles.header)}>
77
+ <div>
78
+ <h1 className={cx('byline-analytics-title', styles.title)}>{t('analytics.title')}</h1>
79
+ <p className={cx('muted', 'byline-analytics-help', styles.help)}>
80
+ {t('analytics.dailyUniquesHelp')}
81
+ </p>
82
+ </div>
83
+ <div className={cx('byline-analytics-controls', styles.controls)}>
84
+ <Select<string>
85
+ id="analytics-period"
86
+ name="analytics-period"
87
+ aria-label={t('analytics.period.label')}
88
+ size="sm"
89
+ value={String(period)}
90
+ items={periodItems}
91
+ onValueChange={(value) => {
92
+ const next = value === 'ytd' || value === 'all' ? value : Number(value)
93
+ if (isAnalyticsDashboardPeriod(next)) onPeriodChange(next)
94
+ }}
95
+ />
96
+ <Button
97
+ type="button"
98
+ size="sm"
99
+ aria-pressed={excluded}
100
+ // Local storage is origin-scoped, so this only governs public-page
101
+ // collection when the admin and the public site share an origin.
102
+ title={t('analytics.exclusion.help')}
103
+ onClick={toggleExclusion}
104
+ >
105
+ {excluded ? t('analytics.exclusion.include') : t('analytics.exclusion.exclude')}
106
+ </Button>
107
+ </div>
108
+ </header>
109
+
110
+ {/* Tinted ground, saturated ink, tracked label, tabular number — the
111
+ same tile grammar as the collection dashboard's status counts. */}
112
+ <div className={cx('byline-analytics-stats', styles.stats)}>
113
+ <StatTile
114
+ tone="views"
115
+ label={t('analytics.stats.views')}
116
+ value={numbers.format(views)}
117
+ foot={t('analytics.stats.perDay', {
118
+ count: days === 0 ? 0 : Math.round(views / days),
119
+ })}
120
+ />
121
+ <StatTile
122
+ tone="visitors"
123
+ label={t('analytics.stats.dailyUniques')}
124
+ value={numbers.format(visitors)}
125
+ // The qualification rides under the figure it qualifies rather
126
+ // than sitting in help text several elements away.
127
+ foot={t('analytics.stats.sumOfDays', { count: days })}
128
+ />
129
+ <StatTile
130
+ tone="downloads"
131
+ label={t('analytics.stats.downloads')}
132
+ value={numbers.format(downloads)}
133
+ foot={t('analytics.stats.shareOfViews', {
134
+ share: formatShare(downloads, views, locale),
135
+ })}
136
+ />
137
+ </div>
138
+
139
+ <Card className={cx('byline-analytics-chart-card', styles.chartCard)}>
140
+ <Card.Header>
141
+ <Card.Title>
142
+ {chartGranularity === 'day'
143
+ ? t('analytics.chart.perDay')
144
+ : chartGranularity === 'seven-day'
145
+ ? t('analytics.chart.perSevenDays')
146
+ : t('analytics.chart.perMonth')}
147
+ </Card.Title>
148
+ </Card.Header>
149
+ <Card.Content>
150
+ <AnalyticsTimeseries
151
+ days={data.summary.timeseries}
152
+ granularity={chartGranularity}
153
+ locale={locale}
154
+ />
155
+ </Card.Content>
156
+ </Card>
157
+
158
+ {/* Top pages is the list people actually read, so it gets the wide
159
+ column; referrers and countries stack beside it. */}
160
+ <div className={cx('byline-analytics-lists', styles.lists)}>
161
+ <RankedList
162
+ title={t('analytics.sections.pages')}
163
+ caption={t('analytics.columns.viewsAndUniques')}
164
+ tone="views"
165
+ locale={locale}
166
+ rows={data.pages.rows.map(toPathRow)}
167
+ total={data.pages.total}
168
+ coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}
169
+ />
170
+ <div className={styles.stack}>
171
+ <RankedList
172
+ title={t('analytics.sections.referrers')}
173
+ tone="visitors"
174
+ locale={locale}
175
+ total={data.referrers.total}
176
+ coverageFrom={partialCoverageFrom(data.range.from, data.coverage.referrersFrom)}
177
+ rows={data.referrers.rows.map((row) => ({
178
+ key: row.referrerHost,
179
+ label: row.referrerHost,
180
+ value: row.views,
181
+ visitors: row.visitors,
182
+ overflow: row.referrerHost === ANALYTICS_OVERFLOW_KEY,
183
+ }))}
184
+ />
185
+ <RankedList
186
+ title={t('analytics.sections.countries')}
187
+ tone="visitors"
188
+ locale={locale}
189
+ rows={data.countries.map((row) => ({
190
+ key: row.country,
191
+ label: row.country,
192
+ value: row.views,
193
+ visitors: row.visitors,
194
+ overflow: false,
195
+ }))}
196
+ />
197
+ </div>
198
+ </div>
199
+
200
+ <RankedList
201
+ title={t('analytics.sections.downloads')}
202
+ caption={t('analytics.columns.clicksAndUniques')}
203
+ tone="downloads"
204
+ locale={locale}
205
+ rows={data.downloads.rows.map(toPathRow)}
206
+ total={data.downloads.total}
207
+ coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}
208
+ />
209
+ </Container>
210
+ </Section>
211
+ )
212
+ }
213
+
214
+ type AnalyticsTone = 'views' | 'visitors' | 'downloads'
215
+
216
+ const TONE_TILE: Record<AnalyticsTone, string | undefined> = {
217
+ views: styles.toneViews,
218
+ visitors: styles.toneVisitors,
219
+ downloads: styles.toneDownloads,
220
+ }
221
+
222
+ const TONE_BAR: Record<AnalyticsTone, string | undefined> = {
223
+ views: styles.barViews,
224
+ visitors: styles.barVisitors,
225
+ downloads: styles.barDownloads,
226
+ }
227
+
228
+ function StatTile({
229
+ tone,
230
+ label,
231
+ value,
232
+ foot,
233
+ }: {
234
+ tone: AnalyticsTone
235
+ label: string
236
+ value: string
237
+ foot: string
238
+ }): React.JSX.Element {
239
+ return (
240
+ <div className={cx('byline-analytics-stat', styles.stat, TONE_TILE[tone])}>
241
+ <span className={cx('byline-analytics-stat-label', styles.statLabel)}>{label}</span>
242
+ <span className={cx('byline-analytics-stat-value', styles.statValue)}>{value}</span>
243
+ <span className={cx('byline-analytics-stat-foot', styles.statFoot)}>{foot}</span>
244
+ </div>
245
+ )
246
+ }
247
+
248
+ interface RankedRow {
249
+ key: string
250
+ label: string
251
+ value: number
252
+ visitors: number
253
+ overflow: boolean
254
+ }
255
+
256
+ function toPathRow(row: { path: string; views: number; visitors: number }): RankedRow {
257
+ return {
258
+ key: row.path,
259
+ label: row.path,
260
+ value: row.views,
261
+ visitors: row.visitors,
262
+ overflow: row.path === ANALYTICS_OVERFLOW_KEY,
263
+ }
264
+ }
265
+
266
+ function RankedList({
267
+ title,
268
+ caption,
269
+ rows,
270
+ tone,
271
+ locale,
272
+ total,
273
+ coverageFrom,
274
+ }: {
275
+ title: string
276
+ caption?: string
277
+ rows: RankedRow[]
278
+ tone: AnalyticsTone
279
+ locale: string
280
+ /** Distinct keys in the period; omit for lists that are never truncated. */
281
+ total?: number
282
+ /** First complete day when the selected report begins before retained rows. */
283
+ coverageFrom?: string
284
+ }): React.JSX.Element {
285
+ const { t } = useTranslation('byline-admin')
286
+ const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale])
287
+ const ceiling = Math.max(1, ...rows.map((row) => row.value))
288
+ // Say so when the list is a top-N slice. Without this the card presents a
289
+ // truncated ranking as though it were the whole set.
290
+ const truncated = total != null && total > rows.length
291
+ const coverageDate = useMemo(
292
+ () =>
293
+ new Intl.DateTimeFormat(locale, {
294
+ dateStyle: 'medium',
295
+ timeZone: 'UTC',
296
+ }),
297
+ [locale]
298
+ )
299
+ const description = [
300
+ truncated ? t('analytics.topOf', { shown: rows.length, total }) : caption,
301
+ coverageFrom == null
302
+ ? undefined
303
+ : t('analytics.coverage.since', {
304
+ date: coverageDate.format(new Date(`${coverageFrom}T00:00:00.000Z`)),
305
+ }),
306
+ ]
307
+ .filter((value): value is string => value != null)
308
+ .join(' · ')
309
+
310
+ return (
311
+ <Card className={cx('byline-analytics-list', styles.list)}>
312
+ <Card.Header>
313
+ <Card.Title>{title}</Card.Title>
314
+ {description.length > 0 && <Card.Description>{description}</Card.Description>}
315
+ </Card.Header>
316
+ <Card.Content>
317
+ {rows.length === 0 ? (
318
+ <p className="muted">{t('analytics.empty')}</p>
319
+ ) : (
320
+ <ol className={cx('byline-analytics-ranking', styles.ranking)}>
321
+ {rows.map((row) => (
322
+ <li
323
+ key={row.key}
324
+ className={cx(
325
+ 'byline-analytics-ranking-row',
326
+ styles.rankingRow,
327
+ TONE_BAR[tone],
328
+ row.overflow && styles.rankingOverflow
329
+ )}
330
+ // The share bar sits behind the row so the label and its
331
+ // magnitude occupy one line and are read together.
332
+ style={
333
+ {
334
+ '--byline-analytics-share': `${shareWidth(row.value, ceiling)}%`,
335
+ } as React.CSSProperties
336
+ }
337
+ >
338
+ <span className={styles.rankingLabel} title={row.key}>
339
+ {/* `__other__` is a reserved aggregate, not a page anyone
340
+ visited — never render it as though it were a real path. */}
341
+ {row.overflow ? t('analytics.overflow') : row.label}
342
+ </span>
343
+ <span className={styles.rankingValue}>{numbers.format(row.value)}</span>
344
+ <span className={styles.rankingVisitors}>{numbers.format(row.visitors)}</span>
345
+ </li>
346
+ ))}
347
+ </ol>
348
+ )}
349
+ </Card.Content>
350
+ </Card>
351
+ )
352
+ }
353
+
354
+ /** Never collapse the bar entirely: a visible sliver still encodes "smallest". */
355
+ export function shareWidth(value: number, ceiling: number): number {
356
+ if (!Number.isFinite(value) || value <= 0 || ceiling <= 0) return 0
357
+ return Math.max(3, Math.min(100, (value / ceiling) * 100))
358
+ }
359
+
360
+ export function formatShare(part: number, whole: number, locale: string): string {
361
+ const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 })
362
+ return percent.format(whole <= 0 ? 0 : part / whole)
363
+ }
364
+
365
+ /** Return the retained boundary only when it truncates the selected range. */
366
+ export function partialCoverageFrom(
367
+ rangeFrom: string,
368
+ coverageFrom: string | null
369
+ ): string | undefined {
370
+ return coverageFrom != null && coverageFrom > rangeFrom ? coverageFrom : undefined
371
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * AnalyticsTimeseries — one column per UTC day.
3
+ *
4
+ * Override handles:
5
+ * .byline-analytics-timeseries — the chart, axis, and readout as one block
6
+ * .byline-analytics-chart — the SVG plot itself
7
+ * .byline-analytics-axis — first/last day labels beneath the plot
8
+ * .byline-analytics-readout — hovered-day figures, or the range hint
9
+ *
10
+ * The metric hues match the collection dashboard's status tiles
11
+ * (`byline-dashboard-stat-tile`) so the two surfaces read as one system.
12
+ */
13
+
14
+ .root {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 0.5rem;
18
+ border-radius: 0.25rem;
19
+ }
20
+
21
+ .root:focus-visible {
22
+ outline: 2px solid var(--primary-500);
23
+ outline-offset: 4px;
24
+ }
25
+
26
+ .chart,
27
+ :global(.byline-analytics-chart) {
28
+ display: block;
29
+ width: 100%;
30
+ height: 11rem;
31
+ }
32
+
33
+ .gridline {
34
+ stroke: var(--gray-200);
35
+ stroke-width: 1;
36
+ }
37
+
38
+ .column {
39
+ transition: opacity 120ms ease;
40
+ }
41
+
42
+ .views {
43
+ fill: #38bdf8;
44
+ opacity: 0.38;
45
+ transition: opacity 120ms ease;
46
+ }
47
+
48
+ .columnActive .views {
49
+ opacity: 0.62;
50
+ }
51
+
52
+ .visitors {
53
+ fill: #7c3aed;
54
+ opacity: 0.95;
55
+ }
56
+
57
+ .hit {
58
+ fill: transparent;
59
+ cursor: crosshair;
60
+ }
61
+
62
+ .axis,
63
+ :global(.byline-analytics-axis) {
64
+ display: flex;
65
+ justify-content: space-between;
66
+ font-size: 0.6875rem;
67
+ font-variant-numeric: tabular-nums;
68
+ letter-spacing: 0.04em;
69
+ color: var(--gray-500);
70
+ }
71
+
72
+ .readout,
73
+ :global(.byline-analytics-readout) {
74
+ display: flex;
75
+ align-items: center;
76
+ flex-wrap: wrap;
77
+ gap: 0.35rem 0.75rem;
78
+ margin: 0;
79
+ min-height: 1.25rem;
80
+ font-size: 0.75rem;
81
+ font-variant-numeric: tabular-nums;
82
+ color: var(--gray-500);
83
+ }
84
+
85
+ .readout strong {
86
+ color: inherit;
87
+ font-weight: 600;
88
+ }
89
+
90
+ .swatchViews,
91
+ .swatchVisitors,
92
+ .swatchDownloads {
93
+ display: inline-block;
94
+ width: 0.5rem;
95
+ height: 0.5rem;
96
+ border-radius: 1px;
97
+ /* Each swatch introduces the pair that follows it, so it needs the gap on
98
+ its right closed up without collapsing the row gap. */
99
+ margin-inline-start: 0.4rem;
100
+ margin-inline-end: -0.4rem;
101
+ }
102
+
103
+ .swatchViews {
104
+ background-color: #38bdf8;
105
+ opacity: 0.55;
106
+ }
107
+
108
+ .swatchVisitors {
109
+ background-color: #7c3aed;
110
+ }
111
+
112
+ .swatchDownloads {
113
+ background-color: #10b981;
114
+ }
115
+
116
+ :is([data-theme="dark"], :global(.dark)) .gridline {
117
+ stroke: var(--gray-700);
118
+ }
119
+
120
+ :is([data-theme="dark"], :global(.dark)) .axis,
121
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-axis),
122
+ :is([data-theme="dark"], :global(.dark)) .readout,
123
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-readout) {
124
+ color: var(--gray-400);
125
+ }
126
+
127
+ :is([data-theme="dark"], :global(.dark)) .visitors {
128
+ fill: #a78bfa;
129
+ }
130
+
131
+ :is([data-theme="dark"], :global(.dark)) .swatchVisitors {
132
+ background-color: #a78bfa;
133
+ }
134
+
135
+ :is([data-theme="dark"], :global(.dark)) .swatchDownloads {
136
+ background-color: #34d399;
137
+ }
138
+
139
+ @media (prefers-reduced-motion: reduce) {
140
+ .column,
141
+ .views {
142
+ transition: none;
143
+ }
144
+ }
145
+
146
+ @media (max-width: 720px) {
147
+ .chart,
148
+ :global(.byline-analytics-chart) {
149
+ height: 9rem;
150
+ }
151
+ }