@stapel/forms-react 0.1.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.
Files changed (203) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/MODULE.md +172 -0
  3. package/README.md +207 -0
  4. package/dist/api/export.d.ts +66 -0
  5. package/dist/api/export.d.ts.map +1 -0
  6. package/dist/api/export.js +138 -0
  7. package/dist/api/export.js.map +1 -0
  8. package/dist/api/extensions.d.ts +8 -0
  9. package/dist/api/extensions.d.ts.map +1 -0
  10. package/dist/api/extensions.js +8 -0
  11. package/dist/api/extensions.js.map +1 -0
  12. package/dist/api/formsApi.d.ts +113 -0
  13. package/dist/api/formsApi.d.ts.map +1 -0
  14. package/dist/api/formsApi.js +81 -0
  15. package/dist/api/formsApi.js.map +1 -0
  16. package/dist/api/generated/schema.d.ts +987 -0
  17. package/dist/api/generated/schema.d.ts.map +1 -0
  18. package/dist/api/generated/schema.js +2 -0
  19. package/dist/api/generated/schema.js.map +1 -0
  20. package/dist/api/types.d.ts +222 -0
  21. package/dist/api/types.d.ts.map +1 -0
  22. package/dist/api/types.js +14 -0
  23. package/dist/api/types.js.map +1 -0
  24. package/dist/default/ConfigField.d.ts +32 -0
  25. package/dist/default/ConfigField.d.ts.map +1 -0
  26. package/dist/default/ConfigField.js +101 -0
  27. package/dist/default/ConfigField.js.map +1 -0
  28. package/dist/default/ErrorAlert.d.ts +9 -0
  29. package/dist/default/ErrorAlert.d.ts.map +1 -0
  30. package/dist/default/ErrorAlert.js +27 -0
  31. package/dist/default/ErrorAlert.js.map +1 -0
  32. package/dist/default/FormBuilderPane.d.ts +36 -0
  33. package/dist/default/FormBuilderPane.d.ts.map +1 -0
  34. package/dist/default/FormBuilderPane.js +70 -0
  35. package/dist/default/FormBuilderPane.js.map +1 -0
  36. package/dist/default/FormsListPane.d.ts +18 -0
  37. package/dist/default/FormsListPane.d.ts.map +1 -0
  38. package/dist/default/FormsListPane.js +28 -0
  39. package/dist/default/FormsListPane.js.map +1 -0
  40. package/dist/default/ResponsesPane.d.ts +21 -0
  41. package/dist/default/ResponsesPane.d.ts.map +1 -0
  42. package/dist/default/ResponsesPane.js +118 -0
  43. package/dist/default/ResponsesPane.js.map +1 -0
  44. package/dist/default/StapelForm.d.ts +76 -0
  45. package/dist/default/StapelForm.d.ts.map +1 -0
  46. package/dist/default/StapelForm.js +118 -0
  47. package/dist/default/StapelForm.js.map +1 -0
  48. package/dist/default/fields.d.ts +11 -0
  49. package/dist/default/fields.d.ts.map +1 -0
  50. package/dist/default/fields.js +273 -0
  51. package/dist/default/fields.js.map +1 -0
  52. package/dist/default/index.d.ts +42 -0
  53. package/dist/default/index.d.ts.map +1 -0
  54. package/dist/default/index.js +37 -0
  55. package/dist/default/index.js.map +1 -0
  56. package/dist/default/slots.d.ts +59 -0
  57. package/dist/default/slots.d.ts.map +1 -0
  58. package/dist/default/slots.js +25 -0
  59. package/dist/default/slots.js.map +1 -0
  60. package/dist/default/theme.d.ts +23 -0
  61. package/dist/default/theme.d.ts.map +1 -0
  62. package/dist/default/theme.js +38 -0
  63. package/dist/default/theme.js.map +1 -0
  64. package/dist/default/types.d.ts +12 -0
  65. package/dist/default/types.d.ts.map +1 -0
  66. package/dist/default/types.js +2 -0
  67. package/dist/default/types.js.map +1 -0
  68. package/dist/flows/errors.d.ts +12 -0
  69. package/dist/flows/errors.d.ts.map +1 -0
  70. package/dist/flows/errors.js +13 -0
  71. package/dist/flows/errors.js.map +1 -0
  72. package/dist/flows/registry.d.ts +22 -0
  73. package/dist/flows/registry.d.ts.map +1 -0
  74. package/dist/flows/registry.js +20 -0
  75. package/dist/flows/registry.js.map +1 -0
  76. package/dist/headless/FormBuilder.d.ts +100 -0
  77. package/dist/headless/FormBuilder.d.ts.map +1 -0
  78. package/dist/headless/FormBuilder.js +278 -0
  79. package/dist/headless/FormBuilder.js.map +1 -0
  80. package/dist/headless/FormFill.d.ts +88 -0
  81. package/dist/headless/FormFill.d.ts.map +1 -0
  82. package/dist/headless/FormFill.js +279 -0
  83. package/dist/headless/FormFill.js.map +1 -0
  84. package/dist/headless/FormList.d.ts +34 -0
  85. package/dist/headless/FormList.d.ts.map +1 -0
  86. package/dist/headless/FormList.js +37 -0
  87. package/dist/headless/FormList.js.map +1 -0
  88. package/dist/headless/FormsProvider.d.ts +19 -0
  89. package/dist/headless/FormsProvider.d.ts.map +1 -0
  90. package/dist/headless/FormsProvider.js +15 -0
  91. package/dist/headless/FormsProvider.js.map +1 -0
  92. package/dist/headless/ResponsesTable.d.ts +82 -0
  93. package/dist/headless/ResponsesTable.d.ts.map +1 -0
  94. package/dist/headless/ResponsesTable.js +195 -0
  95. package/dist/headless/ResponsesTable.js.map +1 -0
  96. package/dist/i18n/errorsMap.d.ts +10 -0
  97. package/dist/i18n/errorsMap.d.ts.map +1 -0
  98. package/dist/i18n/errorsMap.js +20 -0
  99. package/dist/i18n/errorsMap.js.map +1 -0
  100. package/dist/i18n/es.d.ts +7 -0
  101. package/dist/i18n/es.d.ts.map +1 -0
  102. package/dist/i18n/es.js +118 -0
  103. package/dist/i18n/es.js.map +1 -0
  104. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  105. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  106. package/dist/i18n/generated/errors.es.gen.js +79 -0
  107. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  108. package/dist/i18n/generated/errors.gen.d.ts +485 -0
  109. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  110. package/dist/i18n/generated/errors.gen.js +246 -0
  111. package/dist/i18n/generated/errors.gen.js.map +1 -0
  112. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  113. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  114. package/dist/i18n/generated/errors.ru.gen.js +79 -0
  115. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  116. package/dist/i18n/keys.d.ts +141 -0
  117. package/dist/i18n/keys.d.ts.map +1 -0
  118. package/dist/i18n/keys.js +233 -0
  119. package/dist/i18n/keys.js.map +1 -0
  120. package/dist/i18n/ru.d.ts +7 -0
  121. package/dist/i18n/ru.d.ts.map +1 -0
  122. package/dist/i18n/ru.js +127 -0
  123. package/dist/i18n/ru.js.map +1 -0
  124. package/dist/index.d.ts +61 -0
  125. package/dist/index.d.ts.map +1 -0
  126. package/dist/index.js +55 -0
  127. package/dist/index.js.map +1 -0
  128. package/dist/model/context.d.ts +11 -0
  129. package/dist/model/context.d.ts.map +1 -0
  130. package/dist/model/context.js +16 -0
  131. package/dist/model/context.js.map +1 -0
  132. package/dist/model/mutations.d.ts +135 -0
  133. package/dist/model/mutations.d.ts.map +1 -0
  134. package/dist/model/mutations.js +223 -0
  135. package/dist/model/mutations.js.map +1 -0
  136. package/dist/model/queries.d.ts +59 -0
  137. package/dist/model/queries.d.ts.map +1 -0
  138. package/dist/model/queries.js +117 -0
  139. package/dist/model/queries.js.map +1 -0
  140. package/dist/model/queryKeys.d.ts +28 -0
  141. package/dist/model/queryKeys.d.ts.map +1 -0
  142. package/dist/model/queryKeys.js +23 -0
  143. package/dist/model/queryKeys.js.map +1 -0
  144. package/dist/model/runtime.d.ts +28 -0
  145. package/dist/model/runtime.d.ts.map +1 -0
  146. package/dist/model/runtime.js +14 -0
  147. package/dist/model/runtime.js.map +1 -0
  148. package/dist/widgets/configForms.d.ts +112 -0
  149. package/dist/widgets/configForms.d.ts.map +1 -0
  150. package/dist/widgets/configForms.js +211 -0
  151. package/dist/widgets/configForms.js.map +1 -0
  152. package/dist/widgets/registry.d.ts +83 -0
  153. package/dist/widgets/registry.d.ts.map +1 -0
  154. package/dist/widgets/registry.js +27 -0
  155. package/dist/widgets/registry.js.map +1 -0
  156. package/dist/widgets/validate.d.ts +57 -0
  157. package/dist/widgets/validate.d.ts.map +1 -0
  158. package/dist/widgets/validate.js +173 -0
  159. package/dist/widgets/validate.js.map +1 -0
  160. package/llms.txt +124 -0
  161. package/manifest.json +1038 -0
  162. package/package.json +108 -0
  163. package/src/analytics/generated/events.json +7 -0
  164. package/src/api/export.ts +195 -0
  165. package/src/api/extensions.ts +7 -0
  166. package/src/api/formsApi.ts +329 -0
  167. package/src/api/generated/schema.ts +989 -0
  168. package/src/api/types.ts +276 -0
  169. package/src/default/ConfigField.tsx +227 -0
  170. package/src/default/ErrorAlert.tsx +46 -0
  171. package/src/default/FormBuilderPane.tsx +414 -0
  172. package/src/default/FormsListPane.tsx +144 -0
  173. package/src/default/ResponsesPane.tsx +332 -0
  174. package/src/default/StapelForm.tsx +377 -0
  175. package/src/default/fields.tsx +454 -0
  176. package/src/default/index.ts +71 -0
  177. package/src/default/slots.ts +78 -0
  178. package/src/default/theme.tsx +65 -0
  179. package/src/default/types.ts +12 -0
  180. package/src/flows/errors.ts +16 -0
  181. package/src/flows/registry.ts +32 -0
  182. package/src/headless/FormBuilder.tsx +453 -0
  183. package/src/headless/FormFill.tsx +396 -0
  184. package/src/headless/FormList.tsx +74 -0
  185. package/src/headless/FormsProvider.tsx +20 -0
  186. package/src/headless/ResponsesTable.tsx +341 -0
  187. package/src/i18n/errorsMap.ts +31 -0
  188. package/src/i18n/es.ts +140 -0
  189. package/src/i18n/generated/errors.es.gen.ts +85 -0
  190. package/src/i18n/generated/errors.gen.ts +277 -0
  191. package/src/i18n/generated/errors.json +583 -0
  192. package/src/i18n/generated/errors.ru.gen.ts +85 -0
  193. package/src/i18n/keys.ts +264 -0
  194. package/src/i18n/ru.ts +150 -0
  195. package/src/index.ts +184 -0
  196. package/src/model/context.tsx +28 -0
  197. package/src/model/mutations.ts +387 -0
  198. package/src/model/queries.ts +154 -0
  199. package/src/model/queryKeys.ts +61 -0
  200. package/src/model/runtime.ts +48 -0
  201. package/src/widgets/registry.ts +101 -0
  202. package/src/widgets/validate.ts +224 -0
  203. package/tsconfig.json +26 -0
package/package.json ADDED
@@ -0,0 +1,108 @@
1
+ {
2
+ "name": "@stapel/forms-react",
3
+ "version": "0.1.0",
4
+ "description": "Headless React pair for stapel-forms: typed API client, TanStack Query hooks, LoadState-disciplined headless bags (fill / builder / responses / list), a per-kind field-widget registry over the stapel-attributes vocabulary, client-side validation that mirrors the server's own error keys, the captcha_token seam and the X-Forms-Next-Before CSV export cursor. Zero visual opinion in the main entry; an opt-in /default subpath ships the self-themed antd skin (StapelForm, FormBuilderPane, ResponsesPane, FormsListPane) — every field widget and skin slot swappable without a fork.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/usestapel/stapel-react.git",
9
+ "directory": "packages/forms-react"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./default": {
21
+ "types": "./dist/default/index.d.ts",
22
+ "default": "./dist/default/index.js"
23
+ },
24
+ "./i18n/ru": {
25
+ "types": "./dist/i18n/ru.d.ts",
26
+ "default": "./dist/i18n/ru.js"
27
+ },
28
+ "./i18n/es": {
29
+ "types": "./dist/i18n/es.d.ts",
30
+ "default": "./dist/i18n/es.js"
31
+ },
32
+ "./manifest": "./manifest.json",
33
+ "./manifest.json": "./manifest.json",
34
+ "./llms.txt": "./llms.txt",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "tsconfig.json",
41
+ "README.md",
42
+ "MODULE.md",
43
+ "CHANGELOG.md",
44
+ "manifest.json",
45
+ "llms.txt"
46
+ ],
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.json",
49
+ "test": "tsc -p tsconfig.test.json && vitest run --exclude test/prodBundlePurity.test.ts",
50
+ "lint": "eslint .",
51
+ "size": "size-limit",
52
+ "test:pack": "vitest run test/prodBundlePurity.test.ts"
53
+ },
54
+ "size-limit": [
55
+ {
56
+ "name": "index — headless forms pair (client + hooks + bags + widget registry + i18n)",
57
+ "path": "dist/index.js",
58
+ "limit": "12 KB"
59
+ },
60
+ {
61
+ "name": "default — the antd skin (form renderer, builder, responses) must stay out of the main bundle",
62
+ "path": "dist/default/index.js",
63
+ "limit": "18 KB"
64
+ },
65
+ {
66
+ "name": "i18n/ru — the Russian bundle is opt-in, not carried by the main entry",
67
+ "path": "dist/i18n/ru.js",
68
+ "limit": "5 KB"
69
+ },
70
+ {
71
+ "name": "i18n/es — the Spanish bundle is opt-in, not carried by the main entry",
72
+ "path": "dist/i18n/es.js",
73
+ "limit": "4 KB"
74
+ }
75
+ ],
76
+ "peerDependencies": {
77
+ "@stapel/core": ">=0.14.0 <1.0.0",
78
+ "@stapel/tokens-antd": ">=0.5.0",
79
+ "@tanstack/react-query": "^5.0.0",
80
+ "antd": ">=5.20.0 <7",
81
+ "react": ">=19"
82
+ },
83
+ "devDependencies": {
84
+ "@size-limit/preset-small-lib": "^11.2.0",
85
+ "@stapel/core": "workspace:^",
86
+ "@stapel/showcase": "workspace:^",
87
+ "@stapel/tokens": "workspace:^",
88
+ "@stapel/tokens-antd": "workspace:*",
89
+ "@tanstack/react-query": "^5.81.0",
90
+ "@testing-library/react": "^16.3.0",
91
+ "@types/react": "^19.1.0",
92
+ "@types/react-dom": "^19.1.0",
93
+ "antd": "^6.5.0",
94
+ "jsdom": "^26.1.0",
95
+ "msw": "^2.10.2",
96
+ "react": "^19.1.0",
97
+ "react-dom": "^19.1.0",
98
+ "size-limit": "^11.2.0",
99
+ "typescript": "^5.8.3",
100
+ "vitest": "^3.2.4"
101
+ },
102
+ "engines": {
103
+ "node": ">=22"
104
+ },
105
+ "publishConfig": {
106
+ "access": "public"
107
+ }
108
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
+ "package": "@stapel/forms-react",
4
+ "version": "0.1.0",
5
+ "defined": [],
6
+ "flows": []
7
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The CSV half of the stapel-forms surface — hand-authored api/ carve-out
3
+ * (the docs-react `api/content.ts` / recordings-react `uploadRecordingBlob`
4
+ * precedent, the one legal home of `fetch` per `stapel/no-raw-fetch`).
5
+ *
6
+ * `GET /forms/<id>/submissions/export` is not a JSON operation and cannot ride
7
+ * core's `StapelClient` for two independent reasons:
8
+ *
9
+ * 1. the body is a CSV file, and `StapelClient.request` parses/returns a
10
+ * decoded JSON value;
11
+ * 2. the CONTINUATION CURSOR rides a response header,
12
+ * `X-Forms-Next-Before` — which `StapelClient` does not surface at all.
13
+ *
14
+ * Backend delta note 6 explains why the cursor is a header rather than a
15
+ * trailing body field: the body is a spreadsheet, and a cursor appended to it
16
+ * lands as a row in somebody's export. The same note records why the value is
17
+ * **Z-suffixed** rather than isoformat — a bare `+00:00` in a query string
18
+ * decodes to a space, which made the second page a silent 400. This module
19
+ * therefore round-trips the header value VERBATIM into the next `?before=`
20
+ * and never re-formats it.
21
+ *
22
+ * Auth: the runtime's `credentials` / `defaultHeaders` are forwarded here, so
23
+ * a cookie-mode host works unchanged. The bearer-refresh and verification-403
24
+ * seams of `createStapelClient` do NOT run on this raw surface (the same v1
25
+ * limitation docs-react's raw transport documents); a 401 here throws like any
26
+ * other error status rather than triggering a refresh-and-retry.
27
+ */
28
+ import { parseErrorEnvelope } from "@stapel/core";
29
+
30
+ /**
31
+ * Raw-transport binding forwarded from the runtime. Declared here rather than
32
+ * in `formsApi.ts` so the two modules do not import each other's types in a
33
+ * cycle — `formsApi.ts` re-exports it as part of its public surface.
34
+ */
35
+ export interface FormsApiOptions {
36
+ readonly fetch?: typeof globalThis.fetch;
37
+ readonly credentials?: RequestCredentials;
38
+ readonly defaultHeaders?: Record<string, string>;
39
+ }
40
+
41
+ /**
42
+ * Response header carrying the keyset cursor for the NEXT export page.
43
+ * Absent (or empty) means this was the last page.
44
+ */
45
+ export const FORMS_NEXT_BEFORE_HEADER = "X-Forms-Next-Before";
46
+
47
+ /** The raw-transport binding `createFormsApi` closes over. */
48
+ export interface FormsRawTransport {
49
+ /** e.g. `/forms/api/v1` — the same base the pair's `StapelClient` uses. */
50
+ readonly baseUrl: string;
51
+ readonly fetch?: typeof globalThis.fetch;
52
+ readonly credentials?: RequestCredentials;
53
+ /** Merged into every raw request (e.g. a tenant id / auth header). */
54
+ readonly headers?: Record<string, string>;
55
+ }
56
+
57
+ /** One page of the CSV export. */
58
+ export interface CsvExportPage {
59
+ /** The page's CSV text, header row included. */
60
+ readonly csv: string;
61
+ /**
62
+ * The cursor to pass as the next call's `before`, or `null` when the export
63
+ * is complete. Opaque — pass it back verbatim (see the module header).
64
+ */
65
+ readonly nextBefore: string | null;
66
+ }
67
+
68
+ /** Build the transport from the runtime options, dropping absent keys so
69
+ * `exactOptionalPropertyTypes` stays satisfied. */
70
+ export function createExportTransport(
71
+ baseUrl: string,
72
+ options: FormsApiOptions
73
+ ): FormsRawTransport {
74
+ return {
75
+ baseUrl,
76
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
77
+ ...(options.credentials !== undefined
78
+ ? { credentials: options.credentials }
79
+ : {}),
80
+ ...(options.defaultHeaders !== undefined
81
+ ? { headers: options.defaultHeaders }
82
+ : {}),
83
+ };
84
+ }
85
+
86
+ function exportUrl(
87
+ transport: FormsRawTransport,
88
+ formId: string,
89
+ query: Readonly<Record<string, string | number | undefined>>
90
+ ): string {
91
+ const base = transport.baseUrl.endsWith("/")
92
+ ? transport.baseUrl.slice(0, -1)
93
+ : transport.baseUrl;
94
+ const search = new URLSearchParams();
95
+ for (const [key, value] of Object.entries(query)) {
96
+ if (value !== undefined) search.set(key, String(value));
97
+ }
98
+ const qs = search.toString();
99
+ return (
100
+ `${base}/forms/${encodeURIComponent(formId)}/submissions/export` +
101
+ (qs.length > 0 ? `?${qs}` : "")
102
+ );
103
+ }
104
+
105
+ async function throwEnvelope(response: Response): Promise<never> {
106
+ const text = await response.text();
107
+ let body: unknown = text.length > 0 ? text : undefined;
108
+ if (text.length > 0) {
109
+ try {
110
+ body = JSON.parse(text) as unknown;
111
+ } catch {
112
+ /* a non-JSON error body stays the raw text */
113
+ }
114
+ }
115
+ throw parseErrorEnvelope(response.status, body);
116
+ }
117
+
118
+ /**
119
+ * Fetch one page of a form's CSV export.
120
+ *
121
+ * Drive it to completion by feeding {@link CsvExportPage.nextBefore} back in
122
+ * as `before` until it is `null`, concatenating the pages MINUS the repeated
123
+ * header row (see `useCsvExport`, which does exactly that).
124
+ */
125
+ export async function exportSubmissionsCsv(
126
+ transport: FormsRawTransport,
127
+ params: {
128
+ readonly workspaceId: string;
129
+ readonly formId: string;
130
+ readonly before?: string;
131
+ readonly version?: number;
132
+ readonly signal?: AbortSignal;
133
+ }
134
+ ): Promise<CsvExportPage> {
135
+ const fetchImpl = transport.fetch ?? globalThis.fetch.bind(globalThis);
136
+ const headers = new Headers({ Accept: "text/csv" });
137
+ if (transport.headers) {
138
+ for (const [key, value] of Object.entries(transport.headers)) {
139
+ if (!headers.has(key)) headers.set(key, value);
140
+ }
141
+ }
142
+
143
+ const init: RequestInit = { method: "GET", headers };
144
+ if (transport.credentials !== undefined) init.credentials = transport.credentials;
145
+ if (params.signal) init.signal = params.signal;
146
+
147
+ const response = await fetchImpl(
148
+ exportUrl(transport, params.formId, {
149
+ workspace_id: params.workspaceId,
150
+ ...(params.before !== undefined ? { before: params.before } : {}),
151
+ ...(params.version !== undefined ? { version: params.version } : {}),
152
+ }),
153
+ init
154
+ );
155
+
156
+ if (!response.ok) return throwEnvelope(response);
157
+
158
+ const raw = response.headers.get(FORMS_NEXT_BEFORE_HEADER);
159
+ return {
160
+ csv: await response.text(),
161
+ // An empty header is "no more pages", not a cursor of "".
162
+ nextBefore: raw !== null && raw.length > 0 ? raw : null,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Concatenate export pages into one CSV, keeping the header row exactly once.
168
+ *
169
+ * Every page the server streams carries its own header row (it is a standalone
170
+ * CSV file); pasting the pages verbatim would sprinkle header rows through the
171
+ * middle of the spreadsheet. Splitting on the first newline is safe here
172
+ * because a stapel-forms header row is a list of field slugs — slugs cannot
173
+ * contain a newline, so the first line is always exactly the header.
174
+ *
175
+ * Each retained chunk is newline-terminated before the join: a page whose last
176
+ * row has no trailing newline would otherwise be glued to the next page's
177
+ * first row, silently producing one corrupt record per page boundary.
178
+ */
179
+ export function concatCsvPages(pages: readonly string[]): string {
180
+ const nonEmpty = pages.filter((page) => page.length > 0);
181
+ const first = nonEmpty[0];
182
+ if (first === undefined) return "";
183
+ const chunks = [
184
+ first,
185
+ ...nonEmpty.slice(1).map((page) => {
186
+ const newline = page.indexOf("\n");
187
+ return newline === -1 ? "" : page.slice(newline + 1);
188
+ }),
189
+ ].filter((chunk) => chunk.length > 0);
190
+ return chunks
191
+ .map((chunk, index) =>
192
+ index === chunks.length - 1 || chunk.endsWith("\n") ? chunk : `${chunk}\n`
193
+ )
194
+ .join("");
195
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Hand-authored API surface the codegen does not (yet) cover — browser-redirect
3
+ * URL builders, open-redirect guards, narrow domain type-guards, header
4
+ * conventions. Everything that CAN be derived from schema.json belongs in the
5
+ * generated operations (`api/formsApi.ts`), not here.
6
+ */
7
+ export {};
@@ -0,0 +1,329 @@
1
+ import type { StapelClient, StapelRequestOptions } from "@stapel/core";
2
+ import { createExportTransport, exportSubmissionsCsv } from "./export.js";
3
+ import type {
4
+ CsvExportPage,
5
+ FormsApiOptions,
6
+ FormsRawTransport,
7
+ } from "./export.js";
8
+ import type {
9
+ FieldKind,
10
+ FieldKindCatalogue,
11
+ FormCreateRequest,
12
+ FormPatchRequest,
13
+ FormRow,
14
+ FormSchema,
15
+ FormState,
16
+ FormVersion,
17
+ PublicForm,
18
+ PublishResult,
19
+ ResendRequest,
20
+ ResendResult,
21
+ Submission,
22
+ SubmissionListParams,
23
+ SubmitRequest,
24
+ SubmitResult,
25
+ } from "./types.js";
26
+
27
+ /**
28
+ * CSRF rule for cookie-authenticated browser clients (mirrors auth-react and
29
+ * profiles-react): the simplest SPA rule is to always send
30
+ * `X-Requested-With: XMLHttpRequest` on mutating requests. Header-token clients
31
+ * ignore it; it is harmless there, so every mutation carries it.
32
+ *
33
+ * The two ANONYMOUS routes carry it too. They are unauthenticated, so there is
34
+ * no session for a cross-site POST to ride — but a host that mounts the form on
35
+ * a cookie-authenticated origin gets Django's CSRF middleware in the path
36
+ * anyway, and a header a server ignores costs nothing next to a submit that
37
+ * 403s only in production.
38
+ */
39
+ const CSRF_HEADERS: Record<string, string> = {
40
+ "X-Requested-With": "XMLHttpRequest",
41
+ };
42
+
43
+ function mutating(
44
+ options?: Omit<StapelRequestOptions, "method" | "body">
45
+ ): Omit<StapelRequestOptions, "method" | "body"> {
46
+ return {
47
+ ...options,
48
+ headers: { ...CSRF_HEADERS, ...options?.headers },
49
+ };
50
+ }
51
+
52
+ /** URL-safe path segment. `public_id` is a non-enumerable token straight from
53
+ * the host's props, so it is encoded rather than trusted. */
54
+ function seg(value: string): string {
55
+ return encodeURIComponent(value);
56
+ }
57
+
58
+ /** Every admin route is workspace-scoped by an explicit query parameter — the
59
+ * module never infers the workspace from the session. */
60
+ function ws(workspaceId: string): { workspace_id: string } {
61
+ return { workspace_id: workspaceId };
62
+ }
63
+
64
+ /**
65
+ * The pair's typed operation surface — one method per stapel-forms endpoint a
66
+ * JS client may call, bound to the injected {@link StapelClient} (the
67
+ * per-module override seam of frontend-standard §7.2). Paths are relative to
68
+ * the runtime's `baseUrl` (e.g. `/forms/api/v1/`).
69
+ *
70
+ * Two families, and the split matters:
71
+ *
72
+ * - **public** (`getPublicForm`, `submit`) — ANONYMOUS. No capability, no
73
+ * workspace id, no session. A render-only host page needs nothing but
74
+ * `createFormsRuntime({baseUrl})`; this is the standalone-embed property.
75
+ * - **admin** — every one is `IsNotAnonymousUser` + `authorize()` +
76
+ * `workspace_id`, gated on `forms.view` / `forms.manage` /
77
+ * `forms.responses.view` / `forms.responses.manage`.
78
+ *
79
+ * These operations will be GENERATED from schema.json operationIds by gen-api
80
+ * v2 (task `core-typed-ops`); until then they are hand-authored here (the ONE
81
+ * legal home of path strings — `stapel/no-string-paths` §2.3 carve-out).
82
+ */
83
+ export interface FormsApi {
84
+ readonly client: StapelClient;
85
+
86
+ // ── public, anonymous ──────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * The active schema behind a public link. Refuses with
90
+ * `error.404.forms_not_found` (no such link / rotated / soft-deleted) or
91
+ * `error.410.forms_closed` — two DIFFERENT sentences a skin must not merge,
92
+ * and neither of which is "no form here" when the network is what failed.
93
+ */
94
+ getPublicForm(publicId: string): Promise<PublicForm>;
95
+
96
+ /**
97
+ * Submit answers. `answers` values are bare scalars keyed by slug; a `select`
98
+ * answer normalizes to a list server-side, so the caller need not wrap it.
99
+ *
100
+ * The refusals a renderer must tell apart: `409
101
+ * error.409.forms_version_superseded` (the schema moved under the
102
+ * respondent — refetch and preserve compatible values), `409
103
+ * error.409.forms_submission_cap`, `410 error.410.forms_closed`, `413
104
+ * error.413.forms_body_too_large`, `429` throttle, and the per-field
105
+ * `error.400.feature_*` family whose `params.field` names the offending slug.
106
+ */
107
+ submit(publicId: string, body: SubmitRequest): Promise<SubmitResult>;
108
+
109
+ // ── admin: the builder's dictionary ────────────────────────────────────────
110
+
111
+ /**
112
+ * The field kinds a form may be built from, with their config forms.
113
+ * `forms.manage` — a principal who cannot build a form has no use for the
114
+ * builder's dictionary, and the catalogue names host types whose slugs are
115
+ * internal vocabulary.
116
+ *
117
+ * This is what replaced the pair's hand-mirrored copy of
118
+ * `stapel_attributes.config_form` (stapel-forms 0.2.0): the declaration is
119
+ * the single source of truth again, so a type registered through
120
+ * `EXTRA_TYPES` shows up in the builder with no client release.
121
+ */
122
+ listFieldKinds(workspaceId: string): Promise<FieldKindCatalogue>;
123
+
124
+ // ── admin: forms ───────────────────────────────────────────────────────────
125
+
126
+ /** The workspace's forms. `forms.view`. */
127
+ listForms(workspaceId: string, state?: FormState): Promise<readonly FormRow[]>;
128
+ /** One form. `forms.view`. */
129
+ getForm(workspaceId: string, formId: string): Promise<FormRow>;
130
+ /** Create a form (state starts `draft`). `forms.manage`. */
131
+ createForm(body: FormCreateRequest): Promise<FormRow>;
132
+ /** Retitle / re-target a form. `forms.manage`. */
133
+ patchForm(
134
+ workspaceId: string,
135
+ formId: string,
136
+ body: FormPatchRequest
137
+ ): Promise<FormRow>;
138
+ /** Soft-delete. `forms.manage`. */
139
+ deleteForm(workspaceId: string, formId: string): Promise<void>;
140
+
141
+ /** Replace the builder's scratchpad. `forms.manage`. */
142
+ putDraft(
143
+ workspaceId: string,
144
+ formId: string,
145
+ schema: FormSchema
146
+ ): Promise<FormRow>;
147
+ /**
148
+ * Freeze the draft into the next immutable version. `forms.manage`.
149
+ * Refuses an empty schema, a duplicate slug, a kind outside `FIELD_KINDS`,
150
+ * a field count over the cap, and — backend delta note 1 — a config key the
151
+ * type's dataclass does not know (`error.400.forms_invalid_schema` with
152
+ * `params.key`), because a dropped key is a constraint that silently does
153
+ * not exist.
154
+ */
155
+ publish(workspaceId: string, formId: string): Promise<PublishResult>;
156
+ /** Open / close / return to draft. `forms.manage`. */
157
+ setState(
158
+ workspaceId: string,
159
+ formId: string,
160
+ state: FormState
161
+ ): Promise<FormRow>;
162
+ /** Mint a new `public_id`, retiring the old link. `forms.manage`. */
163
+ rotateLink(workspaceId: string, formId: string): Promise<FormRow>;
164
+ /** Every published version, for the responses view's column sets.
165
+ * `forms.view`. */
166
+ listVersions(
167
+ workspaceId: string,
168
+ formId: string
169
+ ): Promise<readonly FormVersion[]>;
170
+
171
+ // ── admin: responses ───────────────────────────────────────────────────────
172
+
173
+ /** One keyset page of responses. `forms.responses.view`. */
174
+ listSubmissions(params: SubmissionListParams): Promise<readonly Submission[]>;
175
+ /** One response. `forms.responses.view`. */
176
+ getSubmission(workspaceId: string, submissionId: string): Promise<Submission>;
177
+ /** Erase one response. `forms.responses.manage`. */
178
+ deleteSubmission(workspaceId: string, submissionId: string): Promise<void>;
179
+ /**
180
+ * Re-deliver one response through the form's notification targets.
181
+ * `forms.responses.manage`. An explicit destination override REPLACES the
182
+ * configured targets (backend delta note 7). Admin-initiated, so it is NOT
183
+ * subject to `NOTIFY_COOLDOWN_SECONDS`.
184
+ */
185
+ resendSubmission(
186
+ workspaceId: string,
187
+ submissionId: string,
188
+ body?: ResendRequest
189
+ ): Promise<ResendResult>;
190
+
191
+ /**
192
+ * One page of the CSV export, plus the continuation cursor. `forms.responses.view`.
193
+ * Rides the raw transport, not the JSON client — see `api/export.ts`.
194
+ */
195
+ exportSubmissions(params: {
196
+ readonly workspaceId: string;
197
+ readonly formId: string;
198
+ readonly before?: string;
199
+ readonly version?: number;
200
+ readonly signal?: AbortSignal;
201
+ }): Promise<CsvExportPage>;
202
+ }
203
+
204
+ /** Raw-transport binding forwarded from the runtime (declared in
205
+ * `api/export.ts`, which owns the surface that consumes it). */
206
+ export type { FormsApiOptions } from "./export.js";
207
+
208
+ export function createFormsApi(
209
+ client: StapelClient,
210
+ options: FormsApiOptions = {}
211
+ ): FormsApi {
212
+ const transport: FormsRawTransport = createExportTransport(
213
+ client.baseUrl,
214
+ options
215
+ );
216
+
217
+ return {
218
+ client,
219
+
220
+ // ── public ───────────────────────────────────────────────────────────────
221
+ getPublicForm: (publicId) => client.get(`/public/${seg(publicId)}/`),
222
+
223
+ submit: (publicId, body) =>
224
+ client.post(
225
+ `/public/${seg(publicId)}/submissions/`,
226
+ body satisfies SubmitRequest,
227
+ mutating()
228
+ ),
229
+
230
+ // ── admin: the builder's dictionary ──────────────────────────────────────
231
+ // Note the path: no `admin/` prefix and no trailing slash, unlike the two
232
+ // anonymous routes. Both are load-bearing — Django resolves this exactly.
233
+ listFieldKinds: async (workspaceId) => {
234
+ const dto = await client.get<{
235
+ kinds?: readonly FieldKind[];
236
+ config_widgets?: Readonly<Record<string, readonly string[]>>;
237
+ }>("/field-kinds", { query: ws(workspaceId) });
238
+ // Both members are optional on the wire (dataclass defaults), and a
239
+ // builder that reads `.kinds.map` off `undefined` is a crash where an
240
+ // empty catalogue would do.
241
+ return {
242
+ kinds: dto.kinds ?? [],
243
+ configWidgets: dto.config_widgets ?? {},
244
+ };
245
+ },
246
+
247
+ // ── admin: forms ─────────────────────────────────────────────────────────
248
+ listForms: (workspaceId, state) =>
249
+ client.get("/forms", {
250
+ query: { ...ws(workspaceId), ...(state !== undefined ? { state } : {}) },
251
+ }),
252
+
253
+ getForm: (workspaceId, formId) =>
254
+ client.get(`/forms/${seg(formId)}`, { query: ws(workspaceId) }),
255
+
256
+ createForm: (body) =>
257
+ client.post("/forms", body satisfies FormCreateRequest, mutating()),
258
+
259
+ patchForm: (workspaceId, formId, body) =>
260
+ client.patch(
261
+ `/forms/${seg(formId)}`,
262
+ body satisfies FormPatchRequest,
263
+ mutating({ query: ws(workspaceId) })
264
+ ),
265
+
266
+ deleteForm: (workspaceId, formId) =>
267
+ client.delete(`/forms/${seg(formId)}`, mutating({ query: ws(workspaceId) })),
268
+
269
+ putDraft: (workspaceId, formId, schema) =>
270
+ client.put(
271
+ `/forms/${seg(formId)}/draft`,
272
+ { schema },
273
+ mutating({ query: ws(workspaceId) })
274
+ ),
275
+
276
+ publish: (workspaceId, formId) =>
277
+ client.post(
278
+ `/forms/${seg(formId)}/publish`,
279
+ undefined,
280
+ mutating({ query: ws(workspaceId) })
281
+ ),
282
+
283
+ setState: (workspaceId, formId, state) =>
284
+ client.post(
285
+ `/forms/${seg(formId)}/state`,
286
+ { state },
287
+ mutating({ query: ws(workspaceId) })
288
+ ),
289
+
290
+ rotateLink: (workspaceId, formId) =>
291
+ client.post(
292
+ `/forms/${seg(formId)}/rotate-link`,
293
+ undefined,
294
+ mutating({ query: ws(workspaceId) })
295
+ ),
296
+
297
+ listVersions: (workspaceId, formId) =>
298
+ client.get(`/forms/${seg(formId)}/versions`, { query: ws(workspaceId) }),
299
+
300
+ // ── admin: responses ─────────────────────────────────────────────────────
301
+ listSubmissions: (params) =>
302
+ client.get(`/forms/${seg(params.formId)}/submissions`, {
303
+ query: {
304
+ ...ws(params.workspaceId),
305
+ ...(params.before !== undefined ? { before: params.before } : {}),
306
+ ...(params.limit !== undefined ? { limit: params.limit } : {}),
307
+ ...(params.version !== undefined ? { version: params.version } : {}),
308
+ },
309
+ }),
310
+
311
+ getSubmission: (workspaceId, submissionId) =>
312
+ client.get(`/submissions/${seg(submissionId)}`, { query: ws(workspaceId) }),
313
+
314
+ deleteSubmission: (workspaceId, submissionId) =>
315
+ client.delete(
316
+ `/submissions/${seg(submissionId)}`,
317
+ mutating({ query: ws(workspaceId) })
318
+ ),
319
+
320
+ resendSubmission: (workspaceId, submissionId, body) =>
321
+ client.post(
322
+ `/submissions/${seg(submissionId)}/resend`,
323
+ body ?? {},
324
+ mutating({ query: ws(workspaceId) })
325
+ ),
326
+
327
+ exportSubmissions: (params) => exportSubmissionsCsv(transport, params),
328
+ };
329
+ }