@c-rex/components 0.3.0-build.39 → 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.
@@ -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
+ };