@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @stapel/forms-react
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b1f9a4e: `@stapel/forms-react` — the frontend pair for stapel-forms.
|
|
8
|
+
|
|
9
|
+
A host page says "put form `<id>` here" and gets a rendered form:
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
const runtime = createFormsRuntime({ baseUrl: "/forms/api/v1/" });
|
|
13
|
+
<FormsProvider runtime={runtime}>
|
|
14
|
+
<StapelForm publicId="k3J…x9" />
|
|
15
|
+
</FormsProvider>;
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
No session, no workspace id, no auth client — the two public endpoints are
|
|
19
|
+
anonymous, so a marketing page can embed a form and nothing else.
|
|
20
|
+
|
|
21
|
+
**A failed schema fetch is never "no form here."** The skin distinguishes "this
|
|
22
|
+
link is not valid" (404), "this form is closed" (410) and "we could not load it,
|
|
23
|
+
our problem, not your link" (network/5xx, with a retry). The data sits behind
|
|
24
|
+
`LoadState`'s discriminant, so the empty-state lie is a compile error rather
|
|
25
|
+
than a code-review note.
|
|
26
|
+
|
|
27
|
+
**Three override levers, none of them a fork.** `registerFormFieldWidget(kind, C)`
|
|
28
|
+
replaces how one field kind draws and outranks the skin's builtin;
|
|
29
|
+
`registerFormsSkinComponent(slot, C)` replaces a piece of the skin across eight
|
|
30
|
+
typed slots; and retheming through the token JSON reaches every surface with
|
|
31
|
+
zero code. A field kind nothing can draw renders a loud notice _and_ blocks the
|
|
32
|
+
submit — quietly skipping a possibly-required field would fabricate an invalid
|
|
33
|
+
submission and get the person refused for a question they never saw.
|
|
34
|
+
|
|
35
|
+
Ten builtin antd widgets, one per kind stapel-forms allows. Headless
|
|
36
|
+
`FormFill` / `FormBuilder` / `ResponsesTable` / `FormList` under the main entry;
|
|
37
|
+
`StapelForm` / `FormBuilderPane` / `ResponsesPane` / `FormsListPane` under
|
|
38
|
+
`./default`; `ru` and `es` as opt-in `./i18n/*` subpaths.
|
|
39
|
+
|
|
40
|
+
The submit path echoes `version_id` (a racing publish becomes a clean 409),
|
|
41
|
+
routes per-field `error.400.feature_*` onto controls by `params.field`, folds a
|
|
42
|
+
409 supersede into a refetch that preserves compatible answers and _says so_,
|
|
43
|
+
and threads `captcha_token` through. CSV export follows the
|
|
44
|
+
`X-Forms-Next-Before` header cursor verbatim.
|
|
45
|
+
|
|
46
|
+
Built against stapel-forms 0.2.0: the builder reads its field-kind catalogue
|
|
47
|
+
from `GET /forms/api/v1/field-kinds` (no mirrored table in the client), and
|
|
48
|
+
the `error.400.feature_*` family comes from the backend's own error contract.
|
|
49
|
+
`stapel_attributes` ships English only, so the generated ru/es bundles are
|
|
50
|
+
`Partial` and the pair layers authored strings for those 12 keys over them.
|
|
51
|
+
|
|
52
|
+
Enrollment note for consumers of other pairs: the `stapel-core` contract pin
|
|
53
|
+
moves v0.23.1 → v0.32.0 to pick up `error.503.mandate_unavailable`. Verified a
|
|
54
|
+
one-key delta — no already-enrolled pair's locale bundle changes.
|
|
55
|
+
|
|
56
|
+
## 0.0.0
|
|
57
|
+
|
|
58
|
+
- Scaffolded by `stapel-new-react-lib` from the auth-react etalon
|
|
59
|
+
(frontend-standard §9, frontend-core-architecture §4 checklist). Layers
|
|
60
|
+
api → model → flows → headless → i18n; drift-gated generated surfaces
|
|
61
|
+
(flows registry, backend error map, manifest + llms.txt) via the shared
|
|
62
|
+
monorepo `gen:*` drivers.
|
package/MODULE.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# @stapel/forms-react — module guide
|
|
2
|
+
|
|
3
|
+
The human companion to the generated `llms.txt` (agent context) and
|
|
4
|
+
`manifest.json` (machine catalog). `README.md` is the how-to; this is the
|
|
5
|
+
why-it-is-shaped-this-way.
|
|
6
|
+
|
|
7
|
+
Built against **stapel-forms 0.2.0** (contract pin
|
|
8
|
+
`d66a1fb048feebebebaf98ec971fb41ab60f820b`), which closed both upstream asks
|
|
9
|
+
this pair filed against 0.1.0 — so two workarounds were deleted rather than
|
|
10
|
+
carried. See "What 0.2.0 deleted" below.
|
|
11
|
+
|
|
12
|
+
## Layers
|
|
13
|
+
|
|
14
|
+
- **api/** — `createFormsApi(client)`, one method per endpoint a JS client may
|
|
15
|
+
call (18 operations over 14 paths), over the generated `components["schemas"]` from this pair's own
|
|
16
|
+
package-local `api/generated/schema.ts` (`pnpm gen:api` ← stapel-forms
|
|
17
|
+
`docs/schema.json`). Two families, and the split is load-bearing:
|
|
18
|
+
`getPublicForm`/`submit` are **anonymous**; everything else is
|
|
19
|
+
`IsNotAnonymousUser` + `authorize()` + an explicit `workspace_id`.
|
|
20
|
+
`api/export.ts` is the raw-`fetch` carve-out (see below).
|
|
21
|
+
- **model/** — `formsQueryKeys` (one key factory, `["forms"]` namespace),
|
|
22
|
+
`createFormsRuntime`, context/hooks, and the read/write hooks.
|
|
23
|
+
- **flows/** — `toFlowError` + the zero-flow `FORMS_FLOWS` registry shim.
|
|
24
|
+
stapel-forms annotates no `@flow_step`, so `docs/flows.json` carries no
|
|
25
|
+
`forms.*` flow and `gen:flows` emits nothing. When the backend annotates
|
|
26
|
+
flows, swap the shim for re-exports of `generated/flows.gen.ts`.
|
|
27
|
+
- **widgets/** — the field-widget registry and the client-side validation
|
|
28
|
+
mirror. Main entry, not `/default`: a host building its own renderer uses the
|
|
29
|
+
same seam the skin does. (The config-form declarations used to live here too,
|
|
30
|
+
as `configForms.ts`; they come off the wire now.)
|
|
31
|
+
- **headless/** — `FormFill`, `FormBuilder`, `ResponsesTable`, `FormList`.
|
|
32
|
+
- **i18n/** — the en floor + generated backend error bundles; `ru`/`es` as
|
|
33
|
+
their own subpaths so a host that does not need them carries nothing.
|
|
34
|
+
- **default/** — the antd skin, a separate entry point.
|
|
35
|
+
|
|
36
|
+
## Why the CSV export does not use core's client
|
|
37
|
+
|
|
38
|
+
`GET /forms/<id>/submissions/export` cannot ride `StapelClient` for two
|
|
39
|
+
independent reasons: the body is a CSV **file**, and the continuation cursor
|
|
40
|
+
rides the **`X-Forms-Next-Before` response header**, which `StapelClient.request`
|
|
41
|
+
does not surface at all. So `api/export.ts` speaks `fetch` directly — the same
|
|
42
|
+
hand-authored carve-out docs-react's `api/content.ts` and recordings-react's
|
|
43
|
+
`uploadRecordingBlob` use, and the one legal home of `fetch` under
|
|
44
|
+
`stapel/no-raw-fetch`.
|
|
45
|
+
|
|
46
|
+
The cursor is passed back **verbatim**. It is Z-suffixed rather than isoformat
|
|
47
|
+
because a bare `+00:00` in a query string decodes to a space, which made the
|
|
48
|
+
second page a silent 400 during the backend build; re-formatting it here would
|
|
49
|
+
reintroduce exactly that.
|
|
50
|
+
|
|
51
|
+
**v1 limitation, stated:** the bearer-refresh and verification-403 seams of
|
|
52
|
+
`createStapelClient` do NOT run on this raw surface. A 401 here throws like any
|
|
53
|
+
other error status instead of triggering a refresh-and-retry. `credentials` and
|
|
54
|
+
`defaultHeaders` from the runtime ARE forwarded, so cookie-mode hosts work
|
|
55
|
+
unchanged. Closing this properly is a core change (a response-returning
|
|
56
|
+
primitive on `StapelClient`), not a per-module workaround.
|
|
57
|
+
|
|
58
|
+
## What 0.2.0 deleted
|
|
59
|
+
|
|
60
|
+
**`widgets/configForms.ts` — the pinned TypeScript mirror — is gone.**
|
|
61
|
+
stapel-forms 0.2.0 serves `stapel_attributes.config_form()` at
|
|
62
|
+
`GET /forms/api/v1/field-kinds`, so the declaration is the single source of
|
|
63
|
+
truth again: a type registered through `EXTRA_TYPES` shows up in the builder
|
|
64
|
+
with no client release, and there is no table left to drift. The builder reads
|
|
65
|
+
it through `useFieldKinds`.
|
|
66
|
+
|
|
67
|
+
Three things about that route are easy to get wrong and are pinned by
|
|
68
|
+
`test/contract.test.ts`: **no `admin/` prefix**, **no trailing slash** (unlike
|
|
69
|
+
the two anonymous routes, which require one), and `?workspace_id=` is
|
|
70
|
+
**required**. It carries `forms.manage`, not `forms.view` — the catalogue names
|
|
71
|
+
host types whose slugs are internal vocabulary, and a principal who cannot build
|
|
72
|
+
a form has no use for the builder's dictionary.
|
|
73
|
+
|
|
74
|
+
Reading the real declarations also corrected a detail the mirror had wrong:
|
|
75
|
+
widget params (`step`, `placeholder`, inline `options`) live **under
|
|
76
|
+
`spec.params`**, not flattened onto the spec. `ConfigField` reads them from
|
|
77
|
+
there now.
|
|
78
|
+
|
|
79
|
+
**The hand-carried `error.400.feature_*` English is gone.** 0.2.0's
|
|
80
|
+
`docs/errors.json` grew 63 → 75 keys, the 12 new ones owned by
|
|
81
|
+
`stapel_attributes`, so the family arrives through `gen:errors` like every other
|
|
82
|
+
backend code. The self-deleting test that asserted the registry omitted them is
|
|
83
|
+
deleted with it; its inverse now asserts the family IS generated, so nobody
|
|
84
|
+
re-adds a hand copy.
|
|
85
|
+
|
|
86
|
+
## Why the ru/es bundles are `Partial`
|
|
87
|
+
|
|
88
|
+
`stapel_attributes` ships English only — it has no `translations/` directory at
|
|
89
|
+
all — so its 12 keys can appear in NO locale catalog, and demanding them made
|
|
90
|
+
the ru/es bundles unbuildable while stapel-forms' own catalog was perfectly
|
|
91
|
+
correct. `scripts/gen-errors.mjs` gained a general knob for exactly this shape
|
|
92
|
+
(a module inheriting an L1 library's keys):
|
|
93
|
+
`ERRORS_LOCALE_EXEMPT_OWNERS=stapel_attributes` for this pair.
|
|
94
|
+
|
|
95
|
+
Exempted keys are not required in a locale catalog and are left OUT of that
|
|
96
|
+
locale's generated bundle, which widens its type to
|
|
97
|
+
`Partial<Record<FormsErrorCode, string>>` — so the gap is visible to TypeScript
|
|
98
|
+
instead of silently falling back to English. The pair's own `i18n/ru.ts` /
|
|
99
|
+
`i18n/es.ts` layer authored strings for all 12 over the generated bundle.
|
|
100
|
+
English is unaffected: it comes from the registry artifact, which is
|
|
101
|
+
authoritative for every owner.
|
|
102
|
+
|
|
103
|
+
This is opt-in per pair, so a module that DOES own a key and dropped it from its
|
|
104
|
+
catalog still goes red. Upstream localization is logged as stapel-forms
|
|
105
|
+
MODULE.md §12.6; when it lands, drop the env knob and the authored strings.
|
|
106
|
+
|
|
107
|
+
## Deviations from the spec's sketch, with reasons
|
|
108
|
+
|
|
109
|
+
1. **`date` renders a native input, not antd's `DatePicker`.** `DatePicker`
|
|
110
|
+
speaks Dayjs; adopting it would add a runtime dependency for one widget and
|
|
111
|
+
insert a format-guessing step where the native control already yields the ISO
|
|
112
|
+
string the attributes date type parses. `precision` picks the input type.
|
|
113
|
+
2. **Builder-less is now a SERVER signal, not a pair opinion** (§12 risk 5,
|
|
114
|
+
applied). Two distinct facts arrive from `/field-kinds` and the bag reports
|
|
115
|
+
them separately so a skin can word them differently:
|
|
116
|
+
`registered: false` (the host allowlisted a kind the attributes registry
|
|
117
|
+
does not carry) and `fields: []` (registered, but declares no config form —
|
|
118
|
+
how `convertible_unit` arrives). Either way the field stays LISTED,
|
|
119
|
+
reorderable and removable, and authorable through the draft PUT: a builder
|
|
120
|
+
that dropped an unknown kind would silently drop the field from a stored
|
|
121
|
+
schema. Separately, two of upstream's 13 config WIDGETS are not implemented
|
|
122
|
+
in this skin (`hierarchical_options`, `timestamp_array`); those individual
|
|
123
|
+
rows say so, and the rest of the kind stays editable.
|
|
124
|
+
3. **The submit-blocked key is `forms.submit.blocked.unsupported_kind`**, not
|
|
125
|
+
the spec's illustrative `forms.unsupported_kind` — every other blocked reason
|
|
126
|
+
in this pair is `forms.<surface>.blocked.<why>`, and one key in a different
|
|
127
|
+
shape is a key somebody will mistype.
|
|
128
|
+
4. **Error params are NOT narrowed to the artifact's declared list.** The
|
|
129
|
+
registry declares only the slots a message interpolates
|
|
130
|
+
(`feature_mandatory_missing` declares `["feature"]`), but the runtime also
|
|
131
|
+
sets `field`, `slug` and `ref_value` — and `params.field` is precisely what
|
|
132
|
+
routes a refusal onto a control. Typing `params` to the declared list would
|
|
133
|
+
make the pair's own field-error routing a type error against a payload the
|
|
134
|
+
server really sends.
|
|
135
|
+
5. **The client-side mirror emits the SERVER's error keys.** A "too long" caught
|
|
136
|
+
locally and one caught by the server render the same sentence in the same
|
|
137
|
+
language. Pair-invented copy for the local half would give one problem two
|
|
138
|
+
wordings and become a lie the moment the backend's rule moved.
|
|
139
|
+
|
|
140
|
+
## Seams
|
|
141
|
+
|
|
142
|
+
| Seam | Call | Beats |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| Field widget | `registerFormFieldWidget(kind, C)` | the skin's builtin |
|
|
145
|
+
| Skin slot | `registerFormsSkinComponent(slot, C)` | the skin's own part |
|
|
146
|
+
| Theme | regenerate the token JSON | everything, with zero code |
|
|
147
|
+
| Client | `clients={{ forms: runtime.client }}` | the default client |
|
|
148
|
+
| i18n | register a bundle last | any pair copy |
|
|
149
|
+
|
|
150
|
+
## Query keys and invalidation
|
|
151
|
+
|
|
152
|
+
Everything under `["forms"]`. Admin mutations invalidate the module ROOT rather
|
|
153
|
+
than guessing which entries moved — `rotateLink` changes the very `public_id`
|
|
154
|
+
the anonymous read is cached under, so a targeted invalidation would leave a
|
|
155
|
+
stale schema under a token that no longer resolves.
|
|
156
|
+
|
|
157
|
+
`useSubmitForm` invalidates nothing (an anonymous respondent holds no admin
|
|
158
|
+
cache) and does not retry (a resubmit is not idempotent — a retried POST the
|
|
159
|
+
server actually received is a duplicate row in somebody's spreadsheet).
|
|
160
|
+
|
|
161
|
+
## Session gating
|
|
162
|
+
|
|
163
|
+
Every admin read is gated on `useActiveSessionReady`. `usePublicForm` is
|
|
164
|
+
deliberately NOT — core's own doc comment carves out "a public GET", and gating
|
|
165
|
+
it would make an embedded form on a marketing page wait for a login bootstrap it
|
|
166
|
+
has no stake in.
|
|
167
|
+
|
|
168
|
+
## Live counts
|
|
169
|
+
|
|
170
|
+
Refetch only. A `forms:ws:<workspace_id>` Signal stream is reserved naming for
|
|
171
|
+
when the stapel-realtime substrate lands; forms does not build a socket, and
|
|
172
|
+
that is a lint boundary rather than an unfinished feature.
|
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# @stapel/forms-react
|
|
2
|
+
|
|
3
|
+
The frontend pair for **stapel-forms**: an admin defines a form's schema, a host
|
|
4
|
+
page says *"put form `<id>` here"*, and this library fetches the schema and
|
|
5
|
+
renders it — default skin, or your own.
|
|
6
|
+
|
|
7
|
+
Business + state in the main entry, zero visual opinion; the antd skin lives
|
|
8
|
+
behind `./default` so a host that renders its own visuals never carries it.
|
|
9
|
+
Built on `@stapel/core` (typed client + `StapelApiError` envelope, token
|
|
10
|
+
refresh, verification-403 interception, i18n engine, analytics seam, TanStack
|
|
11
|
+
Query).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pnpm add @stapel/forms-react @stapel/core @tanstack/react-query react
|
|
17
|
+
# for the default skin:
|
|
18
|
+
pnpm add antd @stapel/tokens-antd
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Put a form on a page
|
|
22
|
+
|
|
23
|
+
The whole ask, in five lines. **No session, no workspace id, no auth client** —
|
|
24
|
+
the two public endpoints are anonymous, so a marketing page can embed a form and
|
|
25
|
+
nothing else:
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { createFormsRuntime, FormsProvider } from "@stapel/forms-react";
|
|
29
|
+
import { StapelForm } from "@stapel/forms-react/default";
|
|
30
|
+
|
|
31
|
+
const runtime = createFormsRuntime({ baseUrl: "/forms/api/v1/" });
|
|
32
|
+
|
|
33
|
+
export function ContactPage() {
|
|
34
|
+
return (
|
|
35
|
+
<FormsProvider runtime={runtime}>
|
|
36
|
+
<StapelForm publicId="k3JhQ2Zt8uY1sVb7cD9xLg" />
|
|
37
|
+
</FormsProvider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The handle you embed is the **`public_id`** — the same non-enumerable token the
|
|
43
|
+
backend serves anonymously. The pair never needs the row UUID or a workspace id
|
|
44
|
+
to render.
|
|
45
|
+
|
|
46
|
+
In an app that already has a session, put the forms client on core's provider
|
|
47
|
+
alongside the others and keep one client per module:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
<StapelProvider client={authRuntime.client} clients={{ forms: runtime.client }} i18n={i18n}>
|
|
51
|
+
<FormsProvider runtime={runtime}>{app}</FormsProvider>
|
|
52
|
+
</StapelProvider>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## A failed fetch is never "no form here"
|
|
56
|
+
|
|
57
|
+
The one rule this pair is built around. A schema read has **three** outcomes and
|
|
58
|
+
the skin says a different thing for each:
|
|
59
|
+
|
|
60
|
+
| What happened | What the person sees |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `404 error.404.forms_not_found` | "This form link is not valid." |
|
|
63
|
+
| `410 error.410.forms_closed` | "This form is closed and is no longer accepting responses." |
|
|
64
|
+
| network fault / 5xx | "We could not load this form. This is a problem on our side, not with your link." **+ a retry button** |
|
|
65
|
+
|
|
66
|
+
The bag exposes `state: LoadState<PublicForm>`, so the data is *behind* the
|
|
67
|
+
discriminant — there is no `.data` to read on a failed load, and the
|
|
68
|
+
`no-flattened-load-state` lint rule enforces it mechanically. Branch on the
|
|
69
|
+
verdicts with core's `hasErrorCode`.
|
|
70
|
+
|
|
71
|
+
## Headless
|
|
72
|
+
|
|
73
|
+
`<StapelForm>` is one renderer over `<FormFill>`. Write another and lose nothing:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
<FormFill publicId="k3J…x9">
|
|
77
|
+
{(bag) => matchLoad(bag.state, {
|
|
78
|
+
loading: () => <Spinner />,
|
|
79
|
+
failed: (e) => <MyError error={e} onRetry={bag.refetch} />,
|
|
80
|
+
ready: (form) => <MyFields form={form} bag={bag} />,
|
|
81
|
+
})}
|
|
82
|
+
</FormFill>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The bag carries `values` / `setValue`, `fieldErrors` keyed by slug, an
|
|
86
|
+
`ActionAvailability` submit gate that always states *why* it is off,
|
|
87
|
+
`superseded`, `unsupportedKinds`, and `setCaptchaToken`.
|
|
88
|
+
|
|
89
|
+
Also headless: `<FormBuilder>`, `<ResponsesTable>`, `<FormList>`.
|
|
90
|
+
|
|
91
|
+
## Three ways to override, none of them a fork
|
|
92
|
+
|
|
93
|
+
**1. Props** — `mode`, `showTitle`, `submitLabel`.
|
|
94
|
+
|
|
95
|
+
**2. The field-widget registry.** The backend's field vocabulary is an open
|
|
96
|
+
registry (`stapel_attributes`), so the frontend's is too:
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
registerFormFieldWidget("signature", SignaturePad);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Resolution is *your registration → the skin's antd builtin → an
|
|
103
|
+
"unsupported field" notice*. A host registration always wins. A kind nothing can
|
|
104
|
+
draw renders the notice **and blocks the submit** — silently skipping a
|
|
105
|
+
possibly-required field would fabricate an invalid submission and get the person
|
|
106
|
+
refused for a field they never saw.
|
|
107
|
+
|
|
108
|
+
**3. The skin-slot registry** — replace a piece of the skin itself:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
registerFormsSkinComponent("fill.submitBar", MySubmitBar);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Slots: `fill.fieldRow`, `fill.submitBar`, `fill.confirmation`,
|
|
115
|
+
`fill.unsupportedField`, `responses.cell`, `responses.toolbar`,
|
|
116
|
+
`builder.fieldRow`, `builder.toolbar`.
|
|
117
|
+
|
|
118
|
+
**And underneath all three: retheming.** Every `/default` surface wraps itself in
|
|
119
|
+
`<FormsSkinTheme>`, which reads `@stapel/tokens` through `@stapel/tokens-antd`.
|
|
120
|
+
Regenerate your `--stapel-*` custom properties from the token JSON and the form
|
|
121
|
+
follows, with zero code.
|
|
122
|
+
|
|
123
|
+
## Builtin widgets
|
|
124
|
+
|
|
125
|
+
| Kind | Widget |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `string` | `Input`, or `Input.TextArea` when `config.multiline` |
|
|
128
|
+
| `int` / `float` | `InputNumber` (min/max/precision/prefix/postfix) |
|
|
129
|
+
| `bool` | `Switch` with the config's `trueLabel`/`falseLabel` |
|
|
130
|
+
| `select` | `Segmented` at ≤4 single choices, else `Select` (multi/tags) |
|
|
131
|
+
| `date` | native `<input type=date\|month\|datetime-local>`, or a year `InputNumber` |
|
|
132
|
+
| `header` | `Typography.Title` — a caption, never a control |
|
|
133
|
+
| `hex_color` | `ColorPicker` |
|
|
134
|
+
| `hierarchical_select` | `Cascader` |
|
|
135
|
+
| `convertible_unit` | `InputNumber` + a unit `Select` |
|
|
136
|
+
|
|
137
|
+
`date` deliberately uses a native input rather than antd's `DatePicker`: the
|
|
138
|
+
picker speaks Dayjs, which would add a runtime dependency for one widget and put
|
|
139
|
+
a format-guessing step between the person and a wire format the backend already
|
|
140
|
+
parses. Register the picker yourself if you want it.
|
|
141
|
+
|
|
142
|
+
## Captcha
|
|
143
|
+
|
|
144
|
+
The netintel tier decides whether a token is required at all, so the pair only
|
|
145
|
+
transports one. Render your challenge through the `captcha` prop and hand the bag
|
|
146
|
+
a token:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
<StapelForm
|
|
150
|
+
publicId="k3J…x9"
|
|
151
|
+
captcha={(bag) => <Turnstile onVerify={(t) => bag.setCaptchaToken(t)} />}
|
|
152
|
+
/>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Admin surface
|
|
156
|
+
|
|
157
|
+
Capability-gated over REST (`forms.view`, `forms.manage`,
|
|
158
|
+
`forms.responses.view`, `forms.responses.manage`):
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<FormsListPane workspaceId={ws} onOpen={(form) => navigate(form.id)} />
|
|
162
|
+
<FormBuilderPane workspaceId={ws} formId={id} />
|
|
163
|
+
<ResponsesPane workspaceId={ws} formId={id} />
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`<FormBuilderPane>` is **data-driven**: a field's options come from
|
|
167
|
+
`GET /field-kinds`, which serves the config declarations `stapel_attributes`
|
|
168
|
+
publishes per type. There is no hand-written form per kind and no mirrored table
|
|
169
|
+
— register a feature type upstream and it appears in the builder with no client
|
|
170
|
+
release.
|
|
171
|
+
|
|
172
|
+
A kind arrives *builder-less* for either of two reasons the server distinguishes:
|
|
173
|
+
`registered: false` (this deployment allowlisted a type the attributes registry
|
|
174
|
+
does not carry) or `fields: []` (registered, but it declares no config form —
|
|
175
|
+
`convertible_unit`). Either way the field is still listed, still renders, still
|
|
176
|
+
submits, and stays authorable through the draft API — a builder that dropped an
|
|
177
|
+
unknown kind would silently drop the field from a stored schema.
|
|
178
|
+
|
|
179
|
+
`<ResponsesPane>` draws **per-version columns** (a response records which schema
|
|
180
|
+
it answered, so an old row shows the questions actually asked), keyset paging,
|
|
181
|
+
resend, and CSV export.
|
|
182
|
+
|
|
183
|
+
### Resend overrides REPLACE
|
|
184
|
+
|
|
185
|
+
An explicit destination on a resend replaces the form's configured recipients for
|
|
186
|
+
that one send — "send this one to legal" must not also re-send it to everybody
|
|
187
|
+
who already received it.
|
|
188
|
+
|
|
189
|
+
## i18n
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
registerFormsI18n(i18n); // en floor, always
|
|
193
|
+
registerFormsI18nRu(i18n); // from @stapel/forms-react/i18n/ru
|
|
194
|
+
registerFormsI18nEs(i18n); // from @stapel/forms-react/i18n/es
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Registration order is override priority — a host bundle registered last wins.
|
|
198
|
+
|
|
199
|
+
**Field labels are not i18n keys.** They are admin-authored content carried in
|
|
200
|
+
the schema and render verbatim in whatever language the admin typed. Translating
|
|
201
|
+
form *content* is a separate problem from translating the pair's chrome.
|
|
202
|
+
|
|
203
|
+
## Docs
|
|
204
|
+
|
|
205
|
+
`MODULE.md` — layer map, seams, and the deliberate deviations, with reasons.
|
|
206
|
+
`llms.txt` / `manifest.json` — the generated agent/machine views, drift-gated
|
|
207
|
+
against the backend contract.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw-transport binding forwarded from the runtime. Declared here rather than
|
|
3
|
+
* in `formsApi.ts` so the two modules do not import each other's types in a
|
|
4
|
+
* cycle — `formsApi.ts` re-exports it as part of its public surface.
|
|
5
|
+
*/
|
|
6
|
+
export interface FormsApiOptions {
|
|
7
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
8
|
+
readonly credentials?: RequestCredentials;
|
|
9
|
+
readonly defaultHeaders?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Response header carrying the keyset cursor for the NEXT export page.
|
|
13
|
+
* Absent (or empty) means this was the last page.
|
|
14
|
+
*/
|
|
15
|
+
export declare const FORMS_NEXT_BEFORE_HEADER = "X-Forms-Next-Before";
|
|
16
|
+
/** The raw-transport binding `createFormsApi` closes over. */
|
|
17
|
+
export interface FormsRawTransport {
|
|
18
|
+
/** e.g. `/forms/api/v1` — the same base the pair's `StapelClient` uses. */
|
|
19
|
+
readonly baseUrl: string;
|
|
20
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
21
|
+
readonly credentials?: RequestCredentials;
|
|
22
|
+
/** Merged into every raw request (e.g. a tenant id / auth header). */
|
|
23
|
+
readonly headers?: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
/** One page of the CSV export. */
|
|
26
|
+
export interface CsvExportPage {
|
|
27
|
+
/** The page's CSV text, header row included. */
|
|
28
|
+
readonly csv: string;
|
|
29
|
+
/**
|
|
30
|
+
* The cursor to pass as the next call's `before`, or `null` when the export
|
|
31
|
+
* is complete. Opaque — pass it back verbatim (see the module header).
|
|
32
|
+
*/
|
|
33
|
+
readonly nextBefore: string | null;
|
|
34
|
+
}
|
|
35
|
+
/** Build the transport from the runtime options, dropping absent keys so
|
|
36
|
+
* `exactOptionalPropertyTypes` stays satisfied. */
|
|
37
|
+
export declare function createExportTransport(baseUrl: string, options: FormsApiOptions): FormsRawTransport;
|
|
38
|
+
/**
|
|
39
|
+
* Fetch one page of a form's CSV export.
|
|
40
|
+
*
|
|
41
|
+
* Drive it to completion by feeding {@link CsvExportPage.nextBefore} back in
|
|
42
|
+
* as `before` until it is `null`, concatenating the pages MINUS the repeated
|
|
43
|
+
* header row (see `useCsvExport`, which does exactly that).
|
|
44
|
+
*/
|
|
45
|
+
export declare function exportSubmissionsCsv(transport: FormsRawTransport, params: {
|
|
46
|
+
readonly workspaceId: string;
|
|
47
|
+
readonly formId: string;
|
|
48
|
+
readonly before?: string;
|
|
49
|
+
readonly version?: number;
|
|
50
|
+
readonly signal?: AbortSignal;
|
|
51
|
+
}): Promise<CsvExportPage>;
|
|
52
|
+
/**
|
|
53
|
+
* Concatenate export pages into one CSV, keeping the header row exactly once.
|
|
54
|
+
*
|
|
55
|
+
* Every page the server streams carries its own header row (it is a standalone
|
|
56
|
+
* CSV file); pasting the pages verbatim would sprinkle header rows through the
|
|
57
|
+
* middle of the spreadsheet. Splitting on the first newline is safe here
|
|
58
|
+
* because a stapel-forms header row is a list of field slugs — slugs cannot
|
|
59
|
+
* contain a newline, so the first line is always exactly the header.
|
|
60
|
+
*
|
|
61
|
+
* Each retained chunk is newline-terminated before the join: a page whose last
|
|
62
|
+
* row has no trailing newline would otherwise be glued to the next page's
|
|
63
|
+
* first row, silently producing one corrupt record per page boundary.
|
|
64
|
+
*/
|
|
65
|
+
export declare function concatCsvPages(pages: readonly string[]): string;
|
|
66
|
+
//# sourceMappingURL=export.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"export.d.ts","sourceRoot":"","sources":["../../src/api/export.ts"],"names":[],"mappings":"AA6BA;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IACzC,QAAQ,CAAC,WAAW,CAAC,EAAE,kBAAkB,CAAC;IAC1C,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClD;AAED;;;GAGG;AACH,eAAO,MAAM,wBAAwB,wBAAwB,CAAC;AAE9D,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IACzC,QAAQ,CAAC,WAAW,CAAC,EAAE,kBAAkB,CAAC;IAC1C,sEAAsE;IACtE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,kCAAkC;AAClC,MAAM,WAAW,aAAa;IAC5B,gDAAgD;IAChD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;mDACmD;AACnD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,eAAe,GACvB,iBAAiB,CAWnB;AAkCD;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,SAAS,EAAE,iBAAiB,EAC5B,MAAM,EAAE;IACN,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B,GACA,OAAO,CAAC,aAAa,CAAC,CA8BxB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAgB/D"}
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
* Response header carrying the keyset cursor for the NEXT export page.
|
|
31
|
+
* Absent (or empty) means this was the last page.
|
|
32
|
+
*/
|
|
33
|
+
export const FORMS_NEXT_BEFORE_HEADER = "X-Forms-Next-Before";
|
|
34
|
+
/** Build the transport from the runtime options, dropping absent keys so
|
|
35
|
+
* `exactOptionalPropertyTypes` stays satisfied. */
|
|
36
|
+
export function createExportTransport(baseUrl, options) {
|
|
37
|
+
return {
|
|
38
|
+
baseUrl,
|
|
39
|
+
...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
|
|
40
|
+
...(options.credentials !== undefined
|
|
41
|
+
? { credentials: options.credentials }
|
|
42
|
+
: {}),
|
|
43
|
+
...(options.defaultHeaders !== undefined
|
|
44
|
+
? { headers: options.defaultHeaders }
|
|
45
|
+
: {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function exportUrl(transport, formId, query) {
|
|
49
|
+
const base = transport.baseUrl.endsWith("/")
|
|
50
|
+
? transport.baseUrl.slice(0, -1)
|
|
51
|
+
: transport.baseUrl;
|
|
52
|
+
const search = new URLSearchParams();
|
|
53
|
+
for (const [key, value] of Object.entries(query)) {
|
|
54
|
+
if (value !== undefined)
|
|
55
|
+
search.set(key, String(value));
|
|
56
|
+
}
|
|
57
|
+
const qs = search.toString();
|
|
58
|
+
return (`${base}/forms/${encodeURIComponent(formId)}/submissions/export` +
|
|
59
|
+
(qs.length > 0 ? `?${qs}` : ""));
|
|
60
|
+
}
|
|
61
|
+
async function throwEnvelope(response) {
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
let body = text.length > 0 ? text : undefined;
|
|
64
|
+
if (text.length > 0) {
|
|
65
|
+
try {
|
|
66
|
+
body = JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* a non-JSON error body stays the raw text */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw parseErrorEnvelope(response.status, body);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Fetch one page of a form's CSV export.
|
|
76
|
+
*
|
|
77
|
+
* Drive it to completion by feeding {@link CsvExportPage.nextBefore} back in
|
|
78
|
+
* as `before` until it is `null`, concatenating the pages MINUS the repeated
|
|
79
|
+
* header row (see `useCsvExport`, which does exactly that).
|
|
80
|
+
*/
|
|
81
|
+
export async function exportSubmissionsCsv(transport, params) {
|
|
82
|
+
const fetchImpl = transport.fetch ?? globalThis.fetch.bind(globalThis);
|
|
83
|
+
const headers = new Headers({ Accept: "text/csv" });
|
|
84
|
+
if (transport.headers) {
|
|
85
|
+
for (const [key, value] of Object.entries(transport.headers)) {
|
|
86
|
+
if (!headers.has(key))
|
|
87
|
+
headers.set(key, value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const init = { method: "GET", headers };
|
|
91
|
+
if (transport.credentials !== undefined)
|
|
92
|
+
init.credentials = transport.credentials;
|
|
93
|
+
if (params.signal)
|
|
94
|
+
init.signal = params.signal;
|
|
95
|
+
const response = await fetchImpl(exportUrl(transport, params.formId, {
|
|
96
|
+
workspace_id: params.workspaceId,
|
|
97
|
+
...(params.before !== undefined ? { before: params.before } : {}),
|
|
98
|
+
...(params.version !== undefined ? { version: params.version } : {}),
|
|
99
|
+
}), init);
|
|
100
|
+
if (!response.ok)
|
|
101
|
+
return throwEnvelope(response);
|
|
102
|
+
const raw = response.headers.get(FORMS_NEXT_BEFORE_HEADER);
|
|
103
|
+
return {
|
|
104
|
+
csv: await response.text(),
|
|
105
|
+
// An empty header is "no more pages", not a cursor of "".
|
|
106
|
+
nextBefore: raw !== null && raw.length > 0 ? raw : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Concatenate export pages into one CSV, keeping the header row exactly once.
|
|
111
|
+
*
|
|
112
|
+
* Every page the server streams carries its own header row (it is a standalone
|
|
113
|
+
* CSV file); pasting the pages verbatim would sprinkle header rows through the
|
|
114
|
+
* middle of the spreadsheet. Splitting on the first newline is safe here
|
|
115
|
+
* because a stapel-forms header row is a list of field slugs — slugs cannot
|
|
116
|
+
* contain a newline, so the first line is always exactly the header.
|
|
117
|
+
*
|
|
118
|
+
* Each retained chunk is newline-terminated before the join: a page whose last
|
|
119
|
+
* row has no trailing newline would otherwise be glued to the next page's
|
|
120
|
+
* first row, silently producing one corrupt record per page boundary.
|
|
121
|
+
*/
|
|
122
|
+
export function concatCsvPages(pages) {
|
|
123
|
+
const nonEmpty = pages.filter((page) => page.length > 0);
|
|
124
|
+
const first = nonEmpty[0];
|
|
125
|
+
if (first === undefined)
|
|
126
|
+
return "";
|
|
127
|
+
const chunks = [
|
|
128
|
+
first,
|
|
129
|
+
...nonEmpty.slice(1).map((page) => {
|
|
130
|
+
const newline = page.indexOf("\n");
|
|
131
|
+
return newline === -1 ? "" : page.slice(newline + 1);
|
|
132
|
+
}),
|
|
133
|
+
].filter((chunk) => chunk.length > 0);
|
|
134
|
+
return chunks
|
|
135
|
+
.map((chunk, index) => index === chunks.length - 1 || chunk.endsWith("\n") ? chunk : `${chunk}\n`)
|
|
136
|
+
.join("");
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=export.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"export.js","sourceRoot":"","sources":["../../src/api/export.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
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 {};
|
|
8
|
+
//# sourceMappingURL=extensions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../src/api/extensions.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,CAAC"}
|