Room list: assign room to custom section (#33238)

* feat(sc): add new toast type for room list

* feat(sc): add section entries in room list item menu

* feat(rls): expose util functions

* feat: allows to tag room with custom sections

* feat(vm): add new Chat moved toast to room list vm

* feat(vm): add section selection to room list item vm

* feat(e2e): add tests for adding room in a custom section

* test(e2e): update existing screenshots

* chore: fix lint after merge

* chore: remove outline in test
This commit is contained in:
Florian Duros
2026-04-22 21:50:54 +02:00
committed by GitHub
parent 73e1b87075
commit f4c62abbcd
28 changed files with 532 additions and 26 deletions
@@ -99,6 +99,7 @@
"voice_call": "Open room %(roomName)s with a voice call."
},
"appearance": "Appearance",
"chat_moved": "Chat moved",
"collapse_filters": "Collapse filter list",
"empty": {
"no_chats": "No chats yet",
@@ -38,3 +38,9 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const SectionCreated: Story = {};
export const ChatMoved: Story = {
args: {
type: "chat_moved",
},
};
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
import * as stories from "./RoomListToast.stories";
const { SectionCreated } = composeStories(stories);
const { SectionCreated, ChatMoved } = composeStories(stories);
describe("<RoomListToast />", () => {
it("renders SectionCreated story", () => {
@@ -21,6 +21,11 @@ describe("<RoomListToast />", () => {
expect(container).toMatchSnapshot();
});
it("renders ChatMoved story", () => {
const { container } = render(<ChatMoved />);
expect(container).toMatchSnapshot();
});
it("calls onClose when the close button is clicked", async () => {
const user = userEvent.setup();
render(<SectionCreated />);
@@ -12,7 +12,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"
import styles from "./RoomListToast.module.css";
import { useI18n } from "../../../core/i18n/i18nContext";
export type ToastType = "section_created";
export type ToastType = "section_created" | "chat_moved";
interface RoomListToastProps {
/** The type of toast to display */
@@ -37,6 +37,9 @@ export function RoomListToast({ type, onClose }: Readonly<RoomListToastProps>):
case "section_created":
content = { text: _t("room_list|section_created"), icon: CheckIcon };
break;
case "chat_moved":
content = { text: _t("room_list|chat_moved"), icon: CheckIcon };
break;
}
return (
@@ -1,5 +1,61 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RoomListToast /> > renders ChatMoved story 1`] = `
<div>
<div
style="position: relative; width: 320px; height: 100px; background-color: grey;"
>
<div
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _toast-container_1ysb3_8 RoomListToast-module_toast _has-close_1ysb3_30"
>
<div
class="_content_1ysb3_34"
>
<svg
aria-hidden="true"
class="_icon_1ysb3_26"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
Chat moved
</div>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 _close_1ysb3_41 _no-background_1215g_42"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
</div>
`;
exports[`<RoomListToast /> > renders SectionCreated story 1`] = `
<div>
<div
@@ -17,3 +17,4 @@ export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyStateView } from "./RoomListEmptyStateView";
export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView";
export * from "../VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView";
export * from "./RoomListToast";
@@ -27,6 +27,7 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
onToggleSection: vi.fn(),
};
const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
@@ -240,4 +241,59 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
expect(mockCallbacks.onCreateSection).toHaveBeenCalled();
});
it("should render section items in move to section submenu", () => {
const sections = [
{ tag: "m.favourite", name: "Favourites", isSelected: false },
{ tag: "element.io.section.custom1", name: "Work", isSelected: true },
{ tag: "element.io.section.custom2", name: "Personal", isSelected: false },
];
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks);
return <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
const favouriteItem = screen.getByRole("menuitem", { name: "Favourites" });
expect(favouriteItem).toBeInTheDocument();
expect(favouriteItem).toHaveAttribute("aria-checked", "false");
const workItem = screen.getByRole("menuitem", { name: "Work" });
expect(workItem).toBeInTheDocument();
expect(workItem).toHaveAttribute("aria-checked", "true");
const personalItem = screen.getByRole("menuitem", { name: "Personal" });
expect(personalItem).toBeInTheDocument();
expect(personalItem).toHaveAttribute("aria-checked", "false");
});
it("should call onToggleSection when a section item is clicked", async () => {
const user = userEvent.setup();
const sections = [
{ tag: "m.favourite", name: "Favourites", isSelected: false },
{ tag: "element.io.section.custom1", name: "Work", isSelected: false },
];
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks);
return <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
const workItem = screen.getByRole("menuitem", { name: "Work" });
await user.click(workItem);
expect(mockCallbacks.onToggleSection).toHaveBeenCalledWith("element.io.section.custom1");
});
it("should not render section items when sections array is empty", () => {
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel({ ...defaultSnapshot, sections: [] }, mockCallbacks);
return <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
expect(screen.getByRole("menuitem", { name: "New section" })).toBeInTheDocument();
});
});
@@ -17,6 +17,7 @@ import {
LeaveIcon,
OverflowHorizontalIcon,
ArrowRightIcon,
CheckIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../core/i18n/i18n";
@@ -136,6 +137,21 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
/>
}
>
{snapshot.sections.map((section) => (
<MenuItem
key={section.tag}
label={section.name}
onSelect={() => vm.onToggleSection(section.tag)}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
aria-checked={section.isSelected}
>
{section.isSelected && (
<CheckIcon color="var(--cpd-color-icon-tertiary)" width="24px" height="24px" />
)}
</MenuItem>
))}
<Separator />
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
</SubMenu>
)}
@@ -28,6 +28,7 @@ describe("<RoomListItemNotificationMenu />", () => {
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
onToggleSection: vi.fn(),
};
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
@@ -39,6 +39,7 @@ const RoomListItemWrapperImpl = ({
onLeaveRoom,
onSetRoomNotifState,
onCreateSection,
onToggleSection,
isSelected,
isFocused,
onFocus,
@@ -58,6 +59,7 @@ const RoomListItemWrapperImpl = ({
onLeaveRoom,
onSetRoomNotifState,
onCreateSection,
onToggleSection,
});
return (
<RoomListItemView
@@ -44,6 +44,19 @@ function getA11yLabel(roomName: string, notification: NotificationDecorationData
}
}
/**
* Describes a section that a room can be assigned to.
* Used to render toggle items in the "Move to section" submenu.
*/
export interface Section {
/** The tag that identifies this section (e.g. `m.favourite`, custom tag) */
tag: string;
/** The human-readable display name of the section */
name: string;
/** Whether the room currently belongs to this section */
isSelected: boolean;
}
/**
* Snapshot for a room list item.
* Contains all the data needed to render a room in the list.
@@ -81,6 +94,8 @@ export interface RoomListItemViewSnapshot {
roomNotifState: RoomNotifState;
/** Whether the room can be moved to a section */
canMoveToSection: boolean;
/** Available sections the room can be assigned to */
sections: Section[];
}
/**
@@ -108,6 +123,8 @@ export interface RoomListItemViewActions {
onSetRoomNotifState: (state: RoomNotifState) => void;
/** Called when creating a new section */
onCreateSection: () => void;
/** Called when toggling a room's membership in a section */
onToggleSection: (tag: string) => void;
}
/**
@@ -37,4 +37,21 @@ export const defaultSnapshot: RoomListItemViewSnapshot = {
canMarkAsUnread: true,
roomNotifState: RoomNotifState.AllMessages,
canMoveToSection: true,
sections: [
{
tag: "m.favourite",
name: "Favourites",
isSelected: false,
},
{
tag: "element.io.section.work",
name: "Work",
isSelected: true,
},
{
tag: "m.lowpriority",
name: "Low Priority",
isSelected: false,
},
],
};
@@ -12,6 +12,7 @@ export type {
RoomListItemViewModel,
RoomListItemViewActions,
RoomListItemViewProps,
Section,
} from "./RoomListItemView";
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
@@ -20,4 +20,5 @@ export const mockedActions: RoomListItemViewActions = {
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
onCreateSection: fn(),
onToggleSection: fn(),
};
@@ -106,6 +106,7 @@ export const createMockRoomSnapshot = (id: string, name: string, index: number):
canMarkAsUnread: true,
roomNotifState: RoomNotifState.AllMessages,
canMoveToSection: true,
sections: [],
});
export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomListItemViewModel {
@@ -123,6 +124,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index:
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
onCreateSection: fn(),
onToggleSection: fn(),
};
}