@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.
- package/CHANGELOG.md +62 -0
- package/MODULE.md +172 -0
- package/README.md +207 -0
- package/dist/api/export.d.ts +66 -0
- package/dist/api/export.d.ts.map +1 -0
- package/dist/api/export.js +138 -0
- package/dist/api/export.js.map +1 -0
- package/dist/api/extensions.d.ts +8 -0
- package/dist/api/extensions.d.ts.map +1 -0
- package/dist/api/extensions.js +8 -0
- package/dist/api/extensions.js.map +1 -0
- package/dist/api/formsApi.d.ts +113 -0
- package/dist/api/formsApi.d.ts.map +1 -0
- package/dist/api/formsApi.js +81 -0
- package/dist/api/formsApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +987 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +222 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +14 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ConfigField.d.ts +32 -0
- package/dist/default/ConfigField.d.ts.map +1 -0
- package/dist/default/ConfigField.js +101 -0
- package/dist/default/ConfigField.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/FormBuilderPane.d.ts +36 -0
- package/dist/default/FormBuilderPane.d.ts.map +1 -0
- package/dist/default/FormBuilderPane.js +70 -0
- package/dist/default/FormBuilderPane.js.map +1 -0
- package/dist/default/FormsListPane.d.ts +18 -0
- package/dist/default/FormsListPane.d.ts.map +1 -0
- package/dist/default/FormsListPane.js +28 -0
- package/dist/default/FormsListPane.js.map +1 -0
- package/dist/default/ResponsesPane.d.ts +21 -0
- package/dist/default/ResponsesPane.d.ts.map +1 -0
- package/dist/default/ResponsesPane.js +118 -0
- package/dist/default/ResponsesPane.js.map +1 -0
- package/dist/default/StapelForm.d.ts +76 -0
- package/dist/default/StapelForm.d.ts.map +1 -0
- package/dist/default/StapelForm.js +118 -0
- package/dist/default/StapelForm.js.map +1 -0
- package/dist/default/fields.d.ts +11 -0
- package/dist/default/fields.d.ts.map +1 -0
- package/dist/default/fields.js +273 -0
- package/dist/default/fields.js.map +1 -0
- package/dist/default/index.d.ts +42 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +37 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/slots.d.ts +59 -0
- package/dist/default/slots.d.ts.map +1 -0
- package/dist/default/slots.js +25 -0
- package/dist/default/slots.js.map +1 -0
- package/dist/default/theme.d.ts +23 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +38 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/flows/errors.d.ts +12 -0
- package/dist/flows/errors.d.ts.map +1 -0
- package/dist/flows/errors.js +13 -0
- package/dist/flows/errors.js.map +1 -0
- package/dist/flows/registry.d.ts +22 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +20 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/FormBuilder.d.ts +100 -0
- package/dist/headless/FormBuilder.d.ts.map +1 -0
- package/dist/headless/FormBuilder.js +278 -0
- package/dist/headless/FormBuilder.js.map +1 -0
- package/dist/headless/FormFill.d.ts +88 -0
- package/dist/headless/FormFill.d.ts.map +1 -0
- package/dist/headless/FormFill.js +279 -0
- package/dist/headless/FormFill.js.map +1 -0
- package/dist/headless/FormList.d.ts +34 -0
- package/dist/headless/FormList.d.ts.map +1 -0
- package/dist/headless/FormList.js +37 -0
- package/dist/headless/FormList.js.map +1 -0
- package/dist/headless/FormsProvider.d.ts +19 -0
- package/dist/headless/FormsProvider.d.ts.map +1 -0
- package/dist/headless/FormsProvider.js +15 -0
- package/dist/headless/FormsProvider.js.map +1 -0
- package/dist/headless/ResponsesTable.d.ts +82 -0
- package/dist/headless/ResponsesTable.d.ts.map +1 -0
- package/dist/headless/ResponsesTable.js +195 -0
- package/dist/headless/ResponsesTable.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +10 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +20 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +7 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +118 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +79 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +485 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +246 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +79 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +141 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +233 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +7 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +127 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/mutations.d.ts +135 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +223 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +59 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +117 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +28 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +23 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +28 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +14 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/widgets/configForms.d.ts +112 -0
- package/dist/widgets/configForms.d.ts.map +1 -0
- package/dist/widgets/configForms.js +211 -0
- package/dist/widgets/configForms.js.map +1 -0
- package/dist/widgets/registry.d.ts +83 -0
- package/dist/widgets/registry.d.ts.map +1 -0
- package/dist/widgets/registry.js +27 -0
- package/dist/widgets/registry.js.map +1 -0
- package/dist/widgets/validate.d.ts +57 -0
- package/dist/widgets/validate.d.ts.map +1 -0
- package/dist/widgets/validate.js +173 -0
- package/dist/widgets/validate.js.map +1 -0
- package/llms.txt +124 -0
- package/manifest.json +1038 -0
- package/package.json +108 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/export.ts +195 -0
- package/src/api/extensions.ts +7 -0
- package/src/api/formsApi.ts +329 -0
- package/src/api/generated/schema.ts +989 -0
- package/src/api/types.ts +276 -0
- package/src/default/ConfigField.tsx +227 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/FormBuilderPane.tsx +414 -0
- package/src/default/FormsListPane.tsx +144 -0
- package/src/default/ResponsesPane.tsx +332 -0
- package/src/default/StapelForm.tsx +377 -0
- package/src/default/fields.tsx +454 -0
- package/src/default/index.ts +71 -0
- package/src/default/slots.ts +78 -0
- package/src/default/theme.tsx +65 -0
- package/src/default/types.ts +12 -0
- package/src/flows/errors.ts +16 -0
- package/src/flows/registry.ts +32 -0
- package/src/headless/FormBuilder.tsx +453 -0
- package/src/headless/FormFill.tsx +396 -0
- package/src/headless/FormList.tsx +74 -0
- package/src/headless/FormsProvider.tsx +20 -0
- package/src/headless/ResponsesTable.tsx +341 -0
- package/src/i18n/errorsMap.ts +31 -0
- package/src/i18n/es.ts +140 -0
- package/src/i18n/generated/errors.es.gen.ts +85 -0
- package/src/i18n/generated/errors.gen.ts +277 -0
- package/src/i18n/generated/errors.json +583 -0
- package/src/i18n/generated/errors.ru.gen.ts +85 -0
- package/src/i18n/keys.ts +264 -0
- package/src/i18n/ru.ts +150 -0
- package/src/index.ts +184 -0
- package/src/model/context.tsx +28 -0
- package/src/model/mutations.ts +387 -0
- package/src/model/queries.ts +154 -0
- package/src/model/queryKeys.ts +61 -0
- package/src/model/runtime.ts +48 -0
- package/src/widgets/registry.ts +101 -0
- package/src/widgets/validate.ts +224 -0
- 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,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
|
+
}
|