Room list: drag and drop rooms into sections (#33366)
* chore: add dnd kit deps * chore: patch dnd kit to fix ts error * feat(sc): add drag-and-drop to room list item and wrapper * feat(sc): make the room list header a droppable element * feat(sc): add dnd to room list view * feat(tags): can tag room as CHAT * feat(vm): implement `changeRoomSection` * feat(sc): disable dragging in flat list * fix: disable keyboard navigation when dragging element * test(sc): update snapshots * test(sc): add dnd test * test(e2e): add e2e tests for room drag and drop * test(vm): add tests for changeRoomSection * fix: remove focus visible when dropping with the mouse * test(playwright): update existing screenshots * chore(sc): move numbers out of main build The Ew RecorderWorklet imports shared component bundle. However if the bundle uses some deps using document/window which, the worklet will not work. The solution is to put the used functions into a separate bundle. * doc(sc): add subpath import into README * doc: typo barrel/bundle * test: improve test expect * refactor: add utils to section tag * fix: incorrect check in tagRoom * fix: add doc about dndkit tunning
This commit is contained in:
+1
-17
@@ -8,7 +8,7 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "../../../element-web-test";
|
||||
import { getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
|
||||
import { assertRoomInSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
|
||||
|
||||
test.describe("Room list custom sections", () => {
|
||||
test.use({
|
||||
@@ -40,22 +40,6 @@ test.describe("Room list custom sections", () => {
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
|
||||
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
|
||||
* Verifies that the closest preceding aria-level=1 row is the expected section header.
|
||||
*/
|
||||
async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
|
||||
const roomList = getRoomList(page);
|
||||
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
|
||||
// Room row must be at aria-level=2 (i.e. inside a section)
|
||||
await expect(roomRow).toHaveAttribute("aria-level", "2");
|
||||
// The closest preceding aria-level=1 row must be the expected section header.
|
||||
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
|
||||
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
|
||||
await expect(closestSectionHeader).toContainText(sectionName);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
// The notification toast is displayed above the search section
|
||||
await app.closeNotificationToast();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test } from "../../../element-web-test";
|
||||
import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
|
||||
import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
|
||||
|
||||
test.describe("Room list sections", () => {
|
||||
test.use({
|
||||
@@ -182,6 +182,37 @@ test.describe("Room list sections", () => {
|
||||
roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||
await expect(roomItem).toBeVisible();
|
||||
});
|
||||
|
||||
test("should move a room from Chats to Favourites when using dnd", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
|
||||
const favouriteId = await app.client.createRoom({ name: "favourite room" });
|
||||
await app.client.evaluate(async (client, roomId) => {
|
||||
await client.setRoomTag(roomId, "m.favourite");
|
||||
}, favouriteId);
|
||||
|
||||
await dragRoomToSection(page, "my room", "Favourites");
|
||||
await assertRoomInSection(page, "Favourites", "my room");
|
||||
});
|
||||
|
||||
test("should move a room from Favourites to Chats when using dnd", async ({ page, app }) => {
|
||||
const favouriteId = await app.client.createRoom({ name: "my room" });
|
||||
await app.client.evaluate(async (client, roomId) => {
|
||||
await client.setRoomTag(roomId, "m.favourite");
|
||||
}, favouriteId);
|
||||
|
||||
// Create a second favourite room to ensure we stay in section mode (not flat list)
|
||||
const favouriteId2 = await app.client.createRoom({ name: "favourite room" });
|
||||
await app.client.evaluate(async (client, roomId) => {
|
||||
await client.setRoomTag(roomId, "m.favourite");
|
||||
}, favouriteId2);
|
||||
|
||||
// Ensure the Chats section is visible by creating a room in it
|
||||
await app.client.createRoom({ name: "room in chats" });
|
||||
|
||||
await dragRoomToSection(page, "my room", "Chats");
|
||||
await assertRoomInSection(page, "Chats", "my room");
|
||||
});
|
||||
});
|
||||
|
||||
test("should show unread indicator on section header", async ({ page, app, bot }) => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the room list
|
||||
@@ -35,6 +35,49 @@ export function getSectionHeader(page: Page, sectionName: string, isUnread = fal
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
|
||||
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
|
||||
* Verifies that the closest preceding aria-level=1 row is the expected section header.
|
||||
*/
|
||||
export async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
|
||||
const roomList = getRoomList(page);
|
||||
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
|
||||
// Room row must be at aria-level=2 (i.e. inside a section)
|
||||
await expect(roomRow).toHaveAttribute("aria-level", "2");
|
||||
// The closest preceding aria-level=1 row must be the expected section header.
|
||||
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
|
||||
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
|
||||
await expect(closestSectionHeader).toContainText(sectionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag and drop a room row onto a section header
|
||||
* @param page
|
||||
* @param roomName
|
||||
* @param sectionName
|
||||
*/
|
||||
export async function dragRoomToSection(page: Page, roomName: string, sectionName: string): Promise<void> {
|
||||
const sourceRow = getRoomList(page).getByRole("row", { name: `Open room ${roomName}` });
|
||||
const source = sourceRow.locator("button").first();
|
||||
const target = getSectionHeader(page, sectionName);
|
||||
|
||||
const sourceBox = await source.boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
|
||||
const sourceX = sourceBox.x + sourceBox.width / 2;
|
||||
const sourceY = sourceBox.y + sourceBox.height / 2;
|
||||
const targetY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
// Grab the room
|
||||
await page.mouse.move(sourceX, sourceY);
|
||||
await page.mouse.down();
|
||||
// Move the room on the section header
|
||||
await page.mouse.move(sourceX, targetY, { steps: 10 });
|
||||
// Drop the room
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary filters container
|
||||
* @param page
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { percentageOf } from "@element-hq/web-shared-components";
|
||||
import { percentageOf } from "@element-hq/web-shared-components/numbers";
|
||||
|
||||
import { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts";
|
||||
|
||||
|
||||
@@ -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, deleteSection, editSection } from "./section";
|
||||
import { CHATS_TAG, createSection, deleteSection, editSection } from "./section";
|
||||
|
||||
/**
|
||||
* These are the filters passed to the room skip list.
|
||||
@@ -86,12 +86,6 @@ export interface Section {
|
||||
rooms: Room[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A synthetic tag used to represent the "Chats" section, which contains
|
||||
* every room that does not belong to any other explicit tag section.
|
||||
*/
|
||||
export const CHATS_TAG = "chats";
|
||||
|
||||
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
||||
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
||||
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
||||
|
||||
@@ -12,9 +12,16 @@ import SettingsStore from "../../settings/SettingsStore";
|
||||
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";
|
||||
|
||||
type Tag = string;
|
||||
|
||||
/**
|
||||
* A synthetic tag used to represent the "Chats" section, which contains
|
||||
* every room that does not belong to any other explicit tag section.
|
||||
*/
|
||||
export const CHATS_TAG = "chats";
|
||||
|
||||
/**
|
||||
* Prefix for custom section tags.
|
||||
*/
|
||||
@@ -29,6 +36,24 @@ export function isCustomSectionTag(tag: string): boolean {
|
||||
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given tag is a default section tag.
|
||||
* @param tagId - The tag to check.
|
||||
* @returns True if the tag is a default section tag, false otherwise.
|
||||
*/
|
||||
export function isDefaultSectionTag(tagId: TagID): boolean {
|
||||
return tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority || tagId === CHATS_TAG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given tag is a section tag.
|
||||
* @param tagId - The tag to check.
|
||||
* @returns True if the tag is a section tag, false otherwise.
|
||||
*/
|
||||
export function isSectionTag(tagId: TagID): boolean {
|
||||
return isCustomSectionTag(tagId) || isDefaultSectionTag(tagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components";
|
||||
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components/numbers";
|
||||
|
||||
/**
|
||||
* Quickly resample an array to have less/more data points. If an input which is larger
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { type TagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import { getTagsForRoom } from "./getTagsForRoom";
|
||||
import { isSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
/**
|
||||
* Get the section tag for a given room.
|
||||
* @param room The room to get the section tag for.
|
||||
* @returns The section tag ID or null if none found.
|
||||
*/
|
||||
export function getSectionTagForRoom(room: Room): TagID | null {
|
||||
return getTagsForRoom(room).find((t) => isSectionTag(t)) ?? null;
|
||||
}
|
||||
@@ -9,11 +9,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import { type TagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import RoomListActions from "../../actions/RoomListActions";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { getTagsForRoom } from "./getTagsForRoom";
|
||||
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
||||
import { CHATS_TAG, isSectionTag } from "../../stores/room-list-v3/section";
|
||||
import { getSectionTagForRoom } from "./getSectionTagForRoom";
|
||||
|
||||
/**
|
||||
* Toggle tag for a given room.
|
||||
@@ -23,19 +23,19 @@ import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
||||
* @param tagId The tag to invert
|
||||
*/
|
||||
export function tagRoom(room: Room, tagId: TagID): void {
|
||||
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
|
||||
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
|
||||
const isChatTag = tagId === CHATS_TAG;
|
||||
const tag = isChatTag ? null : tagId;
|
||||
|
||||
if (!isSectionTag(tagId)) {
|
||||
logger.warn(`Unexpected tag ${tag} applied to ${room.roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
|
||||
const currentSectionTag =
|
||||
getTagsForRoom(room).find(
|
||||
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
|
||||
) ?? null;
|
||||
const currentSectionTag = getSectionTagForRoom(room);
|
||||
|
||||
const isApplied = currentSectionTag === tagId;
|
||||
const isApplied = currentSectionTag === tag;
|
||||
const removeTag = currentSectionTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
const addTag = isApplied ? null : tag;
|
||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||
}
|
||||
|
||||
@@ -38,8 +38,9 @@ import { Action } from "../../dispatcher/actions";
|
||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { type Call, CallEvent } from "../../models/Call";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
interface RoomItemProps {
|
||||
room: Room;
|
||||
@@ -429,9 +430,7 @@ export class RoomListItemViewModel
|
||||
RoomListStoreV3.instance.orderedSectionTags
|
||||
// Exclude the Chats because the user toggle the other sections to move rooms in and out of the Chats section.
|
||||
// Also exclude the default sections because they are available as toggles in the main context menu, and we don't want them to be duplicated in the "Move to section" submenu.
|
||||
.filter(
|
||||
(tag) => tag !== CHATS_TAG && tag !== DefaultTagID.Favourite && tag !== DefaultTagID.LowPriority,
|
||||
)
|
||||
.filter((tag) => !isDefaultSectionTag(tag))
|
||||
.map((tag) => ({
|
||||
tag,
|
||||
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
|
||||
|
||||
@@ -16,8 +16,8 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif
|
||||
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";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
interface RoomListSectionHeaderViewModelProps {
|
||||
tag: string;
|
||||
@@ -45,8 +45,7 @@ export class RoomListSectionHeaderViewModel
|
||||
private readonly expandedBySpace = new Map<string, boolean>();
|
||||
|
||||
public constructor(props: RoomListSectionHeaderViewModelProps) {
|
||||
const isDefaultSection =
|
||||
props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG;
|
||||
const isDefaultSection = isDefaultSectionTag(props.tag);
|
||||
super(props, {
|
||||
id: props.tag,
|
||||
title: props.title,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
_t,
|
||||
type ToastType,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import dispatcher from "../../dispatcher/dispatcher";
|
||||
@@ -24,7 +24,6 @@ import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"
|
||||
import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
|
||||
import SpaceStore from "../../stores/spaces/SpaceStore";
|
||||
import RoomListStoreV3, {
|
||||
CHATS_TAG,
|
||||
RoomListStoreV3Event,
|
||||
type RoomsResult,
|
||||
type Section,
|
||||
@@ -38,6 +37,9 @@ import { keepIfSame } from "../../utils/keepIfSame";
|
||||
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { tagRoom } from "../../utils/room/tagRoom";
|
||||
import { getSectionTagForRoom } from "../../utils/room/getSectionTagForRoom";
|
||||
import { CHATS_TAG } from "../../stores/room-list-v3/section";
|
||||
|
||||
/**
|
||||
* Tracks the position of the active room within a specific section.
|
||||
@@ -667,6 +669,17 @@ export class RoomListViewModel
|
||||
this.closeToast();
|
||||
}, 15 * 1000);
|
||||
}
|
||||
|
||||
public changeRoomSection = (roomId: string, tag: string): void => {
|
||||
const room = this.props.client.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const currentTag = getSectionTagForRoom(room);
|
||||
// Room is already in the section
|
||||
if (currentTag === tag) return;
|
||||
|
||||
tagRoom(room, tag);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,6 @@ import { mocked } from "jest-mock";
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
|
||||
import {
|
||||
CHATS_TAG,
|
||||
LISTS_UPDATE_EVENT,
|
||||
SECTION_CREATED_EVENT,
|
||||
RoomListStoreV3Class,
|
||||
@@ -37,6 +36,7 @@ import * as utils from "../../../../src/utils/notifications";
|
||||
import * as utilsRLS from "../../../../src/stores/room-list-v3/utils.ts";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel.ts";
|
||||
import { CHATS_TAG } from "../../../../src/stores/room-list-v3/section";
|
||||
|
||||
describe("RoomListStoreV3", () => {
|
||||
async function getRoomListStore() {
|
||||
|
||||
@@ -7,9 +7,18 @@
|
||||
|
||||
import Modal from "../../../../src/Modal";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import { createSection, editSection, deleteSection } from "../../../../src/stores/room-list-v3/section";
|
||||
import {
|
||||
CHATS_TAG,
|
||||
CUSTOM_SECTION_TAG_PREFIX,
|
||||
createSection,
|
||||
editSection,
|
||||
deleteSection,
|
||||
isDefaultSectionTag,
|
||||
isSectionTag,
|
||||
} from "../../../../src/stores/room-list-v3/section";
|
||||
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";
|
||||
|
||||
describe("section", () => {
|
||||
afterEach(() => {
|
||||
@@ -203,4 +212,27 @@ describe("section", () => {
|
||||
expect(customDataCall![3]).not.toHaveProperty(tag);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDefaultSectionTag", () => {
|
||||
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG])("returns true for %s", (tag) => {
|
||||
expect(isDefaultSectionTag(tag)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([DefaultTagID.Invite, "some.random.tag"])("returns false for %s", (tag) => {
|
||||
expect(isDefaultSectionTag(tag)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSectionTag", () => {
|
||||
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG, `${CUSTOM_SECTION_TAG_PREFIX}some-uuid`])(
|
||||
"returns true for %s",
|
||||
(tag) => {
|
||||
expect(isSectionTag(tag)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([DefaultTagID.Invite, "some.random.tag"])("returns false for %s", (tag) => {
|
||||
expect(isSectionTag(tag)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
|
||||
import { getSectionTagForRoom } from "../../../../src/utils/room/getSectionTagForRoom";
|
||||
import { getTagsForRoom } from "../../../../src/utils/room/getTagsForRoom";
|
||||
|
||||
jest.mock("../../../../src/utils/room/getTagsForRoom");
|
||||
|
||||
const mockGetTagsForRoom = jest.mocked(getTagsForRoom);
|
||||
|
||||
describe("getSectionTagForRoom", () => {
|
||||
const room = {} as Room;
|
||||
|
||||
it("should return null when room has no tags", () => {
|
||||
mockGetTagsForRoom.mockReturnValue([]);
|
||||
expect(getSectionTagForRoom(room)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when room only has a non-section tag", () => {
|
||||
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Untagged]);
|
||||
expect(getSectionTagForRoom(room)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, `${CUSTOM_SECTION_TAG_PREFIX}abc-123`])(
|
||||
"should return section tag %s when present",
|
||||
(tag) => {
|
||||
mockGetTagsForRoom.mockReturnValue([tag]);
|
||||
expect(getSectionTagForRoom(room)).toBe(tag);
|
||||
},
|
||||
);
|
||||
|
||||
it("should return the first section tag when multiple are present", () => {
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc-123`;
|
||||
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Favourite, customTag]);
|
||||
expect(getSectionTagForRoom(room)).toBe(DefaultTagID.Favourite);
|
||||
});
|
||||
|
||||
it("should ignore non-section tags and return the section tag", () => {
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc-123`;
|
||||
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Untagged, customTag]);
|
||||
expect(getSectionTagForRoom(room)).toBe(customTag);
|
||||
});
|
||||
});
|
||||
@@ -11,23 +11,23 @@ import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import RoomListActions from "../../../../src/actions/RoomListActions";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
|
||||
import { CHATS_TAG, CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
|
||||
import { tagRoom } from "../../../../src/utils/room/tagRoom";
|
||||
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
||||
import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom";
|
||||
import * as getSectionTagForRoomUtils from "../../../../src/utils/room/getSectionTagForRoom";
|
||||
|
||||
describe("tagRoom()", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const roomId = "!room:server.org";
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`;
|
||||
|
||||
const makeRoom = (tags: TagID[] = []): Room => {
|
||||
const makeRoom = (currentSectionTag: TagID | null = null): Room => {
|
||||
const client = getMockClientWithEventEmitter({
|
||||
isGuest: jest.fn(),
|
||||
});
|
||||
const room = new Room(roomId, client, userId);
|
||||
|
||||
jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue(tags);
|
||||
jest.spyOn(getSectionTagForRoomUtils, "getSectionTagForRoom").mockReturnValue(currentSectionTag);
|
||||
|
||||
return room;
|
||||
};
|
||||
@@ -51,7 +51,7 @@ describe("tagRoom()", () => {
|
||||
expect(RoomListActions.tagRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("when a room has no tags", () => {
|
||||
describe("when a room has no section tag", () => {
|
||||
it("should tag a room as favourite", () => {
|
||||
const room = makeRoom();
|
||||
|
||||
@@ -93,11 +93,25 @@ describe("tagRoom()", () => {
|
||||
customTag, // add
|
||||
);
|
||||
});
|
||||
|
||||
it("should do nothing meaningful when applying CHATS_TAG", () => {
|
||||
const room = makeRoom();
|
||||
|
||||
tagRoom(room, CHATS_TAG);
|
||||
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
|
||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||
room.client,
|
||||
room,
|
||||
null, // remove
|
||||
null, // add
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a room is tagged as favourite", () => {
|
||||
it("should unfavourite a room", () => {
|
||||
const room = makeRoom([DefaultTagID.Favourite]);
|
||||
const room = makeRoom(DefaultTagID.Favourite);
|
||||
|
||||
tagRoom(room, DefaultTagID.Favourite);
|
||||
|
||||
@@ -111,7 +125,7 @@ describe("tagRoom()", () => {
|
||||
});
|
||||
|
||||
it("should tag a room low priority", () => {
|
||||
const room = makeRoom([DefaultTagID.Favourite]);
|
||||
const room = makeRoom(DefaultTagID.Favourite);
|
||||
|
||||
tagRoom(room, DefaultTagID.LowPriority);
|
||||
|
||||
@@ -123,10 +137,25 @@ describe("tagRoom()", () => {
|
||||
DefaultTagID.LowPriority, // add
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove the favourite tag when applying CHATS_TAG", () => {
|
||||
const room = makeRoom(DefaultTagID.Favourite);
|
||||
|
||||
tagRoom(room, CHATS_TAG);
|
||||
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
|
||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||
room.client,
|
||||
room,
|
||||
DefaultTagID.Favourite, // remove
|
||||
null, // add
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a room is tagged as low priority", () => {
|
||||
it("should favourite a room", () => {
|
||||
const room = makeRoom([DefaultTagID.LowPriority]);
|
||||
const room = makeRoom(DefaultTagID.LowPriority);
|
||||
|
||||
tagRoom(room, DefaultTagID.Favourite);
|
||||
|
||||
@@ -140,7 +169,7 @@ describe("tagRoom()", () => {
|
||||
});
|
||||
|
||||
it("should untag a room low priority", () => {
|
||||
const room = makeRoom([DefaultTagID.LowPriority]);
|
||||
const room = makeRoom(DefaultTagID.LowPriority);
|
||||
|
||||
tagRoom(room, DefaultTagID.LowPriority);
|
||||
|
||||
@@ -161,8 +190,9 @@ describe("tagRoom()", () => {
|
||||
{ label: "untag the custom section", applyTag: customTag, expectedAdd: null },
|
||||
{ label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite },
|
||||
{ label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag },
|
||||
{ label: "remove section tag when applying CHATS_TAG", applyTag: CHATS_TAG, expectedAdd: null },
|
||||
])("should $label", ({ applyTag, expectedAdd }) => {
|
||||
const room = makeRoom([customTag]);
|
||||
const room = makeRoom(customTag);
|
||||
|
||||
tagRoom(room, applyTag);
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@ import { Action } from "../../../src/dispatcher/actions";
|
||||
import { CallStore } from "../../../src/stores/CallStore";
|
||||
import { CallEvent, type Call } from "../../../src/models/Call";
|
||||
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import * as tagRoomModule from "../../../src/utils/room/tagRoom";
|
||||
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
|
||||
|
||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
||||
|
||||
@@ -13,8 +13,9 @@ import { RoomNotificationStateStore } from "../../../src/stores/notifications/Ro
|
||||
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 RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
|
||||
|
||||
describe("RoomListSectionHeaderViewModel", () => {
|
||||
let onToggleExpanded: jest.Mock;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { mocked } from "jest-mock";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
|
||||
import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils";
|
||||
import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
||||
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
|
||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
||||
@@ -21,6 +21,17 @@ import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListVie
|
||||
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
|
||||
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";
|
||||
|
||||
jest.mock("../../../src/utils/room/tagRoom", () => ({
|
||||
tagRoom: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/utils/room/getSectionTagForRoom", () => ({
|
||||
getSectionTagForRoom: jest.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||
hasCreateRoomRights: jest.fn().mockReturnValue(false),
|
||||
@@ -1108,4 +1119,37 @@ describe("RoomListViewModel", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeRoomSection", () => {
|
||||
beforeEach(() => {
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
mocked(tagRoom).mockClear();
|
||||
});
|
||||
|
||||
it("should call tagRoom with the room and target tag", () => {
|
||||
jest.spyOn(matrixClient, "getRoom").mockReturnValue(room1);
|
||||
mocked(getSectionTagForRoom).mockReturnValue(null);
|
||||
|
||||
viewModel.changeRoomSection(room1.roomId, DefaultTagID.Favourite);
|
||||
|
||||
expect(tagRoom).toHaveBeenCalledWith(room1, DefaultTagID.Favourite);
|
||||
});
|
||||
|
||||
it("should do nothing when the room is not found", () => {
|
||||
jest.spyOn(matrixClient, "getRoom").mockReturnValue(null);
|
||||
|
||||
viewModel.changeRoomSection("!unknown:server", DefaultTagID.Favourite);
|
||||
|
||||
expect(tagRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should do nothing when the room is already in the target section", () => {
|
||||
jest.spyOn(matrixClient, "getRoom").mockReturnValue(room1);
|
||||
mocked(getSectionTagForRoom).mockReturnValue(DefaultTagID.Favourite);
|
||||
|
||||
viewModel.changeRoomSection(room1.roomId, DefaultTagID.Favourite);
|
||||
|
||||
expect(tagRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user