@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gogitcms/design-system",
3
- "version": "0.15.0-next.3",
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
- <View key={section.title}>
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";