Skip to content

Commit bff8195

Browse files
dbkrDileep Bandla
authored andcommitted
Add room list sorting (element-hq#29951)
* Add room list sorting * Prettier * Unit test * Playwright test * Lint * Use released compound * No tooltip wrapper needed
1 parent 8b3af07 commit bff8195

7 files changed

Lines changed: 228 additions & 7 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"@types/png-chunks-extract": "^1.0.2",
9494
"@types/react-virtualized": "^9.21.30",
9595
"@vector-im/compound-design-tokens": "^4.0.0",
96-
"@vector-im/compound-web": "^7.10.2",
96+
"@vector-im/compound-web": "^7.11.0",
9797
"@vector-im/matrix-wysiwyg": "2.38.3",
9898
"@zxcvbn-ts/core": "^3.0.4",
9999
"@zxcvbn-ts/language-common": "^3.0.4",

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ test.describe("Room list filters and sort", () => {
2929
return page.getByRole("button", { name: "Filter" });
3030
}
3131

32+
function getRoomOptionsMenu(page: Page): Locator {
33+
return page.getByRole("button", { name: "Room Options" });
34+
}
35+
3236
/**
3337
* Get the room list
3438
* @param page
@@ -252,6 +256,23 @@ test.describe("Room list filters and sort", () => {
252256
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
253257
},
254258
);
259+
260+
test("should sort the room list alphabetically", async ({ page }) => {
261+
const roomListView = getRoomList(page);
262+
263+
await getRoomOptionsMenu(page).click();
264+
await page.getByRole("menuitemradio", { name: "A-Z" }).click();
265+
266+
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/empty room/);
267+
});
268+
269+
test("should move room to the top on message when sorting by activity", async ({ page, bot }) => {
270+
const roomListView = getRoomList(page);
271+
272+
await bot.sendMessage(unReadDmId, "Hello!");
273+
274+
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/unread dm/);
275+
});
255276
});
256277

257278
test.describe("Empty room list", () => {

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8-
import { IconButton, Menu, MenuTitle, CheckboxMenuItem, Tooltip } from "@vector-im/compound-web";
9-
import React, { type Ref, type JSX, useState } from "react";
8+
import { IconButton, Menu, MenuTitle, CheckboxMenuItem, Tooltip, RadioMenuItem } from "@vector-im/compound-web";
9+
import React, { type Ref, type JSX, useState, useCallback } from "react";
1010
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
1111

1212
import { _t } from "../../../../languageHandler";
1313
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
14+
import { SortOption } from "../../../viewmodels/roomlist/useSorter";
1415

1516
interface MenuTriggerProps extends React.ComponentProps<typeof IconButton> {
1617
ref?: Ref<HTMLButtonElement>;
@@ -39,6 +40,14 @@ interface Props {
3940
export function RoomListOptionsMenu({ vm }: Props): JSX.Element {
4041
const [open, setOpen] = useState(false);
4142

43+
const onActivitySelected = useCallback(() => {
44+
vm.sort(SortOption.Activity);
45+
}, [vm]);
46+
47+
const onAtoZSelected = useCallback(() => {
48+
vm.sort(SortOption.AToZ);
49+
}, [vm]);
50+
4251
return (
4352
<Menu
4453
open={open}
@@ -48,6 +57,17 @@ export function RoomListOptionsMenu({ vm }: Props): JSX.Element {
4857
align="start"
4958
trigger={<MenuTrigger />}
5059
>
60+
<MenuTitle title={_t("room_list|sort")} />
61+
<RadioMenuItem
62+
label={_t("room_list|sort_type|activity")}
63+
checked={vm.activeSortOption === SortOption.Activity}
64+
onSelect={onActivitySelected}
65+
/>
66+
<RadioMenuItem
67+
label={_t("room_list|sort_type|atoz")}
68+
checked={vm.activeSortOption === SortOption.AToZ}
69+
onSelect={onAtoZSelected}
70+
/>
5171
<MenuTitle title={_t("room_list|appearance")} />
5272
<CheckboxMenuItem
5373
label={_t("room_list|show_message_previews")}

src/i18n/strings/en_EN.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2167,9 +2167,14 @@
21672167
"other": "Show %(count)s more"
21682168
},
21692169
"show_previews": "Show previews of messages",
2170+
"sort": "Sort",
21702171
"sort_by": "Sort by",
21712172
"sort_by_activity": "Activity",
21722173
"sort_by_alphabet": "A-Z",
2174+
"sort_type": {
2175+
"activity": "Activity",
2176+
"atoz": "A-Z"
2177+
},
21732178
"sort_unread_first": "Show rooms with unread messages first",
21742179
"space_menu": {
21752180
"home": "Space home",
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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, screen } from "jest-matrix-react";
10+
import userEvent from "@testing-library/user-event";
11+
12+
import { RoomListOptionsMenu } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListOptionsMenu";
13+
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
14+
15+
describe("<RoomListOptionsMenu />", () => {
16+
it("should match snapshot", () => {
17+
const vm = {
18+
sort: jest.fn(),
19+
} as unknown as RoomListViewState;
20+
21+
const { asFragment } = render(<RoomListOptionsMenu vm={vm} />);
22+
23+
expect(asFragment()).toMatchSnapshot();
24+
});
25+
26+
it("should show A to Z selected if activeSortOption is Alphabetic", async () => {
27+
const user = userEvent.setup();
28+
29+
const vm = {
30+
sort: jest.fn(),
31+
activeSortOption: "Alphabetic",
32+
} as unknown as RoomListViewState;
33+
34+
render(<RoomListOptionsMenu vm={vm} />);
35+
36+
// Open the menu
37+
const button = screen.getByRole("button", { name: "Room Options" });
38+
await user.click(button);
39+
40+
expect(screen.getByRole("menuitemradio", { name: "A-Z" })).toBeChecked();
41+
expect(screen.getByRole("menuitemradio", { name: "Activity" })).not.toBeChecked();
42+
});
43+
44+
it("should show Activity selected if activeSortOption is Recency", async () => {
45+
const user = userEvent.setup();
46+
47+
const vm = {
48+
sort: jest.fn(),
49+
activeSortOption: "Recency",
50+
} as unknown as RoomListViewState;
51+
52+
render(<RoomListOptionsMenu vm={vm} />);
53+
54+
// Open the menu
55+
const button = screen.getByRole("button", { name: "Room Options" });
56+
await user.click(button);
57+
58+
expect(screen.getByRole("menuitemradio", { name: "A-Z" })).not.toBeChecked();
59+
expect(screen.getByRole("menuitemradio", { name: "Activity" })).toBeChecked();
60+
});
61+
62+
it("should sort A to Z", async () => {
63+
const user = userEvent.setup();
64+
65+
const vm = {
66+
sort: jest.fn(),
67+
} as unknown as RoomListViewState;
68+
69+
render(<RoomListOptionsMenu vm={vm} />);
70+
71+
await user.click(screen.getByRole("button", { name: "Room Options" }));
72+
73+
await user.click(screen.getByRole("menuitemradio", { name: "A-Z" }));
74+
75+
expect(vm.sort).toHaveBeenCalledWith("Alphabetic");
76+
});
77+
78+
it("should sort by activity", async () => {
79+
const user = userEvent.setup();
80+
81+
const vm = {
82+
sort: jest.fn(),
83+
activeSortOption: "Alphabetic",
84+
} as unknown as RoomListViewState;
85+
86+
render(<RoomListOptionsMenu vm={vm} />);
87+
88+
await user.click(screen.getByRole("button", { name: "Room Options" }));
89+
90+
await user.click(screen.getByRole("menuitemradio", { name: "Activity" }));
91+
92+
expect(vm.sort).toHaveBeenCalledWith("Recency");
93+
});
94+
95+
it("should show message previews disabled", async () => {
96+
const user = userEvent.setup();
97+
98+
const vm = {
99+
shouldShowMessagePreview: false,
100+
} as unknown as RoomListViewState;
101+
102+
render(<RoomListOptionsMenu vm={vm} />);
103+
104+
await user.click(screen.getByRole("button", { name: "Room Options" }));
105+
106+
expect(screen.getByRole("menuitemcheckbox", { name: "Show message previews" })).not.toBeChecked();
107+
});
108+
109+
it("should show message previews enabled", async () => {
110+
const user = userEvent.setup();
111+
112+
const vm = {
113+
shouldShowMessagePreview: true,
114+
} as unknown as RoomListViewState;
115+
116+
render(<RoomListOptionsMenu vm={vm} />);
117+
118+
await user.click(screen.getByRole("button", { name: "Room Options" }));
119+
120+
expect(screen.getByRole("menuitemcheckbox", { name: "Show message previews" })).toBeChecked();
121+
});
122+
123+
it("should toggle message previews", async () => {
124+
const user = userEvent.setup();
125+
126+
const vm = {
127+
toggleMessagePreview: jest.fn(),
128+
} as unknown as RoomListViewState;
129+
130+
render(<RoomListOptionsMenu vm={vm} />);
131+
132+
await user.click(screen.getByRole("button", { name: "Room Options" }));
133+
134+
await user.click(screen.getByRole("menuitemcheckbox", { name: "Show message previews" }));
135+
136+
expect(vm.toggleMessagePreview).toHaveBeenCalled();
137+
});
138+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`<RoomListOptionsMenu /> should match snapshot 1`] = `
4+
<DocumentFragment>
5+
<button
6+
aria-disabled="false"
7+
aria-expanded="false"
8+
aria-haspopup="menu"
9+
aria-label="Room Options"
10+
aria-labelledby="«r2»"
11+
class="_icon-button_m2erp_8 mx_RoomListSecondaryFilters_roomOptionsButton"
12+
data-state="closed"
13+
id="radix-«r0»"
14+
role="button"
15+
style="--cpd-icon-button-size: 32px;"
16+
tabindex="0"
17+
type="button"
18+
>
19+
<div
20+
class="_indicator-icon_zr2a0_17"
21+
style="--cpd-icon-button-size: 100%;"
22+
>
23+
<svg
24+
fill="currentColor"
25+
height="1em"
26+
viewBox="0 0 24 24"
27+
width="1em"
28+
xmlns="http://www.w3.org/2000/svg"
29+
>
30+
<path
31+
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
32+
/>
33+
</svg>
34+
</div>
35+
</button>
36+
</DocumentFragment>
37+
`;

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3750,10 +3750,10 @@
37503750
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-4.0.2.tgz#27363d26446eaa21880ab126fa51fec112e6fd86"
37513751
integrity sha512-y13bhPyJ5OzbGRl21F6+Y2adrjyK+mu67yKTx+o8MfmIpJzMSn4KkHZtcujMquWSh0e5ZAufsnk4VYvxbSpr1A==
37523752

3753-
"@vector-im/compound-web@^7.10.2":
3754-
version "7.10.2"
3755-
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.10.2.tgz#2f62c6ab83269e5b957f53bb53413a74fb65e04d"
3756-
integrity sha512-K9gA1Ah9CTJMeZTkcDFpAdVRNbu/rQEgV3PoDcEPI3e9iDds8Dhbo7EfOciPvtXCZw6Hr83lnhWDnwTFHVlahQ==
3753+
"@vector-im/compound-web@^7.11.0":
3754+
version "7.11.0"
3755+
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.11.0.tgz#b7c466e64089320b41f8eaf6f2b30950e9692ca2"
3756+
integrity sha512-lRxXUOQJHdBswhykpNs/J/cBW4fPY1qbwyDexlWxX5zCVAYiuMCWo2tI+Y7/SK4tNbDr7nwoTDRh4H9CO1L5LQ==
37573757
dependencies:
37583758
"@floating-ui/react" "^0.27.0"
37593759
"@radix-ui/react-context-menu" "^2.2.1"

0 commit comments

Comments
 (0)