@byline/admin 6.3.0 → 6.4.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 (54) hide show
  1. package/dist/fields/array/array-field.js +29 -12
  2. package/dist/fields/blocks/blocks-field.js +20 -8
  3. package/dist/fields/checkbox/checkbox-field.js +1 -0
  4. package/dist/fields/datetime/datetime-field.js +1 -0
  5. package/dist/fields/file/file-field.js +3 -2
  6. package/dist/fields/group/group-field.js +5 -2
  7. package/dist/fields/image/image-field.js +3 -2
  8. package/dist/fields/relation/relation-field.js +5 -0
  9. package/dist/fields/relation/relation-many-field.js +9 -2
  10. package/dist/fields/select/select-field.js +1 -0
  11. package/dist/fields/text/text-field.js +4 -2
  12. package/dist/fields/text-area/text-area-field.js +4 -2
  13. package/dist/fields/use-field-change-handler.js +24 -6
  14. package/dist/fields/use-field-condition.d.ts +0 -15
  15. package/dist/fields/use-field-condition.js +10 -1
  16. package/dist/forms/form-context.d.ts +3 -12
  17. package/dist/forms/form-context.js +111 -129
  18. package/dist/forms/form-layout.js +7 -2
  19. package/dist/forms/form-page-chrome.js +14 -24
  20. package/dist/forms/form-renderer.d.ts +2 -1
  21. package/dist/forms/form-renderer.js +12 -56
  22. package/dist/forms/nested-path.d.ts +2 -0
  23. package/dist/forms/nested-path.js +26 -1
  24. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  25. package/dist/forms/use-document-reload-recovery.js +67 -0
  26. package/dist/forms/use-form-submission.d.ts +1 -1
  27. package/dist/forms/use-form-submission.js +45 -27
  28. package/dist/forms/use-form-tabs.d.ts +2 -2
  29. package/dist/forms/use-form-tabs.js +18 -11
  30. package/package.json +14 -14
  31. package/src/fields/array/array-field.tsx +24 -12
  32. package/src/fields/blocks/blocks-field.tsx +18 -8
  33. package/src/fields/checkbox/checkbox-field.tsx +1 -0
  34. package/src/fields/datetime/datetime-field.tsx +1 -0
  35. package/src/fields/file/file-field.tsx +3 -2
  36. package/src/fields/group/group-field.tsx +2 -2
  37. package/src/fields/image/image-field.tsx +3 -2
  38. package/src/fields/relation/relation-field.tsx +5 -0
  39. package/src/fields/relation/relation-many-field.tsx +16 -2
  40. package/src/fields/select/select-field.tsx +1 -0
  41. package/src/fields/text/text-field.tsx +3 -2
  42. package/src/fields/text-area/text-area-field.tsx +3 -2
  43. package/src/fields/use-field-change-handler.ts +43 -9
  44. package/src/fields/use-field-condition.ts +17 -1
  45. package/src/forms/form-context.tsx +178 -233
  46. package/src/forms/form-correctness.test.tsx +560 -0
  47. package/src/forms/form-layout.tsx +3 -2
  48. package/src/forms/form-page-chrome.tsx +17 -34
  49. package/src/forms/form-renderer-submit.test.tsx +36 -0
  50. package/src/forms/form-renderer.tsx +15 -64
  51. package/src/forms/nested-path.ts +35 -0
  52. package/src/forms/use-document-reload-recovery.ts +92 -0
  53. package/src/forms/use-form-submission.ts +56 -27
  54. package/src/forms/use-form-tabs.ts +25 -17
@@ -0,0 +1,67 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ function useDocumentReloadRecovery({ mutationsBlocked, mutationIssue, onReloadDocument, isBusy }) {
3
+ const formRef = useRef(null);
4
+ const focusBeforeBusyRef = useRef(null);
5
+ const restoreFocusAfterBusyRef = useRef(false);
6
+ const [discarding, setDiscarding] = useState(false);
7
+ const [reloadFailed, setReloadFailed] = useState(false);
8
+ const warningRef = useRef(null);
9
+ const mutationBlockedRef = useRef(mutationsBlocked);
10
+ mutationBlockedRef.current = mutationsBlocked || discarding;
11
+ useEffect(()=>{
12
+ if (mutationIssue) warningRef.current?.focus();
13
+ }, [
14
+ mutationIssue
15
+ ]);
16
+ useEffect(()=>{
17
+ if (!discarding) return;
18
+ Promise.resolve().then(()=>onReloadDocument ? onReloadDocument() : window.location.reload()).catch(()=>{
19
+ setDiscarding(false);
20
+ setReloadFailed(true);
21
+ });
22
+ }, [
23
+ discarding,
24
+ onReloadDocument
25
+ ]);
26
+ const captureFocusBeforeBusy = useCallback(()=>{
27
+ if (null != focusBeforeBusyRef.current) return;
28
+ const activeElement = document.activeElement;
29
+ if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return;
30
+ focusBeforeBusyRef.current = activeElement;
31
+ restoreFocusAfterBusyRef.current = true;
32
+ }, []);
33
+ useEffect(()=>{
34
+ if (isBusy || !restoreFocusAfterBusyRef.current) return;
35
+ restoreFocusAfterBusyRef.current = false;
36
+ if (mutationIssue) {
37
+ warningRef.current?.focus();
38
+ focusBeforeBusyRef.current = null;
39
+ return;
40
+ }
41
+ const original = focusBeforeBusyRef.current;
42
+ focusBeforeBusyRef.current = null;
43
+ const originalCanReceiveFocus = original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]');
44
+ const target = originalCanReceiveFocus ? original : formRef.current?.querySelector('input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])');
45
+ target?.focus({
46
+ preventScroll: true
47
+ });
48
+ }, [
49
+ isBusy,
50
+ mutationIssue
51
+ ]);
52
+ const requestDiscard = useCallback(()=>{
53
+ mutationBlockedRef.current = true;
54
+ setReloadFailed(false);
55
+ setDiscarding(true);
56
+ }, []);
57
+ return {
58
+ formRef,
59
+ warningRef,
60
+ mutationBlockedRef,
61
+ discarding,
62
+ reloadFailed,
63
+ requestDiscard,
64
+ captureFocusBeforeBusy
65
+ };
66
+ }
67
+ export { useDocumentReloadRecovery };
@@ -1,4 +1,4 @@
1
- import type { Field } from '@byline/core';
1
+ import { type Field } from '@byline/core';
2
2
  import type { DocumentPatch } from '@byline/core/patches';
3
3
  /**
4
4
  * Payload emitted by the form on Save. Carries the content (field data +
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { useCallback, useRef, useState } from "react";
3
+ import { getDocumentFieldValidationDetails } from "@byline/core";
3
4
  import { useTranslation } from "@byline/i18n/react";
4
5
  import { useBylineFieldServices } from "../fields/field-services-context.js";
5
6
  import { useFormContext } from "./form-context.js";
@@ -9,11 +10,12 @@ const IDLE = {
9
10
  };
10
11
  function useFormSubmission(options) {
11
12
  const { mode, fields, documentId, advertiseLocales, onSubmit, isBlocked, onBeforeBusy } = options;
12
- const { getFieldValues, runFieldHooks, validateForm, resetHasChanges, getPatches, getDirtyBreakdown, getSystemPath, getSystemAvailableLocales, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
13
+ const { getFieldValues, runFieldHooks, validateForm, resetHasChanges, getPatches, getDirtyBreakdown, getSystemPath, getSystemAvailableLocales, setFieldValue, setFieldError, getPendingUploads, removePendingUpload, clearFieldError, waitForFieldChanges, setFieldUploading } = useFormContext();
13
14
  const { t } = useTranslation('byline-admin');
14
15
  const { uploadField } = useBylineFieldServices();
15
16
  const [phase, setPhase] = useState(IDLE);
16
17
  const inFlightRef = useRef(false);
18
+ const confirmationRef = useRef(null);
17
19
  const isBusy = 'uploading' === phase.kind || 'submitting' === phase.kind;
18
20
  const deliver = useCallback(async (payload)=>{
19
21
  if (isBlocked() || 'function' != typeof onSubmit) return void setPhase(IDLE);
@@ -24,14 +26,18 @@ function useFormSubmission(options) {
24
26
  try {
25
27
  await onSubmit(payload);
26
28
  resetHasChanges();
27
- } catch {} finally{
29
+ } catch (error) {
30
+ const validation = getDocumentFieldValidationDetails(error);
31
+ for (const issue of validation?.issues ?? [])setFieldError(issue.field, issue.message);
32
+ } finally{
28
33
  setPhase(IDLE);
29
34
  }
30
35
  }, [
31
36
  isBlocked,
32
37
  onBeforeBusy,
33
38
  onSubmit,
34
- resetHasChanges
39
+ resetHasChanges,
40
+ setFieldError
35
41
  ]);
36
42
  const runUploads = useCallback(async ()=>{
37
43
  const pendingUploads = getPendingUploads();
@@ -48,22 +54,26 @@ function useFormSubmission(options) {
48
54
  fields,
49
55
  getFormValues: getFieldValues
50
56
  });
57
+ for (const [fieldPath, storedFile] of uploadResult.successful)if (getPendingUploads().get(fieldPath) === pendingUploads.get(fieldPath)) {
58
+ setFieldValue(fieldPath, storedFile);
59
+ clearFieldError(fieldPath);
60
+ removePendingUpload(fieldPath);
61
+ }
51
62
  if (!uploadResult.allSucceeded) {
52
- for (const [fieldPath, errorMessage] of uploadResult.errors.entries())setFieldError(fieldPath, t('forms.uploadFailedFieldError', {
63
+ for (const [fieldPath, errorMessage] of uploadResult.errors)if (getPendingUploads().get(fieldPath) === pendingUploads.get(fieldPath)) setFieldError(fieldPath, t('forms.uploadFailedFieldError', {
53
64
  message: errorMessage
54
65
  }));
55
- console.error('One or more uploads failed:', uploadResult.errors);
56
66
  return false;
57
67
  }
58
- for (const [fieldPath, storedFile] of uploadResult.successful.entries())setFieldValue(fieldPath, storedFile);
59
- clearPendingUploads();
68
+ if (getPendingUploads().size > 0) return false;
60
69
  return true;
61
70
  } catch (err) {
62
71
  console.error('Upload execution error:', err);
63
72
  return false;
64
73
  }
65
74
  }, [
66
- clearPendingUploads,
75
+ removePendingUpload,
76
+ clearFieldError,
67
77
  documentId,
68
78
  fields,
69
79
  getFieldValues,
@@ -81,8 +91,8 @@ function useFormSubmission(options) {
81
91
  return {
82
92
  reason,
83
93
  payload: {
84
- data: getFieldValues(),
85
- patches: getPatches(),
94
+ data: structuredClone(getFieldValues()),
95
+ patches: structuredClone(getPatches()),
86
96
  contentDirty,
87
97
  pathDirty,
88
98
  systemPath: getSystemPath(),
@@ -100,49 +110,56 @@ function useFormSubmission(options) {
100
110
  ]);
101
111
  const submit = useCallback(async ()=>{
102
112
  if (isBlocked()) return;
103
- if (inFlightRef.current) return;
113
+ if (inFlightRef.current || confirmationRef.current) return;
104
114
  inFlightRef.current = true;
105
115
  try {
106
116
  setPhase({
107
117
  kind: 'validating'
108
118
  });
109
- const hookErrors = await runFieldHooks(fields);
110
- const formErrors = validateForm(fields);
111
- const allErrors = [
112
- ...hookErrors,
113
- ...formErrors
114
- ];
115
- if (allErrors.length > 0) {
116
- console.error('Form validation failed:', allErrors);
117
- setPhase(IDLE);
118
- return;
119
+ await waitForFieldChanges();
120
+ if (isBlocked()) return void setPhase(IDLE);
121
+ if ('edit' !== mode || 'direct-write' !== getDirtyBreakdown().reason) {
122
+ const hookErrors = await runFieldHooks(fields);
123
+ const formErrors = validateForm(fields, hookErrors);
124
+ if (formErrors.length > 0) {
125
+ console.error('Form validation failed:', formErrors);
126
+ setPhase(IDLE);
127
+ return;
128
+ }
119
129
  }
120
130
  if (isBlocked()) return void setPhase(IDLE);
121
131
  if (!await runUploads()) return void setPhase(IDLE);
122
132
  const { payload, reason } = buildPayload();
123
- if ('edit' === mode && ('direct-write' === reason || 'both' === reason)) return void setPhase({
124
- kind: 'confirmingSystemFields',
125
- payload
126
- });
133
+ if ('edit' === mode && ('direct-write' === reason || 'both' === reason)) {
134
+ confirmationRef.current = payload;
135
+ setPhase({
136
+ kind: 'confirmingSystemFields',
137
+ payload
138
+ });
139
+ return;
140
+ }
127
141
  await deliver(payload);
128
142
  } finally{
129
143
  inFlightRef.current = false;
130
144
  }
131
145
  }, [
132
146
  buildPayload,
147
+ getDirtyBreakdown,
133
148
  deliver,
134
149
  fields,
135
150
  isBlocked,
136
151
  mode,
137
152
  runUploads,
138
153
  runFieldHooks,
139
- validateForm
154
+ validateForm,
155
+ waitForFieldChanges
140
156
  ]);
141
157
  const confirmSystemFields = useCallback(async ()=>{
142
- if ('confirmingSystemFields' !== phase.kind) return;
158
+ if ('confirmingSystemFields' !== phase.kind || confirmationRef.current !== phase.payload) return;
143
159
  if (inFlightRef.current) return;
144
160
  inFlightRef.current = true;
145
161
  const { payload } = phase;
162
+ confirmationRef.current = null;
146
163
  try {
147
164
  await deliver(payload);
148
165
  } finally{
@@ -153,6 +170,7 @@ function useFormSubmission(options) {
153
170
  phase
154
171
  ]);
155
172
  const cancelSystemFields = useCallback(()=>{
173
+ confirmationRef.current = null;
156
174
  setPhase((current)=>'confirmingSystemFields' === current.kind ? IDLE : current);
157
175
  }, []);
158
176
  return {
@@ -1,4 +1,4 @@
1
- import type { TabDefinition, TabSetDefinition } from '@byline/core';
1
+ import { type TabDefinition, type TabSetDefinition } from '@byline/core';
2
2
  /** One tab set, resolved against live form data and current errors. */
3
3
  export interface ResolvedTabSet {
4
4
  /** Tabs whose `condition` is satisfied, in declaration order. */
@@ -26,4 +26,4 @@ export interface UseFormTabsResult {
26
26
  export declare function useFormTabs(fieldToTabPath: Map<string, {
27
27
  tabSetName: string;
28
28
  tabName: string;
29
- }>, activeTabBySet: Record<string, string>): UseFormTabsResult;
29
+ }>, activeTabBySet: Record<string, string>, tabSets: TabSetDefinition[]): UseFormTabsResult;
@@ -1,21 +1,28 @@
1
1
  "use client";
2
- import { useEffect, useMemo, useState } from "react";
2
+ import { useCallback, useMemo, useSyncExternalStore } from "react";
3
+ import { parseInstancePath } from "@byline/core";
3
4
  import { useFormContext } from "./form-context.js";
4
- function useFormTabs(fieldToTabPath, activeTabBySet) {
5
- const { errors: initialErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext();
6
- const [errors, setErrors] = useState(initialErrors);
7
- useEffect(()=>subscribeErrors((newErrors)=>setErrors(newErrors)), [
8
- subscribeErrors
5
+ function useFormTabs(fieldToTabPath, activeTabBySet, tabSets) {
6
+ const { getErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext();
7
+ const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors);
8
+ const conditionalTabs = useMemo(()=>tabSets.flatMap((set)=>set.tabs.filter((tab)=>tab.condition)), [
9
+ tabSets
9
10
  ]);
10
- const [formData, setFormData] = useState(()=>getFieldValues());
11
- useEffect(()=>subscribeMeta(()=>setFormData(getFieldValues())), [
12
- subscribeMeta,
11
+ const snapshot = useCallback(()=>JSON.stringify(conditionalTabs.map((tab)=>Boolean(tab.condition?.(getFieldValues())))), [
12
+ conditionalTabs,
13
13
  getFieldValues
14
14
  ]);
15
+ const subscribe = useCallback((notify)=>conditionalTabs.length ? subscribeMeta(notify) : ()=>{}, [
16
+ conditionalTabs,
17
+ subscribeMeta
18
+ ]);
19
+ useSyncExternalStore(subscribe, snapshot, snapshot);
15
20
  const errorCountsBySet = useMemo(()=>{
16
21
  const result = {};
17
22
  for (const err of errors){
18
- const path = fieldToTabPath.get(err.field);
23
+ const parsed = parseInstancePath(err.field);
24
+ const root = parsed.ok ? parsed.segments[0] : void 0;
25
+ const path = fieldToTabPath.get(err.field) ?? (root?.kind === 'field' ? fieldToTabPath.get(root.name) : void 0);
19
26
  if (path) {
20
27
  result[path.tabSetName] ??= {};
21
28
  result[path.tabSetName][path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1;
@@ -27,7 +34,7 @@ function useFormTabs(fieldToTabPath, activeTabBySet) {
27
34
  fieldToTabPath
28
35
  ]);
29
36
  const resolve = (set)=>{
30
- const visibleTabs = set.tabs.filter((tab)=>!tab.condition || tab.condition(formData));
37
+ const visibleTabs = set.tabs.filter((tab)=>!tab.condition || tab.condition(getFieldValues()));
31
38
  const requested = activeTabBySet[set.name] ?? '';
32
39
  const activeTabName = visibleTabs.length > 0 && !visibleTabs.some((t)=>t.name === requested) ? visibleTabs[0]?.name ?? requested : requested;
33
40
  return {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "6.3.0",
5
+ "version": "6.4.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -164,7 +164,7 @@
164
164
  "src/"
165
165
  ],
166
166
  "dependencies": {
167
- "@codemirror/commands": "^6.11.0",
167
+ "@codemirror/commands": "^6.11.1",
168
168
  "@codemirror/lang-css": "^6.3.1",
169
169
  "@codemirror/lang-html": "^6.4.12",
170
170
  "@codemirror/lang-javascript": "^6.2.5",
@@ -174,21 +174,21 @@
174
174
  "@codemirror/lang-sql": "^6.10.0",
175
175
  "@codemirror/lang-yaml": "^6.1.3",
176
176
  "@codemirror/language": "^6.12.4",
177
- "@codemirror/state": "^6.7.4",
178
- "@codemirror/view": "^6.43.11",
177
+ "@codemirror/state": "^6.7.5",
178
+ "@codemirror/view": "^6.43.12",
179
179
  "@lezer/highlight": "^1.2.3",
180
180
  "@tanstack/react-form-start": "^1.33.5",
181
181
  "clsx": "^2.1.1",
182
182
  "jose": "^6.2.12",
183
183
  "react-diff-viewer-continued": "^4.4.0",
184
184
  "uuid": "^14.0.2",
185
- "zod": "^4.6.2",
186
- "@byline/analytics": "6.3.0",
187
- "@byline/core": "6.3.0",
188
- "@byline/i18n": "6.3.0",
189
- "@byline/auth": "6.3.0",
190
- "@byline/ui": "6.3.0",
191
- "@byline/analytics-agent": "6.3.0"
185
+ "zod": "^4.6.5",
186
+ "@byline/analytics": "6.4.0",
187
+ "@byline/analytics-agent": "6.4.0",
188
+ "@byline/auth": "6.4.0",
189
+ "@byline/core": "6.4.0",
190
+ "@byline/i18n": "6.4.0",
191
+ "@byline/ui": "6.4.0"
192
192
  },
193
193
  "peerDependencies": {
194
194
  "react": "^19.0.0",
@@ -197,8 +197,8 @@
197
197
  "devDependencies": {
198
198
  "@biomejs/biome": "2.5.13",
199
199
  "@rsbuild/plugin-react": "^2.1.0",
200
- "@rslib/core": "^1.0.0",
201
- "@types/node": "^26.5.1",
200
+ "@rslib/core": "^1.0.1",
201
+ "@types/node": "^26.6.1",
202
202
  "@types/react": "19.3.0",
203
203
  "@types/react-dom": "19.3.0",
204
204
  "@vitejs/plugin-react": "^6.1.1",
@@ -207,7 +207,7 @@
207
207
  "react-dom": "^19.3.0",
208
208
  "typescript": "^7.0.2",
209
209
  "typescript-plugin-css-modules": "^5.2.0",
210
- "vitest": "^5.0.0"
210
+ "vitest": "^5.0.1"
211
211
  },
212
212
  "publishConfig": {
213
213
  "access": "public",
@@ -10,14 +10,15 @@ import { useEffect, useState } from 'react'
10
10
 
11
11
  import type { ArrayField as ArrayFieldType, Field, FieldAdminConfig } from '@byline/core'
12
12
  import { useTranslation } from '@byline/i18n/react'
13
- import { DraggableSortable, IconButton, moveItem, PlusIcon } from '@byline/ui/react'
13
+ import { DraggableSortable, ErrorText, IconButton, moveItem, PlusIcon } from '@byline/ui/react'
14
14
  import cx from 'clsx'
15
15
 
16
16
  import { sliceFieldAdmin } from '../../fields/field-admin'
17
17
  import { defaultScalarForField } from '../../fields/field-helpers'
18
18
  import { FieldRenderer } from '../../fields/field-renderer'
19
19
  import { SortableItem, StaticItem } from '../../fields/sortable-item'
20
- import { useFormContext } from '../../forms/form-context'
20
+ import { useFieldError, useFormContext } from '../../forms/form-context'
21
+ import { useScopedDomId } from '../../forms/form-dom-scope'
21
22
  import { hasExistingIdTargets } from '../../forms/nested-path'
22
23
  import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
23
24
  import styles from './array-field.module.css'
@@ -60,6 +61,8 @@ export const ArrayField = ({
60
61
  const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
61
62
  useFormContext()
62
63
  const { t } = useTranslation('byline-admin')
64
+ const fieldError = useFieldError(path)
65
+ const htmlId = useScopedDomId(path)
63
66
  const [items, setItems] = useState<{ id: string; data: any }[]>([])
64
67
 
65
68
  useEffect(() => {
@@ -95,6 +98,7 @@ export const ArrayField = ({
95
98
  moveFromIndex: number
96
99
  moveToIndex: number
97
100
  }) => {
101
+ if (field.readOnly) return
98
102
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
99
103
  if (!Array.isArray(currentArray)) return
100
104
 
@@ -112,6 +116,7 @@ export const ArrayField = ({
112
116
  }
113
117
 
114
118
  const handleAddItem = async (atIndex?: number) => {
119
+ if (field.readOnly) return
115
120
  const childFields = field.fields ?? []
116
121
  if (childFields.length === 0) return
117
122
 
@@ -164,6 +169,7 @@ export const ArrayField = ({
164
169
  }
165
170
 
166
171
  const handleRemoveItem = (index: number) => {
172
+ if (field.readOnly) return
167
173
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
168
174
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
169
175
 
@@ -185,6 +191,7 @@ export const ArrayField = ({
185
191
  }
186
192
 
187
193
  const handleInsertBelow = (index: number) => {
194
+ if (field.readOnly) return
188
195
  void handleAddItem(index + 1)
189
196
  }
190
197
 
@@ -220,7 +227,11 @@ export const ArrayField = ({
220
227
  {(childField.fields as Field[]).map((innerField) => (
221
228
  <FieldRenderer
222
229
  key={innerField.name}
223
- field={innerField}
230
+ field={
231
+ field.readOnly || childField.readOnly
232
+ ? { ...innerField, readOnly: true }
233
+ : innerField
234
+ }
224
235
  defaultValue={groupData[innerField.name]}
225
236
  basePath={`${arrayElementPath}.${childField.name}`}
226
237
  disableSorting={true}
@@ -237,7 +248,7 @@ export const ArrayField = ({
237
248
  return (
238
249
  <FieldRenderer
239
250
  key={childField.name}
240
- field={childField}
251
+ field={field.readOnly ? { ...childField, readOnly: true } : childField}
241
252
  defaultValue={initial}
242
253
  basePath={arrayElementPath}
243
254
  disableSorting={true}
@@ -258,13 +269,13 @@ export const ArrayField = ({
258
269
  // (add-below / remove / collapse) is always available — StaticItem is
259
270
  // the grip-less sibling of SortableItem (see sortable-item.tsx for why
260
271
  // it is a separate component rather than a prop).
261
- if (disableSorting) {
272
+ if (disableSorting || field.readOnly) {
262
273
  return (
263
274
  <StaticItem
264
275
  key={itemWrapper.id}
265
276
  label={itemLabel}
266
- onAddBelow={() => handleInsertBelow(index)}
267
- onRemove={() => handleRemoveItem(index)}
277
+ onAddBelow={field.readOnly ? undefined : () => handleInsertBelow(index)}
278
+ onRemove={field.readOnly ? undefined : () => handleRemoveItem(index)}
268
279
  >
269
280
  <div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
270
281
  {innerBody}
@@ -278,8 +289,8 @@ export const ArrayField = ({
278
289
  key={itemWrapper.id}
279
290
  id={itemWrapper.id}
280
291
  label={itemLabel}
281
- onAddBelow={() => handleInsertBelow(index)}
282
- onRemove={() => handleRemoveItem(index)}
292
+ onAddBelow={field.readOnly ? undefined : () => handleInsertBelow(index)}
293
+ onRemove={field.readOnly ? undefined : () => handleRemoveItem(index)}
283
294
  >
284
295
  <div className={cx('byline-field-array-group-fields', styles['group-fields'])}>
285
296
  {innerBody}
@@ -321,10 +332,10 @@ export const ArrayField = ({
321
332
  {field.label && (
322
333
  <h3 className={cx('byline-field-array-title', styles.title)}>{field.label}</h3>
323
334
  )}
324
- {disableSorting ? (
335
+ {disableSorting || field.readOnly ? (
325
336
  <div className={cx('byline-field-array-stack', styles.stack)}>
326
337
  {items.map((item, index) => renderItem(item, index))}
327
- {addRow}
338
+ {!field.readOnly && addRow}
328
339
  </div>
329
340
  ) : (
330
341
  <DraggableSortable
@@ -333,9 +344,10 @@ export const ArrayField = ({
333
344
  className={cx('byline-field-array-stack', styles.stack)}
334
345
  >
335
346
  {items.map((item, index) => renderItem(item, index))}
336
- {addRow}
347
+ {!field.readOnly && addRow}
337
348
  </DraggableSortable>
338
349
  )}
350
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
339
351
  </div>
340
352
  )
341
353
  }
@@ -20,6 +20,7 @@ import {
20
20
  Card,
21
21
  CloseIcon,
22
22
  DraggableSortable,
23
+ ErrorText,
23
24
  IconButton,
24
25
  Modal,
25
26
  moveItem,
@@ -29,8 +30,9 @@ import cx from 'clsx'
29
30
 
30
31
  import { defaultScalarForField } from '../../fields/field-helpers'
31
32
  import { GroupField } from '../../fields/group/group-field'
32
- import { SortableItem } from '../../fields/sortable-item'
33
- import { useFormContext } from '../../forms/form-context'
33
+ import { SortableItem, StaticItem } from '../../fields/sortable-item'
34
+ import { useFieldError, useFormContext } from '../../forms/form-context'
35
+ import { useScopedDomId } from '../../forms/form-dom-scope'
34
36
  import { hasExistingIdTargets } from '../../forms/nested-path'
35
37
  import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
36
38
  import styles from './blocks-field.module.css'
@@ -59,6 +61,8 @@ export const BlocksField = ({
59
61
  const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
60
62
  useFormContext()
61
63
  const { t } = useTranslation('byline-admin')
64
+ const fieldError = useFieldError(path)
65
+ const htmlId = useScopedDomId(path)
62
66
  const [items, setItems] = useState<{ id: string; data: any }[]>([])
63
67
  const [showAddBlockModal, setShowAddBlockModal] = useState(false)
64
68
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null)
@@ -121,6 +125,7 @@ export const BlocksField = ({
121
125
  moveFromIndex: number
122
126
  moveToIndex: number
123
127
  }) => {
128
+ if (field.readOnly) return
124
129
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
125
130
  if (!Array.isArray(currentArray)) return
126
131
 
@@ -138,6 +143,7 @@ export const BlocksField = ({
138
143
  }
139
144
 
140
145
  const handleAddItem = async (forcedVariantName?: string, atIndex?: number) => {
146
+ if (field.readOnly) return
141
147
  setShowAddBlockModal(false)
142
148
  setPendingInsertIndex(null)
143
149
 
@@ -184,6 +190,7 @@ export const BlocksField = ({
184
190
  }
185
191
 
186
192
  const handleRemoveItem = (index: number) => {
193
+ if (field.readOnly) return
187
194
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
188
195
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
189
196
 
@@ -241,6 +248,7 @@ export const BlocksField = ({
241
248
  type: 'group',
242
249
  name: subField.blockType,
243
250
  fields: subField.fields,
251
+ readOnly: field.readOnly,
244
252
  label: undefined,
245
253
  } as GroupFieldType
246
254
  }
@@ -256,16 +264,17 @@ export const BlocksField = ({
256
264
  />
257
265
  )
258
266
 
267
+ const Item = field.readOnly ? StaticItem : SortableItem
259
268
  return (
260
- <SortableItem
269
+ <Item
261
270
  key={itemWrapper.id}
262
271
  id={itemWrapper.id}
263
272
  label={label ?? subField.blockType}
264
- onAddBelow={() => handleInsertBelow(index)}
265
- onRemove={() => handleRemoveItem(index)}
273
+ onAddBelow={field.readOnly ? undefined : () => handleInsertBelow(index)}
274
+ onRemove={field.readOnly ? undefined : () => handleRemoveItem(index)}
266
275
  >
267
276
  {body}
268
- </SortableItem>
277
+ </Item>
269
278
  )
270
279
  }
271
280
 
@@ -286,7 +295,7 @@ export const BlocksField = ({
286
295
  setPendingInsertIndex(null)
287
296
  setShowAddBlockModal(true)
288
297
  }}
289
- disabled={!selectedBlockName}
298
+ disabled={field.readOnly || !selectedBlockName}
290
299
  aria-label={t('fields.blocks.addBlockAriaLabel')}
291
300
  variant="outlined"
292
301
  >
@@ -298,7 +307,7 @@ export const BlocksField = ({
298
307
  <button
299
308
  type="button"
300
309
  tabIndex={-1}
301
- disabled={!selectedBlockName}
310
+ disabled={field.readOnly || !selectedBlockName}
302
311
  onClick={() => {
303
312
  setPendingInsertIndex(null)
304
313
  setShowAddBlockModal(true)
@@ -368,6 +377,7 @@ export const BlocksField = ({
368
377
  </Modal.Content>
369
378
  </Modal.Container>
370
379
  </Modal>
380
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
371
381
  </div>
372
382
  )
373
383
  }
@@ -37,6 +37,7 @@ export const CheckboxField = ({
37
37
  return (
38
38
  <div className={`byline-field-checkbox ${field.name} ${styles['field-checkbox']}`}>
39
39
  <Checkbox
40
+ disabled={field.readOnly}
40
41
  id={htmlId}
41
42
  name={field.name}
42
43
  label={field.label}
@@ -39,6 +39,7 @@ export const DateTimeField = ({
39
39
  return (
40
40
  <div className={`byline-field-datetime ${field.name}`}>
41
41
  <DatePicker
42
+ disabled={field.readOnly}
42
43
  id={htmlId}
43
44
  name={field.name}
44
45
  label={field.label}
@@ -110,6 +110,7 @@ export const FileField = ({
110
110
  const uploadGated = field.upload?.requireSavedDocument === true && documentId == null
111
111
 
112
112
  const handleRemove = () => {
113
+ if (field.readOnly) return
113
114
  if (isPending) {
114
115
  removePendingUpload(fieldPath)
115
116
  }
@@ -146,7 +147,7 @@ export const FileField = ({
146
147
  >
147
148
  {t('fields.upload.requireSavedDocument')}
148
149
  </div>
149
- ) : collectionPath ? (
150
+ ) : collectionPath && !field.readOnly ? (
150
151
  <FileUploadField
151
152
  field={field}
152
153
  collectionPath={collectionPath}
@@ -195,7 +196,7 @@ export const FileField = ({
195
196
  intent="noeffect"
196
197
  onClick={handleRemove}
197
198
  size="xs"
198
- disabled={isUploading}
199
+ disabled={isUploading || field.readOnly}
199
200
  aria-label={t('fields.file.removeAriaLabel')}
200
201
  >
201
202
  <CloseIcon width="15px" height="15px" />