@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
|
@@ -2,7 +2,11 @@ import { Tags } from "@c-rex/interfaces";
|
|
|
2
2
|
import {
|
|
3
3
|
clearData,
|
|
4
4
|
memoizeFilteredTags,
|
|
5
|
+
pruneHierarchyFacetItemsForMerge,
|
|
6
|
+
resolveHierarchyFacetCountLabel,
|
|
7
|
+
resolveFacetTaxonomy,
|
|
5
8
|
resolveLabelByLanguage,
|
|
9
|
+
shouldRenderFacetHierarchy,
|
|
6
10
|
updateFilterParam,
|
|
7
11
|
removeFilterItem,
|
|
8
12
|
} from "../utils";
|
|
@@ -63,7 +67,7 @@ describe("filter-sidebar utils", () => {
|
|
|
63
67
|
const result = clearData(tags, { uiLanguage: "en" });
|
|
64
68
|
|
|
65
69
|
expect(result.informationSubjects).toHaveLength(2);
|
|
66
|
-
expect(result.informationSubjects?.map((item) => item.shortId)).toEqual(["
|
|
70
|
+
expect(result.informationSubjects?.map((item) => item.shortId)).toEqual(["subB", "subA"]);
|
|
67
71
|
});
|
|
68
72
|
|
|
69
73
|
it("clearData excludes zero-hit entries when includeZeroHits is false", () => {
|
|
@@ -89,7 +93,8 @@ describe("filter-sidebar utils", () => {
|
|
|
89
93
|
},
|
|
90
94
|
});
|
|
91
95
|
|
|
92
|
-
|
|
96
|
+
const overriddenItem = result.informationSubjects?.find((item) => item.shortId === "subA");
|
|
97
|
+
expect(overriddenItem).toMatchObject({
|
|
93
98
|
label: "Overridden Label",
|
|
94
99
|
groupLabel: "Subject Group",
|
|
95
100
|
sectionLabel: "Process",
|
|
@@ -97,6 +102,168 @@ describe("filter-sidebar utils", () => {
|
|
|
97
102
|
});
|
|
98
103
|
});
|
|
99
104
|
|
|
105
|
+
it("clearData resolves facet presentation by id when shortId lookup is unavailable", () => {
|
|
106
|
+
const tags = createTags();
|
|
107
|
+
const result = clearData(tags, {
|
|
108
|
+
uiLanguage: "en",
|
|
109
|
+
labelOverrides: {
|
|
110
|
+
informationSubjects: {
|
|
111
|
+
"https://example.org/subA": {
|
|
112
|
+
label: "ID Matched Label",
|
|
113
|
+
sectionLabel: "Process",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.informationSubjects?.find((item) => item.shortId === "subA")).toMatchObject({
|
|
120
|
+
label: "ID Matched Label",
|
|
121
|
+
sectionLabel: "Process",
|
|
122
|
+
taxonomyId: "https://example.org/subA",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("clearData keeps party role section labels from overrides instead of raw predicate labels", () => {
|
|
127
|
+
const tags = {
|
|
128
|
+
parties: {
|
|
129
|
+
labels: ["related to party"],
|
|
130
|
+
items: [
|
|
131
|
+
{
|
|
132
|
+
hits: 1,
|
|
133
|
+
total: 1,
|
|
134
|
+
labels: [{ language: "de", value: "Deutsche Gesetzliche Unfallversicherung e.V." }],
|
|
135
|
+
shortId: "dguv",
|
|
136
|
+
id: "https://example.org/party/dguv",
|
|
137
|
+
} as unknown as Tags[string]["items"][number],
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
} as unknown as Tags;
|
|
141
|
+
|
|
142
|
+
const result = clearData(tags, {
|
|
143
|
+
uiLanguage: "de-DE",
|
|
144
|
+
labelOverrides: {
|
|
145
|
+
parties: {
|
|
146
|
+
dguv: {
|
|
147
|
+
label: "Deutsche Gesetzliche Unfallversicherung e.V.",
|
|
148
|
+
sectionLabel: "Traeger",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(result.parties?.[0]).toMatchObject({
|
|
155
|
+
label: "Deutsche Gesetzliche Unfallversicherung e.V.",
|
|
156
|
+
sectionLabel: "Traeger",
|
|
157
|
+
});
|
|
158
|
+
expect(result.parties?.[0]?.sectionLabel).not.toBe("related to party");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("resolves categories taxonomy through the cached informationSubjects hierarchy alias", () => {
|
|
162
|
+
const taxonomy = {
|
|
163
|
+
generatedAt: "2026-04-10T00:00:00.000Z",
|
|
164
|
+
roots: [],
|
|
165
|
+
nodesById: {
|
|
166
|
+
"category-root": {
|
|
167
|
+
id: "category-root",
|
|
168
|
+
shortId: "catRoot",
|
|
169
|
+
label: "Root category",
|
|
170
|
+
parentIds: [],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
expect(resolveFacetTaxonomy("categories", { informationSubjects: taxonomy })).toBe(taxonomy);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("clearData merges category tags into the cached category hierarchy alias", () => {
|
|
179
|
+
const tags = {
|
|
180
|
+
categories: {
|
|
181
|
+
labels: ["Categories"],
|
|
182
|
+
items: [
|
|
183
|
+
{
|
|
184
|
+
hits: 2,
|
|
185
|
+
total: 2,
|
|
186
|
+
labels: [{ language: "de", value: "Unterkategorie" }],
|
|
187
|
+
shortId: "catChild",
|
|
188
|
+
id: "category-child",
|
|
189
|
+
} as unknown as Tags[string]["items"][number],
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
} as unknown as Tags;
|
|
193
|
+
|
|
194
|
+
const result = clearData(tags, {
|
|
195
|
+
uiLanguage: "de-DE",
|
|
196
|
+
facetTaxonomies: {
|
|
197
|
+
informationSubjects: {
|
|
198
|
+
generatedAt: "2026-04-10T00:00:00.000Z",
|
|
199
|
+
roots: [],
|
|
200
|
+
nodesById: {
|
|
201
|
+
"category-root": {
|
|
202
|
+
id: "category-root",
|
|
203
|
+
shortId: "catRoot",
|
|
204
|
+
label: "Wurzel",
|
|
205
|
+
parentIds: [],
|
|
206
|
+
},
|
|
207
|
+
"category-child": {
|
|
208
|
+
id: "category-child",
|
|
209
|
+
shortId: "catChild",
|
|
210
|
+
label: "Unterkategorie",
|
|
211
|
+
parentIds: ["category-root"],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(result.categories?.[0]).toMatchObject({
|
|
219
|
+
shortId: "catChild",
|
|
220
|
+
label: "Unterkategorie",
|
|
221
|
+
taxonomyId: "category-child",
|
|
222
|
+
parentIds: ["category-root"],
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("clearData hides denied qualification groups from semantic facet output", () => {
|
|
227
|
+
const tags = {
|
|
228
|
+
qualifications: {
|
|
229
|
+
labels: ["Qualifications"],
|
|
230
|
+
items: [
|
|
231
|
+
{
|
|
232
|
+
hits: 4,
|
|
233
|
+
total: 4,
|
|
234
|
+
labels: [{ language: "en", value: "License A" }],
|
|
235
|
+
shortId: "qualA",
|
|
236
|
+
id: "qualification-a",
|
|
237
|
+
} as unknown as Tags[string]["items"][number],
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
} as unknown as Tags;
|
|
241
|
+
|
|
242
|
+
const result = clearData(tags, {
|
|
243
|
+
uiLanguage: "en-US",
|
|
244
|
+
facetTaxonomies: {
|
|
245
|
+
qualifications: {
|
|
246
|
+
generatedAt: "2026-04-10T00:00:00.000Z",
|
|
247
|
+
roots: [],
|
|
248
|
+
nodesById: {
|
|
249
|
+
"qualification-a": {
|
|
250
|
+
id: "qualification-a",
|
|
251
|
+
shortId: "qualA",
|
|
252
|
+
label: "License A",
|
|
253
|
+
parentIds: [],
|
|
254
|
+
classRef: {
|
|
255
|
+
id: "https://ids.c-crex.net/ns/iirds/ext#ContentLicense",
|
|
256
|
+
label: "Content License",
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(result.qualifications).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
|
|
100
267
|
it("memoizeFilteredTags marks active filters and active package", () => {
|
|
101
268
|
const tags = createTags();
|
|
102
269
|
const result = memoizeFilteredTags(tags, "informationSubjects.shortId=subA", "pkgA", {
|
|
@@ -108,6 +275,136 @@ describe("filter-sidebar utils", () => {
|
|
|
108
275
|
expect(result.packages?.find((item) => item.shortId === "pkgA")?.active).toBe(true);
|
|
109
276
|
});
|
|
110
277
|
|
|
278
|
+
it("prunes zero-hit category siblings from the hierarchy merge seed when a category is selected", () => {
|
|
279
|
+
const items = [
|
|
280
|
+
{
|
|
281
|
+
shortId: "selected",
|
|
282
|
+
taxonomyId: "selected-id",
|
|
283
|
+
label: "Selected",
|
|
284
|
+
active: true,
|
|
285
|
+
hits: 211,
|
|
286
|
+
total: 211,
|
|
287
|
+
parentIds: ["parent-id"],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
shortId: "sibling-zero",
|
|
291
|
+
taxonomyId: "sibling-zero-id",
|
|
292
|
+
label: "Sibling Zero",
|
|
293
|
+
active: false,
|
|
294
|
+
hits: 0,
|
|
295
|
+
total: 0,
|
|
296
|
+
parentIds: ["parent-id"],
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
shortId: "sibling-live",
|
|
300
|
+
taxonomyId: "sibling-live-id",
|
|
301
|
+
label: "Sibling Live",
|
|
302
|
+
active: false,
|
|
303
|
+
hits: 5,
|
|
304
|
+
total: 5,
|
|
305
|
+
parentIds: ["parent-id"],
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
expect(pruneHierarchyFacetItemsForMerge("categories", [...items])).toEqual([items[0], items[2]]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("keeps zero-hit category items untouched when no category is selected", () => {
|
|
313
|
+
const items = [
|
|
314
|
+
{
|
|
315
|
+
shortId: "category-a",
|
|
316
|
+
taxonomyId: "category-a-id",
|
|
317
|
+
label: "Category A",
|
|
318
|
+
active: false,
|
|
319
|
+
hits: 0,
|
|
320
|
+
total: 0,
|
|
321
|
+
parentIds: [],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
shortId: "category-b",
|
|
325
|
+
taxonomyId: "category-b-id",
|
|
326
|
+
label: "Category B",
|
|
327
|
+
active: false,
|
|
328
|
+
hits: 3,
|
|
329
|
+
total: 3,
|
|
330
|
+
parentIds: [],
|
|
331
|
+
},
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
expect(pruneHierarchyFacetItemsForMerge("informationSubjects", items)).toEqual(items);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("hides the misleading 0/0 count for structural category parents with visible children", () => {
|
|
338
|
+
const item = {
|
|
339
|
+
shortId: "category-parent",
|
|
340
|
+
taxonomyId: "category-parent-id",
|
|
341
|
+
label: "Category Parent",
|
|
342
|
+
active: false,
|
|
343
|
+
hits: 0,
|
|
344
|
+
total: 0,
|
|
345
|
+
parentIds: [],
|
|
346
|
+
isStructural: true,
|
|
347
|
+
};
|
|
348
|
+
const child = {
|
|
349
|
+
shortId: "category-child",
|
|
350
|
+
taxonomyId: "category-child-id",
|
|
351
|
+
label: "Category Child",
|
|
352
|
+
active: false,
|
|
353
|
+
hits: 4,
|
|
354
|
+
total: 4,
|
|
355
|
+
parentIds: ["category-parent-id"],
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
expect(resolveHierarchyFacetCountLabel(item, [child])).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("keeps count labels for live hierarchy nodes", () => {
|
|
362
|
+
const item = {
|
|
363
|
+
shortId: "category-child",
|
|
364
|
+
taxonomyId: "category-child-id",
|
|
365
|
+
label: "Category Child",
|
|
366
|
+
active: false,
|
|
367
|
+
hits: 4,
|
|
368
|
+
total: 7,
|
|
369
|
+
parentIds: ["category-parent-id"],
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
expect(resolveHierarchyFacetCountLabel(item, [])).toBe("(4/7)");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("renders taxonomy-backed facets as flat groups when no parent relations exist", () => {
|
|
376
|
+
const items = [
|
|
377
|
+
{
|
|
378
|
+
shortId: "subject-a",
|
|
379
|
+
taxonomyId: "subject-a-id",
|
|
380
|
+
label: "Abkuerzungsbeschreibung",
|
|
381
|
+
sectionLabel: "Formalitaet",
|
|
382
|
+
active: false,
|
|
383
|
+
hits: 3,
|
|
384
|
+
total: 3,
|
|
385
|
+
parentIds: [],
|
|
386
|
+
},
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
expect(shouldRenderFacetHierarchy(items, { hasTaxonomy: true })).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("renders taxonomy-backed facets as hierarchy when parent relations exist", () => {
|
|
393
|
+
const items = [
|
|
394
|
+
{
|
|
395
|
+
shortId: "category-child",
|
|
396
|
+
taxonomyId: "category-child-id",
|
|
397
|
+
label: "Unterkategorie",
|
|
398
|
+
active: false,
|
|
399
|
+
hits: 4,
|
|
400
|
+
total: 4,
|
|
401
|
+
parentIds: ["category-root-id"],
|
|
402
|
+
},
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
expect(shouldRenderFacetHierarchy(items, { hasTaxonomy: true })).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
|
|
111
408
|
it("updateFilterParam and removeFilterItem keep query behavior stable", () => {
|
|
112
409
|
expect(updateFilterParam("packages", { shortId: "pkgA", hits: 0, total: 0, label: "", active: false }, null)).toEqual({
|
|
113
410
|
packages: "pkgA",
|
|
@@ -126,4 +423,3 @@ describe("filter-sidebar utils", () => {
|
|
|
126
423
|
).toEqual({ filter: "packages.shortId=pkgA" });
|
|
127
424
|
});
|
|
128
425
|
});
|
|
129
|
-
|
|
@@ -21,14 +21,19 @@ import { Button } from "@c-rex/ui/button";
|
|
|
21
21
|
import { parseAsString, useQueryStates } from "nuqs";
|
|
22
22
|
import {
|
|
23
23
|
applyFacetPropertyVisibility,
|
|
24
|
-
|
|
24
|
+
FacetPresentation,
|
|
25
25
|
FacetTaxonomies,
|
|
26
26
|
memoizeFilteredTags,
|
|
27
|
+
pruneHierarchyFacetItemsForMerge,
|
|
27
28
|
removeFilterItem,
|
|
29
|
+
resolveHierarchyFacetCountLabel,
|
|
30
|
+
resolveFacetTaxonomy,
|
|
28
31
|
ResolvedFilterItem,
|
|
29
32
|
resolveLabelByLanguage,
|
|
33
|
+
shouldRenderFacetHierarchy,
|
|
30
34
|
updateFilterParam
|
|
31
35
|
} from "./utils";
|
|
36
|
+
import { buildTaxonomyHierarchy } from "../../taxonomy/hierarchy";
|
|
32
37
|
import { FilterItem, Tags } from "@c-rex/interfaces";
|
|
33
38
|
import type { TaxonomyResult } from "@c-rex/services/read-models";
|
|
34
39
|
import { useSearchNavigationStore } from "../../stores/search-navigation-store";
|
|
@@ -39,7 +44,8 @@ import { useFilterSidebarState } from "./context";
|
|
|
39
44
|
export interface FilterSidebarProps {
|
|
40
45
|
tags?: Tags
|
|
41
46
|
totalItemCount?: number
|
|
42
|
-
|
|
47
|
+
facetPresentation?: FacetPresentation
|
|
48
|
+
facetLabelOverrides?: FacetPresentation
|
|
43
49
|
facetTaxonomies?: FacetTaxonomies
|
|
44
50
|
includeZeroHits?: boolean
|
|
45
51
|
includeProperties?: string[]
|
|
@@ -49,6 +55,7 @@ export interface FilterSidebarProps {
|
|
|
49
55
|
const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
50
56
|
tags,
|
|
51
57
|
totalItemCount,
|
|
58
|
+
facetPresentation,
|
|
52
59
|
facetLabelOverrides,
|
|
53
60
|
facetTaxonomies,
|
|
54
61
|
includeZeroHits = true,
|
|
@@ -56,6 +63,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
56
63
|
excludeProperties,
|
|
57
64
|
}) => {
|
|
58
65
|
const t = useTranslations();
|
|
66
|
+
const tagT = useTranslations("filter.tags");
|
|
59
67
|
const locale = useLocale();
|
|
60
68
|
const device = useBreakpoint();
|
|
61
69
|
const isMobile = device !== null && (device === DEVICE_OPTIONS.MOBILE || device === DEVICE_OPTIONS.TABLET);
|
|
@@ -68,16 +76,17 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
68
76
|
shallow: false,
|
|
69
77
|
});
|
|
70
78
|
const startSearchNavigation = useSearchNavigationStore((state) => state.start);
|
|
79
|
+
const effectiveFacetPresentation = facetPresentation || facetLabelOverrides;
|
|
71
80
|
|
|
72
81
|
const filteredTags = useMemo(() => {
|
|
73
82
|
const resolved = memoizeFilteredTags(tags, params.filter, params.packages, {
|
|
74
83
|
uiLanguage: locale,
|
|
75
|
-
|
|
84
|
+
facetPresentation: effectiveFacetPresentation,
|
|
76
85
|
includeZeroHits,
|
|
77
86
|
facetTaxonomies,
|
|
78
87
|
});
|
|
79
88
|
return applyFacetPropertyVisibility(resolved, includeProperties, excludeProperties);
|
|
80
|
-
}, [tags, params.filter, params.packages, locale,
|
|
89
|
+
}, [tags, params.filter, params.packages, locale, effectiveFacetPresentation, facetTaxonomies, includeZeroHits, includeProperties, excludeProperties]);
|
|
81
90
|
const [expandedFacetItems, setExpandedFacetItems] = useState<Record<string, number>>({});
|
|
82
91
|
const [expandedHierarchyNodes, setExpandedHierarchyNodes] = useState<Record<string, boolean>>({});
|
|
83
92
|
const getVisibleCount = (key: string, total: number) => expandedFacetItems[key] || Math.min(total, 10);
|
|
@@ -106,14 +115,23 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
const resolvePropertyLabel = (propertyKey: string): string => {
|
|
109
|
-
const taxonomy = facetTaxonomies
|
|
118
|
+
const taxonomy = resolveFacetTaxonomy(propertyKey, facetTaxonomies);
|
|
110
119
|
const virtualRootLabel = taxonomy?.roots?.length === 1 && taxonomy.roots[0]?.isVirtualGroup
|
|
111
120
|
? taxonomy.roots[0].label
|
|
112
121
|
: undefined;
|
|
113
|
-
if (virtualRootLabel) {
|
|
122
|
+
if (virtualRootLabel && virtualRootLabel !== "Ungrouped") {
|
|
114
123
|
return virtualRootLabel;
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
try {
|
|
127
|
+
const translated = tagT(propertyKey as Parameters<typeof tagT>[0]);
|
|
128
|
+
if (translated && translated !== propertyKey) {
|
|
129
|
+
return translated;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Fallback to API labels when no UI translation exists for the facet key.
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
const rawLabels = (tags?.[propertyKey]?.labels as unknown[] | undefined) || [];
|
|
118
136
|
const normalized = rawLabels
|
|
119
137
|
.map((item) => {
|
|
@@ -135,100 +153,14 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
135
153
|
return resolveLabelByLanguage(normalized, locale) || propertyKey;
|
|
136
154
|
};
|
|
137
155
|
|
|
138
|
-
const isHierarchicalFacet = (items: ResolvedFilterItem[]): boolean => items.some((item) => (item.parentIds || []).length > 0);
|
|
139
|
-
|
|
140
156
|
const buildHierarchy = (propertyKey: string, items: ResolvedFilterItem[]) => {
|
|
141
|
-
const taxonomy =
|
|
157
|
+
const taxonomy = resolveFacetTaxonomy(propertyKey, facetTaxonomies) as TaxonomyResult | undefined;
|
|
142
158
|
if (taxonomy) {
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
items.forEach((item) => {
|
|
147
|
-
const itemId = item.taxonomyId || item.shortId;
|
|
148
|
-
if (!itemId) return;
|
|
149
|
-
selectedById.set(itemId, item);
|
|
150
|
-
includedIds.add(itemId);
|
|
151
|
-
|
|
152
|
-
const taxonomyNode = taxonomy.nodesById[itemId];
|
|
153
|
-
(taxonomyNode?.ancestorIds || []).forEach((ancestorId) => {
|
|
154
|
-
if (taxonomy.nodesById[ancestorId]) includedIds.add(ancestorId);
|
|
155
|
-
});
|
|
156
|
-
(taxonomyNode?.parentIds || []).forEach((parentId) => {
|
|
157
|
-
if (taxonomy.nodesById[parentId]) includedIds.add(parentId);
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
const byId = new Map<string, ResolvedFilterItem>();
|
|
162
|
-
const children = new Map<string, ResolvedFilterItem[]>();
|
|
163
|
-
|
|
164
|
-
Array.from(includedIds).forEach((nodeId) => {
|
|
165
|
-
const taxonomyNode = taxonomy.nodesById[nodeId];
|
|
166
|
-
if (!taxonomyNode) return;
|
|
167
|
-
|
|
168
|
-
const selected = selectedById.get(nodeId);
|
|
169
|
-
|
|
170
|
-
byId.set(nodeId, selected || {
|
|
171
|
-
shortId: taxonomyNode.shortId || nodeId,
|
|
172
|
-
label: taxonomyNode.label,
|
|
173
|
-
active: false,
|
|
174
|
-
hits: 0,
|
|
175
|
-
total: 0,
|
|
176
|
-
taxonomyId: taxonomyNode.id,
|
|
177
|
-
parentIds: taxonomyNode.parentIds || [],
|
|
178
|
-
isStructural: true,
|
|
179
|
-
});
|
|
180
|
-
children.set(nodeId, []);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
Array.from(includedIds).forEach((nodeId) => {
|
|
184
|
-
const current = byId.get(nodeId);
|
|
185
|
-
const taxonomyNode = taxonomy.nodesById[nodeId];
|
|
186
|
-
if (!current || !taxonomyNode) return;
|
|
187
|
-
|
|
188
|
-
const parentKey = (taxonomyNode.parentIds || []).find((id) => byId.has(id));
|
|
189
|
-
if (parentKey) {
|
|
190
|
-
children.get(parentKey)?.push(current);
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
const roots = Array.from(byId.values()).filter((node) => {
|
|
195
|
-
const nodeId = node.taxonomyId || node.shortId;
|
|
196
|
-
const taxonomyNode = nodeId ? taxonomy.nodesById[nodeId] : undefined;
|
|
197
|
-
return !(taxonomyNode?.parentIds || []).some((id) => byId.has(id));
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
const sortItems = (nodes: ResolvedFilterItem[]) => nodes.sort((a, b) => a.label.localeCompare(b.label));
|
|
201
|
-
sortItems(roots);
|
|
202
|
-
children.forEach((nodes) => sortItems(nodes));
|
|
203
|
-
|
|
204
|
-
return { roots, children };
|
|
159
|
+
const mergeSeedItems = pruneHierarchyFacetItemsForMerge(propertyKey, items);
|
|
160
|
+
return buildTaxonomyHierarchy(mergeSeedItems, taxonomy);
|
|
205
161
|
}
|
|
206
162
|
|
|
207
|
-
|
|
208
|
-
const children = new Map<string, ResolvedFilterItem[]>();
|
|
209
|
-
const roots: ResolvedFilterItem[] = [];
|
|
210
|
-
|
|
211
|
-
items.forEach((item) => {
|
|
212
|
-
const key = item.taxonomyId || item.shortId;
|
|
213
|
-
byId.set(key, item);
|
|
214
|
-
children.set(key, []);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
items.forEach((item) => {
|
|
218
|
-
const parentKey = (item.parentIds || []).find((id) => byId.has(id));
|
|
219
|
-
if (!parentKey) {
|
|
220
|
-
roots.push(item);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
children.get(parentKey)?.push(item);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
const sortItems = (nodes: ResolvedFilterItem[]) => nodes.sort((a, b) => a.label.localeCompare(b.label));
|
|
228
|
-
sortItems(roots);
|
|
229
|
-
children.forEach((nodes) => sortItems(nodes));
|
|
230
|
-
|
|
231
|
-
return { roots, children };
|
|
163
|
+
return buildTaxonomyHierarchy(items);
|
|
232
164
|
};
|
|
233
165
|
|
|
234
166
|
const renderFacetItems = (key: string, items: ResolvedFilterItem[]) => {
|
|
@@ -341,7 +273,13 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
341
273
|
const itemKey = item.taxonomyId || item.shortId;
|
|
342
274
|
const children = childrenMap.get(itemKey) || [];
|
|
343
275
|
const hasChildren = children.length > 0;
|
|
344
|
-
const
|
|
276
|
+
const countLabel = resolveHierarchyFacetCountLabel(item, children);
|
|
277
|
+
const expanded = hasChildren
|
|
278
|
+
? depth === 0
|
|
279
|
+
? expandedHierarchyNodes[itemKey] === true
|
|
280
|
+
: expandedHierarchyNodes[itemKey] !== false
|
|
281
|
+
: false;
|
|
282
|
+
const toggleSlotClass = "mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center";
|
|
345
283
|
|
|
346
284
|
return (
|
|
347
285
|
<SidebarMenuSubItem key={item.shortId}>
|
|
@@ -350,16 +288,14 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
350
288
|
{hasChildren ? (
|
|
351
289
|
<button
|
|
352
290
|
type="button"
|
|
353
|
-
className=
|
|
291
|
+
className={`${toggleSlotClass} rounded hover:bg-sidebar-accent`}
|
|
354
292
|
onClick={() => setExpandedHierarchyNodes((prev) => ({ ...prev, [itemKey]: !prev[itemKey] }))}
|
|
355
293
|
aria-label={expanded ? "Collapse" : "Expand"}
|
|
356
294
|
>
|
|
357
295
|
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
358
296
|
</button>
|
|
359
|
-
) : depth > 0 ? (
|
|
360
|
-
<span className="inline-block w-5" />
|
|
361
297
|
) : (
|
|
362
|
-
<span className=
|
|
298
|
+
<span className={toggleSlotClass} aria-hidden="true" />
|
|
363
299
|
)}
|
|
364
300
|
<SidebarMenuSubButton
|
|
365
301
|
className={item.isStructural ? "!py-1.5 cursor-default text-muted-foreground" : "cursor-pointer !py-1.5"}
|
|
@@ -369,7 +305,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
369
305
|
onClick={item.isStructural ? undefined : () => onClickHandler(propertyKey, item)}
|
|
370
306
|
style={{ marginLeft: depth * 14 }}
|
|
371
307
|
>
|
|
372
|
-
{item.label}
|
|
308
|
+
{item.label}{countLabel ? ` ${countLabel}` : ""}
|
|
373
309
|
{item.active && <Check className="ml-2" />}
|
|
374
310
|
</SidebarMenuSubButton>
|
|
375
311
|
</div>
|
|
@@ -387,10 +323,10 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
387
323
|
<SidebarContent className="!gap-0 capitalize" suppressHydrationWarning>
|
|
388
324
|
{Object.entries(filteredTags).map(([key, value]) => {
|
|
389
325
|
const items = value as ResolvedFilterItem[];
|
|
390
|
-
const hasTaxonomy = Boolean(facetTaxonomies
|
|
391
|
-
const hasSectionLabels = !hasTaxonomy && items.some((item) => Boolean(item.sectionLabel));
|
|
326
|
+
const hasTaxonomy = Boolean(resolveFacetTaxonomy(key, facetTaxonomies));
|
|
392
327
|
const propertyLabel = resolvePropertyLabel(key);
|
|
393
|
-
const
|
|
328
|
+
const hasSectionLabels = items.some((item) => Boolean(item.sectionLabel));
|
|
329
|
+
const hierarchicalFacet = shouldRenderFacetHierarchy(items, { hasTaxonomy });
|
|
394
330
|
const hierarchy = hierarchicalFacet ? buildHierarchy(key, items) : undefined;
|
|
395
331
|
const visibleRootCount = getVisibleCount(key, hierarchy?.roots.length || 0);
|
|
396
332
|
const visibleRoots = hierarchy?.roots.slice(0, visibleRootCount) || [];
|
|
@@ -455,7 +391,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
455
391
|
<div className="sr-only">{propertyLabel}</div>
|
|
456
392
|
{Object.entries(
|
|
457
393
|
items.reduce<Record<string, ResolvedFilterItem[]>>((acc, item) => {
|
|
458
|
-
const sectionLabel = item.sectionLabel ||
|
|
394
|
+
const sectionLabel = item.sectionLabel || propertyLabel;
|
|
459
395
|
if (!acc[sectionLabel]) acc[sectionLabel] = [];
|
|
460
396
|
acc[sectionLabel].push(item);
|
|
461
397
|
return acc;
|
|
@@ -464,6 +400,13 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
464
400
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
465
401
|
.map(([sectionLabel, sectionItems]) => {
|
|
466
402
|
const hasNestedGroups = sectionItems.some((item) => Boolean(item.groupLabel));
|
|
403
|
+
const sectionHasTaxonomy = Boolean(resolveFacetTaxonomy(key, facetTaxonomies));
|
|
404
|
+
const sectionHierarchy = sectionHasTaxonomy ? buildHierarchy(key, sectionItems) : undefined;
|
|
405
|
+
const shouldRenderSectionHierarchy = sectionHasTaxonomy && (sectionHierarchy?.roots.length || 0) > 0;
|
|
406
|
+
const visibleSectionRootCount = getVisibleCount(`${key}:${sectionLabel}`, sectionHierarchy?.roots.length || 0);
|
|
407
|
+
const visibleSectionRoots = sectionHierarchy?.roots.slice(0, visibleSectionRootCount) || [];
|
|
408
|
+
const hasMoreSectionRoots = (sectionHierarchy?.roots.length || 0) > visibleSectionRootCount;
|
|
409
|
+
const canShowLessSectionRoots = visibleSectionRootCount > getInitialVisibleCount(sectionHierarchy?.roots.length || 0);
|
|
467
410
|
return (
|
|
468
411
|
<Collapsible
|
|
469
412
|
defaultOpen
|
|
@@ -484,13 +427,39 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
|
|
|
484
427
|
<SidebarGroupContent>
|
|
485
428
|
<SidebarMenu className="!gap-0.5">
|
|
486
429
|
<SidebarMenuSub className="!ml-2 !pl-1.5 !py-0 !gap-0.5">
|
|
487
|
-
{!hasNestedGroups &&
|
|
430
|
+
{shouldRenderSectionHierarchy && !hasNestedGroups &&
|
|
431
|
+
visibleSectionRoots.map((item) => renderHierarchyNode(key, item, sectionHierarchy!.children, 0))}
|
|
432
|
+
{shouldRenderSectionHierarchy && !hasNestedGroups && (hasMoreSectionRoots || canShowLessSectionRoots) && (
|
|
433
|
+
<SidebarMenuSubItem>
|
|
434
|
+
<div className="flex items-center gap-3 px-2">
|
|
435
|
+
{hasMoreSectionRoots && (
|
|
436
|
+
<Button
|
|
437
|
+
variant="ghost"
|
|
438
|
+
className="justify-start px-0 text-sm"
|
|
439
|
+
onClick={() => showMore(`${key}:${sectionLabel}`, sectionHierarchy?.roots.length || 0)}
|
|
440
|
+
>
|
|
441
|
+
{t("more")}
|
|
442
|
+
</Button>
|
|
443
|
+
)}
|
|
444
|
+
{canShowLessSectionRoots && (
|
|
445
|
+
<Button
|
|
446
|
+
variant="ghost"
|
|
447
|
+
className="justify-start px-0 text-sm"
|
|
448
|
+
onClick={() => showLess(`${key}:${sectionLabel}`, sectionHierarchy?.roots.length || 0)}
|
|
449
|
+
>
|
|
450
|
+
{t("less")}
|
|
451
|
+
</Button>
|
|
452
|
+
)}
|
|
453
|
+
</div>
|
|
454
|
+
</SidebarMenuSubItem>
|
|
455
|
+
)}
|
|
456
|
+
{!shouldRenderSectionHierarchy && !hasNestedGroups &&
|
|
488
457
|
renderGroupedItems(key, `${key}:${sectionLabel}`, sectionItems)}
|
|
489
458
|
|
|
490
459
|
{hasNestedGroups &&
|
|
491
460
|
Object.entries(
|
|
492
461
|
sectionItems.reduce<Record<string, ResolvedFilterItem[]>>((acc, item) => {
|
|
493
|
-
const groupLabel = item.groupLabel ||
|
|
462
|
+
const groupLabel = item.groupLabel || sectionLabel;
|
|
494
463
|
if (!acc[groupLabel]) acc[groupLabel] = [];
|
|
495
464
|
acc[groupLabel].push(item);
|
|
496
465
|
return acc;
|