@c-rex/components 0.3.0-build.38 → 0.3.0-build.40
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/package.json +1 -1
- package/src/info/information-unit-metadata-grid-client.tsx +9 -211
- package/src/restriction-menu/__tests__/restriction-hierarchy.test.ts +12 -0
- package/src/restriction-menu/restriction-hierarchy.ts +7 -0
- package/src/restriction-menu/restriction-menu-item.tsx +8 -2
- package/src/restriction-menu/restriction-selection-command-menu.tsx +79 -20
- package/src/restriction-menu/restriction-selection-menu.tsx +91 -17
- package/src/restriction-menu/taxonomy-restriction-command-menu.tsx +4 -1
- package/src/restriction-menu/taxonomy-restriction-menu.tsx +10 -0
- package/src/results/filter-navbar.tsx +7 -4
- package/src/results/filter-sidebar/__tests__/utils.test.ts +299 -3
- package/src/results/filter-sidebar/index.tsx +77 -108
- package/src/results/filter-sidebar/utils.ts +91 -10
- package/src/results/generic/search-results-client.tsx +14 -8
- package/src/taxonomy/__tests__/hierarchy.test.ts +144 -0
- package/src/taxonomy/hierarchy.ts +137 -0
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { FilterItem, Tags } from "@c-rex/interfaces";
|
|
2
2
|
import { EN_LANG } from "@c-rex/constants";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
type InformationUnitPropertyKey,
|
|
5
|
+
type TaxonomyResult,
|
|
6
|
+
} from "@c-rex/services/read-models";
|
|
7
|
+
import {
|
|
8
|
+
isMetadataGroupDenied,
|
|
9
|
+
isMetadataPropertyRuntimeSuppressed,
|
|
10
|
+
} from "@c-rex/services/metadata-visibility-policy";
|
|
11
|
+
|
|
12
|
+
export type FacetPresentationItem = {
|
|
6
13
|
label: string;
|
|
7
14
|
groupLabel?: string;
|
|
8
15
|
sectionLabel?: string;
|
|
16
|
+
hidden?: boolean;
|
|
9
17
|
};
|
|
10
18
|
|
|
11
|
-
export type
|
|
19
|
+
export type FacetPresentation = Partial<Record<string, Record<string, FacetPresentationItem>>>;
|
|
20
|
+
|
|
21
|
+
export type FacetLabelOverride = FacetPresentationItem;
|
|
22
|
+
export type FacetLabelOverrides = FacetPresentation;
|
|
12
23
|
|
|
13
24
|
export type ResolvedFilterItem = FilterItem & {
|
|
14
25
|
groupLabel?: string;
|
|
@@ -20,6 +31,20 @@ export type ResolvedFilterItem = FilterItem & {
|
|
|
20
31
|
|
|
21
32
|
export type FacetTaxonomies = Partial<Record<string, TaxonomyResult>>;
|
|
22
33
|
|
|
34
|
+
export const resolveFacetTaxonomy = (
|
|
35
|
+
facetKey: string,
|
|
36
|
+
facetTaxonomies?: FacetTaxonomies
|
|
37
|
+
): TaxonomyResult | undefined => {
|
|
38
|
+
if (!facetTaxonomies) return undefined;
|
|
39
|
+
if (facetTaxonomies[facetKey]) return facetTaxonomies[facetKey];
|
|
40
|
+
|
|
41
|
+
if (facetKey === "categories") {
|
|
42
|
+
return facetTaxonomies.informationSubjects;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
23
48
|
export const updateFilterParam = (key: string, item: FilterItem, filter: string | null): Record<string, string | null> => {
|
|
24
49
|
|
|
25
50
|
if (key === "packages") {
|
|
@@ -93,24 +118,28 @@ export const clearData = (
|
|
|
93
118
|
tags: Tags,
|
|
94
119
|
options?: {
|
|
95
120
|
uiLanguage?: string;
|
|
96
|
-
|
|
121
|
+
facetPresentation?: FacetPresentation;
|
|
122
|
+
labelOverrides?: FacetPresentation;
|
|
97
123
|
includeZeroHits?: boolean;
|
|
98
124
|
facetTaxonomies?: FacetTaxonomies;
|
|
99
125
|
}
|
|
100
126
|
): Record<string, ResolvedFilterItem[]> => {
|
|
101
127
|
const includeZeroHits = options?.includeZeroHits ?? true;
|
|
128
|
+
const facetPresentation = options?.facetPresentation || options?.labelOverrides;
|
|
102
129
|
const filteredTags: Record<string, ResolvedFilterItem[]> = {}
|
|
103
130
|
const taxonomyNodesByFacet = Object.entries(options?.facetTaxonomies || {}).reduce<Record<string, Map<string, {
|
|
104
131
|
id: string;
|
|
105
132
|
shortId?: string;
|
|
106
133
|
label: string;
|
|
107
134
|
parentIds: string[];
|
|
135
|
+
classId?: string;
|
|
108
136
|
}>>>((acc, [facetKey, taxonomy]) => {
|
|
109
137
|
const nodes = new Map<string, {
|
|
110
138
|
id: string;
|
|
111
139
|
shortId?: string;
|
|
112
140
|
label: string;
|
|
113
141
|
parentIds: string[];
|
|
142
|
+
classId?: string;
|
|
114
143
|
}>();
|
|
115
144
|
|
|
116
145
|
Object.values(taxonomy?.nodesById || {}).forEach((node) => {
|
|
@@ -119,6 +148,7 @@ export const clearData = (
|
|
|
119
148
|
shortId: node.shortId,
|
|
120
149
|
label: node.label,
|
|
121
150
|
parentIds: node.parentIds || [],
|
|
151
|
+
classId: node.classRef?.id,
|
|
122
152
|
};
|
|
123
153
|
nodes.set(node.id, normalized);
|
|
124
154
|
if (node.shortId) {
|
|
@@ -130,7 +160,14 @@ export const clearData = (
|
|
|
130
160
|
return acc;
|
|
131
161
|
}, {});
|
|
132
162
|
|
|
163
|
+
if (!taxonomyNodesByFacet.categories && taxonomyNodesByFacet.informationSubjects) {
|
|
164
|
+
taxonomyNodesByFacet.categories = taxonomyNodesByFacet.informationSubjects;
|
|
165
|
+
}
|
|
166
|
+
|
|
133
167
|
for (const [key, value] of Object.entries(tags)) {
|
|
168
|
+
if (isMetadataPropertyRuntimeSuppressed(key as InformationUnitPropertyKey)) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
134
171
|
if (!value || !value.items || value.items.length === 0) {
|
|
135
172
|
continue;
|
|
136
173
|
}
|
|
@@ -139,13 +176,16 @@ export const clearData = (
|
|
|
139
176
|
if (item?.shortId === undefined) return null
|
|
140
177
|
if (!includeZeroHits && Number(item.hits) === 0) return null
|
|
141
178
|
|
|
142
|
-
const override = options?.labelOverrides?.[key]?.[item.shortId];
|
|
143
179
|
const runtimeItem = item as typeof item & {
|
|
144
180
|
id?: string;
|
|
145
181
|
parents?: Array<{ id?: string; shortId?: string }>;
|
|
146
182
|
};
|
|
183
|
+
const presentationItem = facetPresentation?.[key]?.[item.shortId]
|
|
184
|
+
|| (runtimeItem.id ? facetPresentation?.[key]?.[runtimeItem.id] : undefined);
|
|
147
185
|
const taxonomyNode = taxonomyNodesByFacet[key]?.get(runtimeItem.id || item.shortId);
|
|
148
|
-
|
|
186
|
+
if (presentationItem?.hidden) return null;
|
|
187
|
+
if (isMetadataGroupDenied(key as InformationUnitPropertyKey, { classId: taxonomyNode?.classId })) return null;
|
|
188
|
+
const label = presentationItem?.label || taxonomyNode?.label || resolveLabelByLanguage(item.labels || [], options?.uiLanguage);
|
|
149
189
|
if (!label) return null;
|
|
150
190
|
const taxonomyId = taxonomyNode?.id || runtimeItem.id;
|
|
151
191
|
const parentIds = taxonomyNode?.parentIds || (runtimeItem.parents || [])
|
|
@@ -158,8 +198,8 @@ export const clearData = (
|
|
|
158
198
|
label,
|
|
159
199
|
active: false,
|
|
160
200
|
shortId: item.shortId,
|
|
161
|
-
groupLabel:
|
|
162
|
-
sectionLabel:
|
|
201
|
+
groupLabel: presentationItem?.groupLabel,
|
|
202
|
+
sectionLabel: presentationItem?.sectionLabel,
|
|
163
203
|
taxonomyId,
|
|
164
204
|
parentIds,
|
|
165
205
|
}
|
|
@@ -182,7 +222,8 @@ export const memoizeFilteredTags = (
|
|
|
182
222
|
packages: string | null,
|
|
183
223
|
options?: {
|
|
184
224
|
uiLanguage?: string;
|
|
185
|
-
|
|
225
|
+
facetPresentation?: FacetPresentation;
|
|
226
|
+
labelOverrides?: FacetPresentation;
|
|
186
227
|
includeZeroHits?: boolean;
|
|
187
228
|
facetTaxonomies?: FacetTaxonomies;
|
|
188
229
|
}
|
|
@@ -247,9 +288,49 @@ export const applyFacetPropertyVisibility = (
|
|
|
247
288
|
]);
|
|
248
289
|
|
|
249
290
|
return Object.entries(tags).reduce<Record<string, ResolvedFilterItem[]>>((acc, [key, value]) => {
|
|
291
|
+
if (isMetadataPropertyRuntimeSuppressed(key as InformationUnitPropertyKey)) return acc;
|
|
250
292
|
if (includeSet.size > 0 && !includeSet.has(key)) return acc;
|
|
251
293
|
if (excludeSet.has(key)) return acc;
|
|
252
294
|
acc[key] = value;
|
|
253
295
|
return acc;
|
|
254
296
|
}, {});
|
|
255
297
|
}
|
|
298
|
+
|
|
299
|
+
export const pruneHierarchyFacetItemsForMerge = (
|
|
300
|
+
facetKey: string,
|
|
301
|
+
items: ResolvedFilterItem[]
|
|
302
|
+
): ResolvedFilterItem[] => {
|
|
303
|
+
const isCategoryFacet = facetKey === "categories" || facetKey === "informationSubjects";
|
|
304
|
+
if (!isCategoryFacet) return items;
|
|
305
|
+
|
|
306
|
+
const hasActiveSelection = items.some((item) => item.active);
|
|
307
|
+
if (!hasActiveSelection) return items;
|
|
308
|
+
|
|
309
|
+
return items.filter((item) => item.active || Number(item.hits) > 0 || Number(item.total) > 0);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export const resolveHierarchyFacetCountLabel = (
|
|
313
|
+
item: ResolvedFilterItem,
|
|
314
|
+
childItems: ResolvedFilterItem[]
|
|
315
|
+
): string | undefined => {
|
|
316
|
+
if (item.isStructural && childItems.length > 0) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return `(${item.hits}/${item.total})`;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export const shouldRenderFacetHierarchy = (
|
|
324
|
+
items: ResolvedFilterItem[],
|
|
325
|
+
options?: { hasTaxonomy?: boolean }
|
|
326
|
+
): boolean => {
|
|
327
|
+
const hasTaxonomy = options?.hasTaxonomy ?? false;
|
|
328
|
+
const hasSectionLabels = items.some((item) => Boolean(item.sectionLabel));
|
|
329
|
+
const hasParentRelations = items.some((item) => (item.parentIds || []).length > 0);
|
|
330
|
+
|
|
331
|
+
if (hasTaxonomy) {
|
|
332
|
+
return hasParentRelations;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return !hasSectionLabels && hasParentRelations;
|
|
336
|
+
};
|
|
@@ -14,7 +14,7 @@ import { GenericTableResultList } from "./table-result-list";
|
|
|
14
14
|
import { Pagination } from "../pagination";
|
|
15
15
|
import { Skeleton } from "@c-rex/ui/skeleton";
|
|
16
16
|
import type { CommonItemsModel, ResultContainerModel, Tags } from "@c-rex/interfaces";
|
|
17
|
-
import type {
|
|
17
|
+
import type { FacetPresentation } from "../filter-sidebar/utils";
|
|
18
18
|
|
|
19
19
|
type SerializedError = { message: string; name: string } | null;
|
|
20
20
|
|
|
@@ -41,7 +41,8 @@ export type GenericSearchResultsClientProps<
|
|
|
41
41
|
linkPattern: string;
|
|
42
42
|
addPackageId: boolean;
|
|
43
43
|
showFilesColumn?: boolean;
|
|
44
|
-
|
|
44
|
+
facetPresentation?: FacetPresentation;
|
|
45
|
+
facetLabelOverrides?: FacetPresentation;
|
|
45
46
|
informationSubjectsTaxonomy?: NonNullable<FilterSidebarProps["facetTaxonomies"]>["informationSubjects"];
|
|
46
47
|
facetExcludeProperties?: string[];
|
|
47
48
|
};
|
|
@@ -83,7 +84,8 @@ type SearchResultsBodyProps<TItem extends CommonItemsModel> = {
|
|
|
83
84
|
data: ResultContainerModel<TItem> | null;
|
|
84
85
|
error: SerializedError;
|
|
85
86
|
isLoading: boolean;
|
|
86
|
-
|
|
87
|
+
facetPresentation?: FacetPresentation;
|
|
88
|
+
facetLabelOverrides?: FacetPresentation;
|
|
87
89
|
informationSubjectsTaxonomy?: NonNullable<FilterSidebarProps["facetTaxonomies"]>["informationSubjects"];
|
|
88
90
|
facetExcludeProperties: string[];
|
|
89
91
|
search: string;
|
|
@@ -96,6 +98,7 @@ const SearchResultsBody = <TItem extends CommonItemsModel>({
|
|
|
96
98
|
data,
|
|
97
99
|
error,
|
|
98
100
|
isLoading,
|
|
101
|
+
facetPresentation,
|
|
99
102
|
facetLabelOverrides,
|
|
100
103
|
informationSubjectsTaxonomy,
|
|
101
104
|
facetExcludeProperties,
|
|
@@ -105,6 +108,7 @@ const SearchResultsBody = <TItem extends CommonItemsModel>({
|
|
|
105
108
|
showFilesColumn,
|
|
106
109
|
}: SearchResultsBodyProps<TItem>) => {
|
|
107
110
|
const [lastFacetTags, setLastFacetTags] = useState<Tags | undefined>(undefined);
|
|
111
|
+
const effectiveFacetPresentation = facetPresentation || facetLabelOverrides;
|
|
108
112
|
|
|
109
113
|
useEffect(() => {
|
|
110
114
|
if (hasFacetTags(data?.tags)) {
|
|
@@ -129,7 +133,7 @@ const SearchResultsBody = <TItem extends CommonItemsModel>({
|
|
|
129
133
|
<div className="flex flex-col gap-4 pb-4">
|
|
130
134
|
<FilterNavbar
|
|
131
135
|
tags={effectiveTags}
|
|
132
|
-
|
|
136
|
+
facetPresentation={effectiveFacetPresentation}
|
|
133
137
|
excludeProperties={facetExcludeProperties}
|
|
134
138
|
/>
|
|
135
139
|
<Empty />
|
|
@@ -142,7 +146,7 @@ const SearchResultsBody = <TItem extends CommonItemsModel>({
|
|
|
142
146
|
<div className="flex flex-col gap-4 pb-4">
|
|
143
147
|
<FilterNavbar
|
|
144
148
|
tags={effectiveTags}
|
|
145
|
-
|
|
149
|
+
facetPresentation={effectiveFacetPresentation}
|
|
146
150
|
excludeProperties={facetExcludeProperties}
|
|
147
151
|
/>
|
|
148
152
|
|
|
@@ -150,7 +154,7 @@ const SearchResultsBody = <TItem extends CommonItemsModel>({
|
|
|
150
154
|
<FilterSidebar
|
|
151
155
|
tags={effectiveTags}
|
|
152
156
|
totalItemCount={data.pageInfo.totalItemCount}
|
|
153
|
-
|
|
157
|
+
facetPresentation={effectiveFacetPresentation}
|
|
154
158
|
facetTaxonomies={informationSubjectsTaxonomy ? { informationSubjects: informationSubjectsTaxonomy } : undefined}
|
|
155
159
|
excludeProperties={facetExcludeProperties}
|
|
156
160
|
/>
|
|
@@ -167,7 +171,7 @@ const SearchResultsBody = <TItem extends CommonItemsModel>({
|
|
|
167
171
|
<div className="flex flex-col gap-4 pb-4">
|
|
168
172
|
<FilterNavbar
|
|
169
173
|
tags={effectiveTags}
|
|
170
|
-
|
|
174
|
+
facetPresentation={effectiveFacetPresentation}
|
|
171
175
|
excludeProperties={facetExcludeProperties}
|
|
172
176
|
/>
|
|
173
177
|
|
|
@@ -175,7 +179,7 @@ const SearchResultsBody = <TItem extends CommonItemsModel>({
|
|
|
175
179
|
<FilterSidebar
|
|
176
180
|
tags={effectiveTags}
|
|
177
181
|
totalItemCount={data.pageInfo.totalItemCount}
|
|
178
|
-
|
|
182
|
+
facetPresentation={effectiveFacetPresentation}
|
|
179
183
|
facetTaxonomies={informationSubjectsTaxonomy ? { informationSubjects: informationSubjectsTaxonomy } : undefined}
|
|
180
184
|
excludeProperties={facetExcludeProperties}
|
|
181
185
|
/>
|
|
@@ -211,6 +215,7 @@ export const GenericSearchResultsClient = <
|
|
|
211
215
|
linkPattern,
|
|
212
216
|
addPackageId,
|
|
213
217
|
showFilesColumn = false,
|
|
218
|
+
facetPresentation,
|
|
214
219
|
facetLabelOverrides,
|
|
215
220
|
informationSubjectsTaxonomy,
|
|
216
221
|
facetExcludeProperties = [],
|
|
@@ -244,6 +249,7 @@ export const GenericSearchResultsClient = <
|
|
|
244
249
|
data={data}
|
|
245
250
|
error={error}
|
|
246
251
|
isLoading={isLoading}
|
|
252
|
+
facetPresentation={facetPresentation}
|
|
247
253
|
facetLabelOverrides={facetLabelOverrides}
|
|
248
254
|
informationSubjectsTaxonomy={informationSubjectsTaxonomy}
|
|
249
255
|
facetExcludeProperties={facetExcludeProperties}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { buildTaxonomyHierarchy, type HierarchyItem } from "../hierarchy";
|
|
2
|
+
|
|
3
|
+
describe("taxonomy hierarchy", () => {
|
|
4
|
+
it("merges runtime items into the cached taxonomy hierarchy and materializes missing parents structurally", () => {
|
|
5
|
+
const items: HierarchyItem[] = [
|
|
6
|
+
{
|
|
7
|
+
shortId: "catChild",
|
|
8
|
+
label: "Child",
|
|
9
|
+
active: false,
|
|
10
|
+
hits: 2,
|
|
11
|
+
total: 2,
|
|
12
|
+
taxonomyId: "category-child",
|
|
13
|
+
parentIds: ["category-root"],
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const hierarchy = buildTaxonomyHierarchy(items, {
|
|
18
|
+
generatedAt: "2026-04-16T00:00:00.000Z",
|
|
19
|
+
roots: [],
|
|
20
|
+
nodesById: {
|
|
21
|
+
"category-root": {
|
|
22
|
+
id: "category-root",
|
|
23
|
+
shortId: "catRoot",
|
|
24
|
+
label: "Root",
|
|
25
|
+
parentIds: [],
|
|
26
|
+
},
|
|
27
|
+
"category-child": {
|
|
28
|
+
id: "category-child",
|
|
29
|
+
shortId: "catChild",
|
|
30
|
+
label: "Child",
|
|
31
|
+
parentIds: ["category-root"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(hierarchy.roots).toEqual([
|
|
37
|
+
expect.objectContaining({
|
|
38
|
+
shortId: "catRoot",
|
|
39
|
+
taxonomyId: "category-root",
|
|
40
|
+
isStructural: true,
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
expect(hierarchy.children.get("category-root")).toEqual([
|
|
44
|
+
expect.objectContaining({
|
|
45
|
+
shortId: "catChild",
|
|
46
|
+
taxonomyId: "category-child",
|
|
47
|
+
label: "Child",
|
|
48
|
+
hits: 2,
|
|
49
|
+
total: 2,
|
|
50
|
+
}),
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("falls back to the local parent links when no taxonomy is provided", () => {
|
|
55
|
+
const items: HierarchyItem[] = [
|
|
56
|
+
{
|
|
57
|
+
shortId: "root",
|
|
58
|
+
label: "Root",
|
|
59
|
+
active: false,
|
|
60
|
+
hits: 1,
|
|
61
|
+
total: 1,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
shortId: "child",
|
|
65
|
+
label: "Child",
|
|
66
|
+
active: false,
|
|
67
|
+
hits: 1,
|
|
68
|
+
total: 1,
|
|
69
|
+
parentIds: ["root"],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const hierarchy = buildTaxonomyHierarchy(items);
|
|
74
|
+
|
|
75
|
+
expect(hierarchy.roots.map((item) => item.shortId)).toEqual(["root"]);
|
|
76
|
+
expect(hierarchy.children.get("root")?.map((item) => item.shortId)).toEqual(["child"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("attaches polyhierarchical nodes to all known parents", () => {
|
|
80
|
+
const items: HierarchyItem[] = [
|
|
81
|
+
{
|
|
82
|
+
shortId: "parent-a",
|
|
83
|
+
label: "Parent A",
|
|
84
|
+
active: false,
|
|
85
|
+
hits: 1,
|
|
86
|
+
total: 1,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
shortId: "parent-b",
|
|
90
|
+
label: "Parent B",
|
|
91
|
+
active: false,
|
|
92
|
+
hits: 1,
|
|
93
|
+
total: 1,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
shortId: "child",
|
|
97
|
+
label: "Child",
|
|
98
|
+
active: false,
|
|
99
|
+
hits: 1,
|
|
100
|
+
total: 1,
|
|
101
|
+
parentIds: ["parent-a", "parent-b"],
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const hierarchy = buildTaxonomyHierarchy(items);
|
|
106
|
+
|
|
107
|
+
expect(hierarchy.children.get("parent-a")?.map((item) => item.shortId)).toEqual(["child"]);
|
|
108
|
+
expect(hierarchy.children.get("parent-b")?.map((item) => item.shortId)).toEqual(["child"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("matches taxonomy nodes by shortId when runtime facet items have no stable taxonomy id", () => {
|
|
112
|
+
const items: HierarchyItem[] = [
|
|
113
|
+
{
|
|
114
|
+
shortId: "catChild",
|
|
115
|
+
label: "Child",
|
|
116
|
+
active: false,
|
|
117
|
+
hits: 2,
|
|
118
|
+
total: 2,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const hierarchy = buildTaxonomyHierarchy(items, {
|
|
123
|
+
generatedAt: "2026-04-24T00:00:00.000Z",
|
|
124
|
+
roots: [],
|
|
125
|
+
nodesById: {
|
|
126
|
+
"category-root": {
|
|
127
|
+
id: "category-root",
|
|
128
|
+
shortId: "catRoot",
|
|
129
|
+
label: "Root",
|
|
130
|
+
parentIds: [],
|
|
131
|
+
},
|
|
132
|
+
"category-child": {
|
|
133
|
+
id: "category-child",
|
|
134
|
+
shortId: "catChild",
|
|
135
|
+
label: "Child",
|
|
136
|
+
parentIds: ["category-root"],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(hierarchy.roots.map((item) => item.shortId)).toEqual(["catRoot"]);
|
|
142
|
+
expect(hierarchy.children.get("category-root")?.map((item) => item.shortId)).toEqual(["catChild"]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { TaxonomyResult } from "@c-rex/services/read-models";
|
|
2
|
+
|
|
3
|
+
export type HierarchyItem = {
|
|
4
|
+
shortId: string;
|
|
5
|
+
label: string;
|
|
6
|
+
active: boolean;
|
|
7
|
+
hits: number;
|
|
8
|
+
total: number;
|
|
9
|
+
taxonomyId?: string;
|
|
10
|
+
parentIds?: string[];
|
|
11
|
+
isStructural?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type HierarchyTree<TItem extends HierarchyItem> = {
|
|
15
|
+
roots: TItem[];
|
|
16
|
+
children: Map<string, TItem[]>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const sortItems = <TItem extends HierarchyItem>(items: TItem[]) =>
|
|
20
|
+
items.sort((a, b) => a.label.localeCompare(b.label));
|
|
21
|
+
|
|
22
|
+
export const buildTaxonomyHierarchy = <TItem extends HierarchyItem>(
|
|
23
|
+
items: TItem[],
|
|
24
|
+
taxonomy?: TaxonomyResult
|
|
25
|
+
): HierarchyTree<TItem> => {
|
|
26
|
+
if (taxonomy) {
|
|
27
|
+
const selectedById = new Map<string, TItem>();
|
|
28
|
+
const includedIds = new Set<string>();
|
|
29
|
+
const resolveTaxonomyNodeId = (item: TItem): string | undefined => {
|
|
30
|
+
const directId = item.taxonomyId || item.shortId;
|
|
31
|
+
if (directId && taxonomy.nodesById[directId]) return directId;
|
|
32
|
+
|
|
33
|
+
return Object.values(taxonomy.nodesById).find((node) => (
|
|
34
|
+
Boolean(item.taxonomyId && node.id === item.taxonomyId) ||
|
|
35
|
+
Boolean(item.shortId && node.shortId === item.shortId)
|
|
36
|
+
))?.id;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const includeAncestorChain = (nodeId: string) => {
|
|
40
|
+
const visited = new Set<string>();
|
|
41
|
+
const stack = [nodeId];
|
|
42
|
+
|
|
43
|
+
while (stack.length > 0) {
|
|
44
|
+
const currentId = stack.pop();
|
|
45
|
+
if (!currentId || visited.has(currentId)) continue;
|
|
46
|
+
visited.add(currentId);
|
|
47
|
+
|
|
48
|
+
const currentNode = taxonomy.nodesById[currentId];
|
|
49
|
+
(currentNode?.parentIds || []).forEach((parentId) => {
|
|
50
|
+
if (taxonomy.nodesById[parentId]) {
|
|
51
|
+
includedIds.add(parentId);
|
|
52
|
+
stack.push(parentId);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
items.forEach((item) => {
|
|
59
|
+
const itemId = resolveTaxonomyNodeId(item);
|
|
60
|
+
if (!itemId) return;
|
|
61
|
+
|
|
62
|
+
selectedById.set(itemId, item);
|
|
63
|
+
includedIds.add(itemId);
|
|
64
|
+
includeAncestorChain(itemId);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const byId = new Map<string, TItem>();
|
|
68
|
+
const children = new Map<string, TItem[]>();
|
|
69
|
+
const parentedIds = new Set<string>();
|
|
70
|
+
|
|
71
|
+
Array.from(includedIds).forEach((nodeId) => {
|
|
72
|
+
const taxonomyNode = taxonomy.nodesById[nodeId];
|
|
73
|
+
if (!taxonomyNode) return;
|
|
74
|
+
|
|
75
|
+
byId.set(nodeId, selectedById.get(nodeId) || {
|
|
76
|
+
shortId: taxonomyNode.shortId || nodeId,
|
|
77
|
+
label: taxonomyNode.label,
|
|
78
|
+
active: false,
|
|
79
|
+
hits: 0,
|
|
80
|
+
total: 0,
|
|
81
|
+
taxonomyId: taxonomyNode.id,
|
|
82
|
+
parentIds: taxonomyNode.parentIds || [],
|
|
83
|
+
isStructural: true,
|
|
84
|
+
} as TItem);
|
|
85
|
+
children.set(nodeId, []);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
Array.from(includedIds).forEach((nodeId) => {
|
|
89
|
+
const current = byId.get(nodeId);
|
|
90
|
+
const taxonomyNode = taxonomy.nodesById[nodeId];
|
|
91
|
+
if (!current || !taxonomyNode) return;
|
|
92
|
+
|
|
93
|
+
(taxonomyNode.parentIds || [])
|
|
94
|
+
.filter((id) => byId.has(id))
|
|
95
|
+
.forEach((parentKey) => {
|
|
96
|
+
children.get(parentKey)?.push(current);
|
|
97
|
+
parentedIds.add(nodeId);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const roots = Array.from(byId.entries())
|
|
102
|
+
.filter(([nodeId]) => !parentedIds.has(nodeId))
|
|
103
|
+
.map(([, node]) => node);
|
|
104
|
+
|
|
105
|
+
sortItems(roots);
|
|
106
|
+
children.forEach((nodes) => sortItems(nodes));
|
|
107
|
+
|
|
108
|
+
return { roots, children };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const byId = new Map<string, TItem>();
|
|
112
|
+
const children = new Map<string, TItem[]>();
|
|
113
|
+
const roots: TItem[] = [];
|
|
114
|
+
|
|
115
|
+
items.forEach((item) => {
|
|
116
|
+
const key = item.taxonomyId || item.shortId;
|
|
117
|
+
byId.set(key, item);
|
|
118
|
+
children.set(key, []);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
items.forEach((item) => {
|
|
122
|
+
const parentKeys = (item.parentIds || []).filter((id) => byId.has(id));
|
|
123
|
+
if (parentKeys.length === 0) {
|
|
124
|
+
roots.push(item);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
parentKeys.forEach((parentKey) => {
|
|
129
|
+
children.get(parentKey)?.push(item);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
sortItems(roots);
|
|
134
|
+
children.forEach((nodes) => sortItems(nodes));
|
|
135
|
+
|
|
136
|
+
return { roots, children };
|
|
137
|
+
};
|