@byline/admin 6.2.1 → 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.
- package/dist/fields/array/array-field.js +29 -12
- package/dist/fields/blocks/blocks-field.js +20 -8
- package/dist/fields/checkbox/checkbox-field.js +4 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +5 -2
- package/dist/fields/field-renderer.js +2 -1
- package/dist/fields/field-services-types.d.ts +23 -0
- package/dist/fields/file/file-field.js +6 -4
- package/dist/fields/group/group-field.js +8 -3
- package/dist/fields/image/image-field.js +6 -4
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +8 -2
- package/dist/fields/relation/relation-many-field.js +12 -4
- package/dist/fields/relation/relation-picker.js +38 -7
- package/dist/fields/relation/relation-picker.module.js +3 -0
- package/dist/fields/relation/relation-picker_module.css +20 -0
- package/dist/fields/select/select-field.js +4 -2
- package/dist/fields/text/text-field.js +6 -3
- package/dist/fields/text-area/text-area-field.js +6 -3
- package/dist/fields/use-field-change-handler.js +24 -6
- package/dist/fields/use-field-condition.d.ts +0 -15
- package/dist/fields/use-field-condition.js +10 -1
- package/dist/forms/form-context.d.ts +3 -12
- package/dist/forms/form-context.js +115 -130
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout.js +10 -3
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +13 -56
- package/dist/forms/nested-path.d.ts +2 -0
- package/dist/forms/nested-path.js +26 -1
- package/dist/forms/use-document-reload-recovery.d.ts +22 -0
- package/dist/forms/use-document-reload-recovery.js +67 -0
- package/dist/forms/use-form-submission.d.ts +1 -1
- package/dist/forms/use-form-submission.js +45 -27
- package/dist/forms/use-form-tabs.d.ts +2 -2
- package/dist/forms/use-form-tabs.js +18 -11
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +14 -14
- package/src/fields/array/array-field.tsx +24 -12
- package/src/fields/blocks/blocks-field.tsx +18 -8
- package/src/fields/checkbox/checkbox-field.tsx +4 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +5 -2
- package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
- package/src/fields/field-dom-ids.test.tsx +293 -0
- package/src/fields/field-renderer.tsx +2 -1
- package/src/fields/field-services-types.ts +25 -0
- package/src/fields/file/file-field.tsx +6 -4
- package/src/fields/group/group-field.tsx +5 -3
- package/src/fields/image/image-field.tsx +6 -4
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +8 -2
- package/src/fields/relation/relation-many-field.tsx +19 -4
- package/src/fields/relation/relation-picker-create.test.tsx +203 -0
- package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
- package/src/fields/relation/relation-picker.module.css +32 -0
- package/src/fields/relation/relation-picker.tsx +59 -1
- package/src/fields/select/select-field.tsx +4 -2
- package/src/fields/text/text-field.tsx +5 -3
- package/src/fields/text-area/text-area-field.tsx +5 -3
- package/src/fields/use-field-change-handler.ts +43 -9
- package/src/fields/use-field-condition.ts +17 -1
- package/src/forms/form-context.tsx +180 -234
- package/src/forms/form-correctness.test.tsx +560 -0
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout.tsx +10 -3
- package/src/forms/form-page-chrome.tsx +17 -34
- package/src/forms/form-renderer-submit.test.tsx +36 -0
- package/src/forms/form-renderer.tsx +26 -64
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/nested-path.ts +35 -0
- package/src/forms/use-document-reload-recovery.ts +92 -0
- package/src/forms/use-form-submission.ts +56 -27
- package/src/forms/use-form-tabs.ts +25 -17
- package/src/presentation/tab-dom-ids.test.tsx +153 -0
- package/src/presentation/tabs.test.tsx +49 -2
- package/src/presentation/tabs.tsx +11 -1
- package/src/react.ts +2 -0
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
/** Reload admission and focus recovery remain synchronous at event boundaries. */
|
|
9
|
+
export declare function useDocumentReloadRecovery({ mutationsBlocked, mutationIssue, onReloadDocument, isBusy, }: {
|
|
10
|
+
mutationsBlocked: boolean;
|
|
11
|
+
mutationIssue?: string | null;
|
|
12
|
+
onReloadDocument?: () => void | Promise<void>;
|
|
13
|
+
isBusy: boolean;
|
|
14
|
+
}): {
|
|
15
|
+
formRef: import("react").RefObject<HTMLFormElement | null>;
|
|
16
|
+
warningRef: import("react").RefObject<HTMLDivElement | null>;
|
|
17
|
+
mutationBlockedRef: import("react").RefObject<boolean>;
|
|
18
|
+
discarding: boolean;
|
|
19
|
+
reloadFailed: boolean;
|
|
20
|
+
requestDiscard: () => void;
|
|
21
|
+
captureFocusBeforeBusy: () => void;
|
|
22
|
+
};
|
|
@@ -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,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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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))
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
|
29
|
+
}>, activeTabBySet: Record<string, string>, tabSets: TabSetDefinition[]): UseFormTabsResult;
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
11
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
@@ -30,6 +30,7 @@ const AdminTabs = ({ tabs, activeTab, onChange, idBase, errorCounts, className }
|
|
|
30
30
|
const tabRefs = useRef(new Map());
|
|
31
31
|
const focusAfterChange = useRef(null);
|
|
32
32
|
const [strip, setStrip] = useState(INITIAL_STRIP_STATE);
|
|
33
|
+
const tabsKey = tabs.map((tab)=>`${tab.name}:${tab.label}`).join('\u0000');
|
|
33
34
|
const measure = useCallback(()=>{
|
|
34
35
|
const container = containerRef.current;
|
|
35
36
|
const row = rowRef.current;
|
|
@@ -54,7 +55,7 @@ const AdminTabs = ({ tabs, activeTab, onChange, idBase, errorCounts, className }
|
|
|
54
55
|
return ()=>observer.disconnect();
|
|
55
56
|
}, [
|
|
56
57
|
measure,
|
|
57
|
-
|
|
58
|
+
tabsKey
|
|
58
59
|
]);
|
|
59
60
|
const reveal = useCallback((name)=>{
|
|
60
61
|
const viewport = viewportRef.current;
|
package/dist/react.d.ts
CHANGED
|
@@ -67,4 +67,4 @@ export * from './presentation/tabs.js';
|
|
|
67
67
|
export * from './widgets/diff-viewer/diff-modal.js';
|
|
68
68
|
export * from './widgets/source-locale-badge/source-locale-badge.js';
|
|
69
69
|
export * from './widgets/status-badge/status-badge.js';
|
|
70
|
-
export type { BylineFieldServices, CollectionListDoc, CollectionListParams, CollectionListResponse, GetCollectionDocumentsFn, GetTreeAncestorsFn, GetTreeParentFn, PlaceTreeNodeFn, PlaceTreeNodeInput, RemoveFromTreeFn, TreeAncestor, UploadedFileResult, UploadFieldFn, } from './fields/field-services-types.js';
|
|
70
|
+
export type { BylineFieldServices, CanCreateInCollectionFn, CollectionListDoc, CollectionListParams, CollectionListResponse, GetCollectionDocumentsFn, GetCreateDocumentUrlFn, GetTreeAncestorsFn, GetTreeParentFn, PlaceTreeNodeFn, PlaceTreeNodeInput, RemoveFromTreeFn, TreeAncestor, UploadedFileResult, UploadFieldFn, } from './fields/field-services-types.js';
|
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.
|
|
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.
|
|
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.
|
|
178
|
-
"@codemirror/view": "^6.43.
|
|
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.
|
|
186
|
-
"@byline/analytics": "6.
|
|
187
|
-
"@byline/
|
|
188
|
-
"@byline/
|
|
189
|
-
"@byline/core": "6.
|
|
190
|
-
"@byline/
|
|
191
|
-
"@byline/
|
|
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.
|
|
201
|
-
"@types/node": "^26.
|
|
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.
|
|
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={
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
}
|