@stapel/vocabularies-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 (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/MODULE.md +92 -0
  4. package/README.md +173 -0
  5. package/dist/api/extensions.d.ts +8 -0
  6. package/dist/api/extensions.d.ts.map +1 -0
  7. package/dist/api/extensions.js +8 -0
  8. package/dist/api/extensions.js.map +1 -0
  9. package/dist/api/generated/schema.d.ts +234 -0
  10. package/dist/api/generated/schema.d.ts.map +1 -0
  11. package/dist/api/generated/schema.js +2 -0
  12. package/dist/api/generated/schema.js.map +1 -0
  13. package/dist/api/types.d.ts +23 -0
  14. package/dist/api/types.d.ts.map +1 -0
  15. package/dist/api/types.js +2 -0
  16. package/dist/api/types.js.map +1 -0
  17. package/dist/api/vocabulariesApi.d.ts +14 -0
  18. package/dist/api/vocabulariesApi.d.ts.map +1 -0
  19. package/dist/api/vocabulariesApi.js +4 -0
  20. package/dist/api/vocabulariesApi.js.map +1 -0
  21. package/dist/client.d.ts +49 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +118 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/default/VocabularyTermSelect.d.ts +24 -0
  26. package/dist/default/VocabularyTermSelect.d.ts.map +1 -0
  27. package/dist/default/VocabularyTermSelect.js +75 -0
  28. package/dist/default/VocabularyTermSelect.js.map +1 -0
  29. package/dist/default/index.d.ts +16 -0
  30. package/dist/default/index.d.ts.map +1 -0
  31. package/dist/default/index.js +14 -0
  32. package/dist/default/index.js.map +1 -0
  33. package/dist/default/types.d.ts +12 -0
  34. package/dist/default/types.d.ts.map +1 -0
  35. package/dist/default/types.js +2 -0
  36. package/dist/default/types.js.map +1 -0
  37. package/dist/flows/errors.d.ts +12 -0
  38. package/dist/flows/errors.d.ts.map +1 -0
  39. package/dist/flows/errors.js +13 -0
  40. package/dist/flows/errors.js.map +1 -0
  41. package/dist/flows/registry.d.ts +22 -0
  42. package/dist/flows/registry.d.ts.map +1 -0
  43. package/dist/flows/registry.js +20 -0
  44. package/dist/flows/registry.js.map +1 -0
  45. package/dist/headless/VocabulariesProvider.d.ts +19 -0
  46. package/dist/headless/VocabulariesProvider.d.ts.map +1 -0
  47. package/dist/headless/VocabulariesProvider.js +15 -0
  48. package/dist/headless/VocabulariesProvider.js.map +1 -0
  49. package/dist/i18n/errorsMap.d.ts +10 -0
  50. package/dist/i18n/errorsMap.d.ts.map +1 -0
  51. package/dist/i18n/errorsMap.js +20 -0
  52. package/dist/i18n/errorsMap.js.map +1 -0
  53. package/dist/i18n/es.d.ts +21 -0
  54. package/dist/i18n/es.d.ts.map +1 -0
  55. package/dist/i18n/es.js +38 -0
  56. package/dist/i18n/es.js.map +1 -0
  57. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  58. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  59. package/dist/i18n/generated/errors.es.gen.js +58 -0
  60. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  61. package/dist/i18n/generated/errors.gen.d.ts +305 -0
  62. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  63. package/dist/i18n/generated/errors.gen.js +156 -0
  64. package/dist/i18n/generated/errors.gen.js.map +1 -0
  65. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  66. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  67. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  68. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  69. package/dist/i18n/keys.d.ts +33 -0
  70. package/dist/i18n/keys.d.ts.map +1 -0
  71. package/dist/i18n/keys.js +44 -0
  72. package/dist/i18n/keys.js.map +1 -0
  73. package/dist/i18n/ru.d.ts +26 -0
  74. package/dist/i18n/ru.d.ts.map +1 -0
  75. package/dist/i18n/ru.js +43 -0
  76. package/dist/i18n/ru.js.map +1 -0
  77. package/dist/index.d.ts +34 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +38 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/model/context.d.ts +11 -0
  82. package/dist/model/context.d.ts.map +1 -0
  83. package/dist/model/context.js +16 -0
  84. package/dist/model/context.js.map +1 -0
  85. package/dist/model/queryKeys.d.ts +5 -0
  86. package/dist/model/queryKeys.d.ts.map +1 -0
  87. package/dist/model/queryKeys.js +28 -0
  88. package/dist/model/queryKeys.js.map +1 -0
  89. package/dist/model/runtime.d.ts +17 -0
  90. package/dist/model/runtime.d.ts.map +1 -0
  91. package/dist/model/runtime.js +6 -0
  92. package/dist/model/runtime.js.map +1 -0
  93. package/dist/model/useTermLabels.d.ts +26 -0
  94. package/dist/model/useTermLabels.d.ts.map +1 -0
  95. package/dist/model/useTermLabels.js +58 -0
  96. package/dist/model/useTermLabels.js.map +1 -0
  97. package/dist/model/useTermSearch.d.ts +24 -0
  98. package/dist/model/useTermSearch.d.ts.map +1 -0
  99. package/dist/model/useTermSearch.js +85 -0
  100. package/dist/model/useTermSearch.js.map +1 -0
  101. package/llms.txt +79 -0
  102. package/manifest.json +466 -0
  103. package/package.json +108 -0
  104. package/src/analytics/generated/events.json +7 -0
  105. package/src/api/extensions.ts +7 -0
  106. package/src/api/generated/schema.ts +236 -0
  107. package/src/api/types.ts +27 -0
  108. package/src/api/vocabulariesApi.ts +17 -0
  109. package/src/client.ts +176 -0
  110. package/src/default/VocabularyTermSelect.tsx +147 -0
  111. package/src/default/index.ts +15 -0
  112. package/src/default/types.ts +11 -0
  113. package/src/flows/errors.ts +16 -0
  114. package/src/flows/registry.ts +32 -0
  115. package/src/headless/VocabulariesProvider.tsx +20 -0
  116. package/src/i18n/errorsMap.ts +31 -0
  117. package/src/i18n/es.ts +49 -0
  118. package/src/i18n/generated/errors.es.gen.ts +64 -0
  119. package/src/i18n/generated/errors.gen.ts +187 -0
  120. package/src/i18n/generated/errors.json +351 -0
  121. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  122. package/src/i18n/keys.ts +53 -0
  123. package/src/i18n/ru.ts +54 -0
  124. package/src/index.ts +92 -0
  125. package/src/model/context.tsx +28 -0
  126. package/src/model/queryKeys.ts +35 -0
  127. package/src/model/runtime.ts +25 -0
  128. package/src/model/useTermLabels.ts +72 -0
  129. package/src/model/useTermSearch.ts +118 -0
  130. package/tsconfig.json +26 -0
package/package.json ADDED
@@ -0,0 +1,108 @@
1
+ {
2
+ "name": "@stapel/vocabularies-react",
3
+ "version": "0.1.0",
4
+ "description": "Reference vocabularies too large to inline into a category schema: term typeahead and code-to-label resolve behind the VocabularyClient seam, plus the antd term select.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/usestapel/stapel-react.git",
9
+ "directory": "packages/vocabularies-react"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./default": {
21
+ "types": "./dist/default/index.d.ts",
22
+ "default": "./dist/default/index.js"
23
+ },
24
+ "./i18n/ru": {
25
+ "types": "./dist/i18n/ru.d.ts",
26
+ "default": "./dist/i18n/ru.js"
27
+ },
28
+ "./i18n/es": {
29
+ "types": "./dist/i18n/es.d.ts",
30
+ "default": "./dist/i18n/es.js"
31
+ },
32
+ "./manifest": "./manifest.json",
33
+ "./manifest.json": "./manifest.json",
34
+ "./llms.txt": "./llms.txt",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "tsconfig.json",
41
+ "README.md",
42
+ "MODULE.md",
43
+ "CHANGELOG.md",
44
+ "manifest.json",
45
+ "llms.txt"
46
+ ],
47
+ "size-limit": [
48
+ {
49
+ "name": "index — the seam client and the hooks stay React-only: no antd",
50
+ "path": "dist/index.js",
51
+ "limit": "8 KB"
52
+ },
53
+ {
54
+ "name": "default — the antd term select must stay out of the main bundle",
55
+ "path": "dist/default/index.js",
56
+ "limit": "10 KB"
57
+ },
58
+ {
59
+ "name": "i18n/ru subpath (opt-in locale: en floor + ru UI)",
60
+ "path": "dist/i18n/ru.js",
61
+ "limit": "4 KB"
62
+ },
63
+ {
64
+ "name": "i18n/es subpath (opt-in locale: en floor + es UI)",
65
+ "path": "dist/i18n/es.js",
66
+ "limit": "4 KB"
67
+ }
68
+ ],
69
+ "peerDependencies": {
70
+ "@stapel/core": ">=0.20.0 <1.0.0",
71
+ "@stapel/tokens-antd": ">=0.8.0",
72
+ "@tanstack/react-query": "^5.0.0",
73
+ "antd": ">=5.20.0 <7",
74
+ "react": ">=19"
75
+ },
76
+ "devDependencies": {
77
+ "@size-limit/preset-small-lib": "^11.2.0",
78
+ "@tanstack/react-query": "^5.81.0",
79
+ "@testing-library/react": "^16.3.0",
80
+ "@types/react": "^19.1.0",
81
+ "@types/react-dom": "^19.1.0",
82
+ "antd": "^6.5.0",
83
+ "jsdom": "^26.1.0",
84
+ "msw": "^2.10.2",
85
+ "react": "^19.1.0",
86
+ "react-dom": "^19.1.0",
87
+ "size-limit": "^11.2.0",
88
+ "typescript": "^5.8.3",
89
+ "vitest": "^3.2.4",
90
+ "@stapel/core": "^0.20.0",
91
+ "@stapel/tokens-antd": "^0.8.1",
92
+ "@stapel/tokens": "^0.6.0",
93
+ "@stapel/showcase": "^0.3.0"
94
+ },
95
+ "engines": {
96
+ "node": ">=22"
97
+ },
98
+ "publishConfig": {
99
+ "access": "public"
100
+ },
101
+ "scripts": {
102
+ "build": "tsc -p tsconfig.json",
103
+ "test": "tsc -p tsconfig.demo.json && tsc -p tsconfig.test.json && vitest run --exclude test/prodBundlePurity.test.ts",
104
+ "test:pack": "vitest run test/prodBundlePurity.test.ts",
105
+ "lint": "eslint .",
106
+ "size": "size-limit"
107
+ }
108
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
+ "package": "@stapel/vocabularies-react",
4
+ "version": "0.1.0",
5
+ "defined": [],
6
+ "flows": []
7
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Hand-authored API surface the codegen does not (yet) cover — browser-redirect
3
+ * URL builders, open-redirect guards, narrow domain type-guards, header
4
+ * conventions. Everything that CAN be derived from schema.json belongs in the
5
+ * generated operations (`api/vocabulariesApi.ts`), not here.
6
+ */
7
+ export {};
@@ -0,0 +1,236 @@
1
+ // AUTO-GENERATED by scripts/gen-api.mjs — do not edit by hand.
2
+ // Source: the backend module's own docs/schema.json (§17-native per-module contract).
3
+ // Regenerate: pnpm gen:api · Drift gate: pnpm gen:api:check
4
+ export interface paths {
5
+ "/vocabularies/api/v1/vocabularies/": {
6
+ parameters: {
7
+ query?: never;
8
+ header?: never;
9
+ path?: never;
10
+ cookie?: never;
11
+ };
12
+ /**
13
+ * List vocabularies
14
+ * @description Every vocabulary this deployment holds, with its levels and revision. Cacheable: the ETag covers the highest revision and the number of vocabularies.
15
+ *
16
+ * **Permissions:** `ReadOnlyOrStaff`
17
+ */
18
+ get: operations["vocabularies_api_v1_vocabularies_list"];
19
+ put?: never;
20
+ post?: never;
21
+ delete?: never;
22
+ options?: never;
23
+ head?: never;
24
+ patch?: never;
25
+ trace?: never;
26
+ };
27
+ "/vocabularies/api/v1/vocabularies/{slug}/": {
28
+ parameters: {
29
+ query?: never;
30
+ header?: never;
31
+ path?: never;
32
+ cookie?: never;
33
+ };
34
+ /**
35
+ * Retrieve a vocabulary
36
+ * @description ``GET vocabularies/{slug}/`` — one vocabulary.
37
+ *
38
+ * **Permissions:** `ReadOnlyOrStaff`
39
+ */
40
+ get: operations["vocabularies_api_v1_vocabularies_retrieve"];
41
+ put?: never;
42
+ post?: never;
43
+ delete?: never;
44
+ options?: never;
45
+ head?: never;
46
+ patch?: never;
47
+ trace?: never;
48
+ };
49
+ "/vocabularies/api/v1/vocabularies/{slug}/terms/": {
50
+ parameters: {
51
+ query?: never;
52
+ header?: never;
53
+ path?: never;
54
+ cookie?: never;
55
+ };
56
+ /**
57
+ * Search the terms of one level
58
+ * @description A page of terms at `level`, optionally the children of a `parent` term at the level above, optionally matching `q`. Prefix matches rank before the rest, then the level's own sort order and label. `has_children` is what tells a cascading control whether to ask for the next level. `total` counts the whole filtered set, before limit and offset.
59
+ *
60
+ * **Permissions:** `ReadOnlyOrStaff`
61
+ */
62
+ get: operations["vocabularies_api_v1_vocabularies_terms_retrieve"];
63
+ put?: never;
64
+ post?: never;
65
+ delete?: never;
66
+ options?: never;
67
+ head?: never;
68
+ patch?: never;
69
+ trace?: never;
70
+ };
71
+ "/vocabularies/api/v1/vocabularies/{slug}/terms/resolve/": {
72
+ parameters: {
73
+ query?: never;
74
+ header?: never;
75
+ path?: never;
76
+ cookie?: never;
77
+ };
78
+ /**
79
+ * Resolve term codes to labels
80
+ * @description `{code: label}` for the codes named in `codes` (comma separated, at most 200 — the rest are ignored). Unknown codes are omitted, so a caller falls back to the code, which is what a stored DAO value does when its labels are missing.
81
+ *
82
+ * **Permissions:** `ReadOnlyOrStaff`
83
+ */
84
+ get: operations["vocabularies_api_v1_vocabularies_terms_resolve_retrieve"];
85
+ put?: never;
86
+ post?: never;
87
+ delete?: never;
88
+ options?: never;
89
+ head?: never;
90
+ patch?: never;
91
+ trace?: never;
92
+ };
93
+ }
94
+ export type webhooks = Record<string, never>;
95
+ export interface components {
96
+ schemas: {
97
+ /** @description One level of a vocabulary. */
98
+ Level: {
99
+ name: string;
100
+ /** @description Name of the level this one hangs off; null for a root level. */
101
+ parent?: string | null;
102
+ };
103
+ /** @description One term of one level. */
104
+ Term: {
105
+ code: string;
106
+ /** @description Resolved for the request's Accept-Language when the term carries a translation, otherwise the term's own label. */
107
+ label: string;
108
+ level: string;
109
+ /** @description Whether this term has any child term — what tells a cascading control whether to ask for the next level. */
110
+ has_children: boolean;
111
+ };
112
+ /** @description One page of terms plus the size of the whole filtered set. */
113
+ TermPage: {
114
+ results: components["schemas"]["Term"][];
115
+ /** @description Number of terms matching level/parent/q, before limit and offset. */
116
+ total: number;
117
+ };
118
+ /** @description A vocabulary as the catalogue endpoints render it. */
119
+ Vocabulary: {
120
+ slug: string;
121
+ name: string;
122
+ levels: components["schemas"]["Level"][];
123
+ term_count: number;
124
+ /** @description Cache key of the whole vocabulary; also the ETag and the vocabulary.changed payload. */
125
+ revision: number;
126
+ };
127
+ };
128
+ responses: never;
129
+ parameters: never;
130
+ requestBodies: never;
131
+ headers: never;
132
+ pathItems: never;
133
+ }
134
+ export type $defs = Record<string, never>;
135
+ export interface operations {
136
+ vocabularies_api_v1_vocabularies_list: {
137
+ parameters: {
138
+ query?: never;
139
+ header?: never;
140
+ path?: never;
141
+ cookie?: never;
142
+ };
143
+ requestBody?: never;
144
+ responses: {
145
+ 200: {
146
+ headers: {
147
+ [name: string]: unknown;
148
+ };
149
+ content: {
150
+ "application/json": components["schemas"]["Vocabulary"][];
151
+ };
152
+ };
153
+ };
154
+ };
155
+ vocabularies_api_v1_vocabularies_retrieve: {
156
+ parameters: {
157
+ query?: never;
158
+ header?: never;
159
+ path: {
160
+ slug: string;
161
+ };
162
+ cookie?: never;
163
+ };
164
+ requestBody?: never;
165
+ responses: {
166
+ 200: {
167
+ headers: {
168
+ [name: string]: unknown;
169
+ };
170
+ content: {
171
+ "application/json": components["schemas"]["Vocabulary"];
172
+ };
173
+ };
174
+ };
175
+ };
176
+ vocabularies_api_v1_vocabularies_terms_retrieve: {
177
+ parameters: {
178
+ query: {
179
+ /** @description Level to list. Required. */
180
+ level: string;
181
+ /** @description Page size, 1..200 (default 50). */
182
+ limit?: number;
183
+ /** @description Rows to skip. */
184
+ offset?: number;
185
+ /** @description Code of a term at the parent level; restricts the page to its children. */
186
+ parent?: string;
187
+ /** @description Case-insensitive substring of the label. */
188
+ q?: string;
189
+ };
190
+ header?: never;
191
+ path: {
192
+ slug: string;
193
+ };
194
+ cookie?: never;
195
+ };
196
+ requestBody?: never;
197
+ responses: {
198
+ 200: {
199
+ headers: {
200
+ [name: string]: unknown;
201
+ };
202
+ content: {
203
+ "application/json": components["schemas"]["TermPage"];
204
+ };
205
+ };
206
+ };
207
+ };
208
+ vocabularies_api_v1_vocabularies_terms_resolve_retrieve: {
209
+ parameters: {
210
+ query: {
211
+ /** @description Comma-separated term codes, at most 200. */
212
+ codes: string;
213
+ /** @description Level the codes belong to. Required. */
214
+ level: string;
215
+ };
216
+ header?: never;
217
+ path: {
218
+ slug: string;
219
+ };
220
+ cookie?: never;
221
+ };
222
+ requestBody?: never;
223
+ responses: {
224
+ 200: {
225
+ headers: {
226
+ [name: string]: unknown;
227
+ };
228
+ content: {
229
+ "application/json": {
230
+ [key: string]: unknown;
231
+ };
232
+ };
233
+ };
234
+ };
235
+ };
236
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Wire types for the stapel-vocabularies HTTP contract — **derived from the generated
3
+ * OpenAPI surface** (frontend-standard §2/§3), never hand-maintained. The
4
+ * single source of truth is `components["schemas"]` from this pair's own
5
+ * package-LOCAL generated schema (`./generated/schema.js`, produced by
6
+ * `pnpm gen:api` from stapel-vocabularies's OWN `docs/schema.json` — the §17-native
7
+ * per-module contract). Alias the schemas this pair uses under local names
8
+ * here; do NOT write parallel response bodies.
9
+ */
10
+ import type { components } from "./generated/schema.js";
11
+
12
+ /** The generated schema table — the one source of truth for wire shapes. */
13
+ export type Schemas = components["schemas"];
14
+
15
+ /** A vocabulary as the catalogue endpoints render it. */
16
+ export type Vocabulary = Schemas["Vocabulary"];
17
+
18
+ /** One level of a vocabulary — `parent` names the level above, null at the root. */
19
+ export type Level = Schemas["Level"];
20
+
21
+ /** One term of one level. `has_children` is what tells a cascading control
22
+ * whether there is another column to ask for. */
23
+ export type Term = Schemas["Term"];
24
+
25
+ /** One page of terms plus the size of the whole filtered set (`total` counts
26
+ * before `limit`/`offset`, so a control can say "50 of 14 962"). */
27
+ export type TermPage = Schemas["TermPage"];
@@ -0,0 +1,17 @@
1
+ import type { StapelClient } from "@stapel/core";
2
+
3
+ /**
4
+ * The pair's typed operation surface. Today a thin holder over the injected
5
+ * {@link StapelClient}; the named, typed operations (`vocabularies.<op>()`) will
6
+ * be GENERATED from schema.json operationIds by gen-api v2 (task
7
+ * `core-typed-ops`). Until then add hand-authored operations here and put
8
+ * anything that can never be derived from the schema in `api/extensions.ts`,
9
+ * each flagged with WHY the codegen does not cover it.
10
+ */
11
+ export interface VocabulariesApi {
12
+ readonly client: StapelClient;
13
+ }
14
+
15
+ export function createVocabulariesApi(client: StapelClient): VocabulariesApi {
16
+ return { client };
17
+ }
package/src/client.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `createVocabularyClient` — the two reads a ref feature needs, over the
3
+ * stapel-vocabularies wire.
4
+ *
5
+ * ── Why this is a bare `fetch` client and not the pair's StapelClient ───────
6
+ *
7
+ * A `ref_select` reaches its terms through a seam
8
+ * (`VocabularyClient`) that `@stapel/attributes-react` DECLARES and this
9
+ * package SATISFIES — structurally, without either importing the other, so two
10
+ * L2 pairs stay independently releasable. The seam is two async functions and
11
+ * nothing else: no query client, no auth runtime, no provider. Anything this
12
+ * function returned that needed React context could not be handed to
13
+ * `<VocabularyClientProvider value={…}>` at a container's composition root,
14
+ * which is the one call site the seam exists for.
15
+ *
16
+ * Both endpoints are public reads (`ReadOnlyOrStaff`, ETag'd on the
17
+ * vocabulary's revision, no session), so there is no token to carry and
18
+ * nothing for the auth interceptors to do. A host that DOES need headers
19
+ * passes its own `fetch`.
20
+ *
21
+ * The caching the seam deliberately leaves out lives in this package's hooks
22
+ * (`useTermLabels` is a TanStack query; `useTermSearch` supersedes in flight),
23
+ * not in the client — a raw client is what makes the seam testable and what
24
+ * lets a host wrap it.
25
+ */
26
+ import { toStapelApiError } from "@stapel/core";
27
+ import type { Term, TermPage } from "./api/types.js";
28
+
29
+ /** One term of a vocabulary level, as `GET …/terms/` returns it.
30
+ *
31
+ * A structural twin of `@stapel/attributes-react`'s `VocabularyTerm` — the same
32
+ * three fields, `has_children` spelled the wire's way. The declaration is not
33
+ * shared because the two packages must not import each other; the assignment
34
+ * is proven by `test/clientShape.test.ts`. */
35
+ export interface VocabularyTerm {
36
+ readonly code: string;
37
+ readonly label: string;
38
+ readonly has_children?: boolean;
39
+ }
40
+
41
+ /**
42
+ * The seam `@stapel/attributes-react` declares and this package implements.
43
+ *
44
+ * ATTRIBUTES-REACT OWNS THIS SHAPE. The copy here exists so a host can name
45
+ * the type without depending on the attributes pair, and
46
+ * `test/clientShape.test.ts` holds a third, hand-transcribed copy of the
47
+ * upstream declaration that the value returned below is assigned to — so a
48
+ * drift in either package is a red build here rather than a runtime hole in a
49
+ * storefront that wired the two together.
50
+ */
51
+ export interface VocabularyClient {
52
+ /** Terms of one level, narrowed by `query` and — when the caller has a
53
+ * parent term — by that term's code. An empty `query` means "the first page
54
+ * of this level", which is what a dropdown opens on. `signal` is honoured. */
55
+ search(
56
+ vocabulary: string,
57
+ level: string,
58
+ query: string,
59
+ parent?: string,
60
+ signal?: AbortSignal
61
+ ): Promise<readonly VocabularyTerm[]>;
62
+ /** `{code: label}` for codes already stored somewhere. Unknown codes are
63
+ * omitted by the server, so a caller falls back to the code itself. */
64
+ resolve(
65
+ vocabulary: string,
66
+ level: string,
67
+ codes: readonly string[]
68
+ ): Promise<Readonly<Record<string, string>>>;
69
+ }
70
+
71
+ export interface CreateVocabularyClientOptions {
72
+ /** Where stapel-vocabularies is mounted, e.g. `/vocabularies/api/v1/` or an
73
+ * absolute origin. A missing trailing slash is added. */
74
+ readonly baseUrl: string;
75
+ /** Transport override — a host's authenticated/instrumented `fetch`.
76
+ * Defaults to the global one. */
77
+ readonly fetch?: typeof globalThis.fetch;
78
+ /** Page size for {@link VocabularyClient.search}; the server caps it at 200
79
+ * and defaults to 50. */
80
+ readonly limit?: number;
81
+ }
82
+
83
+ /** The page a typeahead opens on. Matches the server's own default, so the
84
+ * parameter is sent for explicitness rather than to change behaviour. */
85
+ export const DEFAULT_TERM_LIMIT = 50;
86
+
87
+ /** The server refuses more than this many codes per resolve, and silently
88
+ * ignores the tail — so the client splits instead of losing labels. */
89
+ export const RESOLVE_BATCH = 200;
90
+
91
+ function withSlash(baseUrl: string): string {
92
+ return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
93
+ }
94
+
95
+ /**
96
+ * `{code: label}` out of a body typed as `Record<string, unknown>` (the
97
+ * contract declares `additionalProperties: {}`). Non-string values are
98
+ * dropped rather than stringified: a label that is not a string is a server
99
+ * the caller should fall back from, and `"[object Object]"` in a dropdown is
100
+ * worse than the code.
101
+ */
102
+ function labelsOf(body: unknown): Record<string, string> {
103
+ const out: Record<string, string> = {};
104
+ if (typeof body !== "object" || body === null) return out;
105
+ for (const [code, label] of Object.entries(body as Record<string, unknown>)) {
106
+ if (typeof label === "string") out[code] = label;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ export function createVocabularyClient(
112
+ options: CreateVocabularyClientOptions
113
+ ): VocabularyClient {
114
+ const base = withSlash(options.baseUrl);
115
+ const doFetch = options.fetch ?? globalThis.fetch;
116
+ const limit = options.limit ?? DEFAULT_TERM_LIMIT;
117
+
118
+ async function read(url: string, signal?: AbortSignal): Promise<unknown> {
119
+ let response: Response;
120
+ try {
121
+ response = await doFetch(url, {
122
+ method: "GET",
123
+ headers: { accept: "application/json" },
124
+ ...(signal !== undefined ? { signal } : {}),
125
+ });
126
+ } catch (cause) {
127
+ // An abort is the CALLER's decision, not a failure: rethrow it as-is so
128
+ // `signal.aborted` still discriminates it. Folding it into the error
129
+ // dialect would make a superseded keystroke look like a dead backend.
130
+ if (signal?.aborted === true) throw cause;
131
+ throw toStapelApiError(cause);
132
+ }
133
+ if (!response.ok) {
134
+ // One dialect at the single rethrow point (core `errors.ts`): the body is
135
+ // the backend's envelope, the status is the response's.
136
+ const body: unknown = await response.json().catch(() => undefined);
137
+ throw toStapelApiError(body, response.status);
138
+ }
139
+ return await response.json();
140
+ }
141
+
142
+ return {
143
+ async search(vocabulary, level, query, parent, signal) {
144
+ const params = new URLSearchParams();
145
+ params.set("level", level);
146
+ // OMITTED, not empty: `parent=` would ask the server for the children of
147
+ // a term whose code is the empty string, which is a level with nothing in
148
+ // it — the opposite of "the whole level", which is what no parent means.
149
+ if (parent !== undefined && parent.length > 0) params.set("parent", parent);
150
+ params.set("q", query);
151
+ params.set("limit", String(limit));
152
+ const url = `${base}vocabularies/${encodeURIComponent(vocabulary)}/terms/?${params.toString()}`;
153
+ const page = (await read(url, signal)) as TermPage | undefined;
154
+ const results: readonly Term[] = page?.results ?? [];
155
+ return results.map((term) => ({
156
+ code: term.code,
157
+ label: term.label,
158
+ has_children: term.has_children,
159
+ }));
160
+ },
161
+
162
+ async resolve(vocabulary, level, codes) {
163
+ const wanted = codes.filter((code) => code.length > 0);
164
+ if (wanted.length === 0) return {};
165
+ const out: Record<string, string> = {};
166
+ for (let at = 0; at < wanted.length; at += RESOLVE_BATCH) {
167
+ const params = new URLSearchParams();
168
+ params.set("level", level);
169
+ params.set("codes", wanted.slice(at, at + RESOLVE_BATCH).join(","));
170
+ const url = `${base}vocabularies/${encodeURIComponent(vocabulary)}/terms/resolve/?${params.toString()}`;
171
+ Object.assign(out, labelsOf(await read(url)));
172
+ }
173
+ return out;
174
+ },
175
+ };
176
+ }