Room list: add custom section creation (#33155)

* feat: add creation section dialog

* feat: add in skip list a method to change filters

* feat: add helper to creation section

* feat: add custom sections data to Settings

* feat: add custom section to room list store v3

* feat: update header and room list item vms

* feat: add toast to room list vm

* feat: add new translation

* chore: move util functions of room list specs

* test: add custom section playwright tests

* chore: call loadCustomSections in RoomListStoreV3 ctor
This commit is contained in:
Florian Duros
2026-04-17 12:02:42 +00:00
committed by GitHub
parent 73d4b63ada
commit 6b67b24254
27 changed files with 985 additions and 97 deletions
@@ -0,0 +1,59 @@
/*
* 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 { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { CreateSectionDialog } from "../../../../../src/components/views/dialogs/CreateSectionDialog";
describe("CreateSectionDialog", () => {
const onFinished: jest.Mock = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
});
function renderComponent(): void {
render(<CreateSectionDialog onFinished={onFinished} />);
}
it("renders the dialog", () => {
const { container } = render(<CreateSectionDialog onFinished={onFinished} />);
expect(container).toMatchSnapshot();
});
it("has the create section button disabled when the input is empty", () => {
renderComponent();
const createButton = screen.getByRole("button", { name: "Create section" });
expect(createButton).toBeDisabled();
});
it("calls onFinished with true and the section name when create section is clicked", async () => {
renderComponent();
const input = screen.getByRole("textbox");
await userEvent.type(input, "My section");
const createButton = screen.getByRole("button", { name: "Create section" });
await userEvent.click(createButton);
expect(onFinished).toHaveBeenCalledWith(true, "My section");
});
it("calls onFinished with false when the dialog is cancelled", async () => {
renderComponent();
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(cancelButton);
expect(onFinished).toHaveBeenCalledWith(false, "");
});
it("calls onFinished with true and the section name when the form is submitted", async () => {
renderComponent();
const input = screen.getByRole("textbox");
await userEvent.type(input, "My section");
await userEvent.keyboard("{Enter}");
expect(onFinished).toHaveBeenCalledWith(true, "My section");
});
});
@@ -0,0 +1,106 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CreateSectionDialog renders the dialog 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_CreateSectionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Create a section
</h1>
</div>
<div
class="_flex_4dswl_9 mx_CreateSectionDialog_content"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-6x); --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
>
Sections are only for you
</span>
<form
class="_root_19upo_16 mx_CreateSectionDialog_form"
>
<div
class="_field_19upo_26"
>
<label
class="_label_19upo_59"
for="radix-_r_0_"
>
Section name
</label>
<input
class="_control_sqdq4_10"
id="radix-_r_0_"
name="sectionName"
required=""
title=""
/>
</div>
</form>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
disabled=""
type="button"
>
Create section
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
>
<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>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;
@@ -14,9 +14,11 @@ import type { RoomNotificationState } from "../../../../src/stores/notifications
import {
CHATS_TAG,
LISTS_UPDATE_EVENT,
SECTION_CREATED_EVENT,
RoomListStoreV3Class,
type Section,
} from "../../../../src/stores/room-list-v3/RoomListStoreV3";
import * as sectionModule from "../../../../src/stores/room-list-v3/section";
import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient";
import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter";
import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils";
@@ -830,6 +832,7 @@ describe("RoomListStoreV3", () => {
function enableSections(): void {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [];
return false;
});
}
@@ -1007,6 +1010,84 @@ describe("RoomListStoreV3", () => {
const favSection = findSection(sections, DefaultTagID.Favourite)!;
expect(favSection.rooms).toContain(rooms[3]);
});
describe("createSection", () => {
it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => {
enableSections();
getClientAndRooms();
jest.spyOn(sectionModule, "createSection").mockResolvedValue(true);
const store = new RoomListStoreV3Class(dispatcher);
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();
expect(sectionCreatedListener).toHaveBeenCalled();
expect(listsUpdateListener).toHaveBeenCalled();
});
it("does not emit when section creation is cancelled", async () => {
enableSections();
getClientAndRooms();
jest.spyOn(sectionModule, "createSection").mockResolvedValue(false);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const sectionCreatedListener = jest.fn();
store.on(SECTION_CREATED_EVENT, sectionCreatedListener);
await store.createSection();
expect(sectionCreatedListener).not.toHaveBeenCalled();
});
});
it("updates sections when RoomList.OrderedCustomSections setting changes", async () => {
enableSections();
const { rooms } = getClientAndRooms();
let settingsWatcher: (settingName: string) => void = () => {};
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((settingName, _roomId, callback) => {
if (settingName === "RoomList.OrderedCustomSections") settingsWatcher = callback as () => void;
return "watcher-id";
});
const customTag = "element.io.section.custom";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [];
return false;
});
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
// Initial state: 3 sections (Favourite, Chats, LowPriority)
expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(3);
// Mark a room with the custom tag and update the settings
rooms[0].tags = { [customTag]: { order: 0 } };
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
if (setting === "feature_room_list_sections") return true;
if (setting === "RoomList.OrderedCustomSections") return [customTag];
return false;
});
// Trigger the settings watcher
settingsWatcher("RoomList.OrderedCustomSections");
// Now there should be 4 sections (Favourite, custom, Chats, LowPriority)
expect(store.getSortedRoomsInActiveSpace().sections).toHaveLength(4);
const customSection = findSection(store.getSortedRoomsInActiveSpace().sections, customTag)!;
expect(customSection.rooms).toContain(rooms[0]);
});
});
describe("Muted rooms", () => {
@@ -0,0 +1,71 @@
/*
* 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 Modal from "../../../../src/Modal";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { createSection } from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it.each([
[false, "", false],
[true, "", false],
[true, "My Section", true],
])("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);
});
it("opens the CreateSectionDialog", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false, ""]),
close: jest.fn(),
} as any);
await createSection();
expect(createDialogSpy).toHaveBeenCalledWith(CreateSectionDialog);
});
it("saves section data and ordered sections at ACCOUNT level when confirmed", async () => {
const existingTag = "element.io.section.existing";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return [existingTag];
return null;
});
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await createSection();
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
const savedSection = Object.values(customDataCall![3] as Record<string, { tag: string; name: string }>)[0];
expect(savedSection.name).toBe("My Section");
expect(savedSection.tag).toMatch(/^element\.io\.section\./);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
const savedOrder = orderedCall![3] as string[];
expect(savedOrder[0]).toBe(existingTag);
expect(savedOrder[1]).toMatch(/^element\.io\.section\./);
});
});
@@ -18,6 +18,9 @@ import { getMockedRooms } from "./getMockedRooms";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { MetaSpace } from "../../../../../src/stores/spaces";
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
import { FavouriteFilter } from "../../../../../src/stores/room-list-v3/skip-list/filters/FavouriteFilter";
import { FilterEnum } from "../../../../../src/stores/room-list-v3/skip-list/filters";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
describe("RoomSkipList", () => {
function generateSkipList(roomCount?: number): {
@@ -99,6 +102,26 @@ describe("RoomSkipList", () => {
expect(() => skipList.addNewRoom(room)).toThrow("Can't add room to skiplist");
});
it("Filters are applied to existing nodes when useNewFilters is called", () => {
const { skipList, rooms } = generateSkipList(10);
// Mark some rooms as favourite
const favouriteRooms = [rooms[2], rooms[5], rooms[8]];
for (const room of favouriteRooms) {
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
}
// No filters yet — all rooms are in the list
expect(skipList.size).toEqual(10);
// Apply the favourite filter
skipList.useNewFilters([new FavouriteFilter()]);
// Only favourite rooms should be returned when filtering by favourite
const filteredRooms = Array.from(skipList.getRoomsInActiveSpace([FilterEnum.FavouriteFilter]));
expect(filteredRooms).toHaveLength(favouriteRooms.length);
});
it("Re-sort works when sorter is swapped", () => {
const { skipList, rooms, sorter } = generateSkipList();
const sortedByRecency = [...rooms].sort((a, b) => sorter.comparator(a, b));