@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.
Files changed (43) hide show
  1. package/dist/fields/array/array-field.js +20 -8
  2. package/dist/fields/array/array-field.module.js +5 -1
  3. package/dist/fields/array/array-field_module.css +23 -0
  4. package/dist/fields/blocks/blocks-field.js +33 -12
  5. package/dist/fields/blocks/blocks-field.module.js +2 -0
  6. package/dist/fields/blocks/blocks-field_module.css +37 -0
  7. package/dist/fields/code/code-editor.d.ts +20 -0
  8. package/dist/fields/code/code-editor.js +239 -0
  9. package/dist/fields/code/code-field.d.ts +21 -0
  10. package/dist/fields/code/code-field.js +124 -0
  11. package/dist/fields/code/code-field.module.js +7 -0
  12. package/dist/fields/code/code-field_module.css +58 -0
  13. package/dist/fields/field-helpers.js +1 -0
  14. package/dist/fields/field-renderer.js +15 -1
  15. package/dist/fields/group/group-field.d.ts +10 -2
  16. package/dist/fields/group/group-field.js +4 -2
  17. package/dist/fields/relation/relation-picker.js +8 -2
  18. package/dist/fields/select/select-field.js +4 -2
  19. package/dist/fields/select/select-field.module.js +1 -0
  20. package/dist/fields/select/select-field_module.css +4 -0
  21. package/dist/fields/text/text-field_module.css +1 -0
  22. package/dist/fields/text-area/text-area-field_module.css +1 -0
  23. package/dist/forms/form-renderer.d.ts +1 -1
  24. package/dist/forms/tree-placement-widget.d.ts +1 -1
  25. package/package.json +18 -5
  26. package/src/fields/array/array-field.module.css +30 -0
  27. package/src/fields/array/array-field.tsx +15 -2
  28. package/src/fields/blocks/blocks-field.module.css +53 -0
  29. package/src/fields/blocks/blocks-field.tsx +33 -1
  30. package/src/fields/code/code-editor.tsx +246 -0
  31. package/src/fields/code/code-field.module.css +84 -0
  32. package/src/fields/code/code-field.tsx +191 -0
  33. package/src/fields/field-helpers.ts +1 -0
  34. package/src/fields/field-renderer.tsx +19 -2
  35. package/src/fields/field-services-types.ts +1 -1
  36. package/src/fields/group/group-field.tsx +12 -1
  37. package/src/fields/relation/relation-picker.tsx +11 -0
  38. package/src/fields/select/select-field.module.css +5 -0
  39. package/src/fields/select/select-field.tsx +9 -2
  40. package/src/fields/text/text-field.module.css +1 -0
  41. package/src/fields/text-area/text-area-field.module.css +1 -0
  42. package/src/forms/form-renderer.tsx +1 -1
  43. 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
+ }
@@ -30,6 +30,7 @@ export const placeholderForField = (f: Field): any => {
30
30
  switch (f.type) {
31
31
  case 'text':
32
32
  case 'textArea':
33
+ case 'code':
33
34
  return ''
34
35
  case 'checkbox':
35
36
  return false
@@ -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 textArea render the badge inside their own Label row;
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 = field.type === 'text' || field.type === 'textArea' || field.type === 'richText'
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/03-document-trees.md) -----
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 =
@@ -7,6 +7,11 @@
7
7
  * field has unsaved local changes
8
8
  */
9
9
 
10
+ .label,
11
+ :global(.byline-field-select-label) {
12
+ margin-bottom: 0.25rem;
13
+ }
14
+
10
15
  .dirty,
11
16
  :global(.byline-field-select-dirty) {
12
17
  border-color: var(--blue-300);
@@ -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 id={htmlId} htmlFor={htmlId} label={field.label} required={!field.optional} />
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="sm"
50
+ size="xs"
51
+ variant="outlined"
45
52
  id={htmlId}
46
53
  name={field.name}
47
54
  placeholder="Select an option"