@gogitcms/editor 0.26.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.
- package/app/src/App.tsx +163 -1
- package/app/src/forms.ts +77 -0
- package/app/src/history.ts +50 -0
- package/app/src/queries.ts +163 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/app/vendor/design-system/src/__tests__/FormsBrowser.test.tsx +253 -0
- package/app/vendor/design-system/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +94 -11
- package/app/vendor/design-system/src/components/CollabField.tsx +71 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +366 -61
- package/app/vendor/design-system/src/components/DocumentHistory.tsx +408 -0
- package/app/vendor/design-system/src/components/FormsBrowser.tsx +543 -0
- package/app/vendor/design-system/src/components/ProtectedBranchModal.tsx +136 -0
- package/app/vendor/design-system/src/components/primitives.tsx +46 -1
- package/app/vendor/design-system/src/forms.ts +118 -0
- package/app/vendor/design-system/src/history.ts +82 -0
- package/app/vendor/design-system/src/index.ts +18 -1
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +23 -0
- package/app/vendor/markdown-editor/src/field.tsx +6 -1
- package/app/vendor/markdown-editor/src/types.ts +13 -0
- package/npm-shrinkwrap.json +149 -188
- 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
|
+
}
|