@byline/admin 6.2.0 → 6.2.1
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/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 +100 -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 +104 -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/package.json +7 -7
- 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 +198 -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 +115 -600
- 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
|
@@ -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 };
|
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.2.
|
|
5
|
+
"version": "6.2.1",
|
|
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.2.
|
|
187
|
-
"@byline/
|
|
188
|
-
"@byline/
|
|
189
|
-
"@byline/
|
|
190
|
-
"@byline/
|
|
191
|
-
"@byline/
|
|
186
|
+
"@byline/analytics": "6.2.1",
|
|
187
|
+
"@byline/auth": "6.2.1",
|
|
188
|
+
"@byline/ui": "6.2.1",
|
|
189
|
+
"@byline/core": "6.2.1",
|
|
190
|
+
"@byline/analytics-agent": "6.2.1",
|
|
191
|
+
"@byline/i18n": "6.2.1"
|
|
192
192
|
},
|
|
193
193
|
"peerDependencies": {
|
|
194
194
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest'
|
|
13
|
+
|
|
14
|
+
// jsdom applies no stylesheet, so the one-column guarantee of the stacked
|
|
15
|
+
// variant cannot be asserted by rendering. This reads the stylesheet directly:
|
|
16
|
+
// the class has to exist, and it must not be swept into the two-column grid
|
|
17
|
+
// rule that the split layout gets at >=60rem.
|
|
18
|
+
const css = readFileSync(join(__dirname, 'form-renderer.module.css'), 'utf8')
|
|
19
|
+
|
|
20
|
+
describe('stacked form layout styles', () => {
|
|
21
|
+
it('defines the stacked layout class', () => {
|
|
22
|
+
expect(css).toMatch(/\.layout-stacked\b/)
|
|
23
|
+
expect(css).toMatch(/:global\(\.byline-form-layout-stacked\)/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('does not put the stacked class in the two-column grid rule', () => {
|
|
27
|
+
const desktop = css.slice(css.indexOf('@media (min-width: 60rem)'))
|
|
28
|
+
const gridRule = desktop.slice(0, desktop.indexOf('}\n}') + 3)
|
|
29
|
+
expect(gridRule).toContain('grid-template-columns')
|
|
30
|
+
expect(gridRule).not.toContain('layout-stacked')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,276 @@
|
|
|
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 { AdminResourceConfig, Field } from '@byline/core'
|
|
12
|
+
import { defineAdminConfig } from '@byline/core'
|
|
13
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
14
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
15
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
17
|
+
|
|
18
|
+
import { BylineFieldServicesProvider } from '../fields/field-services-context'
|
|
19
|
+
import { FormProvider } from './form-context'
|
|
20
|
+
import { FormLayout } from './form-layout'
|
|
21
|
+
|
|
22
|
+
// Deliberately not the `;(globalThis as any)` idiom used elsewhere in this
|
|
23
|
+
// suite: Biome's import sorting can move an import across that leading
|
|
24
|
+
// semicolon, and without semicolons the result parses as a call on the import.
|
|
25
|
+
const globalWithAct = globalThis as any
|
|
26
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
27
|
+
|
|
28
|
+
defineAdminConfig({
|
|
29
|
+
i18n: {
|
|
30
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
31
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
32
|
+
},
|
|
33
|
+
collections: [
|
|
34
|
+
{
|
|
35
|
+
path: 'pages',
|
|
36
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
37
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const fields: Field[] = [
|
|
44
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
45
|
+
{ name: 'featured', label: 'Featured', type: 'text' },
|
|
46
|
+
{ name: 'seoTitle', label: 'SEO title', type: 'text' },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
let container: HTMLDivElement
|
|
50
|
+
let root: Root
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
container = document.createElement('div')
|
|
54
|
+
document.body.appendChild(container)
|
|
55
|
+
root = createRoot(container)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
act(() => {
|
|
60
|
+
root.unmount()
|
|
61
|
+
})
|
|
62
|
+
container.remove()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const render = (props: Partial<React.ComponentProps<typeof FormLayout>> = {}) => {
|
|
66
|
+
act(() => {
|
|
67
|
+
root.render(
|
|
68
|
+
<I18nProvider
|
|
69
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
70
|
+
activeLocale="en"
|
|
71
|
+
defaultLocale="en"
|
|
72
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
73
|
+
>
|
|
74
|
+
<BylineFieldServicesProvider
|
|
75
|
+
services={
|
|
76
|
+
{
|
|
77
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
78
|
+
uploadField: async () => ({}),
|
|
79
|
+
} as any
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
<FormProvider initialData={{ fields: {} }} collectionPath="pages">
|
|
83
|
+
<FormLayout
|
|
84
|
+
fields={fields}
|
|
85
|
+
activeTabBySet={{}}
|
|
86
|
+
onTabChange={() => {}}
|
|
87
|
+
{...(props as any)}
|
|
88
|
+
/>
|
|
89
|
+
</FormProvider>
|
|
90
|
+
</BylineFieldServicesProvider>
|
|
91
|
+
</I18nProvider>
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const config = (c: Partial<AdminResourceConfig>) => c as AdminResourceConfig
|
|
97
|
+
|
|
98
|
+
describe('FormLayout regions', () => {
|
|
99
|
+
it('renders main-region fields', () => {
|
|
100
|
+
render({ adminConfig: config({ layout: { main: ['title'] } }) })
|
|
101
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('renders layout.sidebar schema fields', () => {
|
|
105
|
+
render({ adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }) })
|
|
106
|
+
const sidebar = container.querySelector('.byline-form-sidebar')
|
|
107
|
+
expect(sidebar?.querySelector('input[name="featured"]')).not.toBeNull()
|
|
108
|
+
expect(container.querySelector('.byline-form-content input[name="featured"]')).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('omits a region that was not requested', () => {
|
|
112
|
+
render({
|
|
113
|
+
adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
|
|
114
|
+
regions: ['main'],
|
|
115
|
+
})
|
|
116
|
+
expect(container.querySelector('.byline-form-sidebar')).toBeNull()
|
|
117
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('renders both regions in one column in the stacked variant', () => {
|
|
121
|
+
render({
|
|
122
|
+
adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
|
|
123
|
+
variant: 'stacked',
|
|
124
|
+
})
|
|
125
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
126
|
+
expect(container.querySelector('input[name="featured"]')).not.toBeNull()
|
|
127
|
+
expect(container.querySelector('.byline-form-sidebar')).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('FormLayout region order', () => {
|
|
132
|
+
// The prop is an ordered list, and both the interface and the plan promise
|
|
133
|
+
// order. Reducing it to membership checks silently always renders main first.
|
|
134
|
+
const both = config({ layout: { main: ['title'], sidebar: ['featured'] } })
|
|
135
|
+
|
|
136
|
+
const fieldOrder = () =>
|
|
137
|
+
[...container.querySelectorAll('input')].map((i) => i.getAttribute('name'))
|
|
138
|
+
|
|
139
|
+
it('honours a reversed region order in the split variant', () => {
|
|
140
|
+
render({ adminConfig: both, regions: ['sidebar', 'main'] })
|
|
141
|
+
expect(fieldOrder()).toEqual(['featured', 'title'])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('honours a reversed region order in the stacked variant', () => {
|
|
145
|
+
render({ adminConfig: both, regions: ['sidebar', 'main'], variant: 'stacked' })
|
|
146
|
+
expect(fieldOrder()).toEqual(['featured', 'title'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('keeps the slot ahead of the schema fields within the sidebar', () => {
|
|
150
|
+
render({
|
|
151
|
+
adminConfig: both,
|
|
152
|
+
regions: ['sidebar', 'main'],
|
|
153
|
+
sidebarSlot: <div data-testid="path-widget" />,
|
|
154
|
+
})
|
|
155
|
+
const marks = [...container.querySelectorAll('input, [data-testid="path-widget"]')].map(
|
|
156
|
+
(n) => n.getAttribute('data-testid') ?? n.getAttribute('name')
|
|
157
|
+
)
|
|
158
|
+
expect(marks).toEqual(['path-widget', 'featured', 'title'])
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('FormLayout stacked variant', () => {
|
|
163
|
+
// The split layout's own class becomes a two-column grid at >=60rem
|
|
164
|
+
// (form-renderer.module.css). Carrying it on a stacked form reserves an empty
|
|
165
|
+
// second track, which defeats the one-column variant. jsdom cannot measure
|
|
166
|
+
// that, so the guard is that the split class is not applied at all.
|
|
167
|
+
it('does not carry the two-column split layout class', () => {
|
|
168
|
+
render({
|
|
169
|
+
adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
|
|
170
|
+
variant: 'stacked',
|
|
171
|
+
})
|
|
172
|
+
const root = container.querySelector('[class*="layout"]')
|
|
173
|
+
expect(root?.className).toContain('byline-form-layout-stacked')
|
|
174
|
+
expect(root?.classList.contains('byline-form-layout')).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('keeps the split class on the split variant', () => {
|
|
178
|
+
render({ adminConfig: config({ layout: { main: ['title'] } }) })
|
|
179
|
+
const root = container.querySelector('.byline-form-layout')
|
|
180
|
+
expect(root).not.toBeNull()
|
|
181
|
+
expect(root?.className).not.toContain('stacked')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('FormLayout sidebar slot', () => {
|
|
186
|
+
it('renders page-level widgets before the sidebar schema fields', () => {
|
|
187
|
+
// Deliberate deviation from the plan's Task 2 test, decided by Tony:
|
|
188
|
+
// the current editor renders page widgets first, and Phase 1's acceptance
|
|
189
|
+
// is behaviour preservation. Reversing the order here would visibly move
|
|
190
|
+
// `publishedOn` above the path widget in the shipped docs collection.
|
|
191
|
+
render({
|
|
192
|
+
adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
|
|
193
|
+
sidebarSlot: <div data-testid="path-widget" />,
|
|
194
|
+
})
|
|
195
|
+
const sidebar = container.querySelector('.byline-form-sidebar')
|
|
196
|
+
const order = [...(sidebar?.children ?? [])].map(
|
|
197
|
+
(n) => n.getAttribute('data-testid') ?? n.querySelector('input')?.getAttribute('name') ?? '?'
|
|
198
|
+
)
|
|
199
|
+
expect(order).toEqual(['path-widget', 'featured'])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('renders a sidebar for the slot even when no sidebar fields are declared', () => {
|
|
203
|
+
render({
|
|
204
|
+
adminConfig: config({ layout: { main: ['title'] } }),
|
|
205
|
+
sidebarSlot: <div data-testid="path-widget" />,
|
|
206
|
+
})
|
|
207
|
+
expect(container.querySelector('[data-testid="path-widget"]')).not.toBeNull()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('FormLayout tabs', () => {
|
|
212
|
+
const tabbed = config({
|
|
213
|
+
layout: { main: ['content'] },
|
|
214
|
+
tabSets: [
|
|
215
|
+
{
|
|
216
|
+
name: 'content',
|
|
217
|
+
tabs: [
|
|
218
|
+
{ name: 'general', label: 'General', fields: ['title'] },
|
|
219
|
+
{ name: 'seo', label: 'SEO', fields: ['seoTitle'] },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('renders the tab named by the controlled activeTabBySet prop', () => {
|
|
226
|
+
render({ adminConfig: tabbed, activeTabBySet: { content: 'seo' } })
|
|
227
|
+
expect(container.querySelector('input[name="seoTitle"]')).not.toBeNull()
|
|
228
|
+
expect(container.querySelector('input[name="title"]')).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('falls back to the first tab when the set has no selection', () => {
|
|
232
|
+
render({ adminConfig: tabbed, activeTabBySet: {} })
|
|
233
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('reports a tab change upward rather than selecting it itself', () => {
|
|
237
|
+
// Selection stays owned above the keyed FormProvider so tab choices
|
|
238
|
+
// survive the locale-change remount. FormLayout must not self-select.
|
|
239
|
+
const onTabChange = vi.fn()
|
|
240
|
+
render({ adminConfig: tabbed, activeTabBySet: { content: 'general' }, onTabChange })
|
|
241
|
+
|
|
242
|
+
const seoTab = [...container.querySelectorAll('button')].find((b) => b.textContent === 'SEO')
|
|
243
|
+
if (seoTab == null) throw new Error('SEO tab not found')
|
|
244
|
+
act(() => {
|
|
245
|
+
seoTab.click()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
expect(onTabChange).toHaveBeenCalledWith('content', 'seo')
|
|
249
|
+
expect(container.querySelector('input[name="seoTitle"]')).toBeNull()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('hides a tab whose condition is unmet and falls back to a visible one', () => {
|
|
253
|
+
const conditional = config({
|
|
254
|
+
layout: { main: ['content'] },
|
|
255
|
+
tabSets: [
|
|
256
|
+
{
|
|
257
|
+
name: 'content',
|
|
258
|
+
tabs: [
|
|
259
|
+
{ name: 'general', label: 'General', fields: ['title'] },
|
|
260
|
+
{
|
|
261
|
+
name: 'seo',
|
|
262
|
+
label: 'SEO',
|
|
263
|
+
fields: ['seoTitle'],
|
|
264
|
+
condition: (data: Record<string, any>) => data.title === 'show-seo',
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
})
|
|
270
|
+
render({ adminConfig: conditional, activeTabBySet: { content: 'seo' } })
|
|
271
|
+
|
|
272
|
+
const tabLabels = [...container.querySelectorAll('button')].map((b) => b.textContent)
|
|
273
|
+
expect(tabLabels).not.toContain('SEO')
|
|
274
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
275
|
+
})
|
|
276
|
+
})
|