Add RoomListView component
Add RoomListView component that composes RoomList with filters, empty states, and loading skeleton.
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 64 KiB |
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 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.
|
||||
*/
|
||||
|
||||
.genericPlaceholder {
|
||||
align-self: center;
|
||||
/** It should take 2/3 of the width **/
|
||||
width: 66%;
|
||||
/** It should be positioned at 1/3 of the height **/
|
||||
padding-top: 33%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--cpd-font-body-lg-semibold);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.defaultPlaceholder {
|
||||
margin-top: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.genericPlaceholder button {
|
||||
width: 100%;
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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 React, { type JSX, type PropsWithChildren, type ReactNode } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
|
||||
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { useViewModel } from "../../viewmodel";
|
||||
import type { RoomListViewModel } from "./RoomListView";
|
||||
import styles from "./RoomListEmptyState.module.css";
|
||||
|
||||
/**
|
||||
* Props for RoomListEmptyState component
|
||||
*/
|
||||
export interface RoomListEmptyStateProps {
|
||||
/** The view model containing all data and callbacks */
|
||||
vm: RoomListViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state component for the room list.
|
||||
* Displays appropriate message and actions based on the active filter.
|
||||
*/
|
||||
export const RoomListEmptyState: React.FC<RoomListEmptyStateProps> = ({ vm }): JSX.Element => {
|
||||
const snapshot = useViewModel(vm);
|
||||
|
||||
// If there is no active filter, show the default empty state
|
||||
if (!snapshot.activeFilterId) {
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_chats")}
|
||||
description={
|
||||
snapshot.canCreateRoom
|
||||
? _t("room_list|empty|no_chats_description")
|
||||
: _t("room_list|empty|no_chats_description_no_room_rights")
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
className={styles.defaultPlaceholder}
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
gap="var(--cpd-space-4x)"
|
||||
>
|
||||
<Button size="sm" kind="secondary" Icon={ChatIcon} onClick={vm.createChatRoom}>
|
||||
{_t("action|start_chat")}
|
||||
</Button>
|
||||
{snapshot.canCreateRoom && (
|
||||
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
|
||||
{_t("action|new_room")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</GenericPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle different filter cases based on filter ID
|
||||
switch (snapshot.activeFilterId) {
|
||||
case "favourite":
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_favourites")}
|
||||
description={_t("room_list|empty|no_favourites_description")}
|
||||
/>
|
||||
);
|
||||
case "people":
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_people")}
|
||||
description={_t("room_list|empty|no_people_description")}
|
||||
/>
|
||||
);
|
||||
case "rooms":
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_rooms")}
|
||||
description={_t("room_list|empty|no_rooms_description")}
|
||||
/>
|
||||
);
|
||||
case "unread":
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_unread")}
|
||||
action={_t("room_list|empty|show_chats")}
|
||||
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
|
||||
/>
|
||||
);
|
||||
case "invites":
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_invites")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
|
||||
/>
|
||||
);
|
||||
case "mentions":
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_mentions")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
|
||||
/>
|
||||
);
|
||||
case "low_priority":
|
||||
return (
|
||||
<ActionPlaceholder
|
||||
title={_t("room_list|empty|no_lowpriority")}
|
||||
action={_t("room_list|empty|show_activity")}
|
||||
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<GenericPlaceholder
|
||||
title={_t("room_list|empty|no_chats")}
|
||||
description={_t("room_list|empty|no_chats_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface GenericPlaceholderProps {
|
||||
/** The title of the placeholder */
|
||||
title: string;
|
||||
/** The description of the placeholder */
|
||||
description?: string;
|
||||
/** Optional children (e.g., action buttons) */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic placeholder for the room list
|
||||
*/
|
||||
function GenericPlaceholder({ title, description, children }: PropsWithChildren<GenericPlaceholderProps>): JSX.Element {
|
||||
return (
|
||||
<Flex
|
||||
data-testid="empty-room-list"
|
||||
className={styles.genericPlaceholder}
|
||||
direction="column"
|
||||
align="stretch"
|
||||
justify="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
>
|
||||
<span className={styles.title}>{title}</span>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionPlaceholderProps {
|
||||
/** The title to display */
|
||||
title: string;
|
||||
/** The action button text */
|
||||
action: string;
|
||||
/** Callback when the action button is clicked */
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder for the room list when a filter is active
|
||||
* The user can take action to toggle the filter
|
||||
*/
|
||||
function ActionPlaceholder({ title, action, onAction }: ActionPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<GenericPlaceholder title={title}>
|
||||
{onAction && (
|
||||
<Button kind="tertiary" onClick={onAction}>
|
||||
{action}
|
||||
</Button>
|
||||
)}
|
||||
</GenericPlaceholder>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2025 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.
|
||||
*/
|
||||
|
||||
.skeleton {
|
||||
position: relative;
|
||||
margin-left: 4px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.skeleton::before {
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: "";
|
||||
position: absolute;
|
||||
mask-repeat: repeat-y;
|
||||
mask-size: auto 96px;
|
||||
mask-image: url("./assets/skeleton.svg");
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from "react";
|
||||
|
||||
import styles from "./RoomListLoadingSkeleton.module.css";
|
||||
|
||||
/**
|
||||
* Loading skeleton component for the room list.
|
||||
* Displays a repeating skeleton pattern while rooms are being fetched.
|
||||
*/
|
||||
export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => {
|
||||
return <div className={styles.skeleton} />;
|
||||
};
|
||||
@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { FilterId } from "../RoomListPrimaryFilters";
|
||||
import { RoomListView, type RoomListSnapshot, type RoomListViewActions } from "./RoomListView";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import {
|
||||
renderAvatar,
|
||||
createGetRoomItemViewModel,
|
||||
mockRoomIds,
|
||||
smallListRoomIds,
|
||||
largeListRoomIds,
|
||||
} from "../story-mocks";
|
||||
|
||||
type RoomListViewProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: any) => React.ReactElement };
|
||||
|
||||
const mockFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite"];
|
||||
|
||||
// Wrapper component that creates a mocked ViewModel
|
||||
const RoomListViewWrapper = ({
|
||||
onToggleFilter,
|
||||
createChatRoom,
|
||||
createRoom,
|
||||
getRoomItemViewModel,
|
||||
updateVisibleRooms,
|
||||
renderAvatar: renderAvatarProp,
|
||||
...rest
|
||||
}: RoomListViewProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onToggleFilter,
|
||||
createChatRoom,
|
||||
createRoom,
|
||||
getRoomItemViewModel,
|
||||
updateVisibleRooms,
|
||||
});
|
||||
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Room List/RoomListView",
|
||||
component: RoomListViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div
|
||||
style={{
|
||||
width: "320px",
|
||||
height: "600px",
|
||||
border: "1px solid var(--cpd-color-border-interactive-primary)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
resize: "horizontal",
|
||||
overflow: "auto",
|
||||
minWidth: "250px",
|
||||
maxWidth: "800px",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
// Snapshot properties (state)
|
||||
isLoadingRooms: false,
|
||||
isRoomListEmpty: false,
|
||||
filterIds: mockFilterIds,
|
||||
activeFilterId: undefined,
|
||||
roomListState: {
|
||||
activeRoomIndex: undefined,
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
},
|
||||
roomIds: mockRoomIds,
|
||||
canCreateRoom: true,
|
||||
// Action properties (callbacks)
|
||||
onToggleFilter: fn(),
|
||||
createChatRoom: fn(),
|
||||
createRoom: fn(),
|
||||
getRoomItemViewModel: createGetRoomItemViewModel(mockRoomIds),
|
||||
updateVisibleRooms: fn(),
|
||||
renderAvatar,
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=2925-19126",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RoomListViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoadingRooms: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyWithoutCreatePermission: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
canCreateRoom: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithActiveFilter: Story = {
|
||||
args: {
|
||||
filterIds: ["unread", "people", "rooms", "favourite"],
|
||||
activeFilterId: "favourite",
|
||||
roomListState: {
|
||||
activeRoomIndex: undefined,
|
||||
spaceId: "!space:server",
|
||||
filterKeys: ["favourites"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
roomListState: {
|
||||
activeRoomIndex: 0,
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyFavouriteFilter: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
roomIds: [],
|
||||
filterIds: ["favourite", "people"],
|
||||
activeFilterId: "favourite",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyPeopleFilter: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
roomIds: [],
|
||||
filterIds: ["people", "rooms"],
|
||||
activeFilterId: "people",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyRoomsFilter: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
roomIds: [],
|
||||
filterIds: ["rooms", "people"],
|
||||
activeFilterId: "rooms",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyUnreadFilter: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
roomIds: [],
|
||||
filterIds: ["unread", "people"],
|
||||
activeFilterId: "unread",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyInvitesFilter: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
roomIds: [],
|
||||
filterIds: ["invites", "people"],
|
||||
activeFilterId: "invites",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyMentionsFilter: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
roomIds: [],
|
||||
filterIds: ["mentions", "people"],
|
||||
activeFilterId: "mentions",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyLowPriorityFilter: Story = {
|
||||
args: {
|
||||
isRoomListEmpty: true,
|
||||
roomIds: [],
|
||||
filterIds: ["low_priority", "people"],
|
||||
activeFilterId: "low_priority",
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallList: Story = {
|
||||
args: {
|
||||
roomIds: smallListRoomIds,
|
||||
getRoomItemViewModel: createGetRoomItemViewModel(smallListRoomIds),
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeList: Story = {
|
||||
args: {
|
||||
roomIds: largeListRoomIds,
|
||||
getRoomItemViewModel: createGetRoomItemViewModel(largeListRoomIds),
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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 React from "react";
|
||||
import { render, screen } from "@test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { VirtuosoMockContext } from "react-virtuoso";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import * as stories from "./RoomListView.stories";
|
||||
|
||||
const {
|
||||
Default,
|
||||
Loading,
|
||||
Empty,
|
||||
EmptyWithoutCreatePermission,
|
||||
WithActiveFilter,
|
||||
SmallList,
|
||||
LargeList,
|
||||
EmptyFavouriteFilter,
|
||||
EmptyPeopleFilter,
|
||||
EmptyRoomsFilter,
|
||||
EmptyUnreadFilter,
|
||||
EmptyInvitesFilter,
|
||||
EmptyMentionsFilter,
|
||||
EmptyLowPriorityFilter,
|
||||
} = composeStories(stories);
|
||||
|
||||
const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => {
|
||||
return render(component, {
|
||||
wrapper: ({ children }) => (
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}>
|
||||
{children}
|
||||
</VirtuosoMockContext.Provider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe("<RoomListView />", () => {
|
||||
it("renders Default story", () => {
|
||||
const { container } = renderWithMockContext(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders Loading story", () => {
|
||||
const { container } = renderWithMockContext(<Loading />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders Empty story", () => {
|
||||
const { container } = renderWithMockContext(<Empty />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders EmptyWithoutCreatePermission story", () => {
|
||||
const { container } = renderWithMockContext(<EmptyWithoutCreatePermission />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders WithActiveFilter story", () => {
|
||||
const { container } = renderWithMockContext(<WithActiveFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders SmallList story", () => {
|
||||
const { container } = renderWithMockContext(<SmallList />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders LargeList story", () => {
|
||||
const { container } = renderWithMockContext(<LargeList />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders EmptyFavouriteFilter story", () => {
|
||||
const { container } = renderWithMockContext(<EmptyFavouriteFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders EmptyPeopleFilter story", () => {
|
||||
const { container } = renderWithMockContext(<EmptyPeopleFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders EmptyRoomsFilter story", () => {
|
||||
const { container } = renderWithMockContext(<EmptyRoomsFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders EmptyUnreadFilter story", () => {
|
||||
const { container } = renderWithMockContext(<EmptyUnreadFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders EmptyInvitesFilter story", () => {
|
||||
const { container } = renderWithMockContext(<EmptyInvitesFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders EmptyMentionsFilter story", () => {
|
||||
const { container } = renderWithMockContext(<EmptyMentionsFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders EmptyLowPriorityFilter story", () => {
|
||||
const { container } = renderWithMockContext(<EmptyLowPriorityFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should call onToggleFilter when filter is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Default />);
|
||||
|
||||
await user.click(screen.getByRole("option", { name: "People" }));
|
||||
|
||||
expect(Default.args.onToggleFilter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call createRoom when New room button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Empty />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New room" }));
|
||||
|
||||
expect(Empty.args.createRoom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call createChatRoom when Start chat button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Empty />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Start chat" }));
|
||||
|
||||
expect(Empty.args.createChatRoom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onToggleFilter when Show all chats is clicked in unread empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<EmptyUnreadFilter />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show all chats" }));
|
||||
|
||||
expect(EmptyUnreadFilter.args.onToggleFilter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onToggleFilter when See all activity is clicked in invites empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<EmptyInvitesFilter />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "See all activity" }));
|
||||
|
||||
expect(EmptyInvitesFilter.args.onToggleFilter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onToggleFilter when See all activity is clicked in mentions empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<EmptyMentionsFilter />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "See all activity" }));
|
||||
|
||||
expect(EmptyMentionsFilter.args.onToggleFilter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onToggleFilter when See all activity is clicked in low priority empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<EmptyLowPriorityFilter />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "See all activity" }));
|
||||
|
||||
expect(EmptyLowPriorityFilter.args.onToggleFilter).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 React, { type JSX, type ReactNode } from "react";
|
||||
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import { RoomListPrimaryFilters, type FilterId } from "../RoomListPrimaryFilters";
|
||||
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
|
||||
import { RoomListEmptyState } from "./RoomListEmptyState";
|
||||
import { RoomList, type RoomListViewState } from "../RoomList";
|
||||
import { type RoomListItemSnapshot } from "../RoomListItem";
|
||||
|
||||
/**
|
||||
* Snapshot for the room list view
|
||||
*/
|
||||
export type RoomListSnapshot = {
|
||||
/** Whether the rooms are currently loading */
|
||||
isLoadingRooms: boolean;
|
||||
/** Whether the room list is empty */
|
||||
isRoomListEmpty: boolean;
|
||||
/** Array of filter IDs */
|
||||
filterIds: FilterId[];
|
||||
/** Currently active filter ID (if any) */
|
||||
activeFilterId?: FilterId;
|
||||
/** Room list state */
|
||||
roomListState: RoomListViewState;
|
||||
/** Array of room IDs for virtualization */
|
||||
roomIds: string[];
|
||||
/** Optional description for the empty state */
|
||||
emptyStateDescription?: string;
|
||||
/** Optional action element for the empty state */
|
||||
emptyStateAction?: ReactNode;
|
||||
/** Whether the user can create rooms */
|
||||
canCreateRoom?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Actions interface for room list operations
|
||||
*/
|
||||
export interface RoomListViewActions {
|
||||
/** Called when a filter is toggled */
|
||||
onToggleFilter: (filterId: FilterId) => void;
|
||||
/** Called to create a new chat room */
|
||||
createChatRoom: () => void;
|
||||
/** Called to create a new room */
|
||||
createRoom: () => void;
|
||||
/** Get view model for a specific room (virtualization API) */
|
||||
getRoomItemViewModel: (roomId: string) => any;
|
||||
/** Called when the visible range changes (virtualization API) */
|
||||
updateVisibleRooms: (startIndex: number, endIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model type for the room list view
|
||||
*/
|
||||
export type RoomListViewModel = ViewModel<RoomListSnapshot> & RoomListViewActions;
|
||||
|
||||
/**
|
||||
* Props for RoomListView component
|
||||
*/
|
||||
export interface RoomListViewProps {
|
||||
/** The view model containing all data and callbacks */
|
||||
vm: RoomListViewModel;
|
||||
/** Render function for room avatar */
|
||||
renderAvatar: (roomItem: RoomListItemSnapshot) => ReactNode;
|
||||
/** Optional callback for keyboard events on the room list */
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room list view component that manages filters, loading states, empty states, and the room list.
|
||||
*/
|
||||
export const RoomListView: React.FC<RoomListViewProps> = ({ vm, renderAvatar, onKeyDown }): JSX.Element => {
|
||||
const snapshot = useViewModel(vm);
|
||||
let listBody: ReactNode;
|
||||
|
||||
if (snapshot.isLoadingRooms) {
|
||||
listBody = <RoomListLoadingSkeleton />;
|
||||
} else if (snapshot.isRoomListEmpty) {
|
||||
listBody = <RoomListEmptyState vm={vm} />;
|
||||
} else {
|
||||
listBody = <RoomList vm={vm} renderAvatar={renderAvatar} onKeyDown={onKeyDown} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<RoomListPrimaryFilters
|
||||
filterIds={snapshot.filterIds}
|
||||
activeFilterId={snapshot.activeFilterId}
|
||||
onToggleFilter={vm.onToggleFilter}
|
||||
/>
|
||||
</div>
|
||||
{listBody}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,12 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { RoomListView } from "./RoomListView";
|
||||
export type { RoomListViewProps, RoomListViewModel, RoomListSnapshot, RoomListViewActions } from "./RoomListView";
|
||||
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
|
||||
export { RoomListEmptyState } from "./RoomListEmptyState";
|
||||
export type { RoomListEmptyStateProps } from "./RoomListEmptyState";
|
||||