@byline/admin 5.1.3 → 5.1.4
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/modules/analytics/components/dashboard.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
- package/package.json +7 -7
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
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": "5.1.
|
|
5
|
+
"version": "5.1.4",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -183,12 +183,12 @@
|
|
|
183
183
|
"react-diff-viewer-continued": "^4.4.0",
|
|
184
184
|
"uuid": "^14.0.2",
|
|
185
185
|
"zod": "^4.5.4",
|
|
186
|
-
"@byline/analytics": "5.1.
|
|
187
|
-
"@byline/
|
|
188
|
-
"@byline/
|
|
189
|
-
"@byline/
|
|
190
|
-
"@byline/
|
|
191
|
-
"@byline/
|
|
186
|
+
"@byline/analytics": "5.1.4",
|
|
187
|
+
"@byline/auth": "5.1.4",
|
|
188
|
+
"@byline/i18n": "5.1.4",
|
|
189
|
+
"@byline/ui": "5.1.4",
|
|
190
|
+
"@byline/analytics-agent": "5.1.4",
|
|
191
|
+
"@byline/core": "5.1.4"
|
|
192
192
|
},
|
|
193
193
|
"peerDependencies": {
|
|
194
194
|
"react": "^19.0.0",
|
|
@@ -328,3 +328,72 @@
|
|
|
328
328
|
grid-template-columns: 1fr;
|
|
329
329
|
}
|
|
330
330
|
}
|
|
331
|
+
|
|
332
|
+
/* ── Preview cap and full-list modal ───────────────────────────────── */
|
|
333
|
+
|
|
334
|
+
/* The header carries the "View all" action beside the title so the card's
|
|
335
|
+
body stays a plain ranking. */
|
|
336
|
+
.listHeader,
|
|
337
|
+
:global(.byline-analytics-list-header) {
|
|
338
|
+
display: flex;
|
|
339
|
+
align-items: flex-start;
|
|
340
|
+
justify-content: space-between;
|
|
341
|
+
gap: 0.75rem;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.listHeading {
|
|
345
|
+
min-width: 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.viewAll,
|
|
349
|
+
:global(.byline-analytics-view-all) {
|
|
350
|
+
flex-shrink: 0;
|
|
351
|
+
white-space: nowrap;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.modal,
|
|
355
|
+
:global(.byline-analytics-modal) {
|
|
356
|
+
width: min(40rem, calc(100vw - 2rem));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.modalCaption,
|
|
360
|
+
:global(.byline-analytics-modal-caption) {
|
|
361
|
+
margin: 0.25rem 0 0;
|
|
362
|
+
font-size: 0.75rem;
|
|
363
|
+
font-variant-numeric: tabular-nums;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* Twenty-five rows exceed a short viewport; the content scrolls inside the
|
|
367
|
+
dialog so the pager and close action stay reachable. */
|
|
368
|
+
.modalContent,
|
|
369
|
+
:global(.byline-analytics-modal-content) {
|
|
370
|
+
max-height: min(60vh, 48rem);
|
|
371
|
+
overflow-y: auto;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.modalStatus {
|
|
375
|
+
display: flex;
|
|
376
|
+
justify-content: center;
|
|
377
|
+
padding: 2rem 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.modalActions,
|
|
381
|
+
:global(.byline-analytics-modal-actions) {
|
|
382
|
+
display: flex;
|
|
383
|
+
align-items: center;
|
|
384
|
+
justify-content: space-between;
|
|
385
|
+
gap: 0.75rem;
|
|
386
|
+
flex-wrap: wrap;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.srOnly {
|
|
390
|
+
position: absolute;
|
|
391
|
+
width: 1px;
|
|
392
|
+
height: 1px;
|
|
393
|
+
padding: 0;
|
|
394
|
+
margin: -1px;
|
|
395
|
+
overflow: hidden;
|
|
396
|
+
clip: rect(0, 0, 0, 0);
|
|
397
|
+
white-space: nowrap;
|
|
398
|
+
border: 0;
|
|
399
|
+
}
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
import type React from 'react'
|
|
12
12
|
import { useEffect, useMemo, useState } from 'react'
|
|
13
13
|
|
|
14
|
+
import type {
|
|
15
|
+
AnalyticsPathTotal,
|
|
16
|
+
AnalyticsRankedTotals,
|
|
17
|
+
AnalyticsReferrerTotal,
|
|
18
|
+
} from '@byline/analytics'
|
|
14
19
|
import {
|
|
15
20
|
ANALYTICS_DASHBOARD_PERIODS,
|
|
16
21
|
ANALYTICS_OVERFLOW_KEY,
|
|
@@ -18,23 +23,42 @@ import {
|
|
|
18
23
|
} from '@byline/analytics/config'
|
|
19
24
|
import { ANALYTICS_IGNORE_STORAGE_KEY } from '@byline/analytics-agent'
|
|
20
25
|
import { useTranslation } from '@byline/i18n/react'
|
|
21
|
-
import { Button, Card, Container, Section, Select } from '@byline/ui/react'
|
|
26
|
+
import { Button, Card, Container, Section, Select, useModal } from '@byline/ui/react'
|
|
22
27
|
import cx from 'clsx'
|
|
23
28
|
|
|
24
29
|
import styles from './dashboard.module.css'
|
|
30
|
+
import { RankedListModal, type RankedListSourceInput } from './ranked-list-modal.js'
|
|
31
|
+
import { type AnalyticsTone, type RankedRow, RankingRows, regionName } from './ranking.js'
|
|
25
32
|
import { AnalyticsTimeseries, resolveAnalyticsChartGranularity } from './timeseries.js'
|
|
26
33
|
import type { AnalyticsDashboardData, AnalyticsDashboardPeriod } from '../types.js'
|
|
27
34
|
|
|
35
|
+
export { shareWidth } from './ranking.js'
|
|
36
|
+
|
|
37
|
+
/** Rows each ranked card shows before offering the full list. */
|
|
38
|
+
export const ANALYTICS_PREVIEW_ROWS = 10
|
|
39
|
+
|
|
40
|
+
/** The truncated lists a host can fetch in full on demand. */
|
|
41
|
+
export type AnalyticsFullListKind = 'pages' | 'downloads' | 'referrers'
|
|
42
|
+
|
|
28
43
|
export interface AnalyticsDashboardProps {
|
|
29
44
|
data: AnalyticsDashboardData
|
|
30
45
|
period: AnalyticsDashboardPeriod
|
|
31
46
|
onPeriodChange(period: AnalyticsDashboardPeriod): void
|
|
47
|
+
/**
|
|
48
|
+
* Fetch a larger top-N for one of the truncated lists when its "View all"
|
|
49
|
+
* modal opens. Countries are always complete and never use this. Omit it
|
|
50
|
+
* and those cards still show their preview, without the action.
|
|
51
|
+
*/
|
|
52
|
+
loadFullList?(
|
|
53
|
+
kind: AnalyticsFullListKind
|
|
54
|
+
): Promise<AnalyticsRankedTotals<AnalyticsPathTotal | AnalyticsReferrerTotal>>
|
|
32
55
|
}
|
|
33
56
|
|
|
34
57
|
export function AnalyticsDashboard({
|
|
35
58
|
data,
|
|
36
59
|
period,
|
|
37
60
|
onPeriodChange,
|
|
61
|
+
loadFullList,
|
|
38
62
|
}: AnalyticsDashboardProps): React.JSX.Element {
|
|
39
63
|
const { locale, t } = useTranslation('byline-admin')
|
|
40
64
|
const [excluded, setExcluded] = useState(false)
|
|
@@ -70,6 +94,30 @@ export function AnalyticsDashboard({
|
|
|
70
94
|
const days = data.summary.timeseries.length
|
|
71
95
|
const chartGranularity = resolveAnalyticsChartGranularity(period, days)
|
|
72
96
|
|
|
97
|
+
// Each full-list loader is a stable closure per range so the modal's
|
|
98
|
+
// effect does not refetch on every dashboard render.
|
|
99
|
+
const fullLists = useMemo(() => {
|
|
100
|
+
if (!loadFullList) return {}
|
|
101
|
+
const load = (kind: AnalyticsFullListKind) => () =>
|
|
102
|
+
loadFullList(kind).then((result) => ({
|
|
103
|
+
rows: result.rows.map(toRankedRow),
|
|
104
|
+
total: result.total,
|
|
105
|
+
}))
|
|
106
|
+
return { pages: load('pages'), downloads: load('downloads'), referrers: load('referrers') }
|
|
107
|
+
}, [loadFullList])
|
|
108
|
+
|
|
109
|
+
const countryRows = useMemo(
|
|
110
|
+
() =>
|
|
111
|
+
data.countries.map((row) => ({
|
|
112
|
+
key: row.country,
|
|
113
|
+
label: regionName(row.country, locale),
|
|
114
|
+
value: row.views,
|
|
115
|
+
visitors: row.visitors,
|
|
116
|
+
overflow: false,
|
|
117
|
+
})),
|
|
118
|
+
[data.countries, locale]
|
|
119
|
+
)
|
|
120
|
+
|
|
73
121
|
return (
|
|
74
122
|
<Section>
|
|
75
123
|
<Container>
|
|
@@ -156,15 +204,17 @@ export function AnalyticsDashboard({
|
|
|
156
204
|
</Card>
|
|
157
205
|
|
|
158
206
|
{/* Top pages is the list people actually read, so it gets the wide
|
|
159
|
-
column; referrers and countries stack beside it.
|
|
207
|
+
column; referrers and countries stack beside it. Every card shows
|
|
208
|
+
the same preview depth so the band stays even at any traffic. */}
|
|
160
209
|
<div className={cx('byline-analytics-lists', styles.lists)}>
|
|
161
210
|
<RankedList
|
|
162
211
|
title={t('analytics.sections.pages')}
|
|
163
212
|
caption={t('analytics.columns.viewsAndUniques')}
|
|
164
213
|
tone="views"
|
|
165
214
|
locale={locale}
|
|
166
|
-
rows={data.pages.rows.map(
|
|
215
|
+
rows={data.pages.rows.map(toRankedRow)}
|
|
167
216
|
total={data.pages.total}
|
|
217
|
+
fullList={fullLists.pages}
|
|
168
218
|
coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}
|
|
169
219
|
/>
|
|
170
220
|
<div className={styles.stack}>
|
|
@@ -172,27 +222,19 @@ export function AnalyticsDashboard({
|
|
|
172
222
|
title={t('analytics.sections.referrers')}
|
|
173
223
|
tone="visitors"
|
|
174
224
|
locale={locale}
|
|
225
|
+
rows={data.referrers.rows.map(toRankedRow)}
|
|
175
226
|
total={data.referrers.total}
|
|
227
|
+
fullList={fullLists.referrers}
|
|
176
228
|
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
229
|
/>
|
|
185
230
|
<RankedList
|
|
186
231
|
title={t('analytics.sections.countries')}
|
|
187
232
|
tone="visitors"
|
|
188
233
|
locale={locale}
|
|
189
|
-
rows={
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
visitors: row.visitors,
|
|
194
|
-
overflow: false,
|
|
195
|
-
}))}
|
|
234
|
+
rows={countryRows}
|
|
235
|
+
total={countryRows.length}
|
|
236
|
+
// Countries arrive complete, so the full list needs no fetch.
|
|
237
|
+
fullList={{ rows: countryRows, total: countryRows.length }}
|
|
196
238
|
/>
|
|
197
239
|
</div>
|
|
198
240
|
</div>
|
|
@@ -202,8 +244,9 @@ export function AnalyticsDashboard({
|
|
|
202
244
|
caption={t('analytics.columns.clicksAndUniques')}
|
|
203
245
|
tone="downloads"
|
|
204
246
|
locale={locale}
|
|
205
|
-
rows={data.downloads.rows.map(
|
|
247
|
+
rows={data.downloads.rows.map(toRankedRow)}
|
|
206
248
|
total={data.downloads.total}
|
|
249
|
+
fullList={fullLists.downloads}
|
|
207
250
|
coverageFrom={partialCoverageFrom(data.range.from, data.coverage.pathsFrom)}
|
|
208
251
|
/>
|
|
209
252
|
</Container>
|
|
@@ -211,20 +254,12 @@ export function AnalyticsDashboard({
|
|
|
211
254
|
)
|
|
212
255
|
}
|
|
213
256
|
|
|
214
|
-
type AnalyticsTone = 'views' | 'visitors' | 'downloads'
|
|
215
|
-
|
|
216
257
|
const TONE_TILE: Record<AnalyticsTone, string | undefined> = {
|
|
217
258
|
views: styles.toneViews,
|
|
218
259
|
visitors: styles.toneVisitors,
|
|
219
260
|
downloads: styles.toneDownloads,
|
|
220
261
|
}
|
|
221
262
|
|
|
222
|
-
const TONE_BAR: Record<AnalyticsTone, string | undefined> = {
|
|
223
|
-
views: styles.barViews,
|
|
224
|
-
visitors: styles.barVisitors,
|
|
225
|
-
downloads: styles.barDownloads,
|
|
226
|
-
}
|
|
227
|
-
|
|
228
263
|
function StatTile({
|
|
229
264
|
tone,
|
|
230
265
|
label,
|
|
@@ -245,21 +280,15 @@ function StatTile({
|
|
|
245
280
|
)
|
|
246
281
|
}
|
|
247
282
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
value: number
|
|
252
|
-
visitors: number
|
|
253
|
-
overflow: boolean
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function toPathRow(row: { path: string; views: number; visitors: number }): RankedRow {
|
|
283
|
+
/** Map a path or referrer total onto the shared row shape. */
|
|
284
|
+
function toRankedRow(row: AnalyticsPathTotal | AnalyticsReferrerTotal): RankedRow {
|
|
285
|
+
const key = 'path' in row ? row.path : row.referrerHost
|
|
257
286
|
return {
|
|
258
|
-
key
|
|
259
|
-
label:
|
|
287
|
+
key,
|
|
288
|
+
label: key,
|
|
260
289
|
value: row.views,
|
|
261
290
|
visitors: row.visitors,
|
|
262
|
-
overflow:
|
|
291
|
+
overflow: key === ANALYTICS_OVERFLOW_KEY,
|
|
263
292
|
}
|
|
264
293
|
}
|
|
265
294
|
|
|
@@ -270,6 +299,7 @@ function RankedList({
|
|
|
270
299
|
tone,
|
|
271
300
|
locale,
|
|
272
301
|
total,
|
|
302
|
+
fullList,
|
|
273
303
|
coverageFrom,
|
|
274
304
|
}: {
|
|
275
305
|
title: string
|
|
@@ -277,17 +307,19 @@ function RankedList({
|
|
|
277
307
|
rows: RankedRow[]
|
|
278
308
|
tone: AnalyticsTone
|
|
279
309
|
locale: string
|
|
280
|
-
/** Distinct keys in the period
|
|
281
|
-
total
|
|
310
|
+
/** Distinct keys in the period, before any top-N slice. */
|
|
311
|
+
total: number
|
|
312
|
+
/** The complete ranking, ready or fetched on demand; omit to offer no "View all". */
|
|
313
|
+
fullList?: RankedListSourceInput
|
|
282
314
|
/** First complete day when the selected report begins before retained rows. */
|
|
283
315
|
coverageFrom?: string
|
|
284
316
|
}): React.JSX.Element {
|
|
285
317
|
const { t } = useTranslation('byline-admin')
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
// Say so when the
|
|
318
|
+
const modal = useModal()
|
|
319
|
+
const preview = rows.slice(0, ANALYTICS_PREVIEW_ROWS)
|
|
320
|
+
// Say so when the card is a top-N slice. Without this the card presents a
|
|
289
321
|
// truncated ranking as though it were the whole set.
|
|
290
|
-
const truncated = total
|
|
322
|
+
const truncated = total > preview.length
|
|
291
323
|
const coverageDate = useMemo(
|
|
292
324
|
() =>
|
|
293
325
|
new Intl.DateTimeFormat(locale, {
|
|
@@ -297,7 +329,7 @@ function RankedList({
|
|
|
297
329
|
[locale]
|
|
298
330
|
)
|
|
299
331
|
const description = [
|
|
300
|
-
truncated ? t('analytics.topOf', { shown:
|
|
332
|
+
truncated ? t('analytics.topOf', { shown: preview.length, total }) : caption,
|
|
301
333
|
coverageFrom == null
|
|
302
334
|
? undefined
|
|
303
335
|
: t('analytics.coverage.since', {
|
|
@@ -309,54 +341,44 @@ function RankedList({
|
|
|
309
341
|
|
|
310
342
|
return (
|
|
311
343
|
<Card className={cx('byline-analytics-list', styles.list)}>
|
|
312
|
-
<Card.Header>
|
|
313
|
-
<
|
|
314
|
-
|
|
344
|
+
<Card.Header className={cx('byline-analytics-list-header', styles.listHeader)}>
|
|
345
|
+
<div className={styles.listHeading}>
|
|
346
|
+
<Card.Title>{title}</Card.Title>
|
|
347
|
+
{description.length > 0 && <Card.Description>{description}</Card.Description>}
|
|
348
|
+
</div>
|
|
349
|
+
{fullList != null && truncated && (
|
|
350
|
+
<Button
|
|
351
|
+
type="button"
|
|
352
|
+
size="xs"
|
|
353
|
+
variant="outlined"
|
|
354
|
+
className={cx('byline-analytics-view-all', styles.viewAll)}
|
|
355
|
+
onClick={modal.onOpen}
|
|
356
|
+
>
|
|
357
|
+
{t('analytics.viewAll', { count: total })}
|
|
358
|
+
</Button>
|
|
359
|
+
)}
|
|
315
360
|
</Card.Header>
|
|
316
361
|
<Card.Content>
|
|
317
|
-
{
|
|
362
|
+
{preview.length === 0 ? (
|
|
318
363
|
<p className="muted">{t('analytics.empty')}</p>
|
|
319
364
|
) : (
|
|
320
|
-
<
|
|
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>
|
|
365
|
+
<RankingRows rows={preview} tone={tone} locale={locale} />
|
|
348
366
|
)}
|
|
349
367
|
</Card.Content>
|
|
368
|
+
{fullList != null && (
|
|
369
|
+
<RankedListModal
|
|
370
|
+
isOpen={modal.isOpen}
|
|
371
|
+
onDismiss={modal.onDismiss}
|
|
372
|
+
title={title}
|
|
373
|
+
tone={tone}
|
|
374
|
+
locale={locale}
|
|
375
|
+
source={fullList}
|
|
376
|
+
/>
|
|
377
|
+
)}
|
|
350
378
|
</Card>
|
|
351
379
|
)
|
|
352
380
|
}
|
|
353
381
|
|
|
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
382
|
export function formatShare(part: number, whole: number, locale: string): string {
|
|
361
383
|
const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 })
|
|
362
384
|
return percent.format(whole <= 0 ? 0 : part / whole)
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
|
|
9
|
+
import { act } from 'react'
|
|
10
|
+
|
|
11
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
12
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
13
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
15
|
+
|
|
16
|
+
// The real Modal renders through a Base UI portal; a pass-through keeps the
|
|
17
|
+
// rows queryable in the test container. Everything else is the shipped kit.
|
|
18
|
+
vi.mock('@byline/ui/react', async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal<Record<string, unknown>>()
|
|
20
|
+
const Pass = ({ children }: any) => <div>{children}</div>
|
|
21
|
+
const Modal: any = ({ children, isOpen }: any) =>
|
|
22
|
+
isOpen ? <div data-testid="modal">{children}</div> : null
|
|
23
|
+
Modal.Container = Pass
|
|
24
|
+
Modal.Header = Pass
|
|
25
|
+
Modal.Content = Pass
|
|
26
|
+
Modal.Actions = Pass
|
|
27
|
+
return { ...actual, Modal }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
import { RankedListModal } from './ranked-list-modal.js'
|
|
31
|
+
import type { RankedRow } from './ranking.js'
|
|
32
|
+
|
|
33
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
34
|
+
|
|
35
|
+
function rows(count: number): RankedRow[] {
|
|
36
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
37
|
+
key: `row-${i + 1}`,
|
|
38
|
+
label: `Row ${i + 1}`,
|
|
39
|
+
value: count - i,
|
|
40
|
+
visitors: 1,
|
|
41
|
+
overflow: false,
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let root: Root
|
|
46
|
+
let container: HTMLDivElement
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
// The kit pager's mobile toggle reads a media query; jsdom has no matchMedia.
|
|
49
|
+
vi.stubGlobal(
|
|
50
|
+
'matchMedia',
|
|
51
|
+
vi.fn((query: string) => ({
|
|
52
|
+
matches: false,
|
|
53
|
+
media: query,
|
|
54
|
+
addEventListener() {},
|
|
55
|
+
removeEventListener() {},
|
|
56
|
+
addListener() {},
|
|
57
|
+
removeListener() {},
|
|
58
|
+
}))
|
|
59
|
+
)
|
|
60
|
+
container = document.createElement('div')
|
|
61
|
+
document.body.appendChild(container)
|
|
62
|
+
root = createRoot(container)
|
|
63
|
+
})
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
act(() => root.unmount())
|
|
66
|
+
container.remove()
|
|
67
|
+
vi.unstubAllGlobals()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
async function render(props: Partial<React.ComponentProps<typeof RankedListModal>>) {
|
|
71
|
+
await act(async () => {
|
|
72
|
+
root.render(
|
|
73
|
+
<I18nProvider
|
|
74
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
75
|
+
activeLocale="en"
|
|
76
|
+
defaultLocale="en"
|
|
77
|
+
localeDefinitions={[]}
|
|
78
|
+
>
|
|
79
|
+
<RankedListModal
|
|
80
|
+
isOpen
|
|
81
|
+
onDismiss={() => {}}
|
|
82
|
+
title="Countries"
|
|
83
|
+
tone="visitors"
|
|
84
|
+
locale="en"
|
|
85
|
+
source={{ rows: [], total: 0 }}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
</I18nProvider>
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const labels = () =>
|
|
97
|
+
Array.from(container.querySelectorAll('.byline-analytics-ranking-row')).map(
|
|
98
|
+
(row) => row.querySelector('span')?.textContent
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
describe('RankedListModal', () => {
|
|
102
|
+
it('renders a preloaded list on the first page with a range caption', async () => {
|
|
103
|
+
await render({ source: { rows: rows(55), total: 55 } })
|
|
104
|
+
expect(labels()).toHaveLength(25)
|
|
105
|
+
expect(labels()[0]).toBe('Row 1')
|
|
106
|
+
expect(container.textContent).toContain('1–25 of 55')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('moves to the next page when the pager is used', async () => {
|
|
110
|
+
await render({ source: { rows: rows(55), total: 55 } })
|
|
111
|
+
const next = container.querySelector('[aria-label="Next"]')
|
|
112
|
+
expect(next).not.toBeNull()
|
|
113
|
+
await act(async () => {
|
|
114
|
+
;(next as HTMLElement).click()
|
|
115
|
+
})
|
|
116
|
+
expect(labels()[0]).toBe('Row 26')
|
|
117
|
+
expect(container.textContent).toContain('26–50 of 55')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('loads an async source when opened and then renders it', async () => {
|
|
121
|
+
let resolve!: (value: { rows: RankedRow[]; total: number }) => void
|
|
122
|
+
const load = vi.fn(
|
|
123
|
+
() =>
|
|
124
|
+
new Promise<{ rows: RankedRow[]; total: number }>((r) => {
|
|
125
|
+
resolve = r
|
|
126
|
+
})
|
|
127
|
+
)
|
|
128
|
+
await render({ title: 'Top pages', source: load })
|
|
129
|
+
expect(load).toHaveBeenCalledTimes(1)
|
|
130
|
+
expect(container.querySelector('[role="status"]')).not.toBeNull()
|
|
131
|
+
await act(async () => {
|
|
132
|
+
resolve({ rows: rows(3), total: 143 })
|
|
133
|
+
})
|
|
134
|
+
expect(labels()).toHaveLength(3)
|
|
135
|
+
expect(container.textContent).toContain('1–3 of 3')
|
|
136
|
+
expect(container.textContent).toContain('Top 3 of 143')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('shows an error instead of rows when the async source fails', async () => {
|
|
140
|
+
const load = vi.fn(() => Promise.reject(new Error('network')))
|
|
141
|
+
await render({ title: 'Top pages', source: load })
|
|
142
|
+
expect(labels()).toHaveLength(0)
|
|
143
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
|
144
|
+
'Unable to load this list'
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('does not call a loader while closed', async () => {
|
|
149
|
+
const load = vi.fn(() => Promise.resolve({ rows: rows(1), total: 1 }))
|
|
150
|
+
await render({ isOpen: false, source: load })
|
|
151
|
+
expect(load).not.toHaveBeenCalled()
|
|
152
|
+
expect(container.querySelector('[data-testid="modal"]')).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
})
|