mirror of
https://github.com/vector-im/element-web.git
synced 2026-07-02 13:30:13 +00:00
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:
@@ -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",
|
||||
|
||||
+2
@@ -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} />;
|
||||
};
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
+16
-15
@@ -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 },
|
||||
|
||||
+16
-2
@@ -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"
|
||||
|
||||
+2
-15
@@ -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(
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
+1
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user