@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.
Files changed (205) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +194 -0
  3. package/README.md +191 -0
  4. package/dist/api/categoriesApi.d.ts +121 -0
  5. package/dist/api/categoriesApi.d.ts.map +1 -0
  6. package/dist/api/categoriesApi.js +48 -0
  7. package/dist/api/categoriesApi.js.map +1 -0
  8. package/dist/api/generated/schema.d.ts +2321 -0
  9. package/dist/api/generated/schema.d.ts.map +1 -0
  10. package/dist/api/generated/schema.js +2 -0
  11. package/dist/api/generated/schema.js.map +1 -0
  12. package/dist/api/types.d.ts +92 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +2 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/catalog/labels.d.ts +103 -0
  17. package/dist/catalog/labels.d.ts.map +1 -0
  18. package/dist/catalog/labels.js +56 -0
  19. package/dist/catalog/labels.js.map +1 -0
  20. package/dist/catalog/pks.d.ts +25 -0
  21. package/dist/catalog/pks.d.ts.map +1 -0
  22. package/dist/catalog/pks.js +38 -0
  23. package/dist/catalog/pks.js.map +1 -0
  24. package/dist/catalog/sync.d.ts +103 -0
  25. package/dist/catalog/sync.d.ts.map +1 -0
  26. package/dist/catalog/sync.js +114 -0
  27. package/dist/catalog/sync.js.map +1 -0
  28. package/dist/catalog/tree.d.ts +119 -0
  29. package/dist/catalog/tree.d.ts.map +1 -0
  30. package/dist/catalog/tree.js +166 -0
  31. package/dist/catalog/tree.js.map +1 -0
  32. package/dist/default/CatalogPage.d.ts +16 -0
  33. package/dist/default/CatalogPage.d.ts.map +1 -0
  34. package/dist/default/CatalogPage.js +13 -0
  35. package/dist/default/CatalogPage.js.map +1 -0
  36. package/dist/default/CategoryBreadcrumbsBar.d.ts +20 -0
  37. package/dist/default/CategoryBreadcrumbsBar.d.ts.map +1 -0
  38. package/dist/default/CategoryBreadcrumbsBar.js +35 -0
  39. package/dist/default/CategoryBreadcrumbsBar.js.map +1 -0
  40. package/dist/default/CategoryCarouselStrip.d.ts +22 -0
  41. package/dist/default/CategoryCarouselStrip.d.ts.map +1 -0
  42. package/dist/default/CategoryCarouselStrip.js +24 -0
  43. package/dist/default/CategoryCarouselStrip.js.map +1 -0
  44. package/dist/default/CategoryFeatureList.d.ts +23 -0
  45. package/dist/default/CategoryFeatureList.d.ts.map +1 -0
  46. package/dist/default/CategoryFeatureList.js +28 -0
  47. package/dist/default/CategoryFeatureList.js.map +1 -0
  48. package/dist/default/CategoryPage.d.ts +43 -0
  49. package/dist/default/CategoryPage.d.ts.map +1 -0
  50. package/dist/default/CategoryPage.js +21 -0
  51. package/dist/default/CategoryPage.js.map +1 -0
  52. package/dist/default/CategoryPickerField.d.ts +26 -0
  53. package/dist/default/CategoryPickerField.d.ts.map +1 -0
  54. package/dist/default/CategoryPickerField.js +40 -0
  55. package/dist/default/CategoryPickerField.js.map +1 -0
  56. package/dist/default/CategoryTreePane.d.ts +28 -0
  57. package/dist/default/CategoryTreePane.d.ts.map +1 -0
  58. package/dist/default/CategoryTreePane.js +25 -0
  59. package/dist/default/CategoryTreePane.js.map +1 -0
  60. package/dist/default/ErrorAlert.d.ts +9 -0
  61. package/dist/default/ErrorAlert.d.ts.map +1 -0
  62. package/dist/default/ErrorAlert.js +27 -0
  63. package/dist/default/ErrorAlert.js.map +1 -0
  64. package/dist/default/index.d.ts +37 -0
  65. package/dist/default/index.d.ts.map +1 -0
  66. package/dist/default/index.js +28 -0
  67. package/dist/default/index.js.map +1 -0
  68. package/dist/default/theme.d.ts +25 -0
  69. package/dist/default/theme.d.ts.map +1 -0
  70. package/dist/default/theme.js +40 -0
  71. package/dist/default/theme.js.map +1 -0
  72. package/dist/default/types.d.ts +12 -0
  73. package/dist/default/types.d.ts.map +1 -0
  74. package/dist/default/types.js +2 -0
  75. package/dist/default/types.js.map +1 -0
  76. package/dist/headless/CategoriesProvider.d.ts +19 -0
  77. package/dist/headless/CategoriesProvider.d.ts.map +1 -0
  78. package/dist/headless/CategoriesProvider.js +15 -0
  79. package/dist/headless/CategoriesProvider.js.map +1 -0
  80. package/dist/headless/CategoryBreadcrumbs.d.ts +40 -0
  81. package/dist/headless/CategoryBreadcrumbs.d.ts.map +1 -0
  82. package/dist/headless/CategoryBreadcrumbs.js +42 -0
  83. package/dist/headless/CategoryBreadcrumbs.js.map +1 -0
  84. package/dist/headless/CategoryCarousel.d.ts +48 -0
  85. package/dist/headless/CategoryCarousel.d.ts.map +1 -0
  86. package/dist/headless/CategoryCarousel.js +34 -0
  87. package/dist/headless/CategoryCarousel.js.map +1 -0
  88. package/dist/headless/CategoryFeatures.d.ts +67 -0
  89. package/dist/headless/CategoryFeatures.d.ts.map +1 -0
  90. package/dist/headless/CategoryFeatures.js +46 -0
  91. package/dist/headless/CategoryFeatures.js.map +1 -0
  92. package/dist/headless/CategoryPicker.d.ts +69 -0
  93. package/dist/headless/CategoryPicker.d.ts.map +1 -0
  94. package/dist/headless/CategoryPicker.js +86 -0
  95. package/dist/headless/CategoryPicker.js.map +1 -0
  96. package/dist/headless/CategoryTree.d.ts +55 -0
  97. package/dist/headless/CategoryTree.d.ts.map +1 -0
  98. package/dist/headless/CategoryTree.js +51 -0
  99. package/dist/headless/CategoryTree.js.map +1 -0
  100. package/dist/i18n/errorsMap.d.ts +22 -0
  101. package/dist/i18n/errorsMap.d.ts.map +1 -0
  102. package/dist/i18n/errorsMap.js +31 -0
  103. package/dist/i18n/errorsMap.js.map +1 -0
  104. package/dist/i18n/es.d.ts +25 -0
  105. package/dist/i18n/es.d.ts.map +1 -0
  106. package/dist/i18n/es.js +74 -0
  107. package/dist/i18n/es.js.map +1 -0
  108. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  109. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  110. package/dist/i18n/generated/errors.es.gen.js +58 -0
  111. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  112. package/dist/i18n/generated/errors.gen.d.ts +407 -0
  113. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  114. package/dist/i18n/generated/errors.gen.js +207 -0
  115. package/dist/i18n/generated/errors.gen.js.map +1 -0
  116. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  117. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  118. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  119. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  120. package/dist/i18n/keys.d.ts +89 -0
  121. package/dist/i18n/keys.d.ts.map +1 -0
  122. package/dist/i18n/keys.js +134 -0
  123. package/dist/i18n/keys.js.map +1 -0
  124. package/dist/i18n/ru.d.ts +41 -0
  125. package/dist/i18n/ru.d.ts.map +1 -0
  126. package/dist/i18n/ru.js +90 -0
  127. package/dist/i18n/ru.js.map +1 -0
  128. package/dist/index.d.ts +88 -0
  129. package/dist/index.d.ts.map +1 -0
  130. package/dist/index.js +78 -0
  131. package/dist/index.js.map +1 -0
  132. package/dist/model/catalogStore.d.ts +23 -0
  133. package/dist/model/catalogStore.d.ts.map +1 -0
  134. package/dist/model/catalogStore.js +85 -0
  135. package/dist/model/catalogStore.js.map +1 -0
  136. package/dist/model/catalogSync.d.ts +49 -0
  137. package/dist/model/catalogSync.d.ts.map +1 -0
  138. package/dist/model/catalogSync.js +64 -0
  139. package/dist/model/catalogSync.js.map +1 -0
  140. package/dist/model/context.d.ts +11 -0
  141. package/dist/model/context.d.ts.map +1 -0
  142. package/dist/model/context.js +16 -0
  143. package/dist/model/context.js.map +1 -0
  144. package/dist/model/queries.d.ts +124 -0
  145. package/dist/model/queries.d.ts.map +1 -0
  146. package/dist/model/queries.js +147 -0
  147. package/dist/model/queries.js.map +1 -0
  148. package/dist/model/queryKeys.d.ts +30 -0
  149. package/dist/model/queryKeys.d.ts.map +1 -0
  150. package/dist/model/queryKeys.js +16 -0
  151. package/dist/model/queryKeys.js.map +1 -0
  152. package/dist/model/runtime.d.ts +31 -0
  153. package/dist/model/runtime.d.ts.map +1 -0
  154. package/dist/model/runtime.js +6 -0
  155. package/dist/model/runtime.js.map +1 -0
  156. package/dist/nav/manifest.d.ts +31 -0
  157. package/dist/nav/manifest.d.ts.map +1 -0
  158. package/dist/nav/manifest.js +37 -0
  159. package/dist/nav/manifest.js.map +1 -0
  160. package/llms.txt +125 -0
  161. package/manifest.json +918 -0
  162. package/nav-manifest.json +44 -0
  163. package/package.json +119 -0
  164. package/src/analytics/generated/events.json +7 -0
  165. package/src/api/categoriesApi.ts +179 -0
  166. package/src/api/generated/schema.ts +2323 -0
  167. package/src/api/types.ts +99 -0
  168. package/src/catalog/labels.ts +129 -0
  169. package/src/catalog/pks.ts +35 -0
  170. package/src/catalog/sync.ts +169 -0
  171. package/src/catalog/tree.ts +242 -0
  172. package/src/default/CatalogPage.tsx +40 -0
  173. package/src/default/CategoryBreadcrumbsBar.tsx +93 -0
  174. package/src/default/CategoryCarouselStrip.tsx +102 -0
  175. package/src/default/CategoryFeatureList.tsx +125 -0
  176. package/src/default/CategoryPage.tsx +114 -0
  177. package/src/default/CategoryPickerField.tsx +173 -0
  178. package/src/default/CategoryTreePane.tsx +134 -0
  179. package/src/default/ErrorAlert.tsx +46 -0
  180. package/src/default/index.ts +36 -0
  181. package/src/default/theme.tsx +67 -0
  182. package/src/default/types.ts +12 -0
  183. package/src/headless/CategoriesProvider.tsx +20 -0
  184. package/src/headless/CategoryBreadcrumbs.tsx +78 -0
  185. package/src/headless/CategoryCarousel.tsx +80 -0
  186. package/src/headless/CategoryFeatures.tsx +100 -0
  187. package/src/headless/CategoryPicker.tsx +173 -0
  188. package/src/headless/CategoryTree.tsx +115 -0
  189. package/src/i18n/errorsMap.ts +46 -0
  190. package/src/i18n/es.ts +92 -0
  191. package/src/i18n/generated/errors.es.gen.ts +64 -0
  192. package/src/i18n/generated/errors.gen.ts +238 -0
  193. package/src/i18n/generated/errors.json +495 -0
  194. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  195. package/src/i18n/keys.ts +156 -0
  196. package/src/i18n/ru.ts +110 -0
  197. package/src/index.ts +191 -0
  198. package/src/model/catalogStore.ts +102 -0
  199. package/src/model/catalogSync.ts +115 -0
  200. package/src/model/context.tsx +28 -0
  201. package/src/model/queries.ts +226 -0
  202. package/src/model/queryKeys.ts +50 -0
  203. package/src/model/runtime.ts +39 -0
  204. package/src/nav/manifest.ts +66 -0
  205. package/tsconfig.json +26 -0
@@ -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
+ }