@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
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
/**
|
|
12
|
+
* Full ranking for one dashboard card, paged client-side.
|
|
13
|
+
*
|
|
14
|
+
* The dashboard cards show a short preview so the lists band stays even
|
|
15
|
+
* regardless of traffic. This modal shows the whole set behind a card's
|
|
16
|
+
* "View all" action. A `source` may be a ready list (countries are fully
|
|
17
|
+
* loaded with the dashboard) or a loader invoked once each time the modal
|
|
18
|
+
* opens (pages, downloads and referrers fetch a larger top-N on demand).
|
|
19
|
+
*
|
|
20
|
+
* The caption keeps the dashboard's honesty rule: when the loaded rows are
|
|
21
|
+
* a top-N slice of a larger set it says so alongside the page range.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type React from 'react'
|
|
25
|
+
import { useEffect, useState } from 'react'
|
|
26
|
+
|
|
27
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
28
|
+
import { Alert, Button, LoaderRing, Modal, Pagination } from '@byline/ui/react'
|
|
29
|
+
import cx from 'clsx'
|
|
30
|
+
|
|
31
|
+
import styles from './dashboard.module.css'
|
|
32
|
+
import { type AnalyticsTone, pageWindow, type RankedListSource, RankingRows } from './ranking.js'
|
|
33
|
+
|
|
34
|
+
export type RankedListSourceInput = RankedListSource | (() => Promise<RankedListSource>)
|
|
35
|
+
|
|
36
|
+
export interface RankedListModalProps {
|
|
37
|
+
isOpen: boolean
|
|
38
|
+
onDismiss(): void
|
|
39
|
+
title: string
|
|
40
|
+
tone: AnalyticsTone
|
|
41
|
+
locale: string
|
|
42
|
+
source: RankedListSourceInput
|
|
43
|
+
pageSize?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type LoadState =
|
|
47
|
+
| { status: 'idle' }
|
|
48
|
+
| { status: 'loading' }
|
|
49
|
+
| { status: 'ready'; data: RankedListSource }
|
|
50
|
+
| { status: 'error' }
|
|
51
|
+
|
|
52
|
+
export function RankedListModal({
|
|
53
|
+
isOpen,
|
|
54
|
+
onDismiss,
|
|
55
|
+
title,
|
|
56
|
+
tone,
|
|
57
|
+
locale,
|
|
58
|
+
source,
|
|
59
|
+
pageSize = 25,
|
|
60
|
+
}: RankedListModalProps): React.JSX.Element {
|
|
61
|
+
const { t } = useTranslation('byline-admin')
|
|
62
|
+
const [state, setState] = useState<LoadState>({ status: 'idle' })
|
|
63
|
+
const [page, setPage] = useState(1)
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!isOpen) {
|
|
67
|
+
setState({ status: 'idle' })
|
|
68
|
+
setPage(1)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (typeof source !== 'function') {
|
|
72
|
+
setState({ status: 'ready', data: source })
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
let current = true
|
|
76
|
+
setState({ status: 'loading' })
|
|
77
|
+
source()
|
|
78
|
+
.then((data) => {
|
|
79
|
+
if (current) setState({ status: 'ready', data })
|
|
80
|
+
})
|
|
81
|
+
.catch(() => {
|
|
82
|
+
if (current) setState({ status: 'error' })
|
|
83
|
+
})
|
|
84
|
+
return () => {
|
|
85
|
+
current = false
|
|
86
|
+
}
|
|
87
|
+
}, [isOpen, source])
|
|
88
|
+
|
|
89
|
+
const data = state.status === 'ready' ? state.data : undefined
|
|
90
|
+
const loaded = data?.rows.length ?? 0
|
|
91
|
+
const window = pageWindow(loaded, pageSize, page)
|
|
92
|
+
const visible = data?.rows.slice(window.start, window.end) ?? []
|
|
93
|
+
const truncated = data != null && data.total > loaded
|
|
94
|
+
const caption = data
|
|
95
|
+
? [
|
|
96
|
+
loaded === 0
|
|
97
|
+
? undefined
|
|
98
|
+
: t('analytics.range', {
|
|
99
|
+
from: window.start + 1,
|
|
100
|
+
to: window.end,
|
|
101
|
+
total: loaded,
|
|
102
|
+
}),
|
|
103
|
+
truncated ? t('analytics.topOf', { shown: loaded, total: data.total }) : undefined,
|
|
104
|
+
]
|
|
105
|
+
.filter((value): value is string => value != null)
|
|
106
|
+
.join(' · ')
|
|
107
|
+
: undefined
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Modal isOpen={isOpen} onDismiss={onDismiss} closeOnOverlayClick>
|
|
111
|
+
<Modal.Container className={cx('byline-analytics-modal', styles.modal)}>
|
|
112
|
+
<Modal.Header>
|
|
113
|
+
<h2>{title}</h2>
|
|
114
|
+
{caption != null && caption.length > 0 && (
|
|
115
|
+
<p className={cx('muted', 'byline-analytics-modal-caption', styles.modalCaption)}>
|
|
116
|
+
{caption}
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
</Modal.Header>
|
|
120
|
+
<Modal.Content className={cx('byline-analytics-modal-content', styles.modalContent)}>
|
|
121
|
+
{state.status === 'loading' && (
|
|
122
|
+
<div role="status" aria-live="polite" className={styles.modalStatus}>
|
|
123
|
+
<LoaderRing size={28} aria-hidden="true" />
|
|
124
|
+
<span className={styles.srOnly}>{t('common.loading')}</span>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
{state.status === 'error' && (
|
|
128
|
+
<div role="alert">
|
|
129
|
+
<Alert intent="danger" close={false}>
|
|
130
|
+
{t('analytics.loadError')}
|
|
131
|
+
</Alert>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
{data != null && loaded === 0 && <p className="muted">{t('analytics.empty')}</p>}
|
|
135
|
+
{data != null && loaded > 0 && <RankingRows rows={visible} tone={tone} locale={locale} />}
|
|
136
|
+
</Modal.Content>
|
|
137
|
+
<Modal.Actions className={cx('byline-analytics-modal-actions', styles.modalActions)}>
|
|
138
|
+
{window.pageCount > 1 ? (
|
|
139
|
+
<Pagination
|
|
140
|
+
variant="dashboard"
|
|
141
|
+
count={window.pageCount}
|
|
142
|
+
page={Math.min(page, window.pageCount)}
|
|
143
|
+
onChange={(_event, next) => setPage(next)}
|
|
144
|
+
>
|
|
145
|
+
<Pagination.Root ariaLabel={t('analytics.pager')}>
|
|
146
|
+
<Pagination.Pager />
|
|
147
|
+
</Pagination.Root>
|
|
148
|
+
</Pagination>
|
|
149
|
+
) : (
|
|
150
|
+
<span />
|
|
151
|
+
)}
|
|
152
|
+
<Button type="button" size="sm" variant="outlined" onClick={onDismiss}>
|
|
153
|
+
{t('common.actions.close')}
|
|
154
|
+
</Button>
|
|
155
|
+
</Modal.Actions>
|
|
156
|
+
</Modal.Container>
|
|
157
|
+
</Modal>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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 { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { pageWindow, regionName } from './ranking.js'
|
|
12
|
+
|
|
13
|
+
describe('pageWindow', () => {
|
|
14
|
+
it('slices the first page and reports the page count', () => {
|
|
15
|
+
expect(pageWindow(55, 25, 1)).toEqual({ start: 0, end: 25, pageCount: 3 })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('shortens the last page to the remaining rows', () => {
|
|
19
|
+
expect(pageWindow(55, 25, 3)).toEqual({ start: 50, end: 55, pageCount: 3 })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('clamps a page past the end and below the start', () => {
|
|
23
|
+
expect(pageWindow(55, 25, 9)).toEqual({ start: 50, end: 55, pageCount: 3 })
|
|
24
|
+
expect(pageWindow(55, 25, 0)).toEqual({ start: 0, end: 25, pageCount: 3 })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('reports one empty page for no rows', () => {
|
|
28
|
+
expect(pageWindow(0, 25, 1)).toEqual({ start: 0, end: 0, pageCount: 1 })
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('regionName', () => {
|
|
33
|
+
it('renders a known ISO code as the localised region name', () => {
|
|
34
|
+
expect(regionName('TH', 'en')).toBe('Thailand')
|
|
35
|
+
expect(regionName('TH', 'fr')).toBe('Thaïlande')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('falls back to the code for unknown or malformed values', () => {
|
|
39
|
+
expect(regionName('AA', 'en')).toBe('AA')
|
|
40
|
+
expect(regionName('not-a-code', 'en')).toBe('not-a-code')
|
|
41
|
+
expect(regionName('', 'en')).toBe('')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
/**
|
|
12
|
+
* Ranked-row rendering shared by the dashboard cards and the full-list
|
|
13
|
+
* modal, plus the small pure helpers both rely on.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type React from 'react'
|
|
17
|
+
import { useMemo } from 'react'
|
|
18
|
+
|
|
19
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
20
|
+
import cx from 'clsx'
|
|
21
|
+
|
|
22
|
+
import styles from './dashboard.module.css'
|
|
23
|
+
|
|
24
|
+
export type AnalyticsTone = 'views' | 'visitors' | 'downloads'
|
|
25
|
+
|
|
26
|
+
export interface RankedRow {
|
|
27
|
+
key: string
|
|
28
|
+
label: string
|
|
29
|
+
value: number
|
|
30
|
+
visitors: number
|
|
31
|
+
overflow: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A complete ranking plus the size of the set it was drawn from. */
|
|
35
|
+
export interface RankedListSource {
|
|
36
|
+
rows: RankedRow[]
|
|
37
|
+
total: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const TONE_BAR: Record<AnalyticsTone, string | undefined> = {
|
|
41
|
+
views: styles.barViews,
|
|
42
|
+
visitors: styles.barVisitors,
|
|
43
|
+
downloads: styles.barDownloads,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The ordered list with a share bar behind each row. Renders no card chrome. */
|
|
47
|
+
export function RankingRows({
|
|
48
|
+
rows,
|
|
49
|
+
tone,
|
|
50
|
+
locale,
|
|
51
|
+
}: {
|
|
52
|
+
rows: RankedRow[]
|
|
53
|
+
tone: AnalyticsTone
|
|
54
|
+
locale: string
|
|
55
|
+
}): React.JSX.Element {
|
|
56
|
+
const { t } = useTranslation('byline-admin')
|
|
57
|
+
const numbers = useMemo(() => new Intl.NumberFormat(locale), [locale])
|
|
58
|
+
const ceiling = Math.max(1, ...rows.map((row) => row.value))
|
|
59
|
+
return (
|
|
60
|
+
<ol className={cx('byline-analytics-ranking', styles.ranking)}>
|
|
61
|
+
{rows.map((row) => (
|
|
62
|
+
<li
|
|
63
|
+
key={row.key}
|
|
64
|
+
className={cx(
|
|
65
|
+
'byline-analytics-ranking-row',
|
|
66
|
+
styles.rankingRow,
|
|
67
|
+
TONE_BAR[tone],
|
|
68
|
+
row.overflow && styles.rankingOverflow
|
|
69
|
+
)}
|
|
70
|
+
// The share bar sits behind the row so the label and its
|
|
71
|
+
// magnitude occupy one line and are read together.
|
|
72
|
+
style={
|
|
73
|
+
{
|
|
74
|
+
'--byline-analytics-share': `${shareWidth(row.value, ceiling)}%`,
|
|
75
|
+
} as React.CSSProperties
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
<span className={styles.rankingLabel} title={row.key}>
|
|
79
|
+
{/* `__other__` is a reserved aggregate, not a page anyone
|
|
80
|
+
visited — never render it as though it were a real path. */}
|
|
81
|
+
{row.overflow ? t('analytics.overflow') : row.label}
|
|
82
|
+
</span>
|
|
83
|
+
<span className={styles.rankingValue}>{numbers.format(row.value)}</span>
|
|
84
|
+
<span className={styles.rankingVisitors}>{numbers.format(row.visitors)}</span>
|
|
85
|
+
</li>
|
|
86
|
+
))}
|
|
87
|
+
</ol>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Never collapse the bar entirely: a visible sliver still encodes "smallest". */
|
|
92
|
+
export function shareWidth(value: number, ceiling: number): number {
|
|
93
|
+
if (!Number.isFinite(value) || value <= 0 || ceiling <= 0) return 0
|
|
94
|
+
return Math.max(3, Math.min(100, (value / ceiling) * 100))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Row indices for one page of a list, with the page clamped into range. */
|
|
98
|
+
export function pageWindow(
|
|
99
|
+
total: number,
|
|
100
|
+
pageSize: number,
|
|
101
|
+
page: number
|
|
102
|
+
): { start: number; end: number; pageCount: number } {
|
|
103
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
|
104
|
+
const current = Math.min(Math.max(1, page), pageCount)
|
|
105
|
+
const start = (current - 1) * pageSize
|
|
106
|
+
return { start, end: Math.min(total, start + pageSize), pageCount }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Localised region name for an ISO 3166-1 alpha-2 code, or the code itself
|
|
111
|
+
* when the value is not a code or the runtime has no name for it. The
|
|
112
|
+
* dashboard stores bare codes, so this is purely a display concern.
|
|
113
|
+
*/
|
|
114
|
+
export function regionName(code: string, locale: string): string {
|
|
115
|
+
if (!/^[A-Za-z]{2}$/.test(code)) return code
|
|
116
|
+
try {
|
|
117
|
+
const names = new Intl.DisplayNames([locale], { type: 'region', fallback: 'none' })
|
|
118
|
+
return names.of(code.toUpperCase()) ?? code
|
|
119
|
+
} catch {
|
|
120
|
+
return code
|
|
121
|
+
}
|
|
122
|
+
}
|