@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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/MODULE.md +92 -0
- package/README.md +173 -0
- package/dist/api/extensions.d.ts +8 -0
- package/dist/api/extensions.d.ts.map +1 -0
- package/dist/api/extensions.js +8 -0
- package/dist/api/extensions.js.map +1 -0
- package/dist/api/generated/schema.d.ts +234 -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 +23 -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/api/vocabulariesApi.d.ts +14 -0
- package/dist/api/vocabulariesApi.d.ts.map +1 -0
- package/dist/api/vocabulariesApi.js +4 -0
- package/dist/api/vocabulariesApi.js.map +1 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/default/VocabularyTermSelect.d.ts +24 -0
- package/dist/default/VocabularyTermSelect.d.ts.map +1 -0
- package/dist/default/VocabularyTermSelect.js +75 -0
- package/dist/default/VocabularyTermSelect.js.map +1 -0
- package/dist/default/index.d.ts +16 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +14 -0
- package/dist/default/index.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/flows/errors.d.ts +12 -0
- package/dist/flows/errors.d.ts.map +1 -0
- package/dist/flows/errors.js +13 -0
- package/dist/flows/errors.js.map +1 -0
- package/dist/flows/registry.d.ts +22 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +20 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/VocabulariesProvider.d.ts +19 -0
- package/dist/headless/VocabulariesProvider.d.ts.map +1 -0
- package/dist/headless/VocabulariesProvider.js +15 -0
- package/dist/headless/VocabulariesProvider.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +10 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +20 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +21 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +38 -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 +305 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +156 -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 +33 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +44 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +26 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +43 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.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/queryKeys.d.ts +5 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +28 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +17 -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/model/useTermLabels.d.ts +26 -0
- package/dist/model/useTermLabels.d.ts.map +1 -0
- package/dist/model/useTermLabels.js +58 -0
- package/dist/model/useTermLabels.js.map +1 -0
- package/dist/model/useTermSearch.d.ts +24 -0
- package/dist/model/useTermSearch.d.ts.map +1 -0
- package/dist/model/useTermSearch.js +85 -0
- package/dist/model/useTermSearch.js.map +1 -0
- package/llms.txt +79 -0
- package/manifest.json +466 -0
- package/package.json +108 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/extensions.ts +7 -0
- package/src/api/generated/schema.ts +236 -0
- package/src/api/types.ts +27 -0
- package/src/api/vocabulariesApi.ts +17 -0
- package/src/client.ts +176 -0
- package/src/default/VocabularyTermSelect.tsx +147 -0
- package/src/default/index.ts +15 -0
- package/src/default/types.ts +11 -0
- package/src/flows/errors.ts +16 -0
- package/src/flows/registry.ts +32 -0
- package/src/headless/VocabulariesProvider.tsx +20 -0
- package/src/i18n/errorsMap.ts +31 -0
- package/src/i18n/es.ts +49 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +187 -0
- package/src/i18n/generated/errors.json +351 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +53 -0
- package/src/i18n/ru.ts +54 -0
- package/src/index.ts +92 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queryKeys.ts +35 -0
- package/src/model/runtime.ts +25 -0
- package/src/model/useTermLabels.ts +72 -0
- package/src/model/useTermSearch.ts +118 -0
- 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
|
+
* 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
|
+
}
|
package/src/api/types.ts
ADDED
|
@@ -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
|
+
}
|