import type { ReactNode } from "react"; import { loadStateFromQuery, mapLoad } from "@stapel/core"; import type { LoadState } from "@stapel/core"; import { axisRoleOf, byAxisRole, featureType } from "@stapel/attributes-react"; import type { AxisRole } from "@stapel/attributes-react"; import type { CategoryFeature, CategoryFeaturesEffectiveFrom, } from "../api/types.js"; import { featureLabel, featureOptionsAreKeys } from "../catalog/labels.js"; import type { CategoryLabel } from "../catalog/labels.js"; import { useCategoryFeatures } from "../model/queries.js"; /** One feature of a category, with everything a renderer needs decided. */ export interface CategoryFeatureEntry { readonly feature: CategoryFeature; /** `kind: "key"` → translate. `translate: "none"` makes it a literal. */ readonly label: CategoryLabel; /** `config.type`, or `undefined` when the row carries none. An undefined * type is an UNSUPPORTED type as far as any renderer is concerned — * `@stapel/attributes-react` says so loudly rather than skipping the row. */ readonly type: string | undefined; readonly mandatory: boolean; /** Are this feature's option labels translation keys? Only under * `translate: "all"` and while `translatable_options` is not false. */ readonly optionsAreKeys: boolean; /** `feature.divergent === true`, normalized like `mandatory` above. Only * ever true under {@link CategoryFeaturesBag.effectiveFrom} `"children"` — * see `visibleFeatures`. */ readonly divergent: boolean; /** `feature.partial === true` (stapel-categories 0.24.0): only SOME children * of this `chips` parent carry the feature. Normalized like `divergent`, and * like it only ever true under `effectiveFrom: "children"`. The row is shown * either way; this names it for a host that wants to label or filter by it. */ readonly partial: boolean; /** The slugs behind {@link partial}, in `GET /children/` order — empty when * every child carries the feature, or when this is an `"own"` schema. */ readonly carriedBy: readonly string[]; /** * Which classified AXIS this feature IS — `"make"`, `"model"`, * `"generation"`, `"year"`, `"mileage"` — or `null` for the overwhelming * majority of features, which describe an object rather than organise it * (stapel-categories 0.21.0, resolved server-side: an authored value first, * then `load_catalog`'s derivation). * * Read through `@stapel/attributes-react`'s `axisRoleOf`, which owns the * closed vocabulary, so a value this build does not recognise reads as * `null` rather than reaching a renderer as a role nothing can act on. */ readonly axisRole: AxisRole | null; } export interface CategoryFeaturesBag { /** * The feature schema. `empty` means the category declares no features and * inherits none — common for a root category, and a compose form that says * "no extra details for this category" rather than spinning. */ readonly state: LoadState; /** * The raw `FeatureDef[]`, ready to hand straight to * `@stapel/attributes-react`'s ``, `unsupportedTypeGate`, * `mirrorValidate` or `formatFeatureValue`. Kept beside the decorated list * because those functions take the wire shape, and re-deriving it from * `entries` would be a second mapping to keep in step. */ readonly features: readonly CategoryFeature[]; /** Only the features marked `show_as_badge` — the card's summary line. */ readonly badges: readonly CategoryFeature[]; /** Only the features marked `show_at_title` — the generated title parts. */ readonly titleParts: readonly CategoryFeature[]; /** * `{role: feature}` over {@link features} — which feature of THIS category * is the make, which is the model. The lookup a storefront does instead of * matching slugs against a table of its own (`brand` / `make` / `vendor` / * `manufacturer`), which a catalogue spelling the axis a fifth way drops * out of silently. * * `@stapel/attributes-react`'s `byAxisRole` decides it, including the rule * that a role claimed by TWO features is dropped rather than resolved: a * «more of this make» link built off the wrong one sends a buyer to a facet * they did not click. A missing key therefore means "this schema does not * name that axis", which is what a caller can act on. */ readonly axes: Partial>; /** * `"own"` — `features` is this category's own resolved schema, exactly as * every build before stapel-categories 0.20.1 answered. `"children"` — this * category is a `chips` parent declaring none of its own: `features` is the * UNION of its children's (the INTERSECTION before stapel-categories * 0.24.0), a row's `divergent: true` means the children carrying it disagree * on it, and its `partial: true` means only some children carry it at all. * Defaults to `"own"` while the read is not yet `ready` and whenever the * server sends no `X-Effective-From` header at all (a build older than * 0.20.1). */ readonly effectiveFrom: CategoryFeaturesEffectiveFrom; readonly isFetching: boolean; refetch(): void; } /** * Hides a `divergent: true` row until a chip is picked. * * `effectiveFrom: "children"` (see {@link CategoryFeaturesBag}) can carry a * feature whose config, `mandatory` or `rules` disagree between the children * carrying it — the row's `config` is already the WIDEST of theirs, so drawing * it before a chip narrows to one child offers a control that means something * different depending which chip gets picked. A `partial: true` row (one only * some children carry, stapel-categories 0.24.0) is NOT hidden: its config is * merged from the children that do carry it and means one thing, and hiding it * would put the parent back to answering less than the leaf beneath it — * which is the whole reason the schema became a union. * Once a chip IS picked (a `CategoryCascade` `commit: "stage"` stop's own * partition select — see `headless/CategoryCascade.tsx`) the row means * exactly what that child says, and it is safe to show. * * `chipPicked: true` is a no-op (every row passes) — a leaf or a `tiles` * parent's `effectiveFrom: "own"` schema never carries `divergent` at all, so * the filter has nothing to remove there either way. * * The composer (`@stapel/listings-react`) and the facet rail * (`@stapel/search-react`) both call this; neither host is wired to it here. */ export function visibleFeatures( features: readonly CategoryFeature[], options: { readonly chipPicked: boolean } ): readonly CategoryFeature[] { if (options.chipPicked) return features; return features.filter((feature) => feature.divergent !== true); } export interface CategoryFeaturesProps { categoryId: number | null | undefined; enabled?: boolean; children: (bag: CategoryFeaturesBag) => ReactNode; } /** * A category's feature schema — the bridge to `@stapel/attributes-react`. * * `GET /categories/{id}/features/` resolves inheritance and order server-side * and returns `FeatureCompact` rows whose `config` is the polymorphic * attributes config, VERBATIM: `FeatureCompactSerializer.get_config` returns * `obj.config`, not `get_config_with_defaults()`. So an absent config key * means "the type's default", never "off", and this pair does not restate * those defaults — attributes-react owns them (its §13.2 note 1). * * The two consumers this exists for: * * - the **compose form** (`listings-react`), which draws the features with * attributes-react's editor registry and blocks submit on a type this * build cannot draw; and * - the **facet panel** (`search-react`), whose `categoryFeatures` slot takes * exactly `features` below and uses it to caption facet values. * * Neither of them re-fetches: a host mounts this once per category page. */ export function CategoryFeatures(props: CategoryFeaturesProps): ReactNode { const query = useCategoryFeatures( props.categoryId, props.enabled !== undefined ? { enabled: props.enabled } : {} ); const state = loadStateFromQuery(query); const features = state.status === "ready" ? state.data.features : []; return props.children({ state: mapLoad(state, (result) => result.features.map((feature) => ({ feature, label: featureLabel(feature), type: featureType(feature), mandatory: feature.mandatory === true, optionsAreKeys: featureOptionsAreKeys(feature), divergent: feature.divergent === true, partial: feature.partial === true, carriedBy: feature.carried_by ?? [], axisRole: axisRoleOf(feature), })) ), features, badges: features.filter((f) => f.show_as_badge === true), titleParts: features.filter((f) => f.show_at_title === true), axes: byAxisRole(features), effectiveFrom: query.data?.effectiveFrom ?? "own", isFetching: query.isFetching, refetch: () => { void query.refetch(); }, }); }