@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.
Files changed (74) hide show
  1. package/README.md +123 -0
  2. package/dist/default/FeatureBadges.d.ts +33 -0
  3. package/dist/default/FeatureBadges.d.ts.map +1 -0
  4. package/dist/default/FeatureBadges.js +56 -0
  5. package/dist/default/FeatureBadges.js.map +1 -0
  6. package/dist/default/FeatureFields.d.ts +52 -0
  7. package/dist/default/FeatureFields.d.ts.map +1 -0
  8. package/dist/default/FeatureFields.js +60 -0
  9. package/dist/default/FeatureFields.js.map +1 -0
  10. package/dist/default/editors.d.ts +24 -0
  11. package/dist/default/editors.d.ts.map +1 -0
  12. package/dist/default/editors.js +354 -0
  13. package/dist/default/editors.js.map +1 -0
  14. package/dist/default/index.d.ts +27 -0
  15. package/dist/default/index.d.ts.map +1 -0
  16. package/dist/default/index.js +25 -0
  17. package/dist/default/index.js.map +1 -0
  18. package/dist/dto.d.ts +40 -0
  19. package/dist/dto.d.ts.map +1 -0
  20. package/dist/dto.js +64 -0
  21. package/dist/dto.js.map +1 -0
  22. package/dist/errors.d.ts +57 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +98 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/format.d.ts +46 -0
  27. package/dist/format.d.ts.map +1 -0
  28. package/dist/format.js +159 -0
  29. package/dist/format.js.map +1 -0
  30. package/dist/i18n/es.d.ts +13 -0
  31. package/dist/i18n/es.d.ts.map +1 -0
  32. package/dist/i18n/es.js +36 -0
  33. package/dist/i18n/es.js.map +1 -0
  34. package/dist/i18n/keys.d.ts +56 -0
  35. package/dist/i18n/keys.d.ts.map +1 -0
  36. package/dist/i18n/keys.js +80 -0
  37. package/dist/i18n/keys.js.map +1 -0
  38. package/dist/i18n/ru.d.ts +19 -0
  39. package/dist/i18n/ru.d.ts.map +1 -0
  40. package/dist/i18n/ru.js +42 -0
  41. package/dist/i18n/ru.js.map +1 -0
  42. package/dist/index.d.ts +58 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +54 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/registry.d.ts +122 -0
  47. package/dist/registry.d.ts.map +1 -0
  48. package/dist/registry.js +82 -0
  49. package/dist/registry.js.map +1 -0
  50. package/dist/types.d.ts +134 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +35 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/validate.d.ts +88 -0
  55. package/dist/validate.d.ts.map +1 -0
  56. package/dist/validate.js +410 -0
  57. package/dist/validate.js.map +1 -0
  58. package/manifest.json +96 -0
  59. package/package.json +107 -0
  60. package/src/default/FeatureBadges.tsx +123 -0
  61. package/src/default/FeatureFields.tsx +140 -0
  62. package/src/default/editors.tsx +578 -0
  63. package/src/default/index.ts +34 -0
  64. package/src/dto.ts +78 -0
  65. package/src/errors.ts +127 -0
  66. package/src/format.ts +210 -0
  67. package/src/i18n/es.ts +41 -0
  68. package/src/i18n/keys.ts +89 -0
  69. package/src/i18n/ru.ts +48 -0
  70. package/src/index.ts +98 -0
  71. package/src/registry.ts +167 -0
  72. package/src/types.ts +166 -0
  73. package/src/validate.ts +507 -0
  74. package/tsconfig.json +26 -0
@@ -0,0 +1,578 @@
1
+ /**
2
+ * The antd BUILTIN value editors — one per value type `stapel_attributes`
3
+ * ships (`types/`: ten of them).
4
+ *
5
+ * ── Where these sit in the resolution ladder ───────────────────────────────
6
+ *
7
+ * explicit `registerValueEditor(type, …)` ← a host's, always wins
8
+ * → this table ← the skin's default
9
+ * → `<UnsupportedValueEditor/>` ← loud, never silent
10
+ *
11
+ * ── These are NOT forms-react's widgets, and the differences are the point ─
12
+ *
13
+ * Both packages draw "the ten types", and it would be easy to assume one is a
14
+ * copy of the other. They are not: forms-react keys on `FormField.kind` (the
15
+ * admin CONFIG form) while this keys on `config.type` (the VALUE), and three
16
+ * of the ten have a genuinely different value shape on this axis:
17
+ *
18
+ * - **`date` is a Unix timestamp (integer)**, not an ISO string
19
+ * (`types/date/dto.py`: `value: Optional[int] # Unix timestamp`, and
20
+ * `validate_dto` refuses a non-int outright). The control is still a
21
+ * native input — dayjs for one widget is not worth a runtime dependency —
22
+ * but it converts in both directions.
23
+ * - **`hex_color` is an OBJECT** `{simple, hex?, label?}` where `simple` is
24
+ * REQUIRED and drawn from a closed vocabulary of eighteen colour
25
+ * categories (`types/hex_color/constants.py`). A bare `#RRGGBB` string
26
+ * fails validation, so the editor is a category picker with an optional
27
+ * exact-colour swatch, not a bare `ColorPicker`.
28
+ * - **`select` is always a LIST**, even when `maxSelected: 1`
29
+ * (`types/select/dto.py`: `value: List[str]`). A single choice is a
30
+ * one-element array, and `Segmented`'s scalar is wrapped on the way out.
31
+ *
32
+ * `convertible_unit` is the fourth object-valued type (`{value, unit}`), and
33
+ * the editor must NOT convert anything itself: the server converts the number
34
+ * from the submitted unit into the family's base unit before validating.
35
+ */
36
+ import { useMemo } from "react";
37
+ import type { ReactElement } from "react";
38
+ import {
39
+ Cascader,
40
+ ColorPicker,
41
+ Flex,
42
+ Input,
43
+ InputNumber,
44
+ Segmented,
45
+ Select,
46
+ Switch,
47
+ Typography,
48
+ } from "antd";
49
+ import { useT } from "@stapel/core";
50
+ import type { ValueEditor, ValueEditorProps } from "../registry.js";
51
+ import { featureConfig, featureName } from "../types.js";
52
+ import type { FeatureConfig } from "../types.js";
53
+ import { SIMPLE_COLORS } from "../validate.js";
54
+ import { ATTRIBUTES_I18N_KEYS } from "../i18n/keys.js";
55
+
56
+ /** At or below this many choices a single-select renders as a `Segmented` —
57
+ * the profiles-react / forms-react threshold, kept identical on purpose. */
58
+ const SEGMENTED_MAX_OPTIONS = 4;
59
+
60
+ /**
61
+ * antd's `status` prop under `exactOptionalPropertyTypes` does not accept
62
+ * `undefined` — it wants the key ABSENT. Spread this instead of passing
63
+ * `status={error ? "error" : undefined}`.
64
+ */
65
+ function errorStatus(error: unknown): { status: "error" } | Record<string, never> {
66
+ return error ? { status: "error" } : {};
67
+ }
68
+
69
+ function str(value: unknown): string {
70
+ return typeof value === "string" ? value : value === null || value === undefined ? "" : String(value);
71
+ }
72
+
73
+ function numberish(value: unknown): number | undefined {
74
+ if (typeof value === "number" && Number.isFinite(value)) return value;
75
+ if (typeof value === "string" && value.trim().length > 0) {
76
+ const parsed = Number(value);
77
+ if (Number.isFinite(parsed)) return parsed;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ function configOf(props: ValueEditorProps): FeatureConfig {
83
+ return featureConfig(props.feature);
84
+ }
85
+
86
+ /** `{value, label}` choices from either option shape the engine allows, with
87
+ * labels resolved through the host's catalogue when `translatable_options`
88
+ * is on (its default). */
89
+ function useChoices(
90
+ config: FeatureConfig
91
+ ): readonly { value: string; label: string }[] {
92
+ const t = useT();
93
+ return useMemo(() => {
94
+ const raw = config["options"];
95
+ if (!Array.isArray(raw)) return [];
96
+ const translatable = config["translatable_options"] !== false;
97
+ return raw.map((option) => {
98
+ if (option !== null && typeof option === "object") {
99
+ const entry = option as { value?: unknown; label?: unknown };
100
+ const value = str(entry.value);
101
+ const label = str(entry.label) || value;
102
+ return { value, label: translatable && label !== value ? t(label) : label };
103
+ }
104
+ return { value: str(option), label: str(option) };
105
+ });
106
+ }, [config, t]);
107
+ }
108
+
109
+ // ── string ───────────────────────────────────────────────────────────────────
110
+
111
+ /** `string` → `Input`, or `Input.TextArea` when `config.multiline` is set. */
112
+ const StringEditor: ValueEditor = (props: ValueEditorProps) => {
113
+ const cfg = configOf(props);
114
+ const placeholder = str(cfg["placeholder"]);
115
+ // `config.maxLength` is deliberately NOT passed to the control as a hard
116
+ // cap: the engine counts Unicode CODE POINTS and the DOM's `maxlength`
117
+ // counts UTF-16 code units, so a hard cap would stop a person two emoji
118
+ // short of the real limit with no explanation. The mirror reports the
119
+ // actual limit, in the actual unit, when it is actually exceeded.
120
+ const common = {
121
+ id: props.id,
122
+ value: str(props.value),
123
+ disabled: props.disabled === true,
124
+ ...errorStatus(props.error),
125
+ ...(placeholder.length > 0 ? { placeholder } : {}),
126
+ };
127
+ return cfg["multiline"] === true ? (
128
+ <Input.TextArea
129
+ {...common}
130
+ autoSize={{ minRows: 3, maxRows: 8 }}
131
+ onChange={(event) => props.onChange(event.target.value)}
132
+ />
133
+ ) : (
134
+ <Input {...common} onChange={(event) => props.onChange(event.target.value)} />
135
+ );
136
+ };
137
+
138
+ // ── int / float ──────────────────────────────────────────────────────────────
139
+
140
+ function makeNumberEditor(isInt: boolean): ValueEditor {
141
+ const Editor = (props: ValueEditorProps): ReactElement => {
142
+ const cfg = configOf(props);
143
+ const min = numberish(cfg["min"]);
144
+ const max = numberish(cfg["max"]);
145
+ // `int`'s `precision` is a DISPLAY hint upstream (it defaults to 1 and
146
+ // means "significant step", not "decimal places"), so an integer control
147
+ // pins 0 decimals rather than reading it — reading it would let an `int`
148
+ // field accept `1.0` and then silently truncate server-side.
149
+ const precision = isInt ? 0 : numberish(cfg["precision"]);
150
+ const placeholder = str(cfg["placeholder"]);
151
+ return (
152
+ <InputNumber
153
+ id={props.id}
154
+ style={{ width: "100%" }}
155
+ value={numberish(props.value) ?? null}
156
+ disabled={props.disabled === true}
157
+ {...errorStatus(props.error)}
158
+ {...(min !== undefined ? { min } : {})}
159
+ {...(max !== undefined ? { max } : {})}
160
+ {...(precision !== undefined ? { precision } : {})}
161
+ {...(isInt ? { step: 1 } : {})}
162
+ {...(placeholder.length > 0 ? { placeholder } : {})}
163
+ {...(str(cfg["prefix"]).length > 0 ? { prefix: str(cfg["prefix"]) } : {})}
164
+ {...(str(cfg["postfix"]).length > 0 ? { suffix: str(cfg["postfix"]) } : {})}
165
+ onChange={(next) => props.onChange(next ?? undefined)}
166
+ />
167
+ );
168
+ };
169
+ Editor.displayName = isInt ? "IntValueEditor" : "FloatValueEditor";
170
+ return Editor;
171
+ }
172
+
173
+ // ── bool ─────────────────────────────────────────────────────────────────────
174
+
175
+ const BoolEditor: ValueEditor = (props: ValueEditorProps) => {
176
+ const t = useT();
177
+ const cfg = configOf(props);
178
+ const on = props.value === true;
179
+ const trueLabel = str(cfg["trueLabel"]) || t(ATTRIBUTES_I18N_KEYS.boolYes);
180
+ const falseLabel = str(cfg["falseLabel"]) || t(ATTRIBUTES_I18N_KEYS.boolNo);
181
+ return (
182
+ <Flex align="center" gap={8}>
183
+ <Switch
184
+ id={props.id}
185
+ checked={on}
186
+ disabled={props.disabled === true}
187
+ onChange={(checked) => props.onChange(checked)}
188
+ />
189
+ <Typography.Text type="secondary">{on ? trueLabel : falseLabel}</Typography.Text>
190
+ </Flex>
191
+ );
192
+ };
193
+
194
+ // ── select ───────────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * `select` → `Segmented` for a small single choice, `Select` otherwise.
198
+ *
199
+ * The value is a LIST on both branches. `maxSelected` absent means UNLIMITED
200
+ * (the engine's own default); reading an absent key as 1 would silently turn
201
+ * every unconfigured select into a single-choice control.
202
+ */
203
+ const SelectEditor: ValueEditor = (props: ValueEditorProps) => {
204
+ const t = useT();
205
+ const cfg = configOf(props);
206
+ const choices = useChoices(cfg);
207
+ const maxSelected = numberish(cfg["maxSelected"]);
208
+ const multiple = maxSelected === undefined || maxSelected > 1;
209
+ const current = Array.isArray(props.value) ? props.value.map(str) : [];
210
+
211
+ if (
212
+ !multiple &&
213
+ cfg["uiStyle"] !== "dropdown" &&
214
+ choices.length > 0 &&
215
+ choices.length <= SEGMENTED_MAX_OPTIONS
216
+ ) {
217
+ return (
218
+ <Segmented<string>
219
+ id={props.id}
220
+ // antd renders a `radiogroup` div, which a `<label for>` cannot name
221
+ // — so the accessible name comes from the feature itself. Without
222
+ // this the control is announced as "segmented control" and the row's
223
+ // label reaches nothing.
224
+ aria-label={featureName(props.feature)}
225
+ options={[...choices]}
226
+ value={current[0] ?? ""}
227
+ disabled={props.disabled === true}
228
+ onChange={(next) => props.onChange(next.length > 0 ? [next] : undefined)}
229
+ />
230
+ );
231
+ }
232
+
233
+ return (
234
+ <Select
235
+ id={props.id}
236
+ style={{ width: "100%" }}
237
+ options={[...choices]}
238
+ disabled={props.disabled === true}
239
+ {...errorStatus(props.error)}
240
+ placeholder={t(ATTRIBUTES_I18N_KEYS.selectPlaceholder)}
241
+ mode="multiple"
242
+ {...(maxSelected !== undefined ? { maxCount: maxSelected } : {})}
243
+ value={current}
244
+ onChange={(next: readonly string[]) =>
245
+ props.onChange(next.length > 0 ? [...next] : undefined)
246
+ }
247
+ />
248
+ );
249
+ };
250
+
251
+ // ── date ─────────────────────────────────────────────────────────────────────
252
+
253
+ /** `precision` → the native input type whose value converts cleanly to and
254
+ * from the Unix timestamp the engine stores. */
255
+ const DATE_INPUT_TYPE: Readonly<Record<string, string>> = {
256
+ month: "month",
257
+ date: "date",
258
+ datetime: "datetime-local",
259
+ };
260
+
261
+ function pad(n: number): string {
262
+ return String(n).padStart(2, "0");
263
+ }
264
+
265
+ /** Unix seconds → the string a native input of this precision displays,
266
+ * in the VIEWER's time zone (which is what the person typed it in). */
267
+ export function timestampToInputValue(seconds: number, precision: string): string {
268
+ const d = new Date(seconds * 1000);
269
+ const ymd = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
270
+ if (precision === "month") return ymd.slice(0, 7);
271
+ if (precision === "datetime") return `${ymd}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
272
+ return ymd;
273
+ }
274
+
275
+ /**
276
+ * The inverse. Takes no `precision`: a native input's value already says
277
+ * which shape it is (`2010`, `2010-06`, `2010-06-15`, `2010-06-15T14:30`), so
278
+ * reading the config here would only create a way for the two to disagree.
279
+ *
280
+ * Returns `undefined` for an empty or unparseable input rather than 0 — `0`
281
+ * is 1970, a real timestamp, and the one value that must never be produced by
282
+ * "the person cleared the field".
283
+ */
284
+ export function inputValueToTimestamp(text: string): number | undefined {
285
+ if (text.length === 0) return undefined;
286
+ const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?(?:T(\d{2}):(\d{2}))?$/.exec(text);
287
+ if (match === null) return undefined;
288
+ const [, year, month, day, hour, minute] = match;
289
+ const date = new Date(
290
+ Number(year),
291
+ month === undefined ? 0 : Number(month) - 1,
292
+ day === undefined ? 1 : Number(day),
293
+ hour === undefined ? 0 : Number(hour),
294
+ minute === undefined ? 0 : Number(minute)
295
+ );
296
+ const seconds = Math.floor(date.getTime() / 1000);
297
+ return Number.isFinite(seconds) ? seconds : undefined;
298
+ }
299
+
300
+ const DateEditor: ValueEditor = (props: ValueEditorProps) => {
301
+ const cfg = configOf(props);
302
+ const precision = str(cfg["precision"]) || "date";
303
+ const current = numberish(props.value);
304
+
305
+ // "Year only" is a number, not a date: a date input would force a month and
306
+ // a day the admin explicitly said they do not want. The value on the wire
307
+ // is still a timestamp — January 1st of that year, local time.
308
+ if (precision === "year") {
309
+ return (
310
+ <InputNumber
311
+ id={props.id}
312
+ style={{ width: "100%" }}
313
+ value={current === undefined ? null : new Date(current * 1000).getFullYear()}
314
+ disabled={props.disabled === true}
315
+ {...errorStatus(props.error)}
316
+ step={1}
317
+ precision={0}
318
+ onChange={(next) =>
319
+ props.onChange(
320
+ next === null || next === undefined
321
+ ? undefined
322
+ : Math.floor(new Date(next, 0, 1).getTime() / 1000)
323
+ )
324
+ }
325
+ />
326
+ );
327
+ }
328
+
329
+ const min = numberish(cfg["minDate"]);
330
+ const max = numberish(cfg["maxDate"]);
331
+ return (
332
+ <Input
333
+ id={props.id}
334
+ type={DATE_INPUT_TYPE[precision] ?? "date"}
335
+ value={current === undefined ? "" : timestampToInputValue(current, precision)}
336
+ disabled={props.disabled === true}
337
+ {...errorStatus(props.error)}
338
+ {...(min !== undefined ? { min: timestampToInputValue(min, precision) } : {})}
339
+ {...(max !== undefined ? { max: timestampToInputValue(max, precision) } : {})}
340
+ onChange={(event) =>
341
+ props.onChange(inputValueToTimestamp(event.target.value))
342
+ }
343
+ />
344
+ );
345
+ };
346
+
347
+ // ── header ───────────────────────────────────────────────────────────────────
348
+
349
+ /**
350
+ * `header` → a caption, and NOT a control.
351
+ *
352
+ * It never calls `onChange`: the engine regenerates a header's DAO from its
353
+ * config and the batch validator skips headers entirely, so a header that
354
+ * could hold a value would only ever produce a refused submit.
355
+ * `config.style` is `l` (larger) or `m`.
356
+ */
357
+ const HeaderEditor: ValueEditor = (props: ValueEditorProps) => {
358
+ const level = str(configOf(props)["style"]) === "m" ? 4 : 3;
359
+ return (
360
+ <Typography.Title level={level} style={{ marginBottom: 0 }}>
361
+ {featureName(props.feature)}
362
+ </Typography.Title>
363
+ );
364
+ };
365
+
366
+ // ── hex_color ────────────────────────────────────────────────────────────────
367
+
368
+ /**
369
+ * `hex_color` → a colour CATEGORY picker, plus an exact swatch when the config
370
+ * allows a custom colour.
371
+ *
372
+ * The value is `{simple, hex?}`. `simple` is required and must be one of the
373
+ * engine's eighteen categories; when the config lists options, `simple` must
374
+ * additionally be one of THOSE unless `allowCustom`. `hex` is optional and
375
+ * only meaningful as a refinement of the category — which is why the picker
376
+ * comes first and the swatch second, rather than the other way round.
377
+ */
378
+ const HexColorEditor: ValueEditor = (props: ValueEditorProps) => {
379
+ const t = useT();
380
+ const cfg = configOf(props);
381
+ const current =
382
+ props.value !== null && typeof props.value === "object"
383
+ ? (props.value as { simple?: unknown; hex?: unknown })
384
+ : {};
385
+ const simple = str(current.simple);
386
+ const hex = str(current.hex);
387
+ const allowCustom = cfg["allowCustom"] === true;
388
+
389
+ const options = useMemo(() => {
390
+ const raw = cfg["options"];
391
+ const declared = Array.isArray(raw)
392
+ ? raw
393
+ .map((option) =>
394
+ option !== null && typeof option === "object"
395
+ ? str((option as { simple?: unknown }).simple)
396
+ : str(option)
397
+ )
398
+ .filter((code) => code.length > 0)
399
+ : [];
400
+ const source = declared.length > 0 && !allowCustom ? declared : SIMPLE_COLORS;
401
+ return source.map((code) => ({ value: code, label: code }));
402
+ }, [cfg, allowCustom]);
403
+
404
+ const emit = (nextSimple: string, nextHex: string): void => {
405
+ if (nextSimple.length === 0) {
406
+ props.onChange(undefined);
407
+ return;
408
+ }
409
+ props.onChange({ simple: nextSimple, ...(nextHex.length > 0 ? { hex: nextHex } : {}) });
410
+ };
411
+
412
+ return (
413
+ <Flex gap={8} align="center">
414
+ <Select
415
+ id={props.id}
416
+ style={{ flex: 1 }}
417
+ options={options}
418
+ disabled={props.disabled === true}
419
+ {...errorStatus(props.error)}
420
+ placeholder={t(ATTRIBUTES_I18N_KEYS.selectPlaceholder)}
421
+ {...(simple.length > 0 ? { value: simple } : {})}
422
+ onChange={(next: string) => emit(next, hex)}
423
+ />
424
+ {allowCustom && (
425
+ // antd's ColorPicker renders no labelable control and accepts no
426
+ // `id`, so it is the SECONDARY control here and the labelled one is
427
+ // the category select above.
428
+ <ColorPicker
429
+ disabled={props.disabled === true}
430
+ format="hex"
431
+ {...(hex.length > 0 ? { value: hex } : {})}
432
+ onChange={(color) => emit(simple, color.toHexString())}
433
+ showText
434
+ />
435
+ )}
436
+ </Flex>
437
+ );
438
+ };
439
+
440
+ // ── hierarchical_select ──────────────────────────────────────────────────────
441
+
442
+ interface CascaderOption {
443
+ readonly value: string;
444
+ readonly label: string;
445
+ readonly children?: readonly CascaderOption[];
446
+ }
447
+
448
+ function toCascaderOptions(
449
+ raw: unknown,
450
+ t: (key: string) => string,
451
+ translatable: boolean
452
+ ): readonly CascaderOption[] {
453
+ if (!Array.isArray(raw)) return [];
454
+ return raw.map((option) => {
455
+ if (option === null || typeof option !== "object") {
456
+ return { value: str(option), label: str(option) };
457
+ }
458
+ const entry = option as { value?: unknown; label?: unknown; children?: unknown };
459
+ const value = str(entry.value);
460
+ const rawLabel = str(entry.label) || value;
461
+ const children = toCascaderOptions(entry.children, t, translatable);
462
+ return {
463
+ value,
464
+ label: translatable && rawLabel !== value ? t(rawLabel) : rawLabel,
465
+ ...(children.length > 0 ? { children } : {}),
466
+ };
467
+ });
468
+ }
469
+
470
+ /** `hierarchical_select` → `Cascader`. The answer is the path array of
471
+ * `value`s from root to the chosen node, which is exactly what the engine
472
+ * stores and validates level by level. */
473
+ const HierarchicalSelectEditor: ValueEditor = (props: ValueEditorProps) => {
474
+ const t = useT();
475
+ const cfg = configOf(props);
476
+ const options = useMemo(
477
+ () => toCascaderOptions(cfg["options"], t, cfg["translatable_options"] !== false),
478
+ [cfg, t]
479
+ );
480
+ const value = Array.isArray(props.value) ? props.value.map(str) : undefined;
481
+ return (
482
+ <Cascader
483
+ id={props.id}
484
+ style={{ width: "100%" }}
485
+ options={options as never}
486
+ disabled={props.disabled === true}
487
+ {...errorStatus(props.error)}
488
+ placeholder={t(ATTRIBUTES_I18N_KEYS.selectPlaceholder)}
489
+ changeOnSelect
490
+ {...(value ? { value } : {})}
491
+ onChange={(next: unknown) =>
492
+ props.onChange(Array.isArray(next) && next.length > 0 ? next.map(str) : undefined)
493
+ }
494
+ />
495
+ );
496
+ };
497
+
498
+ // ── convertible_unit ─────────────────────────────────────────────────────────
499
+
500
+ /**
501
+ * `convertible_unit` → a number beside the unit it is expressed in.
502
+ *
503
+ * The wire DTO is `{type, value, unit}`: the number AS TYPED, tagged with
504
+ * which of the config's `unit_m` (metric) / `unit_i` (imperial) codes it is
505
+ * in. The server converts to the family's base unit before validating, so
506
+ * the editor must send the unit and must NOT convert anything itself — the
507
+ * conversion table lives in Python.
508
+ */
509
+ const ConvertibleUnitEditor: ValueEditor = (props: ValueEditorProps) => {
510
+ const cfg = configOf(props);
511
+ const units = [str(cfg["unit_m"]), str(cfg["unit_i"])].filter((code) => code.length > 0);
512
+ const current =
513
+ props.value !== null && typeof props.value === "object"
514
+ ? (props.value as { value?: unknown; unit?: unknown })
515
+ : {};
516
+ const unit = str(current.unit) || units[0] || "";
517
+ const amount = numberish(current.value);
518
+ const precision = numberish(cfg["precision"]);
519
+
520
+ const emit = (nextAmount: number | undefined, nextUnit: string): void => {
521
+ if (nextAmount === undefined) {
522
+ props.onChange(undefined);
523
+ return;
524
+ }
525
+ props.onChange({
526
+ value: nextAmount,
527
+ ...(nextUnit.length > 0 ? { unit: nextUnit } : {}),
528
+ });
529
+ };
530
+
531
+ return (
532
+ <Flex gap={8}>
533
+ <InputNumber
534
+ id={props.id}
535
+ style={{ flex: 1 }}
536
+ value={amount ?? null}
537
+ disabled={props.disabled === true}
538
+ {...errorStatus(props.error)}
539
+ {...(precision !== undefined ? { precision } : {})}
540
+ {...(str(cfg["prefix"]).length > 0 ? { prefix: str(cfg["prefix"]) } : {})}
541
+ onChange={(next) => emit(next ?? undefined, unit)}
542
+ />
543
+ {units.length > 0 && (
544
+ <Select
545
+ style={{ width: 96 }}
546
+ disabled={props.disabled === true}
547
+ {...errorStatus(props.error)}
548
+ value={unit}
549
+ options={units.map((code) => ({ value: code, label: code }))}
550
+ onChange={(next: string) => emit(amount, next)}
551
+ />
552
+ )}
553
+ </Flex>
554
+ );
555
+ };
556
+
557
+ /**
558
+ * The skin's builtin editor per value type — the second rung of the ladder.
559
+ * A type absent from this table has no default drawing and reaches
560
+ * `<UnsupportedValueEditor/>`.
561
+ */
562
+ export const BUILTIN_VALUE_EDITORS: Readonly<Record<string, ValueEditor>> = {
563
+ string: StringEditor,
564
+ int: makeNumberEditor(true),
565
+ float: makeNumberEditor(false),
566
+ bool: BoolEditor,
567
+ select: SelectEditor,
568
+ date: DateEditor,
569
+ header: HeaderEditor,
570
+ hex_color: HexColorEditor,
571
+ hierarchical_select: HierarchicalSelectEditor,
572
+ convertible_unit: ConvertibleUnitEditor,
573
+ };
574
+
575
+ /** The types this skin can draw — handed to `unsupportedTypes` so the
576
+ * headless half can judge renderability without importing the skin. */
577
+ export const BUILTIN_VALUE_EDITOR_TYPES: readonly string[] =
578
+ Object.keys(BUILTIN_VALUE_EDITORS).sort();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `@stapel/attributes-react/default` — the antd skin: the ten builtin value
3
+ * editors, the form rows that run the resolution ladder, and the display
4
+ * components.
5
+ *
6
+ * A separate entry point (the convention every pair's `/default` follows) so
7
+ * a consumer rendering its own controls over the registry and the mirror
8
+ * never pulls `antd` into their bundle. `BUILTIN_VALUE_EDITOR_TYPES` is the
9
+ * bridge in the other direction: the headless half judges renderability from
10
+ * that array without importing anything in here.
11
+ *
12
+ * ```tsx
13
+ * import { unsupportedTypeGate, toFeaturesDto } from "@stapel/attributes-react";
14
+ * import { BUILTIN_VALUE_EDITOR_TYPES, FeatureFields } from "@stapel/attributes-react/default";
15
+ *
16
+ * const gate = unsupportedTypeGate(features, BUILTIN_VALUE_EDITOR_TYPES);
17
+ * <FeatureFields features={features} values={values} onChange={setValue} errors={errors} />;
18
+ * <Button disabled={!gate.available}>…</Button>
19
+ * ```
20
+ */
21
+ export { BUILTIN_VALUE_EDITORS, BUILTIN_VALUE_EDITOR_TYPES } from "./editors.js";
22
+ export { inputValueToTimestamp, timestampToInputValue } from "./editors.js";
23
+ export {
24
+ FeatureFields,
25
+ UnsupportedValueEditor,
26
+ featureControlId,
27
+ } from "./FeatureFields.js";
28
+ export type {
29
+ FeatureFieldsProps,
30
+ FeatureRowProps,
31
+ UnsupportedValueEditorProps,
32
+ } from "./FeatureFields.js";
33
+ export { FeatureBadges, FeatureValueList } from "./FeatureBadges.js";
34
+ export type { FeatureDisplayProps } from "./FeatureBadges.js";
package/src/dto.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The `{slug: {type, value}}` envelope — in and out.
3
+ *
4
+ * A composer holds plain values keyed by slug (that is what an editor's
5
+ * `onChange` produces); the wire wants each one tagged with its type, because
6
+ * the server resolves the type handler from the DTO's `type` discriminator.
7
+ * The tag comes from the FEATURE's config, never from the editor: the engine
8
+ * itself overrides whatever the client sent (`dto_data = {**dto_data, 'type':
9
+ * config.type}` — `validation.py`), so a client that guessed differently
10
+ * would be sending a field the server throws away.
11
+ */
12
+ import type { FeatureDef, FeaturesDto, FeatureValueDto } from "./types.js";
13
+ import { featureType } from "./types.js";
14
+ import { isBlank } from "./validate.js";
15
+
16
+ /**
17
+ * Values keyed by slug → the `features_draft` payload.
18
+ *
19
+ * Three deliberate behaviours:
20
+ *
21
+ * - A feature with no declared type is DROPPED. It could not be edited (no
22
+ * editor resolves) and it cannot be tagged, so sending it would produce a
23
+ * row the server cannot route. `unsupportedTypes` is what tells a person
24
+ * it exists; silently sending a broken row would not.
25
+ * - `header` is DROPPED. The engine regenerates a header's DAO from its
26
+ * config and rejects an answer to one outright.
27
+ * - A blank value is DROPPED rather than sent as `null`. "Not answered" and
28
+ * "answered with nothing" are the same thing to the engine's empty check,
29
+ * and omitting the key keeps a draft's payload the size of what was
30
+ * actually filled in.
31
+ *
32
+ * `convertible_unit` is the one type whose editor emits an OBJECT rather than
33
+ * a scalar (`{value, unit}`), because its DTO genuinely carries the unit the
34
+ * number was typed in. Extra keys on that object ride along beside `value`.
35
+ */
36
+ export function toFeaturesDto(
37
+ features: readonly FeatureDef[],
38
+ values: Readonly<Record<string, unknown>>
39
+ ): FeaturesDto {
40
+ const out: Record<string, FeatureValueDto> = {};
41
+ for (const feature of features) {
42
+ const type = featureType(feature);
43
+ if (type === undefined || type === "header") continue;
44
+ const value = values[feature.slug];
45
+ if (isBlank(value)) continue;
46
+ if (type === "convertible_unit" && value !== null && typeof value === "object") {
47
+ const entry = value as { value?: unknown; unit?: unknown };
48
+ out[feature.slug] = {
49
+ type,
50
+ value: entry.value,
51
+ ...(entry.unit === undefined || entry.unit === null ? {} : { unit: entry.unit }),
52
+ };
53
+ continue;
54
+ }
55
+ out[feature.slug] = { type, value };
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * The reverse: a `features_draft` payload → the plain `{slug: value}` map a
62
+ * composer's editors read. Round-trips `convertible_unit` back into the
63
+ * `{value, unit}` object its editor holds.
64
+ */
65
+ export function fromFeaturesDto(dto: FeaturesDto): Readonly<Record<string, unknown>> {
66
+ const out: Record<string, unknown> = {};
67
+ for (const [slug, entry] of Object.entries(dto)) {
68
+ if (entry.type === "convertible_unit") {
69
+ out[slug] = {
70
+ value: entry.value,
71
+ ...(entry["unit"] === undefined ? {} : { unit: entry["unit"] }),
72
+ };
73
+ continue;
74
+ }
75
+ out[slug] = entry.value;
76
+ }
77
+ return out;
78
+ }