@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/registry.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value-editor registry — THE customer seam of this package, and the
|
|
3
|
+
* direct descendant of forms-react's field-widget registry.
|
|
4
|
+
*
|
|
5
|
+
* It keys on `config.type` (the VALUE type: `string`, `int`, `select`, …),
|
|
6
|
+
* not on `FormField.kind` (the admin config form's field kinds). That is the
|
|
7
|
+
* whole reason this package exists next to `@stapel/forms-react` rather than
|
|
8
|
+
* inside it: two different vocabularies, and L2 pairs may not import each
|
|
9
|
+
* other anyway (`stapel-react/README.md` — dependency direction is strictly
|
|
10
|
+
* downward).
|
|
11
|
+
*
|
|
12
|
+
* The type vocabulary is an OPEN registry server-side (builtins →
|
|
13
|
+
* `STAPEL_ATTRIBUTES["EXTRA_TYPES"]` → runtime `register_feature_type`), so a
|
|
14
|
+
* host that adds a type on the backend must be able to draw it on the front
|
|
15
|
+
* without forking the skin:
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* registerValueEditor("size_grid", SizeGridEditor); // at startup
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* ── Resolution, and what happens when nothing matches ──────────────────────
|
|
22
|
+
*
|
|
23
|
+
* This module answers only the FIRST rung: an explicit registration, or
|
|
24
|
+
* `null`. The `/default` skin completes the ladder — explicit registration >
|
|
25
|
+
* skin builtin (antd) > **loud unsupported notice** — verbatim the ladder
|
|
26
|
+
* forms-react's `<StapelForm>` runs.
|
|
27
|
+
*
|
|
28
|
+
* The last rung is a notice, deliberately, and never a skipped field. A
|
|
29
|
+
* category can legally carry a type this build has no editor for; rendering
|
|
30
|
+
* nothing would silently drop a feature that may be MANDATORY, and the person
|
|
31
|
+
* would submit a listing they could not complete and be told, by the server,
|
|
32
|
+
* that an attribute they never saw is missing. `unsupportedTypeGate` blocks
|
|
33
|
+
* the submit with the reason NAMED while such a feature is present — degrade
|
|
34
|
+
* loudly.
|
|
35
|
+
*
|
|
36
|
+
* The registry lives in the main entry, not in `/default`, so a host building
|
|
37
|
+
* its own renderer uses the same seam the skin does rather than a parallel
|
|
38
|
+
* one.
|
|
39
|
+
*/
|
|
40
|
+
import type { ComponentType } from "react";
|
|
41
|
+
import type { FlowError } from "@stapel/core";
|
|
42
|
+
import { actionAvailable, actionBlocked } from "@stapel/core";
|
|
43
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
44
|
+
import type { FeatureDef } from "./types.js";
|
|
45
|
+
import { featureType } from "./types.js";
|
|
46
|
+
import { ATTRIBUTES_I18N_KEYS } from "./i18n/keys.js";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What every value editor receives. One feature, one value, one setter, one
|
|
50
|
+
* error — the editor never touches an API layer or the rest of the form.
|
|
51
|
+
*/
|
|
52
|
+
export interface ValueEditorProps<T = unknown> {
|
|
53
|
+
/** The feature being edited. `feature.config` carries the type's camelCase
|
|
54
|
+
* options (`maxLength`, `minSelected`, `precision`, `multiline`, …). */
|
|
55
|
+
readonly feature: FeatureDef;
|
|
56
|
+
/** The current answer, or `undefined` while unanswered. */
|
|
57
|
+
readonly value: T | undefined;
|
|
58
|
+
/** Report a new answer. Pass the type's own DTO `value` — bare for the
|
|
59
|
+
* scalar types, an array for `select`/`hierarchical_select`, an object for
|
|
60
|
+
* `hex_color` and `convertible_unit` (see `toFeaturesDto`). */
|
|
61
|
+
onChange(value: T | undefined): void;
|
|
62
|
+
/** The feature's current refusal (client mirror or server verdict), or
|
|
63
|
+
* `undefined`. */
|
|
64
|
+
readonly error?: FlowError | undefined;
|
|
65
|
+
/** True while a submit is in flight — editors should go read-only. */
|
|
66
|
+
readonly disabled?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* DOM id the editor MUST put on its primary control. The field row points
|
|
69
|
+
* its `<label for>` at this, so the label actually names the input for a
|
|
70
|
+
* screen reader (and for a click). An editor that drops it renders an
|
|
71
|
+
* unlabelled control — which antd's `Form.Item` cannot detect and will
|
|
72
|
+
* happily draw a label beside.
|
|
73
|
+
*/
|
|
74
|
+
readonly id: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type ValueEditor = ComponentType<ValueEditorProps>;
|
|
78
|
+
|
|
79
|
+
const registered = new Map<string, ValueEditor>();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Register (or override) the editor for a value type. Call at startup, before
|
|
83
|
+
* the first render — the registry is module-global, like the i18n bundle
|
|
84
|
+
* registration and forms-react's widget registry.
|
|
85
|
+
*/
|
|
86
|
+
export function registerValueEditor(type: string, editor: ValueEditor): void {
|
|
87
|
+
registered.set(type, editor);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Remove an explicit registration (the skin's builtin, if any, resolves
|
|
91
|
+
* again). */
|
|
92
|
+
export function unregisterValueEditor(type: string): void {
|
|
93
|
+
registered.delete(type);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The EXPLICIT registration for a type, or `null`. Named for what it is: the
|
|
98
|
+
* skin calls this FIRST and only falls back to its own builtin when it
|
|
99
|
+
* returns `null`, so a host registration always wins.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveValueEditor(type: string): ValueEditor | null {
|
|
102
|
+
return registered.get(type) ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every type with an explicit registration, sorted. */
|
|
106
|
+
export function registeredValueEditorTypes(): readonly string[] {
|
|
107
|
+
return [...registered.keys()].sort();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The value types present in `features` that NOTHING can draw — neither an
|
|
112
|
+
* explicit registration nor the caller's builtin set.
|
|
113
|
+
*
|
|
114
|
+
* Pure and React-free on purpose: the headless half must be able to judge
|
|
115
|
+
* renderability without importing the skin, which is how forms-react's
|
|
116
|
+
* `<FormFill>` avoids pulling antd into a headless bundle. `builtinTypes` is
|
|
117
|
+
* therefore passed IN (the skin exports `BUILTIN_VALUE_EDITOR_TYPES`), not
|
|
118
|
+
* imported from here.
|
|
119
|
+
*
|
|
120
|
+
* A feature whose config declares no `type` at all counts as unsupported and
|
|
121
|
+
* is reported under `"(none)"` — an unnamed hole is still a hole, and a
|
|
122
|
+
* silently dropped mandatory attribute is the exact failure this returns
|
|
123
|
+
* data to prevent.
|
|
124
|
+
*/
|
|
125
|
+
export function unsupportedTypes(
|
|
126
|
+
features: readonly FeatureDef[],
|
|
127
|
+
builtinTypes: readonly string[]
|
|
128
|
+
): readonly string[] {
|
|
129
|
+
const builtin = new Set(builtinTypes);
|
|
130
|
+
const out = new Set<string>();
|
|
131
|
+
for (const feature of features) {
|
|
132
|
+
const type = featureType(feature);
|
|
133
|
+
if (type === undefined) {
|
|
134
|
+
out.add(UNTYPED_FEATURE);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (resolveValueEditor(type) === null && !builtin.has(type)) out.add(type);
|
|
138
|
+
}
|
|
139
|
+
return [...out].sort();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What `unsupportedTypes` reports for a feature whose config names no type
|
|
143
|
+
* at all. Exported so a caller can tell "we cannot draw `size_grid`" from
|
|
144
|
+
* "this row has no type" without string-matching a message. */
|
|
145
|
+
export const UNTYPED_FEATURE = "(none)";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The submit gate for an unsupported type — blocked with the reason named,
|
|
149
|
+
* never a disabled button with no explanation.
|
|
150
|
+
*
|
|
151
|
+
* This package owns its own key (`attributes.submit.blocked.unsupported_type`)
|
|
152
|
+
* because it owns the fact. A pair with its own submit — listings-react's
|
|
153
|
+
* composer, say — raises its own `listings.compose.blocked.unsupported_type`
|
|
154
|
+
* from the SAME `unsupportedTypes` call and never re-derives the fact; both
|
|
155
|
+
* spellings say the same thing to the same person, and neither invents a
|
|
156
|
+
* silent third behaviour.
|
|
157
|
+
*/
|
|
158
|
+
export function unsupportedTypeGate(
|
|
159
|
+
features: readonly FeatureDef[],
|
|
160
|
+
builtinTypes: readonly string[]
|
|
161
|
+
): ActionAvailability {
|
|
162
|
+
const types = unsupportedTypes(features, builtinTypes);
|
|
163
|
+
if (types.length === 0) return actionAvailable();
|
|
164
|
+
return actionBlocked(ATTRIBUTES_I18N_KEYS.submitBlockedUnsupportedType, {
|
|
165
|
+
types: types.join(", "),
|
|
166
|
+
});
|
|
167
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire shapes of `stapel_attributes`, as this package sees them.
|
|
3
|
+
*
|
|
4
|
+
* There is no generated `schema.ts` here and there never will be:
|
|
5
|
+
* stapel-attributes is an **L1 library with no HTTP surface at all** — no
|
|
6
|
+
* models, no views, no urls (`stapel-attributes/docs/readme.md`), so it emits
|
|
7
|
+
* no `docs/schema.json` for `pnpm gen:api` to read. Its shapes reach a browser
|
|
8
|
+
* embedded in someone ELSE's response: a category's features arrive from
|
|
9
|
+
* `GET /categories/api/v1/categories/{id}/features/`, a validation verdict
|
|
10
|
+
* from `POST /categories/{pk}/validate-dto/` and from
|
|
11
|
+
* `POST /listings/{pk}/publish/`. The owning pair generates those; this
|
|
12
|
+
* package types the payload they carry.
|
|
13
|
+
*
|
|
14
|
+
* Every field here was read off the Python it mirrors, and the mirrors are
|
|
15
|
+
* named in the doc comments so the next person can check rather than trust.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A feature's type-specific configuration. `type` is the discriminator — the
|
|
20
|
+
* **value type's slug**, and the axis this whole package switches on.
|
|
21
|
+
*
|
|
22
|
+
* ── The distinction that must not be swallowed ─────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* stapel-attributes has TWO field vocabularies, and `@stapel/forms-react`
|
|
25
|
+
* works with the other one:
|
|
26
|
+
*
|
|
27
|
+
* - `FormField.kind` (`stapel_attributes/config_form.py`) — the field kinds
|
|
28
|
+
* of the ADMIN form that configures a type. That is what
|
|
29
|
+
* `GET /forms/api/v1/field-kinds` enumerates and what forms-react's widget
|
|
30
|
+
* registry keys on.
|
|
31
|
+
* - `config["type"]` — the VALUE type: `int`, `float`, `string`, `bool`,
|
|
32
|
+
* `hex_color`, `select`, `date`, `header`, `hierarchical_select`,
|
|
33
|
+
* `convertible_unit`. That is what a person filling in a listing actually
|
|
34
|
+
* edits, and it is what this package keys on.
|
|
35
|
+
*
|
|
36
|
+
* A storefront therefore needs **no catalogue endpoint**: the type arrives in
|
|
37
|
+
* the data, on every feature.
|
|
38
|
+
*
|
|
39
|
+
* `type` is optional here because the wire can omit it. The features endpoint
|
|
40
|
+
* serializes `obj.config` verbatim (`stapel-categories`
|
|
41
|
+
* `FeatureCompactSerializer.get_config`), NOT `get_config_with_defaults()`, so
|
|
42
|
+
* a config saved without its defaults arrives without them — including,
|
|
43
|
+
* for a malformed row, without a `type`. A missing type is an unsupported
|
|
44
|
+
* type, loudly, rather than a crash.
|
|
45
|
+
*/
|
|
46
|
+
export interface FeatureConfig {
|
|
47
|
+
readonly type?: string;
|
|
48
|
+
readonly [key: string]: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One feature of a category — the browser's view of `stapel_attributes.base
|
|
53
|
+
* .FeatureDef`, as `stapel-categories`' `FeatureCompactSerializer` sends it
|
|
54
|
+
* (`fields = [id, tn_parent, name, slug, icon, comment, config, mandatory,
|
|
55
|
+
* show_as_badge, show_at_title, translate]`).
|
|
56
|
+
*/
|
|
57
|
+
export interface FeatureDef {
|
|
58
|
+
/** Payload key this feature's value is submitted under. */
|
|
59
|
+
readonly slug: string;
|
|
60
|
+
readonly config?: FeatureConfig;
|
|
61
|
+
readonly id?: number | string | null;
|
|
62
|
+
/** Display name or translation key. Falls back to `slug` server-side. */
|
|
63
|
+
readonly name?: string | null;
|
|
64
|
+
readonly mandatory?: boolean;
|
|
65
|
+
readonly show_at_title?: boolean;
|
|
66
|
+
readonly show_as_badge?: boolean;
|
|
67
|
+
readonly translate?: string | null;
|
|
68
|
+
readonly icon?: string | null;
|
|
69
|
+
readonly comment?: string | null;
|
|
70
|
+
readonly tn_parent?: number | string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One submitted value: `{type, value}` plus whatever else the type's DTO
|
|
75
|
+
* carries. Exactly one builtin adds a key — `convertible_unit`, whose DTO is
|
|
76
|
+
* `{type, value, unit}` because the number has to be tagged with the unit it
|
|
77
|
+
* was typed in before the server converts to the family's base unit.
|
|
78
|
+
*/
|
|
79
|
+
export interface FeatureValueDto {
|
|
80
|
+
readonly type: string;
|
|
81
|
+
readonly value: unknown;
|
|
82
|
+
readonly [key: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The `features_draft`-shaped payload: `{slug: {type, value}}`. */
|
|
86
|
+
export type FeaturesDto = Readonly<Record<string, FeatureValueDto>>;
|
|
87
|
+
|
|
88
|
+
/** `stapel_attributes.results.ValidationStatus`. */
|
|
89
|
+
export type ValidationStatus = "ok" | "validation_failed";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `stapel_attributes.results.ValidationErrorCode`, mirrored.
|
|
93
|
+
*
|
|
94
|
+
* Pinned against the engine's own generated corpus
|
|
95
|
+
* (`stapel-attributes/tests/golden/error_codes.json`, itself generated from
|
|
96
|
+
* the enum and asserted by both the Python and the TypeScript half of the
|
|
97
|
+
* cross-language bridge) — see `test/contract.test.ts`.
|
|
98
|
+
*/
|
|
99
|
+
export type ValidationErrorCode =
|
|
100
|
+
| "above_maximum"
|
|
101
|
+
| "below_minimum"
|
|
102
|
+
| "description_too_long"
|
|
103
|
+
| "description_too_short"
|
|
104
|
+
| "duplicate_slug"
|
|
105
|
+
| "empty_options"
|
|
106
|
+
| "invalid_config"
|
|
107
|
+
| "invalid_format"
|
|
108
|
+
| "invalid_type"
|
|
109
|
+
| "mandatory_missing"
|
|
110
|
+
| "min_greater_than_max"
|
|
111
|
+
| "not_allowed"
|
|
112
|
+
| "not_in_options"
|
|
113
|
+
| "unknown_feature"
|
|
114
|
+
| "unknown_feature_type";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One row of a batch verdict — `stapel_attributes.results
|
|
118
|
+
* .FeatureValidationResult` as its DRF serializer sends it.
|
|
119
|
+
*
|
|
120
|
+
* `slug` is the routing key: it is what puts a refusal on a control. The
|
|
121
|
+
* server's `params` carry `{feature, slug}` (never `field`), so
|
|
122
|
+
* `featureErrorsBySlug` adds `field` when folding a row into a `FlowError`,
|
|
123
|
+
* which is what the fleet's `useFieldError` convention reads.
|
|
124
|
+
*/
|
|
125
|
+
export interface FeatureValidationResult {
|
|
126
|
+
readonly slug: string;
|
|
127
|
+
readonly status: ValidationStatus;
|
|
128
|
+
readonly id?: number | string | null;
|
|
129
|
+
readonly error?: ValidationErrorCode | null;
|
|
130
|
+
/** The constraint that was violated (a limit, a list of options). */
|
|
131
|
+
readonly ref_value?: unknown;
|
|
132
|
+
readonly message?: string | null;
|
|
133
|
+
/** `error.400.feature_*` — the key a person's sentence comes from. */
|
|
134
|
+
readonly localizable_error?: string | null;
|
|
135
|
+
readonly params?: Readonly<Record<string, unknown>> | null;
|
|
136
|
+
/** Non-blocking findings (e.g. config keys the parser dropped). Never
|
|
137
|
+
* flips `valid` — a warning is not a refusal. */
|
|
138
|
+
readonly warnings?: readonly string[] | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `stapel_attributes.results.ValidationBatchResult`. */
|
|
142
|
+
export interface ValidationBatchResult {
|
|
143
|
+
readonly valid: boolean;
|
|
144
|
+
readonly results: readonly FeatureValidationResult[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A feature's declared value type, or `undefined` when the config carries
|
|
149
|
+
* none. The ONE place `config.type` is read, so "which axis is this?" has a
|
|
150
|
+
* single answer in this package.
|
|
151
|
+
*/
|
|
152
|
+
export function featureType(feature: FeatureDef): string | undefined {
|
|
153
|
+
const type = feature.config?.type;
|
|
154
|
+
return typeof type === "string" && type.length > 0 ? type : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A feature's config, never `undefined` — saves every reader a `?? {}`. */
|
|
158
|
+
export function featureConfig(feature: FeatureDef): FeatureConfig {
|
|
159
|
+
return feature.config ?? {};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A feature's display name, falling back to its slug exactly as
|
|
163
|
+
* `FeatureDef.__post_init__` does server-side. */
|
|
164
|
+
export function featureName(feature: FeatureDef): string {
|
|
165
|
+
return feature.name ?? feature.slug;
|
|
166
|
+
}
|