@stapel/attributes-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/README.md +123 -0
- package/dist/default/FeatureBadges.d.ts +33 -0
- package/dist/default/FeatureBadges.d.ts.map +1 -0
- package/dist/default/FeatureBadges.js +56 -0
- package/dist/default/FeatureBadges.js.map +1 -0
- package/dist/default/FeatureFields.d.ts +52 -0
- package/dist/default/FeatureFields.d.ts.map +1 -0
- package/dist/default/FeatureFields.js +60 -0
- package/dist/default/FeatureFields.js.map +1 -0
- package/dist/default/editors.d.ts +24 -0
- package/dist/default/editors.d.ts.map +1 -0
- package/dist/default/editors.js +354 -0
- package/dist/default/editors.js.map +1 -0
- package/dist/default/index.d.ts +27 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +25 -0
- package/dist/default/index.js.map +1 -0
- package/dist/dto.d.ts +40 -0
- package/dist/dto.d.ts.map +1 -0
- package/dist/dto.js +64 -0
- package/dist/dto.js.map +1 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +98 -0
- package/dist/errors.js.map +1 -0
- package/dist/format.d.ts +46 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +159 -0
- package/dist/format.js.map +1 -0
- package/dist/i18n/es.d.ts +13 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +36 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/keys.d.ts +56 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +80 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +19 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +42 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +122 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +82 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +35 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +88 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +410 -0
- package/dist/validate.js.map +1 -0
- package/manifest.json +96 -0
- package/package.json +107 -0
- package/src/default/FeatureBadges.tsx +123 -0
- package/src/default/FeatureFields.tsx +140 -0
- package/src/default/editors.tsx +578 -0
- package/src/default/index.ts +34 -0
- package/src/dto.ts +78 -0
- package/src/errors.ts +127 -0
- package/src/format.ts +210 -0
- package/src/i18n/es.ts +41 -0
- package/src/i18n/keys.ts +89 -0
- package/src/i18n/ru.ts +48 -0
- package/src/index.ts +98 -0
- package/src/registry.ts +167 -0
- package/src/types.ts +166 -0
- package/src/validate.ts +507 -0
- package/tsconfig.json +26 -0
package/src/validate.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side validation MIRROR — instant feedback, never a verdict.
|
|
3
|
+
*
|
|
4
|
+
* "Client-side validation mirrors, server decides." Every rule here is
|
|
5
|
+
* derived from the feature's own `config` so a person sees a problem as they
|
|
6
|
+
* type instead of after a round trip; none of it is trusted. The server
|
|
7
|
+
* re-runs `stapel_attributes.validate_dto_structured` on
|
|
8
|
+
* `POST /categories/{pk}/validate-dto/` and again on
|
|
9
|
+
* `POST /listings/{pk}/publish/`, and its `ValidationBatchResult` is the one
|
|
10
|
+
* that counts. This function returns the SAME shape so a server answer can
|
|
11
|
+
* replace a mirrored one with no translation step.
|
|
12
|
+
*
|
|
13
|
+
* ── Two contract details that are easy to get subtly wrong ─────────────────
|
|
14
|
+
*
|
|
15
|
+
* 1. **`pattern` matches the WHOLE value.** The engine uses `re.fullmatch`,
|
|
16
|
+
* and the admin JS mirrors it as `^(?:<pattern>)$`
|
|
17
|
+
* (`stapel-attributes/MODULE.md`, "Pattern contract"). A bare
|
|
18
|
+
* `RegExp.test` is a PREFIX match, so `^\d{4}$`-less patterns would pass
|
|
19
|
+
* here and fail there — the mirror telling a person their input is fine
|
|
20
|
+
* right before the server refuses it.
|
|
21
|
+
*
|
|
22
|
+
* 2. **String length is counted in Unicode CODE POINTS**, on both sides —
|
|
23
|
+
* same source. JavaScript's `String.length` counts UTF-16 code units, so
|
|
24
|
+
* one emoji is 2 and one astral CJK ideograph is 2. A `maxLength: 10`
|
|
25
|
+
* field would refuse ten emoji locally and accept them server-side.
|
|
26
|
+
*
|
|
27
|
+
* ── What the mirror deliberately does NOT judge ────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* - **A value type it does not know.** An unknown `config.type` may be a
|
|
30
|
+
* perfectly valid `EXTRA_TYPES` registration whose rules live only in
|
|
31
|
+
* Python. The mirror runs the type-independent checks (mandatory/empty) and
|
|
32
|
+
* leaves the rest to the server — a mirror that invented a refusal for a
|
|
33
|
+
* type it cannot read would block a submit the backend would have accepted.
|
|
34
|
+
* Whether such a type can be DRAWN is a separate question, answered loudly
|
|
35
|
+
* by `unsupportedTypes`/`unsupportedTypeGate`.
|
|
36
|
+
*
|
|
37
|
+
* - **`convertible_unit` range.** `min`/`max` are expressed in the unit
|
|
38
|
+
* family's canonical base unit and the value is converted before comparison
|
|
39
|
+
* (`stapel_attributes.types.convertible_unit`), and the family table is
|
|
40
|
+
* Python-side. Mirroring the comparison without the conversion would report
|
|
41
|
+
* "too large" for a perfectly good number in the other unit system. The
|
|
42
|
+
* number and the unit code ARE checked; the range is the server's.
|
|
43
|
+
*/
|
|
44
|
+
import type {
|
|
45
|
+
FeatureDef,
|
|
46
|
+
FeatureValidationResult,
|
|
47
|
+
FeaturesDto,
|
|
48
|
+
FeatureValueDto,
|
|
49
|
+
ValidationBatchResult,
|
|
50
|
+
ValidationErrorCode,
|
|
51
|
+
} from "./types.js";
|
|
52
|
+
import { featureConfig, featureName, featureType } from "./types.js";
|
|
53
|
+
import { ERROR_CODE_TO_KEY } from "./errors.js";
|
|
54
|
+
|
|
55
|
+
/** `stapel_attributes.types.hex_color.constants.SIMPLE_COLORS` — the closed
|
|
56
|
+
* set of colour categories a `hex_color` value must name. */
|
|
57
|
+
export const SIMPLE_COLORS: readonly string[] = [
|
|
58
|
+
"black",
|
|
59
|
+
"white",
|
|
60
|
+
"gray",
|
|
61
|
+
"silver",
|
|
62
|
+
"red",
|
|
63
|
+
"pink",
|
|
64
|
+
"orange",
|
|
65
|
+
"yellow",
|
|
66
|
+
"green",
|
|
67
|
+
"blue",
|
|
68
|
+
"purple",
|
|
69
|
+
"brown",
|
|
70
|
+
"gold",
|
|
71
|
+
"beige",
|
|
72
|
+
"turquoise",
|
|
73
|
+
"clear",
|
|
74
|
+
"multicolor",
|
|
75
|
+
"custom",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
|
79
|
+
const TRUE_STRINGS = new Set(["true", "1", "yes", "on"]);
|
|
80
|
+
const FALSE_STRINGS = new Set(["false", "0", "no", "off"]);
|
|
81
|
+
|
|
82
|
+
/** What a rule reports: the engine's machine code plus the constraint that
|
|
83
|
+
* was violated, exactly as `FeatureValidationError` carries them. */
|
|
84
|
+
interface Refusal {
|
|
85
|
+
readonly code: ValidationErrorCode;
|
|
86
|
+
readonly ref_value?: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Length in Unicode code points — the unit BOTH sides count in. */
|
|
90
|
+
export function codePointLength(text: string): number {
|
|
91
|
+
return [...text].length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Does `pattern` match the WHOLE of `text`? `re.fullmatch`, in JavaScript.
|
|
96
|
+
*
|
|
97
|
+
* A pattern JS cannot compile (a Python-only construct) is NOT the person's
|
|
98
|
+
* problem: the mirror stands down and lets the server, which compiled it, be
|
|
99
|
+
* the one to refuse. The `u` flag is tried first because it is what makes `.`
|
|
100
|
+
* and quantifiers count code points rather than surrogate halves, and dropped
|
|
101
|
+
* when a pattern is only valid without it.
|
|
102
|
+
*/
|
|
103
|
+
export function patternFullMatch(pattern: string, text: string): boolean | undefined {
|
|
104
|
+
const anchored = `^(?:${pattern})$`;
|
|
105
|
+
for (const flags of ["u", ""]) {
|
|
106
|
+
try {
|
|
107
|
+
return new RegExp(anchored, flags).test(text);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function num(config: Readonly<Record<string, unknown>>, key: string): number | undefined {
|
|
116
|
+
const raw = config[key];
|
|
117
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function list(config: Readonly<Record<string, unknown>>, key: string): readonly unknown[] {
|
|
121
|
+
const raw = config[key];
|
|
122
|
+
return Array.isArray(raw) ? raw : [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** An option's value, from either shape the engine's types allow (a bare
|
|
126
|
+
* scalar, or an object with `value`). */
|
|
127
|
+
function optionValue(option: unknown): unknown {
|
|
128
|
+
if (option !== null && typeof option === "object" && "value" in option) {
|
|
129
|
+
return (option as { value: unknown }).value;
|
|
130
|
+
}
|
|
131
|
+
return option;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* "Nothing was entered" — the one shape the mandatory rule fires on, and the
|
|
136
|
+
* engine's own predicate: `raw_value is None or raw_value == '' or
|
|
137
|
+
* raw_value == []` (`validation.py`). An explicit `false` is an ANSWER for a
|
|
138
|
+
* `bool`, and `0` is an answer for an `int`, which is why this is not a
|
|
139
|
+
* falsiness check.
|
|
140
|
+
*/
|
|
141
|
+
export function isBlank(value: unknown): boolean {
|
|
142
|
+
if (value === undefined || value === null) return true;
|
|
143
|
+
if (typeof value === "string") return value === "";
|
|
144
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── per-type rules ───────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function validateString(
|
|
151
|
+
config: Readonly<Record<string, unknown>>,
|
|
152
|
+
value: unknown
|
|
153
|
+
): Refusal | undefined {
|
|
154
|
+
const text = typeof value === "string" ? value : String(value);
|
|
155
|
+
const length = codePointLength(text);
|
|
156
|
+
const minLength = num(config, "minLength");
|
|
157
|
+
const maxLength = num(config, "maxLength");
|
|
158
|
+
if (minLength !== undefined && length < minLength) {
|
|
159
|
+
return { code: "below_minimum", ref_value: minLength };
|
|
160
|
+
}
|
|
161
|
+
if (maxLength !== undefined && length > maxLength) {
|
|
162
|
+
return { code: "above_maximum", ref_value: maxLength };
|
|
163
|
+
}
|
|
164
|
+
const pattern = config["pattern"];
|
|
165
|
+
if (typeof pattern === "string" && pattern.length > 0) {
|
|
166
|
+
const matched = patternFullMatch(pattern, text);
|
|
167
|
+
if (matched === false) return { code: "invalid_format", ref_value: pattern };
|
|
168
|
+
}
|
|
169
|
+
const options = list(config, "options");
|
|
170
|
+
// `allowCustom` absent means TRUE for `string` (the dataclass default), so
|
|
171
|
+
// an options list without an explicit `allowCustom: false` constrains
|
|
172
|
+
// nothing. Reading an absent key as "closed set" would refuse values the
|
|
173
|
+
// server accepts.
|
|
174
|
+
if (options.length > 0 && config["allowCustom"] === false) {
|
|
175
|
+
if (!options.some((option) => optionValue(option) === text)) {
|
|
176
|
+
return { code: "not_in_options", ref_value: [...options].map(optionValue) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateNumber(
|
|
183
|
+
config: Readonly<Record<string, unknown>>,
|
|
184
|
+
value: unknown,
|
|
185
|
+
isInt: boolean
|
|
186
|
+
): Refusal | undefined {
|
|
187
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
188
|
+
if (typeof value === "boolean" || !Number.isFinite(parsed)) {
|
|
189
|
+
return { code: "invalid_type" };
|
|
190
|
+
}
|
|
191
|
+
// `int`'s normalizer is Python's `int()`, which TRUNCATES toward zero
|
|
192
|
+
// rather than refusing a fractional input — so the range check runs on the
|
|
193
|
+
// truncated number, exactly as it does server-side.
|
|
194
|
+
const number = isInt ? Math.trunc(parsed) : parsed;
|
|
195
|
+
const min = num(config, "min");
|
|
196
|
+
const max = num(config, "max");
|
|
197
|
+
if (min !== undefined && number < min) return { code: "below_minimum", ref_value: min };
|
|
198
|
+
if (max !== undefined && number > max) return { code: "above_maximum", ref_value: max };
|
|
199
|
+
const options = list(config, "options");
|
|
200
|
+
if (options.length > 0 && config["allowCustom"] === false) {
|
|
201
|
+
const precision = isInt ? 0 : (num(config, "precision") ?? 2);
|
|
202
|
+
const round = (n: number): number => Number(n.toFixed(precision));
|
|
203
|
+
if (!options.some((option) => round(Number(optionValue(option))) === round(number))) {
|
|
204
|
+
return { code: "not_in_options", ref_value: [...options].map(optionValue) };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validateBool(value: unknown): Refusal | undefined {
|
|
211
|
+
if (typeof value === "boolean") return undefined;
|
|
212
|
+
if (typeof value === "number") return undefined; // bool(n), server-side
|
|
213
|
+
if (typeof value === "string") {
|
|
214
|
+
const lower = value.toLowerCase();
|
|
215
|
+
return TRUE_STRINGS.has(lower) || FALSE_STRINGS.has(lower)
|
|
216
|
+
? undefined
|
|
217
|
+
: { code: "invalid_type" };
|
|
218
|
+
}
|
|
219
|
+
return { code: "invalid_type" };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function validateSelect(
|
|
223
|
+
config: Readonly<Record<string, unknown>>,
|
|
224
|
+
value: unknown
|
|
225
|
+
): Refusal | undefined {
|
|
226
|
+
if (!Array.isArray(value)) return { code: "invalid_type" };
|
|
227
|
+
const minSelected = num(config, "minSelected") ?? 0;
|
|
228
|
+
const maxSelected = num(config, "maxSelected");
|
|
229
|
+
if (value.length < minSelected) {
|
|
230
|
+
return { code: "below_minimum", ref_value: minSelected };
|
|
231
|
+
}
|
|
232
|
+
if (maxSelected !== undefined && value.length > maxSelected) {
|
|
233
|
+
return { code: "above_maximum", ref_value: maxSelected };
|
|
234
|
+
}
|
|
235
|
+
if (new Set(value).size !== value.length) return { code: "invalid_format" };
|
|
236
|
+
const allowed = list(config, "options").map((option) => optionValue(option));
|
|
237
|
+
for (const item of value) {
|
|
238
|
+
if (typeof item !== "string") return { code: "invalid_type" };
|
|
239
|
+
if (!allowed.includes(item)) {
|
|
240
|
+
return {
|
|
241
|
+
code: "not_in_options",
|
|
242
|
+
ref_value: [...allowed].map(String).sort(),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function validateDate(
|
|
250
|
+
config: Readonly<Record<string, unknown>>,
|
|
251
|
+
value: unknown
|
|
252
|
+
): Refusal | undefined {
|
|
253
|
+
// The engine's `normalize_dto` coerces int/float/numeric-string to an int
|
|
254
|
+
// and turns ANYTHING else into `None`, which its `validate_dto` then
|
|
255
|
+
// accepts. So a garbage date is silently dropped rather than refused — a
|
|
256
|
+
// known upstream wart, mirrored rather than "improved", because a mirror
|
|
257
|
+
// that refuses what the server accepts is a mirror that blocks a valid
|
|
258
|
+
// submit. (`stapel_attributes.types.date.type.DateFeatureType`.)
|
|
259
|
+
const timestamp =
|
|
260
|
+
typeof value === "number" && Number.isFinite(value)
|
|
261
|
+
? Math.trunc(value)
|
|
262
|
+
: typeof value === "string" && /^-?\d+$/.test(value.trim())
|
|
263
|
+
? Number.parseInt(value.trim(), 10)
|
|
264
|
+
: undefined;
|
|
265
|
+
if (timestamp === undefined) return undefined;
|
|
266
|
+
const now = Math.floor(Date.now() / 1000);
|
|
267
|
+
if (config["allowFuture"] === false && timestamp > now) {
|
|
268
|
+
return { code: "above_maximum", ref_value: now };
|
|
269
|
+
}
|
|
270
|
+
if (config["allowPast"] === false && timestamp < now) {
|
|
271
|
+
return { code: "below_minimum", ref_value: now };
|
|
272
|
+
}
|
|
273
|
+
const minDate = num(config, "minDate");
|
|
274
|
+
const maxDate = num(config, "maxDate");
|
|
275
|
+
if (minDate !== undefined && timestamp < minDate) {
|
|
276
|
+
return { code: "below_minimum", ref_value: minDate };
|
|
277
|
+
}
|
|
278
|
+
if (maxDate !== undefined && timestamp > maxDate) {
|
|
279
|
+
return { code: "above_maximum", ref_value: maxDate };
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function validateHexColor(
|
|
285
|
+
config: Readonly<Record<string, unknown>>,
|
|
286
|
+
value: unknown
|
|
287
|
+
): Refusal | undefined {
|
|
288
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
289
|
+
return { code: "invalid_type" };
|
|
290
|
+
}
|
|
291
|
+
const entry = value as { hex?: unknown; simple?: unknown };
|
|
292
|
+
if (entry.hex !== undefined && entry.hex !== null) {
|
|
293
|
+
if (typeof entry.hex !== "string") return { code: "invalid_type" };
|
|
294
|
+
const hex = entry.hex.trim();
|
|
295
|
+
if (hex.length > 0 && !HEX_PATTERN.test(hex)) return { code: "invalid_format" };
|
|
296
|
+
}
|
|
297
|
+
if (entry.simple === undefined || entry.simple === null || entry.simple === "") {
|
|
298
|
+
return { code: "invalid_format" };
|
|
299
|
+
}
|
|
300
|
+
if (typeof entry.simple !== "string") return { code: "invalid_type" };
|
|
301
|
+
if (!SIMPLE_COLORS.includes(entry.simple)) {
|
|
302
|
+
return { code: "not_in_options", ref_value: [...SIMPLE_COLORS] };
|
|
303
|
+
}
|
|
304
|
+
const options = list(config, "options");
|
|
305
|
+
if (options.length > 0 && config["allowCustom"] !== true) {
|
|
306
|
+
const matched = options.some(
|
|
307
|
+
(option) =>
|
|
308
|
+
option !== null &&
|
|
309
|
+
typeof option === "object" &&
|
|
310
|
+
(option as { simple?: unknown }).simple === entry.simple
|
|
311
|
+
);
|
|
312
|
+
if (!matched) {
|
|
313
|
+
return {
|
|
314
|
+
code: "not_in_options",
|
|
315
|
+
ref_value: options.map((option) =>
|
|
316
|
+
option !== null && typeof option === "object"
|
|
317
|
+
? (option as { simple?: unknown }).simple
|
|
318
|
+
: option
|
|
319
|
+
),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function findOption(
|
|
327
|
+
options: readonly unknown[],
|
|
328
|
+
value: string
|
|
329
|
+
): { readonly children?: unknown } | undefined {
|
|
330
|
+
for (const option of options) {
|
|
331
|
+
if (option !== null && typeof option === "object") {
|
|
332
|
+
if ((option as { value?: unknown }).value === value) {
|
|
333
|
+
return option as { children?: unknown };
|
|
334
|
+
}
|
|
335
|
+
} else if (option === value) {
|
|
336
|
+
return {};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function validateHierarchicalSelect(
|
|
343
|
+
config: Readonly<Record<string, unknown>>,
|
|
344
|
+
value: unknown
|
|
345
|
+
): Refusal | undefined {
|
|
346
|
+
if (!Array.isArray(value)) return { code: "invalid_type" };
|
|
347
|
+
if (value.length === 0) return undefined; // the empty pre-check already ruled
|
|
348
|
+
const minDepth = num(config, "minDepth") ?? 1;
|
|
349
|
+
const maxDepth = num(config, "maxDepth");
|
|
350
|
+
if (value.length < minDepth) return { code: "below_minimum", ref_value: minDepth };
|
|
351
|
+
if (maxDepth !== undefined && value.length > maxDepth) {
|
|
352
|
+
return { code: "above_maximum", ref_value: maxDepth };
|
|
353
|
+
}
|
|
354
|
+
let level: readonly unknown[] = list(config, "options");
|
|
355
|
+
for (const step of value) {
|
|
356
|
+
if (typeof step !== "string") return { code: "invalid_type" };
|
|
357
|
+
const option = findOption(level, step);
|
|
358
|
+
if (option === undefined) {
|
|
359
|
+
return { code: "not_in_options", ref_value: level.map(optionValue) };
|
|
360
|
+
}
|
|
361
|
+
level = Array.isArray(option.children) ? option.children : [];
|
|
362
|
+
}
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function validateConvertibleUnit(
|
|
367
|
+
config: Readonly<Record<string, unknown>>,
|
|
368
|
+
dto: FeatureValueDto
|
|
369
|
+
): Refusal | undefined {
|
|
370
|
+
if (dto.value !== null && dto.value !== undefined) {
|
|
371
|
+
const parsed = typeof dto.value === "number" ? dto.value : Number(dto.value);
|
|
372
|
+
if (typeof dto.value === "boolean" || !Number.isFinite(parsed)) {
|
|
373
|
+
return { code: "invalid_type" };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const unit = dto["unit"];
|
|
377
|
+
if (unit === undefined || unit === null) return undefined;
|
|
378
|
+
const allowed = [config["unit_m"], config["unit_i"]].filter(
|
|
379
|
+
(code): code is string => typeof code === "string" && code.length > 0
|
|
380
|
+
);
|
|
381
|
+
if (!allowed.includes(String(unit))) {
|
|
382
|
+
return { code: "not_in_options", ref_value: allowed };
|
|
383
|
+
}
|
|
384
|
+
// `min`/`max` are in the family's BASE unit and the conversion table is
|
|
385
|
+
// server-side — see this module's header.
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Validate one submitted value against its feature's config. Returns the
|
|
391
|
+
* mirrored refusal, or `undefined` when this side of the wire is satisfied.
|
|
392
|
+
*
|
|
393
|
+
* `header` is never validated: the engine regenerates a header's DAO from its
|
|
394
|
+
* config and skips it outright in the batch validator, so a header has
|
|
395
|
+
* nothing to check and must never carry a value.
|
|
396
|
+
*/
|
|
397
|
+
export function validateFeatureValue(
|
|
398
|
+
feature: FeatureDef,
|
|
399
|
+
dto: FeatureValueDto
|
|
400
|
+
): FeatureValidationResult | undefined {
|
|
401
|
+
const type = featureType(feature);
|
|
402
|
+
if (type === "header") return undefined;
|
|
403
|
+
const config = featureConfig(feature);
|
|
404
|
+
|
|
405
|
+
if (isBlank(dto.value)) {
|
|
406
|
+
return feature.mandatory === true
|
|
407
|
+
? failed(feature, { code: "mandatory_missing" })
|
|
408
|
+
: ok(feature);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const refusal = ((): Refusal | undefined => {
|
|
412
|
+
switch (type) {
|
|
413
|
+
case "string":
|
|
414
|
+
return validateString(config, dto.value);
|
|
415
|
+
case "int":
|
|
416
|
+
return validateNumber(config, dto.value, true);
|
|
417
|
+
case "float":
|
|
418
|
+
return validateNumber(config, dto.value, false);
|
|
419
|
+
case "bool":
|
|
420
|
+
return validateBool(dto.value);
|
|
421
|
+
case "select":
|
|
422
|
+
return validateSelect(config, dto.value);
|
|
423
|
+
case "date":
|
|
424
|
+
return validateDate(config, dto.value);
|
|
425
|
+
case "hex_color":
|
|
426
|
+
return validateHexColor(config, dto.value);
|
|
427
|
+
case "hierarchical_select":
|
|
428
|
+
return validateHierarchicalSelect(config, dto.value);
|
|
429
|
+
case "convertible_unit":
|
|
430
|
+
return validateConvertibleUnit(config, dto);
|
|
431
|
+
default:
|
|
432
|
+
// An unknown type is the server's to judge — see the module header.
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
})();
|
|
436
|
+
|
|
437
|
+
return refusal === undefined ? ok(feature) : failed(feature, refusal);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function ok(feature: FeatureDef): FeatureValidationResult {
|
|
441
|
+
return {
|
|
442
|
+
slug: feature.slug,
|
|
443
|
+
status: "ok",
|
|
444
|
+
...(feature.id === undefined || feature.id === null ? {} : { id: feature.id }),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function failed(feature: FeatureDef, refusal: Refusal): FeatureValidationResult {
|
|
449
|
+
return {
|
|
450
|
+
slug: feature.slug,
|
|
451
|
+
status: "validation_failed",
|
|
452
|
+
error: refusal.code,
|
|
453
|
+
localizable_error: ERROR_CODE_TO_KEY[refusal.code],
|
|
454
|
+
// The engine's own params, verbatim: `{feature, slug}`. `field` is added
|
|
455
|
+
// later, by `featureErrorsBySlug`, so the mirror's rows and the server's
|
|
456
|
+
// rows go through the same one step.
|
|
457
|
+
params: { feature: featureName(feature), slug: feature.slug },
|
|
458
|
+
...(feature.id === undefined || feature.id === null ? {} : { id: feature.id }),
|
|
459
|
+
...(refusal.ref_value === undefined ? {} : { ref_value: refusal.ref_value }),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Validate a whole answer set against a category's features — the client-side
|
|
465
|
+
* twin of `POST /categories/{pk}/validate-dto/`.
|
|
466
|
+
*
|
|
467
|
+
* Mirrors the engine's two passes and their order, because the order is what
|
|
468
|
+
* a caller sees: first every SUBMITTED entry whose slug the category allows
|
|
469
|
+
* (an unknown slug is ignored, not refused — the engine's documented
|
|
470
|
+
* behaviour), then every allowed feature that was never submitted, of which
|
|
471
|
+
* only a mandatory non-header one produces a row.
|
|
472
|
+
*/
|
|
473
|
+
export function mirrorValidate(
|
|
474
|
+
features: readonly FeatureDef[],
|
|
475
|
+
dto: FeaturesDto
|
|
476
|
+
): ValidationBatchResult {
|
|
477
|
+
const bySlug = new Map<string, FeatureDef>();
|
|
478
|
+
for (const feature of features) {
|
|
479
|
+
bySlug.set(feature.slug, feature);
|
|
480
|
+
if (feature.id !== undefined && feature.id !== null) {
|
|
481
|
+
bySlug.set(String(feature.id), feature);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const results: FeatureValidationResult[] = [];
|
|
486
|
+
const seen = new Set<string>();
|
|
487
|
+
|
|
488
|
+
for (const [key, entry] of Object.entries(dto)) {
|
|
489
|
+
const feature = bySlug.get(key);
|
|
490
|
+
if (feature === undefined) continue; // unknown slug — ignored, per the engine
|
|
491
|
+
seen.add(feature.slug);
|
|
492
|
+
const result = validateFeatureValue(feature, entry);
|
|
493
|
+
if (result !== undefined) results.push(result);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
for (const feature of features) {
|
|
497
|
+
if (seen.has(feature.slug)) continue;
|
|
498
|
+
if (featureType(feature) === "header") continue;
|
|
499
|
+
if (feature.mandatory !== true) continue;
|
|
500
|
+
results.push(failed(feature, { code: "mandatory_missing" }));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
valid: results.every((result) => result.status === "ok"),
|
|
505
|
+
results,
|
|
506
|
+
};
|
|
507
|
+
}
|
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
|
+
}
|