@gogitcms/design-system 0.15.0 → 0.16.0-next.1

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",
3
+ "version": "0.16.0-next.1",
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,111 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection } from "../components/ContentBrowser";
5
+ import { FORMS_NAV_KEY, formsNavKey, type FormInfo, type FormsApi } from "../forms";
6
+
7
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
8
+ jest.mock("../ThemeProvider", () => {
9
+ const actual = jest.requireActual("../ThemeProvider");
10
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
11
+ });
12
+
13
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
14
+
15
+ const contact: FormInfo = {
16
+ name: "contact",
17
+ label: "Contact us",
18
+ fields: [{ name: "email", type: "string" }],
19
+ fieldCount: 1,
20
+ submissionCount: 2,
21
+ versioned: true,
22
+ canDelete: true,
23
+ };
24
+
25
+ const newsletter: FormInfo = {
26
+ name: "newsletter",
27
+ label: "Newsletter",
28
+ fields: [{ name: "email", type: "string" }],
29
+ fieldCount: 1,
30
+ submissionCount: 308,
31
+ versioned: false,
32
+ canDelete: false,
33
+ };
34
+
35
+ function makeApi(over: Partial<FormsApi> = {}): FormsApi {
36
+ return {
37
+ forms: [contact, newsletter],
38
+ list: jest.fn(async () => []),
39
+ count: jest.fn(async () => 0),
40
+ ...over,
41
+ };
42
+ }
43
+
44
+ const sections: CmsNavSection[] = [
45
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
46
+ ];
47
+
48
+ const entries: CmsEntry[] = [
49
+ { id: "a", path: "content/posts/a.md", title: "Alpha", body: "" },
50
+ ];
51
+
52
+ function renderForms(activeNavKey: string, onSelectNav = jest.fn()) {
53
+ render(
54
+ wrap(
55
+ <ContentBrowser
56
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
57
+ sections={sections}
58
+ activeNavKey={activeNavKey}
59
+ onSelectNav={onSelectNav}
60
+ entries={entries}
61
+ userInitials="ED"
62
+ forms={makeApi()}
63
+ />,
64
+ ),
65
+ );
66
+ return onSelectNav;
67
+ }
68
+
69
+ // The forms list is a content column, like the documents of a collection or the
70
+ // sets under Media — not the whole area after the sidebar. jsdom computes no
71
+ // layout, so this asserts the mechanism: a fixed width rather than a flex.
72
+ test("the forms list sits in a content column, not the full content area", () => {
73
+ renderForms(FORMS_NAV_KEY);
74
+
75
+ const pane = screen.getByTestId("pane-forms");
76
+ expect(pane).toHaveStyle({ width: "340px" });
77
+ // A flexing pane is the bug: it would fill everything after the sidebar.
78
+ expect(pane).not.toHaveStyle({ flexGrow: 1 });
79
+
80
+ // Headed like every other content pane, and with the seam that resizes it.
81
+ expect(screen.getByText("Forms")).toBeInTheDocument();
82
+ expect(screen.getByTestId("resize-forms")).toBeInTheDocument();
83
+
84
+ // And the area beside it says what to do, the way Media and Changes do.
85
+ expect(screen.getByTestId("forms-empty")).toBeInTheDocument();
86
+ });
87
+
88
+ test("the column lists the forms and opens one through the nav key", () => {
89
+ const onSelectNav = renderForms(FORMS_NAV_KEY);
90
+
91
+ expect(screen.getByText("Contact us")).toBeInTheDocument();
92
+ expect(screen.getByText("Newsletter")).toBeInTheDocument();
93
+
94
+ fireEvent.click(screen.getByTestId("form-row-contact"));
95
+ expect(onSelectNav).toHaveBeenCalledWith(formsNavKey("contact"));
96
+ });
97
+
98
+ // The other half of the rule: submissions are two levels deeper than a content
99
+ // column can express, so opening a form does hand the whole area to
100
+ // FormsBrowser. Pinned so the fix above is not later applied to both states.
101
+ test("an open form takes the whole content area", async () => {
102
+ renderForms(formsNavKey("contact"));
103
+ // Let the submissions load settle before asserting, so the empty inbox — not
104
+ // a pending fetch — is what is on screen.
105
+ await screen.findByText("No submissions yet.");
106
+
107
+ const pane = screen.getByTestId("pane-forms");
108
+ expect(pane).not.toHaveStyle({ width: "340px" });
109
+ expect(screen.queryByTestId("forms-empty")).not.toBeInTheDocument();
110
+ expect(screen.queryByTestId("resize-forms")).not.toBeInTheDocument();
111
+ });
@@ -0,0 +1,253 @@
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 and submission count", () => {
103
+ renderBrowser();
104
+ expect(screen.getByText("Contact us")).toBeTruthy();
105
+ expect(screen.getByText("General enquiries.")).toBeTruthy();
106
+ expect(screen.getByText("2")).toBeTruthy();
107
+ expect(screen.getByText("308")).toBeTruthy();
108
+ });
109
+
110
+ it("does not carry the field count, which describes the form and not its inbox", () => {
111
+ renderBrowser();
112
+ expect(screen.queryByText("3 fields")).toBeNull();
113
+ expect(screen.queryByText(/\bfields?\b/)).toBeNull();
114
+ });
115
+
116
+ it("opens a form when its row is pressed", () => {
117
+ const { onSelectForm } = renderBrowser();
118
+ fireEvent.click(screen.getByTestId("form-row-contact"));
119
+ expect(onSelectForm).toHaveBeenCalledWith("contact");
120
+ });
121
+
122
+ it("says so when the branch declares no forms", () => {
123
+ renderBrowser({ api: makeApi({ forms: [] }) });
124
+ expect(screen.getByText(/declares no forms/i)).toBeTruthy();
125
+ });
126
+ });
127
+
128
+ // ── submissions ─────────────────────────────────────────────────────────────
129
+
130
+ describe("a form's submissions", () => {
131
+ it("lists them with a summary line built from what the person wrote", async () => {
132
+ renderBrowser({ form: "contact" });
133
+ await waitFor(() => expect(screen.getByTestId("submission-row-s1")).toBeTruthy());
134
+ // The email is preferred: it is what a reader scans an inbox for.
135
+ expect(screen.getByText("priya@example.test")).toBeTruthy();
136
+ expect(screen.getByText("sam@example.test")).toBeTruthy();
137
+ });
138
+
139
+ it("reports a tap so the host can bind it to the URL", async () => {
140
+ const onSelectSubmission = jest.fn();
141
+ renderBrowser({ form: "contact", onSelectSubmission });
142
+ await waitFor(() => expect(screen.getByTestId("submission-row-s1")).toBeTruthy());
143
+ fireEvent.click(screen.getByTestId("submission-row-s1"));
144
+ expect(onSelectSubmission).toHaveBeenCalledWith("s1");
145
+ });
146
+
147
+ it("goes back to the forms list", async () => {
148
+ const { onSelectForm } = renderBrowser({ form: "contact" });
149
+ await waitFor(() => expect(screen.getByTestId("forms-back")).toBeTruthy());
150
+ fireEvent.click(screen.getByTestId("forms-back"));
151
+ expect(onSelectForm).toHaveBeenCalledWith(null);
152
+ });
153
+
154
+ it("has somewhere to see spam, since a honeypot hit is recorded not discarded", async () => {
155
+ const { api } = renderBrowser({ form: "contact" });
156
+ await waitFor(() => expect(screen.getByTestId("forms-status-toggle")).toBeTruthy());
157
+ fireEvent.click(screen.getByTestId("forms-status-toggle"));
158
+ await waitFor(() =>
159
+ expect(api.list).toHaveBeenCalledWith(expect.objectContaining({ status: "spam" })),
160
+ );
161
+ await waitFor(() => expect(screen.getByText(/Nothing caught as spam/i)).toBeTruthy());
162
+ });
163
+ });
164
+
165
+ // ── the read-only submission ────────────────────────────────────────────────
166
+
167
+ describe("a selected submission", () => {
168
+ it("renders every value read-only, with no editable control", async () => {
169
+ renderBrowser({ form: "contact", selectedId: "s1" });
170
+ await waitFor(() => expect(screen.getByText("A-1")).toBeTruthy());
171
+ // The email shows twice — once as the row's summary line, once as the
172
+ // detail's value — which is what a list beside a detail looks like.
173
+ expect(screen.getAllByText("priya@example.test").length).toBeGreaterThan(0);
174
+ expect(screen.getByText("support")).toBeTruthy();
175
+ // A submission is a record of what someone sent, not a document with
176
+ // editing switched off.
177
+ expect(screen.queryByDisplayValue("priya@example.test")).toBeNull();
178
+ });
179
+
180
+ it("distinguishes a field that was never asked for from one left blank", async () => {
181
+ renderBrowser({ form: "contact", selectedId: "s2" });
182
+ await waitFor(() => expect(screen.getByTestId("submission-not-asked-orderNumber")).toBeTruthy());
183
+ expect(screen.getByText("Not asked")).toBeTruthy();
184
+ });
185
+
186
+ it("shows where a versioned submission lives in the repository", async () => {
187
+ renderBrowser({ form: "contact", selectedId: "s1" });
188
+ await waitFor(() =>
189
+ expect(screen.getByText("content/submissions/contact/s1.json")).toBeTruthy(),
190
+ );
191
+ });
192
+
193
+ it("omits the repository path for a submission git does not hold", async () => {
194
+ renderBrowser({ form: "contact", selectedId: "s2" });
195
+ await waitFor(() => expect(screen.getByText("Not asked")).toBeTruthy());
196
+ expect(screen.queryByText(/In the repository/i)).toBeNull();
197
+ });
198
+ });
199
+
200
+ // ── the summary line ────────────────────────────────────────────────────────
201
+
202
+ describe("summaryLine", () => {
203
+ it("prefers an email-ish value, then the first readable text", () => {
204
+ expect(summaryLine(submissions[0], contact)).toBe("priya@example.test");
205
+ expect(
206
+ summaryLine(
207
+ { ...submissions[0], fields: { reason: "support", orderNumber: "A-1" } },
208
+ contact,
209
+ ),
210
+ ).toBe("support");
211
+ });
212
+
213
+ it("says so rather than rendering an empty row", () => {
214
+ expect(summaryLine({ ...submissions[0], fields: { agreed: true } }, contact)).toBe("(no text)");
215
+ });
216
+ });
217
+
218
+ // A form can legitimately fail to list — local mode answers NO_SUBMISSION_STORE
219
+ // for a form that keeps its submissions in a database it does not have. Before
220
+ // this had somewhere to go, the promise rejected and the pane spun forever.
221
+ describe("a form whose submissions cannot be listed", () => {
222
+ const failing = (): FormsApi => ({
223
+ forms: [contact],
224
+ list: jest.fn().mockRejectedValue(
225
+ Object.assign(new Error("network"), {
226
+ graphQLErrors: [{ message: "form \"newsletter\" keeps its submissions in a database" }],
227
+ }),
228
+ ),
229
+ count: jest.fn().mockRejectedValue(new Error("network")),
230
+ });
231
+
232
+ it("stops loading instead of spinning forever", async () => {
233
+ renderBrowser({ api: failing(), form: "contact" });
234
+ await waitFor(() => expect(screen.getByTestId("forms-error")).toBeTruthy());
235
+ });
236
+
237
+ it("shows the server's own sentence, which says what to change", async () => {
238
+ renderBrowser({ api: failing(), form: "contact" });
239
+ await waitFor(() =>
240
+ expect(screen.getByText(/keeps its submissions in a database/)).toBeTruthy(),
241
+ );
242
+ });
243
+
244
+ it("falls back to something readable when the error carries no message", async () => {
245
+ const api: FormsApi = {
246
+ forms: [contact],
247
+ list: jest.fn().mockRejectedValue({}),
248
+ count: jest.fn().mockRejectedValue({}),
249
+ };
250
+ renderBrowser({ api, form: "contact" });
251
+ await waitFor(() => expect(screen.getByText(/could not be loaded/)).toBeTruthy());
252
+ });
253
+ });
@@ -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,60 @@ 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 enter through the same content column every other surface uses: the
4147
+ // list of forms sits exactly where a collection's documents would, at the
4148
+ // list width, with the empty state beside it. A list of half a dozen rows
4149
+ // stretched across everything after the sidebar reads as a different kind of
4150
+ // screen than Edit, Changes and Media, when it is the same kind of screen.
4151
+ //
4152
+ // Opening a form is where forms stop fitting the three-pane model, and so it
4153
+ // is where they leave it: that surface is two more levels deep (submissions →
4154
+ // one submission), and threading those through panes meant for collection →
4155
+ // document → editor would give a pane whose meaning changes with the level.
4156
+ // FormsBrowser owns its own split from there.
4157
+ if (formsShowing && props.forms) {
4158
+ if (!activeForm) {
4159
+ return (
4160
+ <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4161
+ {navPane}
4162
+ <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4163
+
4164
+ <Pane width={listW} testID="pane-forms" scroll={false} header={<PaneTitle title="Forms" />}>
4165
+ <FormsBrowser
4166
+ api={props.forms}
4167
+ form={null}
4168
+ onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4169
+ variant="desktop"
4170
+ />
4171
+ </Pane>
4172
+ <ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-forms" />
4173
+
4174
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4175
+ <Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
4176
+ </View>
4177
+ {applyModal}
4178
+ </AppShell>
4179
+ );
4180
+ }
4181
+ return (
4182
+ <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4183
+ {navPane}
4184
+ <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4185
+ <Pane flex={1} testID="pane-forms" scroll={false}>
4186
+ <FormsBrowser
4187
+ api={props.forms}
4188
+ form={activeForm}
4189
+ onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4190
+ selectedId={props.selectedSubmissionId ?? null}
4191
+ onSelectSubmission={props.onSelectSubmission}
4192
+ variant="desktop"
4193
+ />
4194
+ </Pane>
4195
+ {applyModal}
4196
+ </AppShell>
4197
+ );
4198
+ }
4199
+
4132
4200
  if (mediaShowing) {
4133
4201
  return (
4134
4202
  <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
@@ -4436,6 +4504,8 @@ function MobileBrowser(props: ContentBrowserProps) {
4436
4504
  const mediaShowing = isMediaNavKey(activeNavKey);
4437
4505
  const mediaSet = parseMediaNavKey(activeNavKey);
4438
4506
  const mediaSets = props.media?.sets ?? [];
4507
+ const formsShowing = isFormsNavKey(activeNavKey);
4508
+ const activeForm = parseFormsNavKey(activeNavKey);
4439
4509
  // A plugin screen renders as its own drilled-in screen; Back returns to the
4440
4510
  // nav list like any other selection.
4441
4511
  if (props.contentSlot != null) {
@@ -4455,6 +4525,40 @@ function MobileBrowser(props: ContentBrowserProps) {
4455
4525
  </MobileScreen>
4456
4526
  );
4457
4527
  }
4528
+ if (formsShowing && props.forms) {
4529
+ return (
4530
+ <MobileScreen
4531
+ testID="mobile-forms"
4532
+ header={
4533
+ <>
4534
+ {/* Back steps up one level, not out to the nav — the same rule the
4535
+ media and collection screens follow. */}
4536
+ <IconButton
4537
+ name="chevronLeft"
4538
+ onPress={() => (activeForm ? onSelectNav(FORMS_NAV_KEY) : backToNav())}
4539
+ size="md"
4540
+ label="Back"
4541
+ />
4542
+ <Text variant="body" weight="semibold" style={{ flex: 1 }}>
4543
+ {activeForm
4544
+ ? props.forms.forms.find((f) => f.name === activeForm)?.label || activeForm
4545
+ : "Forms"}
4546
+ </Text>
4547
+ </>
4548
+ }
4549
+ >
4550
+ <FormsBrowser
4551
+ api={props.forms}
4552
+ form={activeForm}
4553
+ onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4554
+ selectedId={props.selectedSubmissionId ?? null}
4555
+ onSelectSubmission={props.onSelectSubmission}
4556
+ variant="mobile"
4557
+ />
4558
+ </MobileScreen>
4559
+ );
4560
+ }
4561
+
4458
4562
  if (mediaShowing) {
4459
4563
  if (!mediaSet) {
4460
4564
  return (
@@ -4553,8 +4657,11 @@ function MobileBrowser(props: ContentBrowserProps) {
4553
4657
  </View>
4554
4658
  ) : null}
4555
4659
  {readOnlyBanner}
4556
- {sections.map((section) => (
4557
- <View key={section.title}>
4660
+ {sections.map((section, i) => (
4661
+ // Keyed with the index as a fallback: an untitled section renders its
4662
+ // button without a heading, and there is more than one of those now
4663
+ // (Media, Forms), so the title alone is not unique.
4664
+ <View key={section.title || `section-${i}`}>
4558
4665
  <SectionLabel>{section.title}</SectionLabel>
4559
4666
  {section.items.map((item, i) => (
4560
4667
  <NavRow
@@ -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
+ }
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";