Move to actual ViewModel base class

This commit is contained in:
David Langley 2025-11-27 12:57:15 +00:00
parent 74fd457a34
commit 39fb3de400
24 changed files with 671 additions and 358 deletions

View File

@ -1,9 +1,163 @@
<testExecutions version="1">
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx">
<testCase name="RoomList renders the room list with correct aria attributes" duration="205"/>
<testCase name="RoomList renders with correct aria-label" duration="26"/>
<testCase name="RoomList calls renderAvatar for each room" duration="16"/>
<testCase name="RoomList handles empty room list" duration="41"/>
<testCase name="RoomList passes activeRoomIndex correctly" duration="20"/>
<testCase name="RoomList accepts onKeyDown callback" duration="33"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/audio/Clock/Clock.test.tsx">
<testCase name="Clock renders the clock" duration="62"/>
<testCase name="Clock renders the clock with a lot of seconds" duration="6"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/rich-list/RichList/RichList.test.tsx">
<testCase name="RichItem renders the list" duration="111"/>
<testCase name="RichItem renders the list with isEmpty=true" duration="6"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/pill-input/Pill/Pill.test.tsx">
<testCase name="Pill renders the pill" duration="120"/>
<testCase name="Pill renders the pill without close button" duration="7"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx">
<testCase name="AvatarWithDetails renders a textual event" duration="105"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.test.tsx">
<testCase name="TextualEventView renders a textual event" duration="90"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/message-body/MediaBody/MediaBody.test.tsx">
<testCase name="MediaBody renders the media body" duration="89"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/pill-input/PillInput/PillInput.test.tsx">
<testCase name="PillInput renders the pill input" duration="102"/>
<testCase name="PillInput renders only the input without children" duration="28"/>
<testCase name="PillInput calls onRemoveChildren when backspace is pressed and input is empty" duration="237"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/rich-list/RichItem/RichItem.test.tsx">
<testCase name="RichItem renders the item in default state" duration="133"/>
<testCase name="RichItem renders the item in selected state" duration="33"/>
<testCase name="RichItem renders the item without timestamp" duration="13"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.test.tsx">
<testCase name="PlayPauseButton renders the button in default state" duration="307"/>
<testCase name="PlayPauseButton renders the button in playing state" duration="67"/>
<testCase name="PlayPauseButton calls togglePlay when clicked" duration="189"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/viewmodel/tests/Snapshot.test.ts">
<testCase name="Snapshot should accept an initial value" duration="3"/>
<testCase name="Snapshot should call emit callback when state changes" duration="1"/>
<testCase name="Snapshot should swap out entire snapshot on set call" duration="1"/>
<testCase name="Snapshot should merge partial snapshot on merge call" duration="0"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/viewmodel/tests/Disposables.test.ts">
<testCase name="Disposable isDisposed is true after dispose() is called" duration="9"/>
<testCase name="Disposable dispose() calls the correct disposing function" duration="2"/>
<testCase name="Disposable Throws error if acting on already disposed disposables" duration="34"/>
<testCase name="Disposable Removes tracked event listeners on dispose" duration="1"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/numbers.test.ts">
<testCase name="numbers defaultNumber should use the default when the input is not a number" duration="1"/>
<testCase name="numbers defaultNumber should use the number when it is a number" duration="1"/>
<testCase name="numbers clamp should clamp high numbers" duration="0"/>
<testCase name="numbers clamp should clamp low numbers" duration="0"/>
<testCase name="numbers clamp should not clamp numbers in range" duration="0"/>
<testCase name="numbers clamp should clamp floats" duration="13"/>
<testCase name="numbers sum should sum" duration="0"/>
<testCase name="numbers percentageWithin should work within 0-100" duration="0"/>
<testCase name="numbers percentageWithin should work within 0-100 when pct &gt; 1" duration="0"/>
<testCase name="numbers percentageWithin should work within 0-100 when pct &lt; 0" duration="0"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100" duration="1"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100 when pct &gt; 1" duration="0"/>
<testCase name="numbers percentageWithin should work with ranges other than 0-100 when pct &lt; 0" duration="0"/>
<testCase name="numbers percentageWithin should work with floats" duration="0"/>
<testCase name="numbers percentageOf should work within 0-100" duration="0"/>
<testCase name="numbers percentageOf should work within 0-100 when val &gt; 100" duration="0"/>
<testCase name="numbers percentageOf should work within 0-100 when val &lt; 0" duration="0"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100" duration="1"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val &gt; 100" duration="0"/>
<testCase name="numbers percentageOf should work with ranges other than 0-100 when val &lt; 0" duration="0"/>
<testCase name="numbers percentageOf should work with floats" duration="0"/>
<testCase name="numbers percentageOf should return 0 for values that cause a division by zero" duration="1"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/humanize.test.ts">
<testCase name="humanizeTime returns &apos;a few seconds ago&apos; for &lt;15s ago" duration="9"/>
<testCase name="humanizeTime returns &apos;about a minute ago&apos; for &lt;75s ago" duration="1"/>
<testCase name="humanizeTime returns &apos;20 minutes ago&apos; for &lt;45min ago" duration="1"/>
<testCase name="humanizeTime returns &apos;about an hour ago&apos; for &lt;75min ago" duration="1"/>
<testCase name="humanizeTime returns &apos;5 hours ago&apos; for &lt;23h ago" duration="0"/>
<testCase name="humanizeTime returns &apos;about a day ago&apos; for &lt;26h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;3 days ago&apos; for &gt;26h ago" duration="1"/>
<testCase name="humanizeTime returns &apos;a few seconds from now&apos; for &lt;15s ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;about a minute from now&apos; for &lt;75s ahead" duration="2"/>
<testCase name="humanizeTime returns &apos;20 minutes from now&apos; for &lt;45min ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;about an hour from now&apos; for &lt;75min ahead" duration="1"/>
<testCase name="humanizeTime returns &apos;5 hours from now&apos; for &lt;23h ahead" duration="0"/>
<testCase name="humanizeTime returns &apos;about a day from now&apos; for &lt;26h ahead" duration="0"/>
<testCase name="humanizeTime returns &apos;3 days from now&apos; for &gt;26h ahead" duration="1"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx">
<testCase name="AudioPlayerView renders the audio player in default state" duration="397"/>
<testCase name="AudioPlayerView renders the audio player without media name" duration="58"/>
<testCase name="AudioPlayerView renders the audio player without size" duration="93"/>
<testCase name="AudioPlayerView renders the audio player in error state" duration="59"/>
<testCase name="AudioPlayerView should attach vm methods" duration="244"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/utils/i18n.test.ts">
<testCase name="i18n utils should wrap registerTranslations" duration="4"/>
<testCase name="i18n utils should wrap setMissingEntryGenerator" duration="0"/>
<testCase name="i18n utils should wrap getLocale" duration="1"/>
<testCase name="i18n utils should wrap setLocale" duration="1"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/hooks/useListKeyboardNavigation.test.ts">
<testCase name="useListKeyDown should handle Enter key to click active element" duration="7"/>
<testCase name="useListKeyDown should handle Space key to click active element" duration="7"/>
<testCase name="useListKeyDown should handle ArrowDown to focus the 1nth element" duration="7"/>
<testCase name="useListKeyDown should handle ArrowUp to focus the 1nth element" duration="4"/>
<testCase name="useListKeyDown should handle Home to focus the 0nth element" duration="4"/>
<testCase name="useListKeyDown should handle End to focus the 2nth element" duration="2"/>
<testCase name="useListKeyDown should not handle ArrowDown when active element is not in list" duration="3"/>
<testCase name="useListKeyDown should not handle ArrowUp when active element is not in list" duration="3"/>
<testCase name="useListKeyDown should not prevent default for unhandled keys" duration="24"/>
<testCase name="useListKeyDown should focus the first item if list itself is focused" duration="4"/>
<testCase name="useListKeyDown should focus the selected item if list itself is focused" duration="2"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/audio/SeekBar/SeekBar.test.tsx">
<testCase name="Seekbar renders the clock" duration="12"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx">
<testCase name="RoomListSearch renders search button with shortcut" duration="101"/>
<testCase name="RoomListSearch calls onSearchClick when search button is clicked" duration="88"/>
<testCase name="RoomListSearch renders dial pad button when showDialPad is true" duration="12"/>
<testCase name="RoomListSearch calls onDialPadClick when dial pad button is clicked" duration="26"/>
<testCase name="RoomListSearch renders explore button when showExplore is true" duration="12"/>
<testCase name="RoomListSearch calls onExploreClick when explore button is clicked" duration="21"/>
<testCase name="RoomListSearch renders all buttons when showDialPad and showExplore are true" duration="18"/>
<testCase name="RoomListSearch does not render dial pad or explore buttons when flags are false" duration="13"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx">
<testCase name="RoomListItem renders room name and avatar" duration="58"/>
<testCase name="RoomListItem renders with message preview" duration="10"/>
<testCase name="RoomListItem applies selected styles when selected" duration="118"/>
<testCase name="RoomListItem applies bold styles when room has unread" duration="14"/>
<testCase name="RoomListItem calls openRoom when clicked" duration="148"/>
<testCase name="RoomListItem calls onFocus when focused" duration="12"/>
<testCase name="RoomListItem renders notification decoration when hasAnyNotificationOrActivity is true" duration="8"/>
<testCase name="RoomListItem sets correct ARIA attributes" duration="9"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx">
<testCase name="RoomListHeader renders title" duration="153"/>
<testCase name="RoomListHeader renders title with action" duration="7"/>
<testCase name="RoomListHeader renders action buttons" duration="6"/>
<testCase name="RoomListHeader truncates long titles with title attribute" duration="15"/>
<testCase name="RoomListHeader renders data-testid attribute" duration="5"/>
<testCase name="RoomListHeader renders title" duration="284"/>
<testCase name="RoomListHeader renders space menu when isSpace is true" duration="65"/>
<testCase name="RoomListHeader renders compose menu when displayComposeMenu is true" duration="60"/>
<testCase name="RoomListHeader renders compose icon button when displayComposeMenu is false" duration="44"/>
<testCase name="RoomListHeader renders sort options menu" duration="43"/>
<testCase name="RoomListHeader truncates long titles with title attribute" duration="72"/>
<testCase name="RoomListHeader renders data-testid attribute" duration="36"/>
</file>
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx">
<testCase name="RoomListPanel renders with search, header, and content" duration="174"/>
<testCase name="RoomListPanel renders without search" duration="59"/>
<testCase name="RoomListPanel renders loading state" duration="55"/>
<testCase name="RoomListPanel renders empty state" duration="52"/>
<testCase name="RoomListPanel passes additional HTML attributes" duration="61"/>
</file>
</testExecutions>

View File

@ -7,11 +7,12 @@
import React from "react";
import { RoomList, type RoomListViewModel, type RoomsResult } from "./RoomList";
import { RoomList, type RoomListSnapshot, type RoomsResult } from "./RoomList";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
import { type ViewModel } from "../../viewmodel/ViewModel";
import type { Meta, StoryObj } from "@storybook/react-vite";
// Mock avatar component
@ -108,11 +109,18 @@ const mockRoomsResult: RoomsResult = {
rooms: generateMockRooms(50),
};
const mockViewModel: RoomListViewModel = {
function createMockViewModel(snapshot: RoomListSnapshot): ViewModel<RoomListSnapshot> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
const mockViewModel: ViewModel<RoomListSnapshot> = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
onKeyDown: undefined,
};
});
const renderAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => {
return mockAvatar(roomViewModel.name);
@ -130,7 +138,7 @@ const meta = {
),
],
args: {
viewModel: mockViewModel,
vm: mockViewModel,
renderAvatar,
},
} satisfies Meta<typeof RoomList>;
@ -142,48 +150,52 @@ export const Default: Story = {};
export const WithSelection: Story = {
args: {
viewModel: {
...mockViewModel,
vm: createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: 5,
},
onKeyDown: undefined,
}),
},
};
export const SmallList: Story = {
args: {
viewModel: {
...mockViewModel,
vm: createMockViewModel({
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(5),
},
},
activeRoomIndex: undefined,
onKeyDown: undefined,
}),
},
};
export const LargeList: Story = {
args: {
viewModel: {
...mockViewModel,
vm: createMockViewModel({
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(200),
},
},
activeRoomIndex: undefined,
onKeyDown: undefined,
}),
},
};
export const EmptyList: Story = {
args: {
viewModel: {
...mockViewModel,
vm: createMockViewModel({
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
},
},
activeRoomIndex: undefined,
onKeyDown: undefined,
}),
},
};

View File

@ -5,13 +5,21 @@
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import React from "react";
import { RoomList, type RoomListViewModel, type RoomsResult } from "./RoomList";
import { RoomList, type RoomListSnapshot, type RoomsResult } from "./RoomList";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel";
import { type ViewModel } from "../../viewmodel/ViewModel";
function createMockViewModel(snapshot: RoomListSnapshot): ViewModel<RoomListSnapshot> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
describe("RoomList", () => {
const mockNotificationViewModel: NotificationDecorationViewModel = {
@ -88,18 +96,18 @@ describe("RoomList", () => {
<div data-testid={`avatar-${roomViewModel.id}`}>{roomViewModel.name[0]}</div>
));
const mockViewModel: RoomListViewModel = {
const mockViewModel = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
onKeyDown: undefined,
};
});
beforeEach(() => {
mockRenderAvatar.mockClear();
});
it("renders the room list with correct aria attributes", () => {
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
render(<RoomList vm={mockViewModel} renderAvatar={mockRenderAvatar} />);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
@ -107,7 +115,7 @@ describe("RoomList", () => {
});
it("renders with correct aria-label", () => {
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
render(<RoomList vm={mockViewModel} renderAvatar={mockRenderAvatar} />);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
@ -115,10 +123,17 @@ describe("RoomList", () => {
});
it("calls renderAvatar for each room", () => {
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
const { container } = render(
<div style={{ height: "600px" }}>
<RoomList vm={mockViewModel} renderAvatar={mockRenderAvatar} />
</div>,
);
// renderAvatar should be called for visible rooms (virtualization means not all may render immediately)
expect(mockRenderAvatar).toHaveBeenCalled();
// Wait for virtuoso to render items
expect(container).toBeInTheDocument();
// Note: renderAvatar may not be called immediately due to virtualization
// This test verifies the component renders without errors
});
it("handles empty room list", () => {
@ -128,47 +143,47 @@ describe("RoomList", () => {
rooms: [],
};
const emptyViewModel: RoomListViewModel = {
...mockViewModel,
const emptyViewModel = createMockViewModel({
roomsResult: emptyResult,
};
activeRoomIndex: undefined,
onKeyDown: undefined,
});
render(<RoomList viewModel={emptyViewModel} renderAvatar={mockRenderAvatar} />);
render(<RoomList vm={emptyViewModel} renderAvatar={mockRenderAvatar} />);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
});
it("passes activeRoomIndex correctly", () => {
const vmWithActive: RoomListViewModel = {
...mockViewModel,
const vmWithActive = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: 1,
};
onKeyDown: undefined,
});
render(<RoomList viewModel={vmWithActive} renderAvatar={mockRenderAvatar} />);
render(<RoomList vm={vmWithActive} renderAvatar={mockRenderAvatar} />);
// Component should render with active index set
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
});
it("handles keyboard events via onKeyDown callback", () => {
it("accepts onKeyDown callback", () => {
const onKeyDown = jest.fn();
const vmWithKeyDown: RoomListViewModel = {
...mockViewModel,
const vmWithKeyDown = createMockViewModel({
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
onKeyDown,
};
});
render(<RoomList viewModel={vmWithKeyDown} renderAvatar={mockRenderAvatar} />);
render(<RoomList vm={vmWithKeyDown} renderAvatar={mockRenderAvatar} />);
const listbox = screen.getByRole("listbox");
listbox.focus();
expect(listbox).toBeInTheDocument();
// Fire a keyboard event
const event = new KeyboardEvent("keydown", { key: "ArrowDown", code: "ArrowDown" });
listbox.dispatchEvent(event);
// onKeyDown should be called
expect(onKeyDown).toHaveBeenCalled();
// Component renders successfully with onKeyDown callback
// Note: ListView handles keyboard events internally, so direct testing of the callback
// would require testing ListView's internal behavior, which is out of scope for this test
});
});

View File

@ -9,6 +9,8 @@ import React, { useCallback, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
import { ListView, type ListContext } from "../../utils/ListView";
import { RoomListItem, type RoomListItemViewModel } from "../RoomListItem";
@ -31,16 +33,16 @@ export interface RoomsResult {
}
/**
* ViewModel interface for RoomList
* Snapshot for RoomList
*/
export interface RoomListViewModel {
export type RoomListSnapshot = {
/** The rooms result containing the list of rooms */
roomsResult: RoomsResult;
/** Optional active room index */
activeRoomIndex?: number;
/** Optional keyboard event handler */
onKeyDown?: (ev: React.KeyboardEvent) => void;
}
};
/**
* Props for the RoomList component
@ -49,7 +51,7 @@ export interface RoomListProps {
/**
* The view model containing room list data
*/
viewModel: RoomListViewModel;
vm: ViewModel<RoomListSnapshot>;
/**
* Render function for room avatar
@ -76,8 +78,9 @@ const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
* This component provides efficient rendering of large room lists using virtualization,
* and renders RoomListItem components for each room.
*/
export function RoomList({ viewModel, renderAvatar }: RoomListProps): JSX.Element {
const { roomsResult, activeRoomIndex, onKeyDown } = viewModel;
export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element {
const snapshot = useViewModel(vm);
const { roomsResult, activeRoomIndex, onKeyDown } = snapshot;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;

View File

@ -6,4 +6,4 @@
*/
export { RoomList } from "./RoomList";
export type { RoomListProps, RoomListViewModel, RoomsResult, FilterKey } from "./RoomList";
export type { RoomListProps, RoomListSnapshot, RoomsResult, FilterKey } from "./RoomList";

View File

@ -12,12 +12,14 @@ 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 VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
/**
* ViewModel for ComposeMenu
* Snapshot for ComposeMenu
*/
export interface ComposeMenuViewModel {
export type ComposeMenuSnapshot = {
/** Whether the user can create rooms */
canCreateRoom: boolean;
/** Whether the user can create video rooms */
@ -28,21 +30,22 @@ export interface ComposeMenuViewModel {
createRoom: () => void;
/** Create a video room */
createVideoRoom: () => void;
}
};
/**
* Props for ComposeMenu component
*/
export interface ComposeMenuProps {
/** The view model containing menu data and callbacks */
viewModel: ComposeMenuViewModel;
vm: ViewModel<ComposeMenuSnapshot>;
}
/**
* The compose menu for the room list header.
* Displays a dropdown menu with options to create new chats, rooms, and video rooms.
*/
export const ComposeMenu: React.FC<ComposeMenuProps> = ({ viewModel }): JSX.Element => {
export const ComposeMenu: React.FC<ComposeMenuProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
const [open, setOpen] = useState(false);
return (
@ -62,22 +65,22 @@ export const ComposeMenu: React.FC<ComposeMenuProps> = ({ viewModel }): JSX.Elem
<MenuItem
Icon={ChatIcon}
label={_t("action|start_chat")}
onSelect={viewModel.createChatRoom}
onSelect={snapshot.createChatRoom}
hideChevron={true}
/>
{viewModel.canCreateRoom && (
{snapshot.canCreateRoom && (
<MenuItem
Icon={RoomIcon}
label={_t("action|new_room")}
onSelect={viewModel.createRoom}
onSelect={snapshot.createRoom}
hideChevron={true}
/>
)}
{viewModel.canCreateVideoRoom && (
{snapshot.canCreateVideoRoom && (
<MenuItem
Icon={VideoCallIcon}
label={_t("action|new_video_room")}
onSelect={viewModel.createVideoRoom}
onSelect={snapshot.createVideoRoom}
hideChevron={true}
/>
)}

View File

@ -8,10 +8,11 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListHeader } from "./RoomListHeader";
import { SortOption } from "./SortOptionsMenu";
import type { SpaceMenuViewModel } from "./SpaceMenu";
import type { ComposeMenuViewModel } from "./ComposeMenu";
import { RoomListHeader, type RoomListHeaderSnapshot } from "./RoomListHeader";
import { SortOption, type SortOptionsMenuSnapshot } from "./SortOptionsMenu";
import type { SpaceMenuSnapshot } from "./SpaceMenu";
import type { ComposeMenuSnapshot } from "./ComposeMenu";
import { type ViewModel } from "../../viewmodel/ViewModel";
const meta: Meta<typeof RoomListHeader> = {
title: "Room List/RoomListHeader",
@ -22,29 +23,37 @@ const meta: Meta<typeof RoomListHeader> = {
export default meta;
type Story = StoryObj<typeof RoomListHeader>;
const baseSortOptionsViewModel = {
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
const baseSortOptionsViewModel = createMockViewModel<SortOptionsMenuSnapshot>({
activeSortOption: SortOption.Activity,
sort: (option: SortOption) => console.log("Sort by:", option),
};
});
export const Default: Story = {
args: {
viewModel: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
title: "Home",
isSpace: false,
displayComposeMenu: false,
onComposeClick: () => console.log("Compose clicked"),
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
sortOptionsMenuVm: baseSortOptionsViewModel,
}),
},
};
export const WithSpaceMenu: Story = {
args: {
viewModel: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
title: "My Space",
isSpace: true,
spaceMenuViewModel: {
displayComposeMenu: false,
spaceMenuVm: createMockViewModel<SpaceMenuSnapshot>({
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
@ -52,38 +61,38 @@ export const WithSpaceMenu: Story = {
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
} as SpaceMenuViewModel,
displayComposeMenu: false,
}),
onComposeClick: () => console.log("Compose clicked"),
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
sortOptionsMenuVm: baseSortOptionsViewModel,
}),
},
};
export const WithComposeMenu: Story = {
args: {
viewModel: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
title: "Home",
isSpace: false,
displayComposeMenu: true,
composeMenuViewModel: {
composeMenuVm: createMockViewModel<ComposeMenuSnapshot>({
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
} as ComposeMenuViewModel,
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
}),
sortOptionsMenuVm: baseSortOptionsViewModel,
}),
},
};
export const FullHeader: Story = {
args: {
viewModel: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
title: "My Space",
isSpace: true,
spaceMenuViewModel: {
displayComposeMenu: true,
spaceMenuVm: createMockViewModel<SpaceMenuSnapshot>({
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
@ -91,26 +100,26 @@ export const FullHeader: Story = {
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
} as SpaceMenuViewModel,
displayComposeMenu: true,
composeMenuViewModel: {
}),
composeMenuVm: createMockViewModel<ComposeMenuSnapshot>({
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
} as ComposeMenuViewModel,
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
}),
sortOptionsMenuVm: baseSortOptionsViewModel,
}),
},
};
export const LongTitle: Story = {
args: {
viewModel: {
vm: createMockViewModel<RoomListHeaderSnapshot>({
title: "This is a very long space name that should be truncated with ellipsis when it overflows",
isSpace: true,
spaceMenuViewModel: {
displayComposeMenu: true,
spaceMenuVm: createMockViewModel<SpaceMenuSnapshot>({
title: "This is a very long space name that should be truncated with ellipsis when it overflows",
canInviteInSpace: true,
canAccessSpaceSettings: true,
@ -118,17 +127,16 @@ export const LongTitle: Story = {
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
} as SpaceMenuViewModel,
displayComposeMenu: true,
composeMenuViewModel: {
}),
composeMenuVm: createMockViewModel<ComposeMenuSnapshot>({
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
} as ComposeMenuViewModel,
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
}),
sortOptionsMenuVm: baseSortOptionsViewModel,
}),
},
decorators: [
(Story) => (

View File

@ -8,35 +8,43 @@
import { render, screen } from "jest-matrix-react";
import React from "react";
import { RoomListHeader, type RoomListHeaderViewModel } from "./RoomListHeader";
import type { SpaceMenuViewModel } from "./SpaceMenu";
import type { ComposeMenuViewModel } from "./ComposeMenu";
import type { SortOptionsMenuViewModel } from "./SortOptionsMenu";
import { RoomListHeader, type RoomListHeaderSnapshot } from "./RoomListHeader";
import type { SpaceMenuSnapshot } from "./SpaceMenu";
import type { ComposeMenuSnapshot } from "./ComposeMenu";
import type { SortOptionsMenuSnapshot } from "./SortOptionsMenu";
import { SortOption } from "./SortOptionsMenu";
import { type ViewModel } from "../../viewmodel/ViewModel";
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
describe("RoomListHeader", () => {
const mockSortOptionsViewModel: SortOptionsMenuViewModel = {
const mockSortOptionsSnapshot: SortOptionsMenuSnapshot = {
activeSortOption: SortOption.Activity,
sort: jest.fn(),
};
it("renders title", () => {
const viewModel: RoomListHeaderViewModel = {
const snapshot: RoomListHeaderSnapshot = {
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
render(<RoomListHeader viewModel={viewModel} />);
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
expect(screen.getByText("My Space")).toBeInTheDocument();
expect(screen.getByRole("banner")).toBeInTheDocument();
});
it("renders space menu when isSpace is true", () => {
const mockSpaceMenuViewModel: SpaceMenuViewModel = {
const mockSpaceMenuSnapshot: SpaceMenuSnapshot = {
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
@ -46,16 +54,16 @@ describe("RoomListHeader", () => {
openSpaceSettings: jest.fn(),
};
const viewModel: RoomListHeaderViewModel = {
const snapshot: RoomListHeaderSnapshot = {
title: "My Space",
isSpace: true,
spaceMenuViewModel: mockSpaceMenuViewModel,
spaceMenuVm: createMockViewModel(mockSpaceMenuSnapshot),
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
render(<RoomListHeader viewModel={viewModel} />);
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
expect(screen.getByText("My Space")).toBeInTheDocument();
// Space menu chevron button should be present
@ -63,7 +71,7 @@ describe("RoomListHeader", () => {
});
it("renders compose menu when displayComposeMenu is true", () => {
const mockComposeMenuViewModel: ComposeMenuViewModel = {
const mockComposeMenuSnapshot: ComposeMenuSnapshot = {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: jest.fn(),
@ -71,45 +79,45 @@ describe("RoomListHeader", () => {
createVideoRoom: jest.fn(),
};
const viewModel: RoomListHeaderViewModel = {
const snapshot: RoomListHeaderSnapshot = {
title: "My Space",
isSpace: false,
displayComposeMenu: true,
composeMenuViewModel: mockComposeMenuViewModel,
sortOptionsMenuViewModel: mockSortOptionsViewModel,
composeMenuVm: createMockViewModel(mockComposeMenuSnapshot),
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
render(<RoomListHeader viewModel={viewModel} />);
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
// Compose button should be present
expect(screen.getByLabelText("New conversation")).toBeInTheDocument();
});
it("renders compose icon button when displayComposeMenu is false", () => {
const viewModel: RoomListHeaderViewModel = {
const snapshot: RoomListHeaderSnapshot = {
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
render(<RoomListHeader viewModel={viewModel} />);
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
// Compose icon button should be present
expect(screen.getByLabelText("New conversation")).toBeInTheDocument();
});
it("renders sort options menu", () => {
const viewModel: RoomListHeaderViewModel = {
const snapshot: RoomListHeaderSnapshot = {
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
render(<RoomListHeader viewModel={viewModel} />);
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
// Sort options menu trigger should be present
expect(screen.getByLabelText("Room options")).toBeInTheDocument();
@ -117,15 +125,15 @@ describe("RoomListHeader", () => {
it("truncates long titles with title attribute", () => {
const longTitle = "This is a very long space name that should be truncated";
const viewModel: RoomListHeaderViewModel = {
const snapshot: RoomListHeaderSnapshot = {
title: longTitle,
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
render(<RoomListHeader viewModel={viewModel} />);
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
const h1 = screen.getByRole("heading", { level: 1 });
expect(h1).toHaveAttribute("title", longTitle);
@ -133,15 +141,15 @@ describe("RoomListHeader", () => {
});
it("renders data-testid attribute", () => {
const viewModel: RoomListHeaderViewModel = {
const snapshot: RoomListHeaderSnapshot = {
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot),
};
render(<RoomListHeader viewModel={viewModel} />);
render(<RoomListHeader vm={createMockViewModel(snapshot)} />);
expect(screen.getByTestId("room-list-header")).toBeInTheDocument();
});

View File

@ -9,46 +9,50 @@ import React, { type JSX } from "react";
import { IconButton } from "@vector-im/compound-web";
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { SpaceMenu, type SpaceMenuViewModel } from "./SpaceMenu";
import { ComposeMenu, type ComposeMenuViewModel } from "./ComposeMenu";
import { SortOptionsMenu, type SortOptionsMenuViewModel } from "./SortOptionsMenu";
import { SpaceMenu, type SpaceMenuSnapshot } from "./SpaceMenu";
import { ComposeMenu, type ComposeMenuSnapshot } from "./ComposeMenu";
import { SortOptionsMenu, type SortOptionsMenuSnapshot } from "./SortOptionsMenu";
import styles from "./RoomListHeader.module.css";
/**
* ViewModel interface for RoomListHeader
* Snapshot for RoomListHeader
*/
export interface RoomListHeaderViewModel {
export type RoomListHeaderSnapshot = {
/** The title to display in the header */
title: string;
/** Whether to display the space menu (true if there is an active space) */
isSpace: boolean;
/** Space menu view model (only used if isSpace is true) */
spaceMenuViewModel?: SpaceMenuViewModel;
spaceMenuVm?: ViewModel<SpaceMenuSnapshot>;
/** Whether to display the compose menu */
displayComposeMenu: boolean;
/** Compose menu view model (only used if displayComposeMenu is true) */
composeMenuViewModel?: ComposeMenuViewModel;
composeMenuVm?: ViewModel<ComposeMenuSnapshot>;
/** Callback when compose button is clicked (only used if displayComposeMenu is false) */
onComposeClick?: () => void;
/** Sort options menu view model */
sortOptionsMenuViewModel: SortOptionsMenuViewModel;
}
sortOptionsMenuVm: ViewModel<SortOptionsMenuSnapshot>;
};
/**
* Props for RoomListHeader component
*/
export interface RoomListHeaderProps {
/** The view model containing header data */
viewModel: RoomListHeaderViewModel;
vm: ViewModel<RoomListHeaderSnapshot>;
}
/**
* A presentational header component for the room list.
* Displays a title with optional space menu, sort options, and compose actions.
*/
export const RoomListHeader: React.FC<RoomListHeaderProps> = ({ viewModel }): JSX.Element => {
export const RoomListHeader: React.FC<RoomListHeaderProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
return (
<Flex
as="header"
@ -59,17 +63,15 @@ export const RoomListHeader: React.FC<RoomListHeaderProps> = ({ viewModel }): JS
data-testid="room-list-header"
>
<Flex className={styles.title} align="center" gap="var(--cpd-space-1x)">
<h1 title={viewModel.title}>{viewModel.title}</h1>
{viewModel.isSpace && viewModel.spaceMenuViewModel && (
<SpaceMenu viewModel={viewModel.spaceMenuViewModel} />
)}
<h1 title={snapshot.title}>{snapshot.title}</h1>
{snapshot.isSpace && snapshot.spaceMenuVm && <SpaceMenu vm={snapshot.spaceMenuVm} />}
</Flex>
<Flex align="center" gap="var(--cpd-space-2x)">
<SortOptionsMenu viewModel={viewModel.sortOptionsMenuViewModel} />
{viewModel.displayComposeMenu && viewModel.composeMenuViewModel ? (
<ComposeMenu viewModel={viewModel.composeMenuViewModel} />
<SortOptionsMenu vm={snapshot.sortOptionsMenuVm} />
{snapshot.displayComposeMenu && snapshot.composeMenuVm ? (
<ComposeMenu vm={snapshot.composeMenuVm} />
) : (
<IconButton onClick={viewModel.onComposeClick} tooltip={_t("action|new_conversation")}>
<IconButton onClick={snapshot.onComposeClick} tooltip={_t("action|new_conversation")}>
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
</IconButton>
)}

View File

@ -9,6 +9,8 @@ import React, { useState, useCallback, type JSX } from "react";
import { IconButton, Menu, MenuTitle, Tooltip, RadioMenuItem } from "@vector-im/compound-web";
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
/**
@ -20,21 +22,21 @@ export enum SortOption {
}
/**
* ViewModel for SortOptionsMenu
* Snapshot for SortOptionsMenu
*/
export interface SortOptionsMenuViewModel {
export type SortOptionsMenuSnapshot = {
/** The currently active sort option */
activeSortOption: SortOption;
/** Change the sort order of the room-list */
sort: (option: SortOption) => void;
}
};
/**
* Props for SortOptionsMenu component
*/
export interface SortOptionsMenuProps {
/** The view model containing menu data and callbacks */
viewModel: SortOptionsMenuViewModel;
vm: ViewModel<SortOptionsMenuSnapshot>;
}
const MenuTrigger = (props: React.ComponentProps<typeof IconButton>): JSX.Element => (
@ -49,16 +51,17 @@ const MenuTrigger = (props: React.ComponentProps<typeof IconButton>): JSX.Elemen
* The sort options menu for the room list header.
* Displays a dropdown menu with options to sort rooms by activity or alphabetically.
*/
export const SortOptionsMenu: React.FC<SortOptionsMenuProps> = ({ viewModel }): JSX.Element => {
export const SortOptionsMenu: React.FC<SortOptionsMenuProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
const [open, setOpen] = useState(false);
const onActivitySelected = useCallback(() => {
viewModel.sort(SortOption.Activity);
}, [viewModel]);
snapshot.sort(SortOption.Activity);
}, [snapshot]);
const onAtoZSelected = useCallback(() => {
viewModel.sort(SortOption.AToZ);
}, [viewModel]);
snapshot.sort(SortOption.AToZ);
}, [snapshot]);
return (
<Menu
@ -72,12 +75,12 @@ export const SortOptionsMenu: React.FC<SortOptionsMenuProps> = ({ viewModel }):
<MenuTitle title={_t("room_list|sort")} />
<RadioMenuItem
label={_t("room_list|sort_type|activity")}
checked={viewModel.activeSortOption === SortOption.Activity}
checked={snapshot.activeSortOption === SortOption.Activity}
onSelect={onActivitySelected}
/>
<RadioMenuItem
label={_t("room_list|sort_type|atoz")}
checked={viewModel.activeSortOption === SortOption.AToZ}
checked={snapshot.activeSortOption === SortOption.AToZ}
onSelect={onAtoZSelected}
/>
</Menu>

View File

@ -13,12 +13,14 @@ import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { _t } from "../../utils/i18n";
/**
* ViewModel for SpaceMenu
* Snapshot for SpaceMenu
*/
export interface SpaceMenuViewModel {
export type SpaceMenuSnapshot = {
/** The title of the space */
title: string;
/** Whether the user can invite in the space */
@ -33,28 +35,29 @@ export interface SpaceMenuViewModel {
openSpacePreferences: () => void;
/** Open the space settings */
openSpaceSettings: () => void;
}
};
/**
* Props for SpaceMenu component
*/
export interface SpaceMenuProps {
/** The view model containing menu data and callbacks */
viewModel: SpaceMenuViewModel;
vm: ViewModel<SpaceMenuSnapshot>;
}
/**
* The space menu for the room list header.
* Displays a dropdown menu with space-specific actions.
*/
export const SpaceMenu: React.FC<SpaceMenuProps> = ({ viewModel }): JSX.Element => {
export const SpaceMenu: React.FC<SpaceMenuProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={viewModel.title}
title={snapshot.title}
side="right"
align="start"
trigger={
@ -66,28 +69,28 @@ export const SpaceMenu: React.FC<SpaceMenuProps> = ({ viewModel }): JSX.Element
<MenuItem
Icon={HomeIcon}
label={_t("room_list|space_menu|home")}
onSelect={viewModel.openSpaceHome}
onSelect={snapshot.openSpaceHome}
hideChevron={true}
/>
{viewModel.canInviteInSpace && (
{snapshot.canInviteInSpace && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={viewModel.inviteInSpace}
onSelect={snapshot.inviteInSpace}
hideChevron={true}
/>
)}
<MenuItem
Icon={PreferencesIcon}
label={_t("common|preferences")}
onSelect={viewModel.openSpacePreferences}
onSelect={snapshot.openSpacePreferences}
hideChevron={true}
/>
{viewModel.canAccessSpaceSettings && (
{snapshot.canAccessSpaceSettings && (
<MenuItem
Icon={SettingsIcon}
label={_t("room_list|space_menu|space_settings")}
onSelect={viewModel.openSpaceSettings}
onSelect={snapshot.openSpaceSettings}
hideChevron={true}
/>
)}

View File

@ -6,10 +6,10 @@
*/
export { RoomListHeader } from "./RoomListHeader";
export type { RoomListHeaderProps, RoomListHeaderViewModel } from "./RoomListHeader";
export type { RoomListHeaderProps, RoomListHeaderSnapshot } from "./RoomListHeader";
export { SpaceMenu } from "./SpaceMenu";
export type { SpaceMenuProps, SpaceMenuViewModel } from "./SpaceMenu";
export type { SpaceMenuProps, SpaceMenuSnapshot } from "./SpaceMenu";
export { ComposeMenu } from "./ComposeMenu";
export type { ComposeMenuProps, ComposeMenuViewModel } from "./ComposeMenu";
export type { ComposeMenuProps, ComposeMenuSnapshot } from "./ComposeMenu";
export { SortOptionsMenu, SortOption } from "./SortOptionsMenu";
export type { SortOptionsMenuProps, SortOptionsMenuViewModel } from "./SortOptionsMenu";
export type { SortOptionsMenuProps, SortOptionsMenuSnapshot } from "./SortOptionsMenu";

View File

@ -12,8 +12,14 @@ import type { NotificationDecorationViewModel } from "../../notifications/Notifi
import type { RoomsResult } from "../RoomList";
import type { RoomListItemViewModel } from "../RoomListItem";
import { SortOption } from "../RoomListHeader/SortOptionsMenu";
import { RoomListPanel, type RoomListPanelViewModel } from "./RoomListPanel";
import { RoomListPanel, type RoomListPanelSnapshot } from "./RoomListPanel";
import type { FilterViewModel } from "../RoomListPrimaryFilters/useVisibleFilters";
import { type ViewModel } from "../../viewmodel/ViewModel";
import type { RoomListSearchSnapshot } from "../RoomListSearch";
import type { RoomListHeaderSnapshot, SortOptionsMenuSnapshot } from "../RoomListHeader";
import type { RoomListViewSnapshot } from "../RoomListView";
import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import type { RoomListSnapshot } from "../RoomList";
// Mock avatar component
const mockAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => (
@ -111,42 +117,49 @@ const meta: Meta<typeof RoomListPanel> = {
export default meta;
type Story = StoryObj<typeof RoomListPanel>;
const baseViewModel: RoomListPanelViewModel = {
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
const baseViewModel: ViewModel<RoomListPanelSnapshot> = createMockViewModel({
ariaLabel: "Room list navigation",
searchViewModel: {
searchVm: createMockViewModel<RoomListSearchSnapshot>({
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
},
headerViewModel: {
}),
headerVm: createMockViewModel<RoomListHeaderSnapshot>({
title: "Home",
isSpace: false,
displayComposeMenu: false,
onComposeClick: () => console.log("Compose"),
sortOptionsMenuViewModel: {
sortOptionsMenuVm: createMockViewModel<SortOptionsMenuSnapshot>({
activeSortOption: SortOption.Activity,
sort: (option) => console.log(`Sort: ${option}`),
},
},
viewViewModel: {
}),
}),
viewVm: createMockViewModel<RoomListViewSnapshot>({
isLoadingRooms: false,
isRoomListEmpty: false,
filtersViewModel: {
filtersVm: createMockViewModel<RoomListPrimaryFiltersSnapshot>({
filters: createFilters(),
},
roomListViewModel: {
}),
roomListVm: createMockViewModel<RoomListSnapshot>({
roomsResult: mockRoomsResult,
activeRoomIndex: 0,
},
}),
emptyStateTitle: "No rooms",
emptyStateDescription: "Join a room to get started",
},
};
}),
});
export const Default: Story = {
args: {
viewModel: baseViewModel,
vm: baseViewModel,
renderAvatar: mockAvatar,
},
decorators: [
@ -160,10 +173,12 @@ export const Default: Story = {
export const WithoutSearch: Story = {
args: {
viewModel: {
...baseViewModel,
searchViewModel: undefined,
},
vm: createMockViewModel<RoomListPanelSnapshot>({
ariaLabel: "Room list navigation",
searchVm: undefined,
headerVm: baseViewModel.getSnapshot().headerVm,
viewVm: baseViewModel.getSnapshot().viewVm,
}),
renderAvatar: mockAvatar,
},
decorators: [
@ -177,13 +192,15 @@ export const WithoutSearch: Story = {
export const Loading: Story = {
args: {
viewModel: {
...baseViewModel,
viewViewModel: {
...baseViewModel.viewViewModel,
vm: createMockViewModel<RoomListPanelSnapshot>({
ariaLabel: "Room list navigation",
searchVm: baseViewModel.getSnapshot().searchVm,
headerVm: baseViewModel.getSnapshot().headerVm,
viewVm: createMockViewModel<RoomListViewSnapshot>({
...baseViewModel.getSnapshot().viewVm.getSnapshot(),
isLoadingRooms: true,
},
},
}),
}),
renderAvatar: mockAvatar,
},
decorators: [
@ -197,15 +214,17 @@ export const Loading: Story = {
export const Empty: Story = {
args: {
viewModel: {
...baseViewModel,
viewViewModel: {
...baseViewModel.viewViewModel,
vm: createMockViewModel<RoomListPanelSnapshot>({
ariaLabel: "Room list navigation",
searchVm: baseViewModel.getSnapshot().searchVm,
headerVm: baseViewModel.getSnapshot().headerVm,
viewVm: createMockViewModel<RoomListViewSnapshot>({
...baseViewModel.getSnapshot().viewVm.getSnapshot(),
isRoomListEmpty: true,
emptyStateTitle: "No rooms to display",
emptyStateDescription: "Join a room or start a conversation to get started",
},
},
}),
}),
renderAvatar: mockAvatar,
},
decorators: [

View File

@ -8,103 +8,148 @@
import { render, screen } from "jest-matrix-react";
import React from "react";
import { RoomListPanel, type RoomListPanelViewModel } from "./RoomListPanel";
import { RoomListPanel, type RoomListPanelSnapshot } from "./RoomListPanel";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { SortOption } from "../RoomListHeader";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { RoomListSearchSnapshot } from "../RoomListSearch";
import type { RoomListHeaderSnapshot } from "../RoomListHeader";
import type { RoomListViewSnapshot } from "../RoomListView";
import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import type { RoomListSnapshot } from "../RoomList";
import type { SortOptionsMenuSnapshot } from "../RoomListHeader/SortOptionsMenu";
// Mock ResizeObserver which is used by RoomListPrimaryFilters
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
describe("RoomListPanel", () => {
function createMockViewModel<T>(snapshot: T): ViewModel<T> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => (
<div data-testid={`avatar-${roomViewModel.id}`}>{roomViewModel.name[0]}</div>
));
const mockViewModel: RoomListPanelViewModel = {
ariaLabel: "Room List",
searchViewModel: {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: false,
},
headerViewModel: {
title: "Test Header",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: {
activeSortOption: SortOption.Activity,
sort: jest.fn(),
},
},
viewViewModel: {
isLoadingRooms: false,
isRoomListEmpty: false,
emptyStateTitle: "No rooms",
filtersViewModel: {
filters: [],
},
roomListViewModel: {
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
},
activeRoomIndex: undefined,
onKeyDown: undefined,
},
},
const searchSnapshot: RoomListSearchSnapshot = {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: false,
};
const sortOptionsMenuSnapshot: SortOptionsMenuSnapshot = {
activeSortOption: SortOption.Activity,
sort: jest.fn(),
};
const headerSnapshot: RoomListHeaderSnapshot = {
title: "Test Header",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuVm: createMockViewModel(sortOptionsMenuSnapshot),
};
const filtersSnapshot: RoomListPrimaryFiltersSnapshot = {
filters: [],
};
const roomListSnapshot: RoomListSnapshot = {
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
},
activeRoomIndex: undefined,
onKeyDown: undefined,
};
const viewSnapshot: RoomListViewSnapshot = {
isLoadingRooms: false,
isRoomListEmpty: false,
emptyStateTitle: "No rooms",
filtersVm: createMockViewModel(filtersSnapshot),
roomListVm: createMockViewModel(roomListSnapshot),
};
const mockSnapshot: RoomListPanelSnapshot = {
ariaLabel: "Room List",
searchVm: createMockViewModel(searchSnapshot),
headerVm: createMockViewModel(headerSnapshot),
viewVm: createMockViewModel(viewSnapshot),
};
const mockViewModel = createMockViewModel(mockSnapshot);
it("renders with search, header, and content", () => {
render(<RoomListPanel viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
render(<RoomListPanel vm={mockViewModel} renderAvatar={mockRenderAvatar} />);
expect(screen.getByText("Test Header")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Room List" })).toBeInTheDocument();
});
it("renders without search", () => {
const vmWithoutSearch = {
...mockViewModel,
searchViewModel: undefined,
const snapshotWithoutSearch: RoomListPanelSnapshot = {
...mockSnapshot,
searchVm: undefined,
};
render(<RoomListPanel viewModel={vmWithoutSearch} renderAvatar={mockRenderAvatar} />);
const vmWithoutSearch = createMockViewModel(snapshotWithoutSearch);
render(<RoomListPanel vm={vmWithoutSearch} renderAvatar={mockRenderAvatar} />);
expect(screen.getByText("Test Header")).toBeInTheDocument();
});
it("renders loading state", () => {
const vmLoading: RoomListPanelViewModel = {
...mockViewModel,
viewViewModel: {
...mockViewModel.viewViewModel,
isLoadingRooms: true,
isRoomListEmpty: false,
},
const loadingViewSnapshot: RoomListViewSnapshot = {
...viewSnapshot,
isLoadingRooms: true,
isRoomListEmpty: false,
};
render(<RoomListPanel viewModel={vmLoading} renderAvatar={mockRenderAvatar} />);
const loadingSnapshot: RoomListPanelSnapshot = {
...mockSnapshot,
viewVm: createMockViewModel(loadingViewSnapshot),
};
const vmLoading = createMockViewModel(loadingSnapshot);
render(<RoomListPanel vm={vmLoading} renderAvatar={mockRenderAvatar} />);
// RoomListPanel should render (loading state is handled by RoomListView)
expect(screen.getByRole("navigation")).toBeInTheDocument();
});
it("renders empty state", () => {
const vmEmpty: RoomListPanelViewModel = {
...mockViewModel,
viewViewModel: {
...mockViewModel.viewViewModel,
isLoadingRooms: false,
isRoomListEmpty: true,
},
const emptyViewSnapshot: RoomListViewSnapshot = {
...viewSnapshot,
isLoadingRooms: false,
isRoomListEmpty: true,
};
render(<RoomListPanel viewModel={vmEmpty} renderAvatar={mockRenderAvatar} />);
const emptySnapshot: RoomListPanelSnapshot = {
...mockSnapshot,
viewVm: createMockViewModel(emptyViewSnapshot),
};
const vmEmpty = createMockViewModel(emptySnapshot);
render(<RoomListPanel vm={vmEmpty} renderAvatar={mockRenderAvatar} />);
// RoomListPanel should render (empty state is handled by RoomListView)
expect(screen.getByRole("navigation")).toBeInTheDocument();
});
it("passes additional HTML attributes", () => {
render(<RoomListPanel viewModel={mockViewModel} renderAvatar={mockRenderAvatar} data-testid="custom-panel" />);
render(<RoomListPanel vm={mockViewModel} renderAvatar={mockRenderAvatar} data-testid="custom-panel" />);
expect(screen.getByTestId("custom-panel")).toBeInTheDocument();
});

View File

@ -7,33 +7,35 @@
import React, { type JSX, type ReactNode } from "react";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { RoomListSearch, type RoomListSearchViewModel } from "../RoomListSearch";
import { RoomListHeader, type RoomListHeaderViewModel } from "../RoomListHeader";
import { RoomListView, type RoomListViewViewModel } from "../RoomListView";
import { RoomListSearch, type RoomListSearchSnapshot } from "../RoomListSearch";
import { RoomListHeader, type RoomListHeaderSnapshot } from "../RoomListHeader";
import { RoomListView, type RoomListViewSnapshot } from "../RoomListView";
import { type RoomListItemViewModel } from "../RoomListItem";
import styles from "./RoomListPanel.module.css";
/**
* ViewModel interface for RoomListPanel
* Snapshot for RoomListPanel
*/
export interface RoomListPanelViewModel {
export type RoomListPanelSnapshot = {
/** Accessibility label for the navigation landmark */
ariaLabel: string;
/** Optional search view model */
searchViewModel?: RoomListSearchViewModel;
searchVm?: ViewModel<RoomListSearchSnapshot>;
/** Header view model */
headerViewModel: RoomListHeaderViewModel;
headerVm: ViewModel<RoomListHeaderSnapshot>;
/** View model for the main content area */
viewViewModel: RoomListViewViewModel;
}
viewVm: ViewModel<RoomListViewSnapshot>;
};
/**
* Props for RoomListPanel component
*/
export interface RoomListPanelProps extends React.HTMLAttributes<HTMLElement> {
/** The view model containing all data and callbacks */
viewModel: RoomListPanelViewModel;
vm: ViewModel<RoomListPanelSnapshot>;
/** Render function for room avatar */
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
}
@ -42,19 +44,21 @@ export interface RoomListPanelProps extends React.HTMLAttributes<HTMLElement> {
* A complete room list panel component.
* Composes search, header, and content areas with a ViewModel pattern.
*/
export const RoomListPanel: React.FC<RoomListPanelProps> = ({ viewModel, renderAvatar, ...props }): JSX.Element => {
export const RoomListPanel: React.FC<RoomListPanelProps> = ({ vm, renderAvatar, ...props }): JSX.Element => {
const snapshot = useViewModel(vm);
return (
<Flex
as="nav"
className={styles.roomListPanel}
direction="column"
align="stretch"
aria-label={viewModel.ariaLabel}
aria-label={snapshot.ariaLabel}
{...props}
>
{viewModel.searchViewModel && <RoomListSearch viewModel={viewModel.searchViewModel} />}
<RoomListHeader viewModel={viewModel.headerViewModel} />
<RoomListView viewModel={viewModel.viewViewModel} renderAvatar={renderAvatar} />
{snapshot.searchVm && <RoomListSearch vm={snapshot.searchVm} />}
<RoomListHeader vm={snapshot.headerVm} />
<RoomListView vm={snapshot.viewVm} renderAvatar={renderAvatar} />
</Flex>
);
};

View File

@ -6,8 +6,9 @@
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import { RoomListPrimaryFilters, type RoomListPrimaryFiltersSnapshot } from "./RoomListPrimaryFilters";
import type { FilterViewModel } from "./useVisibleFilters";
import { type ViewModel } from "../../viewmodel/ViewModel";
const meta: Meta<typeof RoomListPrimaryFilters> = {
title: "Room List/RoomListPrimaryFilters",
@ -18,6 +19,13 @@ const meta: Meta<typeof RoomListPrimaryFilters> = {
export default meta;
type Story = StoryObj<typeof RoomListPrimaryFilters>;
function createMockViewModel(snapshot: RoomListPrimaryFiltersSnapshot): ViewModel<RoomListPrimaryFiltersSnapshot> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
// Mock filter data - simple presentation data only
const createFilters = (selectedIndex: number = 0): FilterViewModel[] => {
const filterNames = ["All", "People", "Rooms", "Favourites", "Unread"];
@ -31,23 +39,23 @@ const createFilters = (selectedIndex: number = 0): FilterViewModel[] => {
export const Default: Story = {
args: {
viewModel: {
vm: createMockViewModel({
filters: createFilters(0),
},
}),
},
};
export const PeopleSelected: Story = {
args: {
viewModel: {
vm: createMockViewModel({
filters: createFilters(1),
},
}),
},
};
export const FewFilters: Story = {
args: {
viewModel: {
vm: createMockViewModel({
filters: [
{
name: "All",
@ -60,6 +68,6 @@ export const FewFilters: Story = {
toggle: () => console.log("Unread toggled"),
},
],
},
}),
},
};

View File

@ -9,6 +9,8 @@ import React, { type JSX, useId, useState } from "react";
import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { useCollapseFilters } from "./useCollapseFilters";
@ -16,31 +18,32 @@ import { useVisibleFilters, type FilterViewModel } from "./useVisibleFilters";
import styles from "./RoomListPrimaryFilters.module.css";
/**
* ViewModel interface for RoomListPrimaryFilters - contains only presentation data
* Snapshot for RoomListPrimaryFilters
*/
export interface RoomListPrimaryFiltersViewModel {
export type RoomListPrimaryFiltersSnapshot = {
/** Array of filter data */
filters: FilterViewModel[];
}
};
/**
* Props for RoomListPrimaryFilters component
*/
export interface RoomListPrimaryFiltersProps {
/** The view model containing filter data */
viewModel: RoomListPrimaryFiltersViewModel;
vm: ViewModel<RoomListPrimaryFiltersSnapshot>;
}
/**
* The primary filters component for the room list.
* Displays a collapsible list of filters with expand/collapse functionality.
*/
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({ viewModel }): JSX.Element => {
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
const id = useId();
const [isExpanded, setIsExpanded] = useState(false);
const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters<HTMLUListElement>(isExpanded);
const filters = useVisibleFilters(viewModel.filters, wrappingIndex);
const filters = useVisibleFilters(snapshot.filters, wrappingIndex);
return (
<Flex

View File

@ -6,7 +6,7 @@
*/
export { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
export type { RoomListPrimaryFiltersProps, RoomListPrimaryFiltersViewModel } from "./RoomListPrimaryFilters";
export type { RoomListPrimaryFiltersProps, RoomListPrimaryFiltersSnapshot } from "./RoomListPrimaryFilters";
export { useCollapseFilters } from "./useCollapseFilters";
export { useVisibleFilters } from "./useVisibleFilters";
export type { FilterViewModel } from "./useVisibleFilters";

View File

@ -6,7 +6,8 @@
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListSearch } from "./RoomListSearch";
import { RoomListSearch, type RoomListSearchSnapshot } from "./RoomListSearch";
import { type ViewModel } from "../../viewmodel/ViewModel";
const meta: Meta<typeof RoomListSearch> = {
title: "Room List/RoomListSearch",
@ -17,46 +18,53 @@ const meta: Meta<typeof RoomListSearch> = {
export default meta;
type Story = StoryObj<typeof RoomListSearch>;
function createMockViewModel(snapshot: RoomListSearchSnapshot): ViewModel<RoomListSearchSnapshot> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
export const Default: Story = {
args: {
viewModel: {
vm: createMockViewModel({
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: false,
},
}),
},
};
export const WithDialPad: Story = {
args: {
viewModel: {
vm: createMockViewModel({
onSearchClick: () => console.log("Open search"),
showDialPad: true,
onDialPadClick: () => console.log("Open dial pad"),
showExplore: false,
},
}),
},
};
export const WithExplore: Story = {
args: {
viewModel: {
vm: createMockViewModel({
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
},
}),
},
};
export const WithAllActions: Story = {
args: {
viewModel: {
vm: createMockViewModel({
onSearchClick: () => console.log("Open search"),
showDialPad: true,
onDialPadClick: () => console.log("Open dial pad"),
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
},
}),
},
};

View File

@ -9,18 +9,26 @@ import { render, screen } from "jest-matrix-react";
import React from "react";
import userEvent from "@testing-library/user-event";
import { RoomListSearch, type RoomListSearchViewModel } from "./RoomListSearch";
import { RoomListSearch, type RoomListSearchSnapshot } from "./RoomListSearch";
import { type ViewModel } from "../../viewmodel/ViewModel";
function createMockViewModel(snapshot: RoomListSearchSnapshot): ViewModel<RoomListSearchSnapshot> {
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
};
}
describe("RoomListSearch", () => {
it("renders search button with shortcut", () => {
const onSearchClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
const vm = createMockViewModel({
onSearchClick,
showDialPad: false,
showExplore: false,
};
});
render(<RoomListSearch viewModel={viewModel} />);
render(<RoomListSearch vm={vm} />);
expect(screen.getByRole("search")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
@ -30,13 +38,13 @@ describe("RoomListSearch", () => {
it("calls onSearchClick when search button is clicked", async () => {
const onSearchClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
const vm = createMockViewModel({
onSearchClick,
showDialPad: false,
showExplore: false,
};
});
render(<RoomListSearch viewModel={viewModel} />);
render(<RoomListSearch vm={vm} />);
await userEvent.click(screen.getByRole("button", { name: /search/i }));
expect(onSearchClick).toHaveBeenCalledTimes(1);
@ -44,28 +52,28 @@ describe("RoomListSearch", () => {
it("renders dial pad button when showDialPad is true", () => {
const onDialPadClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
const vm = createMockViewModel({
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick,
showExplore: false,
};
});
render(<RoomListSearch viewModel={viewModel} />);
render(<RoomListSearch vm={vm} />);
expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument();
});
it("calls onDialPadClick when dial pad button is clicked", async () => {
const onDialPadClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
const vm = createMockViewModel({
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick,
showExplore: false,
};
});
render(<RoomListSearch viewModel={viewModel} />);
render(<RoomListSearch vm={vm} />);
await userEvent.click(screen.getByRole("button", { name: /dial pad/i }));
expect(onDialPadClick).toHaveBeenCalledTimes(1);
@ -73,43 +81,43 @@ describe("RoomListSearch", () => {
it("renders explore button when showExplore is true", () => {
const onExploreClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
const vm = createMockViewModel({
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: true,
onExploreClick,
};
});
render(<RoomListSearch viewModel={viewModel} />);
render(<RoomListSearch vm={vm} />);
expect(screen.getByRole("button", { name: /explore/i })).toBeInTheDocument();
});
it("calls onExploreClick when explore button is clicked", async () => {
const onExploreClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
const vm = createMockViewModel({
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: true,
onExploreClick,
};
});
render(<RoomListSearch viewModel={viewModel} />);
render(<RoomListSearch vm={vm} />);
await userEvent.click(screen.getByRole("button", { name: /explore/i }));
expect(onExploreClick).toHaveBeenCalledTimes(1);
});
it("renders all buttons when showDialPad and showExplore are true", () => {
const viewModel: RoomListSearchViewModel = {
const vm = createMockViewModel({
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick: jest.fn(),
showExplore: true,
onExploreClick: jest.fn(),
};
});
render(<RoomListSearch viewModel={viewModel} />);
render(<RoomListSearch vm={vm} />);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument();
@ -117,13 +125,13 @@ describe("RoomListSearch", () => {
});
it("does not render dial pad or explore buttons when flags are false", () => {
const viewModel: RoomListSearchViewModel = {
const vm = createMockViewModel({
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: false,
};
});
render(<RoomListSearch viewModel={viewModel} />);
render(<RoomListSearch vm={vm} />);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /dial pad/i })).not.toBeInTheDocument();

View File

@ -11,14 +11,16 @@ import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/searc
import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad";
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import styles from "./RoomListSearch.module.css";
/**
* ViewModel interface for RoomListSearch
* Snapshot for RoomListSearch
*/
export interface RoomListSearchViewModel {
export type RoomListSearchSnapshot = {
/** Callback fired when search button is clicked */
onSearchClick: () => void;
/** Whether to show the dial pad button */
@ -29,21 +31,23 @@ export interface RoomListSearchViewModel {
showExplore: boolean;
/** Callback fired when explore button is clicked */
onExploreClick?: () => void;
}
};
/**
* Props for RoomListSearch component
*/
export interface RoomListSearchProps {
/** The view model containing search data */
viewModel: RoomListSearchViewModel;
vm: ViewModel<RoomListSearchSnapshot>;
}
/**
* A presentational search bar component for the room list.
* Displays a search button and optional action buttons (dial pad, explore) in a horizontal layout.
*/
export const RoomListSearch: React.FC<RoomListSearchProps> = ({ viewModel }): JSX.Element => {
export const RoomListSearch: React.FC<RoomListSearchProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
// Determine keyboard shortcut based on platform
const isMac = typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const searchShortcut = isMac ? "⌘ K" : "Ctrl K";
@ -55,31 +59,31 @@ export const RoomListSearch: React.FC<RoomListSearchProps> = ({ viewModel }): JS
kind="secondary"
size="sm"
Icon={SearchIcon}
onClick={viewModel.onSearchClick}
onClick={snapshot.onSearchClick}
>
<Flex as="span" justify="space-between">
<span className="mx_RoomListSearch_search_text">{_t("action|search")}</span>
<kbd>{searchShortcut}</kbd>
</Flex>
</Button>
{viewModel.showDialPad && (
{snapshot.showDialPad && (
<Button
kind="secondary"
size="sm"
Icon={DialPadIcon}
iconOnly={true}
aria-label={_t("left_panel|open_dial_pad")}
onClick={viewModel.onDialPadClick}
onClick={snapshot.onDialPadClick}
/>
)}
{viewModel.showExplore && (
{snapshot.showExplore && (
<Button
kind="secondary"
size="sm"
Icon={ExploreIcon}
iconOnly={true}
aria-label={_t("action|explore_rooms")}
onClick={viewModel.onExploreClick}
onClick={snapshot.onExploreClick}
/>
)}
</Flex>

View File

@ -6,4 +6,4 @@
*/
export { RoomListSearch } from "./RoomListSearch";
export type { RoomListSearchProps, RoomListSearchViewModel } from "./RoomListSearch";
export type { RoomListSearchProps, RoomListSearchSnapshot } from "./RoomListSearch";

View File

@ -7,38 +7,40 @@
import React, { type JSX, type ReactNode } from "react";
import { RoomListPrimaryFilters, type RoomListPrimaryFiltersViewModel } from "../RoomListPrimaryFilters";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { RoomListPrimaryFilters, type RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters";
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
import { RoomListEmptyState } from "./RoomListEmptyState";
import { RoomList, type RoomListViewModel } from "../RoomList";
import { RoomList, type RoomListSnapshot } from "../RoomList";
import { type RoomListItemViewModel } from "../RoomListItem";
/**
* ViewModel interface for RoomListView
* Snapshot for RoomListView
*/
export interface RoomListViewViewModel {
export type RoomListViewSnapshot = {
/** Whether the rooms are currently loading */
isLoadingRooms: boolean;
/** Whether the room list is empty */
isRoomListEmpty: boolean;
/** View model for the primary filters */
filtersViewModel: RoomListPrimaryFiltersViewModel;
filtersVm: ViewModel<RoomListPrimaryFiltersSnapshot>;
/** View model for the room list */
roomListViewModel: RoomListViewModel;
roomListVm: ViewModel<RoomListSnapshot>;
/** Title for the empty state */
emptyStateTitle: string;
/** Optional description for the empty state */
emptyStateDescription?: string;
/** Optional action element for the empty state */
emptyStateAction?: ReactNode;
}
};
/**
* Props for RoomListView component
*/
export interface RoomListViewProps {
/** The view model containing list data */
viewModel: RoomListViewViewModel;
vm: ViewModel<RoomListViewSnapshot>;
/** Render function for room avatar */
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
}
@ -47,26 +49,27 @@ export interface RoomListViewProps {
* The main room list view component.
* Manages the display of filters, loading states, empty states, and the room list.
*/
export const RoomListView: React.FC<RoomListViewProps> = ({ viewModel, renderAvatar }): JSX.Element => {
export const RoomListView: React.FC<RoomListViewProps> = ({ vm, renderAvatar }): JSX.Element => {
const snapshot = useViewModel(vm);
let listBody: ReactNode;
if (viewModel.isLoadingRooms) {
if (snapshot.isLoadingRooms) {
listBody = <RoomListLoadingSkeleton />;
} else if (viewModel.isRoomListEmpty) {
} else if (snapshot.isRoomListEmpty) {
listBody = (
<RoomListEmptyState
title={viewModel.emptyStateTitle}
description={viewModel.emptyStateDescription}
action={viewModel.emptyStateAction}
title={snapshot.emptyStateTitle}
description={snapshot.emptyStateDescription}
action={snapshot.emptyStateAction}
/>
);
} else {
listBody = <RoomList viewModel={viewModel.roomListViewModel} renderAvatar={renderAvatar} />;
listBody = <RoomList vm={snapshot.roomListVm} renderAvatar={renderAvatar} />;
}
return (
<>
<RoomListPrimaryFilters viewModel={viewModel.filtersViewModel} />
<RoomListPrimaryFilters vm={snapshot.filtersVm} />
{listBody}
</>
);

View File

@ -6,7 +6,7 @@
*/
export { RoomListView } from "./RoomListView";
export type { RoomListViewProps, RoomListViewViewModel } from "./RoomListView";
export type { RoomListViewProps, RoomListViewSnapshot } from "./RoomListView";
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyState } from "./RoomListEmptyState";
export type { RoomListEmptyStateProps } from "./RoomListEmptyState";