@byline/admin 4.0.0 → 4.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.
- package/dist/fields/array/array-field.js +20 -8
- 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 +33 -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/field-helpers.js +1 -0
- package/dist/fields/field-renderer.js +15 -1
- package/dist/fields/group/group-field.d.ts +10 -2
- package/dist/fields/group/group-field.js +4 -2
- 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/text/text-field_module.css +1 -0
- package/dist/fields/text-area/text-area-field_module.css +1 -0
- package/dist/forms/form-renderer.d.ts +1 -1
- package/dist/forms/tree-placement-widget.d.ts +1 -1
- package/package.json +18 -5
- package/src/fields/array/array-field.module.css +30 -0
- package/src/fields/array/array-field.tsx +15 -2
- package/src/fields/blocks/blocks-field.module.css +53 -0
- package/src/fields/blocks/blocks-field.tsx +33 -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/field-helpers.ts +1 -0
- package/src/fields/field-renderer.tsx +19 -2
- package/src/fields/field-services-types.ts +1 -1
- package/src/fields/group/group-field.tsx +12 -1
- 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/text/text-field.module.css +1 -0
- package/src/fields/text-area/text-area-field.module.css +1 -0
- package/src/forms/form-renderer.tsx +1 -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
|
+
}
|
|
@@ -21,6 +21,7 @@ import { useFormContext } from '../forms/form-context'
|
|
|
21
21
|
import { ArrayField } from './array/array-field'
|
|
22
22
|
import { BlocksField } from './blocks/blocks-field'
|
|
23
23
|
import { CheckboxField } from './checkbox/checkbox-field'
|
|
24
|
+
import { CodeField } from './code/code-field'
|
|
24
25
|
import { DateTimeField } from './datetime/datetime-field'
|
|
25
26
|
import styles from './field-renderer.module.css'
|
|
26
27
|
import { FileField } from './file/file-field'
|
|
@@ -140,6 +141,18 @@ export const FieldRenderer = ({
|
|
|
140
141
|
components={components}
|
|
141
142
|
/>
|
|
142
143
|
)
|
|
144
|
+
case 'code':
|
|
145
|
+
return (
|
|
146
|
+
<CodeField
|
|
147
|
+
field={hideLabel ? { ...field, label: undefined } : field}
|
|
148
|
+
defaultValue={defaultValue}
|
|
149
|
+
onChange={handleChange}
|
|
150
|
+
path={path}
|
|
151
|
+
id={htmlId}
|
|
152
|
+
locale={isLocalised ? contentLocale : undefined}
|
|
153
|
+
components={components}
|
|
154
|
+
/>
|
|
155
|
+
)
|
|
143
156
|
case 'checkbox':
|
|
144
157
|
return (
|
|
145
158
|
<CheckboxField
|
|
@@ -307,9 +320,13 @@ export const FieldRenderer = ({
|
|
|
307
320
|
}
|
|
308
321
|
}
|
|
309
322
|
|
|
310
|
-
// text and
|
|
323
|
+
// text, textArea, and code render the badge inside their own Label row;
|
|
311
324
|
// the outer wrapper is only needed for other field types.
|
|
312
|
-
const selfBadge =
|
|
325
|
+
const selfBadge =
|
|
326
|
+
field.type === 'text' ||
|
|
327
|
+
field.type === 'textArea' ||
|
|
328
|
+
field.type === 'code' ||
|
|
329
|
+
field.type === 'richText'
|
|
313
330
|
|
|
314
331
|
if (badge && !selfBadge) {
|
|
315
332
|
return (
|
|
@@ -62,7 +62,7 @@ export type UploadFieldFn = (
|
|
|
62
62
|
createDocument?: boolean
|
|
63
63
|
) => Promise<UploadedFileResult>
|
|
64
64
|
|
|
65
|
-
// --- Document tree (the `tree: true` primitive — docs/04-collections/
|
|
65
|
+
// --- Document tree (the `tree: true` primitive — docs/04-collections/04-document-trees.md) -----
|
|
66
66
|
|
|
67
67
|
/** One hydrated ancestor in a document's breadcrumb trail (root-first). */
|
|
68
68
|
export interface TreeAncestor {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { useMemo } from 'react'
|
|
10
10
|
|
|
11
|
-
import type { Field, GroupField as GroupFieldType } from '@byline/core'
|
|
11
|
+
import type { Field, FieldAdminConfig, GroupField as GroupFieldType } from '@byline/core'
|
|
12
12
|
import { ErrorText } from '@byline/ui/react'
|
|
13
13
|
import cx from 'classnames'
|
|
14
14
|
|
|
@@ -44,6 +44,14 @@ interface GroupFieldProps {
|
|
|
44
44
|
* locale badge.
|
|
45
45
|
*/
|
|
46
46
|
contentLocale?: string
|
|
47
|
+
/**
|
|
48
|
+
* Per-child-field admin overrides (`components` slots, richtext `editor`),
|
|
49
|
+
* keyed by child field name. Threaded by `BlocksField` from the site-wide
|
|
50
|
+
* `ClientConfig.blockAdmin` registry so block children can take per-field
|
|
51
|
+
* admin config; plain groups receive none today (their children inherit
|
|
52
|
+
* site-wide defaults).
|
|
53
|
+
*/
|
|
54
|
+
fieldAdmin?: Record<string, FieldAdminConfig>
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
export const GroupField = ({
|
|
@@ -52,6 +60,7 @@ export const GroupField = ({
|
|
|
52
60
|
path,
|
|
53
61
|
collectionPath,
|
|
54
62
|
contentLocale,
|
|
63
|
+
fieldAdmin,
|
|
55
64
|
}: GroupFieldProps) => {
|
|
56
65
|
const fieldError = useFieldError(field.name)
|
|
57
66
|
// Default value for a group field is a plain object: { rating: 5, comment: '...' }
|
|
@@ -94,6 +103,8 @@ export const GroupField = ({
|
|
|
94
103
|
disableSorting={true}
|
|
95
104
|
collectionPath={collectionPath}
|
|
96
105
|
contentLocale={contentLocale}
|
|
106
|
+
components={fieldAdmin?.[innerField.name]?.components}
|
|
107
|
+
editor={fieldAdmin?.[innerField.name]?.editor}
|
|
97
108
|
/>
|
|
98
109
|
)
|
|
99
110
|
})}
|
|
@@ -165,6 +165,13 @@ export const RelationPicker = ({
|
|
|
165
165
|
|
|
166
166
|
setLoading(true)
|
|
167
167
|
setError(null)
|
|
168
|
+
// Item-view sort: the target collection's `itemViewSort` (boot-validated)
|
|
169
|
+
// orders the picker independently of its list view's `defaultSort`.
|
|
170
|
+
// Passed as explicit params because the list server fn gives an explicit
|
|
171
|
+
// `order` top precedence; when absent the server falls back through
|
|
172
|
+
// `defaultSort` → `created_at desc` (or `order_key asc` for orderable
|
|
173
|
+
// collections) exactly as before.
|
|
174
|
+
const itemViewSort = targetAdminConfig?.itemViewSort
|
|
168
175
|
getCollectionDocuments({
|
|
169
176
|
collection: targetCollectionPath,
|
|
170
177
|
params: {
|
|
@@ -172,6 +179,9 @@ export const RelationPicker = ({
|
|
|
172
179
|
page_size: PAGE_SIZE,
|
|
173
180
|
query: query.length > 0 ? query : undefined,
|
|
174
181
|
fields: selectFields,
|
|
182
|
+
...(itemViewSort != null
|
|
183
|
+
? { order: String(itemViewSort.field), desc: itemViewSort.direction === 'desc' }
|
|
184
|
+
: {}),
|
|
175
185
|
},
|
|
176
186
|
})
|
|
177
187
|
.then((response: any) => {
|
|
@@ -202,6 +212,7 @@ export const RelationPicker = ({
|
|
|
202
212
|
pickerColumns,
|
|
203
213
|
getCollectionDocuments,
|
|
204
214
|
t,
|
|
215
|
+
targetAdminConfig?.itemViewSort,
|
|
205
216
|
])
|
|
206
217
|
|
|
207
218
|
const resolvedDisplayField =
|
|
@@ -38,10 +38,17 @@ export const SelectField = ({
|
|
|
38
38
|
return (
|
|
39
39
|
<div className={`byline-field-select ${field.name}`}>
|
|
40
40
|
{field.label && (
|
|
41
|
-
<Label
|
|
41
|
+
<Label
|
|
42
|
+
id={htmlId}
|
|
43
|
+
htmlFor={htmlId}
|
|
44
|
+
label={field.label}
|
|
45
|
+
required={!field.optional}
|
|
46
|
+
className={cx('byline-field-select-label', styles.label)}
|
|
47
|
+
/>
|
|
42
48
|
)}
|
|
43
49
|
<Select<string>
|
|
44
|
-
size="
|
|
50
|
+
size="xs"
|
|
51
|
+
variant="outlined"
|
|
45
52
|
id={htmlId}
|
|
46
53
|
name={field.name}
|
|
47
54
|
placeholder="Select an option"
|