@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.
@@ -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
+ }
@@ -130,11 +130,9 @@ function FormRow({ form, onPress }: { form: FormInfo; onPress: () => void }) {
130
130
  </Text>
131
131
  ) : null}
132
132
  </View>
133
- {/* The two numbers the request asks for: how much has come in, and how
134
- much the form asks for. */}
135
- <Text variant="monoSm" color="tertiary">
136
- {form.fieldCount} {form.fieldCount === 1 ? "field" : "fields"}
137
- </Text>
133
+ {/* How much has come in. The field count is deliberately not here: it
134
+ describes the form's shape rather than its inbox, and in a column this
135
+ narrow it competed with the number a reader actually scans for. */}
138
136
  <Text variant="monoSm" color="secondary" style={{ minWidth: 48, textAlign: "right" }}>
139
137
  {form.submissionCount}
140
138
  </Text>
@@ -6,6 +6,7 @@ import { Icon } from "./Icon";
6
6
  import { Button } from "./Button";
7
7
  import { MediaPreview, MediaChip, fileName } from "./MediaPreview";
8
8
  import {
9
+ FieldMediaApi,
9
10
  MediaApi,
10
11
  MediaAsset,
11
12
  MediaKind,
@@ -377,6 +378,67 @@ function AmbiguousNotice({
377
378
  );
378
379
  }
379
380
 
381
+ // useFieldMedia builds the FieldMediaApi a host-supplied control receives
382
+ // through the renderField slot, plus the picker element the caller must mount
383
+ // next to that control (the picker is a modal, so it has to live in the tree).
384
+ //
385
+ // The picker is promise-driven: `pick()` opens it and settles when the author
386
+ // chooses or dismisses. That shape is what lets the markdown editor — which on
387
+ // native runs inside a WebView and only sees async, serializable props — ask
388
+ // for an image without knowing anything about the picker UI.
389
+ //
390
+ // `set` defaults to the first configured set when the field names none (a body
391
+ // field has no display.media), and the picker's own switcher covers the rest.
392
+ export function useFieldMedia(opts: { set?: string; storeAs?: string }): {
393
+ media: FieldMediaApi | undefined;
394
+ picker: React.ReactNode;
395
+ } {
396
+ const api = useMediaApi();
397
+ const contextPath = React.useContext(DocumentPathContext);
398
+ const [request, setRequest] = React.useState<{
399
+ kinds?: MediaKind[];
400
+ resolve: (r: { asset: MediaAsset; path: string } | null) => void;
401
+ } | null>(null);
402
+ const requestRef = React.useRef(request);
403
+ requestRef.current = request;
404
+
405
+ const { set, storeAs } = opts;
406
+ const media = React.useMemo<FieldMediaApi | undefined>(() => {
407
+ if (!api) return undefined;
408
+ const form = (storeAs as StoreAs) || "public";
409
+ return {
410
+ pick: (pickOpts) =>
411
+ new Promise((resolve) => {
412
+ // A second pick while one is open supersedes it: the first caller
413
+ // gets null rather than waiting forever.
414
+ requestRef.current?.resolve(null);
415
+ setRequest({ kinds: pickOpts?.kinds, resolve });
416
+ }),
417
+ resolve: (paths) => api.resolve(paths, contextPath),
418
+ };
419
+ // The pick callback reads the current request through a ref, so only the
420
+ // inputs that change what a pick *means* rebuild the api.
421
+ // eslint-disable-next-line react-hooks/exhaustive-deps
422
+ }, [api, contextPath, storeAs]);
423
+
424
+ const settle = (result: { asset: MediaAsset; path: string } | null) => {
425
+ request?.resolve(result);
426
+ setRequest(null);
427
+ };
428
+
429
+ const picker = api ? (
430
+ <MediaPicker
431
+ visible={!!request}
432
+ onClose={() => settle(null)}
433
+ onSelect={(asset) => settle({ asset, path: pathForStoreAs(asset, (storeAs as StoreAs) || "public") })}
434
+ set={set ?? api.sets[0]?.name}
435
+ kinds={request?.kinds}
436
+ />
437
+ ) : null;
438
+
439
+ return { media, picker };
440
+ }
441
+
380
442
  // ---- picker ----------------------------------------------------------------
381
443
 
382
444
  const PAGE_SIZE = 40;
@@ -27,7 +27,20 @@ export function MediaPreview({ media, maxHeight = 240, compact }: MediaPreviewPr
27
27
  // it through <Image> would silently show nothing on Android, so it takes the
28
28
  // chip path instead — visible and honest.
29
29
  const renderable = media.kind === "image";
30
- if (compact || !renderable) return <MediaChip media={media} />;
30
+ if (!renderable) return <MediaChip media={media} />;
31
+
32
+ // Compact is the picker-row form: the image fills the box it is given.
33
+ if (compact) {
34
+ return (
35
+ <Image
36
+ source={{ uri: media.url }}
37
+ accessibilityLabel={media.repoPath}
38
+ resizeMode="cover"
39
+ testID={`media-thumb-${media.id}`}
40
+ style={{ width: "100%", height: "100%" }}
41
+ />
42
+ );
43
+ }
31
44
 
32
45
  return (
33
46
  <View
@@ -13,15 +13,30 @@ export type MediaPreviewProps = {
13
13
  // Cap on the rendered height. The preview never exceeds it; images letterbox
14
14
  // inside it rather than pushing the rest of the form off-screen.
15
15
  maxHeight?: number;
16
- // Compact renders the chip form regardless of kind — used in the picker grid
17
- // and anywhere a full preview would be too heavy.
16
+ // Compact is the picker-row form: an image fills whatever box it is given
17
+ // (cover-fit, no frame), and every other kind renders the chip. It used to
18
+ // render the chip for images too, which is why the picker showed a filename
19
+ // squeezed into a 48px square instead of a thumbnail.
18
20
  compact?: boolean;
19
21
  };
20
22
 
21
23
  export function MediaPreview({ media, maxHeight = 240, compact }: MediaPreviewProps) {
22
24
  const t = useTheme();
23
25
 
24
- if (compact) return <MediaChip media={media} />;
26
+ if (compact) {
27
+ if (media.kind === "image" || media.kind === "svg") {
28
+ return (
29
+ <img
30
+ src={media.url}
31
+ alt={media.repoPath}
32
+ loading="lazy"
33
+ data-testid={`media-thumb-${media.id}`}
34
+ style={{ display: "block", width: "100%", height: "100%", objectFit: "cover" }}
35
+ />
36
+ );
37
+ }
38
+ return <MediaChip media={media} />;
39
+ }
25
40
 
26
41
  const frame = {
27
42
  borderWidth: 1,