Add room list sorting (#29951)
* Add room list sorting * Prettier * Unit test * Playwright test * Lint * Use released compound * No tooltip wrapper needed
This commit is contained in:
@@ -93,7 +93,7 @@
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/react-virtualized": "^9.21.30",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-web": "^7.10.2",
|
||||
"@vector-im/compound-web": "^7.11.0",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.3",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
|
||||
@@ -29,6 +29,10 @@ test.describe("Room list filters and sort", () => {
|
||||
return page.getByRole("button", { name: "Filter" });
|
||||
}
|
||||
|
||||
function getRoomOptionsMenu(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "Room Options" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the room list
|
||||
* @param page
|
||||
@@ -252,6 +256,23 @@ test.describe("Room list filters and sort", () => {
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test("should sort the room list alphabetically", async ({ page }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
await getRoomOptionsMenu(page).click();
|
||||
await page.getByRole("menuitemradio", { name: "A-Z" }).click();
|
||||
|
||||
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/empty room/);
|
||||
});
|
||||
|
||||
test("should move room to the top on message when sorting by activity", async ({ page, bot }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
await bot.sendMessage(unReadDmId, "Hello!");
|
||||
|
||||
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/unread dm/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Empty room list", () => {
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { IconButton, Menu, MenuTitle, CheckboxMenuItem, Tooltip } from "@vector-im/compound-web";
|
||||
import React, { type Ref, type JSX, useState } from "react";
|
||||
import { IconButton, Menu, MenuTitle, CheckboxMenuItem, Tooltip, RadioMenuItem } from "@vector-im/compound-web";
|
||||
import React, { type Ref, type JSX, useState, useCallback } from "react";
|
||||
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||
import { SortOption } from "../../../viewmodels/roomlist/useSorter";
|
||||
|
||||
interface MenuTriggerProps extends React.ComponentProps<typeof IconButton> {
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
@@ -39,6 +40,14 @@ interface Props {
|
||||
export function RoomListOptionsMenu({ vm }: Props): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const onActivitySelected = useCallback(() => {
|
||||
vm.sort(SortOption.Activity);
|
||||
}, [vm]);
|
||||
|
||||
const onAtoZSelected = useCallback(() => {
|
||||
vm.sort(SortOption.AToZ);
|
||||
}, [vm]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
@@ -48,6 +57,17 @@ export function RoomListOptionsMenu({ vm }: Props): JSX.Element {
|
||||
align="start"
|
||||
trigger={<MenuTrigger />}
|
||||
>
|
||||
<MenuTitle title={_t("room_list|sort")} />
|
||||
<RadioMenuItem
|
||||
label={_t("room_list|sort_type|activity")}
|
||||
checked={vm.activeSortOption === SortOption.Activity}
|
||||
onSelect={onActivitySelected}
|
||||
/>
|
||||
<RadioMenuItem
|
||||
label={_t("room_list|sort_type|atoz")}
|
||||
checked={vm.activeSortOption === SortOption.AToZ}
|
||||
onSelect={onAtoZSelected}
|
||||
/>
|
||||
<MenuTitle title={_t("room_list|appearance")} />
|
||||
<CheckboxMenuItem
|
||||
label={_t("room_list|show_message_previews")}
|
||||
|
||||
@@ -2167,9 +2167,14 @@
|
||||
"other": "Show %(count)s more"
|
||||
},
|
||||
"show_previews": "Show previews of messages",
|
||||
"sort": "Sort",
|
||||
"sort_by": "Sort by",
|
||||
"sort_by_activity": "Activity",
|
||||
"sort_by_alphabet": "A-Z",
|
||||
"sort_type": {
|
||||
"activity": "Activity",
|
||||
"atoz": "A-Z"
|
||||
},
|
||||
"sort_unread_first": "Show rooms with unread messages first",
|
||||
"space_menu": {
|
||||
"home": "Space home",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { RoomListOptionsMenu } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListOptionsMenu";
|
||||
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
|
||||
|
||||
describe("<RoomListOptionsMenu />", () => {
|
||||
it("should match snapshot", () => {
|
||||
const vm = {
|
||||
sort: jest.fn(),
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
const { asFragment } = render(<RoomListOptionsMenu vm={vm} />);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show A to Z selected if activeSortOption is Alphabetic", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = {
|
||||
sort: jest.fn(),
|
||||
activeSortOption: "Alphabetic",
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
render(<RoomListOptionsMenu vm={vm} />);
|
||||
|
||||
// Open the menu
|
||||
const button = screen.getByRole("button", { name: "Room Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menuitemradio", { name: "A-Z" })).toBeChecked();
|
||||
expect(screen.getByRole("menuitemradio", { name: "Activity" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should show Activity selected if activeSortOption is Recency", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = {
|
||||
sort: jest.fn(),
|
||||
activeSortOption: "Recency",
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
render(<RoomListOptionsMenu vm={vm} />);
|
||||
|
||||
// Open the menu
|
||||
const button = screen.getByRole("button", { name: "Room Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menuitemradio", { name: "A-Z" })).not.toBeChecked();
|
||||
expect(screen.getByRole("menuitemradio", { name: "Activity" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("should sort A to Z", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = {
|
||||
sort: jest.fn(),
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
render(<RoomListOptionsMenu vm={vm} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Room Options" }));
|
||||
|
||||
await user.click(screen.getByRole("menuitemradio", { name: "A-Z" }));
|
||||
|
||||
expect(vm.sort).toHaveBeenCalledWith("Alphabetic");
|
||||
});
|
||||
|
||||
it("should sort by activity", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = {
|
||||
sort: jest.fn(),
|
||||
activeSortOption: "Alphabetic",
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
render(<RoomListOptionsMenu vm={vm} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Room Options" }));
|
||||
|
||||
await user.click(screen.getByRole("menuitemradio", { name: "Activity" }));
|
||||
|
||||
expect(vm.sort).toHaveBeenCalledWith("Recency");
|
||||
});
|
||||
|
||||
it("should show message previews disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = {
|
||||
shouldShowMessagePreview: false,
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
render(<RoomListOptionsMenu vm={vm} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Room Options" }));
|
||||
|
||||
expect(screen.getByRole("menuitemcheckbox", { name: "Show message previews" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should show message previews enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = {
|
||||
shouldShowMessagePreview: true,
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
render(<RoomListOptionsMenu vm={vm} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Room Options" }));
|
||||
|
||||
expect(screen.getByRole("menuitemcheckbox", { name: "Show message previews" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("should toggle message previews", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const vm = {
|
||||
toggleMessagePreview: jest.fn(),
|
||||
} as unknown as RoomListViewState;
|
||||
|
||||
render(<RoomListOptionsMenu vm={vm} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Room Options" }));
|
||||
|
||||
await user.click(screen.getByRole("menuitemcheckbox", { name: "Show message previews" }));
|
||||
|
||||
expect(vm.toggleMessagePreview).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<RoomListOptionsMenu /> should match snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Room Options"
|
||||
aria-labelledby="«r2»"
|
||||
class="_icon-button_m2erp_8 mx_RoomListSecondaryFilters_roomOptionsButton"
|
||||
data-state="closed"
|
||||
id="radix-«r0»"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -3750,10 +3750,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-4.0.2.tgz#27363d26446eaa21880ab126fa51fec112e6fd86"
|
||||
integrity sha512-y13bhPyJ5OzbGRl21F6+Y2adrjyK+mu67yKTx+o8MfmIpJzMSn4KkHZtcujMquWSh0e5ZAufsnk4VYvxbSpr1A==
|
||||
|
||||
"@vector-im/compound-web@^7.10.2":
|
||||
version "7.10.2"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.10.2.tgz#2f62c6ab83269e5b957f53bb53413a74fb65e04d"
|
||||
integrity sha512-K9gA1Ah9CTJMeZTkcDFpAdVRNbu/rQEgV3PoDcEPI3e9iDds8Dhbo7EfOciPvtXCZw6Hr83lnhWDnwTFHVlahQ==
|
||||
"@vector-im/compound-web@^7.11.0":
|
||||
version "7.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.11.0.tgz#b7c466e64089320b41f8eaf6f2b30950e9692ca2"
|
||||
integrity sha512-lRxXUOQJHdBswhykpNs/J/cBW4fPY1qbwyDexlWxX5zCVAYiuMCWo2tI+Y7/SK4tNbDr7nwoTDRh4H9CO1L5LQ==
|
||||
dependencies:
|
||||
"@floating-ui/react" "^0.27.0"
|
||||
"@radix-ui/react-context-menu" "^2.2.1"
|
||||
|
||||
Reference in New Issue
Block a user