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
|
// Room is back in the Chats section
|
||||||
await assertRoomInSection(page, "Chats", "my room");
|
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);
|
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 => {
|
private onOrderedCustomSectionsChange = (): void => {
|
||||||
// Rebuild sections list to reflect new order
|
// Rebuild sections list to reflect new order
|
||||||
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
|
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
|
||||||
|
|||||||
@@ -150,7 +150,8 @@
|
|||||||
"low_priority": "Low priority",
|
"low_priority": "Low priority",
|
||||||
"mark_read": "Mark as read",
|
"mark_read": "Mark as read",
|
||||||
"mark_unread": "Mark as unread",
|
"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",
|
"notification_options": "Notification options",
|
||||||
"open_space_menu": "Open space menu",
|
"open_space_menu": "Open space menu",
|
||||||
|
|||||||
+2
@@ -37,6 +37,7 @@ const RoomListItemDragOverlayWrapperImpl = ({
|
|||||||
onSetRoomNotifState,
|
onSetRoomNotifState,
|
||||||
onCreateSection,
|
onCreateSection,
|
||||||
onToggleSection,
|
onToggleSection,
|
||||||
|
onRemoveFromSection,
|
||||||
renderAvatar: renderAvatarProp,
|
renderAvatar: renderAvatarProp,
|
||||||
...rest
|
...rest
|
||||||
}: RoomListItemDragOverlayProps): JSX.Element => {
|
}: RoomListItemDragOverlayProps): JSX.Element => {
|
||||||
@@ -52,6 +53,7 @@ const RoomListItemDragOverlayWrapperImpl = ({
|
|||||||
onSetRoomNotifState,
|
onSetRoomNotifState,
|
||||||
onCreateSection,
|
onCreateSection,
|
||||||
onToggleSection,
|
onToggleSection,
|
||||||
|
onRemoveFromSection,
|
||||||
});
|
});
|
||||||
return <RoomListItemDragOverlayView vm={vm} renderAvatar={renderAvatarProp} />;
|
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 React, { type JSX } from "react";
|
||||||
import { render, screen } from "@test-utils";
|
import { render, screen } from "@test-utils";
|
||||||
import userEvent from "@testing-library/user-event";
|
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 { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
|
||||||
import { useMockedViewModel } from "../../../../core/viewmodel";
|
import { useMockedViewModel } from "../../../../core/viewmodel";
|
||||||
import type { RoomListItemViewSnapshot } from "./RoomListItemView";
|
import type { RoomListItemViewSnapshot } from "./RoomListItemView";
|
||||||
import { defaultSnapshot } from "./default-snapshot";
|
import { defaultSnapshot } from "./default-snapshot";
|
||||||
|
import { mockedActions as mockCallbacks } from "./mocked-actions";
|
||||||
|
|
||||||
describe("<RoomListItemMoreOptionsMenu />", () => {
|
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 renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
|
||||||
const TestComponent = (): JSX.Element => {
|
const TestComponent = (): JSX.Element => {
|
||||||
const vm = useMockedViewModel(
|
const vm = useMockedViewModel(
|
||||||
@@ -242,6 +229,20 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
|
|||||||
expect(mockCallbacks.onCreateSection).toHaveBeenCalled();
|
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", () => {
|
it("should render section items in move to section submenu", () => {
|
||||||
const sections = [
|
const sections = [
|
||||||
{ tag: "m.favourite", name: "Favourites", isSelected: false },
|
{ tag: "m.favourite", name: "Favourites", isSelected: false },
|
||||||
|
|||||||
+16
-2
@@ -5,7 +5,7 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 { IconButton, Menu, MenuItem, Separator, SubMenu, ToggleMenuItem } from "@vector-im/compound-web";
|
||||||
import {
|
import {
|
||||||
MarkAsReadIcon,
|
MarkAsReadIcon,
|
||||||
@@ -18,11 +18,13 @@ import {
|
|||||||
OverflowHorizontalIcon,
|
OverflowHorizontalIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
MinusIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import { _t } from "../../../../core/i18n/i18n";
|
import { _t } from "../../../../core/i18n/i18n";
|
||||||
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
|
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
|
||||||
import type { RoomListItemViewSnapshot, RoomListItemViewActions } from "./RoomListItemView";
|
import type { RoomListItemViewSnapshot, RoomListItemViewActions } from "./RoomListItemView";
|
||||||
|
import styles from "./RoomListItemMoreOptionsMenu.module.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View model type for room list item
|
* View model type for room list item
|
||||||
@@ -73,6 +75,8 @@ interface MoreOptionContentProps {
|
|||||||
|
|
||||||
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
|
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
|
||||||
const snapshot = useViewModel(vm);
|
const snapshot = useViewModel(vm);
|
||||||
|
const hasSections = snapshot.sections.length > 0;
|
||||||
|
const isInSection = useMemo(() => snapshot.sections.some((section) => section.isSelected), [snapshot.sections]);
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
<div onKeyDown={(e) => e.stopPropagation()}>
|
<div onKeyDown={(e) => e.stopPropagation()}>
|
||||||
@@ -141,6 +145,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
key={section.tag}
|
key={section.tag}
|
||||||
label={section.name}
|
label={section.name}
|
||||||
|
labelProps={{ className: styles.sectionLabel }}
|
||||||
onSelect={() => vm.onToggleSection(section.tag)}
|
onSelect={() => vm.onToggleSection(section.tag)}
|
||||||
onClick={(evt) => evt.stopPropagation()}
|
onClick={(evt) => evt.stopPropagation()}
|
||||||
hideChevron={true}
|
hideChevron={true}
|
||||||
@@ -151,10 +156,19 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
<Separator />
|
{hasSections && <Separator />}
|
||||||
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
|
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
|
||||||
</SubMenu>
|
</SubMenu>
|
||||||
)}
|
)}
|
||||||
|
{isInSection && (
|
||||||
|
<MenuItem
|
||||||
|
Icon={MinusIcon}
|
||||||
|
label={_t("room_list|more_options|remove_from_section")}
|
||||||
|
onSelect={vm.onRemoveFromSection}
|
||||||
|
onClick={(evt) => evt.stopPropagation()}
|
||||||
|
hideChevron={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
kind="critical"
|
kind="critical"
|
||||||
|
|||||||
+2
-15
@@ -8,29 +8,16 @@
|
|||||||
import React, { type JSX } from "react";
|
import React, { type JSX } from "react";
|
||||||
import { render, screen } from "@test-utils";
|
import { render, screen } from "@test-utils";
|
||||||
import userEvent from "@testing-library/user-event";
|
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 { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||||
import { RoomNotifState } from "./RoomNotifs";
|
import { RoomNotifState } from "./RoomNotifs";
|
||||||
import { useMockedViewModel } from "../../../../core/viewmodel";
|
import { useMockedViewModel } from "../../../../core/viewmodel";
|
||||||
import type { RoomListItemViewSnapshot } from "./RoomListItemView";
|
import type { RoomListItemViewSnapshot } from "./RoomListItemView";
|
||||||
import { defaultSnapshot } from "./default-snapshot";
|
import { defaultSnapshot } from "./default-snapshot";
|
||||||
|
import { mockedActions as mockCallbacks } from "./mocked-actions";
|
||||||
|
|
||||||
describe("<RoomListItemNotificationMenu />", () => {
|
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 renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
|
||||||
const TestComponent = (): JSX.Element => {
|
const TestComponent = (): JSX.Element => {
|
||||||
const vm = useMockedViewModel(
|
const vm = useMockedViewModel(
|
||||||
|
|||||||
+2
@@ -40,6 +40,7 @@ const RoomListItemWrapperImpl = ({
|
|||||||
onSetRoomNotifState,
|
onSetRoomNotifState,
|
||||||
onCreateSection,
|
onCreateSection,
|
||||||
onToggleSection,
|
onToggleSection,
|
||||||
|
onRemoveFromSection,
|
||||||
isSelected,
|
isSelected,
|
||||||
isFocused,
|
isFocused,
|
||||||
onFocus,
|
onFocus,
|
||||||
@@ -60,6 +61,7 @@ const RoomListItemWrapperImpl = ({
|
|||||||
onSetRoomNotifState,
|
onSetRoomNotifState,
|
||||||
onCreateSection,
|
onCreateSection,
|
||||||
onToggleSection,
|
onToggleSection,
|
||||||
|
onRemoveFromSection,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<RoomListItemView
|
<RoomListItemView
|
||||||
|
|||||||
+2
@@ -125,6 +125,8 @@ export interface RoomListItemViewActions {
|
|||||||
onCreateSection: () => void;
|
onCreateSection: () => void;
|
||||||
/** Called when toggling a room's membership in a section */
|
/** Called when toggling a room's membership in a section */
|
||||||
onToggleSection: (tag: string) => void;
|
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",
|
tag: "element.io.section.work",
|
||||||
name: "Work",
|
name: "Work with a very long name that should be truncated",
|
||||||
isSelected: true,
|
isSelected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+1
@@ -21,4 +21,5 @@ export const mockedActions: RoomListItemViewActions = {
|
|||||||
onSetRoomNotifState: fn(),
|
onSetRoomNotifState: fn(),
|
||||||
onCreateSection: fn(),
|
onCreateSection: fn(),
|
||||||
onToggleSection: fn(),
|
onToggleSection: fn(),
|
||||||
|
onRemoveFromSection: fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index:
|
|||||||
onSetRoomNotifState: fn(),
|
onSetRoomNotifState: fn(),
|
||||||
onCreateSection: fn(),
|
onCreateSection: fn(),
|
||||||
onToggleSection: fn(),
|
onToggleSection: fn(),
|
||||||
|
onRemoveFromSection: fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user