@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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The field-widget registry — THE customer seam of this pair, and the direct
|
|
3
|
+
* descendant of docs-react's editor registry.
|
|
4
|
+
*
|
|
5
|
+
* A form schema's fields are `stapel_attributes` `FeatureDef`s, and that type
|
|
6
|
+
* vocabulary is an OPEN registry server-side (builtins →
|
|
7
|
+
* `STAPEL_ATTRIBUTES["EXTRA_TYPES"]` → runtime `register_feature_type`). A
|
|
8
|
+
* host that adds a type on the backend must be able to render it on the front
|
|
9
|
+
* without forking the skin:
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* registerFormFieldWidget("signature", SignaturePad); // at startup
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* ── Resolution, and what happens when nothing matches ──────────────────────
|
|
16
|
+
*
|
|
17
|
+
* This module answers only the FIRST rung: an explicit registration, or
|
|
18
|
+
* `null`. The `/default` skin completes the ladder — explicit registration >
|
|
19
|
+
* skin builtin (antd) > **unsupported-field notice** — mirroring
|
|
20
|
+
* docs-react's `DocSurface`, where an explicit `registerDocEditor` outranks
|
|
21
|
+
* the skin's own styled default so a host's swap is never shadowed.
|
|
22
|
+
*
|
|
23
|
+
* The last rung is a notice, deliberately, and never a skipped field. A
|
|
24
|
+
* schema can legally contain a kind this build has no widget for; rendering
|
|
25
|
+
* nothing would silently drop a field that may be REQUIRED, and the person
|
|
26
|
+
* would submit a form they could not complete and be told, by the server,
|
|
27
|
+
* that a field they never saw is missing. `<FormFill>` therefore also blocks
|
|
28
|
+
* the submit with `forms.unsupported_kind` while such a field is present —
|
|
29
|
+
* degrade loudly (spec §7.2).
|
|
30
|
+
*
|
|
31
|
+
* The registry lives in the main entry, not in `/default`, so a host building
|
|
32
|
+
* its own renderer over `<FormFill>` uses the same seam as the skin does
|
|
33
|
+
* rather than a parallel one.
|
|
34
|
+
*/
|
|
35
|
+
import type { ComponentType } from "react";
|
|
36
|
+
import type { FlowError } from "@stapel/core";
|
|
37
|
+
import type { FormFieldDef } from "../api/types.js";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What every field widget receives. One field, one value, one setter, one
|
|
41
|
+
* error — the widget never touches the API layer or the rest of the form.
|
|
42
|
+
*/
|
|
43
|
+
export interface FormFieldWidgetProps {
|
|
44
|
+
/** The field's `FeatureDef`. `field.config` carries the kind's camelCase
|
|
45
|
+
* options (`maxLength`, `minSelected`, `precision`, `multiline`, …). */
|
|
46
|
+
readonly field: FormFieldDef;
|
|
47
|
+
/** The current answer, or `undefined` while unanswered. */
|
|
48
|
+
readonly value: unknown;
|
|
49
|
+
/** Report a new answer. Pass the bare scalar — `select` normalizes to a
|
|
50
|
+
* list server-side, so a single choice need not be wrapped. */
|
|
51
|
+
onChange(value: unknown): void;
|
|
52
|
+
/** The field's current refusal (client mirror or server verdict), or
|
|
53
|
+
* `undefined`. */
|
|
54
|
+
readonly error: FlowError | undefined;
|
|
55
|
+
/** True while a submit is in flight — widgets should go read-only. */
|
|
56
|
+
readonly disabled: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* DOM id the widget MUST put on its primary control. The skin's field row
|
|
59
|
+
* points its `<label for>` at this, so the label actually names the input
|
|
60
|
+
* for a screen reader (and for a click). A widget that drops it renders an
|
|
61
|
+
* unlabelled control — which antd's `Form.Item` cannot detect and will
|
|
62
|
+
* happily draw a label beside.
|
|
63
|
+
*/
|
|
64
|
+
readonly id: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type FormFieldWidget = ComponentType<FormFieldWidgetProps>;
|
|
68
|
+
|
|
69
|
+
const registered = new Map<string, FormFieldWidget>();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register (or override) the widget for a field kind. Call at startup, before
|
|
73
|
+
* the first render — the registry is module-global, like the i18n bundle
|
|
74
|
+
* registration and docs-react's editor registry.
|
|
75
|
+
*/
|
|
76
|
+
export function registerFormFieldWidget(
|
|
77
|
+
kind: string,
|
|
78
|
+
widget: FormFieldWidget
|
|
79
|
+
): void {
|
|
80
|
+
registered.set(kind, widget);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Remove an explicit registration (the skin's builtin, if any, resolves
|
|
84
|
+
* again). */
|
|
85
|
+
export function unregisterFormFieldWidget(kind: string): void {
|
|
86
|
+
registered.delete(kind);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The EXPLICIT registration for a kind, or `null`. Named for what it is: the
|
|
91
|
+
* skin calls this FIRST and only falls back to its own builtin when it
|
|
92
|
+
* returns `null`, so a host registration always wins.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveFormFieldWidget(kind: string): FormFieldWidget | null {
|
|
95
|
+
return registered.get(kind) ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every kind with an explicit registration, sorted. */
|
|
99
|
+
export function registeredFormFieldKinds(): readonly string[] {
|
|
100
|
+
return [...registered.keys()].sort();
|
|
101
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side validation MIRROR — instant feedback, never a verdict.
|
|
3
|
+
*
|
|
4
|
+
* "Client-side validation mirrors, server decides" (spec §7.2). Every rule
|
|
5
|
+
* here is derived from the schema's own `config` so the person sees a problem
|
|
6
|
+
* as they type instead of after a round trip; none of it is trusted. The
|
|
7
|
+
* server re-runs `stapel_attributes.validate_dto_structured` on every submit
|
|
8
|
+
* and its answer is the one that counts.
|
|
9
|
+
*
|
|
10
|
+
* ── Why the mirror emits the SERVER's error keys ───────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* Each rule below reports the exact `error.400.feature_*` key
|
|
13
|
+
* (`stapel_attributes.errors.ERROR_CODE_TO_KEY`) the backend would have
|
|
14
|
+
* returned for the same input, with the same `{feature}`/`{field}` params. So
|
|
15
|
+
* a "too long" caught locally and a "too long" caught by the server render
|
|
16
|
+
* the SAME sentence in the SAME language. The alternative — pair-invented
|
|
17
|
+
* copy for the local half — gives the person two different wordings for one
|
|
18
|
+
* problem and makes the local one a lie the moment the backend's rule moves.
|
|
19
|
+
*
|
|
20
|
+
* ── camelCase, and the cap that does not exist ─────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* Config keys are camelCase (`maxLength`, not `max_length`) — backend delta
|
|
23
|
+
* note 1. A type's dataclass parser drops keys it does not know, so a
|
|
24
|
+
* snake_case typo is a constraint that silently does not exist. `publish`
|
|
25
|
+
* refuses those server-side; this mirror simply reads the camelCase names and
|
|
26
|
+
* therefore agrees with the engine by construction.
|
|
27
|
+
*/
|
|
28
|
+
import type { FlowError } from "@stapel/core";
|
|
29
|
+
import type { FormFieldDef } from "../api/types.js";
|
|
30
|
+
|
|
31
|
+
/** The `error.400.feature_*` keys this mirror can raise — a subset of
|
|
32
|
+
* `stapel_attributes.errors.ATTRIBUTES_ERRORS`, listed so the i18n bundle can
|
|
33
|
+
* be checked against it. */
|
|
34
|
+
export const FEATURE_ERROR_CODES: readonly string[] = [
|
|
35
|
+
"error.400.feature_below_minimum",
|
|
36
|
+
"error.400.feature_above_maximum",
|
|
37
|
+
"error.400.feature_not_in_options",
|
|
38
|
+
"error.400.feature_invalid_type",
|
|
39
|
+
"error.400.feature_invalid_format",
|
|
40
|
+
"error.400.feature_mandatory_missing",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function featureError(
|
|
44
|
+
field: FormFieldDef,
|
|
45
|
+
code: string,
|
|
46
|
+
extra: Readonly<Record<string, unknown>> = {}
|
|
47
|
+
): FlowError {
|
|
48
|
+
return {
|
|
49
|
+
code,
|
|
50
|
+
// `field` is what the fleet's `useFieldError` routes on; `slug` and
|
|
51
|
+
// `feature` are what the attributes catalogue's message templates
|
|
52
|
+
// interpolate. The adapter keeps all three server-side (delta note 1), so
|
|
53
|
+
// the mirror does too.
|
|
54
|
+
params: {
|
|
55
|
+
field: field.slug,
|
|
56
|
+
slug: field.slug,
|
|
57
|
+
feature: field.name ?? field.slug,
|
|
58
|
+
...extra,
|
|
59
|
+
},
|
|
60
|
+
status: 400,
|
|
61
|
+
message: undefined,
|
|
62
|
+
language: undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function num(config: Readonly<Record<string, unknown>>, key: string): number | undefined {
|
|
67
|
+
const raw = config[key];
|
|
68
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The declared choice values of an options-bearing config, or `undefined`
|
|
72
|
+
* when the kind has none. Accepts both the bare-scalar and the
|
|
73
|
+
* `{value, label}` option shapes the attributes types allow. */
|
|
74
|
+
export function optionValues(
|
|
75
|
+
field: FormFieldDef
|
|
76
|
+
): readonly unknown[] | undefined {
|
|
77
|
+
const raw = (field.config ?? {})["options"];
|
|
78
|
+
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
|
79
|
+
return raw.map((option) =>
|
|
80
|
+
option !== null && typeof option === "object" && "value" in option
|
|
81
|
+
? (option as { value: unknown }).value
|
|
82
|
+
: option
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when the answer is "nothing was entered" — the one shape the
|
|
87
|
+
* mandatory rule fires on. An explicit `false` is an ANSWER for a `bool`, not
|
|
88
|
+
* an absence, which is why this is not a falsiness check. */
|
|
89
|
+
export function isBlank(value: unknown): boolean {
|
|
90
|
+
if (value === undefined || value === null) return true;
|
|
91
|
+
if (typeof value === "string") return value.trim().length === 0;
|
|
92
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate one answer against its field's config. Returns the mirrored
|
|
98
|
+
* refusal, or `undefined` when this side of the wire is satisfied.
|
|
99
|
+
*
|
|
100
|
+
* `header` fields are skipped outright: the engine regenerates a header's DAO
|
|
101
|
+
* from its config and REJECTS an answer to one (backend delta note 1), so a
|
|
102
|
+
* header has nothing to validate and must never be given a value.
|
|
103
|
+
*/
|
|
104
|
+
export function validateFieldValue(
|
|
105
|
+
field: FormFieldDef,
|
|
106
|
+
value: unknown
|
|
107
|
+
): FlowError | undefined {
|
|
108
|
+
if (field.kind === "header") return undefined;
|
|
109
|
+
|
|
110
|
+
const config = field.config ?? {};
|
|
111
|
+
|
|
112
|
+
if (isBlank(value)) {
|
|
113
|
+
return field.mandatory === true
|
|
114
|
+
? featureError(field, "error.400.feature_mandatory_missing")
|
|
115
|
+
: // An optional field left blank is complete, and no later rule may
|
|
116
|
+
// fire on an absent value — a `minLength` must not turn "did not
|
|
117
|
+
// answer" into "answered too short".
|
|
118
|
+
undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── choices ────────────────────────────────────────────────────────────
|
|
122
|
+
const options = optionValues(field);
|
|
123
|
+
if (options !== undefined && config["allowCustom"] !== true) {
|
|
124
|
+
const answers = Array.isArray(value) ? value : [value];
|
|
125
|
+
const stray = answers.find(
|
|
126
|
+
(answer) => !options.some((option) => option === answer)
|
|
127
|
+
);
|
|
128
|
+
if (stray !== undefined) {
|
|
129
|
+
return featureError(field, "error.400.feature_not_in_options", {
|
|
130
|
+
ref_value: stray,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── multi-select cardinality ───────────────────────────────────────────
|
|
136
|
+
if (field.kind === "select") {
|
|
137
|
+
const count = Array.isArray(value) ? value.length : 1;
|
|
138
|
+
const minSelected = num(config, "minSelected");
|
|
139
|
+
const maxSelected = num(config, "maxSelected");
|
|
140
|
+
if (minSelected !== undefined && count < minSelected) {
|
|
141
|
+
return featureError(field, "error.400.feature_below_minimum", {
|
|
142
|
+
min: minSelected,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (maxSelected !== undefined && count > maxSelected) {
|
|
146
|
+
return featureError(field, "error.400.feature_above_maximum", {
|
|
147
|
+
max: maxSelected,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── numeric range ──────────────────────────────────────────────────────
|
|
154
|
+
if (field.kind === "int" || field.kind === "float") {
|
|
155
|
+
const asNumber = typeof value === "number" ? value : Number(value);
|
|
156
|
+
if (!Number.isFinite(asNumber)) {
|
|
157
|
+
return featureError(field, "error.400.feature_invalid_type");
|
|
158
|
+
}
|
|
159
|
+
if (field.kind === "int" && !Number.isInteger(asNumber)) {
|
|
160
|
+
return featureError(field, "error.400.feature_invalid_type");
|
|
161
|
+
}
|
|
162
|
+
const min = num(config, "min");
|
|
163
|
+
const max = num(config, "max");
|
|
164
|
+
if (min !== undefined && asNumber < min) {
|
|
165
|
+
return featureError(field, "error.400.feature_below_minimum", { min });
|
|
166
|
+
}
|
|
167
|
+
if (max !== undefined && asNumber > max) {
|
|
168
|
+
return featureError(field, "error.400.feature_above_maximum", { max });
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── string length + pattern ────────────────────────────────────────────
|
|
174
|
+
if (field.kind === "string") {
|
|
175
|
+
const text = String(value);
|
|
176
|
+
const minLength = num(config, "minLength");
|
|
177
|
+
const maxLength = num(config, "maxLength");
|
|
178
|
+
if (minLength !== undefined && text.length < minLength) {
|
|
179
|
+
return featureError(field, "error.400.feature_below_minimum", {
|
|
180
|
+
min_length: minLength,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (maxLength !== undefined && text.length > maxLength) {
|
|
184
|
+
return featureError(field, "error.400.feature_above_maximum", {
|
|
185
|
+
max_length: maxLength,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const pattern = config["pattern"];
|
|
189
|
+
if (typeof pattern === "string" && pattern.length > 0) {
|
|
190
|
+
let re: RegExp | undefined;
|
|
191
|
+
try {
|
|
192
|
+
re = new RegExp(pattern);
|
|
193
|
+
} catch {
|
|
194
|
+
// An admin-authored pattern that JS cannot compile (a Python-only
|
|
195
|
+
// construct) is NOT the respondent's problem: skip the mirror and
|
|
196
|
+
// let the server, which compiled it, be the one to refuse.
|
|
197
|
+
re = undefined;
|
|
198
|
+
}
|
|
199
|
+
if (re !== undefined && !re.test(text)) {
|
|
200
|
+
return featureError(field, "error.400.feature_invalid_format");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate a whole answer set. Returns `{slug: FlowError}` for the fields that
|
|
211
|
+
* failed — the exact shape `FormFillBag.fieldErrors` carries, so a server
|
|
212
|
+
* response can be merged into it without a translation step.
|
|
213
|
+
*/
|
|
214
|
+
export function validateAnswers(
|
|
215
|
+
fields: readonly FormFieldDef[],
|
|
216
|
+
values: Readonly<Record<string, unknown>>
|
|
217
|
+
): Readonly<Record<string, FlowError>> {
|
|
218
|
+
const errors: Record<string, FlowError> = {};
|
|
219
|
+
for (const field of fields) {
|
|
220
|
+
const error = validateFieldValue(field, values[field.slug]);
|
|
221
|
+
if (error !== undefined) errors[field.slug] = error;
|
|
222
|
+
}
|
|
223
|
+
return errors;
|
|
224
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"noImplicitOverride": true,
|
|
13
|
+
"exactOptionalPropertyTypes": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"isolatedDeclarations": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"forceConsistentCasingInFileNames": true,
|
|
22
|
+
"outDir": "dist",
|
|
23
|
+
"rootDir": "src"
|
|
24
|
+
},
|
|
25
|
+
"include": ["src"]
|
|
26
|
+
}
|