Room list: drag and drop rooms into sections (#33366)

* chore: add dnd kit deps

* chore: patch dnd kit to fix ts error

* feat(sc): add drag-and-drop to room list item and wrapper

* feat(sc): make the room list header a droppable element

* feat(sc): add dnd to room list view

* feat(tags): can tag room as CHAT

* feat(vm): implement `changeRoomSection`

* feat(sc): disable dragging in flat list

* fix: disable keyboard navigation when dragging element

* test(sc): update snapshots

* test(sc): add dnd test

* test(e2e): add e2e tests for room drag and drop

* test(vm): add tests for changeRoomSection

* fix: remove focus visible when dropping with the mouse

* test(playwright): update existing screenshots

* chore(sc): move numbers out of main build

The Ew RecorderWorklet imports shared component bundle. However if the
bundle uses some deps using document/window which, the worklet will not
work.

The solution is to put the used functions into a separate bundle.

* doc(sc): add subpath import into README

* doc: typo barrel/bundle

* test: improve test expect

* refactor: add utils to section tag

* fix: incorrect check in tagRoom

* fix: add doc about dndkit tunning
This commit is contained in:
Florian Duros
2026-05-13 09:06:22 +00:00
committed by GitHub
parent 97da3be67a
commit 85aca65a81
50 changed files with 4845 additions and 3871 deletions
@@ -8,7 +8,7 @@
import { type Page } from "@playwright/test";
import { expect, test } from "../../../element-web-test";
import { getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
import { assertRoomInSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
test.describe("Room list custom sections", () => {
test.use({
@@ -40,22 +40,6 @@ test.describe("Room list custom sections", () => {
await expect(dialog).not.toBeVisible();
}
/**
* 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.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
@@ -6,7 +6,7 @@
*/
import { expect, test } from "../../../element-web-test";
import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
test.describe("Room list sections", () => {
test.use({
@@ -182,6 +182,37 @@ test.describe("Room list sections", () => {
roomItem = roomList.getByRole("row", { name: "Open room my room" });
await expect(roomItem).toBeVisible();
});
test("should move a room from Chats to Favourites when using dnd", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId);
await dragRoomToSection(page, "my room", "Favourites");
await assertRoomInSection(page, "Favourites", "my room");
});
test("should move a room from Favourites to Chats when using dnd", async ({ page, app }) => {
const favouriteId = await app.client.createRoom({ name: "my room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId);
// Create a second favourite room to ensure we stay in section mode (not flat list)
const favouriteId2 = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId2);
// Ensure the Chats section is visible by creating a room in it
await app.client.createRoom({ name: "room in chats" });
await dragRoomToSection(page, "my room", "Chats");
await assertRoomInSection(page, "Chats", "my room");
});
});
test("should show unread indicator on section header", async ({ page, app, bot }) => {
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { type Locator, type Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
/**
* Get the room list
@@ -35,6 +35,49 @@ export function getSectionHeader(page: Page, sectionName: string, isUnread = fal
});
}
/**
* 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.
*/
export 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);
}
/**
* Drag and drop a room row onto a section header
* @param page
* @param roomName
* @param sectionName
*/
export async function dragRoomToSection(page: Page, roomName: string, sectionName: string): Promise<void> {
const sourceRow = getRoomList(page).getByRole("row", { name: `Open room ${roomName}` });
const source = sourceRow.locator("button").first();
const target = getSectionHeader(page, sectionName);
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;
const targetY = targetBox.y + targetBox.height / 2;
// Grab the room
await page.mouse.move(sourceX, sourceY);
await page.mouse.down();
// Move the room on the section header
await page.mouse.move(sourceX, targetY, { steps: 10 });
// Drop the room
await page.mouse.up();
}
/**
* Get the primary filters container
* @param page
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

+1 -1
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { percentageOf } from "@element-hq/web-shared-components";
import { percentageOf } from "@element-hq/web-shared-components/numbers";
import { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts";
@@ -40,7 +40,7 @@ import { DefaultTagID } from "./skip-list/tag";
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
import { TagFilter } from "./skip-list/filters/TagFilter";
import { filterBoolean } from "../../utils/arrays";
import { createSection, deleteSection, editSection } from "./section";
import { CHATS_TAG, createSection, deleteSection, editSection } from "./section";
/**
* These are the filters passed to the room skip list.
@@ -86,12 +86,6 @@ export interface Section {
rooms: Room[];
}
/**
* A synthetic tag used to represent the "Chats" section, which contains
* every room that does not belong to any other explicit tag section.
*/
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;
@@ -12,9 +12,16 @@ import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
import { DefaultTagID, type TagID } from "./skip-list/tag";
type Tag = string;
/**
* A synthetic tag used to represent the "Chats" section, which contains
* every room that does not belong to any other explicit tag section.
*/
export const CHATS_TAG = "chats";
/**
* Prefix for custom section tags.
*/
@@ -29,6 +36,24 @@ export function isCustomSectionTag(tag: string): boolean {
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
}
/**
* Checks if a given tag is a default section tag.
* @param tagId - The tag to check.
* @returns True if the tag is a default section tag, false otherwise.
*/
export function isDefaultSectionTag(tagId: TagID): boolean {
return tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority || tagId === CHATS_TAG;
}
/**
* Checks if a given tag is a section tag.
* @param tagId - The tag to check.
* @returns True if the tag is a section tag, false otherwise.
*/
export function isSectionTag(tagId: TagID): boolean {
return isCustomSectionTag(tagId) || isDefaultSectionTag(tagId);
}
/**
* 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.
*/
+1 -1
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components";
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components/numbers";
/**
* Quickly resample an array to have less/more data points. If an input which is larger
@@ -0,0 +1,21 @@
/*
* 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.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { type TagID } from "../../stores/room-list-v3/skip-list/tag";
import { getTagsForRoom } from "./getTagsForRoom";
import { isSectionTag } from "../../stores/room-list-v3/section";
/**
* Get the section tag for a given room.
* @param room The room to get the section tag for.
* @returns The section tag ID or null if none found.
*/
export function getSectionTagForRoom(room: Room): TagID | null {
return getTagsForRoom(room).find((t) => isSectionTag(t)) ?? null;
}
+11 -11
View File
@@ -9,11 +9,11 @@ Please see LICENSE files in the repository root for full details.
import { type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/tag";
import { type TagID } from "../../stores/room-list-v3/skip-list/tag";
import RoomListActions from "../../actions/RoomListActions";
import dis from "../../dispatcher/dispatcher";
import { getTagsForRoom } from "./getTagsForRoom";
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
import { CHATS_TAG, isSectionTag } from "../../stores/room-list-v3/section";
import { getSectionTagForRoom } from "./getSectionTagForRoom";
/**
* Toggle tag for a given room.
@@ -23,19 +23,19 @@ import { isCustomSectionTag } from "../../stores/room-list-v3/section";
* @param tagId The tag to invert
*/
export function tagRoom(room: Room, tagId: TagID): void {
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
const isChatTag = tagId === CHATS_TAG;
const tag = isChatTag ? null : tagId;
if (!isSectionTag(tagId)) {
logger.warn(`Unexpected tag ${tag} 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 currentSectionTag = getSectionTagForRoom(room);
const isApplied = currentSectionTag === tagId;
const isApplied = currentSectionTag === tag;
const removeTag = currentSectionTag;
const addTag = isApplied ? null : tagId;
const addTag = isApplied ? null : tag;
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
}
@@ -38,8 +38,9 @@ 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, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { _t } from "../../languageHandler";
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
interface RoomItemProps {
room: Room;
@@ -429,9 +430,7 @@ export class RoomListItemViewModel
RoomListStoreV3.instance.orderedSectionTags
// Exclude the Chats because the user toggle the other sections to move rooms in and out of the Chats section.
// Also exclude the default sections because they are available as toggles in the main context menu, and we don't want them to be duplicated in the "Move to section" submenu.
.filter(
(tag) => tag !== CHATS_TAG && tag !== DefaultTagID.Favourite && tag !== DefaultTagID.LowPriority,
)
.filter((tag) => !isDefaultSectionTag(tag))
.map((tag) => ({
tag,
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
@@ -16,8 +16,8 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
interface RoomListSectionHeaderViewModelProps {
tag: string;
@@ -45,8 +45,7 @@ export class RoomListSectionHeaderViewModel
private readonly expandedBySpace = new Map<string, boolean>();
public constructor(props: RoomListSectionHeaderViewModelProps) {
const isDefaultSection =
props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG;
const isDefaultSection = isDefaultSectionTag(props.tag);
super(props, {
id: props.tag,
title: props.title,
@@ -15,7 +15,7 @@ import {
_t,
type ToastType,
} from "@element-hq/web-shared-components";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { Action } from "../../dispatcher/actions";
import dispatcher from "../../dispatcher/dispatcher";
@@ -24,7 +24,6 @@ import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"
import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
import SpaceStore from "../../stores/spaces/SpaceStore";
import RoomListStoreV3, {
CHATS_TAG,
RoomListStoreV3Event,
type RoomsResult,
type Section,
@@ -38,6 +37,9 @@ import { keepIfSame } from "../../utils/keepIfSame";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
import SettingsStore from "../../settings/SettingsStore";
import { tagRoom } from "../../utils/room/tagRoom";
import { getSectionTagForRoom } from "../../utils/room/getSectionTagForRoom";
import { CHATS_TAG } from "../../stores/room-list-v3/section";
/**
* Tracks the position of the active room within a specific section.
@@ -667,6 +669,17 @@ export class RoomListViewModel
this.closeToast();
}, 15 * 1000);
}
public changeRoomSection = (roomId: string, tag: string): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
const currentTag = getSectionTagForRoom(room);
// Room is already in the section
if (currentTag === tag) return;
tagRoom(room, tag);
};
}
/**
@@ -12,7 +12,6 @@ import { mocked } from "jest-mock";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
import {
CHATS_TAG,
LISTS_UPDATE_EVENT,
SECTION_CREATED_EVENT,
RoomListStoreV3Class,
@@ -37,6 +36,7 @@ import * as utils from "../../../../src/utils/notifications";
import * as utilsRLS from "../../../../src/stores/room-list-v3/utils.ts";
import { Action } from "../../../../src/dispatcher/actions";
import { SettingLevel } from "../../../../src/settings/SettingLevel.ts";
import { CHATS_TAG } from "../../../../src/stores/room-list-v3/section";
describe("RoomListStoreV3", () => {
async function getRoomListStore() {
@@ -7,9 +7,18 @@
import Modal from "../../../../src/Modal";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { createSection, editSection, deleteSection } from "../../../../src/stores/room-list-v3/section";
import {
CHATS_TAG,
CUSTOM_SECTION_TAG_PREFIX,
createSection,
editSection,
deleteSection,
isDefaultSectionTag,
isSectionTag,
} from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
describe("section", () => {
afterEach(() => {
@@ -203,4 +212,27 @@ describe("section", () => {
expect(customDataCall![3]).not.toHaveProperty(tag);
});
});
describe("isDefaultSectionTag", () => {
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG])("returns true for %s", (tag) => {
expect(isDefaultSectionTag(tag)).toBe(true);
});
it.each([DefaultTagID.Invite, "some.random.tag"])("returns false for %s", (tag) => {
expect(isDefaultSectionTag(tag)).toBe(false);
});
});
describe("isSectionTag", () => {
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG, `${CUSTOM_SECTION_TAG_PREFIX}some-uuid`])(
"returns true for %s",
(tag) => {
expect(isSectionTag(tag)).toBe(true);
},
);
it.each([DefaultTagID.Invite, "some.random.tag"])("returns false for %s", (tag) => {
expect(isSectionTag(tag)).toBe(false);
});
});
});
@@ -0,0 +1,51 @@
/*
* 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.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
import { getSectionTagForRoom } from "../../../../src/utils/room/getSectionTagForRoom";
import { getTagsForRoom } from "../../../../src/utils/room/getTagsForRoom";
jest.mock("../../../../src/utils/room/getTagsForRoom");
const mockGetTagsForRoom = jest.mocked(getTagsForRoom);
describe("getSectionTagForRoom", () => {
const room = {} as Room;
it("should return null when room has no tags", () => {
mockGetTagsForRoom.mockReturnValue([]);
expect(getSectionTagForRoom(room)).toBeNull();
});
it("should return null when room only has a non-section tag", () => {
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Untagged]);
expect(getSectionTagForRoom(room)).toBeNull();
});
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, `${CUSTOM_SECTION_TAG_PREFIX}abc-123`])(
"should return section tag %s when present",
(tag) => {
mockGetTagsForRoom.mockReturnValue([tag]);
expect(getSectionTagForRoom(room)).toBe(tag);
},
);
it("should return the first section tag when multiple are present", () => {
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc-123`;
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Favourite, customTag]);
expect(getSectionTagForRoom(room)).toBe(DefaultTagID.Favourite);
});
it("should ignore non-section tags and return the section tag", () => {
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc-123`;
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Untagged, customTag]);
expect(getSectionTagForRoom(room)).toBe(customTag);
});
});
@@ -11,23 +11,23 @@ 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 { CHATS_TAG, 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";
import * as getSectionTagForRoomUtils from "../../../../src/utils/room/getSectionTagForRoom";
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 makeRoom = (currentSectionTag: TagID | null = null): Room => {
const client = getMockClientWithEventEmitter({
isGuest: jest.fn(),
});
const room = new Room(roomId, client, userId);
jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue(tags);
jest.spyOn(getSectionTagForRoomUtils, "getSectionTagForRoom").mockReturnValue(currentSectionTag);
return room;
};
@@ -51,7 +51,7 @@ describe("tagRoom()", () => {
expect(RoomListActions.tagRoom).not.toHaveBeenCalled();
});
describe("when a room has no tags", () => {
describe("when a room has no section tag", () => {
it("should tag a room as favourite", () => {
const room = makeRoom();
@@ -93,11 +93,25 @@ describe("tagRoom()", () => {
customTag, // add
);
});
it("should do nothing meaningful when applying CHATS_TAG", () => {
const room = makeRoom();
tagRoom(room, CHATS_TAG);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
null, // remove
null, // add
);
});
});
describe("when a room is tagged as favourite", () => {
it("should unfavourite a room", () => {
const room = makeRoom([DefaultTagID.Favourite]);
const room = makeRoom(DefaultTagID.Favourite);
tagRoom(room, DefaultTagID.Favourite);
@@ -111,7 +125,7 @@ describe("tagRoom()", () => {
});
it("should tag a room low priority", () => {
const room = makeRoom([DefaultTagID.Favourite]);
const room = makeRoom(DefaultTagID.Favourite);
tagRoom(room, DefaultTagID.LowPriority);
@@ -123,10 +137,25 @@ describe("tagRoom()", () => {
DefaultTagID.LowPriority, // add
);
});
it("should remove the favourite tag when applying CHATS_TAG", () => {
const room = makeRoom(DefaultTagID.Favourite);
tagRoom(room, CHATS_TAG);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.Favourite, // remove
null, // add
);
});
});
describe("when a room is tagged as low priority", () => {
it("should favourite a room", () => {
const room = makeRoom([DefaultTagID.LowPriority]);
const room = makeRoom(DefaultTagID.LowPriority);
tagRoom(room, DefaultTagID.Favourite);
@@ -140,7 +169,7 @@ describe("tagRoom()", () => {
});
it("should untag a room low priority", () => {
const room = makeRoom([DefaultTagID.LowPriority]);
const room = makeRoom(DefaultTagID.LowPriority);
tagRoom(room, DefaultTagID.LowPriority);
@@ -161,8 +190,9 @@ describe("tagRoom()", () => {
{ 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 },
{ label: "remove section tag when applying CHATS_TAG", applyTag: CHATS_TAG, expectedAdd: null },
])("should $label", ({ applyTag, expectedAdd }) => {
const room = makeRoom([customTag]);
const room = makeRoom(customTag);
tagRoom(room, applyTag);
@@ -30,8 +30,9 @@ 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, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
import * as tagRoomModule from "../../../src/utils/room/tagRoom";
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
@@ -13,8 +13,9 @@ import { RoomNotificationStateStore } from "../../../src/stores/notifications/Ro
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
import { createTestClient, mkRoom } from "../../test-utils";
import SettingsStore from "../../../src/settings/SettingsStore";
import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
describe("RoomListSectionHeaderViewModel", () => {
let onToggleExpanded: jest.Mock;
@@ -10,7 +10,7 @@ import { mocked } from "jest-mock";
import { waitFor } from "jest-matrix-react";
import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils";
import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
import dispatcher from "../../../src/dispatcher/dispatcher";
@@ -21,6 +21,17 @@ import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListVie
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import SettingsStore from "../../../src/settings/SettingsStore";
import { tagRoom } from "../../../src/utils/room/tagRoom";
import { getSectionTagForRoom } from "../../../src/utils/room/getSectionTagForRoom";
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
jest.mock("../../../src/utils/room/tagRoom", () => ({
tagRoom: jest.fn(),
}));
jest.mock("../../../src/utils/room/getSectionTagForRoom", () => ({
getSectionTagForRoom: jest.fn().mockReturnValue(null),
}));
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasCreateRoomRights: jest.fn().mockReturnValue(false),
@@ -1108,4 +1119,37 @@ describe("RoomListViewModel", () => {
});
});
});
describe("changeRoomSection", () => {
beforeEach(() => {
viewModel = new RoomListViewModel({ client: matrixClient });
mocked(tagRoom).mockClear();
});
it("should call tagRoom with the room and target tag", () => {
jest.spyOn(matrixClient, "getRoom").mockReturnValue(room1);
mocked(getSectionTagForRoom).mockReturnValue(null);
viewModel.changeRoomSection(room1.roomId, DefaultTagID.Favourite);
expect(tagRoom).toHaveBeenCalledWith(room1, DefaultTagID.Favourite);
});
it("should do nothing when the room is not found", () => {
jest.spyOn(matrixClient, "getRoom").mockReturnValue(null);
viewModel.changeRoomSection("!unknown:server", DefaultTagID.Favourite);
expect(tagRoom).not.toHaveBeenCalled();
});
it("should do nothing when the room is already in the target section", () => {
jest.spyOn(matrixClient, "getRoom").mockReturnValue(room1);
mocked(getSectionTagForRoom).mockReturnValue(DefaultTagID.Favourite);
viewModel.changeRoomSection(room1.roomId, DefaultTagID.Favourite);
expect(tagRoom).not.toHaveBeenCalled();
});
});
});