@byline/admin 6.3.0 → 6.5.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 (62) 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/dist/modules/admin-account/components/change-password.js +6 -0
  31. package/dist/modules/admin-users/components/create.js +4 -3
  32. package/dist/modules/admin-users/components/set-password.js +4 -0
  33. package/dist/modules/auth/components/sign-in-form.js +4 -3
  34. package/package.json +16 -16
  35. package/src/fields/array/array-field.tsx +24 -12
  36. package/src/fields/blocks/blocks-field.tsx +18 -8
  37. package/src/fields/checkbox/checkbox-field.tsx +1 -0
  38. package/src/fields/datetime/datetime-field.tsx +1 -0
  39. package/src/fields/file/file-field.tsx +3 -2
  40. package/src/fields/group/group-field.tsx +2 -2
  41. package/src/fields/image/image-field.tsx +3 -2
  42. package/src/fields/relation/relation-field.tsx +5 -0
  43. package/src/fields/relation/relation-many-field.tsx +16 -2
  44. package/src/fields/select/select-field.tsx +1 -0
  45. package/src/fields/text/text-field.tsx +3 -2
  46. package/src/fields/text-area/text-area-field.tsx +3 -2
  47. package/src/fields/use-field-change-handler.ts +43 -9
  48. package/src/fields/use-field-condition.ts +17 -1
  49. package/src/forms/form-context.tsx +178 -233
  50. package/src/forms/form-correctness.test.tsx +560 -0
  51. package/src/forms/form-layout.tsx +3 -2
  52. package/src/forms/form-page-chrome.tsx +17 -34
  53. package/src/forms/form-renderer-submit.test.tsx +36 -0
  54. package/src/forms/form-renderer.tsx +15 -64
  55. package/src/forms/nested-path.ts +35 -0
  56. package/src/forms/use-document-reload-recovery.ts +92 -0
  57. package/src/forms/use-form-submission.ts +56 -27
  58. package/src/forms/use-form-tabs.ts +25 -17
  59. package/src/modules/admin-account/components/change-password.tsx +6 -0
  60. package/src/modules/admin-users/components/create.tsx +4 -3
  61. package/src/modules/admin-users/components/set-password.tsx +4 -0
  62. package/src/modules/auth/components/sign-in-form.tsx +4 -3
@@ -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 {
@@ -138,6 +138,8 @@ function ChangeAccountPassword({ account, onClose, onSuccess }) {
138
138
  error: field.state.meta.errors.length > 0,
139
139
  errorText: firstError(field.state.meta.errors),
140
140
  autoComplete: "current-password",
141
+ showPasswordLabel: t('common.actions.showPassword'),
142
+ hidePasswordLabel: t('common.actions.hidePassword'),
141
143
  required: true
142
144
  })
143
145
  }),
@@ -153,6 +155,8 @@ function ChangeAccountPassword({ account, onClose, onSuccess }) {
153
155
  error: field.state.meta.errors.length > 0,
154
156
  errorText: translateValidationError(t, firstError(field.state.meta.errors)),
155
157
  autoComplete: "new-password",
158
+ showPasswordLabel: t('common.actions.showPassword'),
159
+ hidePasswordLabel: t('common.actions.hidePassword'),
156
160
  required: true
157
161
  })
158
162
  }),
@@ -168,6 +172,8 @@ function ChangeAccountPassword({ account, onClose, onSuccess }) {
168
172
  error: field.state.meta.errors.length > 0,
169
173
  errorText: firstError(field.state.meta.errors),
170
174
  autoComplete: "new-password",
175
+ showPasswordLabel: t('common.actions.showPassword'),
176
+ hidePasswordLabel: t('common.actions.hidePassword'),
171
177
  required: true
172
178
  })
173
179
  }),
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
4
4
  import { revalidateLogic, useForm } from "@tanstack/react-form-start";
5
5
  import { passwordSchema } from "@byline/core/validation";
6
6
  import { useTranslation } from "@byline/i18n/react";
7
- import { Alert, Button, Checkbox, Input, LoaderEllipsis } from "@byline/ui/react";
7
+ import { Alert, Button, Checkbox, Input, InputPassword, LoaderEllipsis } from "@byline/ui/react";
8
8
  import clsx from "clsx";
9
9
  import { z } from "zod";
10
10
  import { translateValidationError } from "../../../lib/translate-validation-error.js";
@@ -180,11 +180,10 @@ function CreateAdminUser({ onClose, onSuccess }) {
180
180
  }),
181
181
  /*#__PURE__*/ jsx(form.Field, {
182
182
  name: "password",
183
- children: (field)=>/*#__PURE__*/ jsx(Input, {
183
+ children: (field)=>/*#__PURE__*/ jsx(InputPassword, {
184
184
  label: t('adminUsers.create.fields.password'),
185
185
  id: "new-password",
186
186
  name: field.name,
187
- type: "password",
188
187
  value: field.state.value,
189
188
  onBlur: field.handleBlur,
190
189
  onChange: (e)=>field.handleChange(e.currentTarget.value),
@@ -192,6 +191,8 @@ function CreateAdminUser({ onClose, onSuccess }) {
192
191
  errorText: translateValidationError(t, firstError(field.state.meta.errors)),
193
192
  helpText: t('adminUsers.create.fields.passwordHelp'),
194
193
  autoComplete: "new-password",
194
+ showPasswordLabel: t('common.actions.showPassword'),
195
+ hidePasswordLabel: t('common.actions.hidePassword'),
195
196
  required: true
196
197
  })
197
198
  }),
@@ -103,6 +103,8 @@ function SetPassword({ user, onClose, onSuccess }) {
103
103
  error: field.state.meta.errors.length > 0,
104
104
  errorText: translateValidationError(t, firstError(field.state.meta.errors)),
105
105
  autoComplete: "new-password",
106
+ showPasswordLabel: t('common.actions.showPassword'),
107
+ hidePasswordLabel: t('common.actions.hidePassword'),
106
108
  required: true
107
109
  })
108
110
  }),
@@ -118,6 +120,8 @@ function SetPassword({ user, onClose, onSuccess }) {
118
120
  error: field.state.meta.errors.length > 0,
119
121
  errorText: firstError(field.state.meta.errors),
120
122
  autoComplete: "new-password",
123
+ showPasswordLabel: t('common.actions.showPassword'),
124
+ hidePasswordLabel: t('common.actions.hidePassword'),
121
125
  required: true
122
126
  })
123
127
  }),
@@ -3,7 +3,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { getAdminConfig } from "@byline/core";
5
5
  import { useTranslation } from "@byline/i18n/react";
6
- import { Alert, Button, Card, Input, LoaderEllipsis } from "@byline/ui/react";
6
+ import { Alert, Button, Card, Input, InputPassword, LoaderEllipsis } from "@byline/ui/react";
7
7
  import clsx from "clsx";
8
8
  import { useBylineAdminServices } from "../../../services/admin-services-context.js";
9
9
  import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from "../safe-redirect.js";
@@ -78,12 +78,13 @@ function SignInForm({ redirectTo, homeUrl }) {
78
78
  onChange: (event)=>setEmail(event.currentTarget.value),
79
79
  disabled: pending
80
80
  }),
81
- /*#__PURE__*/ jsx(Input, {
81
+ /*#__PURE__*/ jsx(InputPassword, {
82
82
  label: t('common.fields.password'),
83
83
  id: "password",
84
84
  name: "password",
85
- type: "password",
86
85
  autoComplete: "current-password",
86
+ showPasswordLabel: t('common.actions.showPassword'),
87
+ hidePasswordLabel: t('common.actions.hidePassword'),
87
88
  required: true,
88
89
  value: password,
89
90
  onChange: (event)=>setPassword(event.currentTarget.value),
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.5.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,40 +174,40 @@
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.5.0",
187
+ "@byline/auth": "6.5.0",
188
+ "@byline/analytics-agent": "6.5.0",
189
+ "@byline/ui": "6.5.0",
190
+ "@byline/i18n": "6.5.0",
191
+ "@byline/core": "6.5.0"
192
192
  },
193
193
  "peerDependencies": {
194
194
  "react": "^19.0.0",
195
195
  "react-dom": "^19.0.0"
196
196
  },
197
197
  "devDependencies": {
198
- "@biomejs/biome": "2.5.13",
198
+ "@biomejs/biome": "2.5.14",
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.2",
202
202
  "@types/react": "19.3.0",
203
203
  "@types/react-dom": "19.3.0",
204
204
  "@vitejs/plugin-react": "^6.1.1",
205
- "jsdom": "^30.0.1",
205
+ "jsdom": "^30.1.0",
206
206
  "react": "^19.3.0",
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
  }