From c363d2eb8245439885e82b2cb553a764d48c6d5b Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 28 Apr 2026 12:16:34 +0200 Subject: [PATCH] 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 --- .../room-list-custom-sections.spec.ts | 62 +++++ apps/web/res/css/_components.pcss | 1 + .../views/dialogs/_RemoveSectionDialog.pcss | 10 + .../views/dialogs/CreateSectionDialog.tsx | 26 +- .../views/dialogs/RemoveSectionDialog.tsx | 48 ++++ apps/web/src/i18n/strings/en_EN.json | 10 +- .../stores/room-list-v3/RoomListStoreV3.ts | 21 +- apps/web/src/stores/room-list-v3/section.ts | 52 ++++ .../RoomListSectionHeaderViewModel.ts | 38 ++- .../dialogs/CreateSectionDialog-test.tsx | 34 +++ .../dialogs/RemoveSectionDialog-test.tsx | 48 ++++ .../CreateSectionDialog-test.tsx.snap | 3 +- .../RemoveSectionDialog-test.tsx.snap | 161 ++++++++++++ .../room-list-v3/RoomListStoreV3-test.ts | 32 +++ .../stores/room-list-v3/section-test.ts | 245 +++++++++++++----- .../RoomListSectionHeaderViewModel-test.ts | 124 +++++++++ .../src/i18n/strings/en_EN.json | 3 + .../__snapshots__/RoomListView.test.tsx.snap | 152 ++++++----- .../RoomListSectionHeaderView.module.css | 21 +- .../RoomListSectionHeaderView.stories.tsx | 7 +- .../RoomListSectionHeaderView.tsx | 83 +++++- .../RoomListSectionHeaderView.test.tsx.snap | 69 +++-- 22 files changed, 1090 insertions(+), 160 deletions(-) create mode 100644 apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss create mode 100644 apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx create mode 100644 apps/web/test/unit-tests/components/views/dialogs/RemoveSectionDialog-test.tsx create mode 100644 apps/web/test/unit-tests/components/views/dialogs/__snapshots__/RemoveSectionDialog-test.tsx.snap 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 4ed71394b9..742b611faf 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 @@ -196,6 +196,68 @@ test.describe("Room list custom sections", () => { }); }); + test.describe("Section editing", () => { + test("should edit a custom section name via the section header menu", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + // Open the section header menu + const sectionHeader = getSectionHeader(page, "Work"); + await sectionHeader.hover(); + await sectionHeader.getByRole("button", { name: "More options" }).click(); + + // Click "Edit section" + await page.getByRole("menuitem", { name: "Edit section" }).click(); + + // The edit dialog should appear pre-filled with the current name + const dialog = page.getByRole("dialog", { name: "Edit a section" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("textbox", { name: "Section name" })).toHaveValue("Work"); + + // Change the name and confirm + await dialog.getByRole("textbox", { name: "Section name" }).fill("Personal"); + await dialog.getByRole("button", { name: "Edit section" }).click(); + + // Dialog should close + await expect(dialog).not.toBeVisible(); + + // Section should have the new name + await expect(getSectionHeader(page, "Personal")).toBeVisible(); + await expect(getSectionHeader(page, "Work")).not.toBeVisible(); + }); + }); + + test.describe("Section removal", () => { + test("should move rooms back to Chats when their section is removed", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + await createCustomSection(page, "Personal"); + + const roomList = getRoomList(page); + + // Move room to Work section + const roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + await assertRoomInSection(page, "Work", "my room"); + + // Remove the Work section + const sectionHeader = getSectionHeader(page, "Work"); + await sectionHeader.hover(); + await sectionHeader.getByRole("button", { name: "More options" }).click(); + await page.getByRole("menuitem", { name: "Remove section" }).click(); + const dialog = page.getByRole("dialog", { name: "Remove section?" }); + await dialog.getByRole("button", { name: "Remove section" }).click(); + + // Section should be gone + await expect(getSectionHeader(page, "Work")).not.toBeVisible(); + // Room should now be in the Chats section + await assertRoomInSection(page, "Chats", "my room"); + }); + }); + test.describe("Adding a room to a custom section", () => { test("should add a room to a custom section via the More Options menu", async ({ page, app }) => { await app.client.createRoom({ name: "my room" }); diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index bec2a22b47..a175838d57 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -150,6 +150,7 @@ @import "./views/dialogs/_ModalWidgetDialog.pcss"; @import "./views/dialogs/_PollCreateDialog.pcss"; @import "./views/dialogs/_RegistrationEmailPromptDialog.pcss"; +@import "./views/dialogs/_RemoveSectionDialog.pcss"; @import "./views/dialogs/_ReportRoomDialog.pcss"; @import "./views/dialogs/_RoomSettingsDialog.pcss"; @import "./views/dialogs/_RoomSettingsDialogBridges.pcss"; diff --git a/apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss b/apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss new file mode 100644 index 0000000000..30fe63cc9a --- /dev/null +++ b/apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss @@ -0,0 +1,10 @@ +/* + * 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. + */ + +.mx_RemoveSectionDialog { + color: var(--cpd-color-text-primary); +} diff --git a/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx b/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx index 295aa9c643..d8aaed3326 100644 --- a/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx +++ b/apps/web/src/components/views/dialogs/CreateSectionDialog.tsx @@ -14,6 +14,11 @@ import DialogButtons from "../elements/DialogButtons"; import { _t } from "../../../languageHandler"; interface CreateSectionDialogProps { + /** + * The name of the section being edited if defined. Otherwise, create a new section. + */ + sectionToEdit?: string; + /** * Callback called when the dialog is closed. * @param shouldCreateSection Whether a section should be created or not. This will be false if the user cancels the dialog. @@ -25,36 +30,43 @@ interface CreateSectionDialogProps { /** * Dialog shown to the user to create a new section in the room list. */ -export function CreateSectionDialog({ onFinished }: CreateSectionDialogProps): JSX.Element { - const [value, setValue] = useState(""); +export function CreateSectionDialog({ onFinished, sectionToEdit }: CreateSectionDialogProps): JSX.Element { + const isEdition = Boolean(sectionToEdit); + const [value, setValue] = useState(sectionToEdit ?? ""); const isInvalid = Boolean(value.trim().length === 0); return ( onFinished(false, value)} - title={_t("create_section_dialog|title")} + title={isEdition ? _t("create_section_dialog|title_edition") : _t("create_section_dialog|title")} hasCancel={true} > - + {_t("create_section_dialog|description")} { - onFinished(true, value); e.preventDefault(); + if (!isInvalid) onFinished(true, value); }} > {_t("create_section_dialog|label")} - setValue(evt.target.value)} required={true} /> + setValue(evt.target.value)} + required={true} + /> onFinished(false, "")} diff --git a/apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx b/apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx new file mode 100644 index 0000000000..fe2a715a8b --- /dev/null +++ b/apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx @@ -0,0 +1,48 @@ +/* + * 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 React from "react"; +import { type JSX } from "react"; +import { Text } from "@vector-im/compound-web"; + +import { _t } from "../../../languageHandler"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +interface RemoveSectionDialogProps { + onFinished: (shouldRemoveSection: boolean) => void; + /** Whether the section is empty */ + isEmpty: boolean; +} + +/** + * Dialog shown to the user to remove section in the room list. + */ +export function RemoveSectionDialog({ onFinished, isEmpty }: RemoveSectionDialogProps): JSX.Element { + return ( + onFinished(false)} + title={_t("remove_section_dialog|title")} + hasCancel={true} + > + {_t("remove_section_dialog|confirmation")} + {!isEmpty && ( + <> +
+ {_t("remove_section_dialog|description")} + + )} + onFinished(false)} + onPrimaryButtonClick={() => onFinished(true)} + /> +
+ ); +} diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 4af9d4fc0d..62104e0c61 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -683,8 +683,10 @@ "create_section_dialog": { "create_section": "Create section", "description": "Sections are only for you", + "edit_section": "Edit section", "label": "Section name", - "title": "Create a section" + "title": "Create a section", + "title_edition": "Edit a section" }, "create_space": { "add_details_prompt": "Add some details to help people recognise it.", @@ -1851,6 +1853,12 @@ "ongoing": "Removing…", "reason_label": "Reason (optional)" }, + "remove_section_dialog": { + "confirmation": "Are you sure you want to remove this section?", + "description": "The chats in this section will still be available in your chats list.", + "remove_section": "Remove section", + "title": "Remove section?" + }, "report_content": { "description": "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.", "disagree": "Disagree", diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index 423c60a141..60b86ddc81 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -40,7 +40,7 @@ import { DefaultTagID } from "./skip-list/tag"; import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter"; import { TagFilter } from "./skip-list/filters/TagFilter"; import { filterBoolean } from "../../utils/arrays"; -import { createSection } from "./section"; +import { createSection, deleteSection, editSection } from "./section"; /** * These are the filters passed to the room skip list. @@ -498,6 +498,25 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { return tag; } + /** + * Edit a section's name. + * @param tag The tag of the section to edit + */ + public async editSection(tag: string): Promise { + await editSection(tag); + } + + /** + * Remove a section + * Emits {@link LISTS_UPDATE_EVENT} if the section was successfully removed. + * @param tag The tag of the section to remove + * @param isEmpty Whether the section is empty + */ + public async removeSection(tag: string, isEmpty: boolean): Promise { + await deleteSection(tag, isEmpty); + this.scheduleEmit(); + } + /** * Returns the ordered section tags. */ diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 389ec56a91..5cd4f97169 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -5,10 +5,13 @@ * Please see LICENSE files in the repository root for full details. */ +import { logger } from "matrix-js-sdk/src/logger"; + import { SettingLevel } from "../../settings/SettingLevel"; import SettingsStore from "../../settings/SettingsStore"; import Modal from "../../Modal"; import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog"; +import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog"; type Tag = string; @@ -69,3 +72,52 @@ export async function createSection(): Promise { await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections); return tag; } + +/** + * Edits an existing custom section by showing a dialog to the user to enter the new section name. If the user confirms, it updates the section data in the settings. + * @param tag - The tag of the section to edit. + */ +export async function editSection(tag: string): Promise { + const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {}; + const section = sectionData[tag]; + if (!section) { + logger.info("Unknown section tag, cannot edit section", tag); + return; + } + + const modal = Modal.createDialog(CreateSectionDialog, { sectionToEdit: section.name }); + + const [shouldEditSection, newName] = await modal.finished; + const isSameName = newName === section.name; + if (!shouldEditSection || !newName || isSameName) return; + + // Save the new name + sectionData[tag].name = newName; + await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData); +} + +/** + * Deletes a custom section by showing a confirmation dialog to the user. If the user confirms, it removes the section data from the settings and updates the ordered list of sections. + * @param tag - The tag of the section to delete. + * @param isEmpty - Whether the section is empty (has no rooms). If the section is not empty, the confirmation dialog will show a warning message. + */ +export async function deleteSection(tag: string, isEmpty: boolean): Promise { + const sectionData = SettingsStore.getValue("RoomList.CustomSectionData"); + if (!sectionData[tag]) { + logger.info("Unknown section tag, cannot delete section", tag); + return; + } + + const modal = Modal.createDialog(RemoveSectionDialog, { isEmpty }); + const [shouldRemoveSection] = await modal.finished; + if (!shouldRemoveSection) return; + + // Remove the section from the ordered list of sections + const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections"); + const newOrderedSections = orderedSections.filter((sectionTag) => sectionTag !== tag); + await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, newOrderedSections); + + // Remove the section data + delete sectionData[tag]; + await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData); +} diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts index a861c0894a..a7b8d6dd2d 100644 --- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -15,6 +15,9 @@ import { import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../stores/notifications/NotificationState"; import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState"; +import SettingsStore from "../../settings/SettingsStore"; +import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; +import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3"; interface RoomListSectionHeaderViewModelProps { tag: string; @@ -42,7 +45,19 @@ export class RoomListSectionHeaderViewModel private readonly expandedBySpace = new Map(); public constructor(props: RoomListSectionHeaderViewModelProps) { - super(props, { id: props.tag, title: props.title, isExpanded: true, isUnread: false }); + const isDefaultSection = + props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG; + super(props, { + id: props.tag, + title: props.title, + isExpanded: true, + isUnread: false, + displaySectionMenu: !isDefaultSection, + }); + const sectionWatherRef = SettingsStore.watchSetting("RoomList.CustomSectionData", null, () => + this.onCustomSectionDataChange(), + ); + this.disposables.track(() => SettingsStore.unwatchSetting(sectionWatherRef)); } public onClick = (): void => { @@ -120,4 +135,25 @@ export class RoomListSectionHeaderViewModel this.roomNotificationStates.clear(); super.dispose(); } + + /** + * Handle changes to custom section data. + */ + private onCustomSectionDataChange(): void { + const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {}; + const sectionData = customSectionData[this.props.tag]; + if (sectionData) { + this.snapshot.merge({ title: sectionData.name }); + } + } + + public editSection = async (): Promise => { + await RoomListStoreV3.instance.editSection(this.props.tag); + }; + + public removeSection = async (): Promise => { + // There is one notification state per room in the section + const isEmpty = this.roomNotificationStates.size === 0; + await RoomListStoreV3.instance.removeSection(this.props.tag, isEmpty); + }; } diff --git a/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx index ad22a7d74b..3e709d93bd 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx +++ b/apps/web/test/unit-tests/components/views/dialogs/CreateSectionDialog-test.tsx @@ -56,4 +56,38 @@ describe("CreateSectionDialog", () => { await userEvent.keyboard("{Enter}"); expect(onFinished).toHaveBeenCalledWith(true, "My section"); }); + + describe("editing mode", () => { + function renderEditComponent(): void { + render(); + } + + it("pre-fills the input with the existing section name", () => { + renderEditComponent(); + const input = screen.getByRole("textbox"); + expect(input).toHaveValue("Existing Section"); + }); + + it("shows the edit section button instead of create section", () => { + renderEditComponent(); + expect(screen.getByRole("button", { name: "Edit section" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Create section" })).not.toBeInTheDocument(); + }); + + it("calls onFinished with the updated name when edit section is clicked", async () => { + renderEditComponent(); + const input = screen.getByRole("textbox"); + await userEvent.clear(input); + await userEvent.type(input, "Updated Section"); + await userEvent.click(screen.getByRole("button", { name: "Edit section" })); + expect(onFinished).toHaveBeenCalledWith(true, "Updated Section"); + }); + + it("has the edit section button disabled when the input is empty", async () => { + renderEditComponent(); + const input = screen.getByRole("textbox"); + await userEvent.clear(input); + expect(screen.getByRole("button", { name: "Edit section" })).toBeDisabled(); + }); + }); }); diff --git a/apps/web/test/unit-tests/components/views/dialogs/RemoveSectionDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/RemoveSectionDialog-test.tsx new file mode 100644 index 0000000000..5bc664e989 --- /dev/null +++ b/apps/web/test/unit-tests/components/views/dialogs/RemoveSectionDialog-test.tsx @@ -0,0 +1,48 @@ +/* + * 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 { RemoveSectionDialog } from "../../../../../src/components/views/dialogs/RemoveSectionDialog"; + +describe("RemoveSectionDialog", () => { + const onFinished: jest.Mock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("renders the dialog when section is not empty", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect( + screen.getByText("The chats in this section will still be available in your chats list."), + ).toBeInTheDocument(); + }); + + it("renders the dialog when section is empty", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect( + screen.queryByText("The chats in this section will still be available in your chats list."), + ).not.toBeInTheDocument(); + }); + + it("calls onFinished with true when remove section is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Remove section" })); + expect(onFinished).toHaveBeenCalledWith(true); + }); + + it("calls onFinished with false when the dialog is cancelled", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onFinished).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/CreateSectionDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/CreateSectionDialog-test.tsx.snap index 7283c072f5..3308c5a0bb 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/CreateSectionDialog-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/CreateSectionDialog-test.tsx.snap @@ -29,7 +29,7 @@ exports[`CreateSectionDialog renders the dialog 1`] = ` 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;" > Sections are only for you @@ -52,6 +52,7 @@ exports[`CreateSectionDialog renders the dialog 1`] = ` name="sectionName" required="" title="" + value="" /> diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/RemoveSectionDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/RemoveSectionDialog-test.tsx.snap new file mode 100644 index 0000000000..aad2489868 --- /dev/null +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/RemoveSectionDialog-test.tsx.snap @@ -0,0 +1,161 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RemoveSectionDialog renders the dialog when section is empty 1`] = ` +
+
+ +
+
+`; + +exports[`RemoveSectionDialog renders the dialog when section is not empty 1`] = ` +
+
+ +
+
+`; 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 66a966ea36..fa3def1e46 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 @@ -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(); 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 d3d9000907..bfb999871e 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 @@ -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)[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)[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); + }); }); }); diff --git a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts index 702cf412c0..000bbddd61 100644 --- a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts @@ -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; diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 1296591759..d747318b37 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -147,6 +147,9 @@ "room_options": "Room Options", "section_created": "Section created", "section_header": { + "edit_section": "Edit section", + "more_options": "More options", + "remove_section": "Remove section", "toggle": "Toggle %(section)s section", "toggle_unread": "Toggle %(section)s section with unread room(s)" }, diff --git a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap index 477f018b49..9534fa1080 100644 --- a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap @@ -8376,24 +8376,30 @@ exports[` > renders LargeSectionList story 1`] = ` >
- - - - - Favourites - + + + + + Favourites + +
@@ -11528,24 +11534,30 @@ exports[` > renders LargeSectionList story 1`] = ` >
- - - - - Chats - + + + + + Chats + +
@@ -13693,24 +13705,30 @@ exports[` > renders SmallSectionList story 1`] = ` >
- - - - - Favourites - + + + + + Favourites + +
@@ -14020,24 +14038,30 @@ exports[` > renders SmallSectionList story 1`] = ` >
- - - - - Chats - + + + + + Chats + +
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css index 5232b4299f..d587c8014f 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css @@ -19,7 +19,8 @@ background-color: var(--cpd-color-bg-canvas-default); &:hover, - &:focus-visible { + &:focus-visible, + &:has(button[data-state="open"]) { color: var(--cpd-color-text-primary); svg { @@ -29,20 +30,24 @@ .container { background-color: var(--cpd-color-bg-action-tertiary-hovered); } + + .menu { + display: initial; + } } - svg { + .chevron { transition: transform 0.05s linear; } @media (prefers-reduced-motion: reduce) { - svg { + .chevron { transition: none; } } &[aria-expanded="true"] { - svg { + .chevron { transform: rotate(90deg); } } @@ -58,6 +63,10 @@ padding: var(--cpd-space-1-5x) var(--cpd-space-2x) var(--cpd-space-1-5x) var(--cpd-space-1x); border-radius: 8px; + div { + min-width: 0; + } + svg { flex-shrink: 0; } @@ -77,3 +86,7 @@ .lastHeader { padding-bottom: 0; } + +.menu { + display: none; +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx index 9b48f089c9..c4455dc036 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.stories.tsx @@ -25,6 +25,8 @@ type RoomListSectionHeaderProps = RoomListSectionHeaderViewSnapshot & const RoomListSectionHeaderViewWrapperImpl = ({ onClick, onFocus, + editSection, + removeSection, isFocused, sectionIndex, sectionCount, @@ -32,7 +34,7 @@ const RoomListSectionHeaderViewWrapperImpl = ({ roomCountInSection, ...rest }: RoomListSectionHeaderProps): JSX.Element => { - const vm = useMockedViewModel(rest, { onClick }); + const vm = useMockedViewModel(rest, { onClick, editSection, removeSection }); return ( ; + /** Handler invoked when the edit section button is clicked */ + editSection: () => void; + /** Handler invoked when the remove section button is clicked */ + removeSection: () => void; } /** @@ -91,7 +100,7 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView roomCountInSection, }: Readonly): JSX.Element { const { translate: _t } = useI18n(); - const { id, title, isExpanded, isUnread } = useViewModel(vm); + const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm); const isLastSection = sectionIndex === sectionCount - 1; return ( @@ -118,11 +127,75 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView : _t("room_list|section_header|toggle", { section: title }) } > - - - {title} + + + + {title} + + {displaySectionMenu && } ); }); + +interface MenuComponentProps { + vm: RoomListSectionHeaderViewModel; +} + +/** + * + * Menu component for the section header. + */ + +function MenuComponent({ vm }: MenuComponentProps): JSX.Element { + const [open, setOpen] = useState(false); + + return ( + + + + } + > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()} + > + vm.editSection()} + onClick={(evt) => evt.stopPropagation()} + /> + vm.removeSection()} + onClick={(evt) => evt.stopPropagation()} + /> +
+
+ ); +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap index 2cbf196de1..c2eb9d9708 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap @@ -24,24 +24,63 @@ exports[` stories > renders Default story 1`] = ` >
- - - - + + + + Favourites + +
+