@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
|
@@ -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
|
+
}
|