@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.
- 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-selection-command-menu.tsx +76 -11
- package/src/restriction-menu/restriction-selection-menu.tsx +80 -15
- package/src/restriction-menu/taxonomy-restriction-command-menu.tsx +6 -0
- package/src/restriction-menu/taxonomy-restriction-menu.tsx +4 -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
package/package.json
CHANGED
|
@@ -12,32 +12,18 @@ import { InformationUnitsGetAllClient } from "../generated/client-components";
|
|
|
12
12
|
import type {
|
|
13
13
|
CommonItemsModel,
|
|
14
14
|
InformationUnitModel,
|
|
15
|
-
LiteralModel,
|
|
16
|
-
ObjectRefModel,
|
|
17
|
-
RenditionModel,
|
|
18
15
|
} from "@c-rex/interfaces";
|
|
16
|
+
import type { AvailableLanguageVersion } from "@c-rex/services/read-models";
|
|
19
17
|
import {
|
|
20
|
-
|
|
21
|
-
type
|
|
22
|
-
} from "@c-rex/services/metadata-
|
|
23
|
-
import type { AvailableLanguageVersion, MetadataFacetLabelOverrides } from "@c-rex/services/read-models";
|
|
18
|
+
buildMetadataDisplayRows,
|
|
19
|
+
type MetadataFacetLabelOverrides,
|
|
20
|
+
} from "@c-rex/services/metadata-display-builder";
|
|
24
21
|
import { resolveMetadataDisplayProperties } from "@c-rex/services/metadata-view-profile";
|
|
25
22
|
import {
|
|
26
23
|
extractCountryCodeFromLanguage,
|
|
27
|
-
getFileRenditionGroups,
|
|
28
24
|
resolvePreferredLanguage,
|
|
29
|
-
sortAndDeduplicateLanguages,
|
|
30
25
|
} from "@c-rex/utils";
|
|
31
26
|
|
|
32
|
-
type MetadataDisplayRow = {
|
|
33
|
-
key: InformationUnitPropertyKey;
|
|
34
|
-
label: string;
|
|
35
|
-
labelSource: "translationKey" | "direct";
|
|
36
|
-
values: string[];
|
|
37
|
-
valueType?: "text" | "language" | "rendition";
|
|
38
|
-
renditions?: RenditionModel[];
|
|
39
|
-
};
|
|
40
|
-
|
|
41
27
|
type Props = {
|
|
42
28
|
title: string;
|
|
43
29
|
linkPattern: string;
|
|
@@ -51,198 +37,6 @@ type Props = {
|
|
|
51
37
|
availableInVersions?: AvailableLanguageVersion[];
|
|
52
38
|
};
|
|
53
39
|
|
|
54
|
-
const resolveLiteralLabel = (labels?: LiteralModel[] | null, uiLanguage?: string): string | undefined => {
|
|
55
|
-
if (!labels || labels.length === 0) return undefined;
|
|
56
|
-
const language = uiLanguage || "en";
|
|
57
|
-
const preferred = resolvePreferredLanguage(
|
|
58
|
-
labels.map((item) => item.language || ""),
|
|
59
|
-
language
|
|
60
|
-
);
|
|
61
|
-
if (preferred) {
|
|
62
|
-
const exact = labels.find((item) => item.language === preferred)?.value;
|
|
63
|
-
if (exact) return exact;
|
|
64
|
-
}
|
|
65
|
-
return labels.find((item) => item.value)?.value || undefined;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const resolveObjectRefLabel = (item: ObjectRefModel, uiLanguage: string): string | undefined => {
|
|
69
|
-
return resolveLiteralLabel(item.labels || [], uiLanguage) || item.shortId || item.id || undefined;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const resolveObjectRefClassLabel = (item: ObjectRefModel, uiLanguage: string): string | undefined => {
|
|
73
|
-
return resolveLiteralLabel(item.class?.labels || [], uiLanguage)
|
|
74
|
-
|| item.class?.shortId
|
|
75
|
-
|| item.class?.id
|
|
76
|
-
|| undefined;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const asObjectRefArray = (value: unknown): ObjectRefModel[] => {
|
|
80
|
-
if (!Array.isArray(value)) return [];
|
|
81
|
-
return value.filter((item): item is ObjectRefModel => Boolean(item && typeof item === "object"));
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const asLiteralArray = (value: unknown): LiteralModel[] => {
|
|
85
|
-
if (!Array.isArray(value)) return [];
|
|
86
|
-
return value.filter((item): item is LiteralModel => Boolean(item && typeof item === "object"));
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const buildMetadataDisplayRows = (
|
|
90
|
-
data: CommonItemsModel,
|
|
91
|
-
uiLanguage: string,
|
|
92
|
-
includeProperties: InformationUnitPropertyKey[],
|
|
93
|
-
overrides?: MetadataFacetLabelOverrides
|
|
94
|
-
): MetadataDisplayRow[] => {
|
|
95
|
-
const preferredTitle = resolveLiteralLabel(data.titles || [], uiLanguage) || resolveLiteralLabel(data.labels || [], uiLanguage);
|
|
96
|
-
const rows: MetadataDisplayRow[] = [];
|
|
97
|
-
|
|
98
|
-
for (const key of includeProperties) {
|
|
99
|
-
const config = INFORMATION_UNIT_PROPERTY_PRESENTATION[key];
|
|
100
|
-
if (!config?.metadataDisplay?.supported) continue;
|
|
101
|
-
|
|
102
|
-
if (key === "labels") continue;
|
|
103
|
-
|
|
104
|
-
if (key === "titles") {
|
|
105
|
-
if (preferredTitle) {
|
|
106
|
-
rows.push({
|
|
107
|
-
key,
|
|
108
|
-
label: key,
|
|
109
|
-
labelSource: "translationKey",
|
|
110
|
-
values: [preferredTitle],
|
|
111
|
-
valueType: "text",
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const value = (data as InformationUnitModel)[key];
|
|
118
|
-
if (value == null) continue;
|
|
119
|
-
|
|
120
|
-
if (key === "languages") {
|
|
121
|
-
const languages = sortAndDeduplicateLanguages((value as string[]) || []);
|
|
122
|
-
if (languages.length > 0) {
|
|
123
|
-
rows.push({
|
|
124
|
-
key,
|
|
125
|
-
label: key,
|
|
126
|
-
labelSource: "translationKey",
|
|
127
|
-
values: languages,
|
|
128
|
-
valueType: "language",
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (config.valueKind === "scalar") {
|
|
135
|
-
rows.push({
|
|
136
|
-
key,
|
|
137
|
-
label: key,
|
|
138
|
-
labelSource: "translationKey",
|
|
139
|
-
values: [String(value)],
|
|
140
|
-
valueType: "text",
|
|
141
|
-
});
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (config.valueKind === "stringArray") {
|
|
146
|
-
const values = Array.from(new Set((value as string[]).map((item) => String(item)).filter(Boolean)));
|
|
147
|
-
if (values.length > 0) {
|
|
148
|
-
rows.push({
|
|
149
|
-
key,
|
|
150
|
-
label: key,
|
|
151
|
-
labelSource: "translationKey",
|
|
152
|
-
values,
|
|
153
|
-
valueType: "text",
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (config.valueKind === "literalArray") {
|
|
160
|
-
const preferred = resolveLiteralLabel(asLiteralArray(value), uiLanguage);
|
|
161
|
-
if (preferred) {
|
|
162
|
-
rows.push({
|
|
163
|
-
key,
|
|
164
|
-
label: key,
|
|
165
|
-
labelSource: "translationKey",
|
|
166
|
-
values: [preferred],
|
|
167
|
-
valueType: "text",
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (config.valueKind === "objectRef") {
|
|
174
|
-
const label = resolveObjectRefLabel(value as ObjectRefModel, uiLanguage);
|
|
175
|
-
if (label) {
|
|
176
|
-
rows.push({
|
|
177
|
-
key,
|
|
178
|
-
label: key,
|
|
179
|
-
labelSource: "translationKey",
|
|
180
|
-
values: [label],
|
|
181
|
-
valueType: "text",
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (config.valueKind === "objectRefArray") {
|
|
188
|
-
const refs = asObjectRefArray(value);
|
|
189
|
-
if (refs.length === 0) continue;
|
|
190
|
-
|
|
191
|
-
const groupedValues = new Map<string, Set<string>>();
|
|
192
|
-
|
|
193
|
-
refs.forEach((ref) => {
|
|
194
|
-
const shortId = ref.shortId || "";
|
|
195
|
-
const override = shortId ? overrides?.[key]?.[shortId] : undefined;
|
|
196
|
-
const valueLabel = override?.label || resolveObjectRefLabel(ref, uiLanguage);
|
|
197
|
-
if (!valueLabel) return;
|
|
198
|
-
|
|
199
|
-
const sectionLabel = config.metadataDisplay.sectionStrategy === "none"
|
|
200
|
-
? key
|
|
201
|
-
: override?.sectionLabel || resolveObjectRefClassLabel(ref, uiLanguage) || key;
|
|
202
|
-
|
|
203
|
-
const existing = groupedValues.get(sectionLabel) || new Set<string>();
|
|
204
|
-
existing.add(valueLabel);
|
|
205
|
-
groupedValues.set(sectionLabel, existing);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
Array.from(groupedValues.entries())
|
|
209
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
210
|
-
.forEach(([sectionLabel, valueSet]) => {
|
|
211
|
-
const values = Array.from(valueSet).sort((a, b) => a.localeCompare(b));
|
|
212
|
-
if (values.length === 0) return;
|
|
213
|
-
|
|
214
|
-
rows.push({
|
|
215
|
-
key,
|
|
216
|
-
label: sectionLabel,
|
|
217
|
-
labelSource: sectionLabel === key ? "translationKey" : "direct",
|
|
218
|
-
values,
|
|
219
|
-
valueType: "text",
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (config.valueKind === "renditionArray") {
|
|
226
|
-
const renditions = Array.isArray(value)
|
|
227
|
-
? value.filter((item): item is RenditionModel => Boolean(item && typeof item === "object"))
|
|
228
|
-
: [];
|
|
229
|
-
if (renditions.length === 0) continue;
|
|
230
|
-
if (getFileRenditionGroups({ renditions }).length === 0) continue;
|
|
231
|
-
|
|
232
|
-
rows.push({
|
|
233
|
-
key,
|
|
234
|
-
label: "files",
|
|
235
|
-
labelSource: "translationKey",
|
|
236
|
-
values: [],
|
|
237
|
-
valueType: "rendition",
|
|
238
|
-
renditions,
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return rows;
|
|
244
|
-
};
|
|
245
|
-
|
|
246
40
|
const normalizeVersionItems = (items: CommonItemsModel[], uiLanguage: string) => {
|
|
247
41
|
const uniqueByShortId = new Map<string, { shortId: string; language: string }>();
|
|
248
42
|
|
|
@@ -339,7 +133,11 @@ export const InformationUnitMetadataGridClient = ({
|
|
|
339
133
|
: [...(metadataExcludeProperties || []), "renditions"],
|
|
340
134
|
});
|
|
341
135
|
|
|
342
|
-
return buildMetadataDisplayRows(data
|
|
136
|
+
return buildMetadataDisplayRows(data as InformationUnitModel, {
|
|
137
|
+
uiLanguage: locale,
|
|
138
|
+
includeProperties,
|
|
139
|
+
overrides: metadataLabelOverrides,
|
|
140
|
+
});
|
|
343
141
|
}, [data, locale, metadataExcludeProperties, metadataIncludeProperties, metadataLabelOverrides, showFileRenditions]);
|
|
344
142
|
|
|
345
143
|
const cardContent = (
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { isRestrictionHierarchyNodeSelectable } from "../restriction-hierarchy";
|
|
2
|
+
|
|
3
|
+
describe("restriction hierarchy semantics", () => {
|
|
4
|
+
it("treats materialized structural taxonomy nodes as non-selectable", () => {
|
|
5
|
+
expect(isRestrictionHierarchyNodeSelectable({ isStructural: true })).toBe(false);
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it("keeps live taxonomy nodes selectable", () => {
|
|
9
|
+
expect(isRestrictionHierarchyNodeSelectable({ isStructural: false })).toBe(true);
|
|
10
|
+
expect(isRestrictionHierarchyNodeSelectable({})).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { RestrictionNavigationItem } from "./restriction-menu-item";
|
|
10
10
|
import { parseAsString, useQueryState, useQueryStates } from "nuqs";
|
|
11
11
|
import { DomainEntityModel, ObjectRefModel } from "@c-rex/interfaces";
|
|
12
|
+
import type { TaxonomyResult } from "@c-rex/services/read-models";
|
|
12
13
|
import { useLocale, useTranslations } from 'next-intl'
|
|
13
14
|
import { cn, getLabelByLang } from "@c-rex/utils";
|
|
14
15
|
import { useRestrictionStore } from "../stores/restriction-store";
|
|
@@ -20,12 +21,15 @@ import { Button } from "@c-rex/ui/button";
|
|
|
20
21
|
import { Check, ChevronDown } from "lucide-react";
|
|
21
22
|
import { useSearchNavigationStore } from "../stores/search-navigation-store";
|
|
22
23
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@c-rex/ui/tooltip";
|
|
24
|
+
import { buildTaxonomyHierarchy, type HierarchyItem } from "../taxonomy/hierarchy";
|
|
25
|
+
import { isRestrictionHierarchyNodeSelectable } from "./restriction-hierarchy";
|
|
23
26
|
|
|
24
27
|
type Props = {
|
|
25
28
|
restrictField: string
|
|
26
29
|
navigationMenuListClassName?: string
|
|
27
30
|
items: DomainEntityModel[],
|
|
28
31
|
enableHierarchy?: boolean,
|
|
32
|
+
hierarchyTaxonomy?: TaxonomyResult,
|
|
29
33
|
hasMoreItems?: boolean,
|
|
30
34
|
showAllWhenEmpty?: boolean,
|
|
31
35
|
onRequestMore?: () => void,
|
|
@@ -41,6 +45,10 @@ type RestrictionTreeNode = {
|
|
|
41
45
|
children: RestrictionTreeNode[];
|
|
42
46
|
};
|
|
43
47
|
|
|
48
|
+
type RestrictionHierarchyItem = HierarchyItem & {
|
|
49
|
+
item?: DomainEntityModel;
|
|
50
|
+
};
|
|
51
|
+
|
|
44
52
|
const hasSelectedDescendant = (node: RestrictionTreeNode, selectedShortIds: Set<string>): boolean => {
|
|
45
53
|
const shortId = node.item.shortId;
|
|
46
54
|
if (shortId && selectedShortIds.has(shortId)) return true;
|
|
@@ -64,6 +72,13 @@ const extractParentKeys = (item: DomainEntityModel): string[] => {
|
|
|
64
72
|
});
|
|
65
73
|
};
|
|
66
74
|
|
|
75
|
+
const extractParentIds = (item: DomainEntityModel): string[] => {
|
|
76
|
+
const withParents = item as DomainEntityModel & { parents?: ObjectRefModel[] | null };
|
|
77
|
+
return (withParents.parents || [])
|
|
78
|
+
.map((parent) => parent.id || parent.shortId)
|
|
79
|
+
.filter((parentId): parentId is string => Boolean(parentId));
|
|
80
|
+
};
|
|
81
|
+
|
|
67
82
|
const buildRestrictionTree = (items: DomainEntityModel[]): RestrictionTreeNode[] => {
|
|
68
83
|
const nodes = new Map<string, RestrictionTreeNode>();
|
|
69
84
|
items.forEach((item) => {
|
|
@@ -217,18 +232,25 @@ const RestrictionCommandDialog: FC<RestrictionCommandDialogProps> = ({
|
|
|
217
232
|
const groupShortId = node.item.shortId ?? "";
|
|
218
233
|
const groupLabel = fmt(getLabelByLang(node.item.labels, lang) ?? groupShortId);
|
|
219
234
|
const groupIsSelected = selectedRestrictionIds.has(groupShortId);
|
|
235
|
+
const groupIsSelectable = isRestrictionHierarchyNodeSelectable(node.item);
|
|
220
236
|
|
|
221
237
|
return (
|
|
222
238
|
<>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
{groupIsSelectable ? (
|
|
240
|
+
<CommandItem
|
|
241
|
+
key={groupShortId}
|
|
242
|
+
value={groupLabel}
|
|
243
|
+
onSelect={() => handleSelect(groupShortId, groupIsSelected)}
|
|
244
|
+
className="cursor-pointer"
|
|
245
|
+
>
|
|
246
|
+
<span className={cn("flex-1", groupIsSelected && "font-medium")}>{groupLabel}</span>
|
|
247
|
+
{groupIsSelected && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
|
248
|
+
</CommandItem>
|
|
249
|
+
) : (
|
|
250
|
+
<div key={groupShortId} className="px-2 py-3 text-sm text-muted-foreground">
|
|
251
|
+
{groupLabel}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
232
254
|
{node.children.map((child) => {
|
|
233
255
|
const shortId = child.item.shortId ?? "";
|
|
234
256
|
const childLabel = fmt(getLabelByLang(child.item.labels, lang) ?? shortId);
|
|
@@ -260,6 +282,7 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
|
|
|
260
282
|
items,
|
|
261
283
|
restrictField,
|
|
262
284
|
enableHierarchy = false,
|
|
285
|
+
hierarchyTaxonomy,
|
|
263
286
|
hasMoreItems = false,
|
|
264
287
|
showAllWhenEmpty = true,
|
|
265
288
|
onRequestMore,
|
|
@@ -313,8 +336,39 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
|
|
|
313
336
|
|
|
314
337
|
const hierarchyRoots = useMemo(() => {
|
|
315
338
|
if (!enableHierarchy) return [];
|
|
316
|
-
return buildRestrictionTree(sortedItems);
|
|
317
|
-
|
|
339
|
+
if (!hierarchyTaxonomy) return buildRestrictionTree(sortedItems);
|
|
340
|
+
|
|
341
|
+
const hierarchy = buildTaxonomyHierarchy<RestrictionHierarchyItem>(
|
|
342
|
+
sortedItems.map((item) => ({
|
|
343
|
+
shortId: item.shortId || item.id || "",
|
|
344
|
+
label: getLabelByLang(item.labels, lang) || item.shortId || item.id || "",
|
|
345
|
+
active: selectedRestrictionIds.has(item.shortId || ""),
|
|
346
|
+
hits: 0,
|
|
347
|
+
total: 0,
|
|
348
|
+
taxonomyId: item.id || undefined,
|
|
349
|
+
parentIds: extractParentIds(item),
|
|
350
|
+
item,
|
|
351
|
+
})),
|
|
352
|
+
hierarchyTaxonomy
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const toTreeNode = (node: RestrictionHierarchyItem): RestrictionTreeNode => {
|
|
356
|
+
const nodeKey = node.taxonomyId || node.shortId;
|
|
357
|
+
const runtimeItem = node.item || {
|
|
358
|
+
id: node.taxonomyId,
|
|
359
|
+
shortId: node.shortId,
|
|
360
|
+
labels: [{ language: lang, value: node.label }],
|
|
361
|
+
isStructural: node.isStructural,
|
|
362
|
+
} as DomainEntityModel;
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
item: runtimeItem,
|
|
366
|
+
children: (hierarchy.children.get(nodeKey) || []).map((child) => toTreeNode(child)),
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
return hierarchy.roots.map((root) => toTreeNode(root));
|
|
371
|
+
}, [enableHierarchy, hierarchyTaxonomy, lang, selectedRestrictionIds, sortedItems]);
|
|
318
372
|
|
|
319
373
|
const device = useBreakpoint();
|
|
320
374
|
const [visibleCount, setVisibleCount] = useState(0);
|
|
@@ -383,6 +437,7 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
|
|
|
383
437
|
{enableHierarchy && visibleHierarchyRoots.map((rootNode) => {
|
|
384
438
|
const shortId = rootNode.item.shortId || "";
|
|
385
439
|
const hasChildren = rootNode.children.length > 0;
|
|
440
|
+
const isSelectable = isRestrictionHierarchyNodeSelectable(rootNode.item);
|
|
386
441
|
const rawLabel = getLabelByLang(rootNode.item.labels, lang);
|
|
387
442
|
const label = rawLabel
|
|
388
443
|
const rootSelected = restrictionValues.includes(shortId);
|
|
@@ -390,6 +445,16 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
|
|
|
390
445
|
const shouldHighlightBranch = hasActiveDescendant && !rootSelected;
|
|
391
446
|
|
|
392
447
|
if (!hasChildren) {
|
|
448
|
+
if (!isSelectable) {
|
|
449
|
+
return (
|
|
450
|
+
<NavigationMenuItem key={shortId}>
|
|
451
|
+
<div className="flex min-h-9 items-center rounded-full border border-transparent px-4 py-2 text-sm text-muted-foreground">
|
|
452
|
+
{label}
|
|
453
|
+
</div>
|
|
454
|
+
</NavigationMenuItem>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
393
458
|
return (
|
|
394
459
|
<RestrictionNavigationItem
|
|
395
460
|
key={shortId}
|
|
@@ -11,12 +11,15 @@ import {
|
|
|
11
11
|
import { RestrictionDropdownItem, RestrictionNavigationItem } from "./restriction-menu-item";
|
|
12
12
|
import { parseAsString, useQueryStates } from "nuqs";
|
|
13
13
|
import { DomainEntityModel, ObjectRefModel } from "@c-rex/interfaces";
|
|
14
|
+
import type { TaxonomyResult } from "@c-rex/services/read-models";
|
|
14
15
|
import { useLocale, useTranslations } from 'next-intl'
|
|
15
16
|
import { cn, getLabelByLang } from "@c-rex/utils";
|
|
16
17
|
import { useRestrictionStore } from "../stores/restriction-store";
|
|
17
18
|
import { useBreakpoint } from "@c-rex/ui/hooks";
|
|
18
19
|
import { DEVICE_OPTIONS } from "@c-rex/constants";
|
|
19
20
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
21
|
+
import { buildTaxonomyHierarchy, type HierarchyItem } from "../taxonomy/hierarchy";
|
|
22
|
+
import { isRestrictionHierarchyNodeSelectable } from "./restriction-hierarchy";
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
type Props = {
|
|
@@ -24,6 +27,7 @@ type Props = {
|
|
|
24
27
|
navigationMenuListClassName?: string
|
|
25
28
|
items: DomainEntityModel[],
|
|
26
29
|
enableHierarchy?: boolean,
|
|
30
|
+
hierarchyTaxonomy?: TaxonomyResult,
|
|
27
31
|
hasMoreItems?: boolean,
|
|
28
32
|
showAllWhenEmpty?: boolean,
|
|
29
33
|
onRequestMore?: () => void,
|
|
@@ -41,6 +45,10 @@ type RestrictionTreeNode = {
|
|
|
41
45
|
children: RestrictionTreeNode[];
|
|
42
46
|
};
|
|
43
47
|
|
|
48
|
+
type RestrictionHierarchyItem = HierarchyItem & {
|
|
49
|
+
item?: DomainEntityModel;
|
|
50
|
+
};
|
|
51
|
+
|
|
44
52
|
const hasSelectedDescendant = (node: RestrictionTreeNode, selectedShortIds: Set<string>): boolean => {
|
|
45
53
|
const shortId = node.item.shortId;
|
|
46
54
|
if (shortId && selectedShortIds.has(shortId)) {
|
|
@@ -68,6 +76,13 @@ const extractParentKeys = (item: DomainEntityModel): string[] => {
|
|
|
68
76
|
});
|
|
69
77
|
};
|
|
70
78
|
|
|
79
|
+
const extractParentIds = (item: DomainEntityModel): string[] => {
|
|
80
|
+
const withParents = item as DomainEntityModel & { parents?: ObjectRefModel[] | null };
|
|
81
|
+
return (withParents.parents || [])
|
|
82
|
+
.map((parent) => parent.id || parent.shortId)
|
|
83
|
+
.filter((parentId): parentId is string => Boolean(parentId));
|
|
84
|
+
};
|
|
85
|
+
|
|
71
86
|
const buildRestrictionTree = (items: DomainEntityModel[]): RestrictionTreeNode[] => {
|
|
72
87
|
const nodes = new Map<string, RestrictionTreeNode>();
|
|
73
88
|
items.forEach((item) => {
|
|
@@ -116,6 +131,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
|
|
|
116
131
|
items,
|
|
117
132
|
restrictField,
|
|
118
133
|
enableHierarchy = false,
|
|
134
|
+
hierarchyTaxonomy,
|
|
119
135
|
hasMoreItems = false,
|
|
120
136
|
showAllWhenEmpty = true,
|
|
121
137
|
onRequestMore,
|
|
@@ -181,10 +197,40 @@ export const RestrictionSelectionMenu: FC<Props> = ({
|
|
|
181
197
|
return sorted;
|
|
182
198
|
}, [items, restrictionValues, updatePosition]);
|
|
183
199
|
|
|
184
|
-
const
|
|
200
|
+
const hierarchyTree = useMemo(() => {
|
|
185
201
|
if (!enableHierarchy) return [];
|
|
186
|
-
return buildRestrictionTree(sortedItems);
|
|
187
|
-
|
|
202
|
+
if (!hierarchyTaxonomy) return buildRestrictionTree(sortedItems);
|
|
203
|
+
|
|
204
|
+
const hierarchy = buildTaxonomyHierarchy<RestrictionHierarchyItem>(
|
|
205
|
+
sortedItems.map((item) => ({
|
|
206
|
+
shortId: item.shortId || item.id || "",
|
|
207
|
+
label: getLabelByLang(item.labels, lang) || item.shortId || item.id || "",
|
|
208
|
+
active: selectedRestrictionIds.has(item.shortId || ""),
|
|
209
|
+
hits: 0,
|
|
210
|
+
total: 0,
|
|
211
|
+
taxonomyId: item.id || undefined,
|
|
212
|
+
parentIds: extractParentIds(item),
|
|
213
|
+
item,
|
|
214
|
+
})),
|
|
215
|
+
hierarchyTaxonomy
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const toTreeNode = (node: RestrictionHierarchyItem): RestrictionTreeNode => {
|
|
219
|
+
const nodeKey = node.taxonomyId || node.shortId;
|
|
220
|
+
const runtimeItem = node.item || {
|
|
221
|
+
id: node.taxonomyId,
|
|
222
|
+
shortId: node.shortId,
|
|
223
|
+
labels: [{ language: lang, value: node.label }],
|
|
224
|
+
} as DomainEntityModel;
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
item: runtimeItem,
|
|
228
|
+
children: (hierarchy.children.get(nodeKey) || []).map((child) => toTreeNode(child)),
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return hierarchy.roots.map((root) => toTreeNode(root));
|
|
233
|
+
}, [enableHierarchy, hierarchyTaxonomy, lang, selectedRestrictionIds, sortedItems]);
|
|
188
234
|
|
|
189
235
|
const device = useBreakpoint();
|
|
190
236
|
const [visibleCount, setVisibleCount] = useState(0);
|
|
@@ -201,7 +247,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
|
|
|
201
247
|
if (visibleCount == 0) return;
|
|
202
248
|
|
|
203
249
|
if (enableHierarchy) {
|
|
204
|
-
const roots =
|
|
250
|
+
const roots = hierarchyTree.map((node) => node.item);
|
|
205
251
|
setVisibleItems(roots.slice(0, visibleCount));
|
|
206
252
|
setHiddenItems(roots.slice(visibleCount));
|
|
207
253
|
return;
|
|
@@ -209,19 +255,19 @@ export const RestrictionSelectionMenu: FC<Props> = ({
|
|
|
209
255
|
|
|
210
256
|
setVisibleItems(sortedItems.slice(0, visibleCount));
|
|
211
257
|
setHiddenItems(sortedItems.slice(visibleCount));
|
|
212
|
-
}, [enableHierarchy,
|
|
258
|
+
}, [enableHierarchy, hierarchyTree, sortedItems, visibleCount]);
|
|
213
259
|
|
|
214
260
|
const visibleHierarchyRoots = useMemo(() => {
|
|
215
261
|
if (!enableHierarchy) return [];
|
|
216
262
|
const visibleRootShortIds = new Set(visibleItems.map((item) => item.shortId).filter(Boolean));
|
|
217
|
-
return
|
|
218
|
-
}, [enableHierarchy,
|
|
263
|
+
return hierarchyTree.filter((root) => root.item.shortId && visibleRootShortIds.has(root.item.shortId));
|
|
264
|
+
}, [enableHierarchy, hierarchyTree, visibleItems]);
|
|
219
265
|
|
|
220
266
|
const hiddenHierarchyRoots = useMemo(() => {
|
|
221
267
|
if (!enableHierarchy) return [];
|
|
222
268
|
const hiddenRootShortIds = new Set(hiddenItems.map((item) => item.shortId).filter(Boolean));
|
|
223
|
-
return
|
|
224
|
-
}, [enableHierarchy,
|
|
269
|
+
return hierarchyTree.filter((root) => root.item.shortId && hiddenRootShortIds.has(root.item.shortId));
|
|
270
|
+
}, [enableHierarchy, hierarchyTree, hiddenItems]);
|
|
225
271
|
|
|
226
272
|
const [expandedNodes, setExpandedNodes] = useState<Record<string, boolean>>({});
|
|
227
273
|
const toggleExpanded = (shortId: string) => {
|
|
@@ -234,8 +280,10 @@ export const RestrictionSelectionMenu: FC<Props> = ({
|
|
|
234
280
|
const isRootNode = depth === 0;
|
|
235
281
|
const isExpanded = isRootNode || expandedNodes[shortId] === true;
|
|
236
282
|
const isSelected = restrictionValues.includes(shortId);
|
|
283
|
+
const isSelectable = isRestrictionHierarchyNodeSelectable(node.item);
|
|
237
284
|
const hasActiveDescendant = hasChildren && node.children.some((child) => hasSelectedDescendant(child, selectedRestrictionIds));
|
|
238
285
|
const shouldHighlightBranch = hasActiveDescendant && !isSelected;
|
|
286
|
+
const label = getLabelByLang(node.item.labels, lang);
|
|
239
287
|
|
|
240
288
|
return (
|
|
241
289
|
<li key={`tree-node-${shortId}`} className="flex flex-col w-full">
|
|
@@ -259,12 +307,18 @@ export const RestrictionSelectionMenu: FC<Props> = ({
|
|
|
259
307
|
shouldHighlightBranch && "bg-primary/5 ring-1 ring-primary/10"
|
|
260
308
|
)}
|
|
261
309
|
>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
310
|
+
{isSelectable ? (
|
|
311
|
+
<RestrictionDropdownItem
|
|
312
|
+
shortId={shortId}
|
|
313
|
+
restrictField={restrictField}
|
|
314
|
+
label={label}
|
|
315
|
+
selected={isSelected}
|
|
316
|
+
/>
|
|
317
|
+
) : (
|
|
318
|
+
<div className="flex min-h-10 w-full items-center rounded-full px-4 py-2 text-sm text-muted-foreground">
|
|
319
|
+
{label}
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
268
322
|
</div>
|
|
269
323
|
{shouldHighlightBranch ? (
|
|
270
324
|
<span
|
|
@@ -309,12 +363,23 @@ export const RestrictionSelectionMenu: FC<Props> = ({
|
|
|
309
363
|
{enableHierarchy && visibleHierarchyRoots.map((rootNode) => {
|
|
310
364
|
const shortId = rootNode.item.shortId || "";
|
|
311
365
|
const hasChildren = rootNode.children.length > 0;
|
|
366
|
+
const isSelectable = isRestrictionHierarchyNodeSelectable(rootNode.item);
|
|
312
367
|
const label = getLabelByLang(rootNode.item.labels, lang);
|
|
313
368
|
const rootSelected = restrictionValues.includes(shortId);
|
|
314
369
|
const hasActiveDescendant = hasChildren && hasSelectedDescendant(rootNode, selectedRestrictionIds);
|
|
315
370
|
const shouldHighlightBranch = hasActiveDescendant && !rootSelected;
|
|
316
371
|
|
|
317
372
|
if (!hasChildren) {
|
|
373
|
+
if (!isSelectable) {
|
|
374
|
+
return (
|
|
375
|
+
<NavigationMenuItem key={shortId}>
|
|
376
|
+
<div className="flex min-h-9 items-center rounded-full border border-transparent px-4 py-2 text-sm text-muted-foreground">
|
|
377
|
+
{label}
|
|
378
|
+
</div>
|
|
379
|
+
</NavigationMenuItem>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
318
383
|
return (
|
|
319
384
|
<RestrictionNavigationItem
|
|
320
385
|
key={shortId}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { DomainEntityModel } from "@c-rex/interfaces";
|
|
4
|
+
import type { TaxonomyResult } from "@c-rex/services/read-models";
|
|
4
5
|
import { FC, ReactNode, useMemo, useState } from "react";
|
|
5
6
|
import * as ComponentOptions from "../generated/client-components";
|
|
6
7
|
import { Skeleton } from "@c-rex/ui/skeleton";
|
|
@@ -29,6 +30,8 @@ export type TaxonomyRestrictionCommandMenuProps = {
|
|
|
29
30
|
fetchMode?: RestrictionMenuFetchMode;
|
|
30
31
|
showAllWhenEmpty?: boolean;
|
|
31
32
|
queryParams?: GenericQueryParams;
|
|
33
|
+
stripLabelPrefix?: string;
|
|
34
|
+
hierarchyTaxonomy?: TaxonomyResult;
|
|
32
35
|
};
|
|
33
36
|
|
|
34
37
|
export const TaxonomyRestrictionCommandMenu: FC<TaxonomyRestrictionCommandMenuProps> = ({
|
|
@@ -41,6 +44,8 @@ export const TaxonomyRestrictionCommandMenu: FC<TaxonomyRestrictionCommandMenuPr
|
|
|
41
44
|
fetchMode = "deferred",
|
|
42
45
|
showAllWhenEmpty = true,
|
|
43
46
|
navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
|
|
47
|
+
stripLabelPrefix,
|
|
48
|
+
hierarchyTaxonomy,
|
|
44
49
|
}) => {
|
|
45
50
|
const [loadAll, setLoadAll] = useState(false);
|
|
46
51
|
const RequestComponent = ComponentOptions[requestType] as unknown as FC<GenericRequestProps>;
|
|
@@ -92,6 +97,7 @@ export const TaxonomyRestrictionCommandMenu: FC<TaxonomyRestrictionCommandMenuPr
|
|
|
92
97
|
restrictField={restrictField}
|
|
93
98
|
items={data.items || []}
|
|
94
99
|
enableHierarchy={enableHierarchy}
|
|
100
|
+
hierarchyTaxonomy={hierarchyTaxonomy}
|
|
95
101
|
hasMoreItems={hasMoreItems}
|
|
96
102
|
showAllWhenEmpty={showAllWhenEmpty}
|
|
97
103
|
onRequestMore={() => {
|