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:
+36
-1
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user