@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.
Files changed (70) hide show
  1. package/README.md +77 -0
  2. package/css/components.css +312 -0
  3. package/css/tokens.css +212 -0
  4. package/package.json +65 -0
  5. package/src/ThemeProvider.tsx +86 -0
  6. package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  7. package/src/__tests__/BranchImport.test.tsx +46 -0
  8. package/src/__tests__/Button.test.tsx +45 -0
  9. package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  11. package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  12. package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  13. package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  14. package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  15. package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  16. package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  17. package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  18. package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  19. package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  20. package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  21. package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  22. package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  23. package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  24. package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  25. package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  26. package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  27. package/src/__tests__/MediaBrowser.test.tsx +353 -0
  28. package/src/__tests__/MediaField.test.tsx +185 -0
  29. package/src/__tests__/Notifications.test.tsx +69 -0
  30. package/src/__tests__/Onboarding.test.tsx +287 -0
  31. package/src/__tests__/cssTokens.test.ts +201 -0
  32. package/src/__tests__/fieldComponents.test.ts +43 -0
  33. package/src/__tests__/reorder.test.ts +58 -0
  34. package/src/components/ApplyChangesModal.tsx +348 -0
  35. package/src/components/BranchImport.tsx +157 -0
  36. package/src/components/BranchMenu.tsx +192 -0
  37. package/src/components/Button.tsx +131 -0
  38. package/src/components/ChangeDetail.tsx +472 -0
  39. package/src/components/ChangeRequestSummary.tsx +173 -0
  40. package/src/components/CollabField.tsx +388 -0
  41. package/src/components/ContentBrowser.tsx +5073 -0
  42. package/src/components/Icon.tsx +28 -0
  43. package/src/components/Icon.web.tsx +31 -0
  44. package/src/components/Input.tsx +106 -0
  45. package/src/components/MediaBrowser.tsx +766 -0
  46. package/src/components/MediaField.tsx +670 -0
  47. package/src/components/MediaPreview.tsx +91 -0
  48. package/src/components/MediaPreview.web.tsx +169 -0
  49. package/src/components/NavRow.tsx +105 -0
  50. package/src/components/Notifications.tsx +301 -0
  51. package/src/components/Onboarding.tsx +751 -0
  52. package/src/components/ProjectMenu.tsx +124 -0
  53. package/src/components/Segment.tsx +87 -0
  54. package/src/components/Skeleton.tsx +216 -0
  55. package/src/components/Spinner.tsx +44 -0
  56. package/src/components/Text.tsx +85 -0
  57. package/src/components/documentDrafts.ts +213 -0
  58. package/src/components/layout.tsx +284 -0
  59. package/src/components/primitives.tsx +143 -0
  60. package/src/components/reorder.ts +40 -0
  61. package/src/fieldComponents.ts +63 -0
  62. package/src/icons.ts +102 -0
  63. package/src/index.ts +198 -0
  64. package/src/media.ts +229 -0
  65. package/src/theme.ts +116 -0
  66. package/src/web/Button.tsx +110 -0
  67. package/src/web/Icon.tsx +52 -0
  68. package/src/web/Input.tsx +39 -0
  69. package/src/web/index.ts +43 -0
  70. 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
+ }