mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-07 13:16:41 +02:00
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:
parent
73e1b87075
commit
f4c62abbcd
@ -175,4 +175,93 @@ test.describe("Room list custom sections", () => {
|
||||
await expect(getSectionHeader(page, "Low Priority")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Adding a room to a custom section", () => {
|
||||
/**
|
||||
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
|
||||
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
|
||||
* Verifies that the closest preceding aria-level=1 row is the expected section header.
|
||||
*/
|
||||
async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
|
||||
const roomList = getRoomList(page);
|
||||
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
|
||||
// Room row must be at aria-level=2 (i.e. inside a section)
|
||||
await expect(roomRow).toHaveAttribute("aria-level", "2");
|
||||
// The closest preceding aria-level=1 row must be the expected section header.
|
||||
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
|
||||
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
|
||||
await expect(closestSectionHeader).toContainText(sectionName);
|
||||
}
|
||||
|
||||
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
|
||||
const roomList = getRoomList(page);
|
||||
|
||||
// Room starts in Chats section (aria-level=2)
|
||||
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||
await expect(roomItem).toBeVisible();
|
||||
|
||||
// Open More Options and move to the Work section
|
||||
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();
|
||||
|
||||
// Room should now be nested under the Work section header (aria-level=1 → aria-level=2)
|
||||
await assertRoomInSection(page, "Work", "my room");
|
||||
});
|
||||
|
||||
test(
|
||||
"should show 'Chat moved' toast when adding a room to a custom section",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
|
||||
const roomList = getRoomList(page);
|
||||
const 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();
|
||||
|
||||
// The "Chat moved" toast should appear
|
||||
await expect(page.getByText("Chat moved")).toBeVisible();
|
||||
|
||||
// Remove focus outline from the room item before taking the screenshot
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
|
||||
await expect(roomList).toMatchScreenshot("room-list-sections-chat-moved-toast.png");
|
||||
},
|
||||
);
|
||||
|
||||
test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
|
||||
const roomList = getRoomList(page);
|
||||
|
||||
// Move to Work section and verify placement via aria-level
|
||||
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");
|
||||
|
||||
// Toggle off by selecting the same section again
|
||||
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();
|
||||
|
||||
// Room is back in the Chats section
|
||||
await assertRoomInSection(page, "Chats", "my room");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
@ -62,6 +62,8 @@ export enum RoomListStoreV3Event {
|
||||
ListsLoaded = "lists_loaded",
|
||||
/** Fired when a new section is created in the room list. */
|
||||
SectionCreated = "section_created",
|
||||
/** Fired when a room's tags change. */
|
||||
RoomTagged = "room_tagged",
|
||||
}
|
||||
|
||||
// The result object for returning rooms from the store
|
||||
@ -93,6 +95,7 @@ export const CHATS_TAG = "chats";
|
||||
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
||||
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
||||
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
||||
export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged;
|
||||
|
||||
/**
|
||||
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
|
||||
@ -243,6 +246,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
case "MatrixActions.Room.tags": {
|
||||
const room = payload.room;
|
||||
this.addRoomAndEmit(room);
|
||||
this.emit(ROOM_TAGGED_EVENT);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -493,6 +497,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
this.emit(SECTION_CREATED_EVENT, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ordered section tags.
|
||||
*/
|
||||
public get orderedSectionTags(): string[] {
|
||||
return this.sortedTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the custom sections from the settings store and update the sorted tags.
|
||||
*/
|
||||
|
||||
@ -12,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
|
||||
|
||||
type Tag = string;
|
||||
|
||||
/**
|
||||
* Prefix for custom section tags.
|
||||
*/
|
||||
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
|
||||
|
||||
/**
|
||||
* Checks if a given tag is a custom section tag.
|
||||
* @param tag - The tag to check.
|
||||
* @returns True if the tag is a custom section tag, false otherwise.
|
||||
*/
|
||||
export function isCustomSectionTag(tag: string): boolean {
|
||||
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user.
|
||||
*/
|
||||
@ -41,7 +55,7 @@ export async function createSection(): Promise<string | undefined> {
|
||||
const [shouldCreateSection, sectionName] = await modal.finished;
|
||||
if (!shouldCreateSection || !sectionName) return undefined;
|
||||
|
||||
const tag = `element.io.section.${window.crypto.randomUUID()}`;
|
||||
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
|
||||
const newSection: CustomSection = { tag, name: sectionName };
|
||||
|
||||
// Save the new section data
|
||||
|
||||
@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta
|
||||
import RoomListActions from "../../actions/RoomListActions";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { getTagsForRoom } from "./getTagsForRoom";
|
||||
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
/**
|
||||
* Toggle tag for a given room
|
||||
* Toggle tag for a given room.
|
||||
* A room can only be in one section: either a custom section, Favourite, or LowPriority.
|
||||
* Applying any of these will atomically replace the current section tag.
|
||||
* @param room The room to tag
|
||||
* @param tagId The tag to invert
|
||||
*/
|
||||
export function tagRoom(room: Room, tagId: TagID): void {
|
||||
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
|
||||
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
|
||||
const isApplied = getTagsForRoom(room).includes(tagId);
|
||||
const removeTag = isApplied ? tagId : inverseTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||
} else {
|
||||
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
|
||||
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
|
||||
const currentSectionTag =
|
||||
getTagsForRoom(room).find(
|
||||
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
|
||||
) ?? null;
|
||||
|
||||
const isApplied = currentSectionTag === tagId;
|
||||
const removeTag = currentSectionTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
RoomNotifState,
|
||||
type RoomListItemViewSnapshot,
|
||||
type RoomListItemViewActions,
|
||||
type Section,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
@ -37,7 +38,8 @@ import { Action } from "../../dispatcher/actions";
|
||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { type Call, CallEvent } from "../../models/Call";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
interface RoomItemProps {
|
||||
room: Room;
|
||||
@ -96,6 +98,13 @@ export class RoomListItemViewModel
|
||||
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
|
||||
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
|
||||
|
||||
const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () =>
|
||||
this.onOrderedCustomSectionsChange(),
|
||||
);
|
||||
this.disposables.track(() => {
|
||||
SettingsStore.unwatchSetting(orderSectionsRef);
|
||||
});
|
||||
|
||||
// Load message preview asynchronously (sync data is already complete)
|
||||
void this.loadAndSetMessagePreview();
|
||||
}
|
||||
@ -181,6 +190,7 @@ export class RoomListItemViewModel
|
||||
this.snapshot.merge({
|
||||
...newItem,
|
||||
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
|
||||
sections: keepIfSame(this.snapshot.current.sections, newItem.sections),
|
||||
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
|
||||
messagePreview: this.snapshot.current.messagePreview,
|
||||
});
|
||||
@ -279,6 +289,9 @@ export class RoomListItemViewModel
|
||||
|
||||
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
|
||||
|
||||
// Build sections list for the "Move to section" submenu
|
||||
const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : [];
|
||||
|
||||
return {
|
||||
id: room.roomId,
|
||||
room,
|
||||
@ -307,6 +320,7 @@ export class RoomListItemViewModel
|
||||
canMarkAsUnread,
|
||||
roomNotifState,
|
||||
canMoveToSection,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
@ -389,4 +403,42 @@ export class RoomListItemViewModel
|
||||
public onCreateSection = (): void => {
|
||||
RoomListStoreV3.instance.createSection();
|
||||
};
|
||||
|
||||
public onToggleSection = (tag: string): void => {
|
||||
tagRoom(this.props.room, tag);
|
||||
};
|
||||
|
||||
private onOrderedCustomSectionsChange = (): void => {
|
||||
// Rebuild sections list to reflect new order
|
||||
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
|
||||
this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) });
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the list of available sections for the "Move to section" submenu.
|
||||
* Order follows the canonical section order from RoomListStoreV3.
|
||||
*/
|
||||
private static buildSections(roomTags: Room["tags"]): Section[] {
|
||||
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
|
||||
|
||||
return (
|
||||
RoomListStoreV3.instance.orderedSectionTags
|
||||
// Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section.
|
||||
.filter((tag) => tag !== CHATS_TAG)
|
||||
.map((tag) => ({
|
||||
tag,
|
||||
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
|
||||
isSelected: Boolean(roomTags[tag]),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a section based on its tag.
|
||||
*/
|
||||
private static getSectionName(tag: string, customSectionData: Record<string, { name: string }>): string {
|
||||
if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites");
|
||||
if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority");
|
||||
return customSectionData[tag]?.name || tag;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
type RoomListViewState,
|
||||
type RoomListSection,
|
||||
_t,
|
||||
type ToastType,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
@ -156,6 +157,13 @@ export class RoomListViewModel
|
||||
this.onSectionCreated as (...args: unknown[]) => void,
|
||||
);
|
||||
|
||||
// Subscribe to room tagging
|
||||
this.disposables.trackListener(
|
||||
RoomListStoreV3.instance,
|
||||
RoomListStoreV3Event.RoomTagged as any,
|
||||
this.onRoomTagged,
|
||||
);
|
||||
|
||||
// Subscribe to active room changes to update selected room
|
||||
const dispatcherRef = dispatcher.register(this.onDispatch);
|
||||
this.disposables.track(() => {
|
||||
@ -595,15 +603,11 @@ export class RoomListViewModel
|
||||
|
||||
public onSectionCreated = (tag: string): void => {
|
||||
this.updateRoomListData(false, null, tag);
|
||||
this.showToast("section_created");
|
||||
};
|
||||
|
||||
clearTimeout(this.toastRef);
|
||||
this.snapshot.merge({
|
||||
toast: "section_created",
|
||||
});
|
||||
// Automatically close the toast after 15 seconds
|
||||
this.toastRef = setTimeout(() => {
|
||||
this.closeToast();
|
||||
}, 15 * 1000);
|
||||
public onRoomTagged = (): void => {
|
||||
this.showToast("chat_moved");
|
||||
};
|
||||
|
||||
public closeToast: () => void = () => {
|
||||
@ -612,6 +616,15 @@ export class RoomListViewModel
|
||||
toast: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private showToast(toast: ToastType): void {
|
||||
clearTimeout(this.toastRef);
|
||||
this.snapshot.merge({ toast });
|
||||
// Automatically close the toast after 15 seconds
|
||||
this.toastRef = setTimeout(() => {
|
||||
this.closeToast();
|
||||
}, 15 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -11,6 +11,7 @@ import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import RoomListActions from "../../../../src/actions/RoomListActions";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
|
||||
import { tagRoom } from "../../../../src/utils/room/tagRoom";
|
||||
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
||||
import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom";
|
||||
@ -18,6 +19,7 @@ import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom"
|
||||
describe("tagRoom()", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const roomId = "!room:server.org";
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`;
|
||||
|
||||
const makeRoom = (tags: TagID[] = []): Room => {
|
||||
const client = getMockClientWithEventEmitter({
|
||||
@ -59,7 +61,7 @@ describe("tagRoom()", () => {
|
||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||
room.client,
|
||||
room,
|
||||
DefaultTagID.LowPriority, // remove
|
||||
null, // remove
|
||||
DefaultTagID.Favourite, // add
|
||||
);
|
||||
});
|
||||
@ -73,10 +75,24 @@ describe("tagRoom()", () => {
|
||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||
room.client,
|
||||
room,
|
||||
DefaultTagID.Favourite, // remove
|
||||
null, // remove
|
||||
DefaultTagID.LowPriority, // add
|
||||
);
|
||||
});
|
||||
|
||||
it("should tag a room with a custom section", () => {
|
||||
const room = makeRoom();
|
||||
|
||||
tagRoom(room, customTag);
|
||||
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
|
||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||
room.client,
|
||||
room,
|
||||
null, // remove
|
||||
customTag, // add
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a room is tagged as favourite", () => {
|
||||
@ -137,4 +153,26 @@ describe("tagRoom()", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a room is tagged with a custom section", () => {
|
||||
const otherCustomTag = `${CUSTOM_SECTION_TAG_PREFIX}other-section`;
|
||||
|
||||
it.each([
|
||||
{ label: "untag the custom section", applyTag: customTag, expectedAdd: null },
|
||||
{ label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite },
|
||||
{ label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag },
|
||||
])("should $label", ({ applyTag, expectedAdd }) => {
|
||||
const room = makeRoom([customTag]);
|
||||
|
||||
tagRoom(room, applyTag);
|
||||
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
|
||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||
room.client,
|
||||
room,
|
||||
customTag, // remove
|
||||
expectedAdd, // add
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,7 +21,7 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot
|
||||
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
||||
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import SettingsStore, { type CallbackFn } from "../../../src/settings/SettingsStore";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
||||
@ -29,7 +29,8 @@ import { Action } from "../../../src/dispatcher/actions";
|
||||
import { CallStore } from "../../../src/stores/CallStore";
|
||||
import { CallEvent, type Call } from "../../../src/models/Call";
|
||||
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
||||
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import * as tagRoomModule from "../../../src/utils/room/tagRoom";
|
||||
|
||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
||||
@ -83,6 +84,7 @@ describe("RoomListItemViewModel", () => {
|
||||
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
|
||||
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -213,8 +215,8 @@ describe("RoomListItemViewModel", () => {
|
||||
let watchCallback: any;
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview);
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => {
|
||||
watchCallback = callback;
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
|
||||
if (setting === "RoomList.showMessagePreview") watchCallback = callback;
|
||||
return "watcher-id";
|
||||
});
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
|
||||
@ -595,6 +597,93 @@ describe("RoomListItemViewModel", () => {
|
||||
viewModel.onCreateSection();
|
||||
expect(createSectionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call tagRoom when onToggleSection is called", () => {
|
||||
const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {});
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
viewModel.onToggleSection(DefaultTagID.Favourite);
|
||||
|
||||
expect(tagRoomSpy).toHaveBeenCalledWith(room, DefaultTagID.Favourite);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sections", () => {
|
||||
const customTag = "element.io.section.custom1";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([
|
||||
DefaultTagID.Favourite,
|
||||
customTag,
|
||||
CHATS_TAG,
|
||||
DefaultTagID.LowPriority,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should include sections from orderedSectionTags excluding CHATS_TAG", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||
if (setting === "feature_room_list_sections") return true;
|
||||
return false;
|
||||
});
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
const sections = viewModel.getSnapshot().sections;
|
||||
expect(sections.map((s) => s.tag)).toEqual([DefaultTagID.Favourite, customTag, DefaultTagID.LowPriority]);
|
||||
});
|
||||
|
||||
it("should mark the room current section as selected", () => {
|
||||
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||
if (setting === "feature_room_list_sections") return true;
|
||||
return false;
|
||||
});
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
const sections = viewModel.getSnapshot().sections;
|
||||
expect(sections.find((s) => s.tag === DefaultTagID.Favourite)?.isSelected).toBe(true);
|
||||
expect(sections.find((s) => s.tag === DefaultTagID.LowPriority)?.isSelected).toBe(false);
|
||||
});
|
||||
|
||||
it("should use custom section name from CustomSectionData", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||
if (setting === "feature_room_list_sections") return true;
|
||||
if (setting === "RoomList.CustomSectionData")
|
||||
return { [customTag]: { name: "My Custom Section", tag: customTag } };
|
||||
return false;
|
||||
});
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
const section = viewModel.getSnapshot().sections.find((s) => s.tag === customTag);
|
||||
expect(section?.name).toBe("My Custom Section");
|
||||
});
|
||||
|
||||
it("should update sections when OrderedCustomSections setting changes", () => {
|
||||
let watchCallback: CallbackFn = () => {};
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
|
||||
if (setting === "RoomList.OrderedCustomSections") watchCallback = callback;
|
||||
return "watcher-id";
|
||||
});
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||
if (setting === "feature_room_list_sections") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
expect(viewModel.getSnapshot().sections).toHaveLength(3); // Favourite, custom, LowPriority
|
||||
|
||||
// Simulate reordering: custom section removed
|
||||
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([
|
||||
DefaultTagID.Favourite,
|
||||
CHATS_TAG,
|
||||
DefaultTagID.LowPriority,
|
||||
]);
|
||||
watchCallback("RoomList.OrderedCustomSections", null, null as any, null, null);
|
||||
|
||||
expect(viewModel.getSnapshot().sections.map((s) => s.tag)).toEqual([
|
||||
DefaultTagID.Favourite,
|
||||
DefaultTagID.LowPriority,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cleanup", () => {
|
||||
|
||||
@ -619,6 +619,12 @@ describe("RoomListViewModel", () => {
|
||||
expect(viewModel.getSnapshot().toast).toBe("section_created");
|
||||
});
|
||||
|
||||
it("should show toast when RoomTagged event fires", () => {
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
RoomListStoreV3.instance.emit(RoomListStoreV3Event.RoomTagged);
|
||||
expect(viewModel.getSnapshot().toast).toBe("chat_moved");
|
||||
});
|
||||
|
||||
it("should clear toast when closeToast is called", () => {
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user