Skip to content

Commit 6324e56

Browse files
kevinortiz43claude
andcommitted
Web: add sub-community drill-down dropdown to community breadcrumbs
Adds a searchable MUI dropdown next to the community-page breadcrumbs that lists the current community's direct sub-communities and navigates to the selected one, letting users move down the geographic tree (not just up via the existing breadcrumb links). The dropdown is hidden on leaf communities, its label is derived from the children's node type, and it reuses the existing ListCommunities query. Also mocks that query in DiscussionPage's test since it renders the shared community sub-header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 84ff88a commit 6324e56

5 files changed

Lines changed: 388 additions & 2 deletions

File tree

app/web/features/communities/CommunityPage/CommunityPageSubHeader.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import { Breadcrumbs, styled, Typography } from "@mui/material";
33
import BetaFlag from "components/BetaFlag";
44
import StyledLink from "components/StyledLink";
55
import TabBar from "components/TabBar";
6+
import { useListSubCommunities } from "features/communities/hooks";
67
import { useTranslation } from "i18n";
78
import { COMMUNITIES, PUBLIC_TRIPS } from "i18n/namespaces";
89
import { useRouter } from "next/router";
910
import { Community } from "proto/communities_pb";
1011
import { CommunityParent } from "proto/groups_pb";
11-
import { ReactNode } from "react";
12+
import { ReactNode, useEffect } from "react";
1213
import { CommunityTab, routeToCommunity } from "routes";
1314

1415
import JoinCommunityButton from "./JoinCommunityButton";
16+
import SubCommunitiesDropdown from "./SubCommunitiesDropdown";
1517

1618
const StyledBreadcrumbsContainer = styled("div")(() => ({
1719
display: "flex",
@@ -36,6 +38,26 @@ export default function CommunityPageSubHeader({
3638
const isPublicTripsEnabled = process.env.NODE_ENV !== "production";
3739

3840
const router = useRouter();
41+
42+
// MUI's Breadcrumbs inserts a separator per child element regardless of what that child
43+
// renders to, so a leaf `SubCommunitiesDropdown` (which itself returns null) would still leave
44+
// a dangling separator. Read the same (cached, deduped) query here to omit the element
45+
// entirely when there's nothing to drill into.
46+
const {
47+
data: subCommunitiesData,
48+
hasNextPage,
49+
isFetchingNextPage,
50+
fetchNextPage,
51+
} = useListSubCommunities(community.communityId);
52+
// Fetch every page so the dropdown's client-side search sees all children, not just page one.
53+
// Child counts are bounded (tens), so this is cheap.
54+
useEffect(() => {
55+
if (hasNextPage && !isFetchingNextPage) {
56+
fetchNextPage();
57+
}
58+
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
59+
const subCommunities =
60+
subCommunitiesData?.pages.flatMap((page) => page.communitiesList) ?? [];
3961
const communityTabBarLabels: Partial<
4062
Record<CommunityTab, string | ReactNode>
4163
> = {
@@ -89,6 +111,9 @@ export default function CommunityPageSubHeader({
89111
</StyledLink>
90112
),
91113
)}
114+
{subCommunities.length > 0 && (
115+
<SubCommunitiesDropdown subCommunities={subCommunities} />
116+
)}
92117
</StyledBreadcrumbs>
93118
<JoinCommunityButton community={community} />
94119
</StyledBreadcrumbsContainer>
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { Community, NodeType } from "proto/communities_pb";
4+
import { routeToCommunity } from "routes";
5+
import wrapper from "test/hookWrapper";
6+
import i18n from "test/i18n";
7+
8+
import SubCommunitiesDropdown from "./SubCommunitiesDropdown";
9+
10+
const { t } = i18n;
11+
12+
const mockPush = jest.fn();
13+
jest.mock("next/router", () => ({
14+
useRouter: () => ({
15+
push: mockPush,
16+
}),
17+
}));
18+
19+
// jest.mock("next/router") above replaces the whole module, which drops the `Router.events`
20+
// emitter @sentry/nextjs reads at import time - stub Sentry out the same way CommunitySearch's
21+
// test does so the import doesn't crash.
22+
jest.mock("platform/sentry", () => {
23+
const mockCaptureException = jest.fn();
24+
return {
25+
captureException: mockCaptureException,
26+
default: {
27+
captureException: mockCaptureException,
28+
},
29+
};
30+
});
31+
32+
const mockChildCommunity = (
33+
overrides: Partial<Community.AsObject>,
34+
): Community.AsObject => ({
35+
communityId: 100,
36+
name: "Amsterdam",
37+
slug: "amsterdam",
38+
description: "",
39+
parentsList: [],
40+
member: false,
41+
admin: false,
42+
memberCount: 1,
43+
adminCount: 1,
44+
nearbyUserCount: 0,
45+
canModerate: false,
46+
smallCommunityFeaturesEnabled: false,
47+
nodeType: NodeType.NODE_TYPE_LOCALITY,
48+
...overrides,
49+
});
50+
51+
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
52+
await user.click(
53+
await screen.findByRole("button", {
54+
name: t("communities:sub_community_dropdown_a11y"),
55+
}),
56+
);
57+
};
58+
59+
describe("SubCommunitiesDropdown", () => {
60+
beforeEach(() => {
61+
jest.clearAllMocks();
62+
});
63+
64+
it("labels the trigger button for the children's node type", async () => {
65+
render(
66+
<SubCommunitiesDropdown subCommunities={[mockChildCommunity({})]} />,
67+
{
68+
wrapper,
69+
},
70+
);
71+
72+
expect(
73+
await screen.findByRole("button", {
74+
name: t("communities:sub_community_dropdown_a11y"),
75+
}),
76+
).toHaveTextContent(t("communities:select_locality"));
77+
});
78+
79+
it("filters the menu as the user types without losing keystrokes to the menu", async () => {
80+
const user = userEvent.setup();
81+
render(
82+
<SubCommunitiesDropdown
83+
subCommunities={[
84+
mockChildCommunity({ communityId: 100, name: "Amsterdam" }),
85+
mockChildCommunity({
86+
communityId: 101,
87+
name: "Berlin",
88+
slug: "berlin",
89+
}),
90+
]}
91+
/>,
92+
{ wrapper },
93+
);
94+
95+
await openMenu(user);
96+
97+
expect(await screen.findByRole("menuitem", { name: "Amsterdam" }));
98+
expect(screen.getByRole("menuitem", { name: "Berlin" }));
99+
100+
const searchInput = screen.getByPlaceholderText(
101+
t("communities:sub_community_search_placeholder"),
102+
);
103+
await user.type(searchInput, "ams");
104+
105+
expect(searchInput).toHaveValue("ams");
106+
expect(screen.getByRole("menuitem", { name: "Amsterdam" }));
107+
expect(
108+
screen.queryByRole("menuitem", { name: "Berlin" }),
109+
).not.toBeInTheDocument();
110+
});
111+
112+
it("closes the menu when Escape is pressed from the search input", async () => {
113+
const user = userEvent.setup();
114+
render(
115+
<SubCommunitiesDropdown subCommunities={[mockChildCommunity({})]} />,
116+
{
117+
wrapper,
118+
},
119+
);
120+
121+
await openMenu(user);
122+
123+
const searchInput = screen.getByPlaceholderText(
124+
t("communities:sub_community_search_placeholder"),
125+
);
126+
// stopPropagation on the search input must NOT swallow Escape, or the menu can't close.
127+
searchInput.focus();
128+
await user.keyboard("{Escape}");
129+
130+
await waitFor(() =>
131+
expect(
132+
screen.queryByRole("menuitem", { name: "Amsterdam" }),
133+
).not.toBeInTheDocument(),
134+
);
135+
});
136+
137+
it("navigates to the selected child community", async () => {
138+
const amsterdam = mockChildCommunity({
139+
communityId: 100,
140+
name: "Amsterdam",
141+
slug: "amsterdam",
142+
});
143+
const user = userEvent.setup();
144+
render(
145+
<SubCommunitiesDropdown
146+
subCommunities={[
147+
amsterdam,
148+
mockChildCommunity({
149+
communityId: 101,
150+
name: "Berlin",
151+
slug: "berlin",
152+
}),
153+
]}
154+
/>,
155+
{ wrapper },
156+
);
157+
158+
await openMenu(user);
159+
160+
await user.click(
161+
await screen.findByRole("menuitem", { name: "Amsterdam" }),
162+
);
163+
164+
expect(mockPush).toHaveBeenCalledWith(
165+
routeToCommunity(amsterdam.communityId, amsterdam.slug),
166+
);
167+
});
168+
169+
it("shows a request-community link when the search has no matches", async () => {
170+
const user = userEvent.setup();
171+
render(
172+
<SubCommunitiesDropdown subCommunities={[mockChildCommunity({})]} />,
173+
{
174+
wrapper,
175+
},
176+
);
177+
178+
await openMenu(user);
179+
180+
await user.type(
181+
screen.getByPlaceholderText(
182+
t("communities:sub_community_search_placeholder"),
183+
),
184+
"NonExistentCity",
185+
);
186+
187+
expect(
188+
await screen.findByRole("link", { name: "Request this community!" }),
189+
).toBeInTheDocument();
190+
});
191+
});

0 commit comments

Comments
 (0)