From 546083bca9b3e92d4808a0f7f701c609e54b8618 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 23 Apr 2026 12:18:02 +0200 Subject: [PATCH] Room list: assign room to section when section is created (#33240) * feat(rls): return section tag when created * feat(vm): assign section tag to room when section created * test: update exisiting tests * test(e2e): check that room is in section --- .../room-list-custom-sections.spec.ts | 35 ++++++++++--------- .../stores/room-list-v3/RoomListStoreV3.ts | 3 +- .../room-list/RoomListItemViewModel.ts | 8 +++-- .../room-list-v3/RoomListStoreV3-test.ts | 5 ++- .../stores/room-list-v3/section-test.ts | 5 +-- .../room-list/RoomListHeaderViewModel-test.ts | 4 ++- .../room-list/RoomListItemViewModel-test.tsx | 11 ++++-- 7 files changed, 44 insertions(+), 27 deletions(-) diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts index 01146ef0dd..6b341e3d72 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -40,6 +40,22 @@ 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 { + 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(); @@ -97,6 +113,9 @@ test.describe("Room list custom sections", () => { // The custom section should be created await expect(getSectionHeader(page, "Projects")).toBeVisible(); + + // Room should be moved to the new section + await assertRoomInSection(page, "Projects", "my room"); }); test("should cancel section creation when dialog is dismissed", async ({ page, app }) => { @@ -177,22 +196,6 @@ test.describe("Room list custom sections", () => { }); 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 { - 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"); diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index f2527c9971..423c60a141 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -491,10 +491,11 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { * Create a new section. * Emits {@link SECTION_CREATED_EVENT} if the section was successfully created. */ - public async createSection(): Promise { + public async createSection(): Promise { const tag = await createSection(); if (!tag) return; this.emit(SECTION_CREATED_EVENT, tag); + return tag; } /** diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index a87e69f0fb..68f84c7925 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -400,8 +400,12 @@ export class RoomListItemViewModel echoChamber.notificationVolume = elementNotifState; }; - public onCreateSection = (): void => { - RoomListStoreV3.instance.createSection(); + public onCreateSection = async (): Promise => { + const newTag = await RoomListStoreV3.instance.createSection(); + // Add the room to the section + if (newTag) { + tagRoom(this.props.room, newTag); + } }; public onToggleSection = (tag: string): void => { diff --git a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index aac4cdaa2f..66a966ea36 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -1021,11 +1021,10 @@ describe("RoomListStoreV3", () => { await store.start(); const sectionCreatedListener = jest.fn(); - const listsUpdateListener = jest.fn(); store.on(SECTION_CREATED_EVENT, sectionCreatedListener); - store.on(LISTS_UPDATE_EVENT, listsUpdateListener); - await store.createSection(); + const tag = await store.createSection(); + expect(tag).toBe("element.io.section.test-tag"); expect(sectionCreatedListener).toHaveBeenCalledWith("element.io.section.test-tag"); }); diff --git a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts index 2917784638..d3d9000907 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts @@ -23,14 +23,15 @@ describe("createSection", () => { it.each([ [false, "", undefined], [true, "", undefined], - ])("returns undefined when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => { + [true, "My Section", expect.stringMatching(/^element\.io\.section\./)], + ])("returns %s when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => { jest.spyOn(Modal, "createDialog").mockReturnValue({ finished: Promise.resolve([shouldCreate, name]), close: jest.fn(), } as any); const result = await createSection(); - expect(result).toBe(expected); + expect(result).toEqual(expected); }); it("returns the new tag when section is created", async () => { diff --git a/apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts index 608e990af6..53f76d884f 100644 --- a/apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListHeaderViewModel-test.ts @@ -315,7 +315,9 @@ describe("RoomListHeaderViewModel", () => { }); it("should call createSection on RoomListStoreV3 when createSection is called", () => { - const createSectionSpy = jest.spyOn(RoomListStoreV3.instance, "createSection").mockResolvedValue(); + const createSectionSpy = jest + .spyOn(RoomListStoreV3.instance, "createSection") + .mockResolvedValue("element.io.section.work"); vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance }); vm.createSection(); expect(createSectionSpy).toHaveBeenCalled(); diff --git a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx index 35ee53efdb..53cf2f7d82 100644 --- a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -15,6 +15,7 @@ import { type RoomMember, } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import { waitFor } from "jest-matrix-react"; import { createTestClient, flushPromises } from "../../test-utils"; import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState"; @@ -591,11 +592,17 @@ describe("RoomListItemViewModel", () => { }); }); - it("should call createSection on RoomListStoreV3 when onCreateSection is called", () => { - const createSectionSpy = jest.spyOn(RoomListStoreV3.instance, "createSection").mockResolvedValue(); + it("should call createSection on RoomListStoreV3 when onCreateSection is called", async () => { + const createSectionSpy = jest + .spyOn(RoomListStoreV3.instance, "createSection") + .mockResolvedValue("element.io.section.work"); + const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {}); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); viewModel.onCreateSection(); expect(createSectionSpy).toHaveBeenCalled(); + + await waitFor(() => expect(tagRoomSpy).toHaveBeenCalledWith(room, "element.io.section.work")); }); it("should call tagRoom when onToggleSection is called", () => {