@gogitcms/design-system 0.15.0-next.3
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/README.md +77 -0
- package/css/components.css +312 -0
- package/css/tokens.css +212 -0
- package/package.json +65 -0
- package/src/ThemeProvider.tsx +86 -0
- package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/src/__tests__/BranchImport.test.tsx +46 -0
- package/src/__tests__/Button.test.tsx +45 -0
- package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/src/__tests__/MediaField.test.tsx +185 -0
- package/src/__tests__/Notifications.test.tsx +69 -0
- package/src/__tests__/Onboarding.test.tsx +287 -0
- package/src/__tests__/cssTokens.test.ts +201 -0
- package/src/__tests__/fieldComponents.test.ts +43 -0
- package/src/__tests__/reorder.test.ts +58 -0
- package/src/components/ApplyChangesModal.tsx +348 -0
- package/src/components/BranchImport.tsx +157 -0
- package/src/components/BranchMenu.tsx +192 -0
- package/src/components/Button.tsx +131 -0
- package/src/components/ChangeDetail.tsx +472 -0
- package/src/components/ChangeRequestSummary.tsx +173 -0
- package/src/components/CollabField.tsx +388 -0
- package/src/components/ContentBrowser.tsx +5073 -0
- package/src/components/Icon.tsx +28 -0
- package/src/components/Icon.web.tsx +31 -0
- package/src/components/Input.tsx +106 -0
- package/src/components/MediaBrowser.tsx +766 -0
- package/src/components/MediaField.tsx +670 -0
- package/src/components/MediaPreview.tsx +91 -0
- package/src/components/MediaPreview.web.tsx +169 -0
- package/src/components/NavRow.tsx +105 -0
- package/src/components/Notifications.tsx +301 -0
- package/src/components/Onboarding.tsx +751 -0
- package/src/components/ProjectMenu.tsx +124 -0
- package/src/components/Segment.tsx +87 -0
- package/src/components/Skeleton.tsx +216 -0
- package/src/components/Spinner.tsx +44 -0
- package/src/components/Text.tsx +85 -0
- package/src/components/documentDrafts.ts +213 -0
- package/src/components/layout.tsx +284 -0
- package/src/components/primitives.tsx +143 -0
- package/src/components/reorder.ts +40 -0
- package/src/fieldComponents.ts +63 -0
- package/src/icons.ts +102 -0
- package/src/index.ts +198 -0
- package/src/media.ts +229 -0
- package/src/theme.ts +116 -0
- package/src/web/Button.tsx +110 -0
- package/src/web/Icon.tsx +52 -0
- package/src/web/Input.tsx +39 -0
- package/src/web/index.ts +43 -0
- package/src/web/primitives.tsx +119 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { View, ScrollView, Image, Pressable } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
import { Input } from "./Input";
|
|
7
|
+
import { Button } from "./Button";
|
|
8
|
+
import { Badge, DiffStat } from "./primitives";
|
|
9
|
+
|
|
10
|
+
/** How one field differs between the branch and its base. */
|
|
11
|
+
export type FieldChangeKind = "added" | "removed" | "changed" | "unchanged";
|
|
12
|
+
|
|
13
|
+
export type FieldChange = {
|
|
14
|
+
name: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
kind: FieldChangeKind;
|
|
17
|
+
before?: unknown;
|
|
18
|
+
after?: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Renderable versions of a changed binary asset. `after` is the version on this
|
|
23
|
+
* branch, `before` the base branch's.
|
|
24
|
+
*
|
|
25
|
+
* A deleted file has no `after`, which is exactly why the rule "show the asset
|
|
26
|
+
* unless it was deleted" needs no flag: there is nothing current to show, and
|
|
27
|
+
* the URL of a file this branch removed would be a preview of something that is
|
|
28
|
+
* no longer there.
|
|
29
|
+
*/
|
|
30
|
+
export type ChangePreview = {
|
|
31
|
+
/** Render class (image|svg|…). Only visual kinds are previewed. */
|
|
32
|
+
kind?: string;
|
|
33
|
+
beforeUrl?: string | null;
|
|
34
|
+
afterUrl?: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DocumentChange = {
|
|
38
|
+
path: string;
|
|
39
|
+
/** The base branch's path, for a rename. */
|
|
40
|
+
previousPath?: string;
|
|
41
|
+
status: "A" | "M" | "D" | "R";
|
|
42
|
+
label: string;
|
|
43
|
+
/** Ordered to match the content model's schema, so it reads like the editor. */
|
|
44
|
+
fields: FieldChange[];
|
|
45
|
+
bodyBefore?: string | null;
|
|
46
|
+
bodyAfter?: string | null;
|
|
47
|
+
added: number;
|
|
48
|
+
removed: number;
|
|
49
|
+
/** Set for a media change; documents have nothing to preview. */
|
|
50
|
+
preview?: ChangePreview;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** A per-field merge conflict resolution choice. */
|
|
54
|
+
export type ConflictChoice = "ours" | "theirs" | "override";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One field of the selected document that couldn't merge automatically. `before`
|
|
58
|
+
* is the target/HEAD value, `after` the source value — the two sides the user
|
|
59
|
+
* picks between (or overrides). `field` is "" for the document body.
|
|
60
|
+
*/
|
|
61
|
+
export type FieldConflict = {
|
|
62
|
+
id: string;
|
|
63
|
+
field: string;
|
|
64
|
+
isBody: boolean;
|
|
65
|
+
before?: unknown;
|
|
66
|
+
after?: unknown;
|
|
67
|
+
resolution?: ConflictChoice | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type ChangeDetailProps = {
|
|
71
|
+
change?: DocumentChange;
|
|
72
|
+
loading?: boolean;
|
|
73
|
+
/** Shown when nothing is selected. */
|
|
74
|
+
emptyMessage?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Merge conflicts for the selected document. A field with a conflict renders
|
|
77
|
+
* the red resolution control instead of its normal diff. Omitted → no
|
|
78
|
+
* conflicts (the read-only comparison view).
|
|
79
|
+
*/
|
|
80
|
+
conflicts?: FieldConflict[];
|
|
81
|
+
onResolveConflict?: (conflict: FieldConflict, choice: ConflictChoice, override?: unknown) => void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const STATUS_LABEL: Record<DocumentChange["status"], string> = {
|
|
85
|
+
A: "Added",
|
|
86
|
+
M: "Modified",
|
|
87
|
+
D: "Deleted",
|
|
88
|
+
R: "Renamed",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Renders a field value the way the editor would show it, not as raw JSON. */
|
|
92
|
+
function formatValue(value: unknown): string {
|
|
93
|
+
if (value === null || value === undefined) return "—";
|
|
94
|
+
if (typeof value === "string") return value;
|
|
95
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
96
|
+
// Objects and arrays (groups, lists, media references) have no single-line
|
|
97
|
+
// form; pretty JSON is honest about the shape rather than pretending it's text.
|
|
98
|
+
return JSON.stringify(value, null, 2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One side of a field diff: the removed (−) or added (+) value, tinted with the
|
|
103
|
+
* diff signal colours — the only chromatic tokens in the system.
|
|
104
|
+
*/
|
|
105
|
+
function DiffLine({ sign, value, tone }: { sign: "+" | "−"; value: unknown; tone: "add" | "del" }) {
|
|
106
|
+
const t = useTheme();
|
|
107
|
+
const bg = tone === "add" ? t.color.diffAddBg : t.color.diffDelBg;
|
|
108
|
+
const fg = tone === "add" ? t.color.diffAddFg : t.color.diffDelFg;
|
|
109
|
+
return (
|
|
110
|
+
<View
|
|
111
|
+
testID={`diff-${tone}`}
|
|
112
|
+
style={{
|
|
113
|
+
flexDirection: "row",
|
|
114
|
+
gap: t.space(2),
|
|
115
|
+
paddingHorizontal: t.space(3),
|
|
116
|
+
paddingVertical: t.space(2),
|
|
117
|
+
backgroundColor: bg,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<Text variant="mono" color={fg}>{sign}</Text>
|
|
121
|
+
<Text variant="mono" color={fg} style={{ flex: 1 }}>{formatValue(value)}</Text>
|
|
122
|
+
</View>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** An unchanged value: same frame as a read-only input, no diff tint. */
|
|
127
|
+
function ContextValue({ value }: { value: unknown }) {
|
|
128
|
+
const t = useTheme();
|
|
129
|
+
return (
|
|
130
|
+
<View
|
|
131
|
+
style={{
|
|
132
|
+
paddingHorizontal: t.space(3),
|
|
133
|
+
paddingVertical: t.space(2),
|
|
134
|
+
borderWidth: 1,
|
|
135
|
+
borderColor: t.color.borderDefault,
|
|
136
|
+
borderRadius: t.radius.md,
|
|
137
|
+
backgroundColor: t.color.surfaceRaised,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<Text variant="mono" color="secondary">{formatValue(value)}</Text>
|
|
141
|
+
</View>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function FieldRow({ field }: { field: FieldChange }) {
|
|
146
|
+
const t = useTheme();
|
|
147
|
+
const changed = field.kind !== "unchanged";
|
|
148
|
+
return (
|
|
149
|
+
<View testID={`change-field-${field.name}`} style={{ gap: t.space(2) }}>
|
|
150
|
+
<Text variant="label" color="tertiary">
|
|
151
|
+
{(field.label || field.name).toUpperCase()}
|
|
152
|
+
</Text>
|
|
153
|
+
{changed ? (
|
|
154
|
+
<View
|
|
155
|
+
style={{
|
|
156
|
+
borderRadius: t.radius.md,
|
|
157
|
+
borderWidth: 1,
|
|
158
|
+
borderColor: t.color.borderSubtle,
|
|
159
|
+
overflow: "hidden",
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{field.kind !== "added" ? <DiffLine sign="−" value={field.before} tone="del" /> : null}
|
|
163
|
+
{field.kind !== "removed" ? <DiffLine sign="+" value={field.after} tone="add" /> : null}
|
|
164
|
+
</View>
|
|
165
|
+
) : (
|
|
166
|
+
<ContextValue value={field.after} />
|
|
167
|
+
)}
|
|
168
|
+
</View>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A conflicted field: the two sides couldn't merge, so instead of a diff the user
|
|
174
|
+
* picks one — before (HEAD/target), after (Source) — or types an override. The
|
|
175
|
+
* whole row wears a subtle red wash so conflicts are unmistakable in a long form.
|
|
176
|
+
*/
|
|
177
|
+
function ConflictRow({
|
|
178
|
+
conflict,
|
|
179
|
+
label,
|
|
180
|
+
onResolve,
|
|
181
|
+
}: {
|
|
182
|
+
conflict: FieldConflict;
|
|
183
|
+
label: string;
|
|
184
|
+
onResolve?: (choice: ConflictChoice, override?: unknown) => void;
|
|
185
|
+
}) {
|
|
186
|
+
const t = useTheme();
|
|
187
|
+
const [overriding, setOverriding] = useState(conflict.resolution === "override");
|
|
188
|
+
const [draft, setDraft] = useState(
|
|
189
|
+
conflict.resolution === "override" ? "" : typeof conflict.after === "string" ? conflict.after : "",
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const choiceButton = (choice: ConflictChoice, text: string) => {
|
|
193
|
+
const active = conflict.resolution === choice;
|
|
194
|
+
return (
|
|
195
|
+
<Pressable
|
|
196
|
+
testID={`conflict-${conflict.id}-${choice}`}
|
|
197
|
+
onPress={() => {
|
|
198
|
+
if (choice === "override") {
|
|
199
|
+
setOverriding(true);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
setOverriding(false);
|
|
203
|
+
onResolve?.(choice);
|
|
204
|
+
}}
|
|
205
|
+
style={({ pressed }) => ({
|
|
206
|
+
paddingHorizontal: t.space(2.5),
|
|
207
|
+
height: t.control.sm,
|
|
208
|
+
justifyContent: "center",
|
|
209
|
+
borderRadius: t.radius.sm,
|
|
210
|
+
borderWidth: 1,
|
|
211
|
+
borderColor: active ? t.color.borderStrong : t.color.borderDefault,
|
|
212
|
+
backgroundColor: active ? t.color.surfaceInverted : "transparent",
|
|
213
|
+
opacity: pressed ? 0.7 : 1,
|
|
214
|
+
})}
|
|
215
|
+
>
|
|
216
|
+
<Text variant="sm" weight={active ? "semibold" : "regular"} color={active ? t.color.textInverted : "secondary"}>
|
|
217
|
+
{text}
|
|
218
|
+
</Text>
|
|
219
|
+
</Pressable>
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<View testID={`conflict-field-${conflict.field || "body"}`} style={{ gap: t.space(2) }}>
|
|
225
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
226
|
+
<Text variant="label" color="tertiary">{label.toUpperCase()}</Text>
|
|
227
|
+
<Badge label={conflict.resolution ? "Resolved" : "Conflict"} tone={conflict.resolution ? "add" : "del"} />
|
|
228
|
+
</View>
|
|
229
|
+
|
|
230
|
+
{/* The two candidate values, under the red wash. */}
|
|
231
|
+
<View
|
|
232
|
+
style={{
|
|
233
|
+
borderRadius: t.radius.md,
|
|
234
|
+
borderWidth: 1,
|
|
235
|
+
borderColor: t.color.diffDelFg,
|
|
236
|
+
backgroundColor: t.color.diffDelBg,
|
|
237
|
+
overflow: "hidden",
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
<ConflictSide label="before" value={conflict.before} chosen={conflict.resolution === "ours"} />
|
|
241
|
+
<View style={{ height: 1, backgroundColor: t.color.diffDelFg, opacity: 0.3 }} />
|
|
242
|
+
<ConflictSide label="after" value={conflict.after} chosen={conflict.resolution === "theirs"} />
|
|
243
|
+
</View>
|
|
244
|
+
|
|
245
|
+
{/* Resolution controls. */}
|
|
246
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2), flexWrap: "wrap" }}>
|
|
247
|
+
{choiceButton("ours", "Use before")}
|
|
248
|
+
{choiceButton("theirs", "Use after")}
|
|
249
|
+
{choiceButton("override", "Override")}
|
|
250
|
+
</View>
|
|
251
|
+
|
|
252
|
+
{overriding ? (
|
|
253
|
+
<View style={{ gap: t.space(2) }}>
|
|
254
|
+
<Input value={draft} onChangeText={setDraft} placeholder="Enter a value" testID={`conflict-${conflict.id}-override-input`} />
|
|
255
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
|
|
256
|
+
<Button
|
|
257
|
+
title="Save override"
|
|
258
|
+
variant="primary"
|
|
259
|
+
size="sm"
|
|
260
|
+
onPress={() => onResolve?.("override", draft)}
|
|
261
|
+
testID={`conflict-${conflict.id}-override-save`}
|
|
262
|
+
/>
|
|
263
|
+
</View>
|
|
264
|
+
</View>
|
|
265
|
+
) : null}
|
|
266
|
+
</View>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function ConflictSide({ label, value, chosen }: { label: string; value: unknown; chosen: boolean }) {
|
|
271
|
+
const t = useTheme();
|
|
272
|
+
return (
|
|
273
|
+
<View style={{ paddingHorizontal: t.space(3), paddingVertical: t.space(2), gap: 2, backgroundColor: chosen ? t.color.diffAddBg : "transparent" }}>
|
|
274
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(1.5) }}>
|
|
275
|
+
<Text variant="monoSm" color={chosen ? t.color.diffAddFg : "tertiary"}>{label}</Text>
|
|
276
|
+
{chosen ? <Icon name="check" size={12} color={t.color.diffAddFg} /> : null}
|
|
277
|
+
</View>
|
|
278
|
+
<Text variant="mono" color={chosen ? t.color.diffAddFg : "secondary"}>{formatValue(value)}</Text>
|
|
279
|
+
</View>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The body diff. Bodies are prose, so unlike a scalar field they are shown as
|
|
285
|
+
* stacked blocks rather than a single line — the whole before and the whole
|
|
286
|
+
* after, each tinted, so a reviewer can read them.
|
|
287
|
+
*/
|
|
288
|
+
function BodyDiff({ before, after }: { before?: string | null; after?: string | null }) {
|
|
289
|
+
const t = useTheme();
|
|
290
|
+
if ((before ?? "") === (after ?? "")) {
|
|
291
|
+
if (!after) return null;
|
|
292
|
+
return (
|
|
293
|
+
<View style={{ gap: t.space(2) }}>
|
|
294
|
+
<Text variant="label" color="tertiary">BODY</Text>
|
|
295
|
+
<ContextValue value={after} />
|
|
296
|
+
</View>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return (
|
|
300
|
+
<View testID="change-body" style={{ gap: t.space(2) }}>
|
|
301
|
+
<Text variant="label" color="tertiary">BODY</Text>
|
|
302
|
+
<View
|
|
303
|
+
style={{
|
|
304
|
+
borderRadius: t.radius.md,
|
|
305
|
+
borderWidth: 1,
|
|
306
|
+
borderColor: t.color.borderSubtle,
|
|
307
|
+
overflow: "hidden",
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
{before ? <DiffLine sign="−" value={before} tone="del" /> : null}
|
|
311
|
+
{after ? <DiffLine sign="+" value={after} tone="add" /> : null}
|
|
312
|
+
</View>
|
|
313
|
+
</View>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Which render classes are worth showing. A PDF or a video has no cheap inline
|
|
318
|
+
// representation, so those fall through to their metadata rather than to a
|
|
319
|
+
// broken frame.
|
|
320
|
+
const PREVIEWABLE = new Set(["image", "svg"]);
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The asset itself, above its metadata. A modification shows both versions
|
|
324
|
+
* side by side under the same −/+ tinting as a field diff; an addition shows
|
|
325
|
+
* only the new one.
|
|
326
|
+
*/
|
|
327
|
+
function AssetPreview({ preview }: { preview: ChangePreview }) {
|
|
328
|
+
const t = useTheme();
|
|
329
|
+
if (!PREVIEWABLE.has(preview.kind ?? "")) return null;
|
|
330
|
+
// No current version means the file was deleted — there is nothing to show.
|
|
331
|
+
if (!preview.afterUrl) return null;
|
|
332
|
+
|
|
333
|
+
const frame = (url: string, tone: "add" | "del", label: string) => (
|
|
334
|
+
<View
|
|
335
|
+
key={label}
|
|
336
|
+
testID={`preview-${tone}`}
|
|
337
|
+
style={{
|
|
338
|
+
flex: 1,
|
|
339
|
+
gap: t.space(1),
|
|
340
|
+
padding: t.space(2),
|
|
341
|
+
borderRadius: t.radius.md,
|
|
342
|
+
backgroundColor: tone === "add" ? t.color.diffAddBg : t.color.diffDelBg,
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
<Text variant="monoSm" color={tone === "add" ? t.color.diffAddFg : t.color.diffDelFg}>
|
|
346
|
+
{label}
|
|
347
|
+
</Text>
|
|
348
|
+
<Image
|
|
349
|
+
source={{ uri: url }}
|
|
350
|
+
// contain, not cover: this is a diff, and cropping to fill would hide
|
|
351
|
+
// the very change (an aspect-ratio or framing edit) being reviewed.
|
|
352
|
+
resizeMode="contain"
|
|
353
|
+
style={{ width: "100%", height: 180, borderRadius: t.radius.sm }}
|
|
354
|
+
accessibilityLabel={`${label} version`}
|
|
355
|
+
/>
|
|
356
|
+
</View>
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const showBefore = !!preview.beforeUrl;
|
|
360
|
+
return (
|
|
361
|
+
<View testID="change-preview" style={{ gap: t.space(2) }}>
|
|
362
|
+
<Text variant="label" color="tertiary">PREVIEW</Text>
|
|
363
|
+
<View style={{ flexDirection: "row", gap: t.space(2) }}>
|
|
364
|
+
{showBefore ? frame(preview.beforeUrl!, "del", "− before") : null}
|
|
365
|
+
{frame(preview.afterUrl, "add", showBefore ? "+ after" : "+ added")}
|
|
366
|
+
</View>
|
|
367
|
+
</View>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The changes surface's detail pane: one document rendered field by field, with
|
|
373
|
+
* changed fields showing their before/after and unchanged fields shown as
|
|
374
|
+
* context. Strictly read-only — editing happens on the Edit surface, and this
|
|
375
|
+
* view describes a comparison rather than a document you can save.
|
|
376
|
+
*/
|
|
377
|
+
export function ChangeDetail({ change, loading, emptyMessage = "Select a change", conflicts, onResolveConflict }: ChangeDetailProps) {
|
|
378
|
+
const t = useTheme();
|
|
379
|
+
|
|
380
|
+
// Conflicts keyed by field name (and a body flag), so each field can check for
|
|
381
|
+
// one as it renders. The body conflict is looked up separately.
|
|
382
|
+
const conflictByField = new Map<string, FieldConflict>();
|
|
383
|
+
let bodyConflict: FieldConflict | undefined;
|
|
384
|
+
for (const c of conflicts ?? []) {
|
|
385
|
+
if (c.isBody) bodyConflict = c;
|
|
386
|
+
else conflictByField.set(c.field, c);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (loading) {
|
|
390
|
+
return (
|
|
391
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
|
392
|
+
<Text variant="monoSm" color="tertiary" testID="change-loading">Loading…</Text>
|
|
393
|
+
</View>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (!change) {
|
|
397
|
+
return (
|
|
398
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
399
|
+
<Text variant="body" color="tertiary" testID="change-empty">{emptyMessage}</Text>
|
|
400
|
+
</View>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<View testID="change-detail" style={{ flex: 1, minHeight: 0, backgroundColor: t.color.surfacePage }}>
|
|
406
|
+
<View
|
|
407
|
+
style={{
|
|
408
|
+
height: t.layout.topbar,
|
|
409
|
+
paddingHorizontal: t.space(4),
|
|
410
|
+
flexDirection: "row",
|
|
411
|
+
alignItems: "center",
|
|
412
|
+
gap: t.space(2),
|
|
413
|
+
borderBottomWidth: 1,
|
|
414
|
+
borderBottomColor: t.color.borderSubtle,
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
<Icon name="file" size={15} color={t.color.textSecondary} />
|
|
418
|
+
<Text variant="monoSm" numberOfLines={1} style={{ flex: 1 }}>{change.path}</Text>
|
|
419
|
+
<Badge label={STATUS_LABEL[change.status]} tone={change.status === "D" ? "del" : change.status === "A" ? "add" : "neutral"} />
|
|
420
|
+
<DiffStat added={change.added} removed={change.removed} />
|
|
421
|
+
</View>
|
|
422
|
+
|
|
423
|
+
<ScrollView
|
|
424
|
+
key={change.path}
|
|
425
|
+
testID="change-scroll"
|
|
426
|
+
style={{ flex: 1, minHeight: 0 }}
|
|
427
|
+
contentContainerStyle={{ padding: t.space(5), gap: t.space(4) }}
|
|
428
|
+
>
|
|
429
|
+
{change.previousPath ? (
|
|
430
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
431
|
+
<Text variant="monoSm" color="tertiary">{change.previousPath}</Text>
|
|
432
|
+
<Text variant="monoSm" color="tertiary">→</Text>
|
|
433
|
+
<Text variant="monoSm">{change.path}</Text>
|
|
434
|
+
</View>
|
|
435
|
+
) : null}
|
|
436
|
+
{change.preview ? <AssetPreview preview={change.preview} /> : null}
|
|
437
|
+
{change.fields.map((f) => {
|
|
438
|
+
const conflict = conflictByField.get(f.name);
|
|
439
|
+
conflictByField.delete(f.name); // consumed → the leftovers render below
|
|
440
|
+
return conflict ? (
|
|
441
|
+
<ConflictRow
|
|
442
|
+
key={f.name}
|
|
443
|
+
conflict={conflict}
|
|
444
|
+
label={f.label || f.name}
|
|
445
|
+
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(conflict, choice, override) : undefined}
|
|
446
|
+
/>
|
|
447
|
+
) : (
|
|
448
|
+
<FieldRow key={f.name} field={f} />
|
|
449
|
+
);
|
|
450
|
+
})}
|
|
451
|
+
{/* Conflicts on fields the schema-ordered diff didn't surface (rare). */}
|
|
452
|
+
{[...conflictByField.values()].map((c) => (
|
|
453
|
+
<ConflictRow
|
|
454
|
+
key={c.field}
|
|
455
|
+
conflict={c}
|
|
456
|
+
label={c.field}
|
|
457
|
+
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(c, choice, override) : undefined}
|
|
458
|
+
/>
|
|
459
|
+
))}
|
|
460
|
+
{bodyConflict ? (
|
|
461
|
+
<ConflictRow
|
|
462
|
+
conflict={bodyConflict}
|
|
463
|
+
label="Body"
|
|
464
|
+
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(bodyConflict!, choice, override) : undefined}
|
|
465
|
+
/>
|
|
466
|
+
) : (
|
|
467
|
+
<BodyDiff before={change.bodyBefore} after={change.bodyAfter} />
|
|
468
|
+
)}
|
|
469
|
+
</ScrollView>
|
|
470
|
+
</View>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, ScrollView, Linking } 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 { Badge } from "./primitives";
|
|
8
|
+
|
|
9
|
+
export type ChangeRequestComment = {
|
|
10
|
+
author: string;
|
|
11
|
+
body: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ChangeRequestSummaryData = {
|
|
16
|
+
id: string;
|
|
17
|
+
status: "open" | "rejected" | "completed" | string;
|
|
18
|
+
sourceBranch: string;
|
|
19
|
+
targetBranch: string;
|
|
20
|
+
prNumber: number;
|
|
21
|
+
prUrl: string;
|
|
22
|
+
explanation: string;
|
|
23
|
+
conflictFiles: string[];
|
|
24
|
+
mergeBranch: string;
|
|
25
|
+
description?: string | null;
|
|
26
|
+
comments: ChangeRequestComment[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ChangeRequestSummaryProps = {
|
|
30
|
+
data: ChangeRequestSummaryData;
|
|
31
|
+
/** The repository's clone URL, for the developer instructions. */
|
|
32
|
+
cloneUrl?: string;
|
|
33
|
+
/** Back to the changes view. */
|
|
34
|
+
onBack?: () => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const STATUS_TONE: Record<string, "neutral" | "add" | "del" | "strong"> = {
|
|
38
|
+
open: "neutral",
|
|
39
|
+
completed: "add",
|
|
40
|
+
rejected: "del",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The change-request summary surface: the escalated pull request's description
|
|
45
|
+
* and comments, the conflicting files, and developer instructions for resolving
|
|
46
|
+
* them locally or on GitHub. Read-only — the developer acts on GitHub, and the
|
|
47
|
+
* webhook drives the status.
|
|
48
|
+
*/
|
|
49
|
+
export function ChangeRequestSummary({ data, cloneUrl, onBack }: ChangeRequestSummaryProps) {
|
|
50
|
+
const t = useTheme();
|
|
51
|
+
const tone = STATUS_TONE[data.status] ?? "neutral";
|
|
52
|
+
const open = data.status === "open";
|
|
53
|
+
// `data` may come from the lighter openChangeRequest fragment, which omits the
|
|
54
|
+
// live comments/description until the full query resolves.
|
|
55
|
+
const comments = data.comments ?? [];
|
|
56
|
+
const conflictFiles = data.conflictFiles ?? [];
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<ScrollView
|
|
60
|
+
testID="change-request-summary"
|
|
61
|
+
style={{ flex: 1 }}
|
|
62
|
+
contentContainerStyle={{ padding: t.space(5), gap: t.space(5), maxWidth: 820, width: "100%", alignSelf: "center" }}
|
|
63
|
+
>
|
|
64
|
+
{/* Header */}
|
|
65
|
+
<View style={{ gap: t.space(3) }}>
|
|
66
|
+
{onBack ? (
|
|
67
|
+
<Button title="Back to changes" variant="ghost" size="sm" iconLeft="chevronLeft" onPress={onBack} testID="cr-back" />
|
|
68
|
+
) : null}
|
|
69
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(3), flexWrap: "wrap" }}>
|
|
70
|
+
<Icon name="gitPullRequest" size={20} color={t.color.textSecondary} />
|
|
71
|
+
<Text variant="h2" weight="semibold">Change request</Text>
|
|
72
|
+
<Badge tone={tone} label={data.status} testID="cr-status" />
|
|
73
|
+
<Text variant="monoSm" color="tertiary">{`#${data.prNumber}`}</Text>
|
|
74
|
+
</View>
|
|
75
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
76
|
+
<BranchChip name={data.sourceBranch} />
|
|
77
|
+
<Text variant="monoSm" color="tertiary">→</Text>
|
|
78
|
+
<BranchChip name={data.targetBranch} />
|
|
79
|
+
</View>
|
|
80
|
+
<View style={{ flexDirection: "row", gap: t.space(2) }}>
|
|
81
|
+
<Button title="Open on GitHub" variant="primary" size="sm" iconLeft="link" onPress={() => Linking.openURL(data.prUrl)} testID="cr-open-github" />
|
|
82
|
+
</View>
|
|
83
|
+
</View>
|
|
84
|
+
|
|
85
|
+
{/* Explanation / description */}
|
|
86
|
+
<Section title="Description">
|
|
87
|
+
{data.explanation ? <Text variant="body" color="primary">{data.explanation}</Text> : null}
|
|
88
|
+
{data.description ? (
|
|
89
|
+
<Text variant="body" color="secondary" style={{ marginTop: t.space(2) }}>{data.description}</Text>
|
|
90
|
+
) : null}
|
|
91
|
+
</Section>
|
|
92
|
+
|
|
93
|
+
{/* Conflicting files */}
|
|
94
|
+
<Section title={`Conflicting files (${conflictFiles.length})`}>
|
|
95
|
+
{conflictFiles.length === 0 ? (
|
|
96
|
+
<Text variant="body" color="tertiary">None recorded.</Text>
|
|
97
|
+
) : (
|
|
98
|
+
<View style={{ gap: 2 }}>
|
|
99
|
+
{conflictFiles.map((f) => (
|
|
100
|
+
<View key={f} style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
101
|
+
<Icon name="fileText" size={14} color={t.color.diffDelFg} />
|
|
102
|
+
<Text variant="monoSm" color={t.color.diffDelFg} testID={`cr-file-${f}`}>{f}</Text>
|
|
103
|
+
</View>
|
|
104
|
+
))}
|
|
105
|
+
</View>
|
|
106
|
+
)}
|
|
107
|
+
</Section>
|
|
108
|
+
|
|
109
|
+
{/* Developer instructions */}
|
|
110
|
+
{open ? (
|
|
111
|
+
<Section title="Resolve it">
|
|
112
|
+
<Text variant="body" color="secondary">
|
|
113
|
+
The CMS content is already merged on this branch. The files above changed on both branches — merge the pull request and resolve them with normal git conflict resolution (on GitHub, or locally):
|
|
114
|
+
</Text>
|
|
115
|
+
<View style={{ gap: 2, padding: t.space(3), borderRadius: t.radius.md, backgroundColor: t.color.surfaceSunken }}>
|
|
116
|
+
{cloneUrl ? <Text variant="monoSm" color="secondary">{`git clone ${cloneUrl}`}</Text> : null}
|
|
117
|
+
<Text variant="monoSm" color="secondary">{`git fetch origin ${data.targetBranch} ${data.mergeBranch}`}</Text>
|
|
118
|
+
<Text variant="monoSm" color="secondary">{`git checkout ${data.mergeBranch}`}</Text>
|
|
119
|
+
<Text variant="monoSm" color="secondary">{`git merge origin/${data.targetBranch}`}</Text>
|
|
120
|
+
{conflictFiles.map((f) => (
|
|
121
|
+
<Text key={f} variant="monoSm" color="tertiary">{`# resolve ${f}`}</Text>
|
|
122
|
+
))}
|
|
123
|
+
</View>
|
|
124
|
+
</Section>
|
|
125
|
+
) : null}
|
|
126
|
+
|
|
127
|
+
{/* Comments */}
|
|
128
|
+
<Section title={`Comments (${comments.length})`}>
|
|
129
|
+
{comments.length === 0 ? (
|
|
130
|
+
<Text variant="body" color="tertiary">No comments yet.</Text>
|
|
131
|
+
) : (
|
|
132
|
+
<View style={{ gap: t.space(3) }}>
|
|
133
|
+
{comments.map((c, i) => (
|
|
134
|
+
<View key={i} style={{ gap: 2, padding: t.space(3), borderRadius: t.radius.md, borderWidth: 1, borderColor: t.color.borderDefault }}>
|
|
135
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
136
|
+
<Text variant="body" weight="medium">{c.author}</Text>
|
|
137
|
+
<Text variant="monoSm" color="tertiary">{c.createdAt.slice(0, 10)}</Text>
|
|
138
|
+
</View>
|
|
139
|
+
<Text variant="body" color="secondary">{c.body}</Text>
|
|
140
|
+
</View>
|
|
141
|
+
))}
|
|
142
|
+
</View>
|
|
143
|
+
)}
|
|
144
|
+
</Section>
|
|
145
|
+
</ScrollView>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
150
|
+
const t = useTheme();
|
|
151
|
+
return (
|
|
152
|
+
<View style={{ gap: t.space(2) }}>
|
|
153
|
+
<Text variant="h3" weight="semibold">{title}</Text>
|
|
154
|
+
{children}
|
|
155
|
+
</View>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function BranchChip({ name }: { name: string }) {
|
|
160
|
+
const t = useTheme();
|
|
161
|
+
return (
|
|
162
|
+
<View
|
|
163
|
+
style={{
|
|
164
|
+
flexDirection: "row", alignItems: "center", gap: 6,
|
|
165
|
+
paddingHorizontal: t.space(2), height: t.control.sm,
|
|
166
|
+
borderRadius: t.radius.md, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<Icon name="gitBranch" size={13} color={t.color.textSecondary} />
|
|
170
|
+
<Text variant="monoSm" color="secondary">{name}</Text>
|
|
171
|
+
</View>
|
|
172
|
+
);
|
|
173
|
+
}
|