@gogitcms/design-system 0.16.0-next.6 → 0.16.0-next.8
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gogitcms/design-system",
|
|
3
|
-
"version": "0.16.0-next.
|
|
3
|
+
"version": "0.16.0-next.8",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"types": "src/index.ts",
|
|
6
6
|
"// exports": "The root entry is the react-native source the SPAs, desktop app and mobile app consume. ./web is the plain-DOM build for server-rendered surfaces (the Astro docs site) that don't run react-native-web, and ./css ships the tokens as custom properties. The trailing wildcard keeps deep paths resolvable — plugin bundling and the Vite aliases reach into src/ directly.",
|
|
@@ -146,3 +146,51 @@ test("failure surfaces the error and a dismiss button", () => {
|
|
|
146
146
|
expect(screen.getByTestId("merge-error")).toHaveTextContent("main moved while merging");
|
|
147
147
|
expect(screen.getByTestId("apply-dismiss")).toBeInTheDocument();
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
test("with a review reason the confirmation step promises a review, not a merge commit", () => {
|
|
151
|
+
render(
|
|
152
|
+
wrap(
|
|
153
|
+
<ApplyChangesModal
|
|
154
|
+
{...branches}
|
|
155
|
+
reviewReason="main is protected, so this opens a change request for review."
|
|
156
|
+
onConfirm={() => {}}
|
|
157
|
+
onClose={() => {}}
|
|
158
|
+
/>,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const confirm = screen.getByTestId("apply-confirm");
|
|
163
|
+
// The run will stop before the push, so the copy must not describe one.
|
|
164
|
+
expect(confirm).not.toHaveTextContent("pushes a merge commit");
|
|
165
|
+
expect(confirm).not.toHaveTextContent("is then deleted");
|
|
166
|
+
expect(screen.getByTestId("apply-confirm-review")).toHaveTextContent("opens a pull request for review");
|
|
167
|
+
expect(confirm).toHaveTextContent("Nothing is pushed to origin/main until it is approved");
|
|
168
|
+
expect(confirm).toHaveTextContent("main is protected");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("needs_review without code conflicts explains the review instead of listing files", () => {
|
|
172
|
+
const onCreateChangeRequest = jest.fn();
|
|
173
|
+
render(
|
|
174
|
+
wrap(
|
|
175
|
+
<ApplyChangesModal
|
|
176
|
+
{...branches}
|
|
177
|
+
reviewReason="main is protected, so this opens a change request for review."
|
|
178
|
+
progress={{ ...base, status: "needs_review", step: "merging", escalationFiles: [] }}
|
|
179
|
+
onCreateChangeRequest={onCreateChangeRequest}
|
|
180
|
+
onClose={() => {}}
|
|
181
|
+
/>,
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const panel = screen.getByTestId("merge-needs-review");
|
|
186
|
+
// No conflict: it is not called one, and no empty file list is drawn.
|
|
187
|
+
expect(panel).not.toHaveTextContent("Code conflicts need a developer");
|
|
188
|
+
expect(panel).toHaveTextContent("This merge needs a review");
|
|
189
|
+
expect(screen.getByTestId("merge-needs-review-reason")).toHaveTextContent("nothing was pushed to main");
|
|
190
|
+
expect(screen.getByTestId("merge-needs-review-reason")).toHaveTextContent("main is protected");
|
|
191
|
+
|
|
192
|
+
// The same escalation flow: an explanation, then a change request.
|
|
193
|
+
fireEvent.change(screen.getByTestId("change-request-explanation"), { target: { value: "Spring copy" } });
|
|
194
|
+
fireEvent.click(screen.getByTestId("create-change-request-submit"));
|
|
195
|
+
expect(onCreateChangeRequest).toHaveBeenCalledWith("Spring copy");
|
|
196
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Which field the author is in, reported as a dotted path — what a preview
|
|
2
|
+
// pane uses to ring the matching part of the page. Read off the DOM's focus
|
|
3
|
+
// events and the `data-cms-field` attribute every control's wrapper carries,
|
|
4
|
+
// so it works for every kind of control and for a lone author with no live
|
|
5
|
+
// session.
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
8
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
9
|
+
import { ContentBrowser, type CmsEntry, type CmsNavSection, type EntryField } from "../components/ContentBrowser";
|
|
10
|
+
|
|
11
|
+
jest.mock("../ThemeProvider", () => {
|
|
12
|
+
const actual = jest.requireActual("../ThemeProvider");
|
|
13
|
+
return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
17
|
+
|
|
18
|
+
const sections: CmsNavSection[] = [
|
|
19
|
+
{ title: "Content", items: [{ key: "pages", label: "Pages", icon: "newspaper" }] },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const fields: EntryField[] = [
|
|
23
|
+
{ name: "title", label: "Title", type: "string", value: "Home", required: true },
|
|
24
|
+
{
|
|
25
|
+
name: "blocks",
|
|
26
|
+
label: "Blocks",
|
|
27
|
+
type: "array",
|
|
28
|
+
component: "mixedList",
|
|
29
|
+
value: [{ _variant: "hero", heading: "Hi", stats: [{ label: "a", value: "1" }] }],
|
|
30
|
+
variants: [
|
|
31
|
+
{
|
|
32
|
+
name: "hero",
|
|
33
|
+
fields: [
|
|
34
|
+
{ name: "heading", label: "Heading", type: "string", value: null },
|
|
35
|
+
{
|
|
36
|
+
name: "stats",
|
|
37
|
+
label: "Stats",
|
|
38
|
+
type: "array",
|
|
39
|
+
of: "object",
|
|
40
|
+
value: null,
|
|
41
|
+
fields: [
|
|
42
|
+
{ name: "label", label: "Label", type: "string", value: null },
|
|
43
|
+
{ name: "value", label: "Value", type: "string", value: null },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const entry: CmsEntry = { id: "doc-1", path: "content/pages/home.json", title: "Home", body: "", fields };
|
|
53
|
+
|
|
54
|
+
function renderBrowser(onFieldFocus = jest.fn()) {
|
|
55
|
+
render(
|
|
56
|
+
wrap(
|
|
57
|
+
<ContentBrowser
|
|
58
|
+
workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
|
|
59
|
+
sections={sections}
|
|
60
|
+
activeNavKey="pages"
|
|
61
|
+
onSelectNav={() => {}}
|
|
62
|
+
entries={[entry]}
|
|
63
|
+
userInitials="ED"
|
|
64
|
+
onSaveEntry={jest.fn()}
|
|
65
|
+
onFieldFocus={onFieldFocus}
|
|
66
|
+
/>,
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
return onFieldFocus;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The blur report is deferred a tick so a move between fields never passes
|
|
73
|
+
// through null; flush it.
|
|
74
|
+
const settle = () => act(() => new Promise((r) => setTimeout(r, 5)));
|
|
75
|
+
|
|
76
|
+
describe("onFieldFocus", () => {
|
|
77
|
+
it("reports a top-level field by name and null when it blurs", async () => {
|
|
78
|
+
const onFieldFocus = renderBrowser();
|
|
79
|
+
const title = screen.getByDisplayValue("Home");
|
|
80
|
+
fireEvent.focusIn(title);
|
|
81
|
+
expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "title");
|
|
82
|
+
fireEvent.focusOut(title);
|
|
83
|
+
await settle();
|
|
84
|
+
expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", null);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("composes the path of a field inside a mixed-list item and a nested list", async () => {
|
|
88
|
+
const onFieldFocus = renderBrowser();
|
|
89
|
+
const heading = screen.getByDisplayValue("Hi");
|
|
90
|
+
fireEvent.focusIn(heading);
|
|
91
|
+
expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "blocks.0.heading");
|
|
92
|
+
|
|
93
|
+
const statLabel = screen.getByDisplayValue("a");
|
|
94
|
+
// Straight from one field to the next: no null in between.
|
|
95
|
+
fireEvent.focusOut(heading);
|
|
96
|
+
fireEvent.focusIn(statLabel);
|
|
97
|
+
await settle();
|
|
98
|
+
expect(onFieldFocus).not.toHaveBeenCalledWith("doc-1", null);
|
|
99
|
+
expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "blocks.0.stats.0.label");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("annotates every control's wrapper with its path", () => {
|
|
103
|
+
renderBrowser();
|
|
104
|
+
const paths = Array.from(document.querySelectorAll("[data-cms-field]")).map((el) => el.getAttribute("data-cms-field"));
|
|
105
|
+
expect(paths).toEqual(
|
|
106
|
+
expect.arrayContaining(["title", "blocks", "blocks.0", "blocks.0.heading", "blocks.0.stats", "blocks.0.stats.0", "blocks.0.stats.0.label", "blocks.0.stats.0.value"]),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -35,6 +35,15 @@ export type ApplyChangesModalProps = {
|
|
|
35
35
|
progress?: MergeProgress;
|
|
36
36
|
/** A short summary of what will be merged, shown on the confirmation step. */
|
|
37
37
|
summary?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Why this apply cannot push to the target directly and opens a change
|
|
40
|
+
* request instead — the target is protected, or the caller cannot publish
|
|
41
|
+
* every project on the branch. When set, the confirmation step describes the
|
|
42
|
+
* review that will happen rather than a merge commit that will not, and the
|
|
43
|
+
* needs_review panel gives this reason when there are no code conflicts to
|
|
44
|
+
* list. Absent for an ordinary direct merge.
|
|
45
|
+
*/
|
|
46
|
+
reviewReason?: string;
|
|
38
47
|
/** Number of unresolved conflicts, shown on the conflicted state. */
|
|
39
48
|
conflictCount?: number;
|
|
40
49
|
/**
|
|
@@ -99,7 +108,7 @@ function StepRow({ label, state }: { label: string; state: "done" | "active" | "
|
|
|
99
108
|
* (spinner → green check), then shows a success panel with the commit + stats,
|
|
100
109
|
* or an error. Read-only: it reflects a run the host drives via a subscription.
|
|
101
110
|
*/
|
|
102
|
-
export function ApplyChangesModal({ sourceBranch, targetBranch, progress, summary, conflictCount, changeRequestOpen, changeRequestUrl, creatingChangeRequest, onConfirm, onReviewConflicts, onCreateChangeRequest, onViewChangeRequest, onClose, onDone }: ApplyChangesModalProps) {
|
|
111
|
+
export function ApplyChangesModal({ sourceBranch, targetBranch, progress, summary, reviewReason, conflictCount, changeRequestOpen, changeRequestUrl, creatingChangeRequest, onConfirm, onReviewConflicts, onCreateChangeRequest, onViewChangeRequest, onClose, onDone }: ApplyChangesModalProps) {
|
|
103
112
|
const t = useTheme();
|
|
104
113
|
const done = onDone ?? onClose;
|
|
105
114
|
const [explanation, setExplanation] = useState("");
|
|
@@ -121,6 +130,13 @@ export function ApplyChangesModal({ sourceBranch, targetBranch, progress, summar
|
|
|
121
130
|
const status = progress?.status;
|
|
122
131
|
const conflicted = status === "conflicted";
|
|
123
132
|
const needsReview = status === "needs_review";
|
|
133
|
+
// A needs_review run either lists code files a developer must resolve, or
|
|
134
|
+
// lists nothing because the merge was clean and simply may not be pushed by
|
|
135
|
+
// this run (a protected target, or partial project access). The two need
|
|
136
|
+
// different words: the second is not a conflict and naming it one would send
|
|
137
|
+
// the editor looking for files that are not there.
|
|
138
|
+
const escalationFiles = progress?.escalationFiles ?? [];
|
|
139
|
+
const codeConflicts = escalationFiles.length > 0;
|
|
124
140
|
const terminal = status === "succeeded" || status === "failed" || conflicted || needsReview;
|
|
125
141
|
const current = progress ? stepOrder[progress.step] : 0;
|
|
126
142
|
|
|
@@ -201,30 +217,63 @@ export function ApplyChangesModal({ sourceBranch, targetBranch, progress, summar
|
|
|
201
217
|
// Confirmation step: spell out the irreversible, outward-facing effects
|
|
202
218
|
// before anything is pushed to the remote.
|
|
203
219
|
<View testID="apply-confirm" style={{ gap: t.space(3) }}>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
220
|
+
{reviewReason ? (
|
|
221
|
+
// The run will stop before the push, so promising a merge commit
|
|
222
|
+
// on origin here would be describing something that will not
|
|
223
|
+
// happen. Say what will: a review, and nothing on the target
|
|
224
|
+
// until it is approved.
|
|
225
|
+
<>
|
|
226
|
+
<Text variant="body" color="secondary" testID="apply-confirm-review">
|
|
227
|
+
{`This merges ${sourceBranch} into ${targetBranch} in the CMS and opens a pull request for review. Nothing is pushed to origin/${targetBranch} until it is approved.`}
|
|
228
|
+
</Text>
|
|
229
|
+
<Text variant="body" color="secondary">{reviewReason}</Text>
|
|
230
|
+
</>
|
|
231
|
+
) : (
|
|
232
|
+
<>
|
|
233
|
+
<Text variant="body" color="secondary">
|
|
234
|
+
{`This merges ${sourceBranch} into ${targetBranch}, commits the result, and pushes a merge commit to origin/${targetBranch}.`}
|
|
235
|
+
</Text>
|
|
236
|
+
<Text variant="body" color="secondary">
|
|
237
|
+
{`${sourceBranch} is then deleted. This can’t be undone.`}
|
|
238
|
+
</Text>
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
210
241
|
{summary ? <Text variant="monoSm" color="tertiary">{summary}</Text> : null}
|
|
211
242
|
</View>
|
|
212
243
|
) : needsReview ? (
|
|
213
|
-
// Content merged, but
|
|
214
|
-
//
|
|
244
|
+
// Content merged, but the run may not push it. Either code/config
|
|
245
|
+
// files conflict — a content editor can't resolve these — or the
|
|
246
|
+
// target can't take a direct push from this run. Both collect an
|
|
247
|
+
// explanation and escalate through a pull request.
|
|
215
248
|
<View testID="merge-needs-review" style={{ gap: t.space(3) }}>
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
249
|
+
{codeConflicts ? (
|
|
250
|
+
<>
|
|
251
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
252
|
+
<Icon name="gitPullRequest" size={16} color={t.color.diffDelFg} />
|
|
253
|
+
<Text variant="body" weight="medium">Code conflicts need a developer</Text>
|
|
254
|
+
</View>
|
|
255
|
+
<Text variant="body" color="secondary">
|
|
256
|
+
{`The content merged cleanly, but these files changed on both ${sourceBranch} and ${targetBranch} and need a developer to resolve:`}
|
|
257
|
+
</Text>
|
|
258
|
+
<View style={{ gap: 2, padding: t.space(2), borderRadius: t.radius.md, backgroundColor: t.color.diffDelBg }}>
|
|
259
|
+
{escalationFiles.map((f) => (
|
|
260
|
+
<Text key={f} variant="monoSm" color={t.color.diffDelFg} testID={`escalation-file-${f}`}>{f}</Text>
|
|
261
|
+
))}
|
|
262
|
+
</View>
|
|
263
|
+
</>
|
|
264
|
+
) : (
|
|
265
|
+
<>
|
|
266
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
267
|
+
<Icon name="gitPullRequest" size={16} color={t.color.textSecondary} />
|
|
268
|
+
<Text variant="body" weight="medium">This merge needs a review</Text>
|
|
269
|
+
</View>
|
|
270
|
+
<Text variant="body" color="secondary" testID="merge-needs-review-reason">
|
|
271
|
+
{`The content merged cleanly and nothing was pushed to ${targetBranch}. ` +
|
|
272
|
+
(reviewReason ?? `Someone with the authority to publish it has to approve it.`) +
|
|
273
|
+
` Opening a change request creates a pull request for that review.`}
|
|
274
|
+
</Text>
|
|
275
|
+
</>
|
|
276
|
+
)}
|
|
228
277
|
<Input
|
|
229
278
|
value={explanation}
|
|
230
279
|
onChangeText={setExplanation}
|
|
@@ -336,6 +336,15 @@ export type ContentBrowserProps = {
|
|
|
336
336
|
// already separated from the frontmatter.
|
|
337
337
|
onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
|
|
338
338
|
|
|
339
|
+
// Which field of an open document has keyboard focus, as the author moves
|
|
340
|
+
// between controls: the field's dotted path (`title`, `seo.description`,
|
|
341
|
+
// `blocks.0.heading`), or null when nothing in that document is focused.
|
|
342
|
+
// Web only — it reads the DOM's focus events. This is what lets a preview
|
|
343
|
+
// ring the part of the page the author is editing (§4.4 of the preview
|
|
344
|
+
// design), and it is separate from live collaboration: a lone author gets
|
|
345
|
+
// it too.
|
|
346
|
+
onFieldFocus?: (entryId: string, path: string | null) => void;
|
|
347
|
+
|
|
339
348
|
// Optional per-field renderer. When it returns a node for a field, that node
|
|
340
349
|
// replaces the built-in control (used to inject the markdown body editor).
|
|
341
350
|
renderField?: RenderField;
|
|
@@ -483,6 +492,9 @@ export type ContentBrowserProps = {
|
|
|
483
492
|
canApplyChanges?: boolean;
|
|
484
493
|
applyOpen?: boolean;
|
|
485
494
|
applySummary?: string;
|
|
495
|
+
// Why applying opens a change request instead of merging (see
|
|
496
|
+
// ApplyChangesModal.reviewReason). Absent for a direct merge.
|
|
497
|
+
applyReviewReason?: string;
|
|
486
498
|
applyProgress?: MergeProgress;
|
|
487
499
|
applyConflictCount?: number;
|
|
488
500
|
onConfirmApply?: () => void;
|
|
@@ -779,8 +791,40 @@ type FieldControlProps = {
|
|
|
779
791
|
// (see FieldSlotArgs.resetNonce); every built-in control is controlled and
|
|
780
792
|
// re-renders from `value` on its own.
|
|
781
793
|
resetNonce?: number;
|
|
794
|
+
// The field's full dotted path, when the caller knows better than
|
|
795
|
+
// "parent path + field name" — a list item, whose control is named after
|
|
796
|
+
// the list but sits at `list.<index>`.
|
|
797
|
+
fieldPath?: string;
|
|
782
798
|
};
|
|
783
799
|
|
|
800
|
+
// The dotted path of the field being rendered, for the controls under it:
|
|
801
|
+
// a group's children append their names to it, a list's items append their
|
|
802
|
+
// index. It exists so every control can carry its own path as a DOM
|
|
803
|
+
// attribute (`data-cms-field`) without any of them threading a prop through
|
|
804
|
+
// — the focus reporting in EntryDetail reads that attribute off whichever
|
|
805
|
+
// element the keyboard lands in.
|
|
806
|
+
//
|
|
807
|
+
// Deliberately separate from the collab `path` prop: that one is threaded
|
|
808
|
+
// only where a CRDT binding exists (top-level fields and drilled-in groups),
|
|
809
|
+
// whereas this is present for every field including list items, which the
|
|
810
|
+
// collab layer treats as one register.
|
|
811
|
+
const FieldPathContext = React.createContext<string>("");
|
|
812
|
+
|
|
813
|
+
function FieldControl(props: FieldControlProps) {
|
|
814
|
+
const prefix = React.useContext(FieldPathContext);
|
|
815
|
+
const fullPath = props.fieldPath ?? (prefix ? `${prefix}.${props.field.name}` : props.field.name);
|
|
816
|
+
return (
|
|
817
|
+
<FieldPathContext.Provider value={fullPath}>
|
|
818
|
+
<View
|
|
819
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
820
|
+
dataSet={{ cmsField: fullPath }}
|
|
821
|
+
>
|
|
822
|
+
<FieldControlInner {...props} />
|
|
823
|
+
</View>
|
|
824
|
+
</FieldPathContext.Provider>
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
784
828
|
// The scalar type an array's `of` maps to when rendering item controls.
|
|
785
829
|
function ofToType(of?: string): string {
|
|
786
830
|
switch (of) {
|
|
@@ -791,7 +835,7 @@ function ofToType(of?: string): string {
|
|
|
791
835
|
}
|
|
792
836
|
}
|
|
793
837
|
|
|
794
|
-
function
|
|
838
|
+
function FieldControlInner(props: FieldControlProps) {
|
|
795
839
|
const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
|
|
796
840
|
const t = useTheme();
|
|
797
841
|
const label = hideLabel ? "" : field.label || field.name;
|
|
@@ -1628,11 +1672,14 @@ function ListControl({
|
|
|
1628
1672
|
...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
|
|
1629
1673
|
};
|
|
1630
1674
|
|
|
1675
|
+
// The list's own path, from the FieldControl wrapping this control; an item
|
|
1676
|
+
// sits at `<list>.<index>`.
|
|
1677
|
+
const listPath = React.useContext(FieldPathContext);
|
|
1631
1678
|
return (
|
|
1632
1679
|
<View style={{ gap: t.space(2) }}>
|
|
1633
1680
|
{items.map((it, i) => (
|
|
1634
1681
|
<ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
|
|
1635
|
-
<FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel />
|
|
1682
|
+
<FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel fieldPath={listPath ? `${listPath}.${i}` : String(i)} />
|
|
1636
1683
|
</ItemFrame>
|
|
1637
1684
|
))}
|
|
1638
1685
|
{!readOnly && canAdd ? (
|
|
@@ -1672,15 +1719,25 @@ function MixedListControl({
|
|
|
1672
1719
|
onChange(next);
|
|
1673
1720
|
};
|
|
1674
1721
|
const addItem = () => onChange([...items, { [key]: variants[0]?.name ?? "" }]);
|
|
1722
|
+
const listPath = React.useContext(FieldPathContext);
|
|
1675
1723
|
|
|
1676
1724
|
return (
|
|
1677
1725
|
<View style={{ gap: t.space(2) }}>
|
|
1678
1726
|
{items.map((it, i) => {
|
|
1679
1727
|
const variantName = String(it?.[key] ?? "");
|
|
1680
1728
|
const variant = variants.find((v) => v.name === variantName);
|
|
1729
|
+
const itemPath = listPath ? `${listPath}.${i}` : String(i);
|
|
1681
1730
|
return (
|
|
1682
1731
|
<ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
|
|
1683
|
-
|
|
1732
|
+
{/* The item's fields compose under `<list>.<index>`; the item
|
|
1733
|
+
itself carries that path so focusing its variant picker
|
|
1734
|
+
reports the item rather than the whole list. */}
|
|
1735
|
+
<FieldPathContext.Provider value={itemPath}>
|
|
1736
|
+
<View
|
|
1737
|
+
style={{ gap: t.space(2) }}
|
|
1738
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
1739
|
+
dataSet={{ cmsField: itemPath }}
|
|
1740
|
+
>
|
|
1684
1741
|
<SelectControl
|
|
1685
1742
|
value={variantName}
|
|
1686
1743
|
options={variants.map((v) => v.name)}
|
|
@@ -1698,6 +1755,7 @@ function MixedListControl({
|
|
|
1698
1755
|
/>
|
|
1699
1756
|
) : null}
|
|
1700
1757
|
</View>
|
|
1758
|
+
</FieldPathContext.Provider>
|
|
1701
1759
|
</ItemFrame>
|
|
1702
1760
|
);
|
|
1703
1761
|
})}
|
|
@@ -1742,7 +1800,13 @@ function BodyField({
|
|
|
1742
1800
|
readOnly,
|
|
1743
1801
|
});
|
|
1744
1802
|
return (
|
|
1745
|
-
<View
|
|
1803
|
+
<View
|
|
1804
|
+
style={{ gap: t.space(2), flex: 1 }}
|
|
1805
|
+
// The schema-less body is the document's one field; a preview rings
|
|
1806
|
+
// it under the same name the schema form would give it.
|
|
1807
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
1808
|
+
dataSet={{ cmsField: "body" }}
|
|
1809
|
+
>
|
|
1746
1810
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
|
1747
1811
|
<Text variant="label" color="tertiary">Body</Text>
|
|
1748
1812
|
<Text variant="monoSm" color="tertiary">Markdown</Text>
|
|
@@ -2594,6 +2658,7 @@ function EntryDetail({
|
|
|
2594
2658
|
entry,
|
|
2595
2659
|
documentActions,
|
|
2596
2660
|
onEntryDraft,
|
|
2661
|
+
onFieldFocus,
|
|
2597
2662
|
renderField,
|
|
2598
2663
|
onSaveEntry,
|
|
2599
2664
|
readOnly = false,
|
|
@@ -2616,6 +2681,8 @@ function EntryDetail({
|
|
|
2616
2681
|
documentActions?: (entry: CmsEntry) => React.ReactNode;
|
|
2617
2682
|
// Debounced in-flight draft (see ContentBrowserProps.onEntryDraft).
|
|
2618
2683
|
onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
|
|
2684
|
+
// The focused field's path, or null (see ContentBrowserProps.onFieldFocus).
|
|
2685
|
+
onFieldFocus?: (entryId: string, path: string | null) => void;
|
|
2619
2686
|
renderField?: RenderField;
|
|
2620
2687
|
onSaveEntry?: SaveEntry;
|
|
2621
2688
|
readOnly?: boolean;
|
|
@@ -2969,8 +3036,54 @@ function EntryDetail({
|
|
|
2969
3036
|
// The field list + nested value object for the active frame.
|
|
2970
3037
|
const frame = resolveFrame(editFields, values, groupPath);
|
|
2971
3038
|
|
|
3039
|
+
// Which field has the keyboard. One pair of DOM focus listeners on the
|
|
3040
|
+
// column rather than an onFocus on every control: the controls are many
|
|
3041
|
+
// (inputs, selects, the ProseMirror body, plugin fields) and every one of
|
|
3042
|
+
// them renders inside the FieldControl wrapper that carries the path as
|
|
3043
|
+
// `data-cms-field` — so the element the focus landed in is enough.
|
|
3044
|
+
//
|
|
3045
|
+
// focusout fires before the next focusin, so a blur is reported a tick
|
|
3046
|
+
// late and cancelled if focus went straight to another field: moving
|
|
3047
|
+
// between two controls reads as one change, not a flicker through null.
|
|
3048
|
+
const focusRoot = useRef<View>(null);
|
|
3049
|
+
const fieldFocus = useRef(onFieldFocus);
|
|
3050
|
+
fieldFocus.current = onFieldFocus;
|
|
3051
|
+
const entryId = entry.id;
|
|
3052
|
+
useEffect(() => {
|
|
3053
|
+
if (Platform.OS !== "web") return;
|
|
3054
|
+
const node = focusRoot.current as unknown as HTMLElement | null;
|
|
3055
|
+
if (!node || typeof node.addEventListener !== "function") return;
|
|
3056
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
3057
|
+
let last: string | null = null;
|
|
3058
|
+
const report = (path: string | null) => {
|
|
3059
|
+
if (path === last) return;
|
|
3060
|
+
last = path;
|
|
3061
|
+
fieldFocus.current?.(entryId, path);
|
|
3062
|
+
};
|
|
3063
|
+
const onFocusIn = (ev: Event) => {
|
|
3064
|
+
if (pending) { clearTimeout(pending); pending = null; }
|
|
3065
|
+
const target = ev.target as Element | null;
|
|
3066
|
+
const el = target && typeof target.closest === "function" ? target.closest("[data-cms-field]") : null;
|
|
3067
|
+
report(el?.getAttribute("data-cms-field") || null);
|
|
3068
|
+
};
|
|
3069
|
+
const onFocusOut = () => {
|
|
3070
|
+
if (pending) clearTimeout(pending);
|
|
3071
|
+
pending = setTimeout(() => { pending = null; report(null); }, 0);
|
|
3072
|
+
};
|
|
3073
|
+
node.addEventListener("focusin", onFocusIn);
|
|
3074
|
+
node.addEventListener("focusout", onFocusOut);
|
|
3075
|
+
return () => {
|
|
3076
|
+
node.removeEventListener("focusin", onFocusIn);
|
|
3077
|
+
node.removeEventListener("focusout", onFocusOut);
|
|
3078
|
+
if (pending) clearTimeout(pending);
|
|
3079
|
+
// The column is going away with the field still focused: nothing in
|
|
3080
|
+
// this document has focus any more.
|
|
3081
|
+
if (last !== null) fieldFocus.current?.(entryId, null);
|
|
3082
|
+
};
|
|
3083
|
+
}, [entryId]);
|
|
3084
|
+
|
|
2972
3085
|
return (
|
|
2973
|
-
<View style={{ flex: 1 }}>
|
|
3086
|
+
<View style={{ flex: 1 }} ref={focusRoot}>
|
|
2974
3087
|
{/* breadcrumb + actions. position/zIndex lift this row (and the "..." menu
|
|
2975
3088
|
dropdown it hosts) above the content pane so the menu receives clicks. */}
|
|
2976
3089
|
<View
|
|
@@ -3126,6 +3239,8 @@ function EntryDetail({
|
|
|
3126
3239
|
<Text variant="monoSm" color="tertiary">{frame.labels.join(" / ")}</Text>
|
|
3127
3240
|
</Pressable>
|
|
3128
3241
|
) : null}
|
|
3242
|
+
{/* Drilled into a group, every field's path starts with the group's. */}
|
|
3243
|
+
<FieldPathContext.Provider value={groupPath.join(".")}>
|
|
3129
3244
|
{frame.fields.map((f) => (
|
|
3130
3245
|
<FieldControl
|
|
3131
3246
|
key={f.name}
|
|
@@ -3144,6 +3259,7 @@ function EntryDetail({
|
|
|
3144
3259
|
resetNonce={resetNonces[f.name]}
|
|
3145
3260
|
/>
|
|
3146
3261
|
))}
|
|
3262
|
+
</FieldPathContext.Provider>
|
|
3147
3263
|
</ScrollView>
|
|
3148
3264
|
</DocumentPathProvider>
|
|
3149
3265
|
) : (
|
|
@@ -3767,6 +3883,7 @@ function ApplyModalOverlay(props: ContentBrowserProps) {
|
|
|
3767
3883
|
sourceBranch={props.workspace.branch}
|
|
3768
3884
|
targetBranch={props.targetBranch ?? props.defaultBranch ?? ""}
|
|
3769
3885
|
summary={props.applySummary}
|
|
3886
|
+
reviewReason={props.applyReviewReason}
|
|
3770
3887
|
progress={props.applyProgress}
|
|
3771
3888
|
conflictCount={props.applyConflictCount}
|
|
3772
3889
|
changeRequestOpen={props.changeRequestOpen}
|
|
@@ -4129,6 +4246,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4129
4246
|
documentActions={props.documentActions}
|
|
4130
4247
|
history={props.history}
|
|
4131
4248
|
onEntryDraft={props.onEntryDraft}
|
|
4249
|
+
onFieldFocus={props.onFieldFocus}
|
|
4132
4250
|
renderField={renderField}
|
|
4133
4251
|
onSaveEntry={props.onSaveEntry}
|
|
4134
4252
|
readOnly={col.readOnly}
|
|
@@ -5005,6 +5123,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
5005
5123
|
documentActions={props.documentActions}
|
|
5006
5124
|
history={props.history}
|
|
5007
5125
|
onEntryDraft={props.onEntryDraft}
|
|
5126
|
+
onFieldFocus={props.onFieldFocus}
|
|
5008
5127
|
renderField={renderField}
|
|
5009
5128
|
onSaveEntry={props.onSaveEntry}
|
|
5010
5129
|
readOnly={readOnly}
|