@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,543 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { FlatList, Pressable, TextInput, View } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
import { Spinner } from "./Spinner";
|
|
7
|
+
import {
|
|
8
|
+
type FormInfo,
|
|
9
|
+
type FormsApi,
|
|
10
|
+
type SubmissionInfo,
|
|
11
|
+
summaryLine,
|
|
12
|
+
} from "../forms";
|
|
13
|
+
|
|
14
|
+
// The Forms surface: a list of forms, a form's submissions, and one submission
|
|
15
|
+
// rendered read-only (docs/forms.md §10.2).
|
|
16
|
+
//
|
|
17
|
+
// ── Why this does not reuse FieldControl ────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// The obvious move is to render a submission with the same controls the
|
|
20
|
+
// document editor uses, in readOnly mode. Two reasons not to.
|
|
21
|
+
//
|
|
22
|
+
// FieldControl reaches ~18 sibling components inside ContentBrowser.tsx, most of
|
|
23
|
+
// them collaboration-aware (CollabInput, CollabArrayField, GroupPresence…).
|
|
24
|
+
// Extracting it into a shared module so a second consumer could import it is a
|
|
25
|
+
// large refactor of a 5,000-line file, and a submission needs none of what it
|
|
26
|
+
// would drag along: no CRDT session, no draft state, no onChange, no media
|
|
27
|
+
// picker interaction.
|
|
28
|
+
//
|
|
29
|
+
// And it would be worse to read. A submission is a record of what somebody sent,
|
|
30
|
+
// not a document with editing switched off, so a row of disabled inputs is the
|
|
31
|
+
// wrong presentation for it — a label-and-value read view says "this is what
|
|
32
|
+
// they wrote" in a way a greyed-out form never does.
|
|
33
|
+
|
|
34
|
+
const PAGE_SIZE = 50;
|
|
35
|
+
|
|
36
|
+
export type FormsBrowserProps = {
|
|
37
|
+
api: FormsApi;
|
|
38
|
+
/** The form being browsed, from the nav key. Null shows the forms list. */
|
|
39
|
+
form: string | null;
|
|
40
|
+
/** Open a form (null returns to the list). The host binds this to the URL. */
|
|
41
|
+
onSelectForm: (form: string | null) => void;
|
|
42
|
+
/** The selected submission's id, and the reporter for taps. */
|
|
43
|
+
selectedId?: string | null;
|
|
44
|
+
onSelectSubmission?: (id: string | null) => void;
|
|
45
|
+
variant?: "desktop" | "mobile";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function FormsBrowser(props: FormsBrowserProps) {
|
|
49
|
+
const { api, form, onSelectForm, selectedId, onSelectSubmission, variant = "desktop" } = props;
|
|
50
|
+
const t = useTheme();
|
|
51
|
+
const isDesktop = variant === "desktop";
|
|
52
|
+
|
|
53
|
+
const active = form ? api.forms.find((f) => f.name === form) ?? null : null;
|
|
54
|
+
|
|
55
|
+
if (!form || !active) {
|
|
56
|
+
return <FormList forms={api.forms} onSelect={onSelectForm} />;
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<SubmissionsView
|
|
60
|
+
api={api}
|
|
61
|
+
form={active}
|
|
62
|
+
onBack={() => onSelectForm(null)}
|
|
63
|
+
selectedId={selectedId ?? null}
|
|
64
|
+
onSelect={onSelectSubmission}
|
|
65
|
+
isDesktop={isDesktop}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── The forms list ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
// Each row reads like a folder in the hierarchical content models, because that
|
|
73
|
+
// is what a form is here: a container you open to find what is inside it.
|
|
74
|
+
function FormList({ forms, onSelect }: { forms: FormInfo[]; onSelect: (name: string) => void }) {
|
|
75
|
+
const t = useTheme();
|
|
76
|
+
if (forms.length === 0) {
|
|
77
|
+
return (
|
|
78
|
+
<View style={{ padding: t.space(6) }}>
|
|
79
|
+
<Text color="secondary">This branch declares no forms.</Text>
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<View style={{ flex: 1 }}>
|
|
85
|
+
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(3) }}>
|
|
86
|
+
<Text variant="label" color="tertiary">
|
|
87
|
+
{forms.length} {forms.length === 1 ? "form" : "forms"}
|
|
88
|
+
</Text>
|
|
89
|
+
</View>
|
|
90
|
+
<FlatList
|
|
91
|
+
data={forms}
|
|
92
|
+
keyExtractor={(f) => f.name}
|
|
93
|
+
renderItem={({ item }) => <FormRow form={item} onPress={() => onSelect(item.name)} />}
|
|
94
|
+
/>
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function FormRow({ form, onPress }: { form: FormInfo; onPress: () => void }) {
|
|
100
|
+
const t = useTheme();
|
|
101
|
+
const [hover, setHover] = React.useState(false);
|
|
102
|
+
return (
|
|
103
|
+
<Pressable
|
|
104
|
+
onPress={onPress}
|
|
105
|
+
onHoverIn={() => setHover(true)}
|
|
106
|
+
onHoverOut={() => setHover(false)}
|
|
107
|
+
testID={`form-row-${form.name}`}
|
|
108
|
+
style={{
|
|
109
|
+
flexDirection: "row",
|
|
110
|
+
alignItems: "center",
|
|
111
|
+
gap: t.space(3),
|
|
112
|
+
paddingHorizontal: t.space(4),
|
|
113
|
+
paddingVertical: t.space(3),
|
|
114
|
+
borderBottomWidth: 1,
|
|
115
|
+
borderBottomColor: t.color.borderSubtle,
|
|
116
|
+
backgroundColor: hover ? t.color.surfaceHover : "transparent",
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<Icon name="listTree" size={16} color={t.color.textTertiary} />
|
|
120
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
121
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
122
|
+
<Text numberOfLines={1}>{form.label || form.name}</Text>
|
|
123
|
+
{form.versioned ? (
|
|
124
|
+
<Icon name="gitBranch" size={12} color={t.color.textTertiary} />
|
|
125
|
+
) : null}
|
|
126
|
+
</View>
|
|
127
|
+
{form.description ? (
|
|
128
|
+
<Text variant="sm" color="tertiary" numberOfLines={1}>
|
|
129
|
+
{form.description}
|
|
130
|
+
</Text>
|
|
131
|
+
) : null}
|
|
132
|
+
</View>
|
|
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. */}
|
|
136
|
+
<Text variant="monoSm" color="secondary" style={{ minWidth: 48, textAlign: "right" }}>
|
|
137
|
+
{form.submissionCount}
|
|
138
|
+
</Text>
|
|
139
|
+
<Icon name="chevronRight" size={14} color={t.color.textTertiary} />
|
|
140
|
+
</Pressable>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── One form's submissions ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function SubmissionsView({
|
|
147
|
+
api,
|
|
148
|
+
form,
|
|
149
|
+
onBack,
|
|
150
|
+
selectedId,
|
|
151
|
+
onSelect,
|
|
152
|
+
isDesktop,
|
|
153
|
+
}: {
|
|
154
|
+
api: FormsApi;
|
|
155
|
+
form: FormInfo;
|
|
156
|
+
onBack: () => void;
|
|
157
|
+
selectedId: string | null;
|
|
158
|
+
onSelect?: (id: string | null) => void;
|
|
159
|
+
isDesktop: boolean;
|
|
160
|
+
}) {
|
|
161
|
+
const t = useTheme();
|
|
162
|
+
const [items, setItems] = React.useState<SubmissionInfo[]>([]);
|
|
163
|
+
const [total, setTotal] = React.useState<number | null>(null);
|
|
164
|
+
const [loading, setLoading] = React.useState(true);
|
|
165
|
+
// A form can legitimately fail to list: local mode answers
|
|
166
|
+
// NO_SUBMISSION_STORE for a form that keeps its submissions in a database it
|
|
167
|
+
// does not have. Without somewhere to put that, the promise rejected and the
|
|
168
|
+
// pane spun forever — which is a worse answer than the empty inbox this was
|
|
169
|
+
// written to avoid.
|
|
170
|
+
const [failure, setFailure] = React.useState<string | null>(null);
|
|
171
|
+
const [loadingMore, setLoadingMore] = React.useState(false);
|
|
172
|
+
const [exhausted, setExhausted] = React.useState(false);
|
|
173
|
+
const [search, setSearch] = React.useState("");
|
|
174
|
+
const [status, setStatus] = React.useState<"received" | "spam">("received");
|
|
175
|
+
|
|
176
|
+
// Switching form, filter or search is a different result set: reset rather
|
|
177
|
+
// than paging one query's offsets into another's.
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
let alive = true;
|
|
180
|
+
setLoading(true);
|
|
181
|
+
setExhausted(false);
|
|
182
|
+
setFailure(null);
|
|
183
|
+
Promise.all([
|
|
184
|
+
api.list({ form: form.name, status, search, limit: PAGE_SIZE, offset: 0 }),
|
|
185
|
+
api.count({ form: form.name, status, search }),
|
|
186
|
+
])
|
|
187
|
+
.then(([rows, n]) => {
|
|
188
|
+
if (!alive) return;
|
|
189
|
+
setItems(rows);
|
|
190
|
+
setTotal(n);
|
|
191
|
+
setExhausted(rows.length >= n);
|
|
192
|
+
})
|
|
193
|
+
.catch((err: unknown) => {
|
|
194
|
+
if (!alive) return;
|
|
195
|
+
setItems([]);
|
|
196
|
+
setTotal(null);
|
|
197
|
+
// The server's own sentence, which for the case that actually happens
|
|
198
|
+
// says what to change in the config. A generic "couldn't load" would
|
|
199
|
+
// throw that away.
|
|
200
|
+
setFailure(messageOf(err));
|
|
201
|
+
})
|
|
202
|
+
.finally(() => alive && setLoading(false));
|
|
203
|
+
return () => {
|
|
204
|
+
alive = false;
|
|
205
|
+
};
|
|
206
|
+
}, [api, form.name, status, search]);
|
|
207
|
+
|
|
208
|
+
const loadMore = React.useCallback(() => {
|
|
209
|
+
if (loadingMore || exhausted || loading) return;
|
|
210
|
+
setLoadingMore(true);
|
|
211
|
+
api
|
|
212
|
+
.list({ form: form.name, status, search, limit: PAGE_SIZE, offset: items.length })
|
|
213
|
+
.then((rows) => {
|
|
214
|
+
setItems((prev) => [...prev, ...rows]);
|
|
215
|
+
if (rows.length < PAGE_SIZE) setExhausted(true);
|
|
216
|
+
})
|
|
217
|
+
.finally(() => setLoadingMore(false));
|
|
218
|
+
}, [api, form.name, status, search, items.length, loading, loadingMore, exhausted]);
|
|
219
|
+
|
|
220
|
+
const selected = selectedId ? items.find((s) => s.id === selectedId) ?? null : null;
|
|
221
|
+
|
|
222
|
+
const list = (
|
|
223
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
224
|
+
<View
|
|
225
|
+
style={{
|
|
226
|
+
flexDirection: "row",
|
|
227
|
+
alignItems: "center",
|
|
228
|
+
gap: t.space(2),
|
|
229
|
+
paddingHorizontal: t.space(3),
|
|
230
|
+
paddingVertical: t.space(2),
|
|
231
|
+
borderBottomWidth: 1,
|
|
232
|
+
borderBottomColor: t.color.borderSubtle,
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<Pressable onPress={onBack} testID="forms-back" style={{ padding: t.space(1) }}>
|
|
236
|
+
<Icon name="chevronLeft" size={16} color={t.color.textSecondary} />
|
|
237
|
+
</Pressable>
|
|
238
|
+
<Text numberOfLines={1} style={{ flex: 1 }}>
|
|
239
|
+
{form.label || form.name}
|
|
240
|
+
</Text>
|
|
241
|
+
<Text variant="monoSm" color="tertiary">
|
|
242
|
+
{total ?? "—"}
|
|
243
|
+
</Text>
|
|
244
|
+
</View>
|
|
245
|
+
|
|
246
|
+
<View
|
|
247
|
+
style={{
|
|
248
|
+
flexDirection: "row",
|
|
249
|
+
alignItems: "center",
|
|
250
|
+
gap: t.space(2),
|
|
251
|
+
paddingHorizontal: t.space(3),
|
|
252
|
+
paddingVertical: t.space(2),
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<TextInput
|
|
256
|
+
value={search}
|
|
257
|
+
onChangeText={setSearch}
|
|
258
|
+
placeholder="Search submissions"
|
|
259
|
+
placeholderTextColor={t.color.textTertiary}
|
|
260
|
+
testID="forms-search"
|
|
261
|
+
style={{
|
|
262
|
+
flex: 1,
|
|
263
|
+
height: t.control.sm,
|
|
264
|
+
paddingHorizontal: t.space(2),
|
|
265
|
+
borderWidth: 1,
|
|
266
|
+
borderColor: t.color.borderDefault,
|
|
267
|
+
borderRadius: t.radius.sm,
|
|
268
|
+
color: t.color.textPrimary,
|
|
269
|
+
}}
|
|
270
|
+
/>
|
|
271
|
+
{/* Spam is recorded rather than discarded (a honeypot also catches
|
|
272
|
+
browser autofill), so it needs somewhere to be seen. */}
|
|
273
|
+
<Pressable
|
|
274
|
+
onPress={() => setStatus(status === "received" ? "spam" : "received")}
|
|
275
|
+
testID="forms-status-toggle"
|
|
276
|
+
style={{
|
|
277
|
+
height: t.control.sm,
|
|
278
|
+
justifyContent: "center",
|
|
279
|
+
paddingHorizontal: t.space(2),
|
|
280
|
+
borderWidth: 1,
|
|
281
|
+
borderColor: status === "spam" ? t.color.borderStrong : t.color.borderDefault,
|
|
282
|
+
borderRadius: t.radius.sm,
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
<Text variant="monoSm" color={status === "spam" ? "primary" : "tertiary"}>
|
|
286
|
+
Spam
|
|
287
|
+
</Text>
|
|
288
|
+
</Pressable>
|
|
289
|
+
</View>
|
|
290
|
+
|
|
291
|
+
{loading ? (
|
|
292
|
+
<View style={{ padding: t.space(6), alignItems: "center" }}>
|
|
293
|
+
<Spinner />
|
|
294
|
+
</View>
|
|
295
|
+
) : failure ? (
|
|
296
|
+
<View style={{ padding: t.space(6) }} testID="forms-error">
|
|
297
|
+
<Text color="secondary">{failure}</Text>
|
|
298
|
+
</View>
|
|
299
|
+
) : items.length === 0 ? (
|
|
300
|
+
<View style={{ padding: t.space(6) }}>
|
|
301
|
+
<Text color="secondary">
|
|
302
|
+
{status === "spam" ? "Nothing caught as spam." : "No submissions yet."}
|
|
303
|
+
</Text>
|
|
304
|
+
</View>
|
|
305
|
+
) : (
|
|
306
|
+
<FlatList
|
|
307
|
+
data={items}
|
|
308
|
+
keyExtractor={(s) => s.id}
|
|
309
|
+
onEndReached={loadMore}
|
|
310
|
+
onEndReachedThreshold={0.4}
|
|
311
|
+
ListFooterComponent={
|
|
312
|
+
loadingMore ? (
|
|
313
|
+
<View style={{ padding: t.space(4), alignItems: "center" }}>
|
|
314
|
+
<Spinner />
|
|
315
|
+
</View>
|
|
316
|
+
) : null
|
|
317
|
+
}
|
|
318
|
+
renderItem={({ item }) => (
|
|
319
|
+
<SubmissionRow
|
|
320
|
+
submission={item}
|
|
321
|
+
form={form}
|
|
322
|
+
selected={item.id === selectedId}
|
|
323
|
+
onPress={() => onSelect?.(item.id)}
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
/>
|
|
327
|
+
)}
|
|
328
|
+
</View>
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
if (!isDesktop) {
|
|
332
|
+
// Mobile pushes: the list, or the submission on top of it.
|
|
333
|
+
return selected ? (
|
|
334
|
+
<SubmissionDetail submission={selected} form={form} onBack={() => onSelect?.(null)} />
|
|
335
|
+
) : (
|
|
336
|
+
list
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<View style={{ flex: 1, flexDirection: "row", minWidth: 0 }}>
|
|
342
|
+
<View
|
|
343
|
+
style={{
|
|
344
|
+
width: t.layout.column,
|
|
345
|
+
borderRightWidth: 1,
|
|
346
|
+
borderRightColor: t.color.borderSubtle,
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
{list}
|
|
350
|
+
</View>
|
|
351
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
352
|
+
{selected ? (
|
|
353
|
+
<SubmissionDetail submission={selected} form={form} />
|
|
354
|
+
) : (
|
|
355
|
+
<View style={{ padding: t.space(6) }}>
|
|
356
|
+
<Text color="secondary">Select a submission</Text>
|
|
357
|
+
</View>
|
|
358
|
+
)}
|
|
359
|
+
</View>
|
|
360
|
+
</View>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function SubmissionRow({
|
|
365
|
+
submission,
|
|
366
|
+
form,
|
|
367
|
+
selected,
|
|
368
|
+
onPress,
|
|
369
|
+
}: {
|
|
370
|
+
submission: SubmissionInfo;
|
|
371
|
+
form: FormInfo;
|
|
372
|
+
selected: boolean;
|
|
373
|
+
onPress: () => void;
|
|
374
|
+
}) {
|
|
375
|
+
const t = useTheme();
|
|
376
|
+
const [hover, setHover] = React.useState(false);
|
|
377
|
+
return (
|
|
378
|
+
<Pressable
|
|
379
|
+
onPress={onPress}
|
|
380
|
+
onHoverIn={() => setHover(true)}
|
|
381
|
+
onHoverOut={() => setHover(false)}
|
|
382
|
+
testID={`submission-row-${submission.id}`}
|
|
383
|
+
style={{
|
|
384
|
+
gap: 2,
|
|
385
|
+
paddingHorizontal: t.space(4),
|
|
386
|
+
paddingVertical: t.space(3),
|
|
387
|
+
borderBottomWidth: 1,
|
|
388
|
+
borderBottomColor: t.color.borderSubtle,
|
|
389
|
+
backgroundColor: selected
|
|
390
|
+
? t.color.surfaceActive
|
|
391
|
+
: hover
|
|
392
|
+
? t.color.surfaceHover
|
|
393
|
+
: "transparent",
|
|
394
|
+
}}
|
|
395
|
+
>
|
|
396
|
+
<Text numberOfLines={1}>{summaryLine(submission, form)}</Text>
|
|
397
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
398
|
+
<Text variant="monoSm" color="tertiary">
|
|
399
|
+
{formatWhen(submission.submittedAt)}
|
|
400
|
+
</Text>
|
|
401
|
+
{submission.status === "spam" ? (
|
|
402
|
+
<Text variant="monoSm" color={t.color.diffDelFg}>
|
|
403
|
+
spam
|
|
404
|
+
</Text>
|
|
405
|
+
) : null}
|
|
406
|
+
</View>
|
|
407
|
+
</Pressable>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── One submission, read-only ───────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
function SubmissionDetail({
|
|
414
|
+
submission,
|
|
415
|
+
form,
|
|
416
|
+
onBack,
|
|
417
|
+
}: {
|
|
418
|
+
submission: SubmissionInfo;
|
|
419
|
+
form: FormInfo;
|
|
420
|
+
onBack?: () => void;
|
|
421
|
+
}) {
|
|
422
|
+
const t = useTheme();
|
|
423
|
+
return (
|
|
424
|
+
<View style={{ flex: 1 }}>
|
|
425
|
+
<View
|
|
426
|
+
style={{
|
|
427
|
+
flexDirection: "row",
|
|
428
|
+
alignItems: "center",
|
|
429
|
+
gap: t.space(2),
|
|
430
|
+
paddingHorizontal: t.space(4),
|
|
431
|
+
paddingVertical: t.space(3),
|
|
432
|
+
borderBottomWidth: 1,
|
|
433
|
+
borderBottomColor: t.color.borderSubtle,
|
|
434
|
+
}}
|
|
435
|
+
>
|
|
436
|
+
{onBack ? (
|
|
437
|
+
<Pressable onPress={onBack} testID="submission-back" style={{ padding: t.space(1) }}>
|
|
438
|
+
<Icon name="chevronLeft" size={16} color={t.color.textSecondary} />
|
|
439
|
+
</Pressable>
|
|
440
|
+
) : null}
|
|
441
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
442
|
+
<Text numberOfLines={1}>{summaryLine(submission, form)}</Text>
|
|
443
|
+
<Text variant="monoSm" color="tertiary">
|
|
444
|
+
{formatWhen(submission.submittedAt)}
|
|
445
|
+
</Text>
|
|
446
|
+
</View>
|
|
447
|
+
</View>
|
|
448
|
+
|
|
449
|
+
<View style={{ padding: t.space(4), gap: t.space(4) }}>
|
|
450
|
+
{form.fields.map((f) => {
|
|
451
|
+
const present = Object.prototype.hasOwnProperty.call(submission.fields, f.name);
|
|
452
|
+
return (
|
|
453
|
+
<View key={f.name} style={{ gap: t.space(1) }}>
|
|
454
|
+
<Text variant="label" color="tertiary">
|
|
455
|
+
{f.label || f.name}
|
|
456
|
+
</Text>
|
|
457
|
+
{present ? (
|
|
458
|
+
<FieldValue value={submission.fields[f.name]} />
|
|
459
|
+
) : (
|
|
460
|
+
// The visible payoff of storing an inapplicable field as ABSENT
|
|
461
|
+
// rather than null: "we never asked" is a different fact from
|
|
462
|
+
// "they left it blank", and the record can say which.
|
|
463
|
+
<Text variant="sm" color="disabled" testID={`submission-not-asked-${f.name}`}>
|
|
464
|
+
Not asked
|
|
465
|
+
</Text>
|
|
466
|
+
)}
|
|
467
|
+
</View>
|
|
468
|
+
);
|
|
469
|
+
})}
|
|
470
|
+
|
|
471
|
+
{submission.path ? (
|
|
472
|
+
<View style={{ gap: t.space(1), paddingTop: t.space(2) }}>
|
|
473
|
+
<Text variant="label" color="tertiary">
|
|
474
|
+
In the repository
|
|
475
|
+
</Text>
|
|
476
|
+
<Text variant="monoSm" color="secondary">
|
|
477
|
+
{submission.path}
|
|
478
|
+
</Text>
|
|
479
|
+
</View>
|
|
480
|
+
) : null}
|
|
481
|
+
</View>
|
|
482
|
+
</View>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// FieldValue renders one stored value for reading. Deliberately small: the
|
|
487
|
+
// shapes a submission can hold are the config's field types, and each has an
|
|
488
|
+
// obvious read form.
|
|
489
|
+
function FieldValue({ value }: { value: unknown }) {
|
|
490
|
+
const t = useTheme();
|
|
491
|
+
if (value === null || value === undefined || value === "") {
|
|
492
|
+
return (
|
|
493
|
+
<Text variant="sm" color="disabled">
|
|
494
|
+
—
|
|
495
|
+
</Text>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
if (typeof value === "boolean") {
|
|
499
|
+
return <Text>{value ? "Yes" : "No"}</Text>;
|
|
500
|
+
}
|
|
501
|
+
if (Array.isArray(value)) {
|
|
502
|
+
return (
|
|
503
|
+
<View style={{ gap: t.space(1) }}>
|
|
504
|
+
{value.map((v, i) => (
|
|
505
|
+
<Text key={i}>• {typeof v === "object" ? JSON.stringify(v) : String(v)}</Text>
|
|
506
|
+
))}
|
|
507
|
+
</View>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
if (typeof value === "object") {
|
|
511
|
+
return (
|
|
512
|
+
<View style={{ gap: t.space(1) }}>
|
|
513
|
+
{Object.entries(value as Record<string, unknown>).map(([k, v]) => (
|
|
514
|
+
<View key={k} style={{ flexDirection: "row", gap: t.space(2) }}>
|
|
515
|
+
<Text variant="monoSm" color="tertiary">
|
|
516
|
+
{k}
|
|
517
|
+
</Text>
|
|
518
|
+
<Text style={{ flex: 1 }}>{typeof v === "object" ? JSON.stringify(v) : String(v)}</Text>
|
|
519
|
+
</View>
|
|
520
|
+
))}
|
|
521
|
+
</View>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
return <Text>{String(value)}</Text>;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// messageOf digs the human sentence out of whatever the host's api threw. An
|
|
528
|
+
// Apollo error carries the server's message on graphQLErrors; everything else
|
|
529
|
+
// is an Error, or something that is not.
|
|
530
|
+
function messageOf(err: unknown): string {
|
|
531
|
+
const gql = (err as { graphQLErrors?: { message?: string }[] })?.graphQLErrors;
|
|
532
|
+
if (Array.isArray(gql) && gql[0]?.message) return gql[0].message!;
|
|
533
|
+
if (err instanceof Error && err.message) return err.message;
|
|
534
|
+
return "These submissions could not be loaded.";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// formatWhen renders a timestamp the way an inbox does. Falls back to the raw
|
|
538
|
+
// string rather than showing "Invalid Date" for anything unparseable.
|
|
539
|
+
export function formatWhen(iso: string): string {
|
|
540
|
+
const d = new Date(iso);
|
|
541
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
542
|
+
return d.toLocaleString();
|
|
543
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { View, 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
|
+
|
|
9
|
+
export type ProtectedBranchModalProps = {
|
|
10
|
+
/** The protected branch the save was refused on. */
|
|
11
|
+
branch: string;
|
|
12
|
+
/** The document that could not be saved, for naming what is at stake. */
|
|
13
|
+
documentLabel?: string;
|
|
14
|
+
/** Prefills the field — the host derives it from the document. */
|
|
15
|
+
suggestedName?: string;
|
|
16
|
+
/** True while the branch is being created and the save applied. */
|
|
17
|
+
busy?: boolean;
|
|
18
|
+
/** The server's message, when the attempt failed. */
|
|
19
|
+
error?: string | null;
|
|
20
|
+
/** Create `name` from `branch` and save the pending edit onto it. */
|
|
21
|
+
onCreate: (name: string) => void;
|
|
22
|
+
/** Dismiss. The edit stays as an unsaved draft on the protected branch. */
|
|
23
|
+
onCancel: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shown when a save is refused because the branch is protected.
|
|
28
|
+
*
|
|
29
|
+
* It is a prompt rather than an error banner because the refusal has exactly one
|
|
30
|
+
* remedy and the editor knows what it is: the work is intact, it just needs
|
|
31
|
+
* somewhere it can land. Presenting that as "here is a message, now go find the
|
|
32
|
+
* branch menu" would make the author reconstruct a plan the product already has.
|
|
33
|
+
*
|
|
34
|
+
* The draft is deliberately NOT discarded on cancel — a protected branch is
|
|
35
|
+
* often noticed mid-thought, and the author may want to keep writing and pick a
|
|
36
|
+
* branch name later. It stays in storage exactly as any other unsaved work does.
|
|
37
|
+
*/
|
|
38
|
+
export function ProtectedBranchModal({
|
|
39
|
+
branch, documentLabel, suggestedName, busy, error, onCreate, onCancel,
|
|
40
|
+
}: ProtectedBranchModalProps) {
|
|
41
|
+
const t = useTheme();
|
|
42
|
+
const [name, setName] = useState(suggestedName ?? "");
|
|
43
|
+
// The suggestion is derived from the document, which the host may resolve a
|
|
44
|
+
// render after the modal opens. Adopted only while the field is untouched.
|
|
45
|
+
const [touched, setTouched] = useState(false);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!touched && suggestedName) setName(suggestedName);
|
|
48
|
+
}, [suggestedName, touched]);
|
|
49
|
+
|
|
50
|
+
const trimmed = name.trim();
|
|
51
|
+
const submit = () => {
|
|
52
|
+
if (!trimmed || busy) return;
|
|
53
|
+
onCreate(trimmed);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<View
|
|
58
|
+
testID="protected-branch-modal"
|
|
59
|
+
style={{
|
|
60
|
+
position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 50,
|
|
61
|
+
alignItems: "center", justifyContent: "center", padding: t.space(4),
|
|
62
|
+
backgroundColor: "rgba(0,0,0,0.45)",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<View
|
|
66
|
+
style={{
|
|
67
|
+
width: 460, maxWidth: "100%", gap: t.space(4), padding: t.space(5),
|
|
68
|
+
borderRadius: t.radius.lg, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
69
|
+
backgroundColor: t.color.surfaceRaised,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
73
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
74
|
+
<Icon name="lock" size={16} color={t.color.textSecondary} />
|
|
75
|
+
<Text variant="h3" weight="semibold">This branch is protected</Text>
|
|
76
|
+
</View>
|
|
77
|
+
{busy ? null : (
|
|
78
|
+
<Pressable
|
|
79
|
+
testID="protected-branch-close"
|
|
80
|
+
accessibilityRole="button"
|
|
81
|
+
accessibilityLabel="Cancel"
|
|
82
|
+
onPress={onCancel}
|
|
83
|
+
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
|
84
|
+
>
|
|
85
|
+
<Icon name="x" size={18} color={t.color.textSecondary} />
|
|
86
|
+
</Pressable>
|
|
87
|
+
)}
|
|
88
|
+
</View>
|
|
89
|
+
|
|
90
|
+
<Text variant="body" color="secondary">
|
|
91
|
+
{documentLabel
|
|
92
|
+
? `${branch} doesn’t accept changes directly, so “${documentLabel}” can’t be saved to it. Name a branch to save it to instead — your edit lands there.`
|
|
93
|
+
: `${branch} doesn’t accept changes directly. Name a branch to save your edit to instead.`}
|
|
94
|
+
</Text>
|
|
95
|
+
|
|
96
|
+
<Input
|
|
97
|
+
label="New branch"
|
|
98
|
+
value={name}
|
|
99
|
+
onChangeText={(v) => { setTouched(true); setName(v); }}
|
|
100
|
+
onSubmitEditing={submit}
|
|
101
|
+
placeholder="feat/my-change"
|
|
102
|
+
autoCapitalize="none"
|
|
103
|
+
autoFocus
|
|
104
|
+
mono
|
|
105
|
+
editable={!busy}
|
|
106
|
+
error={!!error}
|
|
107
|
+
testID="protected-branch-name"
|
|
108
|
+
/>
|
|
109
|
+
<Text variant="monoSm" color="tertiary">{`Branched from ${branch}`}</Text>
|
|
110
|
+
|
|
111
|
+
{error ? (
|
|
112
|
+
<Text testID="protected-branch-error" variant="monoSm" color={t.color.diffDelFg}>{error}</Text>
|
|
113
|
+
) : null}
|
|
114
|
+
|
|
115
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end", gap: t.space(2) }}>
|
|
116
|
+
<Button
|
|
117
|
+
title="Cancel"
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="md"
|
|
120
|
+
disabled={busy}
|
|
121
|
+
onPress={onCancel}
|
|
122
|
+
testID="protected-branch-cancel"
|
|
123
|
+
/>
|
|
124
|
+
<Button
|
|
125
|
+
title={busy ? "Creating…" : "Create branch and save"}
|
|
126
|
+
variant="primary"
|
|
127
|
+
size="md"
|
|
128
|
+
disabled={busy || trimmed === ""}
|
|
129
|
+
onPress={submit}
|
|
130
|
+
testID="protected-branch-submit"
|
|
131
|
+
/>
|
|
132
|
+
</View>
|
|
133
|
+
</View>
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|