Room list: add drag and drop of sections to reorder them (#33606)

* feat(rls): centralize room-list section ordering via getOrderedSections

Introduce a single source of truth for the ordered list of section
tags (defaults + custom) in section.ts. RoomListStoreV3 no longer
hard-codes the default tag order — it asks section.ts.

* feat(vm): add section reorder logic to RoomListStoreV3 and view model

* refactor(sc): extract RoomListSectionHeaderContent from RoomListSectionHeaderView

* feat(sc): add drag-and-drop reordering for room-list sections

* test(sc): update existing tests

* test: add new tests

* test(sc): add snapshot test for overlay

* test(e2e): add playright test for dnd of sections

* feat: tweak opacity

* fix: section detection when dragged

* feat: use relative position in section order

* chore: move style to room list section header view

* test: add e2e test for moving section before another

* feat: make favourite and low priority no draggable

* test: remove deprecated e2e test

* fix: type correctly dnd provider and hooks

* fix: use String instead of casting to string

* fix: wrong a11y attributes on section header

* test: fix keyboard navigation e2e tests

* feat: use custom a11y announcement for dnd

* fix: add aria-hidden to the overlay

* chore: remove duplicated code in a11Y announcement

* fix: increase keyboard drag offset

* fix: tests

* fix: virtuoso computed item key

* fix: aria-expanded double annoncement why dragging

* fix: re-add screen reader instructions

* chore: remove unused import

* fix: reduce keyboard drag offset

* fix: improve text readback on unread section

* feat: use a custom a11y plugin instead of buitin a11y plugin

* chore: formatting

* test: fix incorrect tests

* chore: use randomUUID

* fix: try to make test working

* fix: put back mock

* fix: circular import

* Revert "fix: circular import"

This reverts commit 6c69313ade9ed2ab17a4622c692b435fbdb94ad2.

* chore: add @dnd-kit/abstract to optimizeDeps.include

* test: fix e2e tests

* fix: disable interaction with section when dragging

* fix: be more explicit if the section will be dropped before or after

* fix: add info that space is dropping too

* fix: scroll to dropped section

* test: fix virtualized room list test

* chore: update lang

* chore: fix dead code analyze

* test: add provider section story

* fix: lang

* fix: again lang...

* fix: improve voice over on chrome

* test: upate snapshot

* fix: add readback when a section is over a non droppable element
This commit is contained in:
Florian Duros
2026-06-26 10:00:44 +00:00
committed by GitHub
parent 4e3f47b948
commit 1f83ba4bbb
40 changed files with 2064 additions and 342 deletions
@@ -9,7 +9,15 @@ import { type Page } from "@playwright/test";
import { closeReleaseAnnouncement, rejectToast } from "@element-hq/element-web-playwright-common";
import { expect, test } from "../../../element-web-test";
import { assertRoomInSection, dragRoomToSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
import {
assertRoomInSection,
assertSectionsOrder,
dragRoomToSection,
dragSectionToSection,
getRoomList,
getRoomListHeader,
getSectionHeader,
} from "./utils";
test.describe("Room list custom sections", () => {
test.use({
@@ -287,6 +295,33 @@ test.describe("Room list custom sections", () => {
});
});
test.describe("Section reordering via dnd", () => {
test("should reorder custom sections via dnd", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
await createCustomSection(page, "Personal");
// Default placement: custom sections sit at the top of Chats
await assertSectionsOrder(page, ["Work", "Personal", "Chats"]);
// Moves Work after Chats
await dragSectionToSection(page, "Work", "Chats");
await assertSectionsOrder(page, ["Personal", "Chats", "Work"]);
});
test("should insert a section before the target when dragging up", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
await createCustomSection(page, "Personal");
await assertSectionsOrder(page, ["Work", "Personal", "Chats"]);
// Personal sits below Work, so dragging it onto Work inserts it before Work.
await dragSectionToSection(page, "Personal", "Work");
await assertSectionsOrder(page, ["Personal", "Work", "Chats"]);
});
});
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" });
@@ -30,8 +30,8 @@ export function getRoomListHeader(page: Page): Locator {
* @param isUnread Whether to look for the unread version of the section header
*/
export function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator {
return getRoomList(page).getByRole("gridcell", {
name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`,
return getRoomList(page).getByRole("button", {
name: isUnread ? `Toggle ${sectionName} section with unread rooms` : `Toggle ${sectionName} section`,
});
}
@@ -106,6 +106,60 @@ async function getBoundingBox(
return box;
}
/**
* Drag and drop a section header onto another section header. The dragged section is moved
* relative to the target: dropped before the target when dragging up, after the target when
* dragging down. Because the dnd start handler collapses every section, the layout changes
* once the drag activates — so the target position is recomputed after activation rather
* than cached up-front.
*/
export async function dragSectionToSection(
page: Page,
sourceSectionName: string,
targetSectionName: string,
): Promise<void> {
const source = getSectionHeader(page, sourceSectionName);
const sourceBox = await source.boundingBox();
if (!sourceBox) throw new Error(`Source section ${sourceSectionName} has no bounding box`);
const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;
// Grab the section header
await page.mouse.move(sourceX, sourceY);
await page.mouse.down();
// Move past the 5px PointerSensor activation threshold so the drag actually starts.
// This triggers onSectionDragStart, which collapses all sections.
await page.mouse.move(sourceX, sourceY + 10, { steps: 5 });
// Re-query the target now that the layout has reflowed.
const target = getSectionHeader(page, targetSectionName);
const targetBox = await target.boundingBox();
if (!targetBox) throw new Error(`Target section ${targetSectionName} has no bounding box`);
const targetY = targetBox.y + targetBox.height / 2;
// Move onto the (possibly relocated) target section header and drop.
await page.mouse.move(sourceX, targetY, { steps: 10 });
await page.mouse.up();
}
/**
* Assert the displayed section headers appear in the given top-to-bottom order.
*/
export async function assertSectionsOrder(page: Page, expectedOrder: string[]): Promise<void> {
const positions: Array<{ name: string; y: number }> = [];
for (const name of expectedOrder) {
const header = getSectionHeader(page, name);
await expect(header).toBeVisible();
const box = await header.boundingBox();
if (!box) throw new Error(`Section ${name} has no bounding box`);
positions.push({ name, y: box.y });
}
for (let i = 1; i < positions.length; i++) {
expect(positions[i].y).toBeGreaterThan(positions[i - 1].y);
}
}
/**
* Get the primary filters container
* @param page
+2 -2
View File
@@ -53,7 +53,7 @@ import InviteRulesConfigController from "./controllers/InviteRulesConfigControll
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
import { type OrderedCustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
import { type ReorderableSection, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
import { type NotificationSound } from "../Notifier.ts";
import VideoRoomsBetaImage from "../../res/img/betas/video_rooms.png";
@@ -371,7 +371,7 @@ export interface Settings {
"blockInvites": IBaseSetting<boolean>;
"Developer.elementCallUrl": IBaseSetting<string>;
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
"RoomList.OrderedCustomSections": IBaseSetting<OrderedCustomSections>;
"RoomList.OrderedCustomSections": IBaseSetting<ReorderableSection[]>;
}
export type SettingKey = keyof Settings;
@@ -36,11 +36,18 @@ import { UnreadSorter } from "./skip-list/sorters/UnreadSorter";
import { getChangedOverrideRoomMutePushRules } from "./utils";
import { isRoomVisible } from "./isRoomVisible";
import { RoomSkipList } from "./skip-list/RoomSkipList";
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 { CHATS_TAG, createSection, deleteSection, editSection, getOrderedCustomSections } from "./section";
import {
CHATS_TAG,
createSection,
deleteSection,
editSection,
getOrderedReorderableSections,
reorderSection,
} from "./section";
import { DefaultTagID } from "./skip-list/tag";
/**
* These are the filters passed to the room skip list.
@@ -513,6 +520,15 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
this.scheduleEmit();
}
/**
* Reorder custom sections by moving sourceTag to the position of targetTag.
* @param sourceTag The tag of the section to move
* @param targetTag The tag of the section to move to
*/
public async reorderSection(sourceTag: string, targetTag: string): Promise<void> {
await reorderSection(sourceTag, targetTag);
}
/**
* Returns the ordered section tags.
*/
@@ -524,8 +540,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
* Load the custom sections from the settings store and update the sorted tags.
*/
private loadCustomSections(): void {
const orderedCustomSections = getOrderedCustomSections();
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
// Favourite is pinned to the top and LowPriority to the bottom. Everything in between
// (custom sections + Chats) is user-reorderable.
const reorderable = getOrderedReorderableSections();
this.sortedTags = [DefaultTagID.Favourite, ...reorderable, DefaultTagID.LowPriority];
}
}
+67 -8
View File
@@ -82,11 +82,25 @@ function isValidCustomSection(value: unknown): value is CustomSection {
* The custom sections data is stored as a record in the settings, where the key is the section tag and the value is the section data (name and tag).
*/
export type CustomSectionsData = Record<CustomTag, CustomSection>;
/**
* Ordered list of custom section tags.
*/
export type OrderedCustomSections = CustomTag[];
/**
* Tags that can be reordered relative to each other (everything except Favourite and LowPriority,
* which are pinned to the top and bottom respectively).
*/
export type ReorderableSection = CustomTag | typeof CHATS_TAG;
/**
* Returns true if the given tag is a tag that can be reordered (custom section or the Chats tag).
*/
function isReorderableSection(tag: string, customData: CustomSectionsData): tag is ReorderableSection {
return tag === CHATS_TAG || (isCustomSectionTag(tag) && tag in customData);
}
/**
* Returns true if the given space key corresponds to an enabled meta-space or a known top-level space room.
*/
@@ -121,12 +135,31 @@ export function getCustomSectionData(): CustomSectionsData {
/**
* Retrieves the ordered list of custom section tags from the settings.
* If the settings contain tags that are not present in the custom section data, they will be filtered out and the settings will be updated to remove the unknown tags.
*
* @knipignore - exported for tests
*/
export function getOrderedCustomSections(): OrderedCustomSections {
const sectionData = getCustomSectionData();
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const orderedSections: OrderedCustomSections = Array.isArray(rawValue) ? rawValue : [];
return orderedSections.filter((tag) => tag in sectionData);
const orderedSections = Array.isArray(rawValue) ? rawValue : [];
return orderedSections.filter((tag): tag is CustomTag => isCustomSectionTag(tag) && tag in sectionData);
}
/**
* Returns the ordered list of reorderable section tags (custom sections + the Chats tag).
* Favourite and LowPriority are not included they are pinned at the top and bottom respectively.
*
* If `CHATS_TAG` is missing from the stored order (e.g. legacy data or a freshly created custom
* section), it is appended at the end so that custom sections sit above Chats by default.
*/
export function getOrderedReorderableSections(): ReorderableSection[] {
const sectionData = getCustomSectionData();
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const stored = Array.isArray(rawValue) ? rawValue : [];
const result = stored.filter((tag): tag is ReorderableSection => isReorderableSection(tag, sectionData));
if (!result.includes(CHATS_TAG)) result.push(CHATS_TAG);
return result;
}
/**
@@ -150,10 +183,12 @@ export async function createSection(spaceId: SpaceKey): Promise<string | undefin
sectionData[tag] = newSection;
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
// Add the new section to the ordered list of sections
const orderedSections = getOrderedCustomSections();
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
// Add the new section to the ordered list of reorderable sections, just before CHATS_TAG
// so that newly-created sections appear above Chats by default.
const reorderable = getOrderedReorderableSections();
const chatsIndex = reorderable.indexOf(CHATS_TAG);
reorderable.splice(chatsIndex === -1 ? reorderable.length : chatsIndex, 0, tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, reorderable);
return tag;
}
@@ -204,11 +239,35 @@ export async function deleteSection(tag: string, isEmpty: boolean): Promise<void
const [shouldRemoveSection] = await modal.finished;
if (!shouldRemoveSection) return;
// Remove the section from the ordered list of sections
const newOrderedSections = getOrderedCustomSections().filter((sectionTag) => sectionTag !== tag);
// Remove the section from the ordered list of reorderable sections (preserves CHATS_TAG position)
const newOrderedSections = getOrderedReorderableSections().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);
}
/**
* Reorders sections by moving sourceTag relative to targetTag within the set of reorderable
* sections (custom sections and the Chats tag). Favourite and LowPriority are not reorderable
* and are rejected as either source or target.
*
* If the source was below the target, it is inserted before the target; otherwise after.
* @param sourceTag - The tag of the section to move.
* @param targetTag - The tag of the section to move relative to.
*/
export async function reorderSection(sourceTag: string, targetTag: string): Promise<void> {
const ordered = getOrderedReorderableSections();
const fromIndex = ordered.indexOf(sourceTag as ReorderableSection);
if (fromIndex === -1 || !ordered.includes(targetTag as ReorderableSection) || sourceTag === targetTag) return;
const toIndex = ordered.indexOf(targetTag as ReorderableSection);
const insertBefore = fromIndex > toIndex;
ordered.splice(fromIndex, 1);
const newToIndex = ordered.indexOf(targetTag as ReorderableSection);
ordered.splice(insertBefore ? newToIndex : newToIndex + 1, 0, sourceTag as ReorderableSection);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, ordered);
}
@@ -19,7 +19,12 @@ import { NotificationStateEvents } from "../../stores/notifications/Notification
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag } from "../../stores/room-list-v3/section";
import {
CHATS_TAG,
getCustomSectionData,
isCustomSectionTag,
isDefaultSectionTag,
} from "../../stores/room-list-v3/section";
import PosthogTrackers from "../../PosthogTrackers";
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { type Call, CallEvent } from "../../models/Call";
@@ -62,6 +67,7 @@ export class RoomListSectionHeaderViewModel
isExpanded: true,
isUnread: false,
displaySectionMenu: !isDefaultSection,
canBeReordered: !isDefaultSection || props.tag === CHATS_TAG,
});
const sectionWatherRef = SettingsStore.watchSetting("RoomList.CustomSectionData", null, () =>
this.onCustomSectionDataChange(),
@@ -94,6 +94,11 @@ export class RoomListViewModel
private roomsMap = new Map<string, Room>();
// Don't clear section vm because we want to keep the expand/collapse state even during space changes.
private readonly roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
/**
* When dragging sections, we want to temporarily expand all sections to make it easier to move rooms between sections.
* This map stores the original expansion state of each section before the drag starts, so we can restore it after the drag ends.
*/
private readonly savedExpansionStates = new Map<string, boolean>();
/**
* Reference to the currently displayed toast, used to automatically close the toast after a timeout.
@@ -663,6 +668,33 @@ export class RoomListViewModel
}, 15 * 1000);
}
public changeSectionOrder = async (sourceTag: string, targetTag: string): Promise<void> => {
await RoomListStoreV3.instance.reorderSection(sourceTag, targetTag);
// Scroll to the section after it moved
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
this.updateRoomsMap(this.roomsResult);
this.updateRoomListData(false, null, sourceTag);
};
public onSectionDragStart = (): void => {
this.savedExpansionStates.clear();
for (const [tag, sectionVM] of this.roomSectionHeaderViewModels) {
this.savedExpansionStates.set(tag, sectionVM.isExpanded);
sectionVM.isExpanded = false;
}
this.updateRoomListData();
};
public onSectionDragEnd = (): void => {
for (const [tag, expanded] of this.savedExpansionStates) {
const sectionVM = this.roomSectionHeaderViewModels.get(tag);
if (sectionVM) sectionVM.isExpanded = expanded;
}
this.savedExpansionStates.clear();
this.updateRoomListData();
};
public changeRoomSection = (roomId: string, tag: string): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
@@ -19,6 +19,7 @@ import {
CHATS_TAG,
CUSTOM_SECTION_TAG_PREFIX,
isSectionTag,
reorderSection,
} from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
@@ -328,13 +329,129 @@ describe("section", () => {
await deleteSection(tag, false);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
expect(orderedCall![3]).toEqual([otherTag]);
// CHATS_TAG is appended because the stored order didn't include it (legacy default position).
expect(orderedCall![3]).toEqual([otherTag, CHATS_TAG]);
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
expect(customDataCall![3]).not.toHaveProperty(tag);
});
});
describe("reorderSection", () => {
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc`;
const customTag2 = `${CUSTOM_SECTION_TAG_PREFIX}def`;
function mockSettings(
orderedTags: string[],
customData: Record<string, { tag: string; name: string }> = {},
): void {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return orderedTags;
if (setting === "RoomList.CustomSectionData") return customData;
return null;
});
}
it.each<{
description: string;
initial: string[];
customData: Record<string, { tag: string; name: string }>;
source: string;
target: string;
expected: string[];
}>([
{
description: "a custom section after another custom section",
initial: [customTag, customTag2],
customData: {
[customTag]: { tag: customTag, name: "A" },
[customTag2]: { tag: customTag2, name: "B" },
},
source: customTag,
target: customTag2,
expected: [customTag2, customTag, CHATS_TAG],
},
{
description: "a custom section before another when dragging up",
initial: [customTag2, customTag],
customData: {
[customTag]: { tag: customTag, name: "A" },
[customTag2]: { tag: customTag2, name: "B" },
},
source: customTag,
target: customTag2,
expected: [customTag, customTag2, CHATS_TAG],
},
{
description: "a custom section past the Chats tag",
initial: [customTag, customTag2, CHATS_TAG],
customData: {
[customTag]: { tag: customTag, name: "A" },
[customTag2]: { tag: customTag2, name: "B" },
},
source: customTag,
target: CHATS_TAG,
expected: [customTag2, CHATS_TAG, customTag],
},
{
description: "the Chats tag above a custom section",
initial: [customTag, customTag2, CHATS_TAG],
customData: {
[customTag]: { tag: customTag, name: "A" },
[customTag2]: { tag: customTag2, name: "B" },
},
source: CHATS_TAG,
target: customTag,
expected: [CHATS_TAG, customTag, customTag2],
},
])(
"moves $description and saves the new order at ACCOUNT level",
async ({ initial, customData, source, target, expected }) => {
mockSettings(initial, customData);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await reorderSection(source, target);
expect(setValueSpy).toHaveBeenCalledWith(
"RoomList.OrderedCustomSections",
null,
expect.anything(),
expected,
);
},
);
it.each([
{
description: "source and target are the same",
source: customTag,
target: customTag,
},
{
description: "source custom section is not in the ordered list",
source: `${CUSTOM_SECTION_TAG_PREFIX}unknown`,
target: customTag,
},
{
description: "target custom section is not in the ordered list",
source: customTag,
target: `${CUSTOM_SECTION_TAG_PREFIX}unknown`,
},
{
description: "source is a default section",
source: DefaultTagID.Favourite,
target: customTag,
},
])("does nothing when $description", async ({ source, target }) => {
mockSettings([customTag], { [customTag]: { tag: customTag, name: "A" } });
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await reorderSection(source, target);
expect(setValueSpy).not.toHaveBeenCalled();
});
});
describe("isDefaultSectionTag", () => {
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG])("returns true for %s", (tag) => {
expect(isDefaultSectionTag(tag)).toBe(true);
@@ -117,6 +117,23 @@ describe("RoomListSectionHeaderViewModel", () => {
});
});
describe("canBeReordered", () => {
it.each([
[DefaultTagID.Favourite, false],
[DefaultTagID.LowPriority, false],
[CHATS_TAG, true],
["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().canBeReordered).toBe(expected);
});
});
describe("onCustomSectionDataChange", () => {
let watchCallback: () => void;
@@ -1178,6 +1178,108 @@ describe("RoomListViewModel", () => {
expect(snapshot.sections[0].roomIds[0]).toBe("!fav1:server");
expect(snapshot.roomListState.activeRoomIndex).toBe(0);
});
describe("Drag and drop", () => {
beforeEach(() => {
viewModel = new RoomListViewModel({ client: matrixClient });
// Ensure section header VMs are created before tests that interact with them
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
viewModel.getSectionHeaderViewModel(CHATS_TAG);
viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority);
});
it("should delegate changeSectionOrder to RoomListStoreV3.reorderSection", async () => {
const reorderSpy = jest
.spyOn(RoomListStoreV3.instance, "reorderSection")
.mockResolvedValue(undefined);
await viewModel.changeSectionOrder(DefaultTagID.Favourite, CHATS_TAG);
expect(reorderSpy).toHaveBeenCalledWith(DefaultTagID.Favourite, CHATS_TAG);
});
it("should scroll the moved section back into view after reordering", async () => {
jest.spyOn(RoomListStoreV3.instance, "reorderSection").mockResolvedValue(undefined);
await viewModel.changeSectionOrder(DefaultTagID.Favourite, CHATS_TAG);
expect(viewModel.getSnapshot().roomListState.scrollToSectionTag).toBe(DefaultTagID.Favourite);
});
it("should collapse every section on drag start", () => {
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(true);
viewModel.onSectionDragStart();
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(false);
for (const section of viewModel.getSnapshot().sections) {
expect(section.roomIds).toEqual([]);
}
});
it("should restore the pre-drag expansion state on drag end", () => {
// Collapse Favourite before the drag; other sections remain expanded
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).onClick();
viewModel.onSectionDragStart();
viewModel.onSectionDragEnd();
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(true);
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
const snapshot = viewModel.getSnapshot();
expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([]);
expect(snapshot.sections.find((s) => s.id === CHATS_TAG)!.roomIds).toEqual([
"!reg1:server",
"!reg2:server",
]);
expect(snapshot.sections.find((s) => s.id === DefaultTagID.LowPriority)!.roomIds).toEqual([
"!low1:server",
]);
});
it("should re-snapshot expansion state on each drag start", () => {
// First cycle: Favourite is collapsed before the drag
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).onClick();
viewModel.onSectionDragStart();
viewModel.onSectionDragEnd();
// Between cycles: collapse CHATS_TAG as well
viewModel.getSectionHeaderViewModel(CHATS_TAG).onClick();
viewModel.onSectionDragStart();
viewModel.onSectionDragEnd();
// The second drag end must restore the state captured at the second drag start
// (Favourite collapsed, CHATS_TAG collapsed, LowPriority expanded), not the first cycle's snapshot.
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
});
it("should be a no-op when drag end is called without drag start", () => {
viewModel.onSectionDragEnd();
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(true);
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(true);
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
const snapshot = viewModel.getSnapshot();
expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([
"!fav1:server",
"!fav2:server",
]);
expect(snapshot.sections.find((s) => s.id === CHATS_TAG)!.roomIds).toEqual([
"!reg1:server",
"!reg2:server",
]);
expect(snapshot.sections.find((s) => s.id === DefaultTagID.LowPriority)!.roomIds).toEqual([
"!low1:server",
]);
});
});
});
});