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
@@ -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);