@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
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ResponsesPane>` — the response review surface (spec §8.2).
|
|
3
|
+
*
|
|
4
|
+
* Per-version column sets, a version filter, a detail drawer, delete under
|
|
5
|
+
* `forms.responses.manage`, resend with an optional destination override, and
|
|
6
|
+
* CSV export driven by the `X-Forms-Next-Before` header cursor.
|
|
7
|
+
*
|
|
8
|
+
* ── Two things this surface deliberately does not do ───────────────────────
|
|
9
|
+
*
|
|
10
|
+
* 1. **No live counts over a socket.** Refetch only. A
|
|
11
|
+
* `forms:ws:<workspace_id>` Signal stream is reserved naming for when the
|
|
12
|
+
* stapel-realtime substrate lands; forms does not build a socket, and that
|
|
13
|
+
* is a lint boundary rather than an unfinished feature.
|
|
14
|
+
* 2. **No client-side CSV escaping.** The formula-injection guard (a `'`
|
|
15
|
+
* prefix on a leading `= + - @`) lives SERVER-side, so every consumer
|
|
16
|
+
* inherits it — a second escape here would double-prefix the cells the
|
|
17
|
+
* server already fixed.
|
|
18
|
+
*/
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
import type { ReactElement } from "react";
|
|
21
|
+
import {
|
|
22
|
+
Button,
|
|
23
|
+
Drawer,
|
|
24
|
+
Empty,
|
|
25
|
+
Flex,
|
|
26
|
+
Input,
|
|
27
|
+
Popconfirm,
|
|
28
|
+
Select,
|
|
29
|
+
Space,
|
|
30
|
+
Spin,
|
|
31
|
+
Table,
|
|
32
|
+
Tag,
|
|
33
|
+
Typography,
|
|
34
|
+
} from "antd";
|
|
35
|
+
import {
|
|
36
|
+
matchLoad,
|
|
37
|
+
toFlowError,
|
|
38
|
+
useActionGate,
|
|
39
|
+
useDescribeFlowError,
|
|
40
|
+
useT,
|
|
41
|
+
} from "@stapel/core";
|
|
42
|
+
import type { Submission } from "../api/types.js";
|
|
43
|
+
import { ResponsesTable } from "../headless/ResponsesTable.js";
|
|
44
|
+
import type {
|
|
45
|
+
ResponseColumn,
|
|
46
|
+
ResponsesTableBag,
|
|
47
|
+
} from "../headless/ResponsesTable.js";
|
|
48
|
+
import { FormsSkinTheme } from "./theme.js";
|
|
49
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
50
|
+
import { resolveFormsSkinComponent } from "./slots.js";
|
|
51
|
+
import type { ThemeModeProp } from "./types.js";
|
|
52
|
+
import { FORMS_I18N_KEYS } from "../i18n/keys.js";
|
|
53
|
+
|
|
54
|
+
/** Props of the `"responses.cell"` slot. */
|
|
55
|
+
export interface ResponseCellSlotProps {
|
|
56
|
+
readonly column: ResponseColumn;
|
|
57
|
+
readonly row: Submission;
|
|
58
|
+
readonly value: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Props of the `"responses.toolbar"` slot. */
|
|
62
|
+
export interface ResponsesToolbarSlotProps {
|
|
63
|
+
readonly bag: ResponsesTableBag;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Render one answer. Objects and arrays are JSON, not `[object Object]` —
|
|
67
|
+
* a reviewer looking at a `convertible_unit` or a multi-select must be able
|
|
68
|
+
* to read what was actually submitted. */
|
|
69
|
+
function renderAnswer(value: unknown): string {
|
|
70
|
+
if (value === undefined || value === null) return "";
|
|
71
|
+
if (Array.isArray(value)) return value.map((entry) => renderAnswer(entry)).join(", ");
|
|
72
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
73
|
+
return String(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Cell(props: ResponseCellSlotProps): ReactElement {
|
|
77
|
+
const Slot = resolveFormsSkinComponent<ResponseCellSlotProps>("responses.cell");
|
|
78
|
+
if (Slot) return <Slot {...props} />;
|
|
79
|
+
return <span>{renderAnswer(props.value)}</span>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function Toolbar(props: ResponsesToolbarSlotProps): ReactElement {
|
|
83
|
+
const t = useT();
|
|
84
|
+
const { bag } = props;
|
|
85
|
+
const nextGate = useActionGate(bag.nextPage);
|
|
86
|
+
const prevGate = useActionGate(bag.prevPage);
|
|
87
|
+
const Slot = resolveFormsSkinComponent<ResponsesToolbarSlotProps>(
|
|
88
|
+
"responses.toolbar"
|
|
89
|
+
);
|
|
90
|
+
if (Slot) return <Slot {...props} />;
|
|
91
|
+
|
|
92
|
+
const versionOptions = matchLoad(bag.versions, {
|
|
93
|
+
loading: () => [],
|
|
94
|
+
failed: () => [],
|
|
95
|
+
ready: (versions) =>
|
|
96
|
+
versions.map((v) => ({
|
|
97
|
+
value: v.version,
|
|
98
|
+
label: `${t(FORMS_I18N_KEYS.responsesVersion)} ${v.version}`,
|
|
99
|
+
})),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Flex gap={8} wrap align="center">
|
|
104
|
+
<Select<number | null>
|
|
105
|
+
style={{ width: 180 }}
|
|
106
|
+
value={bag.version}
|
|
107
|
+
onChange={(next) => bag.setVersion(next)}
|
|
108
|
+
data-testid="forms-responses-version"
|
|
109
|
+
options={[
|
|
110
|
+
{ value: null, label: t(FORMS_I18N_KEYS.responsesAllVersions) },
|
|
111
|
+
...versionOptions,
|
|
112
|
+
]}
|
|
113
|
+
/>
|
|
114
|
+
<Button
|
|
115
|
+
disabled={prevGate.disabled}
|
|
116
|
+
data-analytics="none"
|
|
117
|
+
data-analytics-reason="keyset paging of a read; no flow to step"
|
|
118
|
+
onClick={bag.goPrevPage}
|
|
119
|
+
>
|
|
120
|
+
{t(FORMS_I18N_KEYS.responsesPrev)}
|
|
121
|
+
</Button>
|
|
122
|
+
<Button
|
|
123
|
+
disabled={nextGate.disabled}
|
|
124
|
+
data-analytics="none"
|
|
125
|
+
data-analytics-reason="keyset paging of a read; no flow to step"
|
|
126
|
+
data-testid="forms-responses-next"
|
|
127
|
+
onClick={bag.goNextPage}
|
|
128
|
+
>
|
|
129
|
+
{t(FORMS_I18N_KEYS.responsesNext)}
|
|
130
|
+
</Button>
|
|
131
|
+
<Button
|
|
132
|
+
loading={bag.isExporting}
|
|
133
|
+
data-analytics="flow"
|
|
134
|
+
data-testid="forms-responses-export"
|
|
135
|
+
onClick={() => {
|
|
136
|
+
void bag.exportCsv();
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{bag.isExporting
|
|
140
|
+
? t(FORMS_I18N_KEYS.responsesExporting, { pages: bag.exportPages })
|
|
141
|
+
: t(FORMS_I18N_KEYS.responsesExport)}
|
|
142
|
+
</Button>
|
|
143
|
+
{nextGate.reason !== undefined && (
|
|
144
|
+
<Typography.Text type="secondary">{nextGate.reason}</Typography.Text>
|
|
145
|
+
)}
|
|
146
|
+
</Flex>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function DetailDrawer(props: { bag: ResponsesTableBag }): ReactElement {
|
|
151
|
+
const t = useT();
|
|
152
|
+
const { bag } = props;
|
|
153
|
+
const [override, setOverride] = useState("");
|
|
154
|
+
const row = bag.selected;
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<Drawer
|
|
158
|
+
open={row !== null}
|
|
159
|
+
onClose={() => bag.select(null)}
|
|
160
|
+
title={t(FORMS_I18N_KEYS.responsesDetail)}
|
|
161
|
+
width={480}
|
|
162
|
+
data-testid="forms-responses-drawer"
|
|
163
|
+
>
|
|
164
|
+
{row !== null && (
|
|
165
|
+
<Flex vertical gap={12}>
|
|
166
|
+
<Typography.Text type="secondary">
|
|
167
|
+
{t(FORMS_I18N_KEYS.responsesVersion)} {row.version} ·{" "}
|
|
168
|
+
{row.submitted_at}
|
|
169
|
+
</Typography.Text>
|
|
170
|
+
{row.erased_at != null && <Tag>{t(FORMS_I18N_KEYS.responsesErased)}</Tag>}
|
|
171
|
+
{Object.entries(row.answers).map(([slug, value]) => (
|
|
172
|
+
<div key={slug}>
|
|
173
|
+
<Typography.Text strong>{slug}</Typography.Text>
|
|
174
|
+
<br />
|
|
175
|
+
<Typography.Text>{renderAnswer(value)}</Typography.Text>
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
178
|
+
|
|
179
|
+
<Typography.Text type="secondary">
|
|
180
|
+
{t(FORMS_I18N_KEYS.responsesResendOverrideHint)}
|
|
181
|
+
</Typography.Text>
|
|
182
|
+
<Input
|
|
183
|
+
placeholder={t(FORMS_I18N_KEYS.responsesResendOverride)}
|
|
184
|
+
value={override}
|
|
185
|
+
onChange={(event) => setOverride(event.target.value)}
|
|
186
|
+
data-testid="forms-resend-override"
|
|
187
|
+
/>
|
|
188
|
+
<Space>
|
|
189
|
+
<Button
|
|
190
|
+
loading={bag.isResending}
|
|
191
|
+
data-analytics="flow"
|
|
192
|
+
data-testid="forms-resend"
|
|
193
|
+
onClick={() => {
|
|
194
|
+
const recipients = override
|
|
195
|
+
.split(",")
|
|
196
|
+
.map((entry) => entry.trim())
|
|
197
|
+
.filter((entry) => entry.length > 0);
|
|
198
|
+
// Only send an override when the operator typed one: an empty
|
|
199
|
+
// list would REPLACE the form's targets with nothing.
|
|
200
|
+
bag.resend(
|
|
201
|
+
row.id,
|
|
202
|
+
recipients.length > 0 ? { recipients } : undefined
|
|
203
|
+
);
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
{t(FORMS_I18N_KEYS.responsesResend)}
|
|
207
|
+
</Button>
|
|
208
|
+
<Popconfirm
|
|
209
|
+
title={t(FORMS_I18N_KEYS.responsesDeleteConfirm)}
|
|
210
|
+
onConfirm={() => bag.remove(row.id)}
|
|
211
|
+
>
|
|
212
|
+
<Button danger loading={bag.isRemoving} data-analytics="flow">
|
|
213
|
+
{t(FORMS_I18N_KEYS.responsesDelete)}
|
|
214
|
+
</Button>
|
|
215
|
+
</Popconfirm>
|
|
216
|
+
</Space>
|
|
217
|
+
{bag.lastResendCount !== null && (
|
|
218
|
+
<Typography.Text type="success" data-testid="forms-resend-sent">
|
|
219
|
+
{t(FORMS_I18N_KEYS.responsesResendSent, {
|
|
220
|
+
count: bag.lastResendCount,
|
|
221
|
+
})}
|
|
222
|
+
</Typography.Text>
|
|
223
|
+
)}
|
|
224
|
+
</Flex>
|
|
225
|
+
)}
|
|
226
|
+
</Drawer>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface ResponsesPaneProps extends ThemeModeProp {
|
|
231
|
+
readonly workspaceId: string;
|
|
232
|
+
readonly formId: string;
|
|
233
|
+
readonly limit?: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function ResponsesPane(props: ResponsesPaneProps): ReactElement {
|
|
237
|
+
const t = useT();
|
|
238
|
+
const describe = useDescribeFlowError();
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<FormsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
242
|
+
<ResponsesTable
|
|
243
|
+
workspaceId={props.workspaceId}
|
|
244
|
+
formId={props.formId}
|
|
245
|
+
{...(props.limit !== undefined ? { limit: props.limit } : {})}
|
|
246
|
+
>
|
|
247
|
+
{(bag) => (
|
|
248
|
+
<Flex vertical gap={16}>
|
|
249
|
+
<Typography.Title level={4}>
|
|
250
|
+
{t(FORMS_I18N_KEYS.responsesTitle)}
|
|
251
|
+
</Typography.Title>
|
|
252
|
+
<Toolbar bag={bag} />
|
|
253
|
+
{bag.error !== null && (
|
|
254
|
+
<ErrorAlert
|
|
255
|
+
testId="forms-responses-error"
|
|
256
|
+
error={describe(toFlowError(bag.error))}
|
|
257
|
+
/>
|
|
258
|
+
)}
|
|
259
|
+
{matchLoad(bag.state, {
|
|
260
|
+
loading: () => (
|
|
261
|
+
<Flex justify="center" style={{ padding: 24 }}>
|
|
262
|
+
<Spin data-testid="forms-responses-loading" />
|
|
263
|
+
</Flex>
|
|
264
|
+
),
|
|
265
|
+
// "We could not load the responses" — never an empty grid,
|
|
266
|
+
// which would read as "nobody answered".
|
|
267
|
+
failed: (error) => (
|
|
268
|
+
<ErrorAlert
|
|
269
|
+
testId="forms-responses-failed"
|
|
270
|
+
error={{
|
|
271
|
+
...describe(toFlowError(error)),
|
|
272
|
+
message: t(FORMS_I18N_KEYS.responsesLoadFailed),
|
|
273
|
+
}}
|
|
274
|
+
action={
|
|
275
|
+
<Button
|
|
276
|
+
size="small"
|
|
277
|
+
onClick={bag.refetch}
|
|
278
|
+
data-analytics="none"
|
|
279
|
+
data-analytics-reason="retry of a failed read; no flow to step"
|
|
280
|
+
>
|
|
281
|
+
{t(FORMS_I18N_KEYS.fillRetry)}
|
|
282
|
+
</Button>
|
|
283
|
+
}
|
|
284
|
+
/>
|
|
285
|
+
),
|
|
286
|
+
ready: (view) =>
|
|
287
|
+
view.rows.length === 0 ? (
|
|
288
|
+
<Empty
|
|
289
|
+
data-testid="forms-responses-empty"
|
|
290
|
+
description={t(FORMS_I18N_KEYS.responsesEmpty)}
|
|
291
|
+
/>
|
|
292
|
+
) : (
|
|
293
|
+
<Table<Submission>
|
|
294
|
+
size="small"
|
|
295
|
+
rowKey="id"
|
|
296
|
+
data-testid="forms-responses-table"
|
|
297
|
+
dataSource={[...view.rows]}
|
|
298
|
+
pagination={false}
|
|
299
|
+
onRow={(row) => ({ onClick: () => bag.select(row) })}
|
|
300
|
+
columns={[
|
|
301
|
+
{
|
|
302
|
+
title: t(FORMS_I18N_KEYS.responsesSubmittedAt),
|
|
303
|
+
dataIndex: "submitted_at",
|
|
304
|
+
key: "submitted_at",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
title: t(FORMS_I18N_KEYS.responsesVersion),
|
|
308
|
+
dataIndex: "version",
|
|
309
|
+
key: "version",
|
|
310
|
+
},
|
|
311
|
+
...view.columns.map((column) => ({
|
|
312
|
+
title: column.title,
|
|
313
|
+
key: column.slug,
|
|
314
|
+
render: (_: unknown, row: Submission) => (
|
|
315
|
+
<Cell
|
|
316
|
+
column={column}
|
|
317
|
+
row={row}
|
|
318
|
+
value={row.answers[column.slug]}
|
|
319
|
+
/>
|
|
320
|
+
),
|
|
321
|
+
})),
|
|
322
|
+
]}
|
|
323
|
+
/>
|
|
324
|
+
),
|
|
325
|
+
})}
|
|
326
|
+
<DetailDrawer bag={bag} />
|
|
327
|
+
</Flex>
|
|
328
|
+
)}
|
|
329
|
+
</ResponsesTable>
|
|
330
|
+
</FormsSkinTheme>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<StapelForm>` — the whole answer to the owner's ask: *"the host page says
|
|
3
|
+
* 'put form <id> here', the library fetches the schema and renders it,
|
|
4
|
+
* default skin or override styles."*
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* const runtime = createFormsRuntime({ baseUrl: "/forms/api/v1/" });
|
|
8
|
+
* <FormsProvider runtime={runtime}>
|
|
9
|
+
* <StapelForm publicId="k3J…x9" />
|
|
10
|
+
* </FormsProvider>
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Nothing else is required — no session, no workspace id, no auth client. The
|
|
14
|
+
* component is a renderer over `<FormFill>`'s bag and holds no state of its
|
|
15
|
+
* own, so everything it does is reachable headlessly too.
|
|
16
|
+
*
|
|
17
|
+
* ── LoadState is the law here, visibly ─────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* The failed arm distinguishes THREE things a lesser form collapses into one
|
|
20
|
+
* blank page: "this link is not valid" (404), "this form is closed" (410),
|
|
21
|
+
* and "we could not ask" (network / 5xx). Only the first two are statements
|
|
22
|
+
* about the form; the third is a statement about us, and it offers a retry
|
|
23
|
+
* instead of blaming the person's link. There is no code path in this file
|
|
24
|
+
* that renders an empty form because a fetch failed — `matchLoad`'s three
|
|
25
|
+
* required arms make writing one a compile error.
|
|
26
|
+
*/
|
|
27
|
+
import type { ReactElement, ReactNode } from "react";
|
|
28
|
+
import { Alert, Button, Flex, Form, Spin, Typography } from "antd";
|
|
29
|
+
import {
|
|
30
|
+
hasErrorCode,
|
|
31
|
+
matchLoad,
|
|
32
|
+
toFlowError,
|
|
33
|
+
useActionGate,
|
|
34
|
+
useDescribeFlowError,
|
|
35
|
+
useFormatFlowError,
|
|
36
|
+
useT,
|
|
37
|
+
} from "@stapel/core";
|
|
38
|
+
import type { FlowError, ThemeModeProp } from "./types.js";
|
|
39
|
+
import { FormFill } from "../headless/FormFill.js";
|
|
40
|
+
import type { FormFillBag } from "../headless/FormFill.js";
|
|
41
|
+
import type { FormFieldDef } from "../api/types.js";
|
|
42
|
+
import { resolveFormFieldWidget } from "../widgets/registry.js";
|
|
43
|
+
import { BUILTIN_FIELD_KINDS, BUILTIN_FIELD_WIDGETS } from "./fields.js";
|
|
44
|
+
import { FormsSkinTheme } from "./theme.js";
|
|
45
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
46
|
+
import { resolveFormsSkinComponent } from "./slots.js";
|
|
47
|
+
import { FORMS_I18N_KEYS } from "../i18n/keys.js";
|
|
48
|
+
|
|
49
|
+
/** Stable per-field DOM id, so `<label for>` reaches the control. Slugs are
|
|
50
|
+
* `[a-z0-9_]`-shaped by the engine, so this needs no further escaping. */
|
|
51
|
+
function fieldControlId(slug: string): string {
|
|
52
|
+
return `forms-field-${slug}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CODE_NOT_FOUND = "error.404.forms_not_found";
|
|
56
|
+
const CODE_CLOSED = "error.410.forms_closed";
|
|
57
|
+
|
|
58
|
+
/** Props of the `"fill.fieldRow"` slot. */
|
|
59
|
+
export interface FieldRowSlotProps {
|
|
60
|
+
readonly field: FormFieldDef;
|
|
61
|
+
/** DOM id of the control this row labels. */
|
|
62
|
+
readonly controlId: string;
|
|
63
|
+
readonly control: ReactNode;
|
|
64
|
+
readonly error: FlowError | undefined;
|
|
65
|
+
readonly required: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Props of the `"fill.submitBar"` slot. */
|
|
69
|
+
export interface SubmitBarSlotProps {
|
|
70
|
+
readonly bag: FormFillBag;
|
|
71
|
+
readonly label: string;
|
|
72
|
+
readonly disabled: boolean;
|
|
73
|
+
readonly reason: string | undefined;
|
|
74
|
+
readonly detail: string | undefined;
|
|
75
|
+
onSubmit(): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Props of the `"fill.confirmation"` slot. */
|
|
79
|
+
export interface ConfirmationSlotProps {
|
|
80
|
+
readonly confirmation: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Props of the `"fill.unsupportedField"` slot. */
|
|
84
|
+
export interface UnsupportedFieldSlotProps {
|
|
85
|
+
readonly field: FormFieldDef;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function UnsupportedField(props: UnsupportedFieldSlotProps): ReactElement {
|
|
89
|
+
const t = useT();
|
|
90
|
+
const Slot = resolveFormsSkinComponent<UnsupportedFieldSlotProps>(
|
|
91
|
+
"fill.unsupportedField"
|
|
92
|
+
);
|
|
93
|
+
if (Slot) return <Slot {...props} />;
|
|
94
|
+
return (
|
|
95
|
+
<Alert
|
|
96
|
+
type="warning"
|
|
97
|
+
showIcon
|
|
98
|
+
data-testid="forms-unsupported-field"
|
|
99
|
+
message={props.field.name ?? props.field.slug}
|
|
100
|
+
description={t(FORMS_I18N_KEYS.fillUnsupportedField, {
|
|
101
|
+
kind: props.field.kind,
|
|
102
|
+
})}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `Form.Item` error props for a flow error, spread so no `undefined` is
|
|
109
|
+
* passed under `exactOptionalPropertyTypes` — the fleet's `useFieldError`
|
|
110
|
+
* convention (auth-react `default/panels.tsx`), and the reason a server
|
|
111
|
+
* `error.400.feature_*` lands ON the control that caused it: `<FormFill>`
|
|
112
|
+
* keys `fieldErrors` by the refusal's own `params.field`.
|
|
113
|
+
*/
|
|
114
|
+
function useFieldError(): (
|
|
115
|
+
e: FlowError | undefined
|
|
116
|
+
) => { validateStatus: "error"; help: string } | Record<string, never> {
|
|
117
|
+
const format = useFormatFlowError();
|
|
118
|
+
return (e) => (e ? { validateStatus: "error", help: format(e) } : {});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function FieldRow(props: FieldRowSlotProps): ReactElement {
|
|
122
|
+
const fieldError = useFieldError();
|
|
123
|
+
const Slot = resolveFormsSkinComponent<FieldRowSlotProps>("fill.fieldRow");
|
|
124
|
+
if (Slot) return <Slot {...props} />;
|
|
125
|
+
// A header is a caption: it gets no label, no colon, no required marker —
|
|
126
|
+
// rendering one inside a labelled Form.Item would make a section heading
|
|
127
|
+
// look like a question.
|
|
128
|
+
if (props.field.kind === "header") {
|
|
129
|
+
return <Form.Item style={{ marginBottom: 8 }}>{props.control}</Form.Item>;
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<Form.Item
|
|
133
|
+
label={props.field.name ?? props.field.slug}
|
|
134
|
+
htmlFor={props.controlId}
|
|
135
|
+
required={props.required}
|
|
136
|
+
{...fieldError(props.error)}
|
|
137
|
+
>
|
|
138
|
+
{props.control}
|
|
139
|
+
</Form.Item>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function FieldControl(props: {
|
|
144
|
+
field: FormFieldDef;
|
|
145
|
+
bag: FormFillBag;
|
|
146
|
+
controlId: string;
|
|
147
|
+
}): ReactElement {
|
|
148
|
+
const { field, bag } = props;
|
|
149
|
+
// The ladder: a host's explicit registration outranks the skin's builtin.
|
|
150
|
+
const Widget =
|
|
151
|
+
resolveFormFieldWidget(field.kind) ?? BUILTIN_FIELD_WIDGETS[field.kind];
|
|
152
|
+
if (Widget === undefined) return <UnsupportedField field={field} />;
|
|
153
|
+
return (
|
|
154
|
+
<Widget
|
|
155
|
+
id={props.controlId}
|
|
156
|
+
field={field}
|
|
157
|
+
value={bag.values[field.slug]}
|
|
158
|
+
onChange={(value) => bag.setValue(field.slug, value)}
|
|
159
|
+
error={bag.fieldErrors[field.slug]}
|
|
160
|
+
disabled={bag.isSubmitting}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function Confirmation(props: ConfirmationSlotProps): ReactElement {
|
|
166
|
+
const t = useT();
|
|
167
|
+
const Slot = resolveFormsSkinComponent<ConfirmationSlotProps>(
|
|
168
|
+
"fill.confirmation"
|
|
169
|
+
);
|
|
170
|
+
if (Slot) return <Slot {...props} />;
|
|
171
|
+
return (
|
|
172
|
+
<Alert
|
|
173
|
+
type="success"
|
|
174
|
+
showIcon
|
|
175
|
+
data-testid="forms-confirmation"
|
|
176
|
+
message={
|
|
177
|
+
props.confirmation.length > 0
|
|
178
|
+
? props.confirmation
|
|
179
|
+
: t(FORMS_I18N_KEYS.fillThanks)
|
|
180
|
+
}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function SubmitBar(props: { bag: FormFillBag; label: string }): ReactElement {
|
|
186
|
+
const gate = useActionGate(props.bag.submit);
|
|
187
|
+
const Slot = resolveFormsSkinComponent<SubmitBarSlotProps>("fill.submitBar");
|
|
188
|
+
const slotProps: SubmitBarSlotProps = {
|
|
189
|
+
bag: props.bag,
|
|
190
|
+
label: props.label,
|
|
191
|
+
disabled: gate.disabled,
|
|
192
|
+
reason: gate.reason,
|
|
193
|
+
detail: gate.detail,
|
|
194
|
+
onSubmit: props.bag.doSubmit,
|
|
195
|
+
};
|
|
196
|
+
if (Slot) return <Slot {...slotProps} />;
|
|
197
|
+
return (
|
|
198
|
+
<Flex vertical gap={4} align="flex-start">
|
|
199
|
+
<Button
|
|
200
|
+
type="primary"
|
|
201
|
+
htmlType="submit"
|
|
202
|
+
loading={props.bag.isSubmitting}
|
|
203
|
+
disabled={gate.disabled}
|
|
204
|
+
data-analytics="flow"
|
|
205
|
+
data-testid="forms-submit"
|
|
206
|
+
onClick={props.bag.doSubmit}
|
|
207
|
+
>
|
|
208
|
+
{props.label}
|
|
209
|
+
</Button>
|
|
210
|
+
{/* The reason is TEXT, not a tooltip: a disabled button receives no
|
|
211
|
+
pointer events in any browser, so a tooltip on it is a reason
|
|
212
|
+
nobody can read (core's actionGate module header). */}
|
|
213
|
+
{gate.reason !== undefined && (
|
|
214
|
+
<Typography.Text type="secondary" data-testid="forms-submit-blocked">
|
|
215
|
+
{gate.reason}
|
|
216
|
+
</Typography.Text>
|
|
217
|
+
)}
|
|
218
|
+
{gate.detail !== undefined && (
|
|
219
|
+
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
220
|
+
{gate.detail}
|
|
221
|
+
</Typography.Text>
|
|
222
|
+
)}
|
|
223
|
+
</Flex>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface StapelFormProps extends ThemeModeProp {
|
|
228
|
+
/** The non-enumerable public token the host embeds. */
|
|
229
|
+
readonly publicId: string;
|
|
230
|
+
/** Show the schema's `meta.title` above the fields. Default true. */
|
|
231
|
+
readonly showTitle?: boolean;
|
|
232
|
+
/** Override the submit button's text (otherwise `meta.submit_label`, then
|
|
233
|
+
* the pair's own copy). */
|
|
234
|
+
readonly submitLabel?: string;
|
|
235
|
+
/**
|
|
236
|
+
* Rendered above the submit bar — the interactive-captcha seam. The netintel
|
|
237
|
+
* tier decides whether a token is needed at all, so this stays optional and
|
|
238
|
+
* the widget calls `setCaptchaToken` when it has one.
|
|
239
|
+
*/
|
|
240
|
+
readonly captcha?: (bag: FormFillBag) => ReactNode;
|
|
241
|
+
readonly onSubmitted?: (result: { readonly confirmation: string }) => void;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function StapelForm(props: StapelFormProps): ReactElement {
|
|
245
|
+
const t = useT();
|
|
246
|
+
const describe = useDescribeFlowError();
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<FormsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
250
|
+
<FormFill
|
|
251
|
+
publicId={props.publicId}
|
|
252
|
+
builtinKinds={BUILTIN_FIELD_KINDS}
|
|
253
|
+
{...(props.onSubmitted ? { onSubmitted: props.onSubmitted } : {})}
|
|
254
|
+
>
|
|
255
|
+
{(bag) => {
|
|
256
|
+
if (bag.submitted !== null) {
|
|
257
|
+
return <Confirmation confirmation={bag.submitted.confirmation} />;
|
|
258
|
+
}
|
|
259
|
+
return matchLoad(bag.state, {
|
|
260
|
+
loading: () => (
|
|
261
|
+
<Flex justify="center" style={{ padding: 24 }}>
|
|
262
|
+
<Spin data-testid="forms-loading" aria-label={t(FORMS_I18N_KEYS.fillLoading)} />
|
|
263
|
+
</Flex>
|
|
264
|
+
),
|
|
265
|
+
// The three-way split. `hasErrorCode` reads the API dialect, so
|
|
266
|
+
// this works whether the throw was a StapelApiError or a bare
|
|
267
|
+
// envelope — and a network fault, which carries NEITHER code,
|
|
268
|
+
// correctly falls through to "we could not ask".
|
|
269
|
+
failed: (error) => {
|
|
270
|
+
if (hasErrorCode(error, CODE_NOT_FOUND)) {
|
|
271
|
+
return (
|
|
272
|
+
<Alert
|
|
273
|
+
type="error"
|
|
274
|
+
showIcon
|
|
275
|
+
data-testid="forms-not-found"
|
|
276
|
+
message={t(FORMS_I18N_KEYS.fillNotFound)}
|
|
277
|
+
/>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (hasErrorCode(error, CODE_CLOSED)) {
|
|
281
|
+
return (
|
|
282
|
+
<Alert
|
|
283
|
+
type="info"
|
|
284
|
+
showIcon
|
|
285
|
+
data-testid="forms-closed"
|
|
286
|
+
message={t(FORMS_I18N_KEYS.fillClosed)}
|
|
287
|
+
/>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return (
|
|
291
|
+
<ErrorAlert
|
|
292
|
+
testId="forms-load-failed"
|
|
293
|
+
error={{
|
|
294
|
+
...describe(toFlowError(error)),
|
|
295
|
+
message: t(FORMS_I18N_KEYS.fillLoadFailed),
|
|
296
|
+
}}
|
|
297
|
+
action={
|
|
298
|
+
<Button size="small" onClick={bag.refetch} data-analytics="none"
|
|
299
|
+
data-analytics-reason="retry of a failed read; no flow to step">
|
|
300
|
+
{t(FORMS_I18N_KEYS.fillRetry)}
|
|
301
|
+
</Button>
|
|
302
|
+
}
|
|
303
|
+
/>
|
|
304
|
+
);
|
|
305
|
+
},
|
|
306
|
+
ready: (form) => {
|
|
307
|
+
const label =
|
|
308
|
+
props.submitLabel ??
|
|
309
|
+
form.meta.submit_label ??
|
|
310
|
+
t(FORMS_I18N_KEYS.fillSubmit);
|
|
311
|
+
return (
|
|
312
|
+
<Form
|
|
313
|
+
layout="vertical"
|
|
314
|
+
data-testid="forms-form"
|
|
315
|
+
onFinish={bag.doSubmit}
|
|
316
|
+
>
|
|
317
|
+
{props.showTitle !== false &&
|
|
318
|
+
form.meta.title !== undefined && (
|
|
319
|
+
<Typography.Title level={3}>
|
|
320
|
+
{form.meta.title}
|
|
321
|
+
</Typography.Title>
|
|
322
|
+
)}
|
|
323
|
+
{form.meta.description !== undefined && (
|
|
324
|
+
<Typography.Paragraph type="secondary">
|
|
325
|
+
{form.meta.description}
|
|
326
|
+
</Typography.Paragraph>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{/* A schema that changed under the person is announced, not
|
|
330
|
+
swapped in silently — they must re-read before resubmitting. */}
|
|
331
|
+
{bag.superseded && (
|
|
332
|
+
<Alert
|
|
333
|
+
type="warning"
|
|
334
|
+
showIcon
|
|
335
|
+
style={{ marginBottom: 16 }}
|
|
336
|
+
data-testid="forms-superseded"
|
|
337
|
+
message={t(FORMS_I18N_KEYS.fillSuperseded)}
|
|
338
|
+
/>
|
|
339
|
+
)}
|
|
340
|
+
|
|
341
|
+
{form.fields.map((field) => (
|
|
342
|
+
<FieldRow
|
|
343
|
+
key={field.slug}
|
|
344
|
+
field={field}
|
|
345
|
+
controlId={fieldControlId(field.slug)}
|
|
346
|
+
required={field.mandatory === true}
|
|
347
|
+
error={bag.fieldErrors[field.slug]}
|
|
348
|
+
control={
|
|
349
|
+
<FieldControl
|
|
350
|
+
field={field}
|
|
351
|
+
bag={bag}
|
|
352
|
+
controlId={fieldControlId(field.slug)}
|
|
353
|
+
/>
|
|
354
|
+
}
|
|
355
|
+
/>
|
|
356
|
+
))}
|
|
357
|
+
|
|
358
|
+
{bag.formError !== null && (
|
|
359
|
+
<ErrorAlert
|
|
360
|
+
testId="forms-form-error"
|
|
361
|
+
style={{ marginBottom: 16 }}
|
|
362
|
+
error={describe(toFlowError(bag.formError))}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
365
|
+
|
|
366
|
+
{props.captcha?.(bag)}
|
|
367
|
+
|
|
368
|
+
<SubmitBar bag={bag} label={label} />
|
|
369
|
+
</Form>
|
|
370
|
+
);
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}}
|
|
374
|
+
</FormFill>
|
|
375
|
+
</FormsSkinTheme>
|
|
376
|
+
);
|
|
377
|
+
}
|