@byline/admin 5.1.3 → 5.2.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 (93) hide show
  1. package/dist/modules/analytics/components/dashboard.d.ts +13 -3
  2. package/dist/modules/analytics/components/dashboard.js +86 -68
  3. package/dist/modules/analytics/components/dashboard.module.js +10 -1
  4. package/dist/modules/analytics/components/dashboard_module.css +57 -0
  5. package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
  6. package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
  7. package/dist/modules/analytics/components/ranking.d.ts +45 -0
  8. package/dist/modules/analytics/components/ranking.js +71 -0
  9. package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
  10. package/package.json +14 -14
  11. package/src/modules/analytics/components/dashboard.module.css +69 -0
  12. package/src/modules/analytics/components/dashboard.tsx +105 -83
  13. package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
  14. package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
  15. package/src/modules/analytics/components/ranking.test.node.ts +43 -0
  16. package/src/modules/analytics/components/ranking.tsx +122 -0
  17. package/dist/exports-parity.test.node.js +0 -37
  18. package/dist/fields/array/array-field.jsx +0 -209
  19. package/dist/fields/blocks/blocks-field.jsx +0 -248
  20. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  21. package/dist/fields/code/code-editor.jsx +0 -211
  22. package/dist/fields/code/code-field.jsx +0 -109
  23. package/dist/fields/column-formatter.jsx +0 -25
  24. package/dist/fields/date-time-formatter.jsx +0 -18
  25. package/dist/fields/datetime/datetime-field.jsx +0 -22
  26. package/dist/fields/draggable-context-menu.jsx +0 -50
  27. package/dist/fields/field-admin.test.node.js +0 -40
  28. package/dist/fields/field-renderer.jsx +0 -136
  29. package/dist/fields/field-services-context.jsx +0 -17
  30. package/dist/fields/file/file-field.jsx +0 -176
  31. package/dist/fields/file/file-upload-field.jsx +0 -89
  32. package/dist/fields/group/group-field.jsx +0 -46
  33. package/dist/fields/image/image-field.jsx +0 -183
  34. package/dist/fields/image/image-upload-field.jsx +0 -157
  35. package/dist/fields/local-date-time.jsx +0 -69
  36. package/dist/fields/locale-badge.jsx +0 -19
  37. package/dist/fields/numerical/numerical-field.jsx +0 -93
  38. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  39. package/dist/fields/relation/relation-display.jsx +0 -118
  40. package/dist/fields/relation/relation-field.jsx +0 -108
  41. package/dist/fields/relation/relation-many-field.jsx +0 -142
  42. package/dist/fields/relation/relation-picker.jsx +0 -240
  43. package/dist/fields/relation/relation-summary.jsx +0 -45
  44. package/dist/fields/select/select-field.jsx +0 -27
  45. package/dist/fields/sortable-item.jsx +0 -54
  46. package/dist/fields/text/text-field.jsx +0 -79
  47. package/dist/fields/text-area/text-area-field.jsx +0 -79
  48. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  49. package/dist/forms/available-locales-widget.jsx +0 -63
  50. package/dist/forms/document-actions.jsx +0 -486
  51. package/dist/forms/form-context.jsx +0 -598
  52. package/dist/forms/form-modals.jsx +0 -128
  53. package/dist/forms/form-renderer.jsx +0 -532
  54. package/dist/forms/form-status-display.jsx +0 -69
  55. package/dist/forms/navigation-guard.jsx +0 -68
  56. package/dist/forms/nested-path.test.node.js +0 -115
  57. package/dist/forms/path-widget.jsx +0 -125
  58. package/dist/forms/pending-uploads.test.node.js +0 -19
  59. package/dist/forms/repeating-items.test.node.js +0 -29
  60. package/dist/forms/scheduled-publication-control.jsx +0 -389
  61. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  62. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  63. package/dist/forms/status-transitions.test.node.js +0 -76
  64. package/dist/forms/tree-placement-widget.jsx +0 -160
  65. package/dist/forms/upload-executor.test.node.js +0 -368
  66. package/dist/forms/use-form-layout.test.node.js +0 -69
  67. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  68. package/dist/modules/admin-account/components/container.jsx +0 -164
  69. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  70. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  71. package/dist/modules/admin-account/components/update.jsx +0 -186
  72. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  73. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  74. package/dist/modules/admin-roles/components/create.jsx +0 -155
  75. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  76. package/dist/modules/admin-roles/components/update.jsx +0 -148
  77. package/dist/modules/admin-users/components/create.jsx +0 -206
  78. package/dist/modules/admin-users/components/roles.jsx +0 -126
  79. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  80. package/dist/modules/admin-users/components/update.jsx +0 -215
  81. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  82. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  83. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  84. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  85. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  86. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  87. package/dist/presentation/group.jsx +0 -26
  88. package/dist/presentation/row.jsx +0 -23
  89. package/dist/presentation/tabs.jsx +0 -39
  90. package/dist/services/admin-services-context.jsx +0 -17
  91. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  92. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  93. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -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
+ }
@@ -1,37 +0,0 @@
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
- * Guard against `publishConfig.exports` drift.
10
- *
11
- * `@byline/admin` carries a `publishConfig.exports` block that npm uses to
12
- * **override** the top-level `exports` at publish time. The workspace and dev
13
- * builds resolve through the top-level `exports` (or source), so a subpath
14
- * added there but forgotten in `publishConfig.exports` typechecks and builds
15
- * locally yet is **missing from the published package** — surfacing only as a
16
- * downstream consumer's build error ("X is not exported …"). That is exactly
17
- * how `./admin-activity` slipped through in v3.11.0.
18
- *
19
- * This test fails the moment the two blocks drift, so the gap is caught in
20
- * `pnpm test` / CI rather than in a production Docker build.
21
- */
22
- import { readFileSync } from 'node:fs';
23
- import { dirname, resolve } from 'node:path';
24
- import { fileURLToPath } from 'node:url';
25
- import { describe, expect, it } from 'vitest';
26
- const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json');
27
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
28
- describe('package.json export parity', () => {
29
- it('every top-level export subpath is also declared in publishConfig.exports', () => {
30
- const publishExports = pkg.publishConfig?.exports;
31
- // If there is no override, the top-level exports ship as-is — nothing to check.
32
- if (publishExports == null)
33
- return;
34
- const missing = Object.keys(pkg.exports).filter((key) => !(key in publishExports));
35
- expect(missing, `publishConfig.exports is missing subpath(s) present in the top-level exports: ${missing.join(', ')}. The published package would not expose them — add them to BOTH blocks.`).toEqual([]);
36
- });
37
- });
@@ -1,209 +0,0 @@
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 { useEffect, useState } from 'react';
9
- import { useTranslation } from '@byline/i18n/react';
10
- import { DraggableSortable, IconButton, moveItem, PlusIcon } from '@byline/ui/react';
11
- import cx from 'clsx';
12
- import { sliceFieldAdmin } from '../../fields/field-admin';
13
- import { defaultScalarForField } from '../../fields/field-helpers';
14
- import { FieldRenderer } from '../../fields/field-renderer';
15
- import { SortableItem, StaticItem } from '../../fields/sortable-item';
16
- import { useFormContext } from '../../forms/form-context';
17
- import { hasExistingIdTargets } from '../../forms/nested-path';
18
- import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items';
19
- import styles from './array-field.module.css';
20
- // ---------------------------------------------------------------------------
21
- // ArrayField — renders `type: 'array'` fields. Each item renders every child
22
- // field declared in the array's `fields` (value fields and/or groups, in any
23
- // combination). Supports D&D.
24
- // ---------------------------------------------------------------------------
25
- export const ArrayField = ({ field, defaultValue, path, disableSorting = false, contentLocale, fieldAdmin, }) => {
26
- const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } = useFormContext();
27
- const { t } = useTranslation('byline-admin');
28
- const [items, setItems] = useState([]);
29
- useEffect(() => {
30
- // Prefer the live form-store value over `defaultValue` when seeding the
31
- // rendered list. The store lives in FormProvider — above the tab layout —
32
- // so it survives this field unmounting/remounting as the editor switches
33
- // tabs; `defaultValue` is only the initial seed and is empty in create
34
- // mode. Re-seeding from `defaultValue` on remount would drop items the
35
- // editor had already added on an earlier visit to this tab.
36
- const storeValue = getFieldValue(path);
37
- const source = Array.isArray(storeValue) ? storeValue : defaultValue;
38
- if (Array.isArray(source)) {
39
- setItems(source.map((item) => ({
40
- id: item && typeof item === 'object' && '_id' in item
41
- ? String(item._id)
42
- : item && typeof item === 'object' && 'id' in item
43
- ? String(item.id)
44
- : crypto.randomUUID(),
45
- data: item,
46
- })));
47
- }
48
- else {
49
- setItems([]);
50
- }
51
- }, [defaultValue, getFieldValue, path]);
52
- const handleDragEnd = ({ moveFromIndex, moveToIndex, }) => {
53
- const currentArray = (getFieldValue(path) ?? defaultValue);
54
- if (!Array.isArray(currentArray))
55
- return;
56
- const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex);
57
- if (move == null)
58
- return;
59
- setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex));
60
- setFieldStore(path, move.items);
61
- appendPatch({
62
- kind: 'array.move',
63
- path,
64
- itemId: move.itemId,
65
- toIndex: move.toIndex,
66
- });
67
- };
68
- const handleAddItem = async (atIndex) => {
69
- const childFields = field.fields ?? [];
70
- if (childFields.length === 0)
71
- return;
72
- const newId = crypto.randomUUID();
73
- // Build a new item with default values for ALL child fields.
74
- // Assign the stable `_id` client-side — exactly like BlocksField's
75
- // handleAddItem — so the item is patch-addressable (move / remove by
76
- // `_id`) within the same editing session, before any server write has
77
- // assigned identity. The storage layer persists `_id` via store_meta.
78
- const newItem = { _id: newId };
79
- for (const childField of childFields) {
80
- if (childField.type === 'group' && childField.fields && childField.fields.length > 0) {
81
- // Group child — build a nested object with defaults for each inner field
82
- const groupObj = {};
83
- for (const innerField of childField.fields) {
84
- groupObj[innerField.name] = await defaultScalarForField(innerField, getFieldValues);
85
- }
86
- newItem[childField.name] = groupObj;
87
- }
88
- else {
89
- newItem[childField.name] = await defaultScalarForField(childField, getFieldValues);
90
- }
91
- }
92
- // Defaults may resolve asynchronously. If an enclosing stable-id item was
93
- // removed in the meantime, do not append a structural patch that would
94
- // recreate that missing parent on the server.
95
- if (!hasExistingIdTargets(getFieldValues(), path))
96
- return;
97
- const currentArray = (getFieldValue(path) ?? defaultValue);
98
- const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0;
99
- const newItemWrapper = { id: newId, data: newItem };
100
- setItems((prev) => {
101
- const next = [...prev];
102
- next.splice(insertAt, 0, newItemWrapper);
103
- return next;
104
- });
105
- appendPatch({
106
- kind: 'array.insert',
107
- path: path,
108
- index: insertAt,
109
- item: newItem,
110
- });
111
- const newArrayValue = currentArray ? [...currentArray] : [];
112
- newArrayValue.splice(insertAt, 0, newItem);
113
- setFieldStore(path, newArrayValue);
114
- };
115
- const handleRemoveItem = (index) => {
116
- const currentArray = (getFieldValue(path) ?? defaultValue);
117
- if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length)
118
- return;
119
- const item = currentArray[index];
120
- const itemPath = repeatingItemPath(path, item, index);
121
- setItems((prev) => prev.filter((_, i) => i !== index));
122
- removePendingUploadsUnder(itemPath);
123
- appendPatch({
124
- kind: 'array.remove',
125
- path: path,
126
- itemId: repeatingItemId(item) ?? String(index),
127
- });
128
- const newArrayValue = [...currentArray];
129
- newArrayValue.splice(index, 1);
130
- setFieldStore(path, newArrayValue);
131
- };
132
- const handleInsertBelow = (index) => {
133
- void handleAddItem(index + 1);
134
- };
135
- const renderItem = (itemWrapper, index) => {
136
- const item = itemWrapper.data;
137
- const arrayElementPath = repeatingItemPath(path, item, index);
138
- if (!item || typeof item !== 'object')
139
- return null;
140
- const childFields = field.fields ?? [];
141
- if (childFields.length === 0)
142
- return null;
143
- // Render ALL child fields defined in the array's field schema
144
- const innerBody = childFields.map((childField) => {
145
- const initial = item[childField.name];
146
- if (childField.type === 'group' && childField.fields && childField.fields.length > 0) {
147
- // Group child — render its inner fields with the group's sub-object.
148
- // fieldAdmin slices one level per structural hop: the group's
149
- // descendant map first, then each inner field's own slice.
150
- const groupData = initial && typeof initial === 'object' ? initial : {};
151
- const groupAdmin = sliceFieldAdmin(fieldAdmin, childField.name);
152
- return (<div key={childField.name} className={cx('byline-field-array-group-fields', styles['group-fields'])}>
153
- {childField.label && (<h4 className={cx('byline-field-array-group-header', styles['group-header'])}>
154
- {childField.label}
155
- </h4>)}
156
- {childField.fields.map((innerField) => (<FieldRenderer key={innerField.name} field={innerField} defaultValue={groupData[innerField.name]} basePath={`${arrayElementPath}.${childField.name}`} disableSorting={true} contentLocale={contentLocale} components={groupAdmin?.[innerField.name]?.components} editor={groupAdmin?.[innerField.name]?.editor} fieldAdmin={sliceFieldAdmin(groupAdmin, innerField.name)}/>))}
157
- </div>);
158
- }
159
- return (<FieldRenderer key={childField.name} field={childField} defaultValue={initial} basePath={arrayElementPath} disableSorting={true} contentLocale={contentLocale} components={fieldAdmin?.[childField.name]?.components} editor={fieldAdmin?.[childField.name]?.editor} fieldAdmin={sliceFieldAdmin(fieldAdmin, childField.name)}/>);
160
- });
161
- // Per-item label: the array label plus a 1-based position, so items
162
- // read "Questions 1", "Questions 2" rather than all repeating the bare
163
- // array label.
164
- const itemLabel = `${field.label ?? field.name} ${index + 1}`;
165
- // `disableSorting` removes only the drag affordance. Structural editing
166
- // (add-below / remove / collapse) is always available — StaticItem is
167
- // the grip-less sibling of SortableItem (see sortable-item.tsx for why
168
- // it is a separate component rather than a prop).
169
- if (disableSorting) {
170
- return (<StaticItem key={itemWrapper.id} label={itemLabel} onAddBelow={() => handleInsertBelow(index)} onRemove={() => handleRemoveItem(index)}>
171
- <div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
172
- {innerBody}
173
- </div>
174
- </StaticItem>);
175
- }
176
- return (<SortableItem key={itemWrapper.id} id={itemWrapper.id} label={itemLabel} onAddBelow={() => handleInsertBelow(index)} onRemove={() => handleRemoveItem(index)}>
177
- <div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
178
- {innerBody}
179
- </div>
180
- </SortableItem>);
181
- };
182
- // The add-row renders in both branches — structural editing does not
183
- // depend on sortability.
184
- const addRow = (<div className={cx('byline-field-array-add-row', styles['add-row'])}>
185
- <IconButton onClick={() => {
186
- void handleAddItem();
187
- }} aria-label={t('fields.array.addItemAriaLabel')}>
188
- <PlusIcon />
189
- </IconButton>
190
- {/* Text-styled button so the label is clickable too. Removed from
191
- the tab order (tabIndex -1) — the IconButton is the single
192
- keyboard/screen-reader control for this action. */}
193
- <button type="button" tabIndex={-1} onClick={() => {
194
- void handleAddItem();
195
- }} className={cx('byline-field-array-add-label', styles['add-label'])}>
196
- {t('fields.array.addItem')}
197
- </button>
198
- </div>);
199
- return (<div className={`byline-field-array ${field.name}`}>
200
- {field.label && (<h3 className={cx('byline-field-array-title', styles.title)}>{field.label}</h3>)}
201
- {disableSorting ? (<div className={cx('byline-field-array-stack', styles.stack)}>
202
- {items.map((item, index) => renderItem(item, index))}
203
- {addRow}
204
- </div>) : (<DraggableSortable ids={items.map((i) => i.id)} onDragEnd={handleDragEnd} className={cx('byline-field-array-stack', styles.stack)}>
205
- {items.map((item, index) => renderItem(item, index))}
206
- {addRow}
207
- </DraggableSortable>)}
208
- </div>);
209
- };