Room list: assign room to custom section (#33238)
* feat(sc): add new toast type for room list * feat(sc): add section entries in room list item menu * feat(rls): expose util functions * feat: allows to tag room with custom sections * feat(vm): add new Chat moved toast to room list vm * feat(vm): add section selection to room list item vm * feat(e2e): add tests for adding room in a custom section * test(e2e): update existing screenshots * chore: fix lint after merge * chore: remove outline in test
This commit is contained in:
@@ -175,4 +175,93 @@ test.describe("Room list custom sections", () => {
|
|||||||
await expect(getSectionHeader(page, "Low Priority")).toBeVisible();
|
await expect(getSectionHeader(page, "Low Priority")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("Adding a room to a custom section", () => {
|
||||||
|
/**
|
||||||
|
* 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("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
|
||||||
|
await app.client.createRoom({ name: "my room" });
|
||||||
|
await createCustomSection(page, "Work");
|
||||||
|
|
||||||
|
const roomList = getRoomList(page);
|
||||||
|
|
||||||
|
// Room starts in Chats section (aria-level=2)
|
||||||
|
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||||
|
await expect(roomItem).toBeVisible();
|
||||||
|
|
||||||
|
// Open More Options and move to the Work section
|
||||||
|
await roomItem.hover();
|
||||||
|
await roomItem.getByRole("button", { name: "More Options" }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Move to" }).hover();
|
||||||
|
await page.getByRole("menuitem", { name: "Work" }).click();
|
||||||
|
|
||||||
|
// Room should now be nested under the Work section header (aria-level=1 → aria-level=2)
|
||||||
|
await assertRoomInSection(page, "Work", "my room");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should show 'Chat moved' toast when adding a room to a custom section",
|
||||||
|
{ tag: "@screenshot" },
|
||||||
|
async ({ page, app }) => {
|
||||||
|
await app.client.createRoom({ name: "my room" });
|
||||||
|
await createCustomSection(page, "Work");
|
||||||
|
|
||||||
|
const roomList = getRoomList(page);
|
||||||
|
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||||
|
|
||||||
|
await roomItem.hover();
|
||||||
|
await roomItem.getByRole("button", { name: "More Options" }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Move to" }).hover();
|
||||||
|
await page.getByRole("menuitem", { name: "Work" }).click();
|
||||||
|
|
||||||
|
// The "Chat moved" toast should appear
|
||||||
|
await expect(page.getByText("Chat moved")).toBeVisible();
|
||||||
|
|
||||||
|
// Remove focus outline from the room item before taking the screenshot
|
||||||
|
await page.getByRole("button", { name: "User menu" }).focus();
|
||||||
|
|
||||||
|
await expect(roomList).toMatchScreenshot("room-list-sections-chat-moved-toast.png");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => {
|
||||||
|
await app.client.createRoom({ name: "my room" });
|
||||||
|
await createCustomSection(page, "Work");
|
||||||
|
|
||||||
|
const roomList = getRoomList(page);
|
||||||
|
|
||||||
|
// Move to Work section and verify placement via aria-level
|
||||||
|
let roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||||
|
await roomItem.hover();
|
||||||
|
await roomItem.getByRole("button", { name: "More Options" }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Move to" }).hover();
|
||||||
|
await page.getByRole("menuitem", { name: "Work" }).click();
|
||||||
|
|
||||||
|
await assertRoomInSection(page, "Work", "my room");
|
||||||
|
|
||||||
|
// Toggle off by selecting the same section again
|
||||||
|
roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||||
|
await roomItem.hover();
|
||||||
|
await roomItem.getByRole("button", { name: "More Options" }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Move to" }).hover();
|
||||||
|
await page.getByRole("menuitem", { name: "Work" }).click();
|
||||||
|
|
||||||
|
// Room is back in the Chats section
|
||||||
|
await assertRoomInSection(page, "Chats", "my room");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 9.6 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
@@ -62,6 +62,8 @@ export enum RoomListStoreV3Event {
|
|||||||
ListsLoaded = "lists_loaded",
|
ListsLoaded = "lists_loaded",
|
||||||
/** Fired when a new section is created in the room list. */
|
/** Fired when a new section is created in the room list. */
|
||||||
SectionCreated = "section_created",
|
SectionCreated = "section_created",
|
||||||
|
/** Fired when a room's tags change. */
|
||||||
|
RoomTagged = "room_tagged",
|
||||||
}
|
}
|
||||||
|
|
||||||
// The result object for returning rooms from the store
|
// The result object for returning rooms from the store
|
||||||
@@ -93,6 +95,7 @@ export const CHATS_TAG = "chats";
|
|||||||
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
||||||
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
||||||
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
||||||
|
export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
|
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
|
||||||
@@ -243,6 +246,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
case "MatrixActions.Room.tags": {
|
case "MatrixActions.Room.tags": {
|
||||||
const room = payload.room;
|
const room = payload.room;
|
||||||
this.addRoomAndEmit(room);
|
this.addRoomAndEmit(room);
|
||||||
|
this.emit(ROOM_TAGGED_EVENT);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +497,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
this.emit(SECTION_CREATED_EVENT, tag);
|
this.emit(SECTION_CREATED_EVENT, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ordered section tags.
|
||||||
|
*/
|
||||||
|
public get orderedSectionTags(): string[] {
|
||||||
|
return this.sortedTags;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the custom sections from the settings store and update the sorted tags.
|
* Load the custom sections from the settings store and update the sorted tags.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
|
|||||||
|
|
||||||
type Tag = string;
|
type Tag = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix for custom section tags.
|
||||||
|
*/
|
||||||
|
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a given tag is a custom section tag.
|
||||||
|
* @param tag - The tag to check.
|
||||||
|
* @returns True if the tag is a custom section tag, false otherwise.
|
||||||
|
*/
|
||||||
|
export function isCustomSectionTag(tag: string): boolean {
|
||||||
|
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* 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.
|
||||||
*/
|
*/
|
||||||
@@ -41,7 +55,7 @@ export async function createSection(): Promise<string | undefined> {
|
|||||||
const [shouldCreateSection, sectionName] = await modal.finished;
|
const [shouldCreateSection, sectionName] = await modal.finished;
|
||||||
if (!shouldCreateSection || !sectionName) return undefined;
|
if (!shouldCreateSection || !sectionName) return undefined;
|
||||||
|
|
||||||
const tag = `element.io.section.${window.crypto.randomUUID()}`;
|
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
|
||||||
const newSection: CustomSection = { tag, name: sectionName };
|
const newSection: CustomSection = { tag, name: sectionName };
|
||||||
|
|
||||||
// Save the new section data
|
// Save the new section data
|
||||||
|
|||||||
@@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta
|
|||||||
import RoomListActions from "../../actions/RoomListActions";
|
import RoomListActions from "../../actions/RoomListActions";
|
||||||
import dis from "../../dispatcher/dispatcher";
|
import dis from "../../dispatcher/dispatcher";
|
||||||
import { getTagsForRoom } from "./getTagsForRoom";
|
import { getTagsForRoom } from "./getTagsForRoom";
|
||||||
|
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle tag for a given room
|
* Toggle tag for a given room.
|
||||||
|
* A room can only be in one section: either a custom section, Favourite, or LowPriority.
|
||||||
|
* Applying any of these will atomically replace the current section tag.
|
||||||
* @param room The room to tag
|
* @param room The room to tag
|
||||||
* @param tagId The tag to invert
|
* @param tagId The tag to invert
|
||||||
*/
|
*/
|
||||||
export function tagRoom(room: Room, tagId: TagID): void {
|
export function tagRoom(room: Room, tagId: TagID): void {
|
||||||
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
|
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
|
||||||
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
|
|
||||||
const isApplied = getTagsForRoom(room).includes(tagId);
|
|
||||||
const removeTag = isApplied ? tagId : inverseTag;
|
|
||||||
const addTag = isApplied ? null : tagId;
|
|
||||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
|
||||||
} else {
|
|
||||||
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
|
logger.warn(`Unexpected tag ${tagId} 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 isApplied = currentSectionTag === tagId;
|
||||||
|
const removeTag = currentSectionTag;
|
||||||
|
const addTag = isApplied ? null : tagId;
|
||||||
|
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
RoomNotifState,
|
RoomNotifState,
|
||||||
type RoomListItemViewSnapshot,
|
type RoomListItemViewSnapshot,
|
||||||
type RoomListItemViewActions,
|
type RoomListItemViewActions,
|
||||||
|
type Section,
|
||||||
} from "@element-hq/web-shared-components";
|
} from "@element-hq/web-shared-components";
|
||||||
import { RoomEvent } from "matrix-js-sdk/src/matrix";
|
import { RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||||
@@ -37,7 +38,8 @@ import { Action } from "../../dispatcher/actions";
|
|||||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import PosthogTrackers from "../../PosthogTrackers";
|
import PosthogTrackers from "../../PosthogTrackers";
|
||||||
import { type Call, CallEvent } from "../../models/Call";
|
import { type Call, CallEvent } from "../../models/Call";
|
||||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
|
||||||
|
import { _t } from "../../languageHandler";
|
||||||
|
|
||||||
interface RoomItemProps {
|
interface RoomItemProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -96,6 +98,13 @@ export class RoomListItemViewModel
|
|||||||
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
|
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
|
||||||
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
|
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
|
||||||
|
|
||||||
|
const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () =>
|
||||||
|
this.onOrderedCustomSectionsChange(),
|
||||||
|
);
|
||||||
|
this.disposables.track(() => {
|
||||||
|
SettingsStore.unwatchSetting(orderSectionsRef);
|
||||||
|
});
|
||||||
|
|
||||||
// Load message preview asynchronously (sync data is already complete)
|
// Load message preview asynchronously (sync data is already complete)
|
||||||
void this.loadAndSetMessagePreview();
|
void this.loadAndSetMessagePreview();
|
||||||
}
|
}
|
||||||
@@ -181,6 +190,7 @@ export class RoomListItemViewModel
|
|||||||
this.snapshot.merge({
|
this.snapshot.merge({
|
||||||
...newItem,
|
...newItem,
|
||||||
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
|
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
|
||||||
|
sections: keepIfSame(this.snapshot.current.sections, newItem.sections),
|
||||||
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
|
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
|
||||||
messagePreview: this.snapshot.current.messagePreview,
|
messagePreview: this.snapshot.current.messagePreview,
|
||||||
});
|
});
|
||||||
@@ -279,6 +289,9 @@ export class RoomListItemViewModel
|
|||||||
|
|
||||||
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
|
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
|
||||||
|
|
||||||
|
// Build sections list for the "Move to section" submenu
|
||||||
|
const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: room.roomId,
|
id: room.roomId,
|
||||||
room,
|
room,
|
||||||
@@ -307,6 +320,7 @@ export class RoomListItemViewModel
|
|||||||
canMarkAsUnread,
|
canMarkAsUnread,
|
||||||
roomNotifState,
|
roomNotifState,
|
||||||
canMoveToSection,
|
canMoveToSection,
|
||||||
|
sections,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,4 +403,42 @@ export class RoomListItemViewModel
|
|||||||
public onCreateSection = (): void => {
|
public onCreateSection = (): void => {
|
||||||
RoomListStoreV3.instance.createSection();
|
RoomListStoreV3.instance.createSection();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public onToggleSection = (tag: string): void => {
|
||||||
|
tagRoom(this.props.room, tag);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onOrderedCustomSectionsChange = (): void => {
|
||||||
|
// Rebuild sections list to reflect new order
|
||||||
|
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
|
||||||
|
this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the list of available sections for the "Move to section" submenu.
|
||||||
|
* Order follows the canonical section order from RoomListStoreV3.
|
||||||
|
*/
|
||||||
|
private static buildSections(roomTags: Room["tags"]): Section[] {
|
||||||
|
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
RoomListStoreV3.instance.orderedSectionTags
|
||||||
|
// Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section.
|
||||||
|
.filter((tag) => tag !== CHATS_TAG)
|
||||||
|
.map((tag) => ({
|
||||||
|
tag,
|
||||||
|
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
|
||||||
|
isSelected: Boolean(roomTags[tag]),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display name for a section based on its tag.
|
||||||
|
*/
|
||||||
|
private static getSectionName(tag: string, customSectionData: Record<string, { name: string }>): string {
|
||||||
|
if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites");
|
||||||
|
if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority");
|
||||||
|
return customSectionData[tag]?.name || tag;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type RoomListViewState,
|
type RoomListViewState,
|
||||||
type RoomListSection,
|
type RoomListSection,
|
||||||
_t,
|
_t,
|
||||||
|
type ToastType,
|
||||||
} from "@element-hq/web-shared-components";
|
} from "@element-hq/web-shared-components";
|
||||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
@@ -156,6 +157,13 @@ export class RoomListViewModel
|
|||||||
this.onSectionCreated as (...args: unknown[]) => void,
|
this.onSectionCreated as (...args: unknown[]) => void,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Subscribe to room tagging
|
||||||
|
this.disposables.trackListener(
|
||||||
|
RoomListStoreV3.instance,
|
||||||
|
RoomListStoreV3Event.RoomTagged as any,
|
||||||
|
this.onRoomTagged,
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe to active room changes to update selected room
|
// Subscribe to active room changes to update selected room
|
||||||
const dispatcherRef = dispatcher.register(this.onDispatch);
|
const dispatcherRef = dispatcher.register(this.onDispatch);
|
||||||
this.disposables.track(() => {
|
this.disposables.track(() => {
|
||||||
@@ -595,15 +603,11 @@ export class RoomListViewModel
|
|||||||
|
|
||||||
public onSectionCreated = (tag: string): void => {
|
public onSectionCreated = (tag: string): void => {
|
||||||
this.updateRoomListData(false, null, tag);
|
this.updateRoomListData(false, null, tag);
|
||||||
|
this.showToast("section_created");
|
||||||
|
};
|
||||||
|
|
||||||
clearTimeout(this.toastRef);
|
public onRoomTagged = (): void => {
|
||||||
this.snapshot.merge({
|
this.showToast("chat_moved");
|
||||||
toast: "section_created",
|
|
||||||
});
|
|
||||||
// Automatically close the toast after 15 seconds
|
|
||||||
this.toastRef = setTimeout(() => {
|
|
||||||
this.closeToast();
|
|
||||||
}, 15 * 1000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public closeToast: () => void = () => {
|
public closeToast: () => void = () => {
|
||||||
@@ -612,6 +616,15 @@ export class RoomListViewModel
|
|||||||
toast: undefined,
|
toast: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private showToast(toast: ToastType): void {
|
||||||
|
clearTimeout(this.toastRef);
|
||||||
|
this.snapshot.merge({ toast });
|
||||||
|
// Automatically close the toast after 15 seconds
|
||||||
|
this.toastRef = setTimeout(() => {
|
||||||
|
this.closeToast();
|
||||||
|
}, 15 * 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Room } from "matrix-js-sdk/src/matrix";
|
|||||||
import RoomListActions from "../../../../src/actions/RoomListActions";
|
import RoomListActions from "../../../../src/actions/RoomListActions";
|
||||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||||
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
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 { tagRoom } from "../../../../src/utils/room/tagRoom";
|
import { tagRoom } from "../../../../src/utils/room/tagRoom";
|
||||||
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
||||||
import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom";
|
import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom";
|
||||||
@@ -18,6 +19,7 @@ import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom"
|
|||||||
describe("tagRoom()", () => {
|
describe("tagRoom()", () => {
|
||||||
const userId = "@alice:server.org";
|
const userId = "@alice:server.org";
|
||||||
const roomId = "!room:server.org";
|
const roomId = "!room:server.org";
|
||||||
|
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`;
|
||||||
|
|
||||||
const makeRoom = (tags: TagID[] = []): Room => {
|
const makeRoom = (tags: TagID[] = []): Room => {
|
||||||
const client = getMockClientWithEventEmitter({
|
const client = getMockClientWithEventEmitter({
|
||||||
@@ -59,7 +61,7 @@ describe("tagRoom()", () => {
|
|||||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||||
room.client,
|
room.client,
|
||||||
room,
|
room,
|
||||||
DefaultTagID.LowPriority, // remove
|
null, // remove
|
||||||
DefaultTagID.Favourite, // add
|
DefaultTagID.Favourite, // add
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -73,10 +75,24 @@ describe("tagRoom()", () => {
|
|||||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||||
room.client,
|
room.client,
|
||||||
room,
|
room,
|
||||||
DefaultTagID.Favourite, // remove
|
null, // remove
|
||||||
DefaultTagID.LowPriority, // add
|
DefaultTagID.LowPriority, // add
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should tag a room with a custom section", () => {
|
||||||
|
const room = makeRoom();
|
||||||
|
|
||||||
|
tagRoom(room, customTag);
|
||||||
|
|
||||||
|
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
|
||||||
|
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||||
|
room.client,
|
||||||
|
room,
|
||||||
|
null, // remove
|
||||||
|
customTag, // add
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when a room is tagged as favourite", () => {
|
describe("when a room is tagged as favourite", () => {
|
||||||
@@ -137,4 +153,26 @@ describe("tagRoom()", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("when a room is tagged with a custom section", () => {
|
||||||
|
const otherCustomTag = `${CUSTOM_SECTION_TAG_PREFIX}other-section`;
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ 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 },
|
||||||
|
])("should $label", ({ applyTag, expectedAdd }) => {
|
||||||
|
const room = makeRoom([customTag]);
|
||||||
|
|
||||||
|
tagRoom(room, applyTag);
|
||||||
|
|
||||||
|
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
|
||||||
|
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||||
|
room.client,
|
||||||
|
room,
|
||||||
|
customTag, // remove
|
||||||
|
expectedAdd, // add
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot
|
|||||||
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
|
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
|
||||||
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
||||||
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview";
|
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview";
|
||||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
import SettingsStore, { type CallbackFn } from "../../../src/settings/SettingsStore";
|
||||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||||
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
import dispatcher from "../../../src/dispatcher/dispatcher";
|
||||||
@@ -29,7 +29,8 @@ import { Action } from "../../../src/dispatcher/actions";
|
|||||||
import { CallStore } from "../../../src/stores/CallStore";
|
import { CallStore } from "../../../src/stores/CallStore";
|
||||||
import { CallEvent, type Call } from "../../../src/models/Call";
|
import { CallEvent, type Call } from "../../../src/models/Call";
|
||||||
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
||||||
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||||
|
import * as tagRoomModule from "../../../src/utils/room/tagRoom";
|
||||||
|
|
||||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
||||||
@@ -83,6 +84,7 @@ describe("RoomListItemViewModel", () => {
|
|||||||
|
|
||||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
|
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
|
||||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
|
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
|
||||||
|
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -213,8 +215,8 @@ describe("RoomListItemViewModel", () => {
|
|||||||
let watchCallback: any;
|
let watchCallback: any;
|
||||||
|
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview);
|
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview);
|
||||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => {
|
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
|
||||||
watchCallback = callback;
|
if (setting === "RoomList.showMessagePreview") watchCallback = callback;
|
||||||
return "watcher-id";
|
return "watcher-id";
|
||||||
});
|
});
|
||||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
|
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
|
||||||
@@ -595,6 +597,93 @@ describe("RoomListItemViewModel", () => {
|
|||||||
viewModel.onCreateSection();
|
viewModel.onCreateSection();
|
||||||
expect(createSectionSpy).toHaveBeenCalled();
|
expect(createSectionSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should call tagRoom when onToggleSection is called", () => {
|
||||||
|
const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {});
|
||||||
|
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||||
|
|
||||||
|
viewModel.onToggleSection(DefaultTagID.Favourite);
|
||||||
|
|
||||||
|
expect(tagRoomSpy).toHaveBeenCalledWith(room, DefaultTagID.Favourite);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sections", () => {
|
||||||
|
const customTag = "element.io.section.custom1";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([
|
||||||
|
DefaultTagID.Favourite,
|
||||||
|
customTag,
|
||||||
|
CHATS_TAG,
|
||||||
|
DefaultTagID.LowPriority,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include sections from orderedSectionTags excluding CHATS_TAG", () => {
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||||
|
if (setting === "feature_room_list_sections") return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||||
|
|
||||||
|
const sections = viewModel.getSnapshot().sections;
|
||||||
|
expect(sections.map((s) => s.tag)).toEqual([DefaultTagID.Favourite, customTag, DefaultTagID.LowPriority]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark the room current section as selected", () => {
|
||||||
|
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||||
|
if (setting === "feature_room_list_sections") return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||||
|
|
||||||
|
const sections = viewModel.getSnapshot().sections;
|
||||||
|
expect(sections.find((s) => s.tag === DefaultTagID.Favourite)?.isSelected).toBe(true);
|
||||||
|
expect(sections.find((s) => s.tag === DefaultTagID.LowPriority)?.isSelected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use custom section name from CustomSectionData", () => {
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||||
|
if (setting === "feature_room_list_sections") return true;
|
||||||
|
if (setting === "RoomList.CustomSectionData")
|
||||||
|
return { [customTag]: { name: "My Custom Section", tag: customTag } };
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||||
|
|
||||||
|
const section = viewModel.getSnapshot().sections.find((s) => s.tag === customTag);
|
||||||
|
expect(section?.name).toBe("My Custom Section");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update sections when OrderedCustomSections setting changes", () => {
|
||||||
|
let watchCallback: CallbackFn = () => {};
|
||||||
|
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
|
||||||
|
if (setting === "RoomList.OrderedCustomSections") watchCallback = callback;
|
||||||
|
return "watcher-id";
|
||||||
|
});
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||||
|
if (setting === "feature_room_list_sections") return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||||
|
expect(viewModel.getSnapshot().sections).toHaveLength(3); // Favourite, custom, LowPriority
|
||||||
|
|
||||||
|
// Simulate reordering: custom section removed
|
||||||
|
jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([
|
||||||
|
DefaultTagID.Favourite,
|
||||||
|
CHATS_TAG,
|
||||||
|
DefaultTagID.LowPriority,
|
||||||
|
]);
|
||||||
|
watchCallback("RoomList.OrderedCustomSections", null, null as any, null, null);
|
||||||
|
|
||||||
|
expect(viewModel.getSnapshot().sections.map((s) => s.tag)).toEqual([
|
||||||
|
DefaultTagID.Favourite,
|
||||||
|
DefaultTagID.LowPriority,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Cleanup", () => {
|
describe("Cleanup", () => {
|
||||||
|
|||||||
@@ -619,6 +619,12 @@ describe("RoomListViewModel", () => {
|
|||||||
expect(viewModel.getSnapshot().toast).toBe("section_created");
|
expect(viewModel.getSnapshot().toast).toBe("section_created");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should show toast when RoomTagged event fires", () => {
|
||||||
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||||
|
RoomListStoreV3.instance.emit(RoomListStoreV3Event.RoomTagged);
|
||||||
|
expect(viewModel.getSnapshot().toast).toBe("chat_moved");
|
||||||
|
});
|
||||||
|
|
||||||
it("should clear toast when closeToast is called", () => {
|
it("should clear toast when closeToast is called", () => {
|
||||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -99,6 +99,7 @@
|
|||||||
"voice_call": "Open room %(roomName)s with a voice call."
|
"voice_call": "Open room %(roomName)s with a voice call."
|
||||||
},
|
},
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
|
"chat_moved": "Chat moved",
|
||||||
"collapse_filters": "Collapse filter list",
|
"collapse_filters": "Collapse filter list",
|
||||||
"empty": {
|
"empty": {
|
||||||
"no_chats": "No chats yet",
|
"no_chats": "No chats yet",
|
||||||
|
|||||||
+6
@@ -38,3 +38,9 @@ export default meta;
|
|||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const SectionCreated: Story = {};
|
export const SectionCreated: Story = {};
|
||||||
|
|
||||||
|
export const ChatMoved: Story = {
|
||||||
|
args: {
|
||||||
|
type: "chat_moved",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
+6
-1
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
|
|
||||||
import * as stories from "./RoomListToast.stories";
|
import * as stories from "./RoomListToast.stories";
|
||||||
|
|
||||||
const { SectionCreated } = composeStories(stories);
|
const { SectionCreated, ChatMoved } = composeStories(stories);
|
||||||
|
|
||||||
describe("<RoomListToast />", () => {
|
describe("<RoomListToast />", () => {
|
||||||
it("renders SectionCreated story", () => {
|
it("renders SectionCreated story", () => {
|
||||||
@@ -21,6 +21,11 @@ describe("<RoomListToast />", () => {
|
|||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders ChatMoved story", () => {
|
||||||
|
const { container } = render(<ChatMoved />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
it("calls onClose when the close button is clicked", async () => {
|
it("calls onClose when the close button is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<SectionCreated />);
|
render(<SectionCreated />);
|
||||||
|
|||||||
+4
-1
@@ -12,7 +12,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"
|
|||||||
import styles from "./RoomListToast.module.css";
|
import styles from "./RoomListToast.module.css";
|
||||||
import { useI18n } from "../../../core/i18n/i18nContext";
|
import { useI18n } from "../../../core/i18n/i18nContext";
|
||||||
|
|
||||||
export type ToastType = "section_created";
|
export type ToastType = "section_created" | "chat_moved";
|
||||||
|
|
||||||
interface RoomListToastProps {
|
interface RoomListToastProps {
|
||||||
/** The type of toast to display */
|
/** The type of toast to display */
|
||||||
@@ -37,6 +37,9 @@ export function RoomListToast({ type, onClose }: Readonly<RoomListToastProps>):
|
|||||||
case "section_created":
|
case "section_created":
|
||||||
content = { text: _t("room_list|section_created"), icon: CheckIcon };
|
content = { text: _t("room_list|section_created"), icon: CheckIcon };
|
||||||
break;
|
break;
|
||||||
|
case "chat_moved":
|
||||||
|
content = { text: _t("room_list|chat_moved"), icon: CheckIcon };
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+56
@@ -1,5 +1,61 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`<RoomListToast /> > renders ChatMoved story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style="position: relative; width: 320px; height: 100px; background-color: grey;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _toast-container_1ysb3_8 RoomListToast-module_toast _has-close_1ysb3_30"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_content_1ysb3_34"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_icon_1ysb3_26"
|
||||||
|
fill="currentColor"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Chat moved
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-labelledby="_r_6_"
|
||||||
|
class="_icon-button_1215g_8 _close_1ysb3_41 _no-background_1215g_42"
|
||||||
|
data-kind="secondary"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`<RoomListToast /> > renders SectionCreated story 1`] = `
|
exports[`<RoomListToast /> > renders SectionCreated story 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
|
|||||||
export { RoomListEmptyStateView } from "./RoomListEmptyStateView";
|
export { RoomListEmptyStateView } from "./RoomListEmptyStateView";
|
||||||
export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView";
|
export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView";
|
||||||
export * from "../VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView";
|
export * from "../VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView";
|
||||||
|
export * from "./RoomListToast";
|
||||||
|
|||||||
+56
@@ -27,6 +27,7 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
|
|||||||
onLeaveRoom: vi.fn(),
|
onLeaveRoom: vi.fn(),
|
||||||
onSetRoomNotifState: vi.fn(),
|
onSetRoomNotifState: vi.fn(),
|
||||||
onCreateSection: vi.fn(),
|
onCreateSection: vi.fn(),
|
||||||
|
onToggleSection: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
|
const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
|
||||||
@@ -240,4 +241,59 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
|
|||||||
|
|
||||||
expect(mockCallbacks.onCreateSection).toHaveBeenCalled();
|
expect(mockCallbacks.onCreateSection).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should render section items in move to section submenu", () => {
|
||||||
|
const sections = [
|
||||||
|
{ tag: "m.favourite", name: "Favourites", isSelected: false },
|
||||||
|
{ tag: "element.io.section.custom1", name: "Work", isSelected: true },
|
||||||
|
{ tag: "element.io.section.custom2", name: "Personal", isSelected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TestComponent = (): JSX.Element => {
|
||||||
|
const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks);
|
||||||
|
return <MoreOptionContent vm={vm} />;
|
||||||
|
};
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
const favouriteItem = screen.getByRole("menuitem", { name: "Favourites" });
|
||||||
|
expect(favouriteItem).toBeInTheDocument();
|
||||||
|
expect(favouriteItem).toHaveAttribute("aria-checked", "false");
|
||||||
|
|
||||||
|
const workItem = screen.getByRole("menuitem", { name: "Work" });
|
||||||
|
expect(workItem).toBeInTheDocument();
|
||||||
|
expect(workItem).toHaveAttribute("aria-checked", "true");
|
||||||
|
|
||||||
|
const personalItem = screen.getByRole("menuitem", { name: "Personal" });
|
||||||
|
expect(personalItem).toBeInTheDocument();
|
||||||
|
expect(personalItem).toHaveAttribute("aria-checked", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onToggleSection when a section item is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const sections = [
|
||||||
|
{ tag: "m.favourite", name: "Favourites", isSelected: false },
|
||||||
|
{ tag: "element.io.section.custom1", name: "Work", isSelected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TestComponent = (): JSX.Element => {
|
||||||
|
const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks);
|
||||||
|
return <MoreOptionContent vm={vm} />;
|
||||||
|
};
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
const workItem = screen.getByRole("menuitem", { name: "Work" });
|
||||||
|
await user.click(workItem);
|
||||||
|
|
||||||
|
expect(mockCallbacks.onToggleSection).toHaveBeenCalledWith("element.io.section.custom1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not render section items when sections array is empty", () => {
|
||||||
|
const TestComponent = (): JSX.Element => {
|
||||||
|
const vm = useMockedViewModel({ ...defaultSnapshot, sections: [] }, mockCallbacks);
|
||||||
|
return <MoreOptionContent vm={vm} />;
|
||||||
|
};
|
||||||
|
render(<TestComponent />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("menuitem", { name: "New section" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+16
@@ -17,6 +17,7 @@ import {
|
|||||||
LeaveIcon,
|
LeaveIcon,
|
||||||
OverflowHorizontalIcon,
|
OverflowHorizontalIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
|
CheckIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import { _t } from "../../../../core/i18n/i18n";
|
import { _t } from "../../../../core/i18n/i18n";
|
||||||
@@ -136,6 +137,21 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{snapshot.sections.map((section) => (
|
||||||
|
<MenuItem
|
||||||
|
key={section.tag}
|
||||||
|
label={section.name}
|
||||||
|
onSelect={() => vm.onToggleSection(section.tag)}
|
||||||
|
onClick={(evt) => evt.stopPropagation()}
|
||||||
|
hideChevron={true}
|
||||||
|
aria-checked={section.isSelected}
|
||||||
|
>
|
||||||
|
{section.isSelected && (
|
||||||
|
<CheckIcon color="var(--cpd-color-icon-tertiary)" width="24px" height="24px" />
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
<Separator />
|
||||||
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
|
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
|
||||||
</SubMenu>
|
</SubMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
@@ -28,6 +28,7 @@ describe("<RoomListItemNotificationMenu />", () => {
|
|||||||
onLeaveRoom: vi.fn(),
|
onLeaveRoom: vi.fn(),
|
||||||
onSetRoomNotifState: vi.fn(),
|
onSetRoomNotifState: vi.fn(),
|
||||||
onCreateSection: vi.fn(),
|
onCreateSection: vi.fn(),
|
||||||
|
onToggleSection: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
|
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
|
||||||
|
|||||||
+2
@@ -39,6 +39,7 @@ const RoomListItemWrapperImpl = ({
|
|||||||
onLeaveRoom,
|
onLeaveRoom,
|
||||||
onSetRoomNotifState,
|
onSetRoomNotifState,
|
||||||
onCreateSection,
|
onCreateSection,
|
||||||
|
onToggleSection,
|
||||||
isSelected,
|
isSelected,
|
||||||
isFocused,
|
isFocused,
|
||||||
onFocus,
|
onFocus,
|
||||||
@@ -58,6 +59,7 @@ const RoomListItemWrapperImpl = ({
|
|||||||
onLeaveRoom,
|
onLeaveRoom,
|
||||||
onSetRoomNotifState,
|
onSetRoomNotifState,
|
||||||
onCreateSection,
|
onCreateSection,
|
||||||
|
onToggleSection,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<RoomListItemView
|
<RoomListItemView
|
||||||
|
|||||||
+17
@@ -44,6 +44,19 @@ function getA11yLabel(roomName: string, notification: NotificationDecorationData
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a section that a room can be assigned to.
|
||||||
|
* Used to render toggle items in the "Move to section" submenu.
|
||||||
|
*/
|
||||||
|
export interface Section {
|
||||||
|
/** The tag that identifies this section (e.g. `m.favourite`, custom tag) */
|
||||||
|
tag: string;
|
||||||
|
/** The human-readable display name of the section */
|
||||||
|
name: string;
|
||||||
|
/** Whether the room currently belongs to this section */
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot for a room list item.
|
* Snapshot for a room list item.
|
||||||
* Contains all the data needed to render a room in the list.
|
* Contains all the data needed to render a room in the list.
|
||||||
@@ -81,6 +94,8 @@ export interface RoomListItemViewSnapshot {
|
|||||||
roomNotifState: RoomNotifState;
|
roomNotifState: RoomNotifState;
|
||||||
/** Whether the room can be moved to a section */
|
/** Whether the room can be moved to a section */
|
||||||
canMoveToSection: boolean;
|
canMoveToSection: boolean;
|
||||||
|
/** Available sections the room can be assigned to */
|
||||||
|
sections: Section[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +123,8 @@ export interface RoomListItemViewActions {
|
|||||||
onSetRoomNotifState: (state: RoomNotifState) => void;
|
onSetRoomNotifState: (state: RoomNotifState) => void;
|
||||||
/** Called when creating a new section */
|
/** Called when creating a new section */
|
||||||
onCreateSection: () => void;
|
onCreateSection: () => void;
|
||||||
|
/** Called when toggling a room's membership in a section */
|
||||||
|
onToggleSection: (tag: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+17
@@ -37,4 +37,21 @@ export const defaultSnapshot: RoomListItemViewSnapshot = {
|
|||||||
canMarkAsUnread: true,
|
canMarkAsUnread: true,
|
||||||
roomNotifState: RoomNotifState.AllMessages,
|
roomNotifState: RoomNotifState.AllMessages,
|
||||||
canMoveToSection: true,
|
canMoveToSection: true,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
tag: "m.favourite",
|
||||||
|
name: "Favourites",
|
||||||
|
isSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "element.io.section.work",
|
||||||
|
name: "Work",
|
||||||
|
isSelected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "m.lowpriority",
|
||||||
|
name: "Low Priority",
|
||||||
|
isSelected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+1
@@ -12,6 +12,7 @@ export type {
|
|||||||
RoomListItemViewModel,
|
RoomListItemViewModel,
|
||||||
RoomListItemViewActions,
|
RoomListItemViewActions,
|
||||||
RoomListItemViewProps,
|
RoomListItemViewProps,
|
||||||
|
Section,
|
||||||
} from "./RoomListItemView";
|
} from "./RoomListItemView";
|
||||||
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||||
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
|
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
|
||||||
|
|||||||
+1
@@ -20,4 +20,5 @@ export const mockedActions: RoomListItemViewActions = {
|
|||||||
onLeaveRoom: fn(),
|
onLeaveRoom: fn(),
|
||||||
onSetRoomNotifState: fn(),
|
onSetRoomNotifState: fn(),
|
||||||
onCreateSection: fn(),
|
onCreateSection: fn(),
|
||||||
|
onToggleSection: fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const createMockRoomSnapshot = (id: string, name: string, index: number):
|
|||||||
canMarkAsUnread: true,
|
canMarkAsUnread: true,
|
||||||
roomNotifState: RoomNotifState.AllMessages,
|
roomNotifState: RoomNotifState.AllMessages,
|
||||||
canMoveToSection: true,
|
canMoveToSection: true,
|
||||||
|
sections: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomListItemViewModel {
|
export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomListItemViewModel {
|
||||||
@@ -123,6 +124,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index:
|
|||||||
onLeaveRoom: fn(),
|
onLeaveRoom: fn(),
|
||||||
onSetRoomNotifState: fn(),
|
onSetRoomNotifState: fn(),
|
||||||
onCreateSection: fn(),
|
onCreateSection: fn(),
|
||||||
|
onToggleSection: fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user