Room list: improve section in room list context menu (#33733)

* fix: hide section separator in context menu when there is no section

* fix: truncate long section name

* feat: add remove from section entry to room list item context menu

* test: update tests and stories

* test: add new test

* test: use same mocks

* test: add e2e test for "Remove from section"
This commit is contained in:
Florian Duros
2026-06-09 12:11:02 +02:00
committed by GitHub
parent 47fe4ba4d0
commit 5fa2da2a91
13 changed files with 95 additions and 34 deletions
@@ -371,5 +371,33 @@ test.describe("Room list custom sections", () => {
// Room is back in the Chats section
await assertRoomInSection(page, "Chats", "my room");
});
test("should remove a room from a custom section via the 'Remove from section' menu entry", async ({
page,
app,
}) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
const roomList = getRoomList(page);
// Move the room to the Work section
let roomItem = roomList.getByRole("row", { name: "Open room my room" });
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Move to" }).hover();
await page.getByRole("menuitem", { name: "Work" }).click();
await assertRoomInSection(page, "Work", "my room");
// Open the More Options menu and click "Remove from section"
roomItem = roomList.getByRole("row", { name: "Open room my room" });
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Remove from section" }).click();
// Room is back in the Chats section
await assertRoomInSection(page, "Chats", "my room");
});
});
});
@@ -415,6 +415,14 @@ export class RoomListItemViewModel
tagRoom(this.props.room, tag);
};
public onRemoveFromSection = (): void => {
const roomTags = this.props.room.tags;
const sectionTag = RoomListStoreV3.instance.orderedSectionTags.find((tag) => Boolean(roomTags[tag]));
if (sectionTag) {
tagRoom(this.props.room, sectionTag);
}
};
private onOrderedCustomSectionsChange = (): void => {
// Rebuild sections list to reflect new order
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
@@ -150,7 +150,8 @@
"low_priority": "Low priority",
"mark_read": "Mark as read",
"mark_unread": "Mark as unread",
"move_to_section": "Move to"
"move_to_section": "Move to",
"remove_from_section": "Remove from section"
},
"notification_options": "Notification options",
"open_space_menu": "Open space menu",
@@ -37,6 +37,7 @@ const RoomListItemDragOverlayWrapperImpl = ({
onSetRoomNotifState,
onCreateSection,
onToggleSection,
onRemoveFromSection,
renderAvatar: renderAvatarProp,
...rest
}: RoomListItemDragOverlayProps): JSX.Element => {
@@ -52,6 +53,7 @@ const RoomListItemDragOverlayWrapperImpl = ({
onSetRoomNotifState,
onCreateSection,
onToggleSection,
onRemoveFromSection,
});
return <RoomListItemDragOverlayView vm={vm} renderAvatar={renderAvatarProp} />;
};
@@ -0,0 +1,14 @@
/*
* Copyright 2026 Element Creations 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.
*/
.sectionLabel {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
color: var(--cpd-color-text-primary);
}
@@ -8,28 +8,15 @@
import React, { type JSX } from "react";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
import { useMockedViewModel } from "../../../../core/viewmodel";
import type { RoomListItemViewSnapshot } from "./RoomListItemView";
import { defaultSnapshot } from "./default-snapshot";
import { mockedActions as mockCallbacks } from "./mocked-actions";
describe("<RoomListItemMoreOptionsMenu />", () => {
const mockCallbacks = {
onOpenRoom: vi.fn(),
onMarkAsRead: vi.fn(),
onMarkAsUnread: vi.fn(),
onToggleFavorite: vi.fn(),
onToggleLowPriority: vi.fn(),
onInvite: vi.fn(),
onCopyRoomLink: vi.fn(),
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
onToggleSection: vi.fn(),
};
const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel(
@@ -242,6 +229,20 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
expect(mockCallbacks.onCreateSection).toHaveBeenCalled();
});
it("should call onRemoveFromSection when Remove from section is clicked", async () => {
const user = userEvent.setup();
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel({ ...defaultSnapshot, isInSection: true }, mockCallbacks);
return <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
const removeFromSection = screen.getByRole("menuitem", { name: "Remove from section" });
await user.click(removeFromSection);
expect(mockCallbacks.onRemoveFromSection).toHaveBeenCalled();
});
it("should render section items in move to section submenu", () => {
const sections = [
{ tag: "m.favourite", name: "Favourites", isSelected: false },
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, type JSX } from "react";
import React, { useMemo, useState, type JSX } from "react";
import { IconButton, Menu, MenuItem, Separator, SubMenu, ToggleMenuItem } from "@vector-im/compound-web";
import {
MarkAsReadIcon,
@@ -18,11 +18,13 @@ import {
OverflowHorizontalIcon,
ArrowRightIcon,
CheckIcon,
MinusIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../core/i18n/i18n";
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
import type { RoomListItemViewSnapshot, RoomListItemViewActions } from "./RoomListItemView";
import styles from "./RoomListItemMoreOptionsMenu.module.css";
/**
* View model type for room list item
@@ -73,6 +75,8 @@ interface MoreOptionContentProps {
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
const snapshot = useViewModel(vm);
const hasSections = snapshot.sections.length > 0;
const isInSection = useMemo(() => snapshot.sections.some((section) => section.isSelected), [snapshot.sections]);
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
@@ -141,6 +145,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
<MenuItem
key={section.tag}
label={section.name}
labelProps={{ className: styles.sectionLabel }}
onSelect={() => vm.onToggleSection(section.tag)}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
@@ -151,10 +156,19 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
)}
</MenuItem>
))}
<Separator />
{hasSections && <Separator />}
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
</SubMenu>
)}
{isInSection && (
<MenuItem
Icon={MinusIcon}
label={_t("room_list|more_options|remove_from_section")}
onSelect={vm.onRemoveFromSection}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<Separator />
<MenuItem
kind="critical"
@@ -8,29 +8,16 @@
import React, { type JSX } from "react";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
import { RoomNotifState } from "./RoomNotifs";
import { useMockedViewModel } from "../../../../core/viewmodel";
import type { RoomListItemViewSnapshot } from "./RoomListItemView";
import { defaultSnapshot } from "./default-snapshot";
import { mockedActions as mockCallbacks } from "./mocked-actions";
describe("<RoomListItemNotificationMenu />", () => {
const mockCallbacks = {
onOpenRoom: vi.fn(),
onMarkAsRead: vi.fn(),
onMarkAsUnread: vi.fn(),
onToggleFavorite: vi.fn(),
onToggleLowPriority: vi.fn(),
onInvite: vi.fn(),
onCopyRoomLink: vi.fn(),
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
onToggleSection: vi.fn(),
};
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel(
@@ -40,6 +40,7 @@ const RoomListItemWrapperImpl = ({
onSetRoomNotifState,
onCreateSection,
onToggleSection,
onRemoveFromSection,
isSelected,
isFocused,
onFocus,
@@ -60,6 +61,7 @@ const RoomListItemWrapperImpl = ({
onSetRoomNotifState,
onCreateSection,
onToggleSection,
onRemoveFromSection,
});
return (
<RoomListItemView
@@ -125,6 +125,8 @@ export interface RoomListItemViewActions {
onCreateSection: () => void;
/** Called when toggling a room's membership in a section */
onToggleSection: (tag: string) => void;
/** Called when removing the room from a section */
onRemoveFromSection: () => void;
}
/**
@@ -45,7 +45,7 @@ export const defaultSnapshot: RoomListItemViewSnapshot = {
},
{
tag: "element.io.section.work",
name: "Work",
name: "Work with a very long name that should be truncated",
isSelected: true,
},
{
@@ -21,4 +21,5 @@ export const mockedActions: RoomListItemViewActions = {
onSetRoomNotifState: fn(),
onCreateSection: fn(),
onToggleSection: fn(),
onRemoveFromSection: fn(),
};
@@ -125,6 +125,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index:
onSetRoomNotifState: fn(),
onCreateSection: fn(),
onToggleSection: fn(),
onRemoveFromSection: fn(),
};
}