Skip to content

Commit 69e29a1

Browse files
authored
Merge pull request #9229 from Couchers-org/web/feature/community-subcommunity-dropdown
Web: sub-community drill-down dropdown on community breadcrumbs New SubCommunitiesDropdown — a MUI Button + Menu with a search field and a filterable item list. Reuses the existing useListSubCommunities / listCommunities query and its pages are drained via fetchNextPage so all children load, then the count gates rendering. Responsive: on mobile the breadcrumb row stacks above a centered Join/Leave butto
2 parents ca01c52 + 581ea49 commit 69e29a1

6 files changed

Lines changed: 398 additions & 4 deletions

File tree

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

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,44 @@ 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

16-
const StyledBreadcrumbsContainer = styled("div")(() => ({
18+
const StyledBreadcrumbsContainer = styled("div")(({ theme }) => ({
1719
display: "flex",
1820
alignItems: "center",
1921
justifyContent: "space-between",
22+
gap: theme.spacing(1),
23+
[theme.breakpoints.down("sm")]: {
24+
flexDirection: "column",
25+
alignItems: "flex-start",
26+
},
2027
}));
2128

22-
const StyledBreadcrumbs = styled(Breadcrumbs)(() => ({
29+
const StyledBreadcrumbs = styled(Breadcrumbs)(({ theme }) => ({
30+
minWidth: 0,
2331
"& ol": {
2432
justifyContent: "flex-start",
2533
},
34+
[theme.breakpoints.down("sm")]: {
35+
fontSize: theme.typography.body2.fontSize,
36+
"& .MuiTypography-root, & .MuiLink-root, & .MuiButton-root": {
37+
fontSize: "inherit",
38+
},
39+
"& li": {
40+
flexShrink: 0,
41+
whiteSpace: "nowrap",
42+
},
43+
},
2644
}));
2745

2846
export default function CommunityPageSubHeader({
@@ -36,6 +54,20 @@ export default function CommunityPageSubHeader({
3654
const isPublicTripsEnabled = process.env.NODE_ENV !== "production";
3755

3856
const router = useRouter();
57+
58+
const {
59+
data: subCommunitiesData,
60+
hasNextPage,
61+
isFetchingNextPage,
62+
fetchNextPage,
63+
} = useListSubCommunities(community.communityId);
64+
useEffect(() => {
65+
if (hasNextPage && !isFetchingNextPage) {
66+
fetchNextPage();
67+
}
68+
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
69+
const subCommunities =
70+
subCommunitiesData?.pages.flatMap((page) => page.communitiesList) ?? [];
3971
const communityTabBarLabels: Partial<
4072
Record<CommunityTab, string | ReactNode>
4173
> = {
@@ -89,6 +121,9 @@ export default function CommunityPageSubHeader({
89121
</StyledLink>
90122
),
91123
)}
124+
{subCommunities.length > 0 && (
125+
<SubCommunitiesDropdown subCommunities={subCommunities} />
126+
)}
92127
</StyledBreadcrumbs>
93128
<JoinCommunityButton community={community} />
94129
</StyledBreadcrumbsContainer>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ export default function JoinCommunityButton({
7272
loading={isLoading}
7373
variant={community.member ? "outlined" : "contained"}
7474
onClick={() => (community.member ? leave.mutate() : join.mutate())}
75+
sx={{
76+
whiteSpace: "nowrap",
77+
flexShrink: 0,
78+
alignSelf: { xs: "center", sm: "auto" },
79+
}}
7580
>
7681
{community.member
7782
? t("communities:leave_community")
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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("platform/sentry", () => {
20+
const mockCaptureException = jest.fn();
21+
return {
22+
captureException: mockCaptureException,
23+
default: {
24+
captureException: mockCaptureException,
25+
},
26+
};
27+
});
28+
29+
const mockChildCommunity = (
30+
overrides: Partial<Community.AsObject>,
31+
): Community.AsObject => ({
32+
communityId: 100,
33+
name: "Amsterdam",
34+
slug: "amsterdam",
35+
description: "",
36+
parentsList: [],
37+
member: false,
38+
admin: false,
39+
memberCount: 1,
40+
adminCount: 1,
41+
nearbyUserCount: 0,
42+
canModerate: false,
43+
smallCommunityFeaturesEnabled: false,
44+
nodeType: NodeType.NODE_TYPE_LOCALITY,
45+
...overrides,
46+
});
47+
48+
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
49+
await user.click(
50+
await screen.findByRole("button", {
51+
name: t("communities:sub_community_dropdown_a11y"),
52+
}),
53+
);
54+
};
55+
56+
describe("SubCommunitiesDropdown", () => {
57+
beforeEach(() => {
58+
jest.clearAllMocks();
59+
});
60+
61+
it("labels the trigger button for the children's node type", async () => {
62+
render(
63+
<SubCommunitiesDropdown subCommunities={[mockChildCommunity({})]} />,
64+
{
65+
wrapper,
66+
},
67+
);
68+
69+
expect(
70+
await screen.findByRole("button", {
71+
name: t("communities:sub_community_dropdown_a11y"),
72+
}),
73+
).toHaveTextContent(t("communities:select_locality"));
74+
});
75+
76+
it("filters the menu as the user types without losing keystrokes to the menu", async () => {
77+
const user = userEvent.setup();
78+
render(
79+
<SubCommunitiesDropdown
80+
subCommunities={[
81+
mockChildCommunity({ communityId: 100, name: "Amsterdam" }),
82+
mockChildCommunity({
83+
communityId: 101,
84+
name: "Berlin",
85+
slug: "berlin",
86+
}),
87+
]}
88+
/>,
89+
{ wrapper },
90+
);
91+
92+
await openMenu(user);
93+
94+
expect(await screen.findByRole("menuitem", { name: "Amsterdam" }));
95+
expect(screen.getByRole("menuitem", { name: "Berlin" }));
96+
97+
const searchInput = screen.getByPlaceholderText(
98+
t("communities:sub_community_search_placeholder"),
99+
);
100+
await user.type(searchInput, "ams");
101+
102+
expect(searchInput).toHaveValue("ams");
103+
expect(screen.getByRole("menuitem", { name: "Amsterdam" }));
104+
expect(
105+
screen.queryByRole("menuitem", { name: "Berlin" }),
106+
).not.toBeInTheDocument();
107+
});
108+
109+
it("closes the menu when Escape is pressed from the search input", async () => {
110+
const user = userEvent.setup();
111+
render(
112+
<SubCommunitiesDropdown subCommunities={[mockChildCommunity({})]} />,
113+
{
114+
wrapper,
115+
},
116+
);
117+
118+
await openMenu(user);
119+
120+
const searchInput = screen.getByPlaceholderText(
121+
t("communities:sub_community_search_placeholder"),
122+
);
123+
searchInput.focus();
124+
await user.keyboard("{Escape}");
125+
126+
await waitFor(() =>
127+
expect(
128+
screen.queryByRole("menuitem", { name: "Amsterdam" }),
129+
).not.toBeInTheDocument(),
130+
);
131+
});
132+
133+
it("navigates to the selected child community", async () => {
134+
const amsterdam = mockChildCommunity({
135+
communityId: 100,
136+
name: "Amsterdam",
137+
slug: "amsterdam",
138+
});
139+
const user = userEvent.setup();
140+
render(
141+
<SubCommunitiesDropdown
142+
subCommunities={[
143+
amsterdam,
144+
mockChildCommunity({
145+
communityId: 101,
146+
name: "Berlin",
147+
slug: "berlin",
148+
}),
149+
]}
150+
/>,
151+
{ wrapper },
152+
);
153+
154+
await openMenu(user);
155+
156+
await user.click(
157+
await screen.findByRole("menuitem", { name: "Amsterdam" }),
158+
);
159+
160+
expect(mockPush).toHaveBeenCalledWith(
161+
routeToCommunity(amsterdam.communityId, amsterdam.slug),
162+
);
163+
});
164+
165+
it("shows a request-community link when the search has no matches", async () => {
166+
const user = userEvent.setup();
167+
render(
168+
<SubCommunitiesDropdown subCommunities={[mockChildCommunity({})]} />,
169+
{
170+
wrapper,
171+
},
172+
);
173+
174+
await openMenu(user);
175+
176+
await user.type(
177+
screen.getByPlaceholderText(
178+
t("communities:sub_community_search_placeholder"),
179+
),
180+
"NonExistentCity",
181+
);
182+
183+
expect(
184+
await screen.findByRole("link", { name: "Request this community!" }),
185+
).toBeInTheDocument();
186+
});
187+
});

0 commit comments

Comments
 (0)