@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
@@ -1,248 +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, useMemo, useState } from 'react';
9
- import { getAdminConfig } from '@byline/core';
10
- import { useTranslation } from '@byline/i18n/react';
11
- import { Card, CloseIcon, DraggableSortable, IconButton, Modal, moveItem, PlusIcon, } from '@byline/ui/react';
12
- import cx from 'clsx';
13
- import { defaultScalarForField } from '../../fields/field-helpers';
14
- import { GroupField } from '../../fields/group/group-field';
15
- import { SortableItem } 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 './blocks-field.module.css';
20
- // ---------------------------------------------------------------------------
21
- // BlocksField — renders `type: 'blocks'` fields. Children are heterogeneous
22
- // group fields selected via a modal picker. Supports D&D.
23
- // ---------------------------------------------------------------------------
24
- export const BlocksField = ({ field, defaultValue, path, contentLocale, }) => {
25
- const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } = useFormContext();
26
- const { t } = useTranslation('byline-admin');
27
- const [items, setItems] = useState([]);
28
- const [showAddBlockModal, setShowAddBlockModal] = useState(false);
29
- const [pendingInsertIndex, setPendingInsertIndex] = useState(null);
30
- const availableBlocks = useMemo(() => field.blocks ?? [], [field.blocks]);
31
- // Site-wide per-block admin config (`AdminConfig.blockAdmin`), resolved by
32
- // blockType. Registry-based on purpose: blocks are cross-collection units,
33
- // so their admin config applies wherever the block renders — no threading
34
- // from the collection admin config is needed (mirrors how FieldRenderer
35
- // resolves the global richText editor from admin config).
36
- const blockAdminByType = useMemo(() => {
37
- const map = new Map();
38
- for (const entry of getAdminConfig().blockAdmin ?? []) {
39
- map.set(entry.blockType, entry);
40
- }
41
- return map;
42
- }, []);
43
- const [selectedBlockName, setSelectedBlockName] = useState(() => availableBlocks[0]?.blockType ?? '');
44
- useEffect(() => {
45
- if (selectedBlockName == null ||
46
- !availableBlocks.some((b) => b.blockType === selectedBlockName)) {
47
- setSelectedBlockName(availableBlocks[0]?.blockType ?? '');
48
- }
49
- }, [availableBlocks, selectedBlockName]);
50
- useEffect(() => {
51
- // Prefer the live form-store value over `defaultValue` when seeding the
52
- // rendered list. The store lives in FormProvider — above the tab layout —
53
- // so it survives this field unmounting/remounting as the editor switches
54
- // tabs; `defaultValue` is only the initial seed and is empty in create
55
- // mode. Re-seeding from `defaultValue` on remount would drop blocks the
56
- // editor had already added on an earlier visit to this tab.
57
- const storeValue = getFieldValue(path);
58
- const source = Array.isArray(storeValue) ? storeValue : defaultValue;
59
- if (Array.isArray(source)) {
60
- setItems(source.map((item) => ({
61
- id: item && typeof item === 'object' && '_id' in item
62
- ? String(item._id)
63
- : crypto.randomUUID(),
64
- data: item,
65
- })));
66
- }
67
- else {
68
- setItems([]);
69
- }
70
- }, [defaultValue, getFieldValue, path]);
71
- const handleDragEnd = ({ moveFromIndex, moveToIndex, }) => {
72
- const currentArray = (getFieldValue(path) ?? defaultValue);
73
- if (!Array.isArray(currentArray))
74
- return;
75
- const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex);
76
- if (move == null)
77
- return;
78
- setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex));
79
- setFieldStore(path, move.items);
80
- appendPatch({
81
- kind: 'array.move',
82
- path,
83
- itemId: move.itemId,
84
- toIndex: move.toIndex,
85
- });
86
- };
87
- const handleAddItem = async (forcedVariantName, atIndex) => {
88
- setShowAddBlockModal(false);
89
- setPendingInsertIndex(null);
90
- const variant = (forcedVariantName != null
91
- ? availableBlocks.find((v) => v.blockType === forcedVariantName)
92
- : undefined) ?? availableBlocks[0];
93
- if (!variant)
94
- return;
95
- const compositeFields = (variant.fields ?? []);
96
- const newId = crypto.randomUUID();
97
- const newItem = {
98
- _id: newId,
99
- _type: variant.blockType,
100
- };
101
- for (const f of compositeFields) {
102
- newItem[f.name] = await defaultScalarForField(f, getFieldValues);
103
- }
104
- if (!hasExistingIdTargets(getFieldValues(), path))
105
- return;
106
- const currentArray = (getFieldValue(path) ?? defaultValue);
107
- const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0;
108
- const newItemWrapper = { id: newId, data: newItem };
109
- setItems((prev) => {
110
- const next = [...prev];
111
- next.splice(insertAt, 0, newItemWrapper);
112
- return next;
113
- });
114
- appendPatch({
115
- kind: 'array.insert',
116
- path: path,
117
- index: insertAt,
118
- item: newItem,
119
- });
120
- const newArrayValue = currentArray ? [...currentArray] : [];
121
- newArrayValue.splice(insertAt, 0, newItem);
122
- setFieldStore(path, newArrayValue);
123
- };
124
- const handleRemoveItem = (index) => {
125
- const currentArray = (getFieldValue(path) ?? defaultValue);
126
- if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length)
127
- return;
128
- const item = currentArray[index];
129
- const itemPath = repeatingItemPath(path, item, index);
130
- const itemId = repeatingItemId(item) ?? String(index);
131
- setItems((prev) => prev.filter((_, i) => i !== index));
132
- removePendingUploadsUnder(itemPath);
133
- appendPatch({
134
- kind: 'array.remove',
135
- path: path,
136
- itemId,
137
- });
138
- const newArrayValue = [...currentArray];
139
- newArrayValue.splice(index, 1);
140
- setFieldStore(path, newArrayValue);
141
- };
142
- const handleInsertBelow = (index, forcedVariantName) => {
143
- if (availableBlocks.length > 1 && forcedVariantName == null) {
144
- setPendingInsertIndex(index + 1);
145
- setShowAddBlockModal(true);
146
- }
147
- else {
148
- void handleAddItem(forcedVariantName, index + 1);
149
- }
150
- };
151
- const renderItem = (itemWrapper, index) => {
152
- const item = itemWrapper.data;
153
- const arrayElementPath = repeatingItemPath(path, item, index);
154
- if (!item || typeof item !== 'object' || typeof item._type !== 'string')
155
- return null;
156
- const subField = field.blocks?.find((b) => b.blockType === item._type);
157
- if (subField == null)
158
- return null;
159
- // Extract field data (everything except _id and _type)
160
- const { _id, _type, ...fieldData } = item;
161
- const label = subField.label ?? _type;
162
- // Render the block's children directly with arrayElementPath as the
163
- // path (not basePath). FieldRenderer would append the group name
164
- // (e.g. "richTextBlock") producing paths like
165
- // "content[id=...].richTextBlock.constrainedWidth", but the flat block
166
- // shape stores fields directly on the item so the correct path is
167
- // "content[id=...].constrainedWidth".
168
- const body = (<GroupField key={subField.blockType} field={{
169
- type: 'group',
170
- name: subField.blockType,
171
- fields: subField.fields,
172
- label: undefined,
173
- }} defaultValue={fieldData} path={arrayElementPath}
174
- // Arrays directly inside a block are fully sortable: each
175
- // DraggableSortable is an independent DndContext and drag listeners
176
- // are grip-scoped, so the inner array's drags can't leak into the
177
- // block-level context (and vice versa).
178
- disableSorting={false} contentLocale={contentLocale} fieldAdmin={blockAdminByType.get(item._type)?.fields}/>);
179
- return (<SortableItem key={itemWrapper.id} id={itemWrapper.id} label={label ?? subField.blockType} onAddBelow={() => handleInsertBelow(index)} onRemove={() => handleRemoveItem(index)}>
180
- {body}
181
- </SortableItem>);
182
- };
183
- return (<div className={`byline-field-blocks ${field.name}`}>
184
- {field.label && (<h3 className={cx('byline-field-blocks-title', styles.title)}>{field.label}</h3>)}
185
- <DraggableSortable ids={items.map((i) => i.id)} onDragEnd={handleDragEnd} className={cx('byline-field-blocks-stack', styles.stack)}>
186
- {items.map((item, index) => renderItem(item, index))}
187
- <div className={cx('byline-field-blocks-add-row', styles['add-row'])}>
188
- <IconButton onClick={() => {
189
- setPendingInsertIndex(null);
190
- setShowAddBlockModal(true);
191
- }} disabled={!selectedBlockName} aria-label={t('fields.blocks.addBlockAriaLabel')} variant="outlined">
192
- <PlusIcon />
193
- </IconButton>
194
- {/* Text-styled button so the label is clickable too. Removed from
195
- the tab order (tabIndex -1) — the IconButton is the single
196
- keyboard/screen-reader control for this action. */}
197
- <button type="button" tabIndex={-1} disabled={!selectedBlockName} onClick={() => {
198
- setPendingInsertIndex(null);
199
- setShowAddBlockModal(true);
200
- }} className={cx('byline-field-blocks-add-label', styles['add-label'])}>
201
- {t('fields.blocks.addBlock')}
202
- </button>
203
- </div>
204
- </DraggableSortable>
205
- <Modal isOpen={showAddBlockModal} closeOnOverlayClick={true} onDismiss={() => {
206
- setShowAddBlockModal(false);
207
- setPendingInsertIndex(null);
208
- }}>
209
- <Modal.Container style={{ maxWidth: '700px' }}>
210
- <Modal.Header className={cx('byline-field-blocks-modal-head', styles['modal-head'])}>
211
- <h3 className={cx('byline-field-blocks-modal-title', styles['modal-title'])}>
212
- {t('fields.blocks.modalTitle')}
213
- </h3>
214
- <IconButton arial-label={t('common.actions.close')} size="xs" onClick={() => {
215
- setShowAddBlockModal(false);
216
- setPendingInsertIndex(null);
217
- }}>
218
- <CloseIcon width="16px" height="16px" svgClassName="white-icon"/>
219
- </IconButton>
220
- </Modal.Header>
221
- <Modal.Content className={cx('byline-field-blocks-card-cursor', styles['modal-content'])}>
222
- <div className={cx('byline-field-blocks-grid', styles.grid)}>
223
- {availableBlocks.map((b, index) => (<Card key={b.blockType} hover onClick={() => void handleAddItem(b.blockType, pendingInsertIndex ?? undefined)} className={cx('byline-field-blocks-card', styles.card)}>
224
- <Card.Header>
225
- <div className={cx('byline-field-blocks-card-head', styles['card-head'])}>
226
- <Card.Title className={cx('byline-field-blocks-card-title', styles['card-title'])}>
227
- {b.label ?? b.blockType}
228
- </Card.Title>
229
- <span className={cx('byline-field-blocks-card-index', styles['card-index'])}>
230
- {index + 1}
231
- </span>
232
- </div>
233
- <code className={cx('byline-field-blocks-card-code', styles['card-code'])}>
234
- {b.blockType}
235
- </code>
236
- </Card.Header>
237
- <Card.Content>
238
- <p className={cx('byline-field-blocks-card-body', styles['card-body'])}>
239
- {b.helpText ?? b.label ?? b.blockType}
240
- </p>
241
- </Card.Content>
242
- </Card>))}
243
- </div>
244
- </Modal.Content>
245
- </Modal.Container>
246
- </Modal>
247
- </div>);
248
- };
@@ -1,24 +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 { Checkbox } from '@byline/ui/react';
9
- import { useFieldError, useFieldValue } from '../../forms/form-context';
10
- import styles from './checkbox-field.module.css';
11
- export const CheckboxField = ({ field, value, defaultValue, onChange, id, path, }) => {
12
- const fieldPath = path ?? field.name;
13
- const fieldError = useFieldError(fieldPath);
14
- const fieldValue = useFieldValue(fieldPath);
15
- const checked = value ?? fieldValue ?? defaultValue ?? false;
16
- return (<div className={`byline-field-checkbox ${field.name} ${styles['field-checkbox']}`}>
17
- <Checkbox id={id ?? fieldPath} name={field.name} label={field.label} checked={checked} size="sm" helpText={field.helpText}
18
- // TODO: Handle indeterminate state
19
- onCheckedChange={(value) => {
20
- const next = value === 'indeterminate' ? false : Boolean(value);
21
- onChange?.(next);
22
- }} error={fieldError != null} errorText={fieldError}/>
23
- </div>);
24
- };
@@ -1,211 +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, useRef } from 'react';
9
- import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
10
- import { bracketMatching, HighlightStyle, syntaxHighlighting } from '@codemirror/language';
11
- import { Compartment, EditorState } from '@codemirror/state';
12
- import { EditorView, keymap, lineNumbers } from '@codemirror/view';
13
- import { tags } from '@lezer/highlight';
14
- // ---------------------------------------------------------------------------
15
- // CodeEditor — the CodeMirror 6 half of the `code` field widget.
16
- //
17
- // This module owns EVERY CodeMirror import and is loaded through
18
- // `React.lazy` from `code-field.tsx`, so none of it lands in the main admin
19
- // chunk — the consuming app's bundler splits it out and fetches it the first
20
- // time a code field actually renders. Keep it dependency-tight: anything
21
- // imported here ships in the lazy chunk.
22
- //
23
- // Theming: one theme + one highlight style, both defined entirely in terms
24
- // of `--byline-code-*` CSS custom properties (declared with light/dark
25
- // values in `code-field.module.css`). The admin theme contract is a
26
- // `.dark`/`.light` class on <html>, so the editor follows theme flips live
27
- // with zero JS involvement.
28
- // ---------------------------------------------------------------------------
29
- /**
30
- * Per-language lazy loaders. Each grammar stays in its own chunk — adding a
31
- * code field to a document only fetches the grammar it actually uses.
32
- * Unknown languages resolve to `null` → plain text (no language extension).
33
- */
34
- const LANGUAGE_LOADERS = {
35
- javascript: () => import('@codemirror/lang-javascript').then((m) => m.javascript()),
36
- jsx: () => import('@codemirror/lang-javascript').then((m) => m.javascript({ jsx: true })),
37
- typescript: () => import('@codemirror/lang-javascript').then((m) => m.javascript({ typescript: true })),
38
- tsx: () => import('@codemirror/lang-javascript').then((m) => m.javascript({ jsx: true, typescript: true })),
39
- json: () => import('@codemirror/lang-json').then((m) => m.json()),
40
- html: () => import('@codemirror/lang-html').then((m) => m.html()),
41
- css: () => import('@codemirror/lang-css').then((m) => m.css()),
42
- markdown: () => import('@codemirror/lang-markdown').then((m) => m.markdown()),
43
- python: () => import('@codemirror/lang-python').then((m) => m.python()),
44
- sql: () => import('@codemirror/lang-sql').then((m) => m.sql()),
45
- yaml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
46
- };
47
- /** Common aliases → canonical loader keys. */
48
- const LANGUAGE_ALIASES = {
49
- js: 'javascript',
50
- ts: 'typescript',
51
- md: 'markdown',
52
- py: 'python',
53
- yml: 'yaml',
54
- };
55
- const resolveLanguageLoader = (language) => {
56
- if (!language)
57
- return null;
58
- const key = LANGUAGE_ALIASES[language] ?? language;
59
- return LANGUAGE_LOADERS[key] ?? null;
60
- };
61
- const bylineCodeTheme = EditorView.theme({
62
- '&': {
63
- backgroundColor: 'var(--byline-code-bg)',
64
- color: 'var(--byline-code-fg)',
65
- fontSize: 'var(--byline-code-font-size, 13px)',
66
- border: '1px solid var(--byline-code-border)',
67
- borderRadius: 'var(--byline-code-radius, 4px)',
68
- },
69
- '&.cm-focused': {
70
- outline: '2px solid var(--byline-code-focus-ring)',
71
- outlineOffset: '-1px',
72
- },
73
- '.cm-content': {
74
- fontFamily: 'var(--byline-code-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)',
75
- caretColor: 'var(--byline-code-caret)',
76
- minHeight: 'var(--byline-code-min-height, 6rem)',
77
- },
78
- '.cm-scroller': {
79
- fontFamily: 'inherit',
80
- maxHeight: 'var(--byline-code-max-height, 32rem)',
81
- },
82
- '.cm-gutters': {
83
- backgroundColor: 'var(--byline-code-gutter-bg)',
84
- color: 'var(--byline-code-gutter-fg)',
85
- border: 'none',
86
- borderRight: '1px solid var(--byline-code-border)',
87
- },
88
- '.cm-activeLine': { backgroundColor: 'var(--byline-code-active-line)' },
89
- '.cm-activeLineGutter': { backgroundColor: 'var(--byline-code-active-line)' },
90
- '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
91
- backgroundColor: 'var(--byline-code-selection) !important',
92
- },
93
- '.cm-cursor': { borderLeftColor: 'var(--byline-code-caret)' },
94
- });
95
- const bylineHighlightStyle = HighlightStyle.define([
96
- { tag: tags.keyword, color: 'var(--byline-code-keyword)' },
97
- { tag: [tags.string, tags.special(tags.string)], color: 'var(--byline-code-string)' },
98
- { tag: tags.comment, color: 'var(--byline-code-comment)', fontStyle: 'italic' },
99
- { tag: [tags.number, tags.bool, tags.null], color: 'var(--byline-code-number)' },
100
- {
101
- tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
102
- color: 'var(--byline-code-function)',
103
- },
104
- { tag: [tags.typeName, tags.className, tags.tagName], color: 'var(--byline-code-type)' },
105
- { tag: [tags.propertyName, tags.attributeName], color: 'var(--byline-code-property)' },
106
- { tag: [tags.operator, tags.definitionKeyword], color: 'var(--byline-code-operator)' },
107
- ]);
108
- const CodeEditor = ({ id, value, language, readOnly, onChange, ariaInvalid, ariaDescribedBy, }) => {
109
- const containerRef = useRef(null);
110
- const viewRef = useRef(null);
111
- // Keep the latest onChange without recreating the EditorView.
112
- const onChangeRef = useRef(onChange);
113
- onChangeRef.current = onChange;
114
- const languageCompartment = useRef(new Compartment());
115
- const readOnlyCompartment = useRef(new Compartment());
116
- const ariaCompartment = useRef(new Compartment());
117
- const initialValueRef = useRef(value);
118
- // Create the view once per mount. The form store lives above the tab
119
- // layout, so remounts (tab switches, conditional visibility) re-seed from
120
- // the store-backed `value` — same contract the other widgets follow.
121
- useEffect(() => {
122
- if (containerRef.current == null)
123
- return;
124
- const state = EditorState.create({
125
- doc: initialValueRef.current,
126
- extensions: [
127
- lineNumbers(),
128
- history(),
129
- bracketMatching(),
130
- keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
131
- EditorView.lineWrapping,
132
- bylineCodeTheme,
133
- syntaxHighlighting(bylineHighlightStyle),
134
- languageCompartment.current.of([]),
135
- readOnlyCompartment.current.of([]),
136
- ariaCompartment.current.of([]),
137
- EditorView.updateListener.of((update) => {
138
- if (update.docChanged) {
139
- onChangeRef.current?.(update.state.doc.toString());
140
- }
141
- }),
142
- ],
143
- });
144
- const view = new EditorView({ state, parent: containerRef.current });
145
- viewRef.current = view;
146
- return () => {
147
- view.destroy();
148
- viewRef.current = null;
149
- };
150
- }, []);
151
- // External value sync — only while unfocused, so we never fight the
152
- // editor's own keystrokes (our own onChange echoes back as an equal doc).
153
- useEffect(() => {
154
- const view = viewRef.current;
155
- if (view == null || view.hasFocus)
156
- return;
157
- const current = view.state.doc.toString();
158
- if (current !== value) {
159
- view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
160
- }
161
- }, [value]);
162
- // Language switching through a compartment: each grammar loads lazily the
163
- // first time it's requested and reconfigures in place (no view recreation).
164
- useEffect(() => {
165
- let cancelled = false;
166
- const view = viewRef.current;
167
- if (view == null)
168
- return;
169
- const loader = resolveLanguageLoader(language);
170
- if (loader == null) {
171
- view.dispatch({ effects: languageCompartment.current.reconfigure([]) });
172
- return;
173
- }
174
- loader()
175
- .then((extension) => {
176
- if (!cancelled && viewRef.current != null) {
177
- viewRef.current.dispatch({
178
- effects: languageCompartment.current.reconfigure(extension),
179
- });
180
- }
181
- })
182
- .catch(() => {
183
- // Grammar chunk failed to load — degrade to plain text.
184
- });
185
- return () => {
186
- cancelled = true;
187
- };
188
- }, [language]);
189
- useEffect(() => {
190
- viewRef.current?.dispatch({
191
- effects: readOnlyCompartment.current.reconfigure([
192
- EditorState.readOnly.of(readOnly === true),
193
- EditorView.editable.of(readOnly !== true),
194
- ]),
195
- });
196
- }, [readOnly]);
197
- // ARIA lives on CodeMirror's own contenteditable (which already carries
198
- // role="textbox"), not on the wrapper div.
199
- useEffect(() => {
200
- const attributes = {};
201
- if (ariaInvalid)
202
- attributes['aria-invalid'] = 'true';
203
- if (ariaDescribedBy)
204
- attributes['aria-describedby'] = ariaDescribedBy;
205
- viewRef.current?.dispatch({
206
- effects: ariaCompartment.current.reconfigure(EditorView.contentAttributes.of(attributes)),
207
- });
208
- }, [ariaInvalid, ariaDescribedBy]);
209
- return <div ref={containerRef} id={id} className="byline-code-editor"/>;
210
- };
211
- export default CodeEditor;
@@ -1,109 +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 React, { Suspense, useCallback } from 'react';
9
- import { ErrorText, HelpText, Label } from '@byline/ui/react';
10
- import cx from 'clsx';
11
- import { useFieldError, useFieldValue } from '../../forms/form-context';
12
- import { LocaleBadge } from '../locale-badge';
13
- import styles from './code-field.module.css';
14
- // The CodeMirror half of the widget. `React.lazy` is the bundle-splitting
15
- // boundary: `code-editor.tsx` owns every CodeMirror import, so the editor
16
- // (and its per-language grammars) stays out of the main admin chunk and is
17
- // fetched the first time a code field actually renders.
18
- const CodeEditor = React.lazy(() => import('./code-editor'));
19
- /**
20
- * Resolve the form-store path of a sibling field (same group/block/array
21
- * item scope). `content[id=x].code` + `language` → `content[id=x].language`;
22
- * a top-level `code` + `language` → `language`.
23
- */
24
- const siblingFieldPath = (fieldPath, siblingName) => {
25
- const lastDot = fieldPath.lastIndexOf('.');
26
- return lastDot === -1 ? siblingName : `${fieldPath.slice(0, lastDot + 1)}${siblingName}`;
27
- };
28
- export const CodeField = ({ field, value, defaultValue, onChange, id, path, locale, components, }) => {
29
- const fieldPath = path ?? field.name;
30
- const fieldError = useFieldError(fieldPath);
31
- const fieldValue = useFieldValue(fieldPath);
32
- const incomingValue = value ?? fieldValue ?? defaultValue ?? '';
33
- const htmlId = id ?? fieldPath;
34
- // Effective highlight language: a sibling `languageField` selection (e.g.
35
- // a `select` next to the code field) wins over the schema's static
36
- // `language` hint. The hook subscribes to the sibling path, so switching
37
- // the select re-highlights live. When no `languageField` is declared we
38
- // subscribe to our own path — a stable no-op (hooks must run
39
- // unconditionally).
40
- const siblingPath = field.languageField
41
- ? siblingFieldPath(fieldPath, field.languageField)
42
- : fieldPath;
43
- const siblingLanguage = useFieldValue(siblingPath);
44
- const effectiveLanguage = (field.languageField ? siblingLanguage : undefined) || field.language;
45
- const handleChange = useCallback((value) => {
46
- if (onChange) {
47
- onChange(value);
48
- }
49
- }, [onChange]);
50
- // Custom component slots (from admin config)
51
- const slots = components;
52
- const CustomLabel = slots?.Label;
53
- const CustomHelpText = slots?.HelpText;
54
- const CustomField = slots?.Field;
55
- const BeforeField = slots?.beforeField;
56
- const AfterField = slots?.afterField;
57
- // Shared props available to every slot component
58
- const slotBaseProps = {
59
- field: field,
60
- path: fieldPath,
61
- value: incomingValue,
62
- error: fieldError,
63
- id: htmlId,
64
- };
65
- const showBadge = !!locale && !!field.label;
66
- const hasCustomLabel = !!CustomLabel;
67
- const labelRowClass = cx('byline-field-code-label-row', styles['label-row']);
68
- // ── Label rendering ──────────────────────────────────────────
69
- const renderLabel = () => {
70
- if (hasCustomLabel) {
71
- return (<div className={labelRowClass}>
72
- <CustomLabel {...slotBaseProps} label={field.label} required={!field.optional}/>
73
- {showBadge && <LocaleBadge locale={locale}/>}
74
- </div>);
75
- }
76
- if (field.label) {
77
- return (<div className={labelRowClass}>
78
- <Label id={`${htmlId}-label`} htmlFor={htmlId} label={field.label} required={!field.optional}/>
79
- {showBadge && <LocaleBadge locale={locale}/>}
80
- </div>);
81
- }
82
- return null;
83
- };
84
- // ── Field input rendering ────────────────────────────────────
85
- const renderInput = () => {
86
- if (CustomField) {
87
- return (<CustomField {...slotBaseProps} onChange={handleChange} defaultValue={defaultValue} placeholder={field.placeholder}/>);
88
- }
89
- return (<Suspense fallback={
90
- // Read-only monospace textarea: keeps the layout stable and the
91
- // content visible while the editor chunk loads.
92
- <textarea className={cx('byline-field-code-loading', styles.loading)} readOnly value={incomingValue} rows={6} aria-label={field.label}/>}>
93
- <CodeEditor id={htmlId} value={incomingValue} language={effectiveLanguage} onChange={handleChange} readOnly={field.readOnly === true} ariaInvalid={fieldError != null} ariaDescribedBy={fieldError != null
94
- ? `error-for-${htmlId}`
95
- : field.helpText
96
- ? `help-for-${htmlId}`
97
- : undefined}/>
98
- </Suspense>);
99
- };
100
- return (<div className={`byline-field-code ${field.name}`}>
101
- {renderLabel()}
102
- {BeforeField && <BeforeField {...slotBaseProps}/>}
103
- {renderInput()}
104
- {AfterField && <AfterField {...slotBaseProps}/>}
105
- {fieldError != null && <ErrorText id={`error-for-${htmlId}`} text={fieldError}/>}
106
- {CustomHelpText ? (<CustomHelpText {...slotBaseProps} helpText={field.helpText}/>) : (fieldError == null &&
107
- field.helpText && <HelpText id={`help-for-${htmlId}`} text={field.helpText}/>)}
108
- </div>);
109
- };
@@ -1,25 +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
- * Type guard: returns true when the formatter is a `{ component }` wrapper
10
- * rather than a plain function.
11
- */
12
- export function isComponentFormatter(fmt) {
13
- return typeof fmt === 'object' && fmt !== null && 'component' in fmt;
14
- }
15
- /**
16
- * Render a cell value through its column formatter (if any).
17
- * Handles both plain-function and `{ component }` formatters.
18
- */
19
- export function renderFormatted(value, document, formatter) {
20
- if (isComponentFormatter(formatter)) {
21
- const Component = formatter.component;
22
- return <Component value={value} record={document}/>;
23
- }
24
- return formatter(value, document);
25
- }
@@ -1,18 +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 { LocalDateTime } from './local-date-time';
9
- /**
10
- * SSR-safe date-time column formatter for list views.
11
- *
12
- * Uses the `LocalDateTime` component which renders a placeholder on the server
13
- * and formats to the user's locale/timezone after hydration, avoiding
14
- * server/client mismatches.
15
- */
16
- export function DateTimeFormatter({ value }) {
17
- return <LocalDateTime value={value}/>;
18
- }
@@ -1,22 +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 { DatePicker, ErrorText } from '@byline/ui/react';
9
- import cx from 'clsx';
10
- import { useFieldError, useFieldValue, useIsDirty } from '../../forms/form-context';
11
- import styles from './datetime-field.module.css';
12
- export const DateTimeField = ({ field, value, defaultValue, onChange, id, path, }) => {
13
- const fieldPath = path ?? field.name;
14
- const fieldError = useFieldError(fieldPath);
15
- const isDirty = useIsDirty(fieldPath);
16
- const fieldValue = useFieldValue(fieldPath);
17
- const incomingValue = value ?? fieldValue ?? defaultValue ?? null;
18
- return (<div className={`byline-field-datetime ${field.name}`}>
19
- <DatePicker id={id ?? fieldPath} name={field.name} label={field.label} required={!field.optional} initialValue={incomingValue} mode={field.mode || 'datetime'} yearsInFuture={field.yearsInFuture || 1} yearsInPast={field.yearsInPast || 10} onDateChange={(date) => onChange?.(date)} className={cx(isDirty && ['byline-field-datetime-dirty', styles.dirty])}/>
20
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
21
- </div>);
22
- };