@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,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<FormBuilderPane>` — the workspace admin's authoring surface.
|
|
3
|
+
*
|
|
4
|
+
* The §8 verdict in one component: form authoring is a REACT surface over
|
|
5
|
+
* capability-gated REST, because in this fleet "admin" means a workspace
|
|
6
|
+
* role and only REST reaches a workspace role — Django admin is a staff-only
|
|
7
|
+
* peephole that the actual form authors cannot open.
|
|
8
|
+
*
|
|
9
|
+
* The §8 scope discipline is visible in the code: there is no per-kind form
|
|
10
|
+
* anywhere here. A field's options come from `widgets/configForms.ts` and are
|
|
11
|
+
* drawn by `<ConfigField>`, chosen by config-widget kind. "Rich per-kind
|
|
12
|
+
* config editors and drag-reorder polish are explicitly allowed to be ugly in
|
|
13
|
+
* v1" — so reordering is two buttons, not a drag surface.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactElement } from "react";
|
|
16
|
+
import {
|
|
17
|
+
Alert,
|
|
18
|
+
Button,
|
|
19
|
+
Card,
|
|
20
|
+
Collapse,
|
|
21
|
+
Empty,
|
|
22
|
+
Flex,
|
|
23
|
+
Form,
|
|
24
|
+
Input,
|
|
25
|
+
Select,
|
|
26
|
+
Space,
|
|
27
|
+
Spin,
|
|
28
|
+
Switch,
|
|
29
|
+
Tag,
|
|
30
|
+
Typography,
|
|
31
|
+
} from "antd";
|
|
32
|
+
import {
|
|
33
|
+
matchList,
|
|
34
|
+
matchLoad,
|
|
35
|
+
toFlowError,
|
|
36
|
+
useActionGate,
|
|
37
|
+
useDescribeFlowError,
|
|
38
|
+
useT,
|
|
39
|
+
} from "@stapel/core";
|
|
40
|
+
import type { ConfigFieldSpec, FormRow, FormState } from "../api/types.js";
|
|
41
|
+
import { FormBuilder } from "../headless/FormBuilder.js";
|
|
42
|
+
import type { BuilderField, FormBuilderBag } from "../headless/FormBuilder.js";
|
|
43
|
+
import { ConfigField } from "./ConfigField.js";
|
|
44
|
+
import { FormsSkinTheme } from "./theme.js";
|
|
45
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
46
|
+
import { resolveFormsSkinComponent } from "./slots.js";
|
|
47
|
+
import type { ThemeModeProp } from "./types.js";
|
|
48
|
+
import { FORMS_I18N_KEYS } from "../i18n/keys.js";
|
|
49
|
+
|
|
50
|
+
/** Props of the `"builder.toolbar"` slot. */
|
|
51
|
+
export interface BuilderToolbarSlotProps {
|
|
52
|
+
readonly bag: FormBuilderBag;
|
|
53
|
+
readonly row: FormRow;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Props of the `"builder.fieldRow"` slot. */
|
|
57
|
+
export interface BuilderFieldRowSlotProps {
|
|
58
|
+
readonly entry: BuilderField;
|
|
59
|
+
readonly index: number;
|
|
60
|
+
readonly total: number;
|
|
61
|
+
readonly bag: FormBuilderBag;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function FieldEditor(props: BuilderFieldRowSlotProps): ReactElement {
|
|
65
|
+
const t = useT();
|
|
66
|
+
const Slot = resolveFormsSkinComponent<BuilderFieldRowSlotProps>(
|
|
67
|
+
"builder.fieldRow"
|
|
68
|
+
);
|
|
69
|
+
if (Slot) return <Slot {...props} />;
|
|
70
|
+
|
|
71
|
+
const { entry, index, total, bag } = props;
|
|
72
|
+
const { field } = entry;
|
|
73
|
+
const config = field.config ?? {};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Card
|
|
77
|
+
size="small"
|
|
78
|
+
data-testid={`forms-builder-field-${field.slug}`}
|
|
79
|
+
title={
|
|
80
|
+
<Flex align="center" gap={8}>
|
|
81
|
+
<Tag>{field.kind}</Tag>
|
|
82
|
+
<Typography.Text strong>{field.name || field.slug}</Typography.Text>
|
|
83
|
+
</Flex>
|
|
84
|
+
}
|
|
85
|
+
extra={
|
|
86
|
+
<Space>
|
|
87
|
+
<Button
|
|
88
|
+
size="small"
|
|
89
|
+
disabled={index === 0}
|
|
90
|
+
data-analytics="none"
|
|
91
|
+
data-analytics-reason="local draft reorder; the save mutation is the tracked step"
|
|
92
|
+
onClick={() => bag.moveField(field.slug, index - 1)}
|
|
93
|
+
>
|
|
94
|
+
{t(FORMS_I18N_KEYS.builderMoveUp)}
|
|
95
|
+
</Button>
|
|
96
|
+
<Button
|
|
97
|
+
size="small"
|
|
98
|
+
disabled={index === total - 1}
|
|
99
|
+
data-analytics="none"
|
|
100
|
+
data-analytics-reason="local draft reorder; the save mutation is the tracked step"
|
|
101
|
+
onClick={() => bag.moveField(field.slug, index + 1)}
|
|
102
|
+
>
|
|
103
|
+
{t(FORMS_I18N_KEYS.builderMoveDown)}
|
|
104
|
+
</Button>
|
|
105
|
+
<Button
|
|
106
|
+
size="small"
|
|
107
|
+
danger
|
|
108
|
+
data-analytics="none"
|
|
109
|
+
data-analytics-reason="local draft edit; the save mutation is the tracked step"
|
|
110
|
+
onClick={() => bag.removeField(field.slug)}
|
|
111
|
+
>
|
|
112
|
+
{t(FORMS_I18N_KEYS.builderRemoveField)}
|
|
113
|
+
</Button>
|
|
114
|
+
</Space>
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
<Form layout="vertical" size="small">
|
|
118
|
+
<Form.Item label={t(FORMS_I18N_KEYS.builderFieldSlug)}>
|
|
119
|
+
<Input
|
|
120
|
+
value={field.slug}
|
|
121
|
+
onChange={(event) =>
|
|
122
|
+
bag.updateField(field.slug, { slug: event.target.value })
|
|
123
|
+
}
|
|
124
|
+
/>
|
|
125
|
+
</Form.Item>
|
|
126
|
+
<Form.Item label={t(FORMS_I18N_KEYS.builderFieldLabel)}>
|
|
127
|
+
<Input
|
|
128
|
+
value={field.name ?? ""}
|
|
129
|
+
onChange={(event) =>
|
|
130
|
+
bag.updateField(field.slug, { name: event.target.value })
|
|
131
|
+
}
|
|
132
|
+
/>
|
|
133
|
+
</Form.Item>
|
|
134
|
+
{/* A header is a caption, so "required" would be meaningless on it. */}
|
|
135
|
+
{field.kind !== "header" && (
|
|
136
|
+
<Form.Item label={t(FORMS_I18N_KEYS.builderFieldRequired)}>
|
|
137
|
+
<Switch
|
|
138
|
+
checked={field.mandatory === true}
|
|
139
|
+
onChange={(checked) =>
|
|
140
|
+
bag.updateField(field.slug, { mandatory: checked })
|
|
141
|
+
}
|
|
142
|
+
/>
|
|
143
|
+
</Form.Item>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{entry.builderLess ? (
|
|
147
|
+
<Alert
|
|
148
|
+
type="info"
|
|
149
|
+
showIcon
|
|
150
|
+
data-testid="forms-builder-less"
|
|
151
|
+
// Two different facts, worded differently: the registry does not
|
|
152
|
+
// carry this kind at all, versus it carries it and it simply has
|
|
153
|
+
// no options. Collapsing them would tell an admin their schema is
|
|
154
|
+
// fine when the deployment cannot render the field.
|
|
155
|
+
message={t(
|
|
156
|
+
entry.kindInfo === undefined || entry.kindInfo.registered === false
|
|
157
|
+
? FORMS_I18N_KEYS.builderKindUnregistered
|
|
158
|
+
: FORMS_I18N_KEYS.builderBuilderLess
|
|
159
|
+
)}
|
|
160
|
+
/>
|
|
161
|
+
) : (
|
|
162
|
+
<Collapse
|
|
163
|
+
size="small"
|
|
164
|
+
ghost
|
|
165
|
+
items={[
|
|
166
|
+
{
|
|
167
|
+
key: "config",
|
|
168
|
+
label: t(FORMS_I18N_KEYS.builderFieldKind),
|
|
169
|
+
children: (
|
|
170
|
+
<>
|
|
171
|
+
{entry.configFields.map((spec: ConfigFieldSpec) => (
|
|
172
|
+
<Form.Item key={spec.name} label={spec.name}>
|
|
173
|
+
<ConfigField
|
|
174
|
+
spec={spec}
|
|
175
|
+
value={config[spec.name]}
|
|
176
|
+
disabled={false}
|
|
177
|
+
onChange={(next) =>
|
|
178
|
+
bag.setFieldConfig(field.slug, spec.name, next)
|
|
179
|
+
}
|
|
180
|
+
/>
|
|
181
|
+
</Form.Item>
|
|
182
|
+
))}
|
|
183
|
+
</>
|
|
184
|
+
),
|
|
185
|
+
},
|
|
186
|
+
]}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
</Form>
|
|
190
|
+
</Card>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function Toolbar(props: BuilderToolbarSlotProps): ReactElement {
|
|
195
|
+
const t = useT();
|
|
196
|
+
const saveGate = useActionGate(props.bag.save);
|
|
197
|
+
const publishGate = useActionGate(props.bag.publish);
|
|
198
|
+
const Slot = resolveFormsSkinComponent<BuilderToolbarSlotProps>(
|
|
199
|
+
"builder.toolbar"
|
|
200
|
+
);
|
|
201
|
+
if (Slot) return <Slot {...props} />;
|
|
202
|
+
|
|
203
|
+
const { bag, row } = props;
|
|
204
|
+
const stateLabel: Readonly<Record<FormState, string>> = {
|
|
205
|
+
draft: t(FORMS_I18N_KEYS.builderStateDraft),
|
|
206
|
+
open: t(FORMS_I18N_KEYS.builderStateOpen),
|
|
207
|
+
closed: t(FORMS_I18N_KEYS.builderStateClosed),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Flex vertical gap={8}>
|
|
212
|
+
<Flex gap={8} wrap align="center">
|
|
213
|
+
<Button
|
|
214
|
+
type="default"
|
|
215
|
+
loading={bag.isSaving}
|
|
216
|
+
disabled={saveGate.disabled}
|
|
217
|
+
data-analytics="flow"
|
|
218
|
+
data-testid="forms-builder-save"
|
|
219
|
+
onClick={bag.doSave}
|
|
220
|
+
>
|
|
221
|
+
{t(FORMS_I18N_KEYS.builderSave)}
|
|
222
|
+
</Button>
|
|
223
|
+
<Button
|
|
224
|
+
type="primary"
|
|
225
|
+
loading={bag.isPublishing}
|
|
226
|
+
disabled={publishGate.disabled}
|
|
227
|
+
data-analytics="flow"
|
|
228
|
+
data-testid="forms-builder-publish"
|
|
229
|
+
onClick={bag.doPublish}
|
|
230
|
+
>
|
|
231
|
+
{t(FORMS_I18N_KEYS.builderPublish)}
|
|
232
|
+
</Button>
|
|
233
|
+
<Select<FormState>
|
|
234
|
+
style={{ width: 140 }}
|
|
235
|
+
value={row.state as FormState}
|
|
236
|
+
onChange={(next) => bag.setState(next)}
|
|
237
|
+
options={(["draft", "open", "closed"] as const).map((s) => ({
|
|
238
|
+
value: s,
|
|
239
|
+
label: stateLabel[s],
|
|
240
|
+
}))}
|
|
241
|
+
/>
|
|
242
|
+
<Button
|
|
243
|
+
data-analytics="flow"
|
|
244
|
+
data-testid="forms-builder-rotate"
|
|
245
|
+
onClick={bag.rotateLink}
|
|
246
|
+
>
|
|
247
|
+
{t(FORMS_I18N_KEYS.builderRotateLink)}
|
|
248
|
+
</Button>
|
|
249
|
+
</Flex>
|
|
250
|
+
{/* Reasons as text beside the controls, never as tooltips on disabled
|
|
251
|
+
buttons — those receive no pointer events. */}
|
|
252
|
+
{saveGate.reason !== undefined && (
|
|
253
|
+
<Typography.Text type="secondary">{saveGate.reason}</Typography.Text>
|
|
254
|
+
)}
|
|
255
|
+
{publishGate.reason !== undefined && (
|
|
256
|
+
<Typography.Text type="secondary" data-testid="forms-publish-blocked">
|
|
257
|
+
{publishGate.reason}
|
|
258
|
+
</Typography.Text>
|
|
259
|
+
)}
|
|
260
|
+
<Typography.Text type="secondary" copyable={{ text: row.public_id }}>
|
|
261
|
+
{t(FORMS_I18N_KEYS.builderPublicLink)}: {row.public_id}
|
|
262
|
+
</Typography.Text>
|
|
263
|
+
</Flex>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface FormBuilderPaneProps extends ThemeModeProp {
|
|
268
|
+
readonly workspaceId: string;
|
|
269
|
+
readonly formId: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function FormBuilderPane(props: FormBuilderPaneProps): ReactElement {
|
|
273
|
+
const t = useT();
|
|
274
|
+
const describe = useDescribeFlowError();
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<FormsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
278
|
+
<FormBuilder workspaceId={props.workspaceId} formId={props.formId}>
|
|
279
|
+
{(bag) =>
|
|
280
|
+
matchLoad(bag.state, {
|
|
281
|
+
loading: () => (
|
|
282
|
+
<Flex justify="center" style={{ padding: 24 }}>
|
|
283
|
+
<Spin data-testid="forms-builder-loading" />
|
|
284
|
+
</Flex>
|
|
285
|
+
),
|
|
286
|
+
// Never "this form does not exist" — a failed read is a failed
|
|
287
|
+
// read, and it offers a retry.
|
|
288
|
+
failed: (error) => (
|
|
289
|
+
<ErrorAlert
|
|
290
|
+
testId="forms-builder-failed"
|
|
291
|
+
error={describe(toFlowError(error))}
|
|
292
|
+
action={
|
|
293
|
+
<Button
|
|
294
|
+
size="small"
|
|
295
|
+
onClick={bag.refetch}
|
|
296
|
+
data-analytics="none"
|
|
297
|
+
data-analytics-reason="retry of a failed read; no flow to step"
|
|
298
|
+
>
|
|
299
|
+
{t(FORMS_I18N_KEYS.fillRetry)}
|
|
300
|
+
</Button>
|
|
301
|
+
}
|
|
302
|
+
/>
|
|
303
|
+
),
|
|
304
|
+
ready: (row) => (
|
|
305
|
+
<Flex vertical gap={16}>
|
|
306
|
+
<Typography.Title level={4}>
|
|
307
|
+
{t(FORMS_I18N_KEYS.builderTitle)}
|
|
308
|
+
</Typography.Title>
|
|
309
|
+
|
|
310
|
+
<Toolbar bag={bag} row={row} />
|
|
311
|
+
|
|
312
|
+
{bag.error !== null && (
|
|
313
|
+
<ErrorAlert
|
|
314
|
+
testId="forms-builder-error"
|
|
315
|
+
error={describe(toFlowError(bag.error))}
|
|
316
|
+
/>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
<Form layout="vertical">
|
|
320
|
+
<Form.Item label={t(FORMS_I18N_KEYS.builderMetaTitle)}>
|
|
321
|
+
<Input
|
|
322
|
+
value={bag.meta.title ?? ""}
|
|
323
|
+
onChange={(event) =>
|
|
324
|
+
bag.setMeta({ title: event.target.value })
|
|
325
|
+
}
|
|
326
|
+
/>
|
|
327
|
+
</Form.Item>
|
|
328
|
+
<Form.Item label={t(FORMS_I18N_KEYS.builderMetaDescription)}>
|
|
329
|
+
<Input.TextArea
|
|
330
|
+
autoSize={{ minRows: 2, maxRows: 5 }}
|
|
331
|
+
value={bag.meta.description ?? ""}
|
|
332
|
+
onChange={(event) =>
|
|
333
|
+
bag.setMeta({ description: event.target.value })
|
|
334
|
+
}
|
|
335
|
+
/>
|
|
336
|
+
</Form.Item>
|
|
337
|
+
<Form.Item label={t(FORMS_I18N_KEYS.builderMetaSubmitLabel)}>
|
|
338
|
+
<Input
|
|
339
|
+
value={bag.meta.submit_label ?? ""}
|
|
340
|
+
onChange={(event) =>
|
|
341
|
+
bag.setMeta({ submit_label: event.target.value })
|
|
342
|
+
}
|
|
343
|
+
/>
|
|
344
|
+
</Form.Item>
|
|
345
|
+
<Form.Item label={t(FORMS_I18N_KEYS.builderMetaConfirmation)}>
|
|
346
|
+
<Input
|
|
347
|
+
value={bag.meta.confirmation_text ?? ""}
|
|
348
|
+
onChange={(event) =>
|
|
349
|
+
bag.setMeta({ confirmation_text: event.target.value })
|
|
350
|
+
}
|
|
351
|
+
/>
|
|
352
|
+
</Form.Item>
|
|
353
|
+
</Form>
|
|
354
|
+
|
|
355
|
+
{bag.fields.length === 0 ? (
|
|
356
|
+
<Empty description={t(FORMS_I18N_KEYS.builderEmpty)} />
|
|
357
|
+
) : (
|
|
358
|
+
<Flex vertical gap={12}>
|
|
359
|
+
{bag.fields.map((entry, index) => (
|
|
360
|
+
<FieldEditor
|
|
361
|
+
key={entry.field.slug}
|
|
362
|
+
entry={entry}
|
|
363
|
+
index={index}
|
|
364
|
+
total={bag.fields.length}
|
|
365
|
+
bag={bag}
|
|
366
|
+
/>
|
|
367
|
+
))}
|
|
368
|
+
</Flex>
|
|
369
|
+
)}
|
|
370
|
+
|
|
371
|
+
{/* The catalogue is a LoadState of its own: a builder whose
|
|
372
|
+
dictionary failed to load must say so, not render zero
|
|
373
|
+
buttons and imply this deployment has no field kinds. */}
|
|
374
|
+
{matchList(bag.availableKinds, {
|
|
375
|
+
loading: () => <Spin size="small" data-testid="forms-kinds-loading" />,
|
|
376
|
+
failed: (error) => (
|
|
377
|
+
<ErrorAlert
|
|
378
|
+
testId="forms-kinds-failed"
|
|
379
|
+
error={{
|
|
380
|
+
...describe(toFlowError(error)),
|
|
381
|
+
message: t(FORMS_I18N_KEYS.builderKindsFailed),
|
|
382
|
+
}}
|
|
383
|
+
/>
|
|
384
|
+
),
|
|
385
|
+
empty: () => (
|
|
386
|
+
<Typography.Text type="secondary" data-testid="forms-kinds-empty">
|
|
387
|
+
{t(FORMS_I18N_KEYS.builderNoKinds)}
|
|
388
|
+
</Typography.Text>
|
|
389
|
+
),
|
|
390
|
+
ready: (kinds) => (
|
|
391
|
+
<Flex gap={8} wrap>
|
|
392
|
+
{kinds.map((kind) => (
|
|
393
|
+
<Button
|
|
394
|
+
key={kind.kind}
|
|
395
|
+
size="small"
|
|
396
|
+
data-analytics="none"
|
|
397
|
+
data-analytics-reason="local draft edit; the save mutation is the tracked step"
|
|
398
|
+
data-testid={`forms-builder-add-${kind.kind}`}
|
|
399
|
+
onClick={() => bag.addField(kind.kind)}
|
|
400
|
+
>
|
|
401
|
+
{t(FORMS_I18N_KEYS.builderAddField)}: {kind.kind}
|
|
402
|
+
</Button>
|
|
403
|
+
))}
|
|
404
|
+
</Flex>
|
|
405
|
+
),
|
|
406
|
+
})}
|
|
407
|
+
</Flex>
|
|
408
|
+
),
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
</FormBuilder>
|
|
412
|
+
</FormsSkinTheme>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<FormsListPane>` — the admin surface's entry point: the workspace's forms,
|
|
3
|
+
* and the button that makes another one.
|
|
4
|
+
*
|
|
5
|
+
* Rendered through core's `matchList`, whose FOUR required arms are the point:
|
|
6
|
+
* "no forms in this workspace yet" is reachable only from a load that
|
|
7
|
+
* actually succeeded, so that sentence can never be said about an outage.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReactElement } from "react";
|
|
10
|
+
import { Button, Empty, Flex, List, Segmented, Spin, Tag, Typography } from "antd";
|
|
11
|
+
import {
|
|
12
|
+
matchList,
|
|
13
|
+
toFlowError,
|
|
14
|
+
useDescribeFlowError,
|
|
15
|
+
useT,
|
|
16
|
+
} from "@stapel/core";
|
|
17
|
+
import type { FormRow, FormState } from "../api/types.js";
|
|
18
|
+
import { FormList } from "../headless/FormList.js";
|
|
19
|
+
import { FormsSkinTheme } from "./theme.js";
|
|
20
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
21
|
+
import type { ThemeModeProp } from "./types.js";
|
|
22
|
+
import { FORMS_I18N_KEYS } from "../i18n/keys.js";
|
|
23
|
+
|
|
24
|
+
export interface FormsListPaneProps extends ThemeModeProp {
|
|
25
|
+
readonly workspaceId: string;
|
|
26
|
+
/** Called when a row is activated — the host routes to the builder. */
|
|
27
|
+
readonly onOpen?: (form: FormRow) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function FormsListPane(props: FormsListPaneProps): ReactElement {
|
|
31
|
+
const t = useT();
|
|
32
|
+
const describe = useDescribeFlowError();
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<FormsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
36
|
+
<FormList
|
|
37
|
+
workspaceId={props.workspaceId}
|
|
38
|
+
{...(props.onOpen ? { onCreated: props.onOpen } : {})}
|
|
39
|
+
>
|
|
40
|
+
{(bag) => (
|
|
41
|
+
<Flex vertical gap={16}>
|
|
42
|
+
<Flex justify="space-between" align="center" wrap gap={8}>
|
|
43
|
+
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
44
|
+
{t(FORMS_I18N_KEYS.listTitle)}
|
|
45
|
+
</Typography.Title>
|
|
46
|
+
<Flex gap={8} align="center">
|
|
47
|
+
<Segmented<string>
|
|
48
|
+
value={bag.filter ?? "all"}
|
|
49
|
+
onChange={(next) =>
|
|
50
|
+
bag.setFilter(next === "all" ? null : (next as FormState))
|
|
51
|
+
}
|
|
52
|
+
options={[
|
|
53
|
+
{ value: "all", label: t(FORMS_I18N_KEYS.responsesAllVersions) },
|
|
54
|
+
{ value: "draft", label: t(FORMS_I18N_KEYS.builderStateDraft) },
|
|
55
|
+
{ value: "open", label: t(FORMS_I18N_KEYS.builderStateOpen) },
|
|
56
|
+
{ value: "closed", label: t(FORMS_I18N_KEYS.builderStateClosed) },
|
|
57
|
+
]}
|
|
58
|
+
/>
|
|
59
|
+
<Button
|
|
60
|
+
type="primary"
|
|
61
|
+
loading={bag.isCreating}
|
|
62
|
+
data-analytics="flow"
|
|
63
|
+
data-testid="forms-list-create"
|
|
64
|
+
onClick={() =>
|
|
65
|
+
bag.create({ title: t(FORMS_I18N_KEYS.listNewTitle) })
|
|
66
|
+
}
|
|
67
|
+
>
|
|
68
|
+
{t(FORMS_I18N_KEYS.listCreate)}
|
|
69
|
+
</Button>
|
|
70
|
+
</Flex>
|
|
71
|
+
</Flex>
|
|
72
|
+
|
|
73
|
+
{bag.error !== null && (
|
|
74
|
+
<ErrorAlert
|
|
75
|
+
testId="forms-list-error"
|
|
76
|
+
error={describe(toFlowError(bag.error))}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{matchList(bag.state, {
|
|
81
|
+
loading: () => (
|
|
82
|
+
<Flex justify="center" style={{ padding: 24 }}>
|
|
83
|
+
<Spin data-testid="forms-list-loading" />
|
|
84
|
+
</Flex>
|
|
85
|
+
),
|
|
86
|
+
failed: (error) => (
|
|
87
|
+
<ErrorAlert
|
|
88
|
+
testId="forms-list-failed"
|
|
89
|
+
error={{
|
|
90
|
+
...describe(toFlowError(error)),
|
|
91
|
+
message: t(FORMS_I18N_KEYS.listLoadFailed),
|
|
92
|
+
}}
|
|
93
|
+
action={
|
|
94
|
+
<Button
|
|
95
|
+
size="small"
|
|
96
|
+
onClick={bag.refetch}
|
|
97
|
+
data-analytics="none"
|
|
98
|
+
data-analytics-reason="retry of a failed read; no flow to step"
|
|
99
|
+
>
|
|
100
|
+
{t(FORMS_I18N_KEYS.fillRetry)}
|
|
101
|
+
</Button>
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
),
|
|
105
|
+
empty: () => (
|
|
106
|
+
<Empty
|
|
107
|
+
data-testid="forms-list-empty"
|
|
108
|
+
description={t(FORMS_I18N_KEYS.listEmpty)}
|
|
109
|
+
/>
|
|
110
|
+
),
|
|
111
|
+
ready: (rows) => (
|
|
112
|
+
<List<FormRow>
|
|
113
|
+
data-testid="forms-list"
|
|
114
|
+
dataSource={[...rows]}
|
|
115
|
+
renderItem={(row) => (
|
|
116
|
+
<List.Item
|
|
117
|
+
key={row.id}
|
|
118
|
+
onClick={() => props.onOpen?.(row)}
|
|
119
|
+
// Navigation into the builder, not a tracked outcome:
|
|
120
|
+
// the host owns its own routing analytics, and this
|
|
121
|
+
// pair emitting a funnel step for "opened a row" would
|
|
122
|
+
// put a forms.* event in somebody else's funnel.
|
|
123
|
+
data-analytics="none"
|
|
124
|
+
data-analytics-reason="host-owned navigation; no forms flow is stepped"
|
|
125
|
+
style={{ cursor: props.onOpen ? "pointer" : "default" }}
|
|
126
|
+
>
|
|
127
|
+
<List.Item.Meta
|
|
128
|
+
title={row.title}
|
|
129
|
+
description={t(FORMS_I18N_KEYS.listSubmissionCount, {
|
|
130
|
+
count: row.submission_count ?? 0,
|
|
131
|
+
})}
|
|
132
|
+
/>
|
|
133
|
+
<Tag>{row.state}</Tag>
|
|
134
|
+
</List.Item>
|
|
135
|
+
)}
|
|
136
|
+
/>
|
|
137
|
+
),
|
|
138
|
+
})}
|
|
139
|
+
</Flex>
|
|
140
|
+
)}
|
|
141
|
+
</FormList>
|
|
142
|
+
</FormsSkinTheme>
|
|
143
|
+
);
|
|
144
|
+
}
|