diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts index 0ce13173b8..1d7ce54e8a 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts @@ -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({ @@ -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 @@ -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 }) => { @@ -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( diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/filter-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/filter-menu-linux.png new file mode 100644 index 0000000000..babee9bb0b Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/filter-menu-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png index edef3547e4..009d948cd1 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png index 480e4dfc59..533269ad7f 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png index 91b7934df1..bbef3673fb 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png index 66a3646f37..0b37d566da 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png index 2ebf409cb5..2b03e85b59 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ diff --git a/src/components/views/rooms/RoomListPanel/RoomListFilterMenu.tsx b/src/components/views/rooms/RoomListPanel/RoomListFilterMenu.tsx new file mode 100644 index 0000000000..532c8634ae --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomListFilterMenu.tsx @@ -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 { + ref?: Ref; +} + +const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => ( + + + + + +); + +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 ; + case SecondaryFilters.MentionsOnly: + return ; + case SecondaryFilters.InvitesOnly: + return ; + case SecondaryFilters.LowPriority: + return ; + } +} + +function FilterOption({ filter, selected, onSelect }: FilterOptionProps): JSX.Element { + const checkComponent = ; + + return ( + { + onSelect(filter); + }} + > + {selected && checkComponent} + + ); +} + +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 ( + } + > + {[ + SecondaryFilters.AllActivity, + SecondaryFilters.MentionsOnly, + SecondaryFilters.InvitesOnly, + SecondaryFilters.LowPriority, + ].map((filter) => ( + { + vm.activateSecondaryFilter(selectedFilter); + setOpen(false); + }} + /> + ))} + + ); +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx b/src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx index f162c38f77..6326fe6d7c 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx @@ -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 { /** @@ -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 ( + + {activeFilterText} ); diff --git a/src/components/views/rooms/RoomListPanel/textForFilter.ts b/src/components/views/rooms/RoomListPanel/textForFilter.ts new file mode 100644 index 0000000000..efb748937a --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/textForFilter.ts @@ -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"); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 234e6d7eb3..895aa373e2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", @@ -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", diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx new file mode 100644 index 0000000000..3e14a64f8e --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx @@ -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 }) => {children}, + }; +} + +describe("", () => { + 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(, getRenderOptions()); + expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("opens the menu on click", async () => { + const userevent = userEvent.setup(); + + render(, 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(, 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(, 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(, 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(, 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(, getRenderOptions()); + await userevent.click(screen.getByRole("button", { name: "Filter" })); + await userevent.click(screen.getByRole("menuitem", { name: "Invites only" })); + + expect(vm.activateSecondaryFilter).toHaveBeenCalledWith(SecondaryFilters.InvitesOnly); + }); +}); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListFilterMenu-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListFilterMenu-test.tsx.snap new file mode 100644 index 0000000000..9fa9312e3a --- /dev/null +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListFilterMenu-test.tsx.snap @@ -0,0 +1,208 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render room list filter menu button 1`] = ` + + + +`; + +exports[` shows 'All activity' checked if selected 1`] = ` + +`; + +exports[` shows 'Invites only' checked if selected 1`] = ` + +`; + +exports[` shows 'Low priority' checked if selected 1`] = ` + +`; + +exports[` shows 'Mentions only' checked if selected 1`] = ` + +`; diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap index 9bb7061ba6..e51c4b8ca0 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap @@ -116,17 +116,49 @@ exports[` should not render the RoomListSearch component when U
+ + All activity + All activity + + All activity +