@byline/admin 4.1.0 → 4.3.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.
- package/dist/fields/array/array-field.d.ts +12 -2
- package/dist/fields/array/array-field.js +46 -20
- package/dist/fields/array/array-field.module.js +5 -1
- package/dist/fields/array/array-field_module.css +23 -0
- package/dist/fields/blocks/blocks-field.js +34 -12
- package/dist/fields/blocks/blocks-field.module.js +2 -0
- package/dist/fields/blocks/blocks-field_module.css +37 -0
- package/dist/fields/code/code-editor.d.ts +20 -0
- package/dist/fields/code/code-editor.js +239 -0
- package/dist/fields/code/code-field.d.ts +21 -0
- package/dist/fields/code/code-field.js +124 -0
- package/dist/fields/code/code-field.module.js +7 -0
- package/dist/fields/code/code-field_module.css +58 -0
- package/dist/fields/draggable-context-menu.js +2 -1
- package/dist/fields/field-admin.d.ts +15 -0
- package/dist/fields/field-admin.js +11 -0
- package/dist/fields/field-admin.test.node.d.ts +8 -0
- package/dist/fields/field-helpers.js +1 -0
- package/dist/fields/field-renderer.d.ts +11 -2
- package/dist/fields/field-renderer.js +21 -4
- package/dist/fields/group/group-field.d.ts +23 -2
- package/dist/fields/group/group-field.js +7 -3
- package/dist/fields/relation/relation-picker.js +8 -2
- package/dist/fields/select/select-field.js +4 -2
- package/dist/fields/select/select-field.module.js +1 -0
- package/dist/fields/select/select-field_module.css +4 -0
- package/dist/fields/sortable-item.d.ts +6 -0
- package/dist/fields/sortable-item.js +44 -25
- package/dist/fields/text/text-field_module.css +1 -0
- package/dist/fields/text-area/text-area-field_module.css +1 -0
- package/dist/forms/form-context.js +1 -1
- package/dist/forms/form-renderer.d.ts +1 -1
- package/dist/forms/form-renderer.js +3 -1
- package/dist/forms/tree-placement-widget.d.ts +1 -1
- package/package.json +23 -10
- package/src/fields/array/array-field.module.css +30 -0
- package/src/fields/array/array-field.tsx +74 -20
- package/src/fields/blocks/blocks-field.module.css +53 -0
- package/src/fields/blocks/blocks-field.tsx +38 -1
- package/src/fields/code/code-editor.tsx +246 -0
- package/src/fields/code/code-field.module.css +84 -0
- package/src/fields/code/code-field.tsx +191 -0
- package/src/fields/draggable-context-menu.tsx +9 -1
- package/src/fields/field-admin.test.node.ts +49 -0
- package/src/fields/field-admin.ts +39 -0
- package/src/fields/field-helpers.ts +1 -0
- package/src/fields/field-renderer.tsx +33 -2
- package/src/fields/field-services-types.ts +1 -1
- package/src/fields/group/group-field.tsx +29 -2
- package/src/fields/relation/relation-picker.tsx +11 -0
- package/src/fields/select/select-field.module.css +5 -0
- package/src/fields/select/select-field.tsx +9 -2
- package/src/fields/sortable-item.tsx +115 -34
- package/src/fields/text/text-field.module.css +1 -0
- package/src/fields/text-area/text-area-field.module.css +1 -0
- package/src/forms/form-context.tsx +9 -1
- package/src/forms/form-renderer.tsx +3 -1
- package/src/forms/tree-placement-widget.tsx +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
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 { useEffect, useRef } from 'react'
|
|
10
|
+
|
|
11
|
+
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
|
12
|
+
import { bracketMatching, HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
|
13
|
+
import type { Extension } from '@codemirror/state'
|
|
14
|
+
import { Compartment, EditorState } from '@codemirror/state'
|
|
15
|
+
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
|
|
16
|
+
import { tags } from '@lezer/highlight'
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// CodeEditor — the CodeMirror 6 half of the `code` field widget.
|
|
20
|
+
//
|
|
21
|
+
// This module owns EVERY CodeMirror import and is loaded through
|
|
22
|
+
// `React.lazy` from `code-field.tsx`, so none of it lands in the main admin
|
|
23
|
+
// chunk — the consuming app's bundler splits it out and fetches it the first
|
|
24
|
+
// time a code field actually renders. Keep it dependency-tight: anything
|
|
25
|
+
// imported here ships in the lazy chunk.
|
|
26
|
+
//
|
|
27
|
+
// Theming: one theme + one highlight style, both defined entirely in terms
|
|
28
|
+
// of `--byline-code-*` CSS custom properties (declared with light/dark
|
|
29
|
+
// values in `code-field.module.css`). The admin theme contract is a
|
|
30
|
+
// `.dark`/`.light` class on <html>, so the editor follows theme flips live
|
|
31
|
+
// with zero JS involvement.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-language lazy loaders. Each grammar stays in its own chunk — adding a
|
|
36
|
+
* code field to a document only fetches the grammar it actually uses.
|
|
37
|
+
* Unknown languages resolve to `null` → plain text (no language extension).
|
|
38
|
+
*/
|
|
39
|
+
const LANGUAGE_LOADERS: Record<string, () => Promise<Extension>> = {
|
|
40
|
+
javascript: () => import('@codemirror/lang-javascript').then((m) => m.javascript()),
|
|
41
|
+
jsx: () => import('@codemirror/lang-javascript').then((m) => m.javascript({ jsx: true })),
|
|
42
|
+
typescript: () =>
|
|
43
|
+
import('@codemirror/lang-javascript').then((m) => m.javascript({ typescript: true })),
|
|
44
|
+
tsx: () =>
|
|
45
|
+
import('@codemirror/lang-javascript').then((m) =>
|
|
46
|
+
m.javascript({ jsx: true, typescript: true })
|
|
47
|
+
),
|
|
48
|
+
json: () => import('@codemirror/lang-json').then((m) => m.json()),
|
|
49
|
+
html: () => import('@codemirror/lang-html').then((m) => m.html()),
|
|
50
|
+
css: () => import('@codemirror/lang-css').then((m) => m.css()),
|
|
51
|
+
markdown: () => import('@codemirror/lang-markdown').then((m) => m.markdown()),
|
|
52
|
+
python: () => import('@codemirror/lang-python').then((m) => m.python()),
|
|
53
|
+
sql: () => import('@codemirror/lang-sql').then((m) => m.sql()),
|
|
54
|
+
yaml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Common aliases → canonical loader keys. */
|
|
58
|
+
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
59
|
+
js: 'javascript',
|
|
60
|
+
ts: 'typescript',
|
|
61
|
+
md: 'markdown',
|
|
62
|
+
py: 'python',
|
|
63
|
+
yml: 'yaml',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resolveLanguageLoader = (language: string | undefined): (() => Promise<Extension>) | null => {
|
|
67
|
+
if (!language) return null
|
|
68
|
+
const key = LANGUAGE_ALIASES[language] ?? language
|
|
69
|
+
return LANGUAGE_LOADERS[key] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const bylineCodeTheme = EditorView.theme({
|
|
73
|
+
'&': {
|
|
74
|
+
backgroundColor: 'var(--byline-code-bg)',
|
|
75
|
+
color: 'var(--byline-code-fg)',
|
|
76
|
+
fontSize: 'var(--byline-code-font-size, 13px)',
|
|
77
|
+
border: '1px solid var(--byline-code-border)',
|
|
78
|
+
borderRadius: 'var(--byline-code-radius, 4px)',
|
|
79
|
+
},
|
|
80
|
+
'&.cm-focused': {
|
|
81
|
+
outline: '2px solid var(--byline-code-focus-ring)',
|
|
82
|
+
outlineOffset: '-1px',
|
|
83
|
+
},
|
|
84
|
+
'.cm-content': {
|
|
85
|
+
fontFamily: 'var(--byline-code-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)',
|
|
86
|
+
caretColor: 'var(--byline-code-caret)',
|
|
87
|
+
minHeight: 'var(--byline-code-min-height, 6rem)',
|
|
88
|
+
},
|
|
89
|
+
'.cm-scroller': {
|
|
90
|
+
fontFamily: 'inherit',
|
|
91
|
+
maxHeight: 'var(--byline-code-max-height, 32rem)',
|
|
92
|
+
},
|
|
93
|
+
'.cm-gutters': {
|
|
94
|
+
backgroundColor: 'var(--byline-code-gutter-bg)',
|
|
95
|
+
color: 'var(--byline-code-gutter-fg)',
|
|
96
|
+
border: 'none',
|
|
97
|
+
borderRight: '1px solid var(--byline-code-border)',
|
|
98
|
+
},
|
|
99
|
+
'.cm-activeLine': { backgroundColor: 'var(--byline-code-active-line)' },
|
|
100
|
+
'.cm-activeLineGutter': { backgroundColor: 'var(--byline-code-active-line)' },
|
|
101
|
+
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
|
102
|
+
backgroundColor: 'var(--byline-code-selection) !important',
|
|
103
|
+
},
|
|
104
|
+
'.cm-cursor': { borderLeftColor: 'var(--byline-code-caret)' },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const bylineHighlightStyle = HighlightStyle.define([
|
|
108
|
+
{ tag: tags.keyword, color: 'var(--byline-code-keyword)' },
|
|
109
|
+
{ tag: [tags.string, tags.special(tags.string)], color: 'var(--byline-code-string)' },
|
|
110
|
+
{ tag: tags.comment, color: 'var(--byline-code-comment)', fontStyle: 'italic' },
|
|
111
|
+
{ tag: [tags.number, tags.bool, tags.null], color: 'var(--byline-code-number)' },
|
|
112
|
+
{
|
|
113
|
+
tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
|
|
114
|
+
color: 'var(--byline-code-function)',
|
|
115
|
+
},
|
|
116
|
+
{ tag: [tags.typeName, tags.className, tags.tagName], color: 'var(--byline-code-type)' },
|
|
117
|
+
{ tag: [tags.propertyName, tags.attributeName], color: 'var(--byline-code-property)' },
|
|
118
|
+
{ tag: [tags.operator, tags.definitionKeyword], color: 'var(--byline-code-operator)' },
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
export interface CodeEditorProps {
|
|
122
|
+
id?: string
|
|
123
|
+
/** Initial document. External updates sync only while the editor is unfocused. */
|
|
124
|
+
value: string
|
|
125
|
+
/** Highlight language (canonical name or alias). Unknown → plain text. */
|
|
126
|
+
language?: string
|
|
127
|
+
readOnly?: boolean
|
|
128
|
+
onChange?: (value: string) => void
|
|
129
|
+
ariaInvalid?: boolean
|
|
130
|
+
ariaDescribedBy?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const CodeEditor = ({
|
|
134
|
+
id,
|
|
135
|
+
value,
|
|
136
|
+
language,
|
|
137
|
+
readOnly,
|
|
138
|
+
onChange,
|
|
139
|
+
ariaInvalid,
|
|
140
|
+
ariaDescribedBy,
|
|
141
|
+
}: CodeEditorProps) => {
|
|
142
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
143
|
+
const viewRef = useRef<EditorView | null>(null)
|
|
144
|
+
// Keep the latest onChange without recreating the EditorView.
|
|
145
|
+
const onChangeRef = useRef(onChange)
|
|
146
|
+
onChangeRef.current = onChange
|
|
147
|
+
const languageCompartment = useRef(new Compartment())
|
|
148
|
+
const readOnlyCompartment = useRef(new Compartment())
|
|
149
|
+
const ariaCompartment = useRef(new Compartment())
|
|
150
|
+
const initialValueRef = useRef(value)
|
|
151
|
+
|
|
152
|
+
// Create the view once per mount. The form store lives above the tab
|
|
153
|
+
// layout, so remounts (tab switches, conditional visibility) re-seed from
|
|
154
|
+
// the store-backed `value` — same contract the other widgets follow.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (containerRef.current == null) return
|
|
157
|
+
const state = EditorState.create({
|
|
158
|
+
doc: initialValueRef.current,
|
|
159
|
+
extensions: [
|
|
160
|
+
lineNumbers(),
|
|
161
|
+
history(),
|
|
162
|
+
bracketMatching(),
|
|
163
|
+
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
|
|
164
|
+
EditorView.lineWrapping,
|
|
165
|
+
bylineCodeTheme,
|
|
166
|
+
syntaxHighlighting(bylineHighlightStyle),
|
|
167
|
+
languageCompartment.current.of([]),
|
|
168
|
+
readOnlyCompartment.current.of([]),
|
|
169
|
+
ariaCompartment.current.of([]),
|
|
170
|
+
EditorView.updateListener.of((update) => {
|
|
171
|
+
if (update.docChanged) {
|
|
172
|
+
onChangeRef.current?.(update.state.doc.toString())
|
|
173
|
+
}
|
|
174
|
+
}),
|
|
175
|
+
],
|
|
176
|
+
})
|
|
177
|
+
const view = new EditorView({ state, parent: containerRef.current })
|
|
178
|
+
viewRef.current = view
|
|
179
|
+
return () => {
|
|
180
|
+
view.destroy()
|
|
181
|
+
viewRef.current = null
|
|
182
|
+
}
|
|
183
|
+
}, [])
|
|
184
|
+
|
|
185
|
+
// External value sync — only while unfocused, so we never fight the
|
|
186
|
+
// editor's own keystrokes (our own onChange echoes back as an equal doc).
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const view = viewRef.current
|
|
189
|
+
if (view == null || view.hasFocus) return
|
|
190
|
+
const current = view.state.doc.toString()
|
|
191
|
+
if (current !== value) {
|
|
192
|
+
view.dispatch({ changes: { from: 0, to: current.length, insert: value } })
|
|
193
|
+
}
|
|
194
|
+
}, [value])
|
|
195
|
+
|
|
196
|
+
// Language switching through a compartment: each grammar loads lazily the
|
|
197
|
+
// first time it's requested and reconfigures in place (no view recreation).
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
let cancelled = false
|
|
200
|
+
const view = viewRef.current
|
|
201
|
+
if (view == null) return
|
|
202
|
+
const loader = resolveLanguageLoader(language)
|
|
203
|
+
if (loader == null) {
|
|
204
|
+
view.dispatch({ effects: languageCompartment.current.reconfigure([]) })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
loader()
|
|
208
|
+
.then((extension) => {
|
|
209
|
+
if (!cancelled && viewRef.current != null) {
|
|
210
|
+
viewRef.current.dispatch({
|
|
211
|
+
effects: languageCompartment.current.reconfigure(extension),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
.catch(() => {
|
|
216
|
+
// Grammar chunk failed to load — degrade to plain text.
|
|
217
|
+
})
|
|
218
|
+
return () => {
|
|
219
|
+
cancelled = true
|
|
220
|
+
}
|
|
221
|
+
}, [language])
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
viewRef.current?.dispatch({
|
|
225
|
+
effects: readOnlyCompartment.current.reconfigure([
|
|
226
|
+
EditorState.readOnly.of(readOnly === true),
|
|
227
|
+
EditorView.editable.of(readOnly !== true),
|
|
228
|
+
]),
|
|
229
|
+
})
|
|
230
|
+
}, [readOnly])
|
|
231
|
+
|
|
232
|
+
// ARIA lives on CodeMirror's own contenteditable (which already carries
|
|
233
|
+
// role="textbox"), not on the wrapper div.
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const attributes: Record<string, string> = {}
|
|
236
|
+
if (ariaInvalid) attributes['aria-invalid'] = 'true'
|
|
237
|
+
if (ariaDescribedBy) attributes['aria-describedby'] = ariaDescribedBy
|
|
238
|
+
viewRef.current?.dispatch({
|
|
239
|
+
effects: ariaCompartment.current.reconfigure(EditorView.contentAttributes.of(attributes)),
|
|
240
|
+
})
|
|
241
|
+
}, [ariaInvalid, ariaDescribedBy])
|
|
242
|
+
|
|
243
|
+
return <div ref={containerRef} id={id} className="byline-code-editor" />
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export default CodeEditor
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeField — CodeMirror-backed source-code input.
|
|
3
|
+
*
|
|
4
|
+
* Override handles:
|
|
5
|
+
* .byline-field-code — the wrapper div (also carries the
|
|
6
|
+
* --byline-code-* theme variables)
|
|
7
|
+
* .byline-field-code-label-row — the label + locale-badge row
|
|
8
|
+
* .byline-field-code-loading — the Suspense fallback textarea
|
|
9
|
+
* .byline-code-editor — the CodeMirror mount point
|
|
10
|
+
*
|
|
11
|
+
* Theming: the CodeMirror theme (see code-editor.tsx) is written entirely in
|
|
12
|
+
* terms of the --byline-code-* custom properties declared here, with dark
|
|
13
|
+
* values scoped under the admin's `.dark` root class — the editor follows
|
|
14
|
+
* theme flips live with zero JS. Consumers can re-skin the editor by
|
|
15
|
+
* overriding these variables on `.byline-field-code`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
.label-row,
|
|
19
|
+
:global(.byline-field-code-label-row) {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
margin-bottom: 0.25rem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
:global(.byline-field-code) {
|
|
26
|
+
/* Chrome */
|
|
27
|
+
--byline-code-bg: var(--canvas-50, #fafafa);
|
|
28
|
+
--byline-code-fg: var(--canvas-950, #1c1c1e);
|
|
29
|
+
--byline-code-border: var(--canvas-300, #d6d6d6);
|
|
30
|
+
--byline-code-gutter-bg: var(--canvas-100, #f2f2f2);
|
|
31
|
+
--byline-code-gutter-fg: var(--canvas-500, #8b8b90);
|
|
32
|
+
--byline-code-active-line: rgba(0, 0, 0, 0.04);
|
|
33
|
+
--byline-code-selection: rgba(59, 130, 246, 0.18);
|
|
34
|
+
--byline-code-caret: var(--canvas-950, #1c1c1e);
|
|
35
|
+
--byline-code-focus-ring: rgba(59, 130, 246, 0.6);
|
|
36
|
+
|
|
37
|
+
/* Syntax */
|
|
38
|
+
--byline-code-keyword: #7c3aed;
|
|
39
|
+
--byline-code-string: #15803d;
|
|
40
|
+
--byline-code-comment: #767680;
|
|
41
|
+
--byline-code-number: #c2410c;
|
|
42
|
+
--byline-code-function: #1d4ed8;
|
|
43
|
+
--byline-code-type: #0f766e;
|
|
44
|
+
--byline-code-property: #a16207;
|
|
45
|
+
--byline-code-operator: #52525b;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
:is([data-theme="dark"], :global(.dark)) {
|
|
49
|
+
:global(.byline-field-code) {
|
|
50
|
+
/* Chrome */
|
|
51
|
+
--byline-code-bg: var(--canvas-900, #1b1b1f);
|
|
52
|
+
--byline-code-fg: var(--canvas-100, #e6e6ea);
|
|
53
|
+
--byline-code-border: var(--canvas-700, #3f3f46);
|
|
54
|
+
--byline-code-gutter-bg: var(--canvas-800, #232327);
|
|
55
|
+
--byline-code-gutter-fg: var(--canvas-500, #77777f);
|
|
56
|
+
--byline-code-active-line: rgba(255, 255, 255, 0.05);
|
|
57
|
+
--byline-code-selection: rgba(96, 165, 250, 0.28);
|
|
58
|
+
--byline-code-caret: var(--canvas-100, #e6e6ea);
|
|
59
|
+
--byline-code-focus-ring: rgba(96, 165, 250, 0.55);
|
|
60
|
+
|
|
61
|
+
/* Syntax */
|
|
62
|
+
--byline-code-keyword: #c4b5fd;
|
|
63
|
+
--byline-code-string: #86efac;
|
|
64
|
+
--byline-code-comment: #8e8e96;
|
|
65
|
+
--byline-code-number: #fdba74;
|
|
66
|
+
--byline-code-function: #93c5fd;
|
|
67
|
+
--byline-code-type: #5eead4;
|
|
68
|
+
--byline-code-property: #fde047;
|
|
69
|
+
--byline-code-operator: #a1a1aa;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.loading,
|
|
74
|
+
:global(.byline-field-code-loading) {
|
|
75
|
+
width: 100%;
|
|
76
|
+
resize: none;
|
|
77
|
+
font-family: var(--byline-code-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
|
78
|
+
font-size: var(--byline-code-font-size, 13px);
|
|
79
|
+
color: var(--byline-code-fg);
|
|
80
|
+
background-color: var(--byline-code-bg);
|
|
81
|
+
border: 1px solid var(--byline-code-border);
|
|
82
|
+
border-radius: var(--byline-code-radius, 4px);
|
|
83
|
+
padding: 0.5rem;
|
|
84
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
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 React, { Suspense, useCallback } from 'react'
|
|
10
|
+
|
|
11
|
+
import type { Field, FieldComponentSlots, CodeField as FieldType } from '@byline/core'
|
|
12
|
+
import { ErrorText, HelpText, Label } from '@byline/ui/react'
|
|
13
|
+
import cx from 'classnames'
|
|
14
|
+
|
|
15
|
+
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
16
|
+
import { LocaleBadge } from '../locale-badge'
|
|
17
|
+
import styles from './code-field.module.css'
|
|
18
|
+
|
|
19
|
+
// The CodeMirror half of the widget. `React.lazy` is the bundle-splitting
|
|
20
|
+
// boundary: `code-editor.tsx` owns every CodeMirror import, so the editor
|
|
21
|
+
// (and its per-language grammars) stays out of the main admin chunk and is
|
|
22
|
+
// fetched the first time a code field actually renders.
|
|
23
|
+
const CodeEditor = React.lazy(() => import('./code-editor'))
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the form-store path of a sibling field (same group/block/array
|
|
27
|
+
* item scope). `content[0].code` + `language` → `content[0].language`;
|
|
28
|
+
* a top-level `code` + `language` → `language`.
|
|
29
|
+
*/
|
|
30
|
+
const siblingFieldPath = (fieldPath: string, siblingName: string): string => {
|
|
31
|
+
const lastDot = fieldPath.lastIndexOf('.')
|
|
32
|
+
return lastDot === -1 ? siblingName : `${fieldPath.slice(0, lastDot + 1)}${siblingName}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CodeField = ({
|
|
36
|
+
field,
|
|
37
|
+
value,
|
|
38
|
+
defaultValue,
|
|
39
|
+
onChange,
|
|
40
|
+
id,
|
|
41
|
+
path,
|
|
42
|
+
locale,
|
|
43
|
+
components,
|
|
44
|
+
}: {
|
|
45
|
+
field: FieldType
|
|
46
|
+
value?: string
|
|
47
|
+
defaultValue?: string
|
|
48
|
+
onChange?: (value: string) => void
|
|
49
|
+
id?: string
|
|
50
|
+
path?: string
|
|
51
|
+
/** When provided, renders a LocaleBadge next to the field label. */
|
|
52
|
+
locale?: string
|
|
53
|
+
/** Optional UI component slot overrides from the admin config. */
|
|
54
|
+
components?: FieldComponentSlots
|
|
55
|
+
}) => {
|
|
56
|
+
const fieldPath = path ?? field.name
|
|
57
|
+
const fieldError = useFieldError(fieldPath)
|
|
58
|
+
const fieldValue = useFieldValue<string | undefined>(fieldPath)
|
|
59
|
+
const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
|
|
60
|
+
const htmlId = id ?? fieldPath
|
|
61
|
+
|
|
62
|
+
// Effective highlight language: a sibling `languageField` selection (e.g.
|
|
63
|
+
// a `select` next to the code field) wins over the schema's static
|
|
64
|
+
// `language` hint. The hook subscribes to the sibling path, so switching
|
|
65
|
+
// the select re-highlights live. When no `languageField` is declared we
|
|
66
|
+
// subscribe to our own path — a stable no-op (hooks must run
|
|
67
|
+
// unconditionally).
|
|
68
|
+
const siblingPath = field.languageField
|
|
69
|
+
? siblingFieldPath(fieldPath, field.languageField)
|
|
70
|
+
: fieldPath
|
|
71
|
+
const siblingLanguage = useFieldValue<string | undefined>(siblingPath)
|
|
72
|
+
const effectiveLanguage = (field.languageField ? siblingLanguage : undefined) || field.language
|
|
73
|
+
|
|
74
|
+
const handleChange = useCallback(
|
|
75
|
+
(value: string) => {
|
|
76
|
+
if (onChange) {
|
|
77
|
+
onChange(value)
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[onChange]
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// Custom component slots (from admin config)
|
|
84
|
+
const slots = components
|
|
85
|
+
const CustomLabel = slots?.Label
|
|
86
|
+
const CustomHelpText = slots?.HelpText
|
|
87
|
+
const CustomField = slots?.Field
|
|
88
|
+
const BeforeField = slots?.beforeField
|
|
89
|
+
const AfterField = slots?.afterField
|
|
90
|
+
|
|
91
|
+
// Shared props available to every slot component
|
|
92
|
+
const slotBaseProps = {
|
|
93
|
+
field: field as Field,
|
|
94
|
+
path: fieldPath,
|
|
95
|
+
value: incomingValue,
|
|
96
|
+
error: fieldError,
|
|
97
|
+
id: htmlId,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const showBadge = !!locale && !!field.label
|
|
101
|
+
const hasCustomLabel = !!CustomLabel
|
|
102
|
+
|
|
103
|
+
const labelRowClass = cx('byline-field-code-label-row', styles['label-row'])
|
|
104
|
+
|
|
105
|
+
// ── Label rendering ──────────────────────────────────────────
|
|
106
|
+
const renderLabel = () => {
|
|
107
|
+
if (hasCustomLabel) {
|
|
108
|
+
return (
|
|
109
|
+
<div className={labelRowClass}>
|
|
110
|
+
<CustomLabel {...slotBaseProps} label={field.label} required={!field.optional} />
|
|
111
|
+
{showBadge && <LocaleBadge locale={locale!} />}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (field.label) {
|
|
116
|
+
return (
|
|
117
|
+
<div className={labelRowClass}>
|
|
118
|
+
<Label
|
|
119
|
+
id={`${htmlId}-label`}
|
|
120
|
+
htmlFor={htmlId}
|
|
121
|
+
label={field.label}
|
|
122
|
+
required={!field.optional}
|
|
123
|
+
/>
|
|
124
|
+
{showBadge && <LocaleBadge locale={locale!} />}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Field input rendering ────────────────────────────────────
|
|
132
|
+
const renderInput = () => {
|
|
133
|
+
if (CustomField) {
|
|
134
|
+
return (
|
|
135
|
+
<CustomField
|
|
136
|
+
{...slotBaseProps}
|
|
137
|
+
onChange={handleChange}
|
|
138
|
+
defaultValue={defaultValue}
|
|
139
|
+
placeholder={field.placeholder}
|
|
140
|
+
/>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
return (
|
|
144
|
+
<Suspense
|
|
145
|
+
fallback={
|
|
146
|
+
// Read-only monospace textarea: keeps the layout stable and the
|
|
147
|
+
// content visible while the editor chunk loads.
|
|
148
|
+
<textarea
|
|
149
|
+
className={cx('byline-field-code-loading', styles.loading)}
|
|
150
|
+
readOnly
|
|
151
|
+
value={incomingValue}
|
|
152
|
+
rows={6}
|
|
153
|
+
aria-label={field.label}
|
|
154
|
+
/>
|
|
155
|
+
}
|
|
156
|
+
>
|
|
157
|
+
<CodeEditor
|
|
158
|
+
id={htmlId}
|
|
159
|
+
value={incomingValue}
|
|
160
|
+
language={effectiveLanguage}
|
|
161
|
+
onChange={handleChange}
|
|
162
|
+
readOnly={field.readOnly === true}
|
|
163
|
+
ariaInvalid={fieldError != null}
|
|
164
|
+
ariaDescribedBy={
|
|
165
|
+
fieldError != null
|
|
166
|
+
? `error-for-${htmlId}`
|
|
167
|
+
: field.helpText
|
|
168
|
+
? `help-for-${htmlId}`
|
|
169
|
+
: undefined
|
|
170
|
+
}
|
|
171
|
+
/>
|
|
172
|
+
</Suspense>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className={`byline-field-code ${field.name}`}>
|
|
178
|
+
{renderLabel()}
|
|
179
|
+
{BeforeField && <BeforeField {...slotBaseProps} />}
|
|
180
|
+
{renderInput()}
|
|
181
|
+
{AfterField && <AfterField {...slotBaseProps} />}
|
|
182
|
+
{fieldError != null && <ErrorText id={`error-for-${htmlId}`} text={fieldError} />}
|
|
183
|
+
{CustomHelpText ? (
|
|
184
|
+
<CustomHelpText {...slotBaseProps} helpText={field.helpText} />
|
|
185
|
+
) : (
|
|
186
|
+
fieldError == null &&
|
|
187
|
+
field.helpText && <HelpText id={`help-for-${htmlId}`} text={field.helpText} />
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -37,7 +37,15 @@ export function DraggableContextMenu({
|
|
|
37
37
|
|
|
38
38
|
return (
|
|
39
39
|
<DropdownMenu.Root modal={false}>
|
|
40
|
-
<DropdownMenu.Trigger
|
|
40
|
+
<DropdownMenu.Trigger
|
|
41
|
+
render={
|
|
42
|
+
<IconButton
|
|
43
|
+
variant="text"
|
|
44
|
+
size="sm"
|
|
45
|
+
aria-label={t('fields.draggableMenu.triggerAriaLabel')}
|
|
46
|
+
/>
|
|
47
|
+
}
|
|
48
|
+
>
|
|
41
49
|
<EllipsisIcon width="16px" height="16px" />
|
|
42
50
|
</DropdownMenu.Trigger>
|
|
43
51
|
|
|
@@ -0,0 +1,49 @@
|
|
|
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 { sliceFieldAdmin } from './field-admin.js'
|
|
12
|
+
|
|
13
|
+
describe('sliceFieldAdmin', () => {
|
|
14
|
+
const answer = { editor: (() => null) as any }
|
|
15
|
+
const question = { components: {} }
|
|
16
|
+
|
|
17
|
+
it('returns undefined for an undefined map', () => {
|
|
18
|
+
expect(sliceFieldAdmin(undefined, 'faq')).toBeUndefined()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns undefined when no entry addresses a descendant of the child', () => {
|
|
22
|
+
expect(sliceFieldAdmin({ faq: question, other: question }, 'faq')).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('strips the child prefix from descendant keys', () => {
|
|
26
|
+
expect(sliceFieldAdmin({ 'faq.answer': answer, 'faq.question': question }, 'faq')).toEqual({
|
|
27
|
+
answer,
|
|
28
|
+
question,
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps deeper paths dotted for the next level to slice', () => {
|
|
33
|
+
const map = { 'files.filesGroup.publicationFile': question }
|
|
34
|
+
const level1 = sliceFieldAdmin(map, 'files')
|
|
35
|
+
expect(level1).toEqual({ 'filesGroup.publicationFile': question })
|
|
36
|
+
expect(sliceFieldAdmin(level1, 'filesGroup')).toEqual({ publicationFile: question })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not match on name prefixes that are not path segments', () => {
|
|
40
|
+
// 'faqExtra.answer' must not be sliced by child 'faq'.
|
|
41
|
+
expect(sliceFieldAdmin({ 'faqExtra.answer': answer }, 'faq')).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('excludes the child’s own exact-name entry from the slice', () => {
|
|
45
|
+
expect(sliceFieldAdmin({ faq: question, 'faq.answer': answer }, 'faq')).toEqual({
|
|
46
|
+
answer,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
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 type { FieldAdminConfig } from '@byline/core'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Field admin map slicing — `fields{}` override maps (CollectionAdminConfig /
|
|
13
|
+
// BlockAdminConfig) are keyed by dotted, index-free schema paths relative to
|
|
14
|
+
// the map's root ('title', 'faq.answer'). Structural widgets thread the map
|
|
15
|
+
// down one level at a time: a child field takes its own entry by exact name,
|
|
16
|
+
// and receives the descendant entries re-keyed relative to itself.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Entries of `map` addressing descendants of `childName`, re-keyed with the
|
|
21
|
+
* `childName.` prefix stripped — the sub-map a structural child (group /
|
|
22
|
+
* array) threads to its own children. Returns `undefined` when `map` has no
|
|
23
|
+
* descendant entries for the child, so leaf widgets aren't handed empty maps.
|
|
24
|
+
*/
|
|
25
|
+
export function sliceFieldAdmin(
|
|
26
|
+
map: Record<string, FieldAdminConfig> | undefined,
|
|
27
|
+
childName: string
|
|
28
|
+
): Record<string, FieldAdminConfig> | undefined {
|
|
29
|
+
if (map == null) return undefined
|
|
30
|
+
const prefix = `${childName}.`
|
|
31
|
+
let sliced: Record<string, FieldAdminConfig> | undefined
|
|
32
|
+
for (const [key, value] of Object.entries(map)) {
|
|
33
|
+
if (key.startsWith(prefix)) {
|
|
34
|
+
sliced ??= {}
|
|
35
|
+
sliced[key.slice(prefix.length)] = value
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return sliced
|
|
39
|
+
}
|