@byline/admin 6.2.0 → 6.3.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/checkbox/checkbox-field.js +3 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +4 -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 +3 -2
- package/dist/fields/group/group-field.js +3 -1
- package/dist/fields/image/image-field.js +3 -2
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +3 -2
- package/dist/fields/relation/relation-many-field.js +3 -2
- 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 +3 -2
- package/dist/fields/text/text-field.js +2 -1
- package/dist/fields/text-area/text-area-field.js +2 -1
- package/dist/forms/form-context.js +4 -1
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
- package/dist/forms/form-layout.d.ts +39 -0
- package/dist/forms/form-layout.js +102 -0
- package/dist/forms/form-page-chrome.d.ts +110 -0
- package/dist/forms/form-page-chrome.js +253 -0
- package/dist/forms/form-renderer.d.ts +2 -18
- package/dist/forms/form-renderer.js +105 -423
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +13 -0
- package/dist/forms/use-form-submission.d.ts +74 -0
- package/dist/forms/use-form-submission.js +166 -0
- package/dist/forms/use-form-tabs.d.ts +29 -0
- package/dist/forms/use-form-tabs.js +45 -0
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +7 -7
- package/src/fields/checkbox/checkbox-field.tsx +3 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +4 -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 +3 -2
- package/src/fields/group/group-field.tsx +3 -1
- package/src/fields/image/image-field.tsx +3 -2
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +3 -2
- package/src/fields/relation/relation-many-field.tsx +3 -2
- 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 +3 -2
- package/src/fields/text/text-field.tsx +2 -1
- package/src/fields/text-area/text-area-field.tsx +2 -1
- package/src/forms/form-context.tsx +2 -1
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout-styles.test.node.ts +32 -0
- package/src/forms/form-layout.test.tsx +276 -0
- package/src/forms/form-layout.tsx +204 -0
- package/src/forms/form-page-chrome.tsx +460 -0
- package/src/forms/form-renderer-submit.test.tsx +40 -0
- package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
- package/src/forms/form-renderer.module.css +18 -0
- package/src/forms/form-renderer.tsx +126 -600
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/use-form-submission.test.tsx +564 -0
- package/src/forms/use-form-submission.ts +293 -0
- package/src/forms/use-form-tabs.ts +89 -0
- 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,74 @@
|
|
|
1
|
+
import type { Field } from '@byline/core';
|
|
2
|
+
import type { DocumentPatch } from '@byline/core/patches';
|
|
3
|
+
/**
|
|
4
|
+
* Payload emitted by the form on Save. Carries the content (field data +
|
|
5
|
+
* patches) alongside the document-grain system fields (path / advertised
|
|
6
|
+
* locales) and per-bucket dirty flags so the host can route each piece to the
|
|
7
|
+
* right write path — versioned for content, immediate/non-versioned for the
|
|
8
|
+
* system fields. See docs/08-internationalization/index.md.
|
|
9
|
+
*/
|
|
10
|
+
export interface SystemFieldsSubmitPayload {
|
|
11
|
+
data: any;
|
|
12
|
+
patches: DocumentPatch[];
|
|
13
|
+
contentDirty: boolean;
|
|
14
|
+
pathDirty: boolean;
|
|
15
|
+
systemPath?: string | null;
|
|
16
|
+
availableLocalesDirty: boolean;
|
|
17
|
+
systemAvailableLocales?: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Where a save has got to. Mutually exclusive by construction: the form cannot
|
|
21
|
+
* be validating and uploading at once, which two independent booleans allowed.
|
|
22
|
+
*/
|
|
23
|
+
export type SubmissionPhase = {
|
|
24
|
+
kind: 'idle';
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'validating';
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'uploading';
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'confirmingSystemFields';
|
|
31
|
+
payload: SystemFieldsSubmitPayload;
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'submitting';
|
|
34
|
+
};
|
|
35
|
+
export interface UseFormSubmissionOptions {
|
|
36
|
+
mode: 'create' | 'edit';
|
|
37
|
+
fields: Field[];
|
|
38
|
+
/** Persisted document id (edit mode only); feeds server-side upload hooks. */
|
|
39
|
+
documentId?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Only collections that opted into the advertised-locale widget emit the
|
|
42
|
+
* locale set, so the write path never touches
|
|
43
|
+
* `byline_document_available_locales` for the others.
|
|
44
|
+
*/
|
|
45
|
+
advertiseLocales?: boolean;
|
|
46
|
+
onSubmit: (payload: SystemFieldsSubmitPayload) => void | Promise<void>;
|
|
47
|
+
/** Mutations blocked (stale/lock/discarding). Read synchronously, per call. */
|
|
48
|
+
isBlocked: () => boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Called immediately before the form becomes `inert` — once before uploads
|
|
51
|
+
* and once before submission — so the caller can record the focused element
|
|
52
|
+
* and restore it when busy ends. The DOM refs stay in the component; the
|
|
53
|
+
* hook only signals the transition.
|
|
54
|
+
*/
|
|
55
|
+
onBeforeBusy?: () => void;
|
|
56
|
+
}
|
|
57
|
+
export interface UseFormSubmissionResult {
|
|
58
|
+
phase: SubmissionPhase;
|
|
59
|
+
isBusy: boolean;
|
|
60
|
+
submit: () => Promise<void>;
|
|
61
|
+
confirmSystemFields: () => Promise<void>;
|
|
62
|
+
cancelSystemFields: () => void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Owns one save: validate → upload → (confirm) → submit.
|
|
66
|
+
*
|
|
67
|
+
* Admission is decided by a ref, not by `phase`. Rendered state is not a
|
|
68
|
+
* re-entry guard: two `submit()` calls in the same turn both observe
|
|
69
|
+
* `phase.kind === 'idle'` because React has not rerendered between them. The
|
|
70
|
+
* ref is set synchronously at the top of `submit()` and cleared in `finally`,
|
|
71
|
+
* spanning validation through submission — the whole window, not just the
|
|
72
|
+
* final request. `phase` drives the UI; the ref decides admission.
|
|
73
|
+
*/
|
|
74
|
+
export declare function useFormSubmission(options: UseFormSubmissionOptions): UseFormSubmissionResult;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
4
|
+
import { useBylineFieldServices } from "../fields/field-services-context.js";
|
|
5
|
+
import { useFormContext } from "./form-context.js";
|
|
6
|
+
import { executeUploadsWithProgress } from "./upload-executor.js";
|
|
7
|
+
const IDLE = {
|
|
8
|
+
kind: 'idle'
|
|
9
|
+
};
|
|
10
|
+
function useFormSubmission(options) {
|
|
11
|
+
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 { t } = useTranslation('byline-admin');
|
|
14
|
+
const { uploadField } = useBylineFieldServices();
|
|
15
|
+
const [phase, setPhase] = useState(IDLE);
|
|
16
|
+
const inFlightRef = useRef(false);
|
|
17
|
+
const isBusy = 'uploading' === phase.kind || 'submitting' === phase.kind;
|
|
18
|
+
const deliver = useCallback(async (payload)=>{
|
|
19
|
+
if (isBlocked() || 'function' != typeof onSubmit) return void setPhase(IDLE);
|
|
20
|
+
onBeforeBusy?.();
|
|
21
|
+
setPhase({
|
|
22
|
+
kind: 'submitting'
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
await onSubmit(payload);
|
|
26
|
+
resetHasChanges();
|
|
27
|
+
} catch {} finally{
|
|
28
|
+
setPhase(IDLE);
|
|
29
|
+
}
|
|
30
|
+
}, [
|
|
31
|
+
isBlocked,
|
|
32
|
+
onBeforeBusy,
|
|
33
|
+
onSubmit,
|
|
34
|
+
resetHasChanges
|
|
35
|
+
]);
|
|
36
|
+
const runUploads = useCallback(async ()=>{
|
|
37
|
+
const pendingUploads = getPendingUploads();
|
|
38
|
+
if (0 === pendingUploads.size) return true;
|
|
39
|
+
onBeforeBusy?.();
|
|
40
|
+
setPhase({
|
|
41
|
+
kind: 'uploading'
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const uploadResult = await executeUploadsWithProgress(pendingUploads, uploadField, ({ fieldPath, status })=>{
|
|
45
|
+
setFieldUploading(fieldPath, 'uploading' === status);
|
|
46
|
+
}, {
|
|
47
|
+
documentId: 'edit' === mode ? documentId : void 0,
|
|
48
|
+
fields,
|
|
49
|
+
getFormValues: getFieldValues
|
|
50
|
+
});
|
|
51
|
+
if (!uploadResult.allSucceeded) {
|
|
52
|
+
for (const [fieldPath, errorMessage] of uploadResult.errors.entries())setFieldError(fieldPath, t('forms.uploadFailedFieldError', {
|
|
53
|
+
message: errorMessage
|
|
54
|
+
}));
|
|
55
|
+
console.error('One or more uploads failed:', uploadResult.errors);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
for (const [fieldPath, storedFile] of uploadResult.successful.entries())setFieldValue(fieldPath, storedFile);
|
|
59
|
+
clearPendingUploads();
|
|
60
|
+
return true;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('Upload execution error:', err);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}, [
|
|
66
|
+
clearPendingUploads,
|
|
67
|
+
documentId,
|
|
68
|
+
fields,
|
|
69
|
+
getFieldValues,
|
|
70
|
+
getPendingUploads,
|
|
71
|
+
mode,
|
|
72
|
+
onBeforeBusy,
|
|
73
|
+
setFieldError,
|
|
74
|
+
setFieldUploading,
|
|
75
|
+
setFieldValue,
|
|
76
|
+
t,
|
|
77
|
+
uploadField
|
|
78
|
+
]);
|
|
79
|
+
const buildPayload = useCallback(()=>{
|
|
80
|
+
const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown();
|
|
81
|
+
return {
|
|
82
|
+
reason,
|
|
83
|
+
payload: {
|
|
84
|
+
data: getFieldValues(),
|
|
85
|
+
patches: getPatches(),
|
|
86
|
+
contentDirty,
|
|
87
|
+
pathDirty,
|
|
88
|
+
systemPath: getSystemPath(),
|
|
89
|
+
availableLocalesDirty,
|
|
90
|
+
systemAvailableLocales: advertiseLocales ? getSystemAvailableLocales() : void 0
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}, [
|
|
94
|
+
advertiseLocales,
|
|
95
|
+
getDirtyBreakdown,
|
|
96
|
+
getFieldValues,
|
|
97
|
+
getPatches,
|
|
98
|
+
getSystemAvailableLocales,
|
|
99
|
+
getSystemPath
|
|
100
|
+
]);
|
|
101
|
+
const submit = useCallback(async ()=>{
|
|
102
|
+
if (isBlocked()) return;
|
|
103
|
+
if (inFlightRef.current) return;
|
|
104
|
+
inFlightRef.current = true;
|
|
105
|
+
try {
|
|
106
|
+
setPhase({
|
|
107
|
+
kind: 'validating'
|
|
108
|
+
});
|
|
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
|
+
}
|
|
120
|
+
if (isBlocked()) return void setPhase(IDLE);
|
|
121
|
+
if (!await runUploads()) return void setPhase(IDLE);
|
|
122
|
+
const { payload, reason } = buildPayload();
|
|
123
|
+
if ('edit' === mode && ('direct-write' === reason || 'both' === reason)) return void setPhase({
|
|
124
|
+
kind: 'confirmingSystemFields',
|
|
125
|
+
payload
|
|
126
|
+
});
|
|
127
|
+
await deliver(payload);
|
|
128
|
+
} finally{
|
|
129
|
+
inFlightRef.current = false;
|
|
130
|
+
}
|
|
131
|
+
}, [
|
|
132
|
+
buildPayload,
|
|
133
|
+
deliver,
|
|
134
|
+
fields,
|
|
135
|
+
isBlocked,
|
|
136
|
+
mode,
|
|
137
|
+
runUploads,
|
|
138
|
+
runFieldHooks,
|
|
139
|
+
validateForm
|
|
140
|
+
]);
|
|
141
|
+
const confirmSystemFields = useCallback(async ()=>{
|
|
142
|
+
if ('confirmingSystemFields' !== phase.kind) return;
|
|
143
|
+
if (inFlightRef.current) return;
|
|
144
|
+
inFlightRef.current = true;
|
|
145
|
+
const { payload } = phase;
|
|
146
|
+
try {
|
|
147
|
+
await deliver(payload);
|
|
148
|
+
} finally{
|
|
149
|
+
inFlightRef.current = false;
|
|
150
|
+
}
|
|
151
|
+
}, [
|
|
152
|
+
deliver,
|
|
153
|
+
phase
|
|
154
|
+
]);
|
|
155
|
+
const cancelSystemFields = useCallback(()=>{
|
|
156
|
+
setPhase((current)=>'confirmingSystemFields' === current.kind ? IDLE : current);
|
|
157
|
+
}, []);
|
|
158
|
+
return {
|
|
159
|
+
phase,
|
|
160
|
+
isBusy,
|
|
161
|
+
submit,
|
|
162
|
+
confirmSystemFields,
|
|
163
|
+
cancelSystemFields
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export { useFormSubmission };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TabDefinition, TabSetDefinition } from '@byline/core';
|
|
2
|
+
/** One tab set, resolved against live form data and current errors. */
|
|
3
|
+
export interface ResolvedTabSet {
|
|
4
|
+
/** Tabs whose `condition` is satisfied, in declaration order. */
|
|
5
|
+
visibleTabs: TabDefinition[];
|
|
6
|
+
/** The tab that should render, after falling back off a hidden selection. */
|
|
7
|
+
activeTabName: string;
|
|
8
|
+
activeTab: TabDefinition | undefined;
|
|
9
|
+
/** Error count per tab name, for the tab bar's badges. */
|
|
10
|
+
errorCounts: Record<string, number> | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface UseFormTabsResult {
|
|
13
|
+
resolve: (set: TabSetDefinition) => ResolvedTabSet;
|
|
14
|
+
errorCountsBySet: Record<string, Record<string, number>>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Tab visibility, per-tab error counts, and fallback when the selected tab is
|
|
18
|
+
* hidden.
|
|
19
|
+
*
|
|
20
|
+
* It deliberately does **not** own the selection. `FormRenderer` lifts that
|
|
21
|
+
* above the keyed `FormProvider` so the editor's tab choices survive the
|
|
22
|
+
* remount a locale change triggers; a hook living inside that subtree would
|
|
23
|
+
* lose them on every switch. The selection arrives as `activeTabBySet` and
|
|
24
|
+
* changes are reported upward.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useFormTabs(fieldToTabPath: Map<string, {
|
|
27
|
+
tabSetName: string;
|
|
28
|
+
tabName: string;
|
|
29
|
+
}>, activeTabBySet: Record<string, string>): UseFormTabsResult;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
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
|
|
9
|
+
]);
|
|
10
|
+
const [formData, setFormData] = useState(()=>getFieldValues());
|
|
11
|
+
useEffect(()=>subscribeMeta(()=>setFormData(getFieldValues())), [
|
|
12
|
+
subscribeMeta,
|
|
13
|
+
getFieldValues
|
|
14
|
+
]);
|
|
15
|
+
const errorCountsBySet = useMemo(()=>{
|
|
16
|
+
const result = {};
|
|
17
|
+
for (const err of errors){
|
|
18
|
+
const path = fieldToTabPath.get(err.field);
|
|
19
|
+
if (path) {
|
|
20
|
+
result[path.tabSetName] ??= {};
|
|
21
|
+
result[path.tabSetName][path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}, [
|
|
26
|
+
errors,
|
|
27
|
+
fieldToTabPath
|
|
28
|
+
]);
|
|
29
|
+
const resolve = (set)=>{
|
|
30
|
+
const visibleTabs = set.tabs.filter((tab)=>!tab.condition || tab.condition(formData));
|
|
31
|
+
const requested = activeTabBySet[set.name] ?? '';
|
|
32
|
+
const activeTabName = visibleTabs.length > 0 && !visibleTabs.some((t)=>t.name === requested) ? visibleTabs[0]?.name ?? requested : requested;
|
|
33
|
+
return {
|
|
34
|
+
visibleTabs,
|
|
35
|
+
activeTabName,
|
|
36
|
+
activeTab: visibleTabs.find((t)=>t.name === activeTabName),
|
|
37
|
+
errorCounts: errorCountsBySet[set.name]
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
resolve,
|
|
42
|
+
errorCountsBySet
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export { useFormTabs };
|
|
@@ -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.3.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -183,12 +183,12 @@
|
|
|
183
183
|
"react-diff-viewer-continued": "^4.4.0",
|
|
184
184
|
"uuid": "^14.0.2",
|
|
185
185
|
"zod": "^4.6.2",
|
|
186
|
-
"@byline/analytics": "6.
|
|
187
|
-
"@byline/
|
|
188
|
-
"@byline/
|
|
189
|
-
"@byline/auth": "6.
|
|
190
|
-
"@byline/
|
|
191
|
-
"@byline/
|
|
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"
|
|
192
192
|
},
|
|
193
193
|
"peerDependencies": {
|
|
194
194
|
"react": "^19.0.0",
|
|
@@ -10,6 +10,7 @@ import type { CheckboxField as FieldType } from '@byline/core'
|
|
|
10
10
|
import { Checkbox } from '@byline/ui/react'
|
|
11
11
|
|
|
12
12
|
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
13
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
13
14
|
import styles from './checkbox-field.module.css'
|
|
14
15
|
|
|
15
16
|
export const CheckboxField = ({
|
|
@@ -28,6 +29,7 @@ export const CheckboxField = ({
|
|
|
28
29
|
path?: string
|
|
29
30
|
}) => {
|
|
30
31
|
const fieldPath = path ?? field.name
|
|
32
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
31
33
|
const fieldError = useFieldError(fieldPath)
|
|
32
34
|
const fieldValue = useFieldValue<boolean | undefined>(fieldPath)
|
|
33
35
|
const checked = value ?? fieldValue ?? defaultValue ?? false
|
|
@@ -35,7 +37,7 @@ export const CheckboxField = ({
|
|
|
35
37
|
return (
|
|
36
38
|
<div className={`byline-field-checkbox ${field.name} ${styles['field-checkbox']}`}>
|
|
37
39
|
<Checkbox
|
|
38
|
-
id={
|
|
40
|
+
id={htmlId}
|
|
39
41
|
name={field.name}
|
|
40
42
|
label={field.label}
|
|
41
43
|
checked={checked}
|
|
@@ -13,6 +13,7 @@ import { ErrorText, HelpText, Label } from '@byline/ui/react'
|
|
|
13
13
|
import cx from 'clsx'
|
|
14
14
|
|
|
15
15
|
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
16
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
16
17
|
import { LocaleBadge } from '../locale-badge'
|
|
17
18
|
import styles from './code-field.module.css'
|
|
18
19
|
|
|
@@ -57,7 +58,7 @@ export const CodeField = ({
|
|
|
57
58
|
const fieldError = useFieldError(fieldPath)
|
|
58
59
|
const fieldValue = useFieldValue<string | undefined>(fieldPath)
|
|
59
60
|
const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
|
|
60
|
-
const htmlId = id
|
|
61
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
61
62
|
|
|
62
63
|
// Effective highlight language: a sibling `languageField` selection (e.g.
|
|
63
64
|
// a `select` next to the code field) wins over the schema's static
|
|
@@ -11,6 +11,7 @@ import { DatePicker, ErrorText } from '@byline/ui/react'
|
|
|
11
11
|
import cx from 'clsx'
|
|
12
12
|
|
|
13
13
|
import { useFieldError, useFieldValue, useIsDirty } from '../../forms/form-context'
|
|
14
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
14
15
|
import styles from './datetime-field.module.css'
|
|
15
16
|
|
|
16
17
|
export const DateTimeField = ({
|
|
@@ -29,6 +30,7 @@ export const DateTimeField = ({
|
|
|
29
30
|
path?: string
|
|
30
31
|
}) => {
|
|
31
32
|
const fieldPath = path ?? field.name
|
|
33
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
32
34
|
const fieldError = useFieldError(fieldPath)
|
|
33
35
|
const isDirty = useIsDirty(fieldPath)
|
|
34
36
|
const fieldValue = useFieldValue<Date | null | undefined>(fieldPath)
|
|
@@ -37,7 +39,7 @@ export const DateTimeField = ({
|
|
|
37
39
|
return (
|
|
38
40
|
<div className={`byline-field-datetime ${field.name}`}>
|
|
39
41
|
<DatePicker
|
|
40
|
-
id={
|
|
42
|
+
id={htmlId}
|
|
41
43
|
name={field.name}
|
|
42
44
|
label={field.label}
|
|
43
45
|
required={!field.optional}
|
|
@@ -48,7 +50,7 @@ export const DateTimeField = ({
|
|
|
48
50
|
onDateChange={(date) => onChange?.(date)}
|
|
49
51
|
className={cx(isDirty && ['byline-field-datetime-dirty', styles.dirty])}
|
|
50
52
|
/>
|
|
51
|
-
{fieldError && <ErrorText id={`${
|
|
53
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
52
54
|
</div>
|
|
53
55
|
)
|
|
54
56
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { act } from 'react'
|
|
10
|
+
|
|
11
|
+
import type { Field } from '@byline/core'
|
|
12
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
13
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
14
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
16
|
+
|
|
17
|
+
import { FormProvider } from '../forms/form-context'
|
|
18
|
+
import { CheckboxField } from './checkbox/checkbox-field'
|
|
19
|
+
import { DateTimeField } from './datetime/datetime-field'
|
|
20
|
+
import { TextField } from './text/text-field'
|
|
21
|
+
|
|
22
|
+
const globalWithAct = globalThis as any
|
|
23
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
24
|
+
|
|
25
|
+
let container: HTMLDivElement
|
|
26
|
+
let root: Root
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
container = document.createElement('div')
|
|
30
|
+
document.body.appendChild(container)
|
|
31
|
+
root = createRoot(container)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
act(() => {
|
|
36
|
+
root.unmount()
|
|
37
|
+
})
|
|
38
|
+
container.remove()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* These widgets are re-exported from `@byline/admin/react`, so a host can compose
|
|
43
|
+
* them directly rather than through `FieldRenderer`. Going through `FieldRenderer`
|
|
44
|
+
* would hand them an already-scoped `id` and prove nothing about their own
|
|
45
|
+
* fallback, which is the path a direct consumer takes.
|
|
46
|
+
*/
|
|
47
|
+
const renderDirect = (node: (formName: string) => React.ReactNode) => {
|
|
48
|
+
act(() => {
|
|
49
|
+
root.render(
|
|
50
|
+
<I18nProvider
|
|
51
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
52
|
+
activeLocale="en"
|
|
53
|
+
defaultLocale="en"
|
|
54
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
55
|
+
>
|
|
56
|
+
{['parent', 'child'].map((formName) => (
|
|
57
|
+
<form aria-label={formName} key={formName}>
|
|
58
|
+
<FormProvider initialData={{ fields: {} }} collectionPath="pages">
|
|
59
|
+
{node(formName)}
|
|
60
|
+
</FormProvider>
|
|
61
|
+
</form>
|
|
62
|
+
))}
|
|
63
|
+
</I18nProvider>
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const controlIds = (selector: string) =>
|
|
69
|
+
Array.from(container.querySelectorAll(selector)).map((node) => node.id)
|
|
70
|
+
|
|
71
|
+
describe('widgets composed directly under two form providers', () => {
|
|
72
|
+
it('gives a text field distinct ids without FieldRenderer supplying one', () => {
|
|
73
|
+
const field = { name: 'title', label: 'Title', type: 'text' } as Field
|
|
74
|
+
renderDirect(() => <TextField field={field as any} />)
|
|
75
|
+
|
|
76
|
+
const ids = controlIds('input')
|
|
77
|
+
expect(ids).toHaveLength(2)
|
|
78
|
+
expect(ids.every((id) => id !== '')).toBe(true)
|
|
79
|
+
expect(new Set(ids).size).toBe(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('gives a checkbox distinct ids', () => {
|
|
83
|
+
const field = { name: 'featured', label: 'Featured', type: 'checkbox' } as Field
|
|
84
|
+
renderDirect(() => <CheckboxField field={field as any} />)
|
|
85
|
+
|
|
86
|
+
const ids = Array.from(container.querySelectorAll('[role="checkbox"], input[type="checkbox"]'))
|
|
87
|
+
.map((node) => node.id)
|
|
88
|
+
.filter((id) => id !== '')
|
|
89
|
+
|
|
90
|
+
expect(ids).toHaveLength(2)
|
|
91
|
+
expect(new Set(ids).size).toBe(2)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('gives a datetime control distinct ids on the control itself', () => {
|
|
95
|
+
const field = { name: 'publishedOn', label: 'Published on', type: 'datetime' } as Field
|
|
96
|
+
renderDirect(() => <DateTimeField field={field as any} />)
|
|
97
|
+
|
|
98
|
+
// The scoped id must reach the control the label points at, not only the
|
|
99
|
+
// error element — the first revision scoped the error and left the input.
|
|
100
|
+
const ids = controlIds('input').filter((id) => id !== '')
|
|
101
|
+
expect(ids.length).toBeGreaterThanOrEqual(2)
|
|
102
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('associates each directly composed label with the control in its own form', () => {
|
|
106
|
+
const field = { name: 'title', label: 'Title', type: 'text' } as Field
|
|
107
|
+
renderDirect(() => <TextField field={field as any} />)
|
|
108
|
+
|
|
109
|
+
const labels = Array.from(container.querySelectorAll('label[for]'))
|
|
110
|
+
expect(labels.length).toBeGreaterThanOrEqual(2)
|
|
111
|
+
|
|
112
|
+
for (const label of labels) {
|
|
113
|
+
const target = document.getElementById(label.getAttribute('for') as string)
|
|
114
|
+
expect(target).not.toBeNull()
|
|
115
|
+
expect(target?.closest('form')).toBe(label.closest('form'))
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* An explicit id belongs to the caller: it may already be referenced from
|
|
121
|
+
* outside the form, and scoping it would break that reference. Two forms given
|
|
122
|
+
* the same explicit id therefore still collide — that is the caller's choice,
|
|
123
|
+
* and the test records it as intended rather than accidental.
|
|
124
|
+
*/
|
|
125
|
+
it('passes a caller-supplied id through unchanged in every direct widget', () => {
|
|
126
|
+
const text = { name: 'title', label: 'Title', type: 'text' } as Field
|
|
127
|
+
renderDirect(() => <TextField field={text as any} id="explicit-text" />)
|
|
128
|
+
expect(controlIds('input')).toEqual(['explicit-text', 'explicit-text'])
|
|
129
|
+
|
|
130
|
+
const checkbox = { name: 'featured', label: 'Featured', type: 'checkbox' } as Field
|
|
131
|
+
renderDirect(() => <CheckboxField field={checkbox as any} id="explicit-checkbox" />)
|
|
132
|
+
expect(
|
|
133
|
+
Array.from(container.querySelectorAll('[id="explicit-checkbox"]')).length
|
|
134
|
+
).toBeGreaterThanOrEqual(2)
|
|
135
|
+
|
|
136
|
+
const datetime = { name: 'publishedOn', label: 'On', type: 'datetime' } as Field
|
|
137
|
+
renderDirect(() => <DateTimeField field={datetime as any} id="explicit-datetime" />)
|
|
138
|
+
expect(
|
|
139
|
+
Array.from(container.querySelectorAll('[id="explicit-datetime"]')).length
|
|
140
|
+
).toBeGreaterThanOrEqual(2)
|
|
141
|
+
})
|
|
142
|
+
})
|