Skip to content

Commit 8cb7dcd

Browse files
Merge tags and tagGroups with the same name in sidebar
Mimic the more permissive "Enhanced tags" behaviour from OpenAPI 3.2.0, by allowing a tag to have both direct operations and nested child tags from tagGroups. When a tag and x-tagGroup share the same name, they are now combined into a single sidebar category instead of appearing twice. However, this current implementation does not use `x-displayName` as, these could still be different tags. Only the actual `name` of a tag is considered when checking name equality.
1 parent 7fecf7d commit 8cb7dcd

File tree

2 files changed

+104
-17
lines changed

2 files changed

+104
-17
lines changed

packages/zudoku/src/lib/plugins/openapi/util/buildTagCategories.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,86 @@ describe("buildTagCategories", () => {
123123

124124
expect(result).toEqual([]);
125125
});
126+
127+
it("merges a tag and tagGroup with the same name", () => {
128+
const tagCategories = new Map<string, NavigationItem>([
129+
["Contacts", makeTag("Contacts")],
130+
["Notes", makeTag("Notes")],
131+
]);
132+
133+
const result = buildTagCategories({
134+
tagCategories,
135+
tagGroups: [{ name: "Contacts", tags: ["Contacts", "Notes"] }],
136+
});
137+
138+
expect(result).toHaveLength(1);
139+
140+
const contacts = result[0];
141+
expect(contacts?.label).toBe("Contacts");
142+
expect(contacts?.type).toBe("category");
143+
144+
if (contacts?.type === "category") {
145+
// Operations from the "Contacts" tag come first, then "Notes" as a child
146+
expect(contacts.items).toHaveLength(2);
147+
expect(contacts.items[0]?.label).toBe("Contacts op");
148+
expect(contacts.items[1]?.label).toBe("Notes");
149+
}
150+
});
151+
152+
it("excludes self-reference when tagGroup includes its own name in tags", () => {
153+
const tagCategories = new Map<string, NavigationItem>([
154+
["Contacts", makeTag("Contacts")],
155+
["Notes", makeTag("Notes")],
156+
]);
157+
158+
const result = buildTagCategories({
159+
tagCategories,
160+
tagGroups: [{ name: "Contacts", tags: ["Contacts", "Notes"] }],
161+
});
162+
163+
const contacts = result[0];
164+
if (contacts?.type === "category") {
165+
// "Contacts" tag should NOT appear as a nested child of itself
166+
const childLabels = contacts.items.map((i) => i.label);
167+
expect(childLabels).not.toContain("Contacts");
168+
}
169+
});
170+
171+
it("excludes merged tag from ungrouped results", () => {
172+
const tagCategories = new Map<string, NavigationItem>([
173+
["Contacts", makeTag("Contacts")],
174+
["Notes", makeTag("Notes")],
175+
["Standalone", makeTag("Standalone")],
176+
]);
177+
178+
const result = buildTagCategories({
179+
tagCategories,
180+
tagGroups: [{ name: "Contacts", tags: ["Contacts", "Notes"] }],
181+
});
182+
183+
// "Contacts" (merged) + "Standalone" (ungrouped) = 2 top-level entries
184+
expect(result).toHaveLength(2);
185+
expect(result.map((r) => r.label)).toEqual(["Contacts", "Standalone"]);
186+
});
187+
188+
it("merges tag with tagGroup that has no additional child tags", () => {
189+
const tagCategories = new Map<string, NavigationItem>([
190+
["Contacts", makeTag("Contacts")],
191+
]);
192+
193+
const result = buildTagCategories({
194+
tagCategories,
195+
tagGroups: [{ name: "Contacts", tags: ["Contacts"] }],
196+
});
197+
198+
expect(result).toHaveLength(1);
199+
200+
const contacts = result[0];
201+
expect(contacts?.label).toBe("Contacts");
202+
if (contacts?.type === "category") {
203+
// Only the original operations, no nested tags
204+
expect(contacts.items).toHaveLength(1);
205+
expect(contacts.items[0]?.label).toBe("Contacts op");
206+
}
207+
});
126208
});

packages/zudoku/src/lib/plugins/openapi/util/buildTagCategories.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,38 @@ export const buildTagCategories = ({
1313
tagGroups,
1414
expandAllTags,
1515
}: BuildTagCategoriesOptions): NavigationItem[] => {
16-
const groupedTags = new Set(
17-
tagGroups.flatMap((group) =>
18-
group.tags.filter((name) => tagCategories.has(name)),
19-
),
20-
);
16+
const consumedTags = new Set<string>();
2117

2218
const groupedCategories: NavigationItem[] = tagGroups.flatMap((group) => {
23-
const items = group.tags
24-
.map((name) => tagCategories.get(name))
25-
.filter(Boolean) as NavigationItem[];
19+
const matchingTag = tagCategories.get(group.name);
20+
const base = matchingTag?.type === "category" ? matchingTag : undefined;
21+
22+
if (base) consumedTags.add(group.name);
23+
24+
const childTags = group.tags
25+
.filter((name) => name !== group.name && tagCategories.has(name))
26+
.flatMap((name) => {
27+
consumedTags.add(name);
28+
const tag = tagCategories.get(name);
29+
return tag ? [tag] : [];
30+
});
31+
32+
if (!base && childTags.length === 0) return [];
2633

27-
if (items.length === 0) {
28-
return [];
29-
}
3034
return [
3135
{
32-
type: "category",
33-
label: group.name,
34-
items,
35-
collapsible: true,
36-
collapsed: !expandAllTags,
36+
...base,
37+
type: "category" as const,
38+
label: base?.label ?? group.name,
39+
items: [...(base?.items ?? []), ...childTags],
40+
collapsible: base?.collapsible ?? true,
41+
collapsed: base?.collapsed ?? !expandAllTags,
3742
},
3843
];
3944
});
4045

4146
const ungroupedCategories = Array.from(tagCategories.entries())
42-
.filter(([name]) => !groupedTags.has(name))
47+
.filter(([name]) => !consumedTags.has(name))
4348
.map(([, cat]) => cat);
4449

4550
return [...groupedCategories, ...ungroupedCategories].sort((a, b) =>

0 commit comments

Comments
 (0)