Skip to content

Commit 81b0ba4

Browse files
authored
Merge branch 'develop' into andybalaam/reset-encryption-redesign2
2 parents 992ce94 + 4f4f391 commit 81b0ba4

File tree

15 files changed

+616
-13
lines changed

15 files changed

+616
-13
lines changed

playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { expect, test } from "../../../element-web-test";
9-
import type { Page } from "@playwright/test";
9+
import type { Locator, Page } from "@playwright/test";
1010

1111
test.describe("Room list filters and sort", () => {
1212
test.use({
@@ -18,10 +18,14 @@ test.describe("Room list filters and sort", () => {
1818
labsFlags: ["feature_new_room_list"],
1919
});
2020

21-
function getPrimaryFilters(page: Page) {
21+
function getPrimaryFilters(page: Page): Locator {
2222
return page.getByRole("listbox", { name: "Room list filters" });
2323
}
2424

25+
function getSecondaryFilters(page: Page): Locator {
26+
return page.getByRole("button", { name: "Filter" });
27+
}
28+
2529
/**
2630
* Get the room list
2731
* @param page
@@ -106,6 +110,11 @@ test.describe("Room list filters and sort", () => {
106110
await app.client.evaluate(async (client, favouriteId) => {
107111
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
108112
}, favouriteId);
113+
114+
const lowPrioId = await app.client.createRoom({ name: "Low prio room" });
115+
await app.client.evaluate(async (client, id) => {
116+
await client.setRoomTag(id, "m.lowpriority", { order: 0.5 });
117+
}, lowPrioId);
109118
});
110119

111120
test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
@@ -137,7 +146,19 @@ test.describe("Room list filters and sort", () => {
137146
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
138147
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
139148
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
140-
expect(await roomList.locator("role=gridcell").count()).toBe(3);
149+
expect(await roomList.locator("role=gridcell").count()).toBe(4);
150+
});
151+
152+
test("should filter the list (with secondary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
153+
const roomList = getRoomList(page);
154+
const secondaryFilters = getSecondaryFilters(page);
155+
await secondaryFilters.click();
156+
157+
await expect(page.getByRole("menu", { name: "Filter" })).toMatchScreenshot("filter-menu.png");
158+
159+
await page.getByRole("menuitem", { name: "Low priority" }).click();
160+
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
161+
expect(await roomList.locator("role=gridcell").count()).toBe(1);
141162
});
142163

143164
test(
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
9+
import React, { type Ref, type JSX, useState } from "react";
10+
import {
11+
ArrowDownIcon,
12+
ChatIcon,
13+
ChatNewIcon,
14+
CheckIcon,
15+
FilterIcon,
16+
MentionIcon,
17+
} from "@vector-im/compound-design-tokens/assets/web/icons";
18+
19+
import { _t } from "../../../../languageHandler";
20+
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
21+
import { SecondaryFilters } from "../../../viewmodels/roomlist/useFilteredRooms";
22+
import { textForSecondaryFilter } from "./textForFilter";
23+
24+
interface MenuTriggerProps extends React.ComponentProps<typeof IconButton> {
25+
ref?: Ref<HTMLButtonElement>;
26+
}
27+
28+
const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => (
29+
<Tooltip label={_t("room_list|filter")}>
30+
<IconButton size="28px" aria-label={_t("room_list|filter")} {...props} ref={ref}>
31+
<FilterIcon />
32+
</IconButton>
33+
</Tooltip>
34+
);
35+
36+
interface FilterOptionProps {
37+
/**
38+
* The filter to display
39+
*/
40+
filter: SecondaryFilters;
41+
42+
/**
43+
* True if the filter is selected
44+
*/
45+
selected: boolean;
46+
47+
/**
48+
* The function to call when the filter is selected
49+
*/
50+
onSelect: (filter: SecondaryFilters) => void;
51+
}
52+
53+
function iconForFilter(filter: SecondaryFilters, size: string): JSX.Element {
54+
switch (filter) {
55+
case SecondaryFilters.AllActivity:
56+
return <ChatIcon width={size} height={size} />;
57+
case SecondaryFilters.MentionsOnly:
58+
return <MentionIcon width={size} height={size} />;
59+
case SecondaryFilters.InvitesOnly:
60+
return <ChatNewIcon width={size} height={size} />;
61+
case SecondaryFilters.LowPriority:
62+
return <ArrowDownIcon width={size} height={size} />;
63+
}
64+
}
65+
66+
function FilterOption({ filter, selected, onSelect }: FilterOptionProps): JSX.Element {
67+
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
68+
69+
return (
70+
<MenuItem
71+
aria-selected={selected}
72+
hideChevron={true}
73+
Icon={iconForFilter(filter, "20px")}
74+
label={textForSecondaryFilter(filter)}
75+
onSelect={() => {
76+
onSelect(filter);
77+
}}
78+
>
79+
{selected && checkComponent}
80+
</MenuItem>
81+
);
82+
}
83+
84+
interface Props {
85+
/**
86+
* The view model for the room list view
87+
*/
88+
vm: RoomListViewState;
89+
}
90+
91+
export function RoomListFilterMenu({ vm }: Props): JSX.Element {
92+
const [open, setOpen] = useState(false);
93+
94+
return (
95+
<Menu
96+
open={open}
97+
onOpenChange={setOpen}
98+
title={_t("room_list|filter")}
99+
showTitle={true}
100+
align="start"
101+
trigger={<MenuTrigger />}
102+
>
103+
{[
104+
SecondaryFilters.AllActivity,
105+
SecondaryFilters.MentionsOnly,
106+
SecondaryFilters.InvitesOnly,
107+
SecondaryFilters.LowPriority,
108+
].map((filter) => (
109+
<FilterOption
110+
key={filter}
111+
filter={filter}
112+
selected={vm.activeSecondaryFilter === filter}
113+
onSelect={(selectedFilter) => {
114+
vm.activateSecondaryFilter(selectedFilter);
115+
setOpen(false);
116+
}}
117+
/>
118+
))}
119+
</Menu>
120+
);
121+
}

src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListVie
1111
import { Flex } from "../../../utils/Flex";
1212
import { _t } from "../../../../languageHandler";
1313
import { RoomListOptionsMenu } from "./RoomListOptionsMenu";
14+
import { RoomListFilterMenu } from "./RoomListFilterMenu";
15+
import { textForSecondaryFilter } from "./textForFilter";
1416

1517
interface Props {
1618
/**
@@ -23,13 +25,17 @@ interface Props {
2325
* The secondary filters for the room list (eg. mentions only / invites only).
2426
*/
2527
export function RoomListSecondaryFilters({ vm }: Props): JSX.Element {
28+
const activeFilterText = textForSecondaryFilter(vm.activeSecondaryFilter);
29+
2630
return (
2731
<Flex
2832
aria-label={_t("room_list|secondary_filters")}
2933
className="mx_RoomListSecondaryFilters"
3034
align="center"
31-
gap="8px"
35+
gap="4px"
3236
>
37+
<RoomListFilterMenu vm={vm} />
38+
{activeFilterText}
3339
<RoomListOptionsMenu vm={vm} />
3440
</Flex>
3541
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { _t } from "../../../../languageHandler";
9+
import { SecondaryFilters } from "../../../viewmodels/roomlist/useFilteredRooms";
10+
11+
/**
12+
* Gives the human readable text name for a secondary filter.
13+
* @param filter The filter in question
14+
* @returns The translated, human readable name for the filter
15+
*/
16+
export function textForSecondaryFilter(filter: SecondaryFilters): string {
17+
switch (filter) {
18+
case SecondaryFilters.AllActivity:
19+
return _t("room_list|secondary_filter|all_activity");
20+
case SecondaryFilters.MentionsOnly:
21+
return _t("room_list|secondary_filter|mentions_only");
22+
case SecondaryFilters.InvitesOnly:
23+
return _t("room_list|secondary_filter|invites_only");
24+
case SecondaryFilters.LowPriority:
25+
return _t("room_list|secondary_filter|low_priority");
26+
default:
27+
throw new Error("Unknown filter");
28+
}
29+
}

src/i18n/strings/en_EN.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2118,6 +2118,7 @@
21182118
"failed_add_tag": "Failed to add tag %(tagName)s to room",
21192119
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
21202120
"failed_set_dm_tag": "Failed to set direct message tag",
2121+
"filter": "Filter",
21212122
"filters": {
21222123
"favourite": "Favourites",
21232124
"people": "People",
@@ -2151,6 +2152,12 @@
21512152
"open_room": "Open room %(roomName)s"
21522153
},
21532154
"room_options": "Room Options",
2155+
"secondary_filter": {
2156+
"all_activity": "All activity",
2157+
"invites_only": "Invites only",
2158+
"low_priority": "Low priority",
2159+
"mentions_only": "Mentions only"
2160+
},
21542161
"secondary_filters": "Secondary filters",
21552162
"show_less": "Show less",
21562163
"show_message_previews": "Show message previews",
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React from "react";
9+
import { render, type RenderOptions, screen } from "jest-matrix-react";
10+
import userEvent from "@testing-library/user-event";
11+
import { TooltipProvider } from "@vector-im/compound-web";
12+
13+
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
14+
import { SecondaryFilters } from "../../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
15+
import { SortOption } from "../../../../../../src/components/viewmodels/roomlist/useSorter";
16+
import { RoomListFilterMenu } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListFilterMenu";
17+
18+
function getRenderOptions(): RenderOptions {
19+
return {
20+
wrapper: ({ children }) => <TooltipProvider>{children}</TooltipProvider>,
21+
};
22+
}
23+
24+
describe("<RoomListFilterMenu />", () => {
25+
let vm: RoomListViewState;
26+
27+
beforeEach(() => {
28+
vm = {
29+
rooms: [],
30+
canCreateRoom: true,
31+
createRoom: jest.fn(),
32+
createChatRoom: jest.fn(),
33+
primaryFilters: [],
34+
activateSecondaryFilter: () => {},
35+
activeSecondaryFilter: SecondaryFilters.AllActivity,
36+
sort: jest.fn(),
37+
activeSortOption: SortOption.Activity,
38+
shouldShowMessagePreview: false,
39+
toggleMessagePreview: jest.fn(),
40+
activeIndex: undefined,
41+
};
42+
});
43+
44+
it("should render room list filter menu button", async () => {
45+
const { asFragment } = render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
46+
expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument();
47+
expect(asFragment()).toMatchSnapshot();
48+
});
49+
50+
it("opens the menu on click", async () => {
51+
const userevent = userEvent.setup();
52+
53+
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
54+
await userevent.click(screen.getByRole("button", { name: "Filter" }));
55+
expect(screen.getByRole("menu", { name: "Filter" })).toBeInTheDocument();
56+
});
57+
58+
it("shows 'All activity' checked if selected", async () => {
59+
const userevent = userEvent.setup();
60+
61+
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
62+
await userevent.click(screen.getByRole("button", { name: "Filter" }));
63+
64+
const shouldBeSelected = screen.getByRole("menuitem", { name: "All activity" });
65+
expect(shouldBeSelected).toHaveAttribute("aria-selected", "true");
66+
expect(shouldBeSelected).toMatchSnapshot();
67+
});
68+
69+
it("shows 'Invites only' checked if selected", async () => {
70+
const userevent = userEvent.setup();
71+
72+
vm.activeSecondaryFilter = SecondaryFilters.InvitesOnly;
73+
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
74+
await userevent.click(screen.getByRole("button", { name: "Filter" }));
75+
76+
const shouldBeSelected = screen.getByRole("menuitem", { name: "Invites only" });
77+
expect(shouldBeSelected).toHaveAttribute("aria-selected", "true");
78+
expect(shouldBeSelected).toMatchSnapshot();
79+
});
80+
81+
it("shows 'Low priority' checked if selected", async () => {
82+
const userevent = userEvent.setup();
83+
84+
vm.activeSecondaryFilter = SecondaryFilters.LowPriority;
85+
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
86+
await userevent.click(screen.getByRole("button", { name: "Filter" }));
87+
88+
const shouldBeSelected = screen.getByRole("menuitem", { name: "Low priority" });
89+
expect(shouldBeSelected).toHaveAttribute("aria-selected", "true");
90+
expect(shouldBeSelected).toMatchSnapshot();
91+
});
92+
93+
it("shows 'Mentions only' checked if selected", async () => {
94+
const userevent = userEvent.setup();
95+
96+
vm.activeSecondaryFilter = SecondaryFilters.MentionsOnly;
97+
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
98+
await userevent.click(screen.getByRole("button", { name: "Filter" }));
99+
100+
const shouldBeSelected = screen.getByRole("menuitem", { name: "Mentions only" });
101+
expect(shouldBeSelected).toHaveAttribute("aria-selected", "true");
102+
expect(shouldBeSelected).toMatchSnapshot();
103+
});
104+
105+
it("activates filter when item clicked", async () => {
106+
const userevent = userEvent.setup();
107+
108+
vm.activateSecondaryFilter = jest.fn();
109+
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
110+
await userevent.click(screen.getByRole("button", { name: "Filter" }));
111+
await userevent.click(screen.getByRole("menuitem", { name: "Invites only" }));
112+
113+
expect(vm.activateSecondaryFilter).toHaveBeenCalledWith(SecondaryFilters.InvitesOnly);
114+
});
115+
});

0 commit comments

Comments
 (0)