@byline/admin 5.1.2 → 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.
Files changed (93) hide show
  1. package/dist/exports-parity.test.node.js +37 -0
  2. package/dist/fields/array/array-field.jsx +209 -0
  3. package/dist/fields/blocks/blocks-field.jsx +248 -0
  4. package/dist/fields/checkbox/checkbox-field.jsx +24 -0
  5. package/dist/fields/code/code-editor.jsx +211 -0
  6. package/dist/fields/code/code-field.jsx +109 -0
  7. package/dist/fields/column-formatter.jsx +25 -0
  8. package/dist/fields/date-time-formatter.jsx +18 -0
  9. package/dist/fields/datetime/datetime-field.jsx +22 -0
  10. package/dist/fields/draggable-context-menu.jsx +50 -0
  11. package/dist/fields/field-admin.test.node.js +40 -0
  12. package/dist/fields/field-renderer.jsx +136 -0
  13. package/dist/fields/field-services-context.jsx +17 -0
  14. package/dist/fields/file/file-field.jsx +176 -0
  15. package/dist/fields/file/file-upload-field.jsx +89 -0
  16. package/dist/fields/group/group-field.jsx +46 -0
  17. package/dist/fields/image/image-field.jsx +183 -0
  18. package/dist/fields/image/image-upload-field.jsx +157 -0
  19. package/dist/fields/local-date-time.jsx +69 -0
  20. package/dist/fields/locale-badge.jsx +19 -0
  21. package/dist/fields/numerical/numerical-field.jsx +93 -0
  22. package/dist/fields/relation/relation-column-formatter.jsx +74 -0
  23. package/dist/fields/relation/relation-display.jsx +118 -0
  24. package/dist/fields/relation/relation-field.jsx +108 -0
  25. package/dist/fields/relation/relation-many-field.jsx +142 -0
  26. package/dist/fields/relation/relation-picker.jsx +240 -0
  27. package/dist/fields/relation/relation-summary.jsx +45 -0
  28. package/dist/fields/select/select-field.jsx +27 -0
  29. package/dist/fields/sortable-item.jsx +54 -0
  30. package/dist/fields/text/text-field.jsx +79 -0
  31. package/dist/fields/text-area/text-area-field.jsx +79 -0
  32. package/dist/forms/available-locales-reconcile.test.node.js +43 -0
  33. package/dist/forms/available-locales-widget.jsx +63 -0
  34. package/dist/forms/document-actions.jsx +486 -0
  35. package/dist/forms/form-context.jsx +598 -0
  36. package/dist/forms/form-modals.jsx +128 -0
  37. package/dist/forms/form-renderer.jsx +532 -0
  38. package/dist/forms/form-status-display.jsx +69 -0
  39. package/dist/forms/navigation-guard.jsx +68 -0
  40. package/dist/forms/nested-path.test.node.js +115 -0
  41. package/dist/forms/path-widget.jsx +125 -0
  42. package/dist/forms/pending-uploads.test.node.js +19 -0
  43. package/dist/forms/repeating-items.test.node.js +29 -0
  44. package/dist/forms/scheduled-publication-control.jsx +389 -0
  45. package/dist/forms/scheduled-publication-state.test.node.js +121 -0
  46. package/dist/forms/scheduled-publication-time.test.node.js +70 -0
  47. package/dist/forms/status-transitions.test.node.js +76 -0
  48. package/dist/forms/tree-placement-widget.jsx +160 -0
  49. package/dist/forms/upload-executor.test.node.js +368 -0
  50. package/dist/forms/use-form-layout.test.node.js +69 -0
  51. package/dist/modules/admin-account/components/change-password.jsx +169 -0
  52. package/dist/modules/admin-account/components/container.jsx +164 -0
  53. package/dist/modules/admin-account/components/preferences.jsx +148 -0
  54. package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
  55. package/dist/modules/admin-account/components/update.jsx +186 -0
  56. package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
  57. package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
  58. package/dist/modules/admin-roles/components/create.jsx +155 -0
  59. package/dist/modules/admin-roles/components/permissions.jsx +239 -0
  60. package/dist/modules/admin-roles/components/update.jsx +148 -0
  61. package/dist/modules/admin-users/components/create.jsx +206 -0
  62. package/dist/modules/admin-users/components/roles.jsx +126 -0
  63. package/dist/modules/admin-users/components/set-password.jsx +133 -0
  64. package/dist/modules/admin-users/components/update.jsx +215 -0
  65. package/dist/modules/analytics/components/dashboard.d.ts +13 -3
  66. package/dist/modules/analytics/components/dashboard.js +86 -68
  67. package/dist/modules/analytics/components/dashboard.jsx +208 -0
  68. package/dist/modules/analytics/components/dashboard.module.js +10 -1
  69. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  70. package/dist/modules/analytics/components/dashboard_module.css +57 -0
  71. package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
  72. package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
  73. package/dist/modules/analytics/components/ranking.d.ts +45 -0
  74. package/dist/modules/analytics/components/ranking.js +71 -0
  75. package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
  76. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  77. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  78. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  79. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  80. package/dist/presentation/group.jsx +26 -0
  81. package/dist/presentation/row.jsx +23 -0
  82. package/dist/presentation/tabs.jsx +39 -0
  83. package/dist/services/admin-services-context.jsx +17 -0
  84. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  85. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  86. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  87. package/package.json +7 -7
  88. package/src/modules/analytics/components/dashboard.module.css +69 -0
  89. package/src/modules/analytics/components/dashboard.tsx +105 -83
  90. package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
  91. package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
  92. package/src/modules/analytics/components/ranking.test.node.ts +43 -0
  93. package/src/modules/analytics/components/ranking.tsx +122 -0
@@ -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(toPathRow)}
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={data.countries.map((row) => ({
190
- key: row.country,
191
- label: row.country,
192
- value: row.views,
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(toPathRow)}
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
- 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 {
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: row.path,
259
- label: row.path,
287
+ key,
288
+ label: key,
260
289
  value: row.views,
261
290
  visitors: row.visitors,
262
- overflow: row.path === ANALYTICS_OVERFLOW_KEY,
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; omit for lists that are never truncated. */
281
- total?: number
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 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
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 != null && total > rows.length
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: rows.length, total }) : caption,
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
- <Card.Title>{title}</Card.Title>
314
- {description.length > 0 && <Card.Description>{description}</Card.Description>}
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
- {rows.length === 0 ? (
362
+ {preview.length === 0 ? (
318
363
  <p className="muted">{t('analytics.empty')}</p>
319
364
  ) : (
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>
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
+ })
@@ -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
+ })