@gogitcms/design-system 0.15.0-next.3 → 0.16.0-next.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/package.json +1 -1
- package/src/__tests__/FormsBrowser.test.tsx +248 -0
- package/src/components/ContentBrowser.tsx +79 -2
- package/src/components/FormsBrowser.tsx +545 -0
- package/src/forms.ts +118 -0
- package/src/index.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gogitcms/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0-next.0",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"types": "src/index.ts",
|
|
6
6
|
"// exports": "The root entry is the react-native source the SPAs, desktop app and mobile app consume. ./web is the plain-DOM build for server-rendered surfaces (the Astro docs site) that don't run react-native-web, and ./css ships the tokens as custom properties. The trailing wildcard keeps deep paths resolvable — plugin bundling and the Vite aliases reach into src/ directly.",
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { FormsBrowser } from "../components/FormsBrowser";
|
|
5
|
+
import type { FormInfo, FormsApi, SubmissionInfo } from "../forms";
|
|
6
|
+
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, summaryLine } from "../forms";
|
|
7
|
+
|
|
8
|
+
const contact: FormInfo = {
|
|
9
|
+
name: "contact",
|
|
10
|
+
label: "Contact us",
|
|
11
|
+
description: "General enquiries.",
|
|
12
|
+
fields: [
|
|
13
|
+
{ name: "email", label: "Email", type: "string" },
|
|
14
|
+
{ name: "reason", label: "Reason", type: "string" },
|
|
15
|
+
{ name: "orderNumber", label: "Order number", type: "string" },
|
|
16
|
+
],
|
|
17
|
+
fieldCount: 3,
|
|
18
|
+
submissionCount: 2,
|
|
19
|
+
versioned: true,
|
|
20
|
+
storePath: "content/submissions/contact/*.json",
|
|
21
|
+
canDelete: true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const newsletter: FormInfo = {
|
|
25
|
+
name: "newsletter",
|
|
26
|
+
label: "Newsletter",
|
|
27
|
+
fields: [{ name: "email", type: "string" }],
|
|
28
|
+
fieldCount: 1,
|
|
29
|
+
submissionCount: 308,
|
|
30
|
+
versioned: false,
|
|
31
|
+
canDelete: false,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const submissions: SubmissionInfo[] = [
|
|
35
|
+
{
|
|
36
|
+
id: "s1",
|
|
37
|
+
form: "contact",
|
|
38
|
+
fields: { email: "priya@example.test", reason: "support", orderNumber: "A-1" },
|
|
39
|
+
submittedAt: "2026-08-28T12:00:00Z",
|
|
40
|
+
status: "received",
|
|
41
|
+
path: "content/submissions/contact/s1.json",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
// reason is "sales", so orderNumber was never asked for — it is ABSENT
|
|
45
|
+
// rather than null, which the detail view must show as such.
|
|
46
|
+
id: "s2",
|
|
47
|
+
form: "contact",
|
|
48
|
+
fields: { email: "sam@example.test", reason: "sales" },
|
|
49
|
+
submittedAt: "2026-08-27T09:30:00Z",
|
|
50
|
+
status: "received",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function makeApi(over: Partial<FormsApi> = {}): FormsApi {
|
|
55
|
+
return {
|
|
56
|
+
forms: [contact, newsletter],
|
|
57
|
+
list: jest.fn(async ({ status }) =>
|
|
58
|
+
status === "spam" ? [] : submissions,
|
|
59
|
+
),
|
|
60
|
+
count: jest.fn(async ({ status }) => (status === "spam" ? 0 : submissions.length)),
|
|
61
|
+
...over,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderBrowser(props: Partial<React.ComponentProps<typeof FormsBrowser>> = {}) {
|
|
66
|
+
const api = props.api ?? makeApi();
|
|
67
|
+
const onSelectForm = props.onSelectForm ?? jest.fn();
|
|
68
|
+
const utils = render(
|
|
69
|
+
<ThemeProvider>
|
|
70
|
+
<FormsBrowser
|
|
71
|
+
api={api}
|
|
72
|
+
form={props.form ?? null}
|
|
73
|
+
onSelectForm={onSelectForm}
|
|
74
|
+
selectedId={props.selectedId}
|
|
75
|
+
onSelectSubmission={props.onSelectSubmission}
|
|
76
|
+
variant={props.variant ?? "desktop"}
|
|
77
|
+
/>
|
|
78
|
+
</ThemeProvider>,
|
|
79
|
+
);
|
|
80
|
+
return { ...utils, api, onSelectForm };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── nav keys ────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe("the forms nav-key space", () => {
|
|
86
|
+
it("cannot collide with a collection, because a collection name has no colon", () => {
|
|
87
|
+
expect(isFormsNavKey("forms")).toBe(false); // a collection literally named "forms"
|
|
88
|
+
expect(isFormsNavKey(FORMS_NAV_KEY)).toBe(true);
|
|
89
|
+
expect(isFormsNavKey(formsNavKey("contact"))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("tells the Forms button apart from a chosen form", () => {
|
|
93
|
+
expect(parseFormsNavKey(FORMS_NAV_KEY)).toBeNull();
|
|
94
|
+
expect(parseFormsNavKey(formsNavKey("contact"))).toBe("contact");
|
|
95
|
+
expect(parseFormsNavKey("posts")).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── the forms list ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("the forms list", () => {
|
|
102
|
+
it("shows each form's name, description, field count and submission count", () => {
|
|
103
|
+
renderBrowser();
|
|
104
|
+
expect(screen.getByText("Contact us")).toBeTruthy();
|
|
105
|
+
expect(screen.getByText("General enquiries.")).toBeTruthy();
|
|
106
|
+
expect(screen.getByText("3 fields")).toBeTruthy();
|
|
107
|
+
expect(screen.getByText("2")).toBeTruthy();
|
|
108
|
+
expect(screen.getByText("308")).toBeTruthy();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("opens a form when its row is pressed", () => {
|
|
112
|
+
const { onSelectForm } = renderBrowser();
|
|
113
|
+
fireEvent.click(screen.getByTestId("form-row-contact"));
|
|
114
|
+
expect(onSelectForm).toHaveBeenCalledWith("contact");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("says so when the branch declares no forms", () => {
|
|
118
|
+
renderBrowser({ api: makeApi({ forms: [] }) });
|
|
119
|
+
expect(screen.getByText(/declares no forms/i)).toBeTruthy();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── submissions ─────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe("a form's submissions", () => {
|
|
126
|
+
it("lists them with a summary line built from what the person wrote", async () => {
|
|
127
|
+
renderBrowser({ form: "contact" });
|
|
128
|
+
await waitFor(() => expect(screen.getByTestId("submission-row-s1")).toBeTruthy());
|
|
129
|
+
// The email is preferred: it is what a reader scans an inbox for.
|
|
130
|
+
expect(screen.getByText("priya@example.test")).toBeTruthy();
|
|
131
|
+
expect(screen.getByText("sam@example.test")).toBeTruthy();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reports a tap so the host can bind it to the URL", async () => {
|
|
135
|
+
const onSelectSubmission = jest.fn();
|
|
136
|
+
renderBrowser({ form: "contact", onSelectSubmission });
|
|
137
|
+
await waitFor(() => expect(screen.getByTestId("submission-row-s1")).toBeTruthy());
|
|
138
|
+
fireEvent.click(screen.getByTestId("submission-row-s1"));
|
|
139
|
+
expect(onSelectSubmission).toHaveBeenCalledWith("s1");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("goes back to the forms list", async () => {
|
|
143
|
+
const { onSelectForm } = renderBrowser({ form: "contact" });
|
|
144
|
+
await waitFor(() => expect(screen.getByTestId("forms-back")).toBeTruthy());
|
|
145
|
+
fireEvent.click(screen.getByTestId("forms-back"));
|
|
146
|
+
expect(onSelectForm).toHaveBeenCalledWith(null);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("has somewhere to see spam, since a honeypot hit is recorded not discarded", async () => {
|
|
150
|
+
const { api } = renderBrowser({ form: "contact" });
|
|
151
|
+
await waitFor(() => expect(screen.getByTestId("forms-status-toggle")).toBeTruthy());
|
|
152
|
+
fireEvent.click(screen.getByTestId("forms-status-toggle"));
|
|
153
|
+
await waitFor(() =>
|
|
154
|
+
expect(api.list).toHaveBeenCalledWith(expect.objectContaining({ status: "spam" })),
|
|
155
|
+
);
|
|
156
|
+
await waitFor(() => expect(screen.getByText(/Nothing caught as spam/i)).toBeTruthy());
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── the read-only submission ────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe("a selected submission", () => {
|
|
163
|
+
it("renders every value read-only, with no editable control", async () => {
|
|
164
|
+
renderBrowser({ form: "contact", selectedId: "s1" });
|
|
165
|
+
await waitFor(() => expect(screen.getByText("A-1")).toBeTruthy());
|
|
166
|
+
// The email shows twice — once as the row's summary line, once as the
|
|
167
|
+
// detail's value — which is what a list beside a detail looks like.
|
|
168
|
+
expect(screen.getAllByText("priya@example.test").length).toBeGreaterThan(0);
|
|
169
|
+
expect(screen.getByText("support")).toBeTruthy();
|
|
170
|
+
// A submission is a record of what someone sent, not a document with
|
|
171
|
+
// editing switched off.
|
|
172
|
+
expect(screen.queryByDisplayValue("priya@example.test")).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("distinguishes a field that was never asked for from one left blank", async () => {
|
|
176
|
+
renderBrowser({ form: "contact", selectedId: "s2" });
|
|
177
|
+
await waitFor(() => expect(screen.getByTestId("submission-not-asked-orderNumber")).toBeTruthy());
|
|
178
|
+
expect(screen.getByText("Not asked")).toBeTruthy();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("shows where a versioned submission lives in the repository", async () => {
|
|
182
|
+
renderBrowser({ form: "contact", selectedId: "s1" });
|
|
183
|
+
await waitFor(() =>
|
|
184
|
+
expect(screen.getByText("content/submissions/contact/s1.json")).toBeTruthy(),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("omits the repository path for a submission git does not hold", async () => {
|
|
189
|
+
renderBrowser({ form: "contact", selectedId: "s2" });
|
|
190
|
+
await waitFor(() => expect(screen.getByText("Not asked")).toBeTruthy());
|
|
191
|
+
expect(screen.queryByText(/In the repository/i)).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── the summary line ────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
describe("summaryLine", () => {
|
|
198
|
+
it("prefers an email-ish value, then the first readable text", () => {
|
|
199
|
+
expect(summaryLine(submissions[0], contact)).toBe("priya@example.test");
|
|
200
|
+
expect(
|
|
201
|
+
summaryLine(
|
|
202
|
+
{ ...submissions[0], fields: { reason: "support", orderNumber: "A-1" } },
|
|
203
|
+
contact,
|
|
204
|
+
),
|
|
205
|
+
).toBe("support");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("says so rather than rendering an empty row", () => {
|
|
209
|
+
expect(summaryLine({ ...submissions[0], fields: { agreed: true } }, contact)).toBe("(no text)");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// A form can legitimately fail to list — local mode answers NO_SUBMISSION_STORE
|
|
214
|
+
// for a form that keeps its submissions in a database it does not have. Before
|
|
215
|
+
// this had somewhere to go, the promise rejected and the pane spun forever.
|
|
216
|
+
describe("a form whose submissions cannot be listed", () => {
|
|
217
|
+
const failing = (): FormsApi => ({
|
|
218
|
+
forms: [contact],
|
|
219
|
+
list: jest.fn().mockRejectedValue(
|
|
220
|
+
Object.assign(new Error("network"), {
|
|
221
|
+
graphQLErrors: [{ message: "form \"newsletter\" keeps its submissions in a database" }],
|
|
222
|
+
}),
|
|
223
|
+
),
|
|
224
|
+
count: jest.fn().mockRejectedValue(new Error("network")),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("stops loading instead of spinning forever", async () => {
|
|
228
|
+
renderBrowser({ api: failing(), form: "contact" });
|
|
229
|
+
await waitFor(() => expect(screen.getByTestId("forms-error")).toBeTruthy());
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("shows the server's own sentence, which says what to change", async () => {
|
|
233
|
+
renderBrowser({ api: failing(), form: "contact" });
|
|
234
|
+
await waitFor(() =>
|
|
235
|
+
expect(screen.getByText(/keeps its submissions in a database/)).toBeTruthy(),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("falls back to something readable when the error carries no message", async () => {
|
|
240
|
+
const api: FormsApi = {
|
|
241
|
+
forms: [contact],
|
|
242
|
+
list: jest.fn().mockRejectedValue({}),
|
|
243
|
+
count: jest.fn().mockRejectedValue({}),
|
|
244
|
+
};
|
|
245
|
+
renderBrowser({ api, form: "contact" });
|
|
246
|
+
await waitFor(() => expect(screen.getByText(/could not be loaded/)).toBeTruthy());
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
import { clamp, reorder, slotAtX } from "./reorder";
|
|
32
32
|
import { MediaField, MediaProvider, DocumentPathProvider } from "./MediaField";
|
|
33
33
|
import { MediaBrowser } from "./MediaBrowser";
|
|
34
|
+
import { FormsBrowser } from "./FormsBrowser";
|
|
35
|
+
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
|
|
34
36
|
import {
|
|
35
37
|
MEDIA_NAV_KEY,
|
|
36
38
|
mediaNavKey,
|
|
@@ -334,6 +336,14 @@ export type ContentBrowserProps = {
|
|
|
334
336
|
// store gets.
|
|
335
337
|
media?: MediaApi;
|
|
336
338
|
|
|
339
|
+
// Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
|
|
340
|
+
// surface never renders, which is what a config declaring no forms looks like.
|
|
341
|
+
forms?: FormsApi;
|
|
342
|
+
// The selected submission's id, and the reporter for taps — bound to the URL
|
|
343
|
+
// by the host exactly as document selection is.
|
|
344
|
+
selectedSubmissionId?: string | null;
|
|
345
|
+
onSelectSubmission?: (id: string | null) => void;
|
|
346
|
+
|
|
337
347
|
// When provided, field/body edits autosave 1s after the last keystroke and an
|
|
338
348
|
// autosave indicator appears in the detail header. Omitted → edits stay local
|
|
339
349
|
// (the demo/read-only behavior).
|
|
@@ -3599,6 +3609,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
3599
3609
|
const mediaShowing = isMediaNavKey(activeNavKey);
|
|
3600
3610
|
const mediaSet = parseMediaNavKey(activeNavKey);
|
|
3601
3611
|
const mediaSets = props.media?.sets ?? [];
|
|
3612
|
+
// Forms occupy their own prefixed nav-key space, mirroring media's — a
|
|
3613
|
+
// collection name cannot contain a colon, so neither can collide.
|
|
3614
|
+
const formsShowing = isFormsNavKey(activeNavKey);
|
|
3615
|
+
const activeForm = parseFormsNavKey(activeNavKey);
|
|
3602
3616
|
// "Browsing" mode (collection-driven): nothing auto-selected, empty states shown.
|
|
3603
3617
|
const browsing = entriesEmpty !== undefined;
|
|
3604
3618
|
// Controlled selection: when the host wired onSelectEntry, selection lives in
|
|
@@ -4129,6 +4143,30 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4129
4143
|
// lists the media sets exactly where a collection's documents would be, and the
|
|
4130
4144
|
// details pane holds the browser for whichever set is selected. Selecting a set
|
|
4131
4145
|
// goes through onSelectNav, so it lands in the URL like any other selection.
|
|
4146
|
+
// Forms take the whole content area rather than the three-pane model: the
|
|
4147
|
+
// surface is already two levels deep (forms → submissions → one submission),
|
|
4148
|
+
// and threading that through panes designed for collection → document → editor
|
|
4149
|
+
// would mean a pane whose meaning changes with the level.
|
|
4150
|
+
if (formsShowing && props.forms) {
|
|
4151
|
+
return (
|
|
4152
|
+
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4153
|
+
{navPane}
|
|
4154
|
+
<ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
|
|
4155
|
+
<Pane flex={1} testID="pane-forms" scroll={false}>
|
|
4156
|
+
<FormsBrowser
|
|
4157
|
+
api={props.forms}
|
|
4158
|
+
form={activeForm}
|
|
4159
|
+
onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
|
|
4160
|
+
selectedId={props.selectedSubmissionId ?? null}
|
|
4161
|
+
onSelectSubmission={props.onSelectSubmission}
|
|
4162
|
+
variant="desktop"
|
|
4163
|
+
/>
|
|
4164
|
+
</Pane>
|
|
4165
|
+
{applyModal}
|
|
4166
|
+
</AppShell>
|
|
4167
|
+
);
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4132
4170
|
if (mediaShowing) {
|
|
4133
4171
|
return (
|
|
4134
4172
|
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
@@ -4436,6 +4474,8 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4436
4474
|
const mediaShowing = isMediaNavKey(activeNavKey);
|
|
4437
4475
|
const mediaSet = parseMediaNavKey(activeNavKey);
|
|
4438
4476
|
const mediaSets = props.media?.sets ?? [];
|
|
4477
|
+
const formsShowing = isFormsNavKey(activeNavKey);
|
|
4478
|
+
const activeForm = parseFormsNavKey(activeNavKey);
|
|
4439
4479
|
// A plugin screen renders as its own drilled-in screen; Back returns to the
|
|
4440
4480
|
// nav list like any other selection.
|
|
4441
4481
|
if (props.contentSlot != null) {
|
|
@@ -4455,6 +4495,40 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4455
4495
|
</MobileScreen>
|
|
4456
4496
|
);
|
|
4457
4497
|
}
|
|
4498
|
+
if (formsShowing && props.forms) {
|
|
4499
|
+
return (
|
|
4500
|
+
<MobileScreen
|
|
4501
|
+
testID="mobile-forms"
|
|
4502
|
+
header={
|
|
4503
|
+
<>
|
|
4504
|
+
{/* Back steps up one level, not out to the nav — the same rule the
|
|
4505
|
+
media and collection screens follow. */}
|
|
4506
|
+
<IconButton
|
|
4507
|
+
name="chevronLeft"
|
|
4508
|
+
onPress={() => (activeForm ? onSelectNav(FORMS_NAV_KEY) : backToNav())}
|
|
4509
|
+
size="md"
|
|
4510
|
+
label="Back"
|
|
4511
|
+
/>
|
|
4512
|
+
<Text variant="body" weight="semibold" style={{ flex: 1 }}>
|
|
4513
|
+
{activeForm
|
|
4514
|
+
? props.forms.forms.find((f) => f.name === activeForm)?.label || activeForm
|
|
4515
|
+
: "Forms"}
|
|
4516
|
+
</Text>
|
|
4517
|
+
</>
|
|
4518
|
+
}
|
|
4519
|
+
>
|
|
4520
|
+
<FormsBrowser
|
|
4521
|
+
api={props.forms}
|
|
4522
|
+
form={activeForm}
|
|
4523
|
+
onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
|
|
4524
|
+
selectedId={props.selectedSubmissionId ?? null}
|
|
4525
|
+
onSelectSubmission={props.onSelectSubmission}
|
|
4526
|
+
variant="mobile"
|
|
4527
|
+
/>
|
|
4528
|
+
</MobileScreen>
|
|
4529
|
+
);
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4458
4532
|
if (mediaShowing) {
|
|
4459
4533
|
if (!mediaSet) {
|
|
4460
4534
|
return (
|
|
@@ -4553,8 +4627,11 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4553
4627
|
</View>
|
|
4554
4628
|
) : null}
|
|
4555
4629
|
{readOnlyBanner}
|
|
4556
|
-
{sections.map((section) => (
|
|
4557
|
-
|
|
4630
|
+
{sections.map((section, i) => (
|
|
4631
|
+
// Keyed with the index as a fallback: an untitled section renders its
|
|
4632
|
+
// button without a heading, and there is more than one of those now
|
|
4633
|
+
// (Media, Forms), so the title alone is not unique.
|
|
4634
|
+
<View key={section.title || `section-${i}`}>
|
|
4558
4635
|
<SectionLabel>{section.title}</SectionLabel>
|
|
4559
4636
|
{section.items.map((item, i) => (
|
|
4560
4637
|
<NavRow
|
|
@@ -0,0 +1,545 @@
|
|
|
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
|
+
{/* 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>
|
|
138
|
+
<Text variant="monoSm" color="secondary" style={{ minWidth: 48, textAlign: "right" }}>
|
|
139
|
+
{form.submissionCount}
|
|
140
|
+
</Text>
|
|
141
|
+
<Icon name="chevronRight" size={14} color={t.color.textTertiary} />
|
|
142
|
+
</Pressable>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── One form's submissions ──────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function SubmissionsView({
|
|
149
|
+
api,
|
|
150
|
+
form,
|
|
151
|
+
onBack,
|
|
152
|
+
selectedId,
|
|
153
|
+
onSelect,
|
|
154
|
+
isDesktop,
|
|
155
|
+
}: {
|
|
156
|
+
api: FormsApi;
|
|
157
|
+
form: FormInfo;
|
|
158
|
+
onBack: () => void;
|
|
159
|
+
selectedId: string | null;
|
|
160
|
+
onSelect?: (id: string | null) => void;
|
|
161
|
+
isDesktop: boolean;
|
|
162
|
+
}) {
|
|
163
|
+
const t = useTheme();
|
|
164
|
+
const [items, setItems] = React.useState<SubmissionInfo[]>([]);
|
|
165
|
+
const [total, setTotal] = React.useState<number | null>(null);
|
|
166
|
+
const [loading, setLoading] = React.useState(true);
|
|
167
|
+
// A form can legitimately fail to list: local mode answers
|
|
168
|
+
// NO_SUBMISSION_STORE for a form that keeps its submissions in a database it
|
|
169
|
+
// does not have. Without somewhere to put that, the promise rejected and the
|
|
170
|
+
// pane spun forever — which is a worse answer than the empty inbox this was
|
|
171
|
+
// written to avoid.
|
|
172
|
+
const [failure, setFailure] = React.useState<string | null>(null);
|
|
173
|
+
const [loadingMore, setLoadingMore] = React.useState(false);
|
|
174
|
+
const [exhausted, setExhausted] = React.useState(false);
|
|
175
|
+
const [search, setSearch] = React.useState("");
|
|
176
|
+
const [status, setStatus] = React.useState<"received" | "spam">("received");
|
|
177
|
+
|
|
178
|
+
// Switching form, filter or search is a different result set: reset rather
|
|
179
|
+
// than paging one query's offsets into another's.
|
|
180
|
+
React.useEffect(() => {
|
|
181
|
+
let alive = true;
|
|
182
|
+
setLoading(true);
|
|
183
|
+
setExhausted(false);
|
|
184
|
+
setFailure(null);
|
|
185
|
+
Promise.all([
|
|
186
|
+
api.list({ form: form.name, status, search, limit: PAGE_SIZE, offset: 0 }),
|
|
187
|
+
api.count({ form: form.name, status, search }),
|
|
188
|
+
])
|
|
189
|
+
.then(([rows, n]) => {
|
|
190
|
+
if (!alive) return;
|
|
191
|
+
setItems(rows);
|
|
192
|
+
setTotal(n);
|
|
193
|
+
setExhausted(rows.length >= n);
|
|
194
|
+
})
|
|
195
|
+
.catch((err: unknown) => {
|
|
196
|
+
if (!alive) return;
|
|
197
|
+
setItems([]);
|
|
198
|
+
setTotal(null);
|
|
199
|
+
// The server's own sentence, which for the case that actually happens
|
|
200
|
+
// says what to change in the config. A generic "couldn't load" would
|
|
201
|
+
// throw that away.
|
|
202
|
+
setFailure(messageOf(err));
|
|
203
|
+
})
|
|
204
|
+
.finally(() => alive && setLoading(false));
|
|
205
|
+
return () => {
|
|
206
|
+
alive = false;
|
|
207
|
+
};
|
|
208
|
+
}, [api, form.name, status, search]);
|
|
209
|
+
|
|
210
|
+
const loadMore = React.useCallback(() => {
|
|
211
|
+
if (loadingMore || exhausted || loading) return;
|
|
212
|
+
setLoadingMore(true);
|
|
213
|
+
api
|
|
214
|
+
.list({ form: form.name, status, search, limit: PAGE_SIZE, offset: items.length })
|
|
215
|
+
.then((rows) => {
|
|
216
|
+
setItems((prev) => [...prev, ...rows]);
|
|
217
|
+
if (rows.length < PAGE_SIZE) setExhausted(true);
|
|
218
|
+
})
|
|
219
|
+
.finally(() => setLoadingMore(false));
|
|
220
|
+
}, [api, form.name, status, search, items.length, loading, loadingMore, exhausted]);
|
|
221
|
+
|
|
222
|
+
const selected = selectedId ? items.find((s) => s.id === selectedId) ?? null : null;
|
|
223
|
+
|
|
224
|
+
const list = (
|
|
225
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
226
|
+
<View
|
|
227
|
+
style={{
|
|
228
|
+
flexDirection: "row",
|
|
229
|
+
alignItems: "center",
|
|
230
|
+
gap: t.space(2),
|
|
231
|
+
paddingHorizontal: t.space(3),
|
|
232
|
+
paddingVertical: t.space(2),
|
|
233
|
+
borderBottomWidth: 1,
|
|
234
|
+
borderBottomColor: t.color.borderSubtle,
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
<Pressable onPress={onBack} testID="forms-back" style={{ padding: t.space(1) }}>
|
|
238
|
+
<Icon name="chevronLeft" size={16} color={t.color.textSecondary} />
|
|
239
|
+
</Pressable>
|
|
240
|
+
<Text numberOfLines={1} style={{ flex: 1 }}>
|
|
241
|
+
{form.label || form.name}
|
|
242
|
+
</Text>
|
|
243
|
+
<Text variant="monoSm" color="tertiary">
|
|
244
|
+
{total ?? "—"}
|
|
245
|
+
</Text>
|
|
246
|
+
</View>
|
|
247
|
+
|
|
248
|
+
<View
|
|
249
|
+
style={{
|
|
250
|
+
flexDirection: "row",
|
|
251
|
+
alignItems: "center",
|
|
252
|
+
gap: t.space(2),
|
|
253
|
+
paddingHorizontal: t.space(3),
|
|
254
|
+
paddingVertical: t.space(2),
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
<TextInput
|
|
258
|
+
value={search}
|
|
259
|
+
onChangeText={setSearch}
|
|
260
|
+
placeholder="Search submissions"
|
|
261
|
+
placeholderTextColor={t.color.textTertiary}
|
|
262
|
+
testID="forms-search"
|
|
263
|
+
style={{
|
|
264
|
+
flex: 1,
|
|
265
|
+
height: t.control.sm,
|
|
266
|
+
paddingHorizontal: t.space(2),
|
|
267
|
+
borderWidth: 1,
|
|
268
|
+
borderColor: t.color.borderDefault,
|
|
269
|
+
borderRadius: t.radius.sm,
|
|
270
|
+
color: t.color.textPrimary,
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
{/* Spam is recorded rather than discarded (a honeypot also catches
|
|
274
|
+
browser autofill), so it needs somewhere to be seen. */}
|
|
275
|
+
<Pressable
|
|
276
|
+
onPress={() => setStatus(status === "received" ? "spam" : "received")}
|
|
277
|
+
testID="forms-status-toggle"
|
|
278
|
+
style={{
|
|
279
|
+
height: t.control.sm,
|
|
280
|
+
justifyContent: "center",
|
|
281
|
+
paddingHorizontal: t.space(2),
|
|
282
|
+
borderWidth: 1,
|
|
283
|
+
borderColor: status === "spam" ? t.color.borderStrong : t.color.borderDefault,
|
|
284
|
+
borderRadius: t.radius.sm,
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<Text variant="monoSm" color={status === "spam" ? "primary" : "tertiary"}>
|
|
288
|
+
Spam
|
|
289
|
+
</Text>
|
|
290
|
+
</Pressable>
|
|
291
|
+
</View>
|
|
292
|
+
|
|
293
|
+
{loading ? (
|
|
294
|
+
<View style={{ padding: t.space(6), alignItems: "center" }}>
|
|
295
|
+
<Spinner />
|
|
296
|
+
</View>
|
|
297
|
+
) : failure ? (
|
|
298
|
+
<View style={{ padding: t.space(6) }} testID="forms-error">
|
|
299
|
+
<Text color="secondary">{failure}</Text>
|
|
300
|
+
</View>
|
|
301
|
+
) : items.length === 0 ? (
|
|
302
|
+
<View style={{ padding: t.space(6) }}>
|
|
303
|
+
<Text color="secondary">
|
|
304
|
+
{status === "spam" ? "Nothing caught as spam." : "No submissions yet."}
|
|
305
|
+
</Text>
|
|
306
|
+
</View>
|
|
307
|
+
) : (
|
|
308
|
+
<FlatList
|
|
309
|
+
data={items}
|
|
310
|
+
keyExtractor={(s) => s.id}
|
|
311
|
+
onEndReached={loadMore}
|
|
312
|
+
onEndReachedThreshold={0.4}
|
|
313
|
+
ListFooterComponent={
|
|
314
|
+
loadingMore ? (
|
|
315
|
+
<View style={{ padding: t.space(4), alignItems: "center" }}>
|
|
316
|
+
<Spinner />
|
|
317
|
+
</View>
|
|
318
|
+
) : null
|
|
319
|
+
}
|
|
320
|
+
renderItem={({ item }) => (
|
|
321
|
+
<SubmissionRow
|
|
322
|
+
submission={item}
|
|
323
|
+
form={form}
|
|
324
|
+
selected={item.id === selectedId}
|
|
325
|
+
onPress={() => onSelect?.(item.id)}
|
|
326
|
+
/>
|
|
327
|
+
)}
|
|
328
|
+
/>
|
|
329
|
+
)}
|
|
330
|
+
</View>
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
if (!isDesktop) {
|
|
334
|
+
// Mobile pushes: the list, or the submission on top of it.
|
|
335
|
+
return selected ? (
|
|
336
|
+
<SubmissionDetail submission={selected} form={form} onBack={() => onSelect?.(null)} />
|
|
337
|
+
) : (
|
|
338
|
+
list
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<View style={{ flex: 1, flexDirection: "row", minWidth: 0 }}>
|
|
344
|
+
<View
|
|
345
|
+
style={{
|
|
346
|
+
width: t.layout.column,
|
|
347
|
+
borderRightWidth: 1,
|
|
348
|
+
borderRightColor: t.color.borderSubtle,
|
|
349
|
+
}}
|
|
350
|
+
>
|
|
351
|
+
{list}
|
|
352
|
+
</View>
|
|
353
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
354
|
+
{selected ? (
|
|
355
|
+
<SubmissionDetail submission={selected} form={form} />
|
|
356
|
+
) : (
|
|
357
|
+
<View style={{ padding: t.space(6) }}>
|
|
358
|
+
<Text color="secondary">Select a submission</Text>
|
|
359
|
+
</View>
|
|
360
|
+
)}
|
|
361
|
+
</View>
|
|
362
|
+
</View>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function SubmissionRow({
|
|
367
|
+
submission,
|
|
368
|
+
form,
|
|
369
|
+
selected,
|
|
370
|
+
onPress,
|
|
371
|
+
}: {
|
|
372
|
+
submission: SubmissionInfo;
|
|
373
|
+
form: FormInfo;
|
|
374
|
+
selected: boolean;
|
|
375
|
+
onPress: () => void;
|
|
376
|
+
}) {
|
|
377
|
+
const t = useTheme();
|
|
378
|
+
const [hover, setHover] = React.useState(false);
|
|
379
|
+
return (
|
|
380
|
+
<Pressable
|
|
381
|
+
onPress={onPress}
|
|
382
|
+
onHoverIn={() => setHover(true)}
|
|
383
|
+
onHoverOut={() => setHover(false)}
|
|
384
|
+
testID={`submission-row-${submission.id}`}
|
|
385
|
+
style={{
|
|
386
|
+
gap: 2,
|
|
387
|
+
paddingHorizontal: t.space(4),
|
|
388
|
+
paddingVertical: t.space(3),
|
|
389
|
+
borderBottomWidth: 1,
|
|
390
|
+
borderBottomColor: t.color.borderSubtle,
|
|
391
|
+
backgroundColor: selected
|
|
392
|
+
? t.color.surfaceActive
|
|
393
|
+
: hover
|
|
394
|
+
? t.color.surfaceHover
|
|
395
|
+
: "transparent",
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
<Text numberOfLines={1}>{summaryLine(submission, form)}</Text>
|
|
399
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
400
|
+
<Text variant="monoSm" color="tertiary">
|
|
401
|
+
{formatWhen(submission.submittedAt)}
|
|
402
|
+
</Text>
|
|
403
|
+
{submission.status === "spam" ? (
|
|
404
|
+
<Text variant="monoSm" color={t.color.diffDelFg}>
|
|
405
|
+
spam
|
|
406
|
+
</Text>
|
|
407
|
+
) : null}
|
|
408
|
+
</View>
|
|
409
|
+
</Pressable>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── One submission, read-only ───────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
function SubmissionDetail({
|
|
416
|
+
submission,
|
|
417
|
+
form,
|
|
418
|
+
onBack,
|
|
419
|
+
}: {
|
|
420
|
+
submission: SubmissionInfo;
|
|
421
|
+
form: FormInfo;
|
|
422
|
+
onBack?: () => void;
|
|
423
|
+
}) {
|
|
424
|
+
const t = useTheme();
|
|
425
|
+
return (
|
|
426
|
+
<View style={{ flex: 1 }}>
|
|
427
|
+
<View
|
|
428
|
+
style={{
|
|
429
|
+
flexDirection: "row",
|
|
430
|
+
alignItems: "center",
|
|
431
|
+
gap: t.space(2),
|
|
432
|
+
paddingHorizontal: t.space(4),
|
|
433
|
+
paddingVertical: t.space(3),
|
|
434
|
+
borderBottomWidth: 1,
|
|
435
|
+
borderBottomColor: t.color.borderSubtle,
|
|
436
|
+
}}
|
|
437
|
+
>
|
|
438
|
+
{onBack ? (
|
|
439
|
+
<Pressable onPress={onBack} testID="submission-back" style={{ padding: t.space(1) }}>
|
|
440
|
+
<Icon name="chevronLeft" size={16} color={t.color.textSecondary} />
|
|
441
|
+
</Pressable>
|
|
442
|
+
) : null}
|
|
443
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
444
|
+
<Text numberOfLines={1}>{summaryLine(submission, form)}</Text>
|
|
445
|
+
<Text variant="monoSm" color="tertiary">
|
|
446
|
+
{formatWhen(submission.submittedAt)}
|
|
447
|
+
</Text>
|
|
448
|
+
</View>
|
|
449
|
+
</View>
|
|
450
|
+
|
|
451
|
+
<View style={{ padding: t.space(4), gap: t.space(4) }}>
|
|
452
|
+
{form.fields.map((f) => {
|
|
453
|
+
const present = Object.prototype.hasOwnProperty.call(submission.fields, f.name);
|
|
454
|
+
return (
|
|
455
|
+
<View key={f.name} style={{ gap: t.space(1) }}>
|
|
456
|
+
<Text variant="label" color="tertiary">
|
|
457
|
+
{f.label || f.name}
|
|
458
|
+
</Text>
|
|
459
|
+
{present ? (
|
|
460
|
+
<FieldValue value={submission.fields[f.name]} />
|
|
461
|
+
) : (
|
|
462
|
+
// The visible payoff of storing an inapplicable field as ABSENT
|
|
463
|
+
// rather than null: "we never asked" is a different fact from
|
|
464
|
+
// "they left it blank", and the record can say which.
|
|
465
|
+
<Text variant="sm" color="disabled" testID={`submission-not-asked-${f.name}`}>
|
|
466
|
+
Not asked
|
|
467
|
+
</Text>
|
|
468
|
+
)}
|
|
469
|
+
</View>
|
|
470
|
+
);
|
|
471
|
+
})}
|
|
472
|
+
|
|
473
|
+
{submission.path ? (
|
|
474
|
+
<View style={{ gap: t.space(1), paddingTop: t.space(2) }}>
|
|
475
|
+
<Text variant="label" color="tertiary">
|
|
476
|
+
In the repository
|
|
477
|
+
</Text>
|
|
478
|
+
<Text variant="monoSm" color="secondary">
|
|
479
|
+
{submission.path}
|
|
480
|
+
</Text>
|
|
481
|
+
</View>
|
|
482
|
+
) : null}
|
|
483
|
+
</View>
|
|
484
|
+
</View>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// FieldValue renders one stored value for reading. Deliberately small: the
|
|
489
|
+
// shapes a submission can hold are the config's field types, and each has an
|
|
490
|
+
// obvious read form.
|
|
491
|
+
function FieldValue({ value }: { value: unknown }) {
|
|
492
|
+
const t = useTheme();
|
|
493
|
+
if (value === null || value === undefined || value === "") {
|
|
494
|
+
return (
|
|
495
|
+
<Text variant="sm" color="disabled">
|
|
496
|
+
—
|
|
497
|
+
</Text>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (typeof value === "boolean") {
|
|
501
|
+
return <Text>{value ? "Yes" : "No"}</Text>;
|
|
502
|
+
}
|
|
503
|
+
if (Array.isArray(value)) {
|
|
504
|
+
return (
|
|
505
|
+
<View style={{ gap: t.space(1) }}>
|
|
506
|
+
{value.map((v, i) => (
|
|
507
|
+
<Text key={i}>• {typeof v === "object" ? JSON.stringify(v) : String(v)}</Text>
|
|
508
|
+
))}
|
|
509
|
+
</View>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (typeof value === "object") {
|
|
513
|
+
return (
|
|
514
|
+
<View style={{ gap: t.space(1) }}>
|
|
515
|
+
{Object.entries(value as Record<string, unknown>).map(([k, v]) => (
|
|
516
|
+
<View key={k} style={{ flexDirection: "row", gap: t.space(2) }}>
|
|
517
|
+
<Text variant="monoSm" color="tertiary">
|
|
518
|
+
{k}
|
|
519
|
+
</Text>
|
|
520
|
+
<Text style={{ flex: 1 }}>{typeof v === "object" ? JSON.stringify(v) : String(v)}</Text>
|
|
521
|
+
</View>
|
|
522
|
+
))}
|
|
523
|
+
</View>
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
return <Text>{String(value)}</Text>;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// messageOf digs the human sentence out of whatever the host's api threw. An
|
|
530
|
+
// Apollo error carries the server's message on graphQLErrors; everything else
|
|
531
|
+
// is an Error, or something that is not.
|
|
532
|
+
function messageOf(err: unknown): string {
|
|
533
|
+
const gql = (err as { graphQLErrors?: { message?: string }[] })?.graphQLErrors;
|
|
534
|
+
if (Array.isArray(gql) && gql[0]?.message) return gql[0].message!;
|
|
535
|
+
if (err instanceof Error && err.message) return err.message;
|
|
536
|
+
return "These submissions could not be loaded.";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// formatWhen renders a timestamp the way an inbox does. Falls back to the raw
|
|
540
|
+
// string rather than showing "Invalid Date" for anything unparseable.
|
|
541
|
+
export function formatWhen(iso: string): string {
|
|
542
|
+
const d = new Date(iso);
|
|
543
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
544
|
+
return d.toLocaleString();
|
|
545
|
+
}
|
package/src/forms.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Forms in the editor: the nav-key space they occupy and the data seam the host
|
|
2
|
+
// fills (docs/forms.md §10).
|
|
3
|
+
//
|
|
4
|
+
// The scheme mirrors media's exactly, and for the same reason: a collection name
|
|
5
|
+
// is restricted to [A-Za-z0-9_-] by the config schema, so it can never contain a
|
|
6
|
+
// colon — not even a collection literally named "forms".
|
|
7
|
+
//
|
|
8
|
+
// "forms:" the Forms button — the list of forms
|
|
9
|
+
// "forms:contact" one form's submissions, which is what makes it deep-linkable
|
|
10
|
+
const FORMS_NAV_PREFIX = "forms:";
|
|
11
|
+
|
|
12
|
+
/** FORMS_NAV_KEY is the Forms button's own key — forms with none selected. */
|
|
13
|
+
export const FORMS_NAV_KEY = FORMS_NAV_PREFIX;
|
|
14
|
+
|
|
15
|
+
/** formsNavKey returns the nav key for a form, or for the Forms button. */
|
|
16
|
+
export function formsNavKey(form?: string): string {
|
|
17
|
+
return form ? FORMS_NAV_PREFIX + form : FORMS_NAV_PREFIX;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* isFormsNavKey reports whether a nav key addresses forms at all — true for the
|
|
22
|
+
* Forms button and for any form beneath it. This is what a host checks to know
|
|
23
|
+
* the forms surface is showing.
|
|
24
|
+
*/
|
|
25
|
+
export function isFormsNavKey(key: string | null | undefined): boolean {
|
|
26
|
+
return !!key && key.startsWith(FORMS_NAV_PREFIX);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* parseFormsNavKey returns the form a nav key selects: null for an ordinary
|
|
31
|
+
* collection, and also null for the bare Forms button (forms are showing, but no
|
|
32
|
+
* form is chosen). Pair it with isFormsNavKey to tell those two apart.
|
|
33
|
+
*/
|
|
34
|
+
export function parseFormsNavKey(key: string | null | undefined): string | null {
|
|
35
|
+
if (!isFormsNavKey(key)) return null;
|
|
36
|
+
const form = key!.slice(FORMS_NAV_PREFIX.length);
|
|
37
|
+
return form.length > 0 ? form : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One field of a form, as the editor needs to render a submission. */
|
|
41
|
+
export type FormFieldInfo = {
|
|
42
|
+
name: string;
|
|
43
|
+
label?: string | null;
|
|
44
|
+
/** string | integer | float | boolean | array | object */
|
|
45
|
+
type: string;
|
|
46
|
+
component?: string | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** One form, with the summary numbers the Forms list shows. */
|
|
50
|
+
export type FormInfo = {
|
|
51
|
+
name: string;
|
|
52
|
+
label?: string | null;
|
|
53
|
+
description?: string | null;
|
|
54
|
+
fields: FormFieldInfo[];
|
|
55
|
+
fieldCount: number;
|
|
56
|
+
submissionCount: number;
|
|
57
|
+
lastSubmissionAt?: string | null;
|
|
58
|
+
/** True when the form writes its submissions to git. */
|
|
59
|
+
versioned: boolean;
|
|
60
|
+
storePath?: string | null;
|
|
61
|
+
canDelete: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** One submission, read-only by construction — a record of what someone sent. */
|
|
65
|
+
export type SubmissionInfo = {
|
|
66
|
+
id: string;
|
|
67
|
+
form: string;
|
|
68
|
+
/** Field name → value, missing entirely for a field whose condition was false. */
|
|
69
|
+
fields: Record<string, unknown>;
|
|
70
|
+
submittedAt: string;
|
|
71
|
+
/** "received" | "spam" */
|
|
72
|
+
status: string;
|
|
73
|
+
/** Where it lives in the repository, when the form versions its submissions. */
|
|
74
|
+
path?: string | null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* FormsApi is the data seam between the design system and its host: the DS owns
|
|
79
|
+
* the browsing UI, the host owns fetching (Apollo on web/mobile, REST on
|
|
80
|
+
* desktop). Omitting it from ContentBrowser hides the Forms surface entirely,
|
|
81
|
+
* which is what a config declaring no forms should look like.
|
|
82
|
+
*/
|
|
83
|
+
export interface FormsApi {
|
|
84
|
+
/** The forms on this branch, with their summary numbers. */
|
|
85
|
+
forms: FormInfo[];
|
|
86
|
+
/** One page of a form's submissions, newest first. */
|
|
87
|
+
list: (params: {
|
|
88
|
+
form: string;
|
|
89
|
+
status?: string;
|
|
90
|
+
search?: string;
|
|
91
|
+
limit?: number;
|
|
92
|
+
offset?: number;
|
|
93
|
+
}) => Promise<SubmissionInfo[]>;
|
|
94
|
+
/** How many match, ignoring paging — the total behind "12 of 340". */
|
|
95
|
+
count: (params: { form: string; status?: string; search?: string }) => Promise<number>;
|
|
96
|
+
/** Delete submissions. Omit to hide the affordance. */
|
|
97
|
+
remove?: (ids: string[]) => Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* summaryLine builds the one-line label a submission row shows.
|
|
102
|
+
*
|
|
103
|
+
* A submission has no title — what makes one recognisable is what the person
|
|
104
|
+
* wrote — so the first field carrying readable text is used, preferring an
|
|
105
|
+
* email-ish value because that is what a reader scans an inbox for.
|
|
106
|
+
*/
|
|
107
|
+
export function summaryLine(sub: SubmissionInfo, form?: FormInfo): string {
|
|
108
|
+
const order = form ? form.fields.map((f) => f.name) : Object.keys(sub.fields);
|
|
109
|
+
let firstText = "";
|
|
110
|
+
for (const name of order) {
|
|
111
|
+
const v = sub.fields[name];
|
|
112
|
+
if (typeof v !== "string" || v.trim() === "") continue;
|
|
113
|
+
if (v.includes("@")) return v.trim();
|
|
114
|
+
if (!firstText) firstText = v.trim();
|
|
115
|
+
}
|
|
116
|
+
if (firstText) return firstText.length > 80 ? firstText.slice(0, 79) + "…" : firstText;
|
|
117
|
+
return "(no text)";
|
|
118
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -196,3 +196,14 @@ export type {
|
|
|
196
196
|
CollabPeer,
|
|
197
197
|
CollabParticipant,
|
|
198
198
|
} from "./components/ContentBrowser";
|
|
199
|
+
|
|
200
|
+
export { FormsBrowser } from "./components/FormsBrowser";
|
|
201
|
+
export type { FormsBrowserProps } from "./components/FormsBrowser";
|
|
202
|
+
export {
|
|
203
|
+
FORMS_NAV_KEY,
|
|
204
|
+
formsNavKey,
|
|
205
|
+
isFormsNavKey,
|
|
206
|
+
parseFormsNavKey,
|
|
207
|
+
summaryLine,
|
|
208
|
+
} from "./forms";
|
|
209
|
+
export type { FormsApi, FormInfo, FormFieldInfo, SubmissionInfo } from "./forms";
|