@stapel/categories-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/CHANGELOG.md +1 -0
- package/MODULE.md +194 -0
- package/README.md +191 -0
- package/dist/api/categoriesApi.d.ts +121 -0
- package/dist/api/categoriesApi.d.ts.map +1 -0
- package/dist/api/categoriesApi.js +48 -0
- package/dist/api/categoriesApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +2321 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +92 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/catalog/labels.d.ts +103 -0
- package/dist/catalog/labels.d.ts.map +1 -0
- package/dist/catalog/labels.js +56 -0
- package/dist/catalog/labels.js.map +1 -0
- package/dist/catalog/pks.d.ts +25 -0
- package/dist/catalog/pks.d.ts.map +1 -0
- package/dist/catalog/pks.js +38 -0
- package/dist/catalog/pks.js.map +1 -0
- package/dist/catalog/sync.d.ts +103 -0
- package/dist/catalog/sync.d.ts.map +1 -0
- package/dist/catalog/sync.js +114 -0
- package/dist/catalog/sync.js.map +1 -0
- package/dist/catalog/tree.d.ts +119 -0
- package/dist/catalog/tree.d.ts.map +1 -0
- package/dist/catalog/tree.js +166 -0
- package/dist/catalog/tree.js.map +1 -0
- package/dist/default/CatalogPage.d.ts +16 -0
- package/dist/default/CatalogPage.d.ts.map +1 -0
- package/dist/default/CatalogPage.js +13 -0
- package/dist/default/CatalogPage.js.map +1 -0
- package/dist/default/CategoryBreadcrumbsBar.d.ts +20 -0
- package/dist/default/CategoryBreadcrumbsBar.d.ts.map +1 -0
- package/dist/default/CategoryBreadcrumbsBar.js +35 -0
- package/dist/default/CategoryBreadcrumbsBar.js.map +1 -0
- package/dist/default/CategoryCarouselStrip.d.ts +22 -0
- package/dist/default/CategoryCarouselStrip.d.ts.map +1 -0
- package/dist/default/CategoryCarouselStrip.js +24 -0
- package/dist/default/CategoryCarouselStrip.js.map +1 -0
- package/dist/default/CategoryFeatureList.d.ts +23 -0
- package/dist/default/CategoryFeatureList.d.ts.map +1 -0
- package/dist/default/CategoryFeatureList.js +28 -0
- package/dist/default/CategoryFeatureList.js.map +1 -0
- package/dist/default/CategoryPage.d.ts +43 -0
- package/dist/default/CategoryPage.d.ts.map +1 -0
- package/dist/default/CategoryPage.js +21 -0
- package/dist/default/CategoryPage.js.map +1 -0
- package/dist/default/CategoryPickerField.d.ts +26 -0
- package/dist/default/CategoryPickerField.d.ts.map +1 -0
- package/dist/default/CategoryPickerField.js +40 -0
- package/dist/default/CategoryPickerField.js.map +1 -0
- package/dist/default/CategoryTreePane.d.ts +28 -0
- package/dist/default/CategoryTreePane.d.ts.map +1 -0
- package/dist/default/CategoryTreePane.js +25 -0
- package/dist/default/CategoryTreePane.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/index.d.ts +37 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +28 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +25 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +40 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/CategoriesProvider.d.ts +19 -0
- package/dist/headless/CategoriesProvider.d.ts.map +1 -0
- package/dist/headless/CategoriesProvider.js +15 -0
- package/dist/headless/CategoriesProvider.js.map +1 -0
- package/dist/headless/CategoryBreadcrumbs.d.ts +40 -0
- package/dist/headless/CategoryBreadcrumbs.d.ts.map +1 -0
- package/dist/headless/CategoryBreadcrumbs.js +42 -0
- package/dist/headless/CategoryBreadcrumbs.js.map +1 -0
- package/dist/headless/CategoryCarousel.d.ts +48 -0
- package/dist/headless/CategoryCarousel.d.ts.map +1 -0
- package/dist/headless/CategoryCarousel.js +34 -0
- package/dist/headless/CategoryCarousel.js.map +1 -0
- package/dist/headless/CategoryFeatures.d.ts +67 -0
- package/dist/headless/CategoryFeatures.d.ts.map +1 -0
- package/dist/headless/CategoryFeatures.js +46 -0
- package/dist/headless/CategoryFeatures.js.map +1 -0
- package/dist/headless/CategoryPicker.d.ts +69 -0
- package/dist/headless/CategoryPicker.d.ts.map +1 -0
- package/dist/headless/CategoryPicker.js +86 -0
- package/dist/headless/CategoryPicker.js.map +1 -0
- package/dist/headless/CategoryTree.d.ts +55 -0
- package/dist/headless/CategoryTree.d.ts.map +1 -0
- package/dist/headless/CategoryTree.js +51 -0
- package/dist/headless/CategoryTree.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +22 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +31 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +25 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +74 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +407 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +207 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +89 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +134 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +41 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +90 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +78 -0
- package/dist/index.js.map +1 -0
- package/dist/model/catalogStore.d.ts +23 -0
- package/dist/model/catalogStore.d.ts.map +1 -0
- package/dist/model/catalogStore.js +85 -0
- package/dist/model/catalogStore.js.map +1 -0
- package/dist/model/catalogSync.d.ts +49 -0
- package/dist/model/catalogSync.d.ts.map +1 -0
- package/dist/model/catalogSync.js +64 -0
- package/dist/model/catalogSync.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queries.d.ts +124 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +147 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +30 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +16 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +31 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/nav/manifest.d.ts +31 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +37 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +125 -0
- package/manifest.json +918 -0
- package/nav-manifest.json +44 -0
- package/package.json +119 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/categoriesApi.ts +179 -0
- package/src/api/generated/schema.ts +2323 -0
- package/src/api/types.ts +99 -0
- package/src/catalog/labels.ts +129 -0
- package/src/catalog/pks.ts +35 -0
- package/src/catalog/sync.ts +169 -0
- package/src/catalog/tree.ts +242 -0
- package/src/default/CatalogPage.tsx +40 -0
- package/src/default/CategoryBreadcrumbsBar.tsx +93 -0
- package/src/default/CategoryCarouselStrip.tsx +102 -0
- package/src/default/CategoryFeatureList.tsx +125 -0
- package/src/default/CategoryPage.tsx +114 -0
- package/src/default/CategoryPickerField.tsx +173 -0
- package/src/default/CategoryTreePane.tsx +134 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/index.ts +36 -0
- package/src/default/theme.tsx +67 -0
- package/src/default/types.ts +12 -0
- package/src/headless/CategoriesProvider.tsx +20 -0
- package/src/headless/CategoryBreadcrumbs.tsx +78 -0
- package/src/headless/CategoryCarousel.tsx +80 -0
- package/src/headless/CategoryFeatures.tsx +100 -0
- package/src/headless/CategoryPicker.tsx +173 -0
- package/src/headless/CategoryTree.tsx +115 -0
- package/src/i18n/errorsMap.ts +46 -0
- package/src/i18n/es.ts +92 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +238 -0
- package/src/i18n/generated/errors.json +495 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +156 -0
- package/src/i18n/ru.ts +110 -0
- package/src/index.ts +191 -0
- package/src/model/catalogStore.ts +102 -0
- package/src/model/catalogSync.ts +115 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queries.ts +226 -0
- package/src/model/queryKeys.ts +50 -0
- package/src/model/runtime.ts +39 -0
- package/src/nav/manifest.ts +66 -0
- package/tsconfig.json +26 -0
package/src/api/types.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire shapes of `stapel-categories`, taken from the generated schema
|
|
3
|
+
* wherever the generated schema is TRUE, and hand-mirrored — with the reason
|
|
4
|
+
* written down — in the one place it is not.
|
|
5
|
+
*
|
|
6
|
+
* ── The one place the generated types are wrong: `config.type` ─────────────
|
|
7
|
+
*
|
|
8
|
+
* `FeatureCompact.config` is `{"allOf": [{"$ref": "FeatureConfig"}]}`, and
|
|
9
|
+
* `FeatureConfig` is a `oneOf` over ten config schemas carrying
|
|
10
|
+
* `discriminator: {propertyName: "type", mapping: {"null": ConvertibleUnitConfig}}`
|
|
11
|
+
* (stapel-categories `docs/schema.json`). That mapping is malformed — it has
|
|
12
|
+
* ONE entry, keyed `"null"`, instead of the ten type slugs — so
|
|
13
|
+
* openapi-typescript does two things to the generated surface:
|
|
14
|
+
*
|
|
15
|
+
* 1. it strips the discriminator from every use site, emitting
|
|
16
|
+
* `Omit<components["schemas"]["FeatureConfig"], "type">`; and
|
|
17
|
+
* 2. it re-adds a SYNTHETIC discriminant on each member, so the generated
|
|
18
|
+
* `IntConfig` declares `type: "IntConfig"` where the wire sends
|
|
19
|
+
* `type: "int"` (see `generated/schema.ts`, "discriminator enum property
|
|
20
|
+
* added by openapi-typescript").
|
|
21
|
+
*
|
|
22
|
+
* A pair that typed features off the generated union would therefore hand
|
|
23
|
+
* `@stapel/attributes-react` a `config` with no `type` at all, and any
|
|
24
|
+
* narrowing written against it would compare the value to a string the server
|
|
25
|
+
* never sends. So the features surface is typed through attributes-react's
|
|
26
|
+
* hand-mirrored `FeatureDef` / `FeatureConfig` (`attributes-react/src/types.ts`,
|
|
27
|
+
* itself checked against the engine's generated golden corpus) — the same
|
|
28
|
+
* shapes the value editors, the mirror and the formatter already switch on.
|
|
29
|
+
* Everything else here comes from the generated schema untouched.
|
|
30
|
+
*
|
|
31
|
+
* The upstream ask is recorded rather than worked around twice: emit the ten
|
|
32
|
+
* `type` slugs in `discriminator.mapping`, and both of the above disappear.
|
|
33
|
+
*/
|
|
34
|
+
import type { FeatureConfig, FeatureDef } from "@stapel/attributes-react";
|
|
35
|
+
import type { components } from "./generated/schema.js";
|
|
36
|
+
|
|
37
|
+
export type Schemas = components["schemas"];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One category row, exactly as the list / children / carousel endpoints send
|
|
41
|
+
* it (`CategorySerializer`).
|
|
42
|
+
*
|
|
43
|
+
* Three fields decide what a storefront may do with it, and each is a trap if
|
|
44
|
+
* read casually:
|
|
45
|
+
*
|
|
46
|
+
* - `name` is a **translation key**, not a label, whenever `translatable` is
|
|
47
|
+
* true — see `catalog/labels.ts`. Nothing on this endpoint resolves it.
|
|
48
|
+
* - `tn_ancestors_pks` / `tn_children_pks` are django-treenode's
|
|
49
|
+
* COMMA-JOINED PK STRINGS (`treenode/utils.py: PKS_SEPARATOR = ","`), typed
|
|
50
|
+
* `string` here because that is what arrives — `""` for a root. Parse them
|
|
51
|
+
* with `parseTreenodePks`, never with `JSON.parse` and never by assuming an
|
|
52
|
+
* array.
|
|
53
|
+
* - `deleted` is a TOMBSTONE flag, not an absence: a soft-deleted row is still
|
|
54
|
+
* served (the list endpoint's `include_deleted` defaults to **true**), which
|
|
55
|
+
* is exactly what makes the delta protocol work and exactly what shows a
|
|
56
|
+
* deleted category in a menu if nobody filters.
|
|
57
|
+
*/
|
|
58
|
+
export type Category = Schemas["Category"];
|
|
59
|
+
|
|
60
|
+
/** The `{pagination, revisions, results}` envelope of `RevisionPagination`. */
|
|
61
|
+
export type CategoryPage = Schemas["PaginatedCategoryList"];
|
|
62
|
+
|
|
63
|
+
/** `revisions` — the delta-sync bookkeeping half of the envelope. */
|
|
64
|
+
export type CategoryRevisions = CategoryPage["revisions"];
|
|
65
|
+
|
|
66
|
+
/** `GET /categories/revision/` — `{revision}`, the table's current maximum. */
|
|
67
|
+
export type MaxRevision = Schemas["MaxRevision"];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One resolved feature of a category (own + inherited, deduplicated by slug,
|
|
71
|
+
* in the category's own order first).
|
|
72
|
+
*
|
|
73
|
+
* Structurally `FeatureCompact`, but typed as attributes-react's `FeatureDef`
|
|
74
|
+
* for the discriminator reason at the top of this file. The two agree field
|
|
75
|
+
* for field; only `config` differs, and only in the direction of the truth.
|
|
76
|
+
*
|
|
77
|
+
* `config` arrives **verbatim**, NOT through `get_config_with_defaults()`
|
|
78
|
+
* (`FeatureCompactSerializer.get_config`) — an absent key means "the type's
|
|
79
|
+
* default", never "off". attributes-react owns those defaults; this pair does
|
|
80
|
+
* not re-state them.
|
|
81
|
+
*/
|
|
82
|
+
export type CategoryFeature = FeatureDef;
|
|
83
|
+
|
|
84
|
+
export type { FeatureConfig };
|
|
85
|
+
|
|
86
|
+
/** Query parameters of `GET /categories/api/v1/categories/`. */
|
|
87
|
+
export interface CategoryListParams {
|
|
88
|
+
/** Exclusive lower bound: rows with `revision > minRevision`. Omit for a
|
|
89
|
+
* full sync. `0` is NOT the same as omitting it — it skips unsynced legacy
|
|
90
|
+
* rows whose revision is still 0. */
|
|
91
|
+
readonly minRevision?: number;
|
|
92
|
+
/** Inclusive upper bound. The pair pins it across a multi-page walk so a
|
|
93
|
+
* write landing mid-walk cannot shift page boundaries under the reader. */
|
|
94
|
+
readonly maxRevision?: number;
|
|
95
|
+
/** Default on the server is **true**. */
|
|
96
|
+
readonly includeDeleted?: boolean;
|
|
97
|
+
readonly page?: number;
|
|
98
|
+
readonly pageSize?: number;
|
|
99
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NAMES ARE TRANSLATION KEYS. NOTHING ON THE WIRE RESOLVES THEM.
|
|
3
|
+
*
|
|
4
|
+
* This is the fact a storefront most needs stated plainly, so it is stated
|
|
5
|
+
* here and mirrored honestly rather than papered over.
|
|
6
|
+
*
|
|
7
|
+
* `stapel-categories` stores keys, never a catalogue: "This module stores
|
|
8
|
+
* translation **keys** (e.g. `category.electronics`); it never owns a
|
|
9
|
+
* translation catalog" (`translation.py`). Rendering runs through a
|
|
10
|
+
* `DISPLAY_TRANSLATOR` seam whose default is the identity function, and — this
|
|
11
|
+
* is the part that decides the client's behaviour — **no serializer calls it**.
|
|
12
|
+
* `translate()` has exactly two call sites in the module, `Category.__str__`
|
|
13
|
+
* and the admin's memoized feature label (`models.py`, `translation.py`); the
|
|
14
|
+
* REST layer serializes `name` straight off the model. So:
|
|
15
|
+
*
|
|
16
|
+
* GET /categories/api/v1/categories/ → { "name": "category.electronics" }
|
|
17
|
+
*
|
|
18
|
+
* even on a deployment that has configured a real translator. There is no
|
|
19
|
+
* `?lang=`, no `Accept-Language` handling, and no second endpoint that returns
|
|
20
|
+
* resolved labels: `GET /categories/translation-keys/` is `IsServiceRequest`
|
|
21
|
+
* and returns the KEYS with admin refs — it is the extraction feed for
|
|
22
|
+
* translators, the opposite direction.
|
|
23
|
+
*
|
|
24
|
+
* ── Which strings are keys ─────────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* The wire says so per row, and the pair reads the flag instead of guessing:
|
|
27
|
+
*
|
|
28
|
+
* - a category's `name` is a key **iff `translatable` is true** (default
|
|
29
|
+
* `true`; the field's own help text is "If True, category name is a
|
|
30
|
+
* translation key").
|
|
31
|
+
* - a feature's `name` is a key iff its `translate` mode is `"all"` or
|
|
32
|
+
* `"title"`; `"none"` means the stored string IS the label.
|
|
33
|
+
* - a feature's option labels and its `placeholder`/`prefix`/`postfix`
|
|
34
|
+
* config strings are keys only under `translate: "all"`, and only while
|
|
35
|
+
* `config.translatable_options` is not false
|
|
36
|
+
* (`translation_keys.py: _extract_option_keys_as_list`).
|
|
37
|
+
*
|
|
38
|
+
* ── What this pair does about it ───────────────────────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* It resolves through the HOST's i18n engine — the same `useT` every other key
|
|
41
|
+
* in the fleet goes through — and it does NOT ship a catalogue: category names
|
|
42
|
+
* are a deployment's content, not a library's chrome, so `category.electronics`
|
|
43
|
+
* belongs in the host's bundle beside its own copy, not in
|
|
44
|
+
* `@stapel/categories-react/i18n/ru`.
|
|
45
|
+
*
|
|
46
|
+
* And when the key does not resolve, the key is shown. That is deliberate.
|
|
47
|
+
* The alternative — falling back to the slug, or to a prettified
|
|
48
|
+
* `"category.electronics" → "Electronics"` — invents a label the operator
|
|
49
|
+
* never approved and hides a missing translation behind something that looks
|
|
50
|
+
* finished. A visible `category.electronics` gets fixed; a plausible-looking
|
|
51
|
+
* "Electronics" ships for a year in the wrong language.
|
|
52
|
+
*/
|
|
53
|
+
import type { Category, CategoryFeature } from "../api/types.js";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How a display string reached the screen — the honest answer, carried instead
|
|
57
|
+
* of being flattened into a string.
|
|
58
|
+
*
|
|
59
|
+
* `"key"` the value is a translation key; run it through i18n.
|
|
60
|
+
* `"literal"` the row opted out of translation; the value is the label.
|
|
61
|
+
*/
|
|
62
|
+
export type CategoryLabelKind = "key" | "literal";
|
|
63
|
+
|
|
64
|
+
export interface CategoryLabel {
|
|
65
|
+
readonly kind: CategoryLabelKind;
|
|
66
|
+
/** The key to translate, or the literal to print. */
|
|
67
|
+
readonly value: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A category's display string and what to do with it.
|
|
72
|
+
*
|
|
73
|
+
* `translatable` is optional in the schema and defaults to `true` on the model,
|
|
74
|
+
* so an absent flag means KEY. Guessing "literal" for an absent flag is the
|
|
75
|
+
* wrong-way-round failure: it prints `category.electronics` at a visitor on a
|
|
76
|
+
* fully translated deployment.
|
|
77
|
+
*/
|
|
78
|
+
export function categoryLabel(category: Category): CategoryLabel {
|
|
79
|
+
return {
|
|
80
|
+
kind: category.translatable === false ? "literal" : "key",
|
|
81
|
+
value: category.name,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A feature's display string and what to do with it.
|
|
87
|
+
*
|
|
88
|
+
* `translate: "none"` is the only mode that makes the name a literal;
|
|
89
|
+
* `"title"` and `"all"` both translate it, and the difference between them is
|
|
90
|
+
* about the feature's OPTIONS, which are attributes-react's business, not
|
|
91
|
+
* this function's. An absent mode means `"all"` (the model default).
|
|
92
|
+
*/
|
|
93
|
+
export function featureLabel(feature: CategoryFeature): CategoryLabel {
|
|
94
|
+
return {
|
|
95
|
+
kind: feature.translate === "none" ? "literal" : "key",
|
|
96
|
+
// `name` falls back to `slug` server-side (`FeatureDef.__post_init__`);
|
|
97
|
+
// mirror that rather than rendering an empty label.
|
|
98
|
+
value: feature.name ?? feature.slug,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Are this feature's option labels translation keys?
|
|
104
|
+
*
|
|
105
|
+
* Only under `translate: "all"`, and only while the config has not opted out
|
|
106
|
+
* with `translatable_options: false`. A consumer that labels facet values (the
|
|
107
|
+
* search pair does) needs both halves of that: translating an opted-out option
|
|
108
|
+
* shows the raw key, and NOT translating an opted-in one shows the raw key
|
|
109
|
+
* too — same symptom, opposite fix.
|
|
110
|
+
*/
|
|
111
|
+
export function featureOptionsAreKeys(feature: CategoryFeature): boolean {
|
|
112
|
+
if ((feature.translate ?? "all") !== "all") return false;
|
|
113
|
+
const config = feature.config;
|
|
114
|
+
return config?.["translatable_options"] !== false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a label with a translator, without pretending about misses.
|
|
119
|
+
*
|
|
120
|
+
* `translate` is the host's `t` (core's `useT()`); i18n engines return the key
|
|
121
|
+
* itself for a miss, which is exactly the behaviour this pair wants and
|
|
122
|
+
* documents above.
|
|
123
|
+
*/
|
|
124
|
+
export function renderCategoryLabel(
|
|
125
|
+
label: CategoryLabel,
|
|
126
|
+
translate: (key: string) => string
|
|
127
|
+
): string {
|
|
128
|
+
return label.kind === "key" ? translate(label.value) : label.value;
|
|
129
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* django-treenode's ancestry columns, parsed.
|
|
3
|
+
*
|
|
4
|
+
* `tn_ancestors_pks`, `tn_children_pks`, `tn_descendants_pks` and
|
|
5
|
+
* `tn_siblings_pks` are `TextField`s holding a COMMA-JOINED list of primary
|
|
6
|
+
* keys (`treenode/utils.py`: `PKS_SEPARATOR = ","`, `join_pks`, `split_pks`),
|
|
7
|
+
* and drf-spectacular therefore types them `string` in the schema. The spec
|
|
8
|
+
* (§4.3) describes them as if they were arrays; on the wire they are `"1,7,12"`
|
|
9
|
+
* and `""`.
|
|
10
|
+
*
|
|
11
|
+
* Root-first ordering is a property of how treenode writes the column
|
|
12
|
+
* (`update_tree` walks down from the root), and the whole breadcrumb depends
|
|
13
|
+
* on it — so it is asserted in `test/tree.test.ts` against a real fixture
|
|
14
|
+
* rather than assumed here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a treenode pks column into numeric ids, root-first.
|
|
19
|
+
*
|
|
20
|
+
* Empty string → `[]` (a root has no ancestors; a leaf has no children).
|
|
21
|
+
* Non-numeric fragments are dropped rather than turned into `NaN`: a `NaN`
|
|
22
|
+
* silently fails every `===` against a real id, which reads as "the parent is
|
|
23
|
+
* missing" — the same wrong answer, two hours later.
|
|
24
|
+
*/
|
|
25
|
+
export function parseTreenodePks(column: string | null | undefined): readonly number[] {
|
|
26
|
+
if (column === null || column === undefined || column === "") return [];
|
|
27
|
+
const out: number[] = [];
|
|
28
|
+
for (const part of column.split(",")) {
|
|
29
|
+
const trimmed = part.trim();
|
|
30
|
+
if (trimmed === "") continue;
|
|
31
|
+
const value = Number(trimmed);
|
|
32
|
+
if (Number.isInteger(value)) out.push(value);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The delta protocol, as pure state transitions.
|
|
3
|
+
*
|
|
4
|
+
* `stapel-categories` documents the flow on the viewset itself (`views.py`,
|
|
5
|
+
* `CategoryViewSet` docstring): full `GET /categories/` → store
|
|
6
|
+
* `revisions.global_max` → later `GET /categories/?min_revision=<stored>` →
|
|
7
|
+
* drop the rows that come back `deleted: true`. The spec's verdict (§4.3) is
|
|
8
|
+
* that the result lives in `createRepository()` rather than being refetched
|
|
9
|
+
* per page, because without it every storefront page pulls the whole
|
|
10
|
+
* catalogue.
|
|
11
|
+
*
|
|
12
|
+
* Three things the documented flow does not say, all of which change the code:
|
|
13
|
+
*
|
|
14
|
+
* 1. **`revisions.deleted_ids` is the authoritative tombstone channel**, not
|
|
15
|
+
* the `deleted: true` rows. The rows are PAGINATED — a tombstone can land
|
|
16
|
+
* on page 3 of a walk that stopped at page 2 — while `deleted_ids` is
|
|
17
|
+
* computed unpaginated over `deleted=True AND revision > min_revision`
|
|
18
|
+
* (`stapel_core/django/api/revision.py`). It is also a snapshot of the
|
|
19
|
+
* CURRENT flag, so a category deleted and then restored is simply absent
|
|
20
|
+
* from it. Both channels are applied; the list is the one that is complete.
|
|
21
|
+
* On a FULL sync `deleted_ids` is `[]` by construction (the server only
|
|
22
|
+
* computes it when `min_revision` was sent), which is why dropping
|
|
23
|
+
* `deleted: true` rows still matters.
|
|
24
|
+
*
|
|
25
|
+
* 2. **A multi-page walk must pin its upper bound.** Pages are ordered by
|
|
26
|
+
* `revision` and filtered at request time; a write landing between page 1
|
|
27
|
+
* and page 2 shifts every subsequent page boundary, and the walk silently
|
|
28
|
+
* skips a row. `max_revision` exists for exactly this and the documented
|
|
29
|
+
* flow never mentions it: `nextPageRequest` pins the window to the
|
|
30
|
+
* `global_max` observed on the FIRST page and stores that as the new
|
|
31
|
+
* cursor. Anything written during the walk is picked up by the next delta.
|
|
32
|
+
*
|
|
33
|
+
* 3. **`revisions.global_max` is a property of the TABLE, not of the page** —
|
|
34
|
+
* it is `Max(revision)` over the whole model, so it is already the right
|
|
35
|
+
* cursor even when the page came back empty.
|
|
36
|
+
*/
|
|
37
|
+
import type { Category, CategoryPage } from "../api/types.js";
|
|
38
|
+
import type { CategoryListParams } from "../api/types.js";
|
|
39
|
+
|
|
40
|
+
/** The persisted catalogue: rows plus the cursor they are current as of. */
|
|
41
|
+
export interface CategorySnapshot {
|
|
42
|
+
/** Storage-format marker. A snapshot written by an older shape is discarded
|
|
43
|
+
* rather than migrated — it is a cache, and a full resync costs one request. */
|
|
44
|
+
readonly version: 1;
|
|
45
|
+
/** `revisions.global_max` as of the last completed sync. */
|
|
46
|
+
readonly cursor: number;
|
|
47
|
+
/** Live rows, tombstones already applied. Order is not meaningful. */
|
|
48
|
+
readonly rows: readonly Category[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An empty snapshot — the state before the first sync. */
|
|
52
|
+
export const EMPTY_SNAPSHOT: CategorySnapshot = {
|
|
53
|
+
version: 1,
|
|
54
|
+
cursor: 0,
|
|
55
|
+
rows: [],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A snapshot with no rows has never synced, whatever its cursor says. */
|
|
59
|
+
export function isEmptySnapshot(snapshot: CategorySnapshot): boolean {
|
|
60
|
+
return snapshot.rows.length === 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Accept a value read back from persistence, or reject it.
|
|
65
|
+
*
|
|
66
|
+
* A repository read can hand back anything: a snapshot written by a previous
|
|
67
|
+
* version of this pair, a half-written value, `undefined` after a cleared
|
|
68
|
+
* origin. Every one of those must degrade to "no snapshot" — a cache that
|
|
69
|
+
* throws on read is worse than no cache.
|
|
70
|
+
*/
|
|
71
|
+
export function parseSnapshot(value: unknown): CategorySnapshot | undefined {
|
|
72
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
73
|
+
const candidate = value as Partial<CategorySnapshot>;
|
|
74
|
+
if (candidate.version !== 1) return undefined;
|
|
75
|
+
if (typeof candidate.cursor !== "number" || !Number.isFinite(candidate.cursor)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(candidate.rows)) return undefined;
|
|
79
|
+
for (const row of candidate.rows) {
|
|
80
|
+
if (typeof row !== "object" || row === null) return undefined;
|
|
81
|
+
if (typeof (row as Category).id !== "number") return undefined;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
version: 1,
|
|
85
|
+
cursor: candidate.cursor,
|
|
86
|
+
rows: candidate.rows as readonly Category[],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The request that starts a sync from `snapshot`.
|
|
92
|
+
*
|
|
93
|
+
* A cold start asks for everything with `include_deleted: false` — nothing is
|
|
94
|
+
* cached, so a tombstone has nothing to evict and the payload is smaller. A
|
|
95
|
+
* warm start asks for the delta with `include_deleted: true`, because there
|
|
96
|
+
* the tombstones are the point.
|
|
97
|
+
*/
|
|
98
|
+
export function firstPageRequest(snapshot: CategorySnapshot): CategoryListParams {
|
|
99
|
+
if (isEmptySnapshot(snapshot)) {
|
|
100
|
+
return { includeDeleted: false, page: 1 };
|
|
101
|
+
}
|
|
102
|
+
return { minRevision: snapshot.cursor, includeDeleted: true, page: 1 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The request for the next page of an in-flight walk, or `undefined` when the
|
|
107
|
+
* walk is done.
|
|
108
|
+
*
|
|
109
|
+
* `maxRevision` pins the window to what the first page reported, so the walk
|
|
110
|
+
* reads one consistent snapshot of the table instead of a moving one.
|
|
111
|
+
*/
|
|
112
|
+
export function nextPageRequest(
|
|
113
|
+
previous: CategoryListParams,
|
|
114
|
+
page: CategoryPage
|
|
115
|
+
): CategoryListParams | undefined {
|
|
116
|
+
if (!page.pagination.has_next) return undefined;
|
|
117
|
+
return {
|
|
118
|
+
...previous,
|
|
119
|
+
page: page.pagination.page + 1,
|
|
120
|
+
maxRevision: previous.maxRevision ?? page.revisions.global_max,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Fold one page into an accumulator.
|
|
126
|
+
*
|
|
127
|
+
* There is one merge rule, and the FULL/DELTA distinction lives in what the
|
|
128
|
+
* caller starts from, not in a flag here: a delta folds into the stored
|
|
129
|
+
* snapshot, a full sync folds into {@link EMPTY_SNAPSHOT}. That is the whole
|
|
130
|
+
* difference — a row the server no longer lists is gone from a full listing
|
|
131
|
+
* precisely because it was never folded in. Expressing it as a flag on the
|
|
132
|
+
* merge invites the two silent failures instead: a delta treated as full
|
|
133
|
+
* empties the catalogue, a full treated as delta resurrects rows deleted while
|
|
134
|
+
* the client was away.
|
|
135
|
+
*
|
|
136
|
+
* The new snapshot is not published until the walk COMPLETES (see
|
|
137
|
+
* `model/catalogSync.ts`), so a walk that fails halfway leaves the previous
|
|
138
|
+
* catalogue on screen rather than a truncated one.
|
|
139
|
+
*
|
|
140
|
+
* The cursor only ever moves FORWARD, and never past `cursorLimit`:
|
|
141
|
+
* `global_max` is the table's live maximum, which can already exceed the
|
|
142
|
+
* window a walk pinned with `max_revision`. Taking it verbatim would record
|
|
143
|
+
* having read rows the walk never asked for.
|
|
144
|
+
*/
|
|
145
|
+
export function applyCategoryPage(
|
|
146
|
+
accumulator: CategorySnapshot,
|
|
147
|
+
page: CategoryPage,
|
|
148
|
+
options: { readonly cursorLimit?: number } = {}
|
|
149
|
+
): CategorySnapshot {
|
|
150
|
+
const byId = new Map<number, Category>();
|
|
151
|
+
for (const row of accumulator.rows) byId.set(row.id, row);
|
|
152
|
+
|
|
153
|
+
for (const row of page.results) {
|
|
154
|
+
if (row.deleted === true) byId.delete(row.id);
|
|
155
|
+
else byId.set(row.id, row);
|
|
156
|
+
}
|
|
157
|
+
for (const id of page.revisions.deleted_ids) byId.delete(id);
|
|
158
|
+
|
|
159
|
+
const observed =
|
|
160
|
+
options.cursorLimit !== undefined
|
|
161
|
+
? Math.min(page.revisions.global_max, options.cursorLimit)
|
|
162
|
+
: page.revisions.global_max;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
version: 1,
|
|
166
|
+
cursor: Math.max(accumulator.cursor, observed),
|
|
167
|
+
rows: [...byId.values()],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tree the server does not build.
|
|
3
|
+
*
|
|
4
|
+
* `stapel-categories` has no tree endpoint. `GET /categories/` returns FLAT
|
|
5
|
+
* rows ordered by `revision`, each carrying `tn_parent`, `tn_priority` and the
|
|
6
|
+
* treenode ancestry columns — the client assembles the hierarchy (spec §4.3).
|
|
7
|
+
* Everything in this file is pure: no React, no fetch, no storage, so the same
|
|
8
|
+
* functions serve a browser, an SSR render and a test.
|
|
9
|
+
*
|
|
10
|
+
* ── Three server facts this module encodes, each a bug if forgotten ────────
|
|
11
|
+
*
|
|
12
|
+
* 1. **`deleted` rows are served.** `include_deleted` defaults to `true`, and
|
|
13
|
+
* the delta protocol NEEDS it that way. A tree built without filtering
|
|
14
|
+
* shows soft-deleted categories in a menu.
|
|
15
|
+
* 2. **`active: false` rows are served too**, and nothing on the list endpoint
|
|
16
|
+
* filters them — only `carousel` does. "Active" is the storefront's
|
|
17
|
+
* visibility switch, so the public tree drops them by default and the
|
|
18
|
+
* option to keep them is explicit.
|
|
19
|
+
* 3. **Order is `tn_priority` DESCENDING**, which is what both `children` and
|
|
20
|
+
* `carousel` do server-side (`views.py: order_by("-tn_priority")`). Ties
|
|
21
|
+
* are broken by `id` so a rebuild is deterministic — priority defaults to
|
|
22
|
+
* `0`, so a catalogue that never set it is ALL ties, and an unstable order
|
|
23
|
+
* there means the menu reshuffles between renders.
|
|
24
|
+
*/
|
|
25
|
+
import type { Category } from "../api/types.js";
|
|
26
|
+
import { parseTreenodePks } from "./pks.js";
|
|
27
|
+
|
|
28
|
+
/** One node of the assembled tree. */
|
|
29
|
+
export interface CategoryNode {
|
|
30
|
+
readonly category: Category;
|
|
31
|
+
readonly id: number;
|
|
32
|
+
readonly depth: number;
|
|
33
|
+
readonly children: readonly CategoryNode[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A built catalogue: the rows, plus the lookups a screen actually asks for.
|
|
38
|
+
*
|
|
39
|
+
* Built once per snapshot and shared, because every one of these questions is
|
|
40
|
+
* asked on every render of every category page.
|
|
41
|
+
*/
|
|
42
|
+
export interface CategoryIndex {
|
|
43
|
+
/** Roots, in display order. */
|
|
44
|
+
readonly roots: readonly CategoryNode[];
|
|
45
|
+
/** Every node by id, including ones filtered out of `roots`' subtrees? No —
|
|
46
|
+
* only the nodes that survived the filter. What is not in the tree is not
|
|
47
|
+
* addressable. */
|
|
48
|
+
readonly byId: ReadonlyMap<number, CategoryNode>;
|
|
49
|
+
/** Slug → node. See `resolveCategorySlug` for the collision rule. */
|
|
50
|
+
readonly bySlug: ReadonlyMap<string, CategoryNode>;
|
|
51
|
+
/** How many rows the source snapshot had before filtering. */
|
|
52
|
+
readonly totalRows: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BuildCategoryTreeOptions {
|
|
56
|
+
/** Keep `deleted: true` rows. Default `false`. */
|
|
57
|
+
readonly includeDeleted?: boolean;
|
|
58
|
+
/** Keep `active: false` rows. Default `false` — the storefront's answer.
|
|
59
|
+
* A catalogue admin passes `true`. */
|
|
60
|
+
readonly includeInactive?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function displayOrder(a: Category, b: Category): number {
|
|
64
|
+
const pa = a.tn_priority ?? 0;
|
|
65
|
+
const pb = b.tn_priority ?? 0;
|
|
66
|
+
if (pa !== pb) return pb - pa;
|
|
67
|
+
return a.id - b.id;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function keeps(row: Category, options: BuildCategoryTreeOptions): boolean {
|
|
71
|
+
if (row.deleted === true && options.includeDeleted !== true) return false;
|
|
72
|
+
// `active` is optional in the schema and defaults to true on the model, so
|
|
73
|
+
// the test is "not explicitly false" — an absent field is an active
|
|
74
|
+
// category, never a hidden one.
|
|
75
|
+
if (row.active === false && options.includeInactive !== true) return false;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Assemble flat rows into a tree.
|
|
81
|
+
*
|
|
82
|
+
* A row whose `tn_parent` is not in the surviving set becomes a ROOT rather
|
|
83
|
+
* than disappearing. That is not tidiness — it is the only safe answer to the
|
|
84
|
+
* two ways it legitimately happens: a delta page that brought a child before
|
|
85
|
+
* its parent, and a parent filtered out by `active: false` while the child was
|
|
86
|
+
* left active by a catalogue editor. Dropping the subtree would delete a live
|
|
87
|
+
* branch of the catalogue from the menu and give nobody a reason.
|
|
88
|
+
*/
|
|
89
|
+
export function buildCategoryTree(
|
|
90
|
+
rows: Iterable<Category>,
|
|
91
|
+
options: BuildCategoryTreeOptions = {}
|
|
92
|
+
): CategoryIndex {
|
|
93
|
+
const all = [...rows];
|
|
94
|
+
const kept = all.filter((row) => keeps(row, options)).sort(displayOrder);
|
|
95
|
+
|
|
96
|
+
const childrenOf = new Map<number, Category[]>();
|
|
97
|
+
const present = new Set<number>(kept.map((row) => row.id));
|
|
98
|
+
const roots: Category[] = [];
|
|
99
|
+
|
|
100
|
+
for (const row of kept) {
|
|
101
|
+
const parent = row.tn_parent;
|
|
102
|
+
if (parent === null || parent === undefined || !present.has(parent)) {
|
|
103
|
+
roots.push(row);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const bucket = childrenOf.get(parent);
|
|
107
|
+
if (bucket === undefined) childrenOf.set(parent, [row]);
|
|
108
|
+
else bucket.push(row);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const byId = new Map<number, CategoryNode>();
|
|
112
|
+
const bySlug = new Map<string, CategoryNode>();
|
|
113
|
+
|
|
114
|
+
// Iterative build (not recursion): a catalogue is authored data, and a cycle
|
|
115
|
+
// introduced by a bad `tn_parent` would blow the stack instead of rendering.
|
|
116
|
+
// `seen` makes a cycle a finite, visible truncation.
|
|
117
|
+
function build(row: Category, depth: number, seen: ReadonlySet<number>): CategoryNode {
|
|
118
|
+
const kids = childrenOf.get(row.id) ?? [];
|
|
119
|
+
const nextSeen = new Set(seen).add(row.id);
|
|
120
|
+
const node: CategoryNode = {
|
|
121
|
+
category: row,
|
|
122
|
+
id: row.id,
|
|
123
|
+
depth,
|
|
124
|
+
children: kids
|
|
125
|
+
.filter((kid) => !nextSeen.has(kid.id))
|
|
126
|
+
.map((kid) => build(kid, depth + 1, nextSeen)),
|
|
127
|
+
};
|
|
128
|
+
byId.set(row.id, node);
|
|
129
|
+
if (row.slug !== "" && !bySlug.has(row.slug)) bySlug.set(row.slug, node);
|
|
130
|
+
return node;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const builtRoots = roots.map((row) => build(row, 0, new Set<number>()));
|
|
134
|
+
|
|
135
|
+
// A row that no root can reach is a row inside a PARENT CYCLE (`a.parent =
|
|
136
|
+
// b, b.parent = a` — authored data, so possible). The recursion above
|
|
137
|
+
// refuses to follow the cycle, which keeps the stack safe but would drop
|
|
138
|
+
// those rows entirely: they would vanish from the menu with no error
|
|
139
|
+
// anywhere. Promote whatever is left to roots instead, so a broken branch
|
|
140
|
+
// is VISIBLE and fixable rather than absent.
|
|
141
|
+
for (const row of kept) {
|
|
142
|
+
if (byId.has(row.id)) continue;
|
|
143
|
+
builtRoots.push(build(row, 0, new Set<number>()));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { roots: builtRoots, byId, bySlug, totalRows: all.length };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a URL slug to a category — the lookup the SERVER does not offer.
|
|
151
|
+
*
|
|
152
|
+
* `CategoryViewSet` never overrides `lookup_field` and the list endpoint has
|
|
153
|
+
* no slug filter, so `GET /categories/<slug>/` is a 404 and `?slug=` is
|
|
154
|
+
* ignored. The storefront's `/c/:slug` therefore resolves against the synced
|
|
155
|
+
* tree (spec §4.3), which is one more reason the catalogue is cached rather
|
|
156
|
+
* than fetched per page.
|
|
157
|
+
*
|
|
158
|
+
* `Category.slug` is `unique=True` at the model level, so a collision cannot
|
|
159
|
+
* come from the database. It CAN come from the client's own snapshot — a
|
|
160
|
+
* delta that renamed a slug from A to B arrives as one row, and a stale row
|
|
161
|
+
* still holding B has not been evicted yet. First-in-display-order wins,
|
|
162
|
+
* deterministically, and the loser is reachable by id.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveCategorySlug(
|
|
165
|
+
index: CategoryIndex,
|
|
166
|
+
slug: string
|
|
167
|
+
): CategoryNode | undefined {
|
|
168
|
+
return index.bySlug.get(slug);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The path from the root down to `id`, inclusive — the breadcrumb.
|
|
173
|
+
*
|
|
174
|
+
* Walks `tn_parent` through the built index rather than trusting
|
|
175
|
+
* `tn_ancestors_pks`, because the index is what the screen is showing: if a
|
|
176
|
+
* filter removed an ancestor, the crumb must not name a category the visitor
|
|
177
|
+
* cannot open. `categoryAncestorIds` is the other answer — the server's — and
|
|
178
|
+
* the two are compared in `test/tree.test.ts` precisely so a divergence is a
|
|
179
|
+
* red test rather than a subtly wrong crumb.
|
|
180
|
+
*
|
|
181
|
+
* An unknown id gives `[]`, not a partial path: half a breadcrumb reads as a
|
|
182
|
+
* top-level category.
|
|
183
|
+
*/
|
|
184
|
+
export function categoryBreadcrumbs(
|
|
185
|
+
index: CategoryIndex,
|
|
186
|
+
id: number | null | undefined
|
|
187
|
+
): readonly CategoryNode[] {
|
|
188
|
+
if (id === null || id === undefined) return [];
|
|
189
|
+
const start = index.byId.get(id);
|
|
190
|
+
if (start === undefined) return [];
|
|
191
|
+
|
|
192
|
+
const path: CategoryNode[] = [start];
|
|
193
|
+
const seen = new Set<number>([start.id]);
|
|
194
|
+
let cursor = start.category.tn_parent;
|
|
195
|
+
while (cursor !== null && cursor !== undefined && !seen.has(cursor)) {
|
|
196
|
+
const parent = index.byId.get(cursor);
|
|
197
|
+
if (parent === undefined) break;
|
|
198
|
+
path.push(parent);
|
|
199
|
+
seen.add(parent.id);
|
|
200
|
+
cursor = parent.category.tn_parent;
|
|
201
|
+
}
|
|
202
|
+
return path.reverse();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The server's own answer to "who are this row's ancestors", root-first, from
|
|
207
|
+
* `tn_ancestors_pks`.
|
|
208
|
+
*
|
|
209
|
+
* Useful without a built index (a single category fetched on its own), and the
|
|
210
|
+
* cross-check for {@link categoryBreadcrumbs}.
|
|
211
|
+
*/
|
|
212
|
+
export function categoryAncestorIds(category: Category): readonly number[] {
|
|
213
|
+
return parseTreenodePks(category.tn_ancestors_pks);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The server's own answer to "who are this row's children", from
|
|
218
|
+
* `tn_children_pks`.
|
|
219
|
+
*
|
|
220
|
+
* This is the FULL child set, including soft-deleted and inactive rows — the
|
|
221
|
+
* column is maintained by treenode, which knows nothing about either flag. Use
|
|
222
|
+
* it to detect "this category has sub-categories at all"; use the built node's
|
|
223
|
+
* `children` to render them.
|
|
224
|
+
*/
|
|
225
|
+
export function categoryChildIds(category: Category): readonly number[] {
|
|
226
|
+
return parseTreenodePks(category.tn_children_pks);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Depth-first walk of a subtree (or the whole forest), in display order. */
|
|
230
|
+
export function flattenCategoryNodes(
|
|
231
|
+
nodes: readonly CategoryNode[]
|
|
232
|
+
): readonly CategoryNode[] {
|
|
233
|
+
const out: CategoryNode[] = [];
|
|
234
|
+
const walk = (list: readonly CategoryNode[]): void => {
|
|
235
|
+
for (const node of list) {
|
|
236
|
+
out.push(node);
|
|
237
|
+
walk(node.children);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
walk(nodes);
|
|
241
|
+
return out;
|
|
242
|
+
}
|