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