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
@@ -1046,6 +1046,38 @@ describe("RoomListStoreV3", () => {
});
});
describe("editSection", () => {
it("delegates to the section module", async () => {
enableSections();
getClientAndRooms();
const editSectionSpy = jest.spyOn(sectionModule, "editSection").mockResolvedValue(undefined);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
await store.editSection("element.io.section.test-tag");
expect(editSectionSpy).toHaveBeenCalledWith("element.io.section.test-tag");
});
});
describe("removeSection", () => {
it("delegates to the section module and emits LISTS_UPDATE_EVENT", async () => {
enableSections();
getClientAndRooms();
jest.spyOn(sectionModule, "deleteSection").mockResolvedValue(undefined);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const listsUpdateListener = jest.fn();
store.on(LISTS_UPDATE_EVENT, listsUpdateListener);
await store.removeSection("element.io.section.test-tag", false);
expect(sectionModule.deleteSection).toHaveBeenCalledWith("element.io.section.test-tag", false);
expect(listsUpdateListener).toHaveBeenCalled();
});
});
it("updates sections when RoomList.OrderedCustomSections setting changes", async () => {
enableSections();
const { rooms } = getClientAndRooms();
@@ -7,75 +7,200 @@
import Modal from "../../../../src/Modal";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { createSection } from "../../../../src/stores/room-list-v3/section";
import { createSection, editSection, deleteSection } from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
describe("section", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it.each([
[false, "", undefined],
[true, "", undefined],
[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).toEqual(expected);
});
it("returns the new tag when section is created", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const result = await createSection();
expect(result).toMatch(/^element\.io\.section\./);
});
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;
describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
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();
it.each([
[false, "", undefined],
[true, "", undefined],
[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 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 result = await createSection();
expect(result).toEqual(expected);
});
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\./);
it("returns the new tag when section is created", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const result = await createSection();
expect(result).toMatch(/^element\.io\.section\./);
});
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\./);
});
});
describe("editSection", () => {
const tag = "element.io.section.abc";
const existingSectionData = { [tag]: { tag, name: "Old Name" } };
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(existingSectionData);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("does nothing if the section does not exist", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const createDialogSpy = jest.spyOn(Modal, "createDialog");
await editSection(tag);
expect(createDialogSpy).not.toHaveBeenCalled();
});
it("opens the CreateSectionDialog with the current section name", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false, ""]),
close: jest.fn(),
} as any);
await editSection(tag);
expect(createDialogSpy).toHaveBeenCalledWith(CreateSectionDialog, { sectionToEdit: "Old Name" });
});
it.each([
[false, "New Name"],
[true, ""],
[true, "Old Name"],
])("does not save when shouldEdit=%s and name='%s'", async (shouldEdit, name) => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([shouldEdit, name]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await editSection(tag);
expect(setValueSpy).not.toHaveBeenCalled();
});
it("saves the new name when confirmed with a different name", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "New Name"]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await editSection(tag);
expect(setValueSpy).toHaveBeenCalledWith(
"RoomList.CustomSectionData",
null,
expect.anything(),
expect.objectContaining({ [tag]: { tag, name: "New Name" } }),
);
});
});
describe("deleteSection", () => {
const tag = "element.io.section.abc";
const otherTag = "element.io.section.other";
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.CustomSectionData") return { [tag]: { tag, name: "My Section" } };
if (setting === "RoomList.OrderedCustomSections") return [otherTag, tag];
return null;
});
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("does nothing if the section does not exist", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const createDialogSpy = jest.spyOn(Modal, "createDialog");
await deleteSection(tag, false);
expect(createDialogSpy).not.toHaveBeenCalled();
});
it.each([
[true, "empty"],
[false, "non-empty"],
])("opens the RemoveSectionDialog with isEmpty=%s for %s section", async (isEmpty) => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false]),
close: jest.fn(),
} as any);
await deleteSection(tag, isEmpty);
expect(createDialogSpy).toHaveBeenCalledWith(RemoveSectionDialog, { isEmpty });
});
it("does not save when user cancels", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await deleteSection(tag, false);
expect(setValueSpy).not.toHaveBeenCalled();
});
it("removes the section from ordered list and section data when confirmed", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await deleteSection(tag, false);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
expect(orderedCall![3]).toEqual([otherTag]);
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
expect(customDataCall![3]).not.toHaveProperty(tag);
});
});
});