Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

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

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

function getPrimaryFilters(page: Page) {
function getPrimaryFilters(page: Page): Locator {
return page.getByRole("listbox", { name: "Room list filters" });
}

function getSecondaryFilters(page: Page): Locator {
return page.getByRole("button", { name: "Filter" });
}

/**
* Get the room list
* @param page
Expand Down Expand Up @@ -106,6 +110,11 @@ test.describe("Room list filters and sort", () => {
await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, favouriteId);

const lowPrioId = await app.client.createRoom({ name: "Low prio room" });
await app.client.evaluate(async (client, id) => {
await client.setRoomTag(id, "m.lowpriority", { order: 0.5 });
}, lowPrioId);
});

test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
Expand Down Expand Up @@ -137,7 +146,19 @@ test.describe("Room list filters and sort", () => {
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(3);
expect(await roomList.locator("role=gridcell").count()).toBe(4);
});

test("should filter the list (with secondary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomList = getRoomList(page);
const secondaryFilters = getSecondaryFilters(page);
await secondaryFilters.click();

await expect(page.getByRole("menu", { name: "Filter" })).toMatchScreenshot("filter-menu.png");

await page.getByRole("menuitem", { name: "Low priority" }).click();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
});

test(
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
121 changes: 121 additions & 0 deletions src/components/views/rooms/RoomListPanel/RoomListFilterMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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 { IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import React, { type Ref, type JSX, useState } from "react";
import {
ArrowDownIcon,
ChatIcon,
ChatNewIcon,
CheckIcon,
FilterIcon,
MentionIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";

import { _t } from "../../../../languageHandler";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { SecondaryFilters } from "../../../viewmodels/roomlist/useFilteredRooms";
import { textForSecondaryFilter } from "./textForFilter";

interface MenuTriggerProps extends React.ComponentProps<typeof IconButton> {
ref?: Ref<HTMLButtonElement>;
}

const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => (
<Tooltip label={_t("room_list|filter")}>
<IconButton size="28px" aria-label={_t("room_list|filter")} {...props} ref={ref}>
<FilterIcon />
</IconButton>
</Tooltip>
);

interface FilterOptionProps {
/**
* The filter to display
*/
filter: SecondaryFilters;

/**
* True if the filter is selected
*/
selected: boolean;

/**
* The function to call when the filter is selected
*/
onSelect: (filter: SecondaryFilters) => void;
}

function iconForFilter(filter: SecondaryFilters, size: string): JSX.Element {
switch (filter) {
case SecondaryFilters.AllActivity:
return <ChatIcon width={size} height={size} />;
case SecondaryFilters.MentionsOnly:
return <MentionIcon width={size} height={size} />;
case SecondaryFilters.InvitesOnly:
return <ChatNewIcon width={size} height={size} />;
case SecondaryFilters.LowPriority:
return <ArrowDownIcon width={size} height={size} />;
}
}

function FilterOption({ filter, selected, onSelect }: FilterOptionProps): JSX.Element {
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;

return (
<MenuItem
aria-selected={selected}
hideChevron={true}
Icon={iconForFilter(filter, "20px")}
label={textForSecondaryFilter(filter)}
onSelect={() => {
onSelect(filter);
}}
>
{selected && checkComponent}
</MenuItem>
);
}

interface Props {
/**
* The view model for the room list view
*/
vm: RoomListViewState;
}

export function RoomListFilterMenu({ vm }: Props): JSX.Element {
const [open, setOpen] = useState(false);

return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|filter")}
showTitle={true}
align="start"
trigger={<MenuTrigger />}
>
{[
SecondaryFilters.AllActivity,
SecondaryFilters.MentionsOnly,
SecondaryFilters.InvitesOnly,
SecondaryFilters.LowPriority,
].map((filter) => (
<FilterOption
key={filter}
filter={filter}
selected={vm.activeSecondaryFilter === filter}
onSelect={(selectedFilter) => {
vm.activateSecondaryFilter(selectedFilter);
setOpen(false);
}}
/>
))}
</Menu>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListVie
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
import { RoomListOptionsMenu } from "./RoomListOptionsMenu";
import { RoomListFilterMenu } from "./RoomListFilterMenu";
import { textForSecondaryFilter } from "./textForFilter";

interface Props {
/**
Expand All @@ -23,13 +25,17 @@ interface Props {
* The secondary filters for the room list (eg. mentions only / invites only).
*/
export function RoomListSecondaryFilters({ vm }: Props): JSX.Element {
const activeFilterText = textForSecondaryFilter(vm.activeSecondaryFilter);

return (
<Flex
aria-label={_t("room_list|secondary_filters")}
className="mx_RoomListSecondaryFilters"
align="center"
gap="8px"
gap="4px"
>
<RoomListFilterMenu vm={vm} />
{activeFilterText}
<RoomListOptionsMenu vm={vm} />
</Flex>
);
Expand Down
29 changes: 29 additions & 0 deletions src/components/views/rooms/RoomListPanel/textForFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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 { _t } from "../../../../languageHandler";
import { SecondaryFilters } from "../../../viewmodels/roomlist/useFilteredRooms";

/**
* Gives the human readable text name for a secondary filter.
* @param filter The filter in question
* @returns The translated, human readable name for the filter
*/
export function textForSecondaryFilter(filter: SecondaryFilters): string {
switch (filter) {
case SecondaryFilters.AllActivity:
return _t("room_list|secondary_filter|all_activity");
case SecondaryFilters.MentionsOnly:
return _t("room_list|secondary_filter|mentions_only");
case SecondaryFilters.InvitesOnly:
return _t("room_list|secondary_filter|invites_only");
case SecondaryFilters.LowPriority:
return _t("room_list|secondary_filter|low_priority");
default:
throw new Error("Unknown filter");
}
}
7 changes: 7 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2121,6 +2121,7 @@
"failed_add_tag": "Failed to add tag %(tagName)s to room",
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
"failed_set_dm_tag": "Failed to set direct message tag",
"filter": "Filter",
"filters": {
"favourite": "Favourites",
"people": "People",
Expand Down Expand Up @@ -2154,6 +2155,12 @@
"open_room": "Open room %(roomName)s"
},
"room_options": "Room Options",
"secondary_filter": {
"all_activity": "All activity",
"invites_only": "Invites only",
"low_priority": "Low priority",
"mentions_only": "Mentions only"
},
"secondary_filters": "Secondary filters",
"show_less": "Show less",
"show_message_previews": "Show message previews",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* 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, type RenderOptions, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";

import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { SecondaryFilters } from "../../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
import { SortOption } from "../../../../../../src/components/viewmodels/roomlist/useSorter";
import { RoomListFilterMenu } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListFilterMenu";

function getRenderOptions(): RenderOptions {
return {
wrapper: ({ children }) => <TooltipProvider>{children}</TooltipProvider>,
};
}

describe("<RoomListFilterMenu />", () => {
let vm: RoomListViewState;

beforeEach(() => {
vm = {
rooms: [],
canCreateRoom: true,
createRoom: jest.fn(),
createChatRoom: jest.fn(),
primaryFilters: [],
activateSecondaryFilter: () => {},
activeSecondaryFilter: SecondaryFilters.AllActivity,
sort: jest.fn(),
activeSortOption: SortOption.Activity,
shouldShowMessagePreview: false,
toggleMessagePreview: jest.fn(),
activeIndex: undefined,
};
});

it("should render room list filter menu button", async () => {
const { asFragment } = render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});

it("opens the menu on click", async () => {
const userevent = userEvent.setup();

render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
await userevent.click(screen.getByRole("button", { name: "Filter" }));
expect(screen.getByRole("menu", { name: "Filter" })).toBeInTheDocument();
});

it("shows 'All activity' checked if selected", async () => {
const userevent = userEvent.setup();

render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
await userevent.click(screen.getByRole("button", { name: "Filter" }));

const shouldBeSelected = screen.getByRole("menuitem", { name: "All activity" });
expect(shouldBeSelected).toHaveAttribute("aria-selected", "true");
expect(shouldBeSelected).toMatchSnapshot();
});

it("shows 'Invites only' checked if selected", async () => {
const userevent = userEvent.setup();

vm.activeSecondaryFilter = SecondaryFilters.InvitesOnly;
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
await userevent.click(screen.getByRole("button", { name: "Filter" }));

const shouldBeSelected = screen.getByRole("menuitem", { name: "Invites only" });
expect(shouldBeSelected).toHaveAttribute("aria-selected", "true");
expect(shouldBeSelected).toMatchSnapshot();
});

it("shows 'Low priority' checked if selected", async () => {
const userevent = userEvent.setup();

vm.activeSecondaryFilter = SecondaryFilters.LowPriority;
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
await userevent.click(screen.getByRole("button", { name: "Filter" }));

const shouldBeSelected = screen.getByRole("menuitem", { name: "Low priority" });
expect(shouldBeSelected).toHaveAttribute("aria-selected", "true");
expect(shouldBeSelected).toMatchSnapshot();
});

it("shows 'Mentions only' checked if selected", async () => {
const userevent = userEvent.setup();

vm.activeSecondaryFilter = SecondaryFilters.MentionsOnly;
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
await userevent.click(screen.getByRole("button", { name: "Filter" }));

const shouldBeSelected = screen.getByRole("menuitem", { name: "Mentions only" });
expect(shouldBeSelected).toHaveAttribute("aria-selected", "true");
expect(shouldBeSelected).toMatchSnapshot();
});

it("activates filter when item clicked", async () => {
const userevent = userEvent.setup();

vm.activateSecondaryFilter = jest.fn();
render(<RoomListFilterMenu vm={vm} />, getRenderOptions());
await userevent.click(screen.getByRole("button", { name: "Filter" }));
await userevent.click(screen.getByRole("menuitem", { name: "Invites only" }));

expect(vm.activateSecondaryFilter).toHaveBeenCalledWith(SecondaryFilters.InvitesOnly);
});
});
Loading
Loading