Room list: edit or remove custom sections (#33283)

* feat(sc): add section menu to section header

* feat(rls): add edit and remove sections

* feat(dialog): add editing mode to CreateSectionDialog

* feat(dialog): add remove section dialog

* feat(vm): wire up vm and stores

* test: update existing snapshots

* test(e2e): add playwright tests to edit and remove a section

* chore: fix remove section i18n key

* fix: able to send empty sections

* chore: update create section editing docs

* chore: remove useless fallback

* chore: add logs when section is unknown

* feat: use different wording when removing an empty section

* fix: only animate the chevron icon in the section header

* fix: change dialog subtitle weight to medium
This commit is contained in:
Florian Duros
2026-04-28 10:16:34 +00:00
committed by GitHub
parent 1dd5748d6f
commit c363d2eb82
22 changed files with 1090 additions and 160 deletions
@@ -12,6 +12,9 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
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 { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
describe("RoomListSectionHeaderViewModel", () => {
let onToggleExpanded: jest.Mock;
@@ -20,6 +23,12 @@ describe("RoomListSectionHeaderViewModel", () => {
beforeEach(() => {
onToggleExpanded = jest.fn();
matrixClient = createTestClient();
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("watcher-id");
jest.spyOn(SettingsStore, "unwatchSetting").mockReturnValue(undefined);
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return [];
return null;
});
});
afterEach(() => {
@@ -87,6 +96,121 @@ describe("RoomListSectionHeaderViewModel", () => {
expect(vm.isExpanded).toBe(false);
});
describe("displaySectionMenu", () => {
it.each([
[DefaultTagID.Favourite, false],
[DefaultTagID.LowPriority, false],
[CHATS_TAG, false],
["element.io.section.custom", true],
])("should be %s for tag %s", (tag, expected) => {
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Section",
spaceId: "!space:server",
onToggleExpanded,
});
expect(vm.getSnapshot().displaySectionMenu).toBe(expected);
});
});
describe("onCustomSectionDataChange", () => {
let watchCallback: () => void;
beforeEach(() => {
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((settingName, _roomId, callback) => {
if (settingName === "RoomList.CustomSectionData") watchCallback = callback as () => void;
return "watcher-id";
});
});
it("should update title when custom section data changes", () => {
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Old Title",
spaceId: "!space:server",
onToggleExpanded,
});
expect(vm.getSnapshot().title).toBe("Old Title");
jest.spyOn(SettingsStore, "getValue").mockReturnValue({ [tag]: { tag, name: "New Title" } });
watchCallback();
expect(vm.getSnapshot().title).toBe("New Title");
});
it("should not update title when section data is missing", () => {
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "My Section",
spaceId: "!space:server",
onToggleExpanded,
});
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
watchCallback();
expect(vm.getSnapshot().title).toBe("My Section");
});
});
describe("editSection", () => {
it("should delegate to RoomListStoreV3.instance.editSection", async () => {
const editSectionSpy = jest.spyOn(RoomListStoreV3.instance, "editSection").mockResolvedValue(undefined);
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Section",
spaceId: "!space:server",
onToggleExpanded,
});
await vm.editSection();
expect(editSectionSpy).toHaveBeenCalledWith(tag);
});
});
describe("removeSection", () => {
beforeEach(() => {
const mockState = {
on: jest.fn(),
off: jest.fn(),
hasAnyNotificationOrActivity: false,
} as unknown as RoomNotificationState;
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(mockState);
});
it("should delegate to RoomListStoreV3.instance.removeSection with isEmpty=true when no rooms", async () => {
const removeSectionSpy = jest.spyOn(RoomListStoreV3.instance, "removeSection").mockResolvedValue(undefined);
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Section",
spaceId: "!space:server",
onToggleExpanded,
});
await vm.removeSection();
expect(removeSectionSpy).toHaveBeenCalledWith(tag, true);
});
it("should delegate to RoomListStoreV3.instance.removeSection with isEmpty=false when rooms exist", async () => {
const removeSectionSpy = jest.spyOn(RoomListStoreV3.instance, "removeSection").mockResolvedValue(undefined);
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Section",
spaceId: "!space:server",
onToggleExpanded,
});
vm.setRooms([mkRoom(matrixClient, "!room:server")]);
await vm.removeSection();
expect(removeSectionSpy).toHaveBeenCalledWith(tag, false);
});
});
describe("unread status", () => {
let room: Room;
let notificationState: RoomNotificationState;