@gogitcms/design-system 0.16.0-next.0 → 0.16.0-next.10
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 +1 -1
- package/src/__tests__/ApplyChangesModal.test.tsx +48 -0
- package/src/__tests__/BranchDeletedModal.test.tsx +42 -0
- package/src/__tests__/ContentBrowser.fieldfocus.test.tsx +109 -0
- package/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
- package/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/src/__tests__/ContentBrowser.slotmedia.test.tsx +150 -0
- package/src/__tests__/FormsBrowser.test.tsx +7 -2
- package/src/__tests__/MediaField.test.tsx +18 -0
- package/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
- package/src/components/ApplyChangesModal.tsx +70 -21
- package/src/components/BranchDeletedModal.tsx +111 -0
- package/src/components/ChangeDetail.tsx +94 -11
- package/src/components/CollabField.tsx +71 -0
- package/src/components/ContentBrowser.tsx +440 -71
- package/src/components/DocumentHistory.tsx +408 -0
- package/src/components/FormsBrowser.tsx +3 -5
- package/src/components/MediaField.tsx +62 -0
- package/src/components/MediaPreview.tsx +14 -1
- package/src/components/MediaPreview.web.tsx +18 -3
- package/src/components/Notifications.tsx +146 -28
- package/src/components/ProtectedBranchModal.tsx +136 -0
- package/src/components/primitives.tsx +46 -1
- package/src/history.ts +82 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +13 -3
- package/src/media.ts +15 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { ProtectedBranchModal } from "../components/ProtectedBranchModal";
|
|
5
|
+
|
|
6
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
7
|
+
|
|
8
|
+
const base = { branch: "main", onCreate: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
test("it names the branch, the document, and offers the one remedy", () => {
|
|
11
|
+
render(wrap(<ProtectedBranchModal {...base} documentLabel="Hello world" />));
|
|
12
|
+
|
|
13
|
+
const modal = screen.getByTestId("protected-branch-modal");
|
|
14
|
+
expect(modal).toHaveTextContent("This branch is protected");
|
|
15
|
+
expect(modal).toHaveTextContent("main");
|
|
16
|
+
expect(modal).toHaveTextContent("Hello world");
|
|
17
|
+
expect(modal).toHaveTextContent("Branched from main");
|
|
18
|
+
expect(screen.getByTestId("protected-branch-submit")).toHaveTextContent("Create branch and save");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the suggested name prefills the field and submits", () => {
|
|
22
|
+
const onCreate = jest.fn();
|
|
23
|
+
render(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" onCreate={onCreate} />));
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("edit/hello");
|
|
26
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
27
|
+
expect(onCreate).toHaveBeenCalledWith("edit/hello");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// The author's own name wins: a suggestion arriving late (the host resolves the
|
|
31
|
+
// document a render after the modal opens) must not overwrite what they typed.
|
|
32
|
+
test("a late suggestion does not overwrite a typed name", () => {
|
|
33
|
+
const { rerender } = render(wrap(<ProtectedBranchModal {...base} />));
|
|
34
|
+
fireEvent.change(screen.getByTestId("protected-branch-name"), { target: { value: "mine" } });
|
|
35
|
+
rerender(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" />));
|
|
36
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("mine");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("an empty name cannot be submitted", () => {
|
|
40
|
+
const onCreate = jest.fn();
|
|
41
|
+
render(wrap(<ProtectedBranchModal {...base} onCreate={onCreate} />));
|
|
42
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
43
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// While the branch is being created there is no way out but forward: cancelling
|
|
47
|
+
// mid-flight would abandon a save whose branch may already exist.
|
|
48
|
+
test("busy disables both actions and hides the close control", () => {
|
|
49
|
+
const onCreate = jest.fn();
|
|
50
|
+
render(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" busy onCreate={onCreate} />));
|
|
51
|
+
|
|
52
|
+
expect(screen.getByTestId("protected-branch-submit")).toHaveTextContent("Creating…");
|
|
53
|
+
expect(screen.queryByTestId("protected-branch-close")).not.toBeInTheDocument();
|
|
54
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
55
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the server's message is shown so a rejected name can be corrected", () => {
|
|
59
|
+
render(wrap(<ProtectedBranchModal {...base} error={`branch "edit/hello" already exists`} />));
|
|
60
|
+
expect(screen.getByTestId("protected-branch-error")).toHaveTextContent("already exists");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("cancel dismisses", () => {
|
|
64
|
+
const onCancel = jest.fn();
|
|
65
|
+
render(wrap(<ProtectedBranchModal {...base} onCancel={onCancel} />));
|
|
66
|
+
fireEvent.click(screen.getByTestId("protected-branch-cancel"));
|
|
67
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
@@ -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}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, ScrollView } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
import { Button } from "./Button";
|
|
7
|
+
import type { BranchRef } from "./BranchMenu";
|
|
8
|
+
|
|
9
|
+
export type BranchDeletedModalProps = {
|
|
10
|
+
/**
|
|
11
|
+
* The branch that no longer exists. Omitted when only its id is known — a
|
|
12
|
+
* link into a branch that was deleted before the page loaded.
|
|
13
|
+
*/
|
|
14
|
+
branch?: string;
|
|
15
|
+
/** "owner/name", when the host knows it. */
|
|
16
|
+
repository?: string;
|
|
17
|
+
/** The branches still connected, to switch to. The deleted one is not among them. */
|
|
18
|
+
branches: BranchRef[];
|
|
19
|
+
/** Open the editor on `branch` instead. */
|
|
20
|
+
onSwitch: (branch: BranchRef) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Leave this repository for the picker. Offered as the way out when there is
|
|
23
|
+
* no other branch to switch to; omitted → the list is the only exit.
|
|
24
|
+
*/
|
|
25
|
+
onChooseRepository?: () => void;
|
|
26
|
+
testID?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shown when the branch the editor is on stops existing — deleted on the
|
|
31
|
+
* provider, or disconnected from the CMS — while someone is looking at it.
|
|
32
|
+
*
|
|
33
|
+
* It is a modal rather than a redirect because the screen behind it may hold
|
|
34
|
+
* work in progress. Nothing on it can be saved to this branch any more, but
|
|
35
|
+
* bouncing the user to another branch without a word would make that content
|
|
36
|
+
* vanish mid-thought; the modal says what happened and leaves the page visible
|
|
37
|
+
* while they decide where to go. It cannot be dismissed into the dead branch,
|
|
38
|
+
* because there is nothing there to go back to: every exit is another branch.
|
|
39
|
+
*/
|
|
40
|
+
export function BranchDeletedModal({
|
|
41
|
+
branch, repository, branches, onSwitch, onChooseRepository, testID,
|
|
42
|
+
}: BranchDeletedModalProps) {
|
|
43
|
+
const t = useTheme();
|
|
44
|
+
const title = branch ? `${branch} was deleted` : "This branch no longer exists";
|
|
45
|
+
const where = repository ? ` in ${repository}` : "";
|
|
46
|
+
const detail = branch
|
|
47
|
+
? `The ${branch} branch${where} was deleted on the remote or disconnected from the CMS, and its content was removed with it. Nothing on this screen can be saved to it any more.`
|
|
48
|
+
: `The branch this link points to${where} was deleted on the remote or disconnected from the CMS, so there is nothing here to edit.`;
|
|
49
|
+
const hasOthers = branches.length > 0;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View
|
|
53
|
+
testID={testID ?? "branch-deleted-modal"}
|
|
54
|
+
style={{
|
|
55
|
+
position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 50,
|
|
56
|
+
alignItems: "center", justifyContent: "center", padding: t.space(4),
|
|
57
|
+
backgroundColor: "rgba(0,0,0,0.45)",
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<View
|
|
61
|
+
style={{
|
|
62
|
+
width: 460, maxWidth: "100%", maxHeight: "100%", gap: t.space(4), padding: t.space(5),
|
|
63
|
+
borderRadius: t.radius.lg, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
64
|
+
backgroundColor: t.color.surfaceRaised,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
68
|
+
<Icon name="alert" size={16} color={t.color.diffDelFg} />
|
|
69
|
+
<Text variant="h3" weight="semibold" testID="branch-deleted-title">{title}</Text>
|
|
70
|
+
</View>
|
|
71
|
+
|
|
72
|
+
<Text variant="body" color="secondary">{detail}</Text>
|
|
73
|
+
|
|
74
|
+
{hasOthers ? (
|
|
75
|
+
<>
|
|
76
|
+
<Text variant="sm" weight="semibold">Switch to another branch to keep working</Text>
|
|
77
|
+
<ScrollView style={{ maxHeight: 280 }} contentContainerStyle={{ gap: t.space(2) }}>
|
|
78
|
+
{branches.map((b) => (
|
|
79
|
+
<Button
|
|
80
|
+
key={b.id}
|
|
81
|
+
title={b.name}
|
|
82
|
+
fullWidth
|
|
83
|
+
onPress={() => onSwitch(b)}
|
|
84
|
+
testID={`branch-deleted-switch-${b.id}`}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
</ScrollView>
|
|
88
|
+
</>
|
|
89
|
+
) : (
|
|
90
|
+
<Text variant="sm" color="secondary" testID="branch-deleted-none">
|
|
91
|
+
{repository
|
|
92
|
+
? `No other branch of ${repository} is connected to the CMS.`
|
|
93
|
+
: "No other branch of this repository is connected to the CMS."}
|
|
94
|
+
</Text>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{onChooseRepository ? (
|
|
98
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
|
|
99
|
+
<Button
|
|
100
|
+
title="Choose a repository"
|
|
101
|
+
variant={hasOthers ? "ghost" : "primary"}
|
|
102
|
+
size="md"
|
|
103
|
+
onPress={onChooseRepository}
|
|
104
|
+
testID="branch-deleted-choose-repository"
|
|
105
|
+
/>
|
|
106
|
+
</View>
|
|
107
|
+
) : null}
|
|
108
|
+
</View>
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -5,7 +5,7 @@ import { Text } from "./Text";
|
|
|
5
5
|
import { Icon } from "./Icon";
|
|
6
6
|
import { Input } from "./Input";
|
|
7
7
|
import { Button } from "./Button";
|
|
8
|
-
import { Badge, DiffStat } from "./primitives";
|
|
8
|
+
import { Badge, CheckBox, DiffStat } from "./primitives";
|
|
9
9
|
|
|
10
10
|
/** How one field differs between the branch and its base. */
|
|
11
11
|
export type FieldChangeKind = "added" | "removed" | "changed" | "unchanged";
|
|
@@ -38,7 +38,15 @@ export type DocumentChange = {
|
|
|
38
38
|
path: string;
|
|
39
39
|
/** The base branch's path, for a rename. */
|
|
40
40
|
previousPath?: string;
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A/M/D/R are git's letters, so a branch comparison renders them untranslated.
|
|
43
|
+
* "U" (unchanged) has no place in a diff of two branches — an unchanged
|
|
44
|
+
* document is not a change — but it is a real answer for a document version:
|
|
45
|
+
* the newest commit in a document's history is usually still what the document
|
|
46
|
+
* is, and saying "Unchanged" is more useful than an empty diff with no
|
|
47
|
+
* explanation.
|
|
48
|
+
*/
|
|
49
|
+
status: "A" | "M" | "D" | "R" | "U";
|
|
42
50
|
label: string;
|
|
43
51
|
/** Ordered to match the content model's schema, so it reads like the editor. */
|
|
44
52
|
fields: FieldChange[];
|
|
@@ -79,13 +87,35 @@ export type ChangeDetailProps = {
|
|
|
79
87
|
*/
|
|
80
88
|
conflicts?: FieldConflict[];
|
|
81
89
|
onResolveConflict?: (conflict: FieldConflict, choice: ConflictChoice, override?: unknown) => void;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Per-field selection, for a caller that does something with a subset of the
|
|
93
|
+
* diff — restoring fields from a past version, in the history pane.
|
|
94
|
+
*
|
|
95
|
+
* Only CHANGED fields get a box: an unchanged field's two sides are the same
|
|
96
|
+
* value, so selecting it could only ever be a no-op dressed up as a choice.
|
|
97
|
+
* The body is selectable under the name BODY_FIELD.
|
|
98
|
+
*
|
|
99
|
+
* Omitting onToggleField leaves the diff exactly as it was — a read-only
|
|
100
|
+
* comparison, which is what the changes surface wants.
|
|
101
|
+
*/
|
|
102
|
+
selectedFields?: readonly string[];
|
|
103
|
+
onToggleField?: (name: string) => void;
|
|
82
104
|
};
|
|
83
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The name the body answers to in `selectedFields` — the document body is a
|
|
108
|
+
* selectable "field" here but has no entry in `fields`, and a real frontmatter
|
|
109
|
+
* key could otherwise collide with whatever plain string we picked.
|
|
110
|
+
*/
|
|
111
|
+
export const BODY_FIELD = "\u0000body";
|
|
112
|
+
|
|
84
113
|
const STATUS_LABEL: Record<DocumentChange["status"], string> = {
|
|
85
114
|
A: "Added",
|
|
86
115
|
M: "Modified",
|
|
87
116
|
D: "Deleted",
|
|
88
117
|
R: "Renamed",
|
|
118
|
+
U: "Unchanged",
|
|
89
119
|
};
|
|
90
120
|
|
|
91
121
|
/** Renders a field value the way the editor would show it, not as raw JSON. */
|
|
@@ -142,14 +172,29 @@ function ContextValue({ value }: { value: unknown }) {
|
|
|
142
172
|
);
|
|
143
173
|
}
|
|
144
174
|
|
|
145
|
-
function FieldRow({
|
|
175
|
+
function FieldRow({
|
|
176
|
+
field,
|
|
177
|
+
selected,
|
|
178
|
+
onToggle,
|
|
179
|
+
}: {
|
|
180
|
+
field: FieldChange;
|
|
181
|
+
// Both present or both absent: selection is offered only for a changed field
|
|
182
|
+
// whose caller wants it (see ChangeDetailProps.onToggleField).
|
|
183
|
+
selected?: boolean;
|
|
184
|
+
onToggle?: () => void;
|
|
185
|
+
}) {
|
|
146
186
|
const t = useTheme();
|
|
147
187
|
const changed = field.kind !== "unchanged";
|
|
148
188
|
return (
|
|
149
189
|
<View testID={`change-field-${field.name}`} style={{ gap: t.space(2) }}>
|
|
150
|
-
<
|
|
151
|
-
{
|
|
152
|
-
|
|
190
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
191
|
+
{onToggle ? (
|
|
192
|
+
<CheckBox value={!!selected} onToggle={onToggle} testID={`restore-field-${field.name}`} />
|
|
193
|
+
) : null}
|
|
194
|
+
<Text variant="label" color="tertiary">
|
|
195
|
+
{(field.label || field.name).toUpperCase()}
|
|
196
|
+
</Text>
|
|
197
|
+
</View>
|
|
153
198
|
{changed ? (
|
|
154
199
|
<View
|
|
155
200
|
style={{
|
|
@@ -285,7 +330,17 @@ function ConflictSide({ label, value, chosen }: { label: string; value: unknown;
|
|
|
285
330
|
* stacked blocks rather than a single line — the whole before and the whole
|
|
286
331
|
* after, each tinted, so a reviewer can read them.
|
|
287
332
|
*/
|
|
288
|
-
function BodyDiff({
|
|
333
|
+
function BodyDiff({
|
|
334
|
+
before,
|
|
335
|
+
after,
|
|
336
|
+
selected,
|
|
337
|
+
onToggle,
|
|
338
|
+
}: {
|
|
339
|
+
before?: string | null;
|
|
340
|
+
after?: string | null;
|
|
341
|
+
selected?: boolean;
|
|
342
|
+
onToggle?: () => void;
|
|
343
|
+
}) {
|
|
289
344
|
const t = useTheme();
|
|
290
345
|
if ((before ?? "") === (after ?? "")) {
|
|
291
346
|
if (!after) return null;
|
|
@@ -298,7 +353,12 @@ function BodyDiff({ before, after }: { before?: string | null; after?: string |
|
|
|
298
353
|
}
|
|
299
354
|
return (
|
|
300
355
|
<View testID="change-body" style={{ gap: t.space(2) }}>
|
|
301
|
-
<
|
|
356
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
357
|
+
{onToggle ? (
|
|
358
|
+
<CheckBox value={!!selected} onToggle={onToggle} testID="restore-field-body" />
|
|
359
|
+
) : null}
|
|
360
|
+
<Text variant="label" color="tertiary">BODY</Text>
|
|
361
|
+
</View>
|
|
302
362
|
<View
|
|
303
363
|
style={{
|
|
304
364
|
borderRadius: t.radius.md,
|
|
@@ -374,8 +434,21 @@ function AssetPreview({ preview }: { preview: ChangePreview }) {
|
|
|
374
434
|
* context. Strictly read-only — editing happens on the Edit surface, and this
|
|
375
435
|
* view describes a comparison rather than a document you can save.
|
|
376
436
|
*/
|
|
377
|
-
export function ChangeDetail({
|
|
437
|
+
export function ChangeDetail({
|
|
438
|
+
change,
|
|
439
|
+
loading,
|
|
440
|
+
emptyMessage = "Select a change",
|
|
441
|
+
conflicts,
|
|
442
|
+
onResolveConflict,
|
|
443
|
+
selectedFields,
|
|
444
|
+
onToggleField,
|
|
445
|
+
}: ChangeDetailProps) {
|
|
378
446
|
const t = useTheme();
|
|
447
|
+
const chosen = new Set(selectedFields ?? []);
|
|
448
|
+
// A field offers a box only when the caller wants selection AND there is
|
|
449
|
+
// something to select — an unchanged field's two sides are the same value.
|
|
450
|
+
const toggleFor = (name: string, changed: boolean) =>
|
|
451
|
+
onToggleField && changed ? () => onToggleField(name) : undefined;
|
|
379
452
|
|
|
380
453
|
// Conflicts keyed by field name (and a body flag), so each field can check for
|
|
381
454
|
// one as it renders. The body conflict is looked up separately.
|
|
@@ -445,7 +518,12 @@ export function ChangeDetail({ change, loading, emptyMessage = "Select a change"
|
|
|
445
518
|
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(conflict, choice, override) : undefined}
|
|
446
519
|
/>
|
|
447
520
|
) : (
|
|
448
|
-
<FieldRow
|
|
521
|
+
<FieldRow
|
|
522
|
+
key={f.name}
|
|
523
|
+
field={f}
|
|
524
|
+
selected={chosen.has(f.name)}
|
|
525
|
+
onToggle={toggleFor(f.name, f.kind !== "unchanged")}
|
|
526
|
+
/>
|
|
449
527
|
);
|
|
450
528
|
})}
|
|
451
529
|
{/* Conflicts on fields the schema-ordered diff didn't surface (rare). */}
|
|
@@ -464,7 +542,12 @@ export function ChangeDetail({ change, loading, emptyMessage = "Select a change"
|
|
|
464
542
|
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(bodyConflict!, choice, override) : undefined}
|
|
465
543
|
/>
|
|
466
544
|
) : (
|
|
467
|
-
<BodyDiff
|
|
545
|
+
<BodyDiff
|
|
546
|
+
before={change.bodyBefore}
|
|
547
|
+
after={change.bodyAfter}
|
|
548
|
+
selected={chosen.has(BODY_FIELD)}
|
|
549
|
+
onToggle={toggleFor(BODY_FIELD, (change.bodyBefore ?? "") !== (change.bodyAfter ?? ""))}
|
|
550
|
+
/>
|
|
468
551
|
)}
|
|
469
552
|
</ScrollView>
|
|
470
553
|
</View>
|
|
@@ -386,3 +386,74 @@ export function CollabTextarea({
|
|
|
386
386
|
</PresenceField>
|
|
387
387
|
);
|
|
388
388
|
}
|
|
389
|
+
|
|
390
|
+
// ---- writing a whole value into the shared document ------------------------
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Where one leaf of a document value lives in the shared doc.
|
|
394
|
+
*
|
|
395
|
+
* The split mirrors what the server seeds (collab.go's collectLeaves), because
|
|
396
|
+
* that is what decides which binding a control actually reads: a string is a
|
|
397
|
+
* Y.Text, and everything else — numbers, booleans, null, and whole lists — is a
|
|
398
|
+
* key in the "reg" map. Writing to the other one is not an error anyone sees;
|
|
399
|
+
* it is a value that silently lands where nothing is looking.
|
|
400
|
+
*/
|
|
401
|
+
type CollabLeaf = { kind: "text"; value: string } | { kind: "reg"; value: unknown };
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Flattens a value into the leaves the shared document holds it as, keyed by
|
|
405
|
+
* dotted path — `seo.title` for a group's child, exactly as the server does.
|
|
406
|
+
*
|
|
407
|
+
* `undefined` yields no leaves at all: it means the field is absent, which is
|
|
408
|
+
* expressed by clearing whatever was there rather than by writing something.
|
|
409
|
+
*/
|
|
410
|
+
function collabLeaves(path: string, value: unknown, into = new Map<string, CollabLeaf>()): Map<string, CollabLeaf> {
|
|
411
|
+
if (value === undefined) return into;
|
|
412
|
+
if (typeof value === "string") {
|
|
413
|
+
into.set(path, { kind: "text", value });
|
|
414
|
+
return into;
|
|
415
|
+
}
|
|
416
|
+
// A list is a whole-value register, matching CollabArrayField — its items are
|
|
417
|
+
// not addressable leaves, so it is written and cleared in one piece.
|
|
418
|
+
if (Array.isArray(value) || value === null || typeof value !== "object") {
|
|
419
|
+
into.set(path, { kind: "reg", value });
|
|
420
|
+
return into;
|
|
421
|
+
}
|
|
422
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
423
|
+
collabLeaves(path ? `${path}.${k}` : k, v, into);
|
|
424
|
+
}
|
|
425
|
+
return into;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Replaces a field's value in the shared document, so a value written by
|
|
430
|
+
* something other than typing — restoring a field from a past version — reaches
|
|
431
|
+
* the room instead of only the local form.
|
|
432
|
+
*
|
|
433
|
+
* In a live session the shared document is the source of truth: every collab
|
|
434
|
+
* control reads its binding in preference to the host's value, so a write that
|
|
435
|
+
* only touches local state is displayed for an instant and then overwritten by
|
|
436
|
+
* the CRDT's unchanged value. It has to be written here to be written at all.
|
|
437
|
+
*
|
|
438
|
+
* `previous` is what the field held, and is needed rather than inferable: the
|
|
439
|
+
* new value's leaves say what to write, but only the old value's leaves say
|
|
440
|
+
* what to CLEAR — a group that loses a child, or a field being cleared outright,
|
|
441
|
+
* would otherwise keep the shared value nobody can see any more.
|
|
442
|
+
*/
|
|
443
|
+
export function writeCollabValue(collab: CollabApi, path: string, previous: unknown, next: unknown): void {
|
|
444
|
+
const before = collabLeaves(path, previous);
|
|
445
|
+
const after = collabLeaves(path, next);
|
|
446
|
+
|
|
447
|
+
for (const [leafPath, leaf] of before) {
|
|
448
|
+
if (after.has(leafPath)) continue;
|
|
449
|
+
// Clearing is per shared type: a Y.Text empties, a register goes back to
|
|
450
|
+
// unset (which is what an absent key reads as, and what makes the control
|
|
451
|
+
// fall back to the document's own value).
|
|
452
|
+
if (leaf.kind === "text") collab.text(leafPath).set("");
|
|
453
|
+
else collab.register(leafPath).set(undefined);
|
|
454
|
+
}
|
|
455
|
+
for (const [leafPath, leaf] of after) {
|
|
456
|
+
if (leaf.kind === "text") collab.text(leafPath).set(leaf.value);
|
|
457
|
+
else collab.register(leafPath).set(leaf.value);
|
|
458
|
+
}
|
|
459
|
+
}
|