@byline/admin 5.2.0 → 6.0.1

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 (78) 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.jsx +208 -0
  66. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  67. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  68. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  69. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  70. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  71. package/dist/presentation/group.jsx +26 -0
  72. package/dist/presentation/row.jsx +23 -0
  73. package/dist/presentation/tabs.jsx +39 -0
  74. package/dist/services/admin-services-context.jsx +17 -0
  75. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  76. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  77. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  78. package/package.json +7 -7
@@ -0,0 +1,211 @@
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;
@@ -0,0 +1,109 @@
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
+ };
@@ -0,0 +1,25 @@
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
+ }
@@ -0,0 +1,18 @@
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
+ }
@@ -0,0 +1,22 @@
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
+ };
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+ /**
3
+ * This Source Code is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
+ *
7
+ * Copyright (c) Infonomic Company Limited
8
+ */
9
+ import { useTranslation } from '@byline/i18n/react';
10
+ import { DeleteIcon, Dropdown as DropdownMenu, EllipsisIcon, IconButton, PlusIcon, } from '@byline/ui/react';
11
+ import cx from 'clsx';
12
+ import styles from './draggable-context-menu.module.css';
13
+ export function DraggableContextMenu({ onAddBelow, onRemove, }) {
14
+ const { t } = useTranslation('byline-admin');
15
+ const itemClass = cx('byline-draggable-menu-item', styles.item);
16
+ const rowClass = cx('byline-draggable-menu-row', styles.row);
17
+ const iconSlotClass = cx('byline-draggable-menu-icon-slot', styles['icon-slot']);
18
+ const labelClass = cx('byline-draggable-menu-label', styles.label);
19
+ return (<DropdownMenu.Root modal={false}>
20
+ <DropdownMenu.Trigger render={<IconButton variant="text" size="sm" aria-label={t('fields.draggableMenu.triggerAriaLabel')}/>}>
21
+ <EllipsisIcon width="16px" height="16px"/>
22
+ </DropdownMenu.Trigger>
23
+
24
+ <DropdownMenu.Portal>
25
+ <DropdownMenu.Content align="end" sideOffset={0} className={cx('byline-draggable-menu', styles.menu)}>
26
+ <DropdownMenu.Item className={itemClass} onClick={onAddBelow}>
27
+ <div className={rowClass}>
28
+ <span className={iconSlotClass}>
29
+ <PlusIcon width="18px" height="18px"/>
30
+ </span>
31
+ <span className={labelClass}>{t('fields.draggableMenu.addBelow')}</span>
32
+ </div>
33
+ </DropdownMenu.Item>
34
+ <DropdownMenu.Separator className={cx('byline-draggable-menu-separator', styles.separator)}/>
35
+ <DropdownMenu.Item className={itemClass} onClick={onRemove}>
36
+ <div className={rowClass}>
37
+ <div className={rowClass}>
38
+ <span className={iconSlotClass}>
39
+ <DeleteIcon width="18px" height="18px" svgClassName={cx('byline-draggable-menu-delete-icon', styles['delete-icon'])}/>
40
+ </span>
41
+ <span className={cx('byline-draggable-menu-label byline-draggable-menu-label-danger', styles.label, styles['label-danger'])}>
42
+ {t('fields.draggableMenu.remove')}
43
+ </span>
44
+ </div>
45
+ </div>
46
+ </DropdownMenu.Item>
47
+ </DropdownMenu.Content>
48
+ </DropdownMenu.Portal>
49
+ </DropdownMenu.Root>);
50
+ }
@@ -0,0 +1,40 @@
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 { describe, expect, it } from 'vitest';
9
+ import { sliceFieldAdmin } from './field-admin.js';
10
+ describe('sliceFieldAdmin', () => {
11
+ const answer = { editor: (() => null) };
12
+ const question = { components: {} };
13
+ it('returns undefined for an undefined map', () => {
14
+ expect(sliceFieldAdmin(undefined, 'faq')).toBeUndefined();
15
+ });
16
+ it('returns undefined when no entry addresses a descendant of the child', () => {
17
+ expect(sliceFieldAdmin({ faq: question, other: question }, 'faq')).toBeUndefined();
18
+ });
19
+ it('strips the child prefix from descendant keys', () => {
20
+ expect(sliceFieldAdmin({ 'faq.answer': answer, 'faq.question': question }, 'faq')).toEqual({
21
+ answer,
22
+ question,
23
+ });
24
+ });
25
+ it('keeps deeper paths dotted for the next level to slice', () => {
26
+ const map = { 'files.filesGroup.publicationFile': question };
27
+ const level1 = sliceFieldAdmin(map, 'files');
28
+ expect(level1).toEqual({ 'filesGroup.publicationFile': question });
29
+ expect(sliceFieldAdmin(level1, 'filesGroup')).toEqual({ publicationFile: question });
30
+ });
31
+ it('does not match on name prefixes that are not path segments', () => {
32
+ // 'faqExtra.answer' must not be sliced by child 'faq'.
33
+ expect(sliceFieldAdmin({ 'faqExtra.answer': answer }, 'faq')).toBeUndefined();
34
+ });
35
+ it('excludes the child’s own exact-name entry from the slice', () => {
36
+ expect(sliceFieldAdmin({ faq: question, 'faq.answer': answer }, 'faq')).toEqual({
37
+ answer,
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,136 @@
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 { getAdminConfig } from '@byline/core';
9
+ import cx from 'clsx';
10
+ import { useFormContext } from '../forms/form-context';
11
+ import { ArrayField } from './array/array-field';
12
+ import { BlocksField } from './blocks/blocks-field';
13
+ import { CheckboxField } from './checkbox/checkbox-field';
14
+ import { CodeField } from './code/code-field';
15
+ import { DateTimeField } from './datetime/datetime-field';
16
+ import styles from './field-renderer.module.css';
17
+ import { FileField } from './file/file-field';
18
+ import { GroupField } from './group/group-field';
19
+ import { ImageField } from './image/image-field';
20
+ import { LocaleBadge } from './locale-badge';
21
+ import { NumericalField } from './numerical/numerical-field';
22
+ import { RelationField } from './relation/relation-field';
23
+ import { RelationManyField } from './relation/relation-many-field';
24
+ import { SelectField } from './select/select-field';
25
+ import { TextField } from './text/text-field';
26
+ import { TextAreaField } from './text-area/text-area-field';
27
+ import { useFieldChangeHandler } from './use-field-change-handler';
28
+ import { useFieldCondition } from './use-field-condition';
29
+ export const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, contentLocale, components, editor, fieldAdmin, }) => {
30
+ const path = basePath ? `${basePath}.${field.name}` : field.name;
31
+ const htmlId = path.replace(/[[\].]/g, '-');
32
+ const handleChange = useFieldChangeHandler(field, path);
33
+ const { getFieldValue } = useFormContext();
34
+ // Conditional visibility (BaseField.condition) — re-evaluated on every form
35
+ // edit; the field unmounts while its condition is false.
36
+ const visible = useFieldCondition(field, basePath);
37
+ // Conditional fields unmount while hidden, so on re-show the uncontrolled
38
+ // widget must be re-seeded from the live form store (which survives the
39
+ // unmount) rather than the initial document data — otherwise an edit made
40
+ // before hiding would be visually reverted while the store (and the patch
41
+ // stream) still carried it. `undefined` means the path was never written,
42
+ // in which case the initial default stands.
43
+ const storedValue = field.condition ? getFieldValue(path) : undefined;
44
+ const defaultValue = storedValue !== undefined ? storedValue : initialDefault;
45
+ // When a locale is active and the field is localised, inject a badge into
46
+ // the field label so the editor knows they are editing locale-specific content.
47
+ const isLocalised = field.localized === true;
48
+ const badge = isLocalised && contentLocale && !hideLabel ? <LocaleBadge locale={contentLocale}/> : null;
49
+ // All hooks have run by this point, so a conditional bail-out is safe.
50
+ // Hiding retains the field's stored value — no clearing patch is emitted.
51
+ if (!visible)
52
+ return null;
53
+ /**
54
+ * Render the underlying field widget. If the field is localised, we wrap it
55
+ * so we can append the locale badge after the label.
56
+ */
57
+ const renderField = () => {
58
+ switch (field.type) {
59
+ case 'text':
60
+ return (<TextField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} locale={isLocalised ? contentLocale : undefined} components={components}/>);
61
+ case 'textArea':
62
+ return (<TextAreaField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} locale={isLocalised ? contentLocale : undefined} components={components}/>);
63
+ case 'code':
64
+ return (<CodeField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} locale={isLocalised ? contentLocale : undefined} components={components}/>);
65
+ case 'checkbox':
66
+ return (<CheckboxField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId}/>);
67
+ case 'select':
68
+ return (<SelectField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId}/>);
69
+ case 'richText': {
70
+ // Admin-side per-field override takes precedence over the globally
71
+ // registered editor (`AdminConfig.fields.richText.editor`). The
72
+ // override travels via `FieldAdminConfig.fields.<name>.editor` —
73
+ // see admin-types — so React component references stay out of the
74
+ // schema graph that's loaded by the server bootstrap.
75
+ const RichTextEditor = editor ?? getAdminConfig().fields?.richText?.editor;
76
+ if (!RichTextEditor) {
77
+ throw new Error('No richText editor registered. Install @byline/richtext-lexical and set ' +
78
+ '`fields.richText.editor` in your admin config.');
79
+ }
80
+ return (<RichTextEditor field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} instanceKey={htmlId} locale={isLocalised ? contentLocale : undefined}/>);
81
+ }
82
+ case 'datetime':
83
+ return (<DateTimeField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId}/>);
84
+ case 'integer':
85
+ case 'float':
86
+ case 'decimal':
87
+ return (<NumericalField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} components={components}/>);
88
+ case 'counter':
89
+ // Counter values are allocator-assigned; force readOnly at the
90
+ // renderer level so the widget is always non-editable regardless
91
+ // of whether the developer set `field.readOnly` explicitly.
92
+ return (<NumericalField field={hideLabel
93
+ ? { ...field, label: undefined, readOnly: true }
94
+ : { ...field, readOnly: true }} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId} components={components}/>);
95
+ case 'file':
96
+ return (<FileField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path}/>);
97
+ case 'image':
98
+ return (<ImageField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path}/>);
99
+ case 'relation':
100
+ if (field.hasMany) {
101
+ return (<RelationManyField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} path={path} id={htmlId}/>);
102
+ }
103
+ return (<RelationField field={hideLabel ? { ...field, label: undefined } : field} defaultValue={defaultValue} onChange={handleChange} path={path} id={htmlId}/>);
104
+ case 'group':
105
+ // Render a group field as a fixed-order inline field group.
106
+ return (<GroupField field={hideLabel
107
+ ? { ...field, label: undefined }
108
+ : field} defaultValue={defaultValue} path={path} disableSorting={disableSorting} contentLocale={contentLocale} fieldAdmin={fieldAdmin}/>);
109
+ case 'blocks':
110
+ if (!field.blocks)
111
+ return null;
112
+ return (<BlocksField field={field} defaultValue={defaultValue} path={path} contentLocale={contentLocale}/>);
113
+ case 'array':
114
+ if (!field.fields)
115
+ return null;
116
+ return (<ArrayField field={field} defaultValue={defaultValue} path={path} disableSorting={disableSorting} contentLocale={contentLocale} fieldAdmin={fieldAdmin}/>);
117
+ default:
118
+ return null;
119
+ }
120
+ };
121
+ // text, textArea, and code render the badge inside their own Label row;
122
+ // the outer wrapper is only needed for other field types.
123
+ const selfBadge = field.type === 'text' ||
124
+ field.type === 'textArea' ||
125
+ field.type === 'code' ||
126
+ field.type === 'richText';
127
+ if (badge && !selfBadge) {
128
+ return (<div className={cx('byline-field-localized-wrap', styles['localized-wrap'])}>
129
+ {renderField()}
130
+ <span className={cx('byline-field-localized-badge', styles['localized-badge'])}>
131
+ {badge}
132
+ </span>
133
+ </div>);
134
+ }
135
+ return renderField();
136
+ };
@@ -0,0 +1,17 @@
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 { createContext, useContext } from 'react';
9
+ const FieldServicesContext = createContext(null);
10
+ export const BylineFieldServicesProvider = ({ services, children, }) => (<FieldServicesContext.Provider value={services}>{children}</FieldServicesContext.Provider>);
11
+ export const useBylineFieldServices = () => {
12
+ const ctx = useContext(FieldServicesContext);
13
+ if (!ctx) {
14
+ throw new Error('@byline/ui: BylineFieldServicesProvider missing. Wrap your admin tree with <BylineFieldServicesProvider services={…} />.');
15
+ }
16
+ return ctx;
17
+ };