Room list: improve custom sections in Spaces (#33523)
* feat: display empty custom sections in the space where they were created * test: update tests * fix: room can't be drag in to a section if a section is created before * fix: re-render issue when section is created
This commit is contained in:
+17
-1
@@ -9,7 +9,7 @@ import { type Page } from "@playwright/test";
|
||||
import { rejectToast } from "@element-hq/element-web-playwright-common";
|
||||
|
||||
import { expect, test } from "../../../element-web-test";
|
||||
import { assertRoomInSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
|
||||
import { assertRoomInSection, dragRoomToSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
|
||||
|
||||
test.describe("Room list custom sections", () => {
|
||||
test.use({
|
||||
@@ -330,6 +330,22 @@ test.describe("Room list custom sections", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("should accept drag and drop into a section created after another section exists", async ({
|
||||
page,
|
||||
app,
|
||||
}) => {
|
||||
await app.client.createRoom({ name: "room A" });
|
||||
await app.client.createRoom({ name: "room B" });
|
||||
await createCustomSection(page, "Work");
|
||||
await createCustomSection(page, "Personal");
|
||||
|
||||
await dragRoomToSection(page, "room A", "Personal");
|
||||
await assertRoomInSection(page, "Personal", "room A");
|
||||
|
||||
await dragRoomToSection(page, "room B", "Work");
|
||||
await assertRoomInSection(page, "Work", "room B");
|
||||
});
|
||||
|
||||
test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
|
||||
@@ -486,7 +486,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
* Emits {@link SECTION_CREATED_EVENT} if the section was successfully created.
|
||||
*/
|
||||
public async createSection(): Promise<string | undefined> {
|
||||
const tag = await createSection();
|
||||
const tag = await createSection(SpaceStore.instance.activeSpace);
|
||||
if (!tag) return;
|
||||
this.emit(SECTION_CREATED_EVENT, tag);
|
||||
return tag;
|
||||
|
||||
@@ -13,6 +13,8 @@ import Modal from "../../Modal";
|
||||
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
|
||||
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
|
||||
import { DefaultTagID, type TagID } from "./skip-list/tag";
|
||||
import { isMetaSpace, MetaSpace, type SpaceKey } from "../spaces";
|
||||
import SpaceStore from "../spaces/SpaceStore";
|
||||
|
||||
/**
|
||||
* A synthetic tag used to represent the "Chats" section, which contains
|
||||
@@ -60,6 +62,8 @@ export function isSectionTag(tagId: TagID): boolean {
|
||||
type CustomSection = {
|
||||
tag: CustomTag;
|
||||
name: string;
|
||||
/** The space in which this section was created. Used to control visibility of empty sections. */
|
||||
spaceId?: SpaceKey;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -83,6 +87,14 @@ export type CustomSectionsData = Record<CustomTag, CustomSection>;
|
||||
*/
|
||||
export type OrderedCustomSections = CustomTag[];
|
||||
|
||||
/**
|
||||
* Returns true if the given space key corresponds to an enabled meta-space or a known top-level space room.
|
||||
*/
|
||||
function doesSpaceExist(spaceId: SpaceKey): boolean {
|
||||
if (isMetaSpace(spaceId)) return SpaceStore.instance.enabledMetaSpaces.includes(spaceId);
|
||||
return SpaceStore.instance.spacePanelSpaces.some((room) => room.roomId === spaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the custom sections data from the settings.
|
||||
* Invalid or malformed entries are dropped and the cleaned data is persisted back to settings.
|
||||
@@ -92,9 +104,17 @@ export function getCustomSectionData(): CustomSectionsData {
|
||||
// Data are malformed
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
||||
|
||||
// Filter out invalid entries
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).filter(([key, value]) => isValidCustomSection(value) && value.tag === key),
|
||||
Object.entries(raw)
|
||||
.filter(([key, value]) => isValidCustomSection(value) && value.tag === key)
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
...value,
|
||||
// Default to MetaSpace.Home for legacy sections (no spaceId) or if the stored space no longer exists
|
||||
spaceId: value.spaceId && doesSpaceExist(value.spaceId) ? value.spaceId : MetaSpace.Home,
|
||||
},
|
||||
]),
|
||||
) as CustomSectionsData;
|
||||
}
|
||||
|
||||
@@ -113,16 +133,17 @@ export function getOrderedCustomSections(): OrderedCustomSections {
|
||||
* Creates a new custom section by showing a dialog to the user to enter the section name.
|
||||
* If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections.
|
||||
*
|
||||
* @param spaceId The space in which the section is being created. Used to control visibility of the empty section.
|
||||
* @return A promise that resolves to the new section tag if created, or undefined if cancelled.
|
||||
*/
|
||||
export async function createSection(): Promise<string | undefined> {
|
||||
export async function createSection(spaceId: SpaceKey): Promise<string | undefined> {
|
||||
const modal = Modal.createDialog(CreateSectionDialog);
|
||||
|
||||
const [shouldCreateSection, sectionName] = await modal.finished;
|
||||
if (!shouldCreateSection || !sectionName) return undefined;
|
||||
|
||||
const tag: CustomTag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
|
||||
const newSection: CustomSection = { tag, name: sectionName };
|
||||
const newSection: CustomSection = { tag, name: sectionName, spaceId };
|
||||
|
||||
// Save the new section data
|
||||
const sectionData = getCustomSectionData();
|
||||
|
||||
@@ -645,6 +645,10 @@ export class RoomListViewModel
|
||||
};
|
||||
|
||||
public onSectionCreated = (tag: string): void => {
|
||||
// Refresh roomsResult so the new section lands in the same snapshot as the scroll-to.
|
||||
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
|
||||
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
|
||||
this.updateRoomsMap(this.roomsResult);
|
||||
this.updateRoomListData(false, null, tag);
|
||||
this.showToast("section_created");
|
||||
};
|
||||
@@ -694,9 +698,11 @@ function computeSections(
|
||||
const customSections = getCustomSectionData();
|
||||
|
||||
const sections = roomsResult.sections
|
||||
// Only include sections that have rooms or are custom sections (which may be empty but should still be shown)
|
||||
// Only include sections that have rooms, or custom sections that were created in the current space.
|
||||
.filter(
|
||||
(section) => section.rooms.length > 0 || (isCustomSectionTag(section.tag) && customSections[section.tag]),
|
||||
(section) =>
|
||||
section.rooms.length > 0 ||
|
||||
(isCustomSectionTag(section.tag) && customSections[section.tag]?.spaceId === roomsResult.spaceId),
|
||||
)
|
||||
// Remove roomIds for sections that are currently collapsed according to their section header view model
|
||||
.map((section) => ({
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import Modal from "../../../../src/Modal";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import {
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
|
||||
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
|
||||
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import { MetaSpace } from "../../../../src/stores/spaces";
|
||||
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
|
||||
|
||||
describe("section", () => {
|
||||
afterEach(() => {
|
||||
@@ -33,7 +37,9 @@ describe("section", () => {
|
||||
const validEntry = { tag: validTag, name: "Valid" };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
|
||||
// Default: no known spaces
|
||||
jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([]);
|
||||
jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([]);
|
||||
});
|
||||
|
||||
it.each([null, false, 42, "string", []] as const)("returns an empty object when the raw value is %p", (raw) => {
|
||||
@@ -41,12 +47,12 @@ describe("section", () => {
|
||||
expect(getCustomSectionData()).toEqual({});
|
||||
});
|
||||
|
||||
it("returns valid entries and drops invalid ones", () => {
|
||||
it("returns valid entries and drops invalid ones, defaulting spaceId to MetaSpace.Home", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
|
||||
[validTag]: validEntry,
|
||||
[invalidTag]: { tag: "element.io.section.mismatch", name: "Bad" },
|
||||
});
|
||||
expect(getCustomSectionData()).toEqual({ [validTag]: validEntry });
|
||||
expect(getCustomSectionData()).toEqual({ [validTag]: { ...validEntry, spaceId: MetaSpace.Home } });
|
||||
});
|
||||
|
||||
it("drops entries that fail the isValidCustomSection check", () => {
|
||||
@@ -58,13 +64,44 @@ describe("section", () => {
|
||||
});
|
||||
expect(getCustomSectionData()).toEqual({});
|
||||
});
|
||||
|
||||
it("defaults spaceId to MetaSpace.Home when spaceId is missing", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue({ [validTag]: validEntry });
|
||||
expect(getCustomSectionData()[validTag].spaceId).toBe(MetaSpace.Home);
|
||||
});
|
||||
|
||||
it("defaults spaceId to MetaSpace.Home when the stored space does not exist", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
|
||||
[validTag]: { ...validEntry, spaceId: "!gone:server" },
|
||||
});
|
||||
// spacePanelSpaces is empty (default mock), so !gone:server is unknown
|
||||
expect(getCustomSectionData()[validTag].spaceId).toBe(MetaSpace.Home);
|
||||
});
|
||||
|
||||
it("keeps spaceId when the meta-space is enabled", () => {
|
||||
jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([MetaSpace.Home]);
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
|
||||
[validTag]: { ...validEntry, spaceId: MetaSpace.Home },
|
||||
});
|
||||
expect(getCustomSectionData()[validTag].spaceId).toBe(MetaSpace.Home);
|
||||
});
|
||||
|
||||
it("keeps spaceId when the real space room exists", () => {
|
||||
const spaceId = "!space:server";
|
||||
jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([{ roomId: spaceId } as Room]);
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
|
||||
[validTag]: { ...validEntry, spaceId },
|
||||
});
|
||||
expect(getCustomSectionData()[validTag].spaceId).toBe(spaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrderedCustomSections", () => {
|
||||
const tag = "element.io.section.abc";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
|
||||
jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([]);
|
||||
jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when the raw value is not an array", () => {
|
||||
@@ -93,6 +130,8 @@ describe("section", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
|
||||
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
|
||||
jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([]);
|
||||
jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -105,7 +144,7 @@ describe("section", () => {
|
||||
close: jest.fn(),
|
||||
} as any);
|
||||
|
||||
const result = await createSection();
|
||||
const result = await createSection(MetaSpace.Home);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -115,7 +154,7 @@ describe("section", () => {
|
||||
close: jest.fn(),
|
||||
} as any);
|
||||
|
||||
const result = await createSection();
|
||||
const result = await createSection(MetaSpace.Home);
|
||||
expect(result).toMatch(/^element\.io\.section\./);
|
||||
});
|
||||
|
||||
@@ -125,7 +164,7 @@ describe("section", () => {
|
||||
close: jest.fn(),
|
||||
} as any);
|
||||
|
||||
await createSection();
|
||||
await createSection(MetaSpace.Home);
|
||||
expect(createDialogSpy).toHaveBeenCalledWith(CreateSectionDialog);
|
||||
});
|
||||
|
||||
@@ -143,7 +182,7 @@ describe("section", () => {
|
||||
} as any);
|
||||
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
|
||||
|
||||
await createSection();
|
||||
await createSection(MetaSpace.Home);
|
||||
|
||||
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
|
||||
const savedOrder = orderedCall![3] as string[];
|
||||
@@ -152,9 +191,12 @@ describe("section", () => {
|
||||
|
||||
const newTag = savedOrder[1];
|
||||
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
|
||||
const savedSection = (customDataCall![3] as Record<string, { tag: string; name: string }>)[newTag];
|
||||
const savedSection = (customDataCall![3] as Record<string, { tag: string; name: string; spaceId: string }>)[
|
||||
newTag
|
||||
];
|
||||
expect(savedSection.name).toBe("My Section");
|
||||
expect(savedSection.tag).toBe(newTag);
|
||||
expect(savedSection.spaceId).toBe(MetaSpace.Home);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,7 +261,7 @@ describe("section", () => {
|
||||
"RoomList.CustomSectionData",
|
||||
null,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ [tag]: { tag, name: "New Name" } }),
|
||||
expect.objectContaining({ [tag]: expect.objectContaining({ tag, name: "New Name" }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,8 @@ import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { tagRoom } from "../../../src/utils/room/tagRoom";
|
||||
import { getSectionTagForRoom } from "../../../src/utils/room/getSectionTagForRoom";
|
||||
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
|
||||
import { CHATS_TAG, CUSTOM_SECTION_TAG_PREFIX } from "../../../src/stores/room-list-v3/section";
|
||||
import { MetaSpace } from "../../../src/stores/spaces";
|
||||
|
||||
jest.mock("../../../src/utils/room/tagRoom", () => ({
|
||||
tagRoom: jest.fn(),
|
||||
@@ -996,6 +997,73 @@ describe("RoomListViewModel", () => {
|
||||
expect(favSection!.roomIds).toEqual(["!fav1:server"]);
|
||||
});
|
||||
|
||||
describe("custom section visibility by originating space", () => {
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}test-uuid` as const;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([MetaSpace.Home]);
|
||||
jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([
|
||||
mkStubRoom("!space:server", "My Space", matrixClient),
|
||||
]);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
|
||||
if (setting === "feature_room_list_sections") return true;
|
||||
if (setting === "RoomList.CustomSectionData")
|
||||
return {
|
||||
[customTag]: { tag: customTag, name: "My Section", spaceId: "!space:server" },
|
||||
};
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an empty custom section when viewing its originating space", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => {
|
||||
if (setting === "feature_room_list_sections") return true;
|
||||
if (setting === "RoomList.CustomSectionData")
|
||||
return { [customTag]: { tag: customTag, name: "My Section", spaceId: MetaSpace.Home } };
|
||||
return false;
|
||||
});
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: MetaSpace.Home,
|
||||
sections: [
|
||||
{ tag: customTag, rooms: [] },
|
||||
{ tag: CHATS_TAG, rooms: [regularRoom1] },
|
||||
],
|
||||
});
|
||||
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
|
||||
expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides an empty custom section in a different space", () => {
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: MetaSpace.Home,
|
||||
sections: [
|
||||
{ tag: customTag, rooms: [] },
|
||||
{ tag: CHATS_TAG, rooms: [regularRoom1] },
|
||||
],
|
||||
});
|
||||
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
|
||||
expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a non-empty custom section regardless of originating space", () => {
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: MetaSpace.Home,
|
||||
sections: [
|
||||
{ tag: customTag, rooms: [regularRoom1] },
|
||||
{ tag: CHATS_TAG, rooms: [regularRoom2] },
|
||||
],
|
||||
});
|
||||
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
|
||||
expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Collapse/expand all sections", () => {
|
||||
it("should collapse all sections when Action.RoomListCollapseAllSections is dispatched", async () => {
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
|
||||
+2
@@ -264,6 +264,8 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
|
||||
return (
|
||||
<RoomListSectionHeaderView
|
||||
// Stable key per section avoids a @dnd-kit registration race when a new section is inserted.
|
||||
key={headerId}
|
||||
vm={sectionHeaderVM}
|
||||
isFocused={isFocused}
|
||||
onFocus={onFocus}
|
||||
|
||||
Reference in New Issue
Block a user