@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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DISPLAY half — `<FeatureBadges/>` for a card, `<FeatureValueList/>` for
|
|
3
|
+
* a detail page. Both are renderers over `formatFeatureValue`; neither
|
|
4
|
+
* re-implements a single type's formatting.
|
|
5
|
+
*
|
|
6
|
+
* Two rules they hold that a naive spec table does not:
|
|
7
|
+
*
|
|
8
|
+
* - **An unreadable value says so.** A type this build cannot format renders
|
|
9
|
+
* a named notice, not an empty cell — the display twin of the unsupported
|
|
10
|
+
* editor. A blank cell where a spec line belongs reads as "this listing
|
|
11
|
+
* has no engine size", which is a different and false statement.
|
|
12
|
+
* - **`show_as_badge` / `show_at_title` are the CATEGORY's decision**, made
|
|
13
|
+
* by whoever configured the feature, and this component honours it instead
|
|
14
|
+
* of picking its own first three values.
|
|
15
|
+
*/
|
|
16
|
+
import type { ReactElement } from "react";
|
|
17
|
+
import { Descriptions, Flex, Tag, Typography } from "antd";
|
|
18
|
+
import { useI18n, useT } from "@stapel/core";
|
|
19
|
+
import type { FeatureDef, FeaturesDto } from "../types.js";
|
|
20
|
+
import { featureName, featureType } from "../types.js";
|
|
21
|
+
import { formatFeatureValue, hexColorSwatch } from "../format.js";
|
|
22
|
+
import { ATTRIBUTES_I18N_KEYS } from "../i18n/keys.js";
|
|
23
|
+
|
|
24
|
+
export interface FeatureDisplayProps {
|
|
25
|
+
readonly features: readonly FeatureDef[];
|
|
26
|
+
/** The stored values, in the same `{slug: {type, value}}` envelope the
|
|
27
|
+
* composer submitted. */
|
|
28
|
+
readonly values: FeaturesDto;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Feature + its formatted value, in the order the category declared. */
|
|
32
|
+
function useRows(
|
|
33
|
+
props: FeatureDisplayProps,
|
|
34
|
+
only?: (feature: FeatureDef) => boolean
|
|
35
|
+
): readonly {
|
|
36
|
+
readonly feature: FeatureDef;
|
|
37
|
+
readonly text: string | undefined;
|
|
38
|
+
readonly swatch: string | undefined;
|
|
39
|
+
}[] {
|
|
40
|
+
const t = useT();
|
|
41
|
+
const { locale } = useI18n();
|
|
42
|
+
return props.features
|
|
43
|
+
.filter((feature) => featureType(feature) !== "header")
|
|
44
|
+
.filter((feature) => (only ? only(feature) : true))
|
|
45
|
+
.map((feature) => {
|
|
46
|
+
const dto = props.values[feature.slug];
|
|
47
|
+
return {
|
|
48
|
+
feature,
|
|
49
|
+
text: formatFeatureValue(feature, dto, { t, locale }),
|
|
50
|
+
swatch: featureType(feature) === "hex_color" ? hexColorSwatch(dto) : undefined,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A row's value, or the named reason there is none. */
|
|
56
|
+
function ValueText(props: {
|
|
57
|
+
readonly feature: FeatureDef;
|
|
58
|
+
readonly text: string | undefined;
|
|
59
|
+
readonly hasValue: boolean;
|
|
60
|
+
}): ReactElement {
|
|
61
|
+
const t = useT();
|
|
62
|
+
if (props.text !== undefined) return <>{props.text}</>;
|
|
63
|
+
const type = featureType(props.feature);
|
|
64
|
+
// Two different absences, said differently: nothing was entered, versus
|
|
65
|
+
// something was entered that this build cannot read.
|
|
66
|
+
if (!props.hasValue || type === undefined) {
|
|
67
|
+
return (
|
|
68
|
+
<Typography.Text type="secondary">{t(ATTRIBUTES_I18N_KEYS.valueNotSet)}</Typography.Text>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<Typography.Text type="warning" data-testid="attributes-unreadable-value">
|
|
73
|
+
{t(ATTRIBUTES_I18N_KEYS.valueUnreadable, { type })}
|
|
74
|
+
</Typography.Text>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The `show_as_badge` values, as antd `Tag`s — what a result card shows under
|
|
80
|
+
* a title. Features with no value are omitted here (a card is a summary, and
|
|
81
|
+
* "not specified" is not a selling point); the detail list below says it
|
|
82
|
+
* explicitly instead.
|
|
83
|
+
*/
|
|
84
|
+
export function FeatureBadges(props: FeatureDisplayProps): ReactElement {
|
|
85
|
+
const rows = useRows(props, (feature) => feature.show_as_badge === true);
|
|
86
|
+
const shown = rows.filter((row) => row.text !== undefined);
|
|
87
|
+
return (
|
|
88
|
+
<Flex gap={4} wrap data-testid="attributes-badges">
|
|
89
|
+
{shown.map((row) => (
|
|
90
|
+
<Tag
|
|
91
|
+
key={row.feature.slug}
|
|
92
|
+
{...(row.swatch ? { color: row.swatch } : {})}
|
|
93
|
+
data-testid={`attributes-badge-${row.feature.slug}`}
|
|
94
|
+
>
|
|
95
|
+
{row.text}
|
|
96
|
+
</Tag>
|
|
97
|
+
))}
|
|
98
|
+
</Flex>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every feature and its value — the spec table of a detail page. */
|
|
103
|
+
export function FeatureValueList(props: FeatureDisplayProps): ReactElement {
|
|
104
|
+
const rows = useRows(props);
|
|
105
|
+
return (
|
|
106
|
+
<Descriptions
|
|
107
|
+
column={1}
|
|
108
|
+
size="small"
|
|
109
|
+
data-testid="attributes-value-list"
|
|
110
|
+
items={rows.map((row) => ({
|
|
111
|
+
key: row.feature.slug,
|
|
112
|
+
label: featureName(row.feature),
|
|
113
|
+
children: (
|
|
114
|
+
<ValueText
|
|
115
|
+
feature={row.feature}
|
|
116
|
+
text={row.text}
|
|
117
|
+
hasValue={props.values[row.feature.slug] !== undefined}
|
|
118
|
+
/>
|
|
119
|
+
),
|
|
120
|
+
}))}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<FeatureFields/>` — a category's features, drawn as antd form rows, and
|
|
3
|
+
* the place the three-rung ladder is actually executed.
|
|
4
|
+
*
|
|
5
|
+
* It holds no state and owns no submit: values and errors come in, changes go
|
|
6
|
+
* out. That is what lets the composer that DOES own the submit
|
|
7
|
+
* (listings-react) keep one source of truth for a draft, and what lets this
|
|
8
|
+
* component be dropped into any form — a filter panel, an admin preview —
|
|
9
|
+
* without dragging a mutation along.
|
|
10
|
+
*/
|
|
11
|
+
import type { ReactElement, ReactNode } from "react";
|
|
12
|
+
import { Alert, Form } from "antd";
|
|
13
|
+
import { useFormatFlowError, useT } from "@stapel/core";
|
|
14
|
+
import type { FlowError } from "@stapel/core";
|
|
15
|
+
import type { FeatureDef } from "../types.js";
|
|
16
|
+
import { featureName, featureType } from "../types.js";
|
|
17
|
+
import { resolveValueEditor } from "../registry.js";
|
|
18
|
+
import { BUILTIN_VALUE_EDITORS } from "./editors.js";
|
|
19
|
+
import { ATTRIBUTES_I18N_KEYS } from "../i18n/keys.js";
|
|
20
|
+
|
|
21
|
+
/** Stable per-feature DOM id, so `<label for>` reaches the control. Slugs are
|
|
22
|
+
* `[a-z0-9_]`-shaped by the engine, so this needs no further escaping. */
|
|
23
|
+
export function featureControlId(slug: string): string {
|
|
24
|
+
return `attributes-field-${slug}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UnsupportedValueEditorProps {
|
|
28
|
+
readonly feature: FeatureDef;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The loud last rung. NOT a skipped field: a category can legally carry a
|
|
33
|
+
* type this build has no editor for, and drawing nothing would silently drop
|
|
34
|
+
* a feature that may be mandatory — the person would submit a listing they
|
|
35
|
+
* could not complete and be told, by the server, that an attribute they never
|
|
36
|
+
* saw is missing.
|
|
37
|
+
*/
|
|
38
|
+
export function UnsupportedValueEditor(
|
|
39
|
+
props: UnsupportedValueEditorProps
|
|
40
|
+
): ReactElement {
|
|
41
|
+
const t = useT();
|
|
42
|
+
const type = featureType(props.feature);
|
|
43
|
+
return (
|
|
44
|
+
<Alert
|
|
45
|
+
type="warning"
|
|
46
|
+
showIcon
|
|
47
|
+
data-testid="attributes-unsupported-type"
|
|
48
|
+
message={featureName(props.feature)}
|
|
49
|
+
description={
|
|
50
|
+
type === undefined
|
|
51
|
+
? t(ATTRIBUTES_I18N_KEYS.untypedFeature)
|
|
52
|
+
: t(ATTRIBUTES_I18N_KEYS.unsupportedType, { type })
|
|
53
|
+
}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface FeatureFieldsProps {
|
|
59
|
+
readonly features: readonly FeatureDef[];
|
|
60
|
+
/** Current answers keyed by slug. The DTO envelope is built at submit time
|
|
61
|
+
* with `toFeaturesDto`, not held here. */
|
|
62
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
63
|
+
onChange(slug: string, value: unknown): void;
|
|
64
|
+
/** Refusals keyed by slug — mirrored or from the server, folded through
|
|
65
|
+
* `featureErrorsBySlug` either way. */
|
|
66
|
+
readonly errors?: Readonly<Record<string, FlowError>>;
|
|
67
|
+
/** True while a submit is in flight — every editor goes read-only. */
|
|
68
|
+
readonly disabled?: boolean;
|
|
69
|
+
/** Rendered instead of a default `Form.Item` row, for a host with its own
|
|
70
|
+
* field chrome. */
|
|
71
|
+
readonly renderRow?: (row: FeatureRowProps) => ReactNode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface FeatureRowProps {
|
|
75
|
+
readonly feature: FeatureDef;
|
|
76
|
+
readonly controlId: string;
|
|
77
|
+
readonly control: ReactNode;
|
|
78
|
+
readonly error: FlowError | undefined;
|
|
79
|
+
readonly required: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function FeatureRow(props: FeatureRowProps): ReactElement {
|
|
83
|
+
const format = useFormatFlowError();
|
|
84
|
+
// A header is a caption: no label, no colon, no required marker — rendering
|
|
85
|
+
// one inside a labelled Form.Item would make a section heading look like a
|
|
86
|
+
// question.
|
|
87
|
+
if (featureType(props.feature) === "header") {
|
|
88
|
+
return <Form.Item style={{ marginBottom: 8 }}>{props.control}</Form.Item>;
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<Form.Item
|
|
92
|
+
label={featureName(props.feature)}
|
|
93
|
+
htmlFor={props.controlId}
|
|
94
|
+
required={props.required}
|
|
95
|
+
{...(props.error ? { validateStatus: "error" as const, help: format(props.error) } : {})}
|
|
96
|
+
>
|
|
97
|
+
{props.control}
|
|
98
|
+
</Form.Item>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A category's features as form rows, each drawn by the ladder's winner. */
|
|
103
|
+
export function FeatureFields(props: FeatureFieldsProps): ReactElement {
|
|
104
|
+
const errors = props.errors ?? {};
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
{props.features.map((feature) => {
|
|
108
|
+
const controlId = featureControlId(feature.slug);
|
|
109
|
+
const type = featureType(feature);
|
|
110
|
+
// The ladder: a host's explicit registration outranks the skin's
|
|
111
|
+
// builtin, and nothing at all is a NOTICE, never an omission.
|
|
112
|
+
const Editor =
|
|
113
|
+
(type === undefined ? null : resolveValueEditor(type)) ??
|
|
114
|
+
(type === undefined ? undefined : BUILTIN_VALUE_EDITORS[type]);
|
|
115
|
+
const control =
|
|
116
|
+
Editor === undefined || Editor === null ? (
|
|
117
|
+
<UnsupportedValueEditor feature={feature} />
|
|
118
|
+
) : (
|
|
119
|
+
<Editor
|
|
120
|
+
id={controlId}
|
|
121
|
+
feature={feature}
|
|
122
|
+
value={props.values[feature.slug]}
|
|
123
|
+
onChange={(value) => props.onChange(feature.slug, value)}
|
|
124
|
+
error={errors[feature.slug]}
|
|
125
|
+
disabled={props.disabled === true}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
const row: FeatureRowProps = {
|
|
129
|
+
feature,
|
|
130
|
+
controlId,
|
|
131
|
+
control,
|
|
132
|
+
error: errors[feature.slug],
|
|
133
|
+
required: feature.mandatory === true,
|
|
134
|
+
};
|
|
135
|
+
if (props.renderRow) return <div key={feature.slug}>{props.renderRow(row)}</div>;
|
|
136
|
+
return <FeatureRow key={feature.slug} {...row} />;
|
|
137
|
+
})}
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|