@gogitcms/design-system 0.16.0-next.3 → 0.16.0-next.5

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.
@@ -0,0 +1,408 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { View, FlatList, Pressable, ActivityIndicator } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { Badge } from "./primitives";
7
+ import { Button } from "./Button";
8
+ import { BODY_FIELD, ChangeDetail, type DocumentChange } from "./ChangeDetail";
9
+ import { firstLine, relativeTime, type DocumentVersion, type HistoryApi } from "../history";
10
+
11
+ /** How many versions a page holds. One page is one provider request. */
12
+ const PAGE = 20;
13
+
14
+ export type DocumentHistoryProps = {
15
+ /** The document whose past is being read. */
16
+ documentId: string;
17
+ /** Shown above the list, so a narrow column still says what it is looking at. */
18
+ documentPath: string;
19
+ history: HistoryApi;
20
+ /** Leaves history and returns the column to the editor. */
21
+ onClose: () => void;
22
+ /**
23
+ * Put a past version's values for the chosen fields back into the editor.
24
+ *
25
+ * It restores into the FORM, not into git and not straight to a save: the
26
+ * document becomes unsaved with those fields rolled back, and the author
27
+ * reviews and saves it like any other edit. Reading history and rewriting the
28
+ * document are two different acts, and only one of them should be one click
29
+ * from a list you are still browsing.
30
+ *
31
+ * `body` is present only when the body was among the chosen fields — null is
32
+ * a real restorable value (a document that had no body), so its absence has
33
+ * to be expressed by the key being missing rather than by null.
34
+ *
35
+ * Omitted (a read-only document) → no boxes and no restore bar, only reading.
36
+ */
37
+ onRestore?: (restored: { fields: Record<string, unknown>; body?: string | null }) => void;
38
+ };
39
+
40
+ /**
41
+ * DocumentHistory is the version browser that takes over a document's column:
42
+ * the versions on the right, and on the left the document as it was at the
43
+ * selected one, diffed against what it is now.
44
+ *
45
+ * It owns its own state — which version is selected, the pages loaded so far —
46
+ * because that state is ephemeral by design: history is a thing you open, read
47
+ * and close, not a place the editor navigates to. Nothing here survives closing
48
+ * the pane, and nothing about it is in the URL.
49
+ */
50
+ export function DocumentHistory({ documentId, documentPath, history, onClose, onRestore }: DocumentHistoryProps) {
51
+ const t = useTheme();
52
+
53
+ const [versions, setVersions] = useState<DocumentVersion[]>([]);
54
+ const [loading, setLoading] = useState(true);
55
+ const [loadingMore, setLoadingMore] = useState(false);
56
+ const [exhausted, setExhausted] = useState(false);
57
+ const [error, setError] = useState<string | null>(null);
58
+
59
+ const [selected, setSelected] = useState<string | null>(null);
60
+ const [detail, setDetail] = useState<DocumentChange | undefined>(undefined);
61
+ const [loadingDetail, setLoadingDetail] = useState(false);
62
+ const [detailError, setDetailError] = useState<string | null>(null);
63
+ // Which fields the reader has picked to take from this version. Cleared on
64
+ // every version change below: a tick means "restore THIS value", so carrying
65
+ // it to another version would silently repoint it at a different one.
66
+ const [picked, setPicked] = useState<readonly string[]>([]);
67
+ const togglePick = useCallback(
68
+ (name: string) => setPicked((p) => (p.includes(name) ? p.filter((n) => n !== name) : [...p, name])),
69
+ [],
70
+ );
71
+
72
+ // Guards every async result against a document switch or an unmount: the pane
73
+ // is inside a column whose document can change under it, and a page that
74
+ // arrives after that would otherwise be listed as this document's history.
75
+ const liveFor = useRef(documentId);
76
+ useEffect(() => {
77
+ liveFor.current = documentId;
78
+ return () => {
79
+ liveFor.current = "";
80
+ };
81
+ }, [documentId]);
82
+
83
+ // First page. Selecting its newest version immediately is what makes the pane
84
+ // useful on open — an empty diff beside a full list is a second click for
85
+ // nothing.
86
+ useEffect(() => {
87
+ let cancelled = false;
88
+ setLoading(true);
89
+ setError(null);
90
+ setVersions([]);
91
+ setExhausted(false);
92
+ setSelected(null);
93
+ setDetail(undefined);
94
+ setPicked([]);
95
+ history
96
+ .list({ documentId, limit: PAGE, offset: 0 })
97
+ .then((page) => {
98
+ if (cancelled || liveFor.current !== documentId) return;
99
+ setVersions(page);
100
+ setExhausted(page.length < PAGE);
101
+ if (page.length > 0) setSelected(page[0].sha);
102
+ })
103
+ .catch((e: unknown) => {
104
+ if (cancelled || liveFor.current !== documentId) return;
105
+ setError(messageOf(e));
106
+ })
107
+ .finally(() => {
108
+ if (!cancelled) setLoading(false);
109
+ });
110
+ return () => {
111
+ cancelled = true;
112
+ };
113
+ }, [documentId, history]);
114
+
115
+ const loadMore = useCallback(() => {
116
+ if (loading || loadingMore || exhausted) return;
117
+ setLoadingMore(true);
118
+ const offset = versions.length;
119
+ history
120
+ .list({ documentId, limit: PAGE, offset })
121
+ .then((page) => {
122
+ if (liveFor.current !== documentId) return;
123
+ // Offsets can overlap when the branch moves under a paging read, so
124
+ // fold by sha rather than appending blindly — a repeated version would
125
+ // otherwise be a duplicate React key and a second identical row.
126
+ setVersions((prev) => mergeBySha(prev, page));
127
+ setExhausted(page.length < PAGE);
128
+ })
129
+ .catch(() => {
130
+ // A failed page is not a failed history: what is already listed stays
131
+ // readable, and the end-of-list footer stops offering more.
132
+ setExhausted(true);
133
+ })
134
+ .finally(() => setLoadingMore(false));
135
+ }, [documentId, history, loading, loadingMore, exhausted, versions.length]);
136
+
137
+ // The selected version's document + diff.
138
+ useEffect(() => {
139
+ // A tick names a value, not a field: it means "take THIS version's title".
140
+ // Carrying the selection to the next version would keep the ticks while
141
+ // silently repointing them at different values.
142
+ setPicked([]);
143
+ if (!selected) {
144
+ setDetail(undefined);
145
+ return;
146
+ }
147
+ let cancelled = false;
148
+ setLoadingDetail(true);
149
+ setDetailError(null);
150
+ history
151
+ .get({ documentId, sha: selected })
152
+ .then((d) => {
153
+ if (cancelled || liveFor.current !== documentId) return;
154
+ setDetail(d ?? undefined);
155
+ if (!d) setDetailError("This version could not be read.");
156
+ })
157
+ .catch((e: unknown) => {
158
+ if (cancelled || liveFor.current !== documentId) return;
159
+ setDetail(undefined);
160
+ setDetailError(messageOf(e));
161
+ })
162
+ .finally(() => {
163
+ if (!cancelled) setLoadingDetail(false);
164
+ });
165
+ return () => {
166
+ cancelled = true;
167
+ };
168
+ }, [documentId, history, selected]);
169
+
170
+ // Restore is offered only for a document the caller can write AND a version
171
+ // that differs from it: a version identical to the current document has no
172
+ // field whose old value is anything but the value already there.
173
+ const canRestore = !!onRestore && !!detail && detail.status !== "U";
174
+
175
+ const restore = useCallback(() => {
176
+ if (!onRestore || !detail) return;
177
+ const chosen = new Set(picked);
178
+ const fields: Record<string, unknown> = {};
179
+ for (const f of detail.fields) {
180
+ if (!chosen.has(f.name)) continue;
181
+ // `before` is this version's value, which is the whole point — and it is
182
+ // undefined for a field the version did not have. Restoring that has to
183
+ // mean "clear it", so it is written as undefined rather than skipped.
184
+ fields[f.name] = f.before;
185
+ }
186
+ const restored: { fields: Record<string, unknown>; body?: string | null } = { fields };
187
+ // The key is present only when the body was picked: null is a real value to
188
+ // restore (a document that had no body), so absence cannot be spelled null.
189
+ if (chosen.has(BODY_FIELD)) restored.body = detail.bodyBefore ?? null;
190
+ onRestore(restored);
191
+ }, [onRestore, detail, picked]);
192
+
193
+ return (
194
+ <View testID="document-history" style={{ flex: 1, minHeight: 0, flexDirection: "row" }}>
195
+ {/* Left: the document at the selected version, annotated with what has
196
+ changed since. ChangeDetail is the changes surface's pane, used
197
+ unchanged — a version diff and a branch diff are the same question
198
+ asked of different pairs. */}
199
+ <View style={{ flex: 1, minWidth: 0 }}>
200
+ {detailError && !loadingDetail ? (
201
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
202
+ <Text variant="body" color="tertiary" testID="history-detail-error">{detailError}</Text>
203
+ </View>
204
+ ) : (
205
+ <ChangeDetail
206
+ change={detail}
207
+ loading={loadingDetail}
208
+ emptyMessage={loading ? "Loading history…" : "Select a version"}
209
+ selectedFields={picked}
210
+ onToggleField={canRestore ? togglePick : undefined}
211
+ />
212
+ )}
213
+ {canRestore ? <RestoreBar count={picked.length} onRestore={restore} onClear={() => setPicked([])} /> : null}
214
+ </View>
215
+
216
+ {/* Right: the versions. */}
217
+ <View
218
+ style={{
219
+ width: 264,
220
+ borderLeftWidth: 1,
221
+ borderLeftColor: t.color.borderSubtle,
222
+ backgroundColor: t.color.surfaceSunken,
223
+ minHeight: 0,
224
+ }}
225
+ >
226
+ <View
227
+ style={{
228
+ height: t.layout.topbar,
229
+ paddingHorizontal: t.space(3),
230
+ flexDirection: "row",
231
+ alignItems: "center",
232
+ gap: t.space(2),
233
+ borderBottomWidth: 1,
234
+ borderBottomColor: t.color.borderSubtle,
235
+ }}
236
+ >
237
+ <Icon name="history" size={15} color={t.color.textSecondary} />
238
+ <Text variant="monoSm" numberOfLines={1} style={{ flex: 1 }}>History</Text>
239
+ <Pressable onPress={onClose} testID="history-close" hitSlop={8} style={{ padding: t.space(1) }}>
240
+ <Icon name="x" size={15} color={t.color.textTertiary} />
241
+ </Pressable>
242
+ </View>
243
+
244
+ {loading ? (
245
+ <View style={{ padding: t.space(4), alignItems: "center" }}>
246
+ <ActivityIndicator testID="history-loading" />
247
+ </View>
248
+ ) : error ? (
249
+ <View style={{ padding: t.space(4), gap: t.space(2) }}>
250
+ <Text variant="monoSm" color={t.color.diffDelFg} testID="history-error">{error}</Text>
251
+ </View>
252
+ ) : versions.length === 0 ? (
253
+ <View style={{ padding: t.space(4) }}>
254
+ <Text variant="monoSm" color="tertiary" testID="history-empty">
255
+ No commits yet for {documentPath}. A document saved in the CMS appears here once it has been pushed to git.
256
+ </Text>
257
+ </View>
258
+ ) : (
259
+ <FlatList
260
+ testID="history-list"
261
+ data={versions}
262
+ keyExtractor={(v) => v.sha}
263
+ style={{ flex: 1, minHeight: 0 }}
264
+ onEndReached={loadMore}
265
+ onEndReachedThreshold={0.4}
266
+ renderItem={({ item }) => (
267
+ <VersionRow
268
+ version={item}
269
+ selected={item.sha === selected}
270
+ onPress={() => setSelected(item.sha)}
271
+ />
272
+ )}
273
+ ListFooterComponent={
274
+ loadingMore ? (
275
+ <View style={{ padding: t.space(3), alignItems: "center" }}>
276
+ <ActivityIndicator testID="history-loading-more" />
277
+ </View>
278
+ ) : exhausted ? (
279
+ // Why the list ends matters here: GitHub's commits API cannot
280
+ // follow a rename, so "that's all" can mean "that is where this
281
+ // path began" rather than "that is where the document began".
282
+ <View style={{ padding: t.space(3) }}>
283
+ <Text variant="monoSm" color="tertiary" testID="history-end">
284
+ End of history for this path. Commits from before a rename are not listed.
285
+ </Text>
286
+ </View>
287
+ ) : null
288
+ }
289
+ />
290
+ )}
291
+ </View>
292
+ </View>
293
+ );
294
+ }
295
+
296
+ /**
297
+ * The restore action, docked under the diff.
298
+ *
299
+ * Always mounted while a writable version is selected, rather than appearing
300
+ * once something is ticked: a bar that materialises on the first tick shifts
301
+ * the diff under the hand that just ticked it, and the disabled button is what
302
+ * tells you the boxes are for something in the first place.
303
+ */
304
+ function RestoreBar({
305
+ count,
306
+ onRestore,
307
+ onClear,
308
+ }: {
309
+ count: number;
310
+ onRestore: () => void;
311
+ onClear: () => void;
312
+ }) {
313
+ const t = useTheme();
314
+ return (
315
+ <View
316
+ testID="restore-bar"
317
+ style={{
318
+ flexDirection: "row",
319
+ alignItems: "center",
320
+ gap: t.space(2),
321
+ paddingHorizontal: t.space(4),
322
+ paddingVertical: t.space(2),
323
+ borderTopWidth: 1,
324
+ borderTopColor: t.color.borderSubtle,
325
+ backgroundColor: t.color.surfaceSunken,
326
+ }}
327
+ >
328
+ <Text variant="monoSm" color="tertiary" style={{ flex: 1 }}>
329
+ {count === 0
330
+ ? "Tick a field to take its earlier value"
331
+ : count === 1
332
+ ? "1 field selected"
333
+ : `${count} fields selected`}
334
+ </Text>
335
+ {count > 0 ? (
336
+ <Pressable onPress={onClear} testID="restore-clear" style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}>
337
+ <Text variant="monoSm" color="tertiary">Clear</Text>
338
+ </Pressable>
339
+ ) : null}
340
+ <Button
341
+ testID="restore-apply"
342
+ // Named for what it does to the thing in front of you. It does not write
343
+ // to git and does not save — it puts the values back in the form, and
344
+ // the author still has to save them.
345
+ title={count > 1 ? `Restore ${count} fields` : "Restore field"}
346
+ size="sm"
347
+ variant={count > 0 ? "primary" : "default"}
348
+ disabled={count === 0}
349
+ onPress={onRestore}
350
+ style={{ alignSelf: "center" }}
351
+ />
352
+ </View>
353
+ );
354
+ }
355
+
356
+ function VersionRow({
357
+ version,
358
+ selected,
359
+ onPress,
360
+ }: {
361
+ version: DocumentVersion;
362
+ selected: boolean;
363
+ onPress: () => void;
364
+ }) {
365
+ const t = useTheme();
366
+ return (
367
+ <Pressable
368
+ testID={`version-${version.shortSha}`}
369
+ onPress={onPress}
370
+ style={{
371
+ paddingHorizontal: t.space(3),
372
+ paddingVertical: t.space(3),
373
+ gap: t.space(1),
374
+ borderBottomWidth: 1,
375
+ borderBottomColor: t.color.borderSubtle,
376
+ backgroundColor: selected ? t.color.surfaceRaised : "transparent",
377
+ borderLeftWidth: 2,
378
+ borderLeftColor: selected ? t.color.borderActive : "transparent",
379
+ }}
380
+ >
381
+ <Text variant="sm" numberOfLines={2} weight={selected ? "medium" : undefined}>
382
+ {firstLine(version.message)}
383
+ </Text>
384
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
385
+ <Text variant="monoSm" color="tertiary" numberOfLines={1} style={{ flex: 1 }}>
386
+ {version.authorName} · {relativeTime(version.authoredAt)}
387
+ </Text>
388
+ </View>
389
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
390
+ <Badge label={version.shortSha} tone="neutral" />
391
+ <Text variant="monoSm" color="tertiary">
392
+ {version.filesChanged === 1 ? "1 file" : `${version.filesChanged} files`}
393
+ </Text>
394
+ </View>
395
+ </Pressable>
396
+ );
397
+ }
398
+
399
+ /** mergeBySha appends a page, dropping versions already listed. */
400
+ function mergeBySha(prev: DocumentVersion[], page: DocumentVersion[]): DocumentVersion[] {
401
+ const seen = new Set(prev.map((v) => v.sha));
402
+ return [...prev, ...page.filter((v) => !seen.has(v.sha))];
403
+ }
404
+
405
+ function messageOf(e: unknown): string {
406
+ const err = e as { graphQLErrors?: { message?: string }[]; message?: string } | undefined;
407
+ return err?.graphQLErrors?.[0]?.message || err?.message || "Couldn’t load this document’s history.";
408
+ }
@@ -1,7 +1,8 @@
1
1
  import React from "react";
2
- import { View, StyleProp, ViewStyle } from "react-native";
2
+ import { View, Pressable, StyleProp, ViewStyle } from "react-native";
3
3
  import { useTheme } from "../ThemeProvider";
4
4
  import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
5
6
 
6
7
  /** Bordered surface card — defined by a hairline border, never a shadow. */
7
8
  export function Card({
@@ -141,3 +142,47 @@ export function SectionLabel({ children, style }: { children: React.ReactNode; s
141
142
  </View>
142
143
  );
143
144
  }
145
+
146
+ /**
147
+ * A square check box: filled and ticked when on, an empty hairline box when
148
+ * off.
149
+ *
150
+ * Lives here rather than beside its first caller because two surfaces select
151
+ * things now — entry rows in the browser, and the fields of a past version in
152
+ * the history diff — and they have to look like the same control. The press
153
+ * stops propagating: every current caller puts this inside a larger Pressable
154
+ * (a list row, a field block), and a tick that also opened the row would be
155
+ * unusable.
156
+ */
157
+ export function CheckBox({
158
+ value,
159
+ onToggle,
160
+ testID = "check-box",
161
+ }: {
162
+ value: boolean;
163
+ onToggle: () => void;
164
+ testID?: string;
165
+ }) {
166
+ const t = useTheme();
167
+ return (
168
+ <Pressable
169
+ onPress={(e?: { stopPropagation?: () => void }) => {
170
+ e?.stopPropagation?.();
171
+ onToggle();
172
+ }}
173
+ testID={testID}
174
+ style={{
175
+ width: 18,
176
+ height: 18,
177
+ borderRadius: t.radius.sm,
178
+ borderWidth: 1,
179
+ borderColor: value ? t.color.borderStrong : t.color.borderDefault,
180
+ backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
181
+ alignItems: "center",
182
+ justifyContent: "center",
183
+ }}
184
+ >
185
+ {value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
186
+ </Pressable>
187
+ );
188
+ }
package/src/history.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { DocumentChange } from "./components/ChangeDetail";
2
+
3
+ /**
4
+ * One commit in a document's history — a row in the version list.
5
+ *
6
+ * This is the provider's history for the document's CURRENT path. GitHub's
7
+ * commits API has no equivalent of `git log --follow`, so a renamed document's
8
+ * history stops at the rename; the list says so at its end rather than letting
9
+ * the absence read as "this is where the document began".
10
+ */
11
+ export type DocumentVersion = {
12
+ sha: string;
13
+ /** First 7 characters — an identity without the noise. */
14
+ shortSha: string;
15
+ /** The full commit message; the list shows its first line. */
16
+ message: string;
17
+ authorName: string;
18
+ authorEmail?: string | null;
19
+ /** ISO 8601, in UTC. */
20
+ authoredAt: string;
21
+ /** The commit's page on the provider. */
22
+ url: string;
23
+ /** How many files the commit touched — this document is one of them. */
24
+ filesChanged: number;
25
+ };
26
+
27
+ /**
28
+ * HistoryApi is the data seam between the design system and its host, the same
29
+ * split MediaApi and FormsApi use: the DS owns the version browser's UI and its
30
+ * (ephemeral) open/selected state, the host owns fetching. Omitting it from
31
+ * ContentBrowser hides the history affordance entirely, which is what a
32
+ * deployment with no provider — local mode, the desktop app — should look like.
33
+ */
34
+ export interface HistoryApi {
35
+ /**
36
+ * One page of a document's versions, newest first. The DS pages as the list
37
+ * scrolls; a short page (fewer than `limit`) ends it.
38
+ */
39
+ list: (params: { documentId: string; limit: number; offset: number }) => Promise<DocumentVersion[]>;
40
+ /**
41
+ * The document as it was at one commit, diffed against what it is now:
42
+ * `before` on each field is the value at that version, `after` its current
43
+ * value. Unchanged fields are included, which is what makes the result a
44
+ * readable preview of the whole document rather than a list of differences.
45
+ *
46
+ * Null when the version can't be read (a commit that has gone away under a
47
+ * force push, say) — the pane says so instead of showing an empty document.
48
+ */
49
+ get: (params: { documentId: string; sha: string }) => Promise<DocumentChange | null>;
50
+ }
51
+
52
+ /**
53
+ * firstLine is the summary a version row shows. A commit message's first line is
54
+ * its subject by convention, and the body below it is prose that would push
55
+ * every other row off the screen.
56
+ */
57
+ export function firstLine(message: string): string {
58
+ const line = message.split("\n", 1)[0].trim();
59
+ return line || "(no commit message)";
60
+ }
61
+
62
+ /**
63
+ * relativeTime renders a commit's age the way a history reads — "3d ago" —
64
+ * falling back to the date once "ago" stops being informative.
65
+ *
66
+ * `now` is injectable so a test can assert a fixed string rather than race the
67
+ * clock.
68
+ */
69
+ export function relativeTime(iso: string, now: Date = new Date()): string {
70
+ const then = new Date(iso);
71
+ const seconds = Math.round((now.getTime() - then.getTime()) / 1000);
72
+ if (!Number.isFinite(seconds)) return "";
73
+ if (seconds < 60) return "just now";
74
+ const minutes = Math.round(seconds / 60);
75
+ if (minutes < 60) return `${minutes}m ago`;
76
+ const hours = Math.round(minutes / 60);
77
+ if (hours < 24) return `${hours}h ago`;
78
+ const days = Math.round(hours / 24);
79
+ if (days < 30) return `${days}d ago`;
80
+ // Past a month, "5 weeks ago" is harder to place than the date itself.
81
+ return then.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
82
+ }
package/src/index.ts CHANGED
@@ -65,7 +65,11 @@ export type {
65
65
  } from "./components/ChangeRequestSummary";
66
66
 
67
67
  // Changes surface: one document's diff between a branch and its base
68
- export { ChangeDetail } from "./components/ChangeDetail";
68
+ export { ChangeDetail, BODY_FIELD } from "./components/ChangeDetail";
69
+ export { DocumentHistory } from "./components/DocumentHistory";
70
+ export type { DocumentHistoryProps } from "./components/DocumentHistory";
71
+ export { firstLine, relativeTime } from "./history";
72
+ export type { DocumentVersion, HistoryApi } from "./history";
69
73
  export type {
70
74
  ChangeDetailProps,
71
75
  DocumentChange,