@gogitcms/editor 0.25.0 → 0.28.0

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 (24) hide show
  1. package/app/src/App.tsx +163 -1
  2. package/app/src/forms.ts +77 -0
  3. package/app/src/history.ts +50 -0
  4. package/app/src/queries.ts +163 -0
  5. package/app/vendor/design-system/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
  6. package/app/vendor/design-system/src/__tests__/ContentBrowser.history.test.tsx +385 -0
  7. package/app/vendor/design-system/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
  8. package/app/vendor/design-system/src/__tests__/FormsBrowser.test.tsx +253 -0
  9. package/app/vendor/design-system/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
  10. package/app/vendor/design-system/src/components/ChangeDetail.tsx +94 -11
  11. package/app/vendor/design-system/src/components/CollabField.tsx +71 -0
  12. package/app/vendor/design-system/src/components/ContentBrowser.tsx +366 -61
  13. package/app/vendor/design-system/src/components/DocumentHistory.tsx +408 -0
  14. package/app/vendor/design-system/src/components/FormsBrowser.tsx +543 -0
  15. package/app/vendor/design-system/src/components/ProtectedBranchModal.tsx +136 -0
  16. package/app/vendor/design-system/src/components/primitives.tsx +46 -1
  17. package/app/vendor/design-system/src/forms.ts +118 -0
  18. package/app/vendor/design-system/src/history.ts +82 -0
  19. package/app/vendor/design-system/src/index.ts +18 -1
  20. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +23 -0
  21. package/app/vendor/markdown-editor/src/field.tsx +6 -1
  22. package/app/vendor/markdown-editor/src/types.ts +13 -0
  23. package/npm-shrinkwrap.json +149 -188
  24. package/package.json +6 -6
@@ -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
+ }