From 99e170f8b4dded1ff50c4bfa1b313f86141de7bb Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 10 Dec 2025 17:02:07 +0000 Subject: [PATCH] Detangle state from callbacks --- packages/shared-components/jest-sonar.xml | 167 +------ .../AudioPlayerView.test.tsx.snap | 8 +- .../PlayPauseButton.test.tsx.snap | 4 +- .../Pill/__snapshots__/Pill.test.tsx.snap | 2 +- .../room-list/RoomList/RoomList.stories.tsx | 94 +++- .../src/room-list/RoomList/RoomList.test.tsx | 131 +++-- .../src/room-list/RoomList/RoomList.tsx | 101 +--- .../src/room-list/RoomList/index.ts | 4 +- .../room-list/RoomListHeader/ComposeMenu.tsx | 44 +- .../RoomListHeader/RoomListHeader.stories.tsx | 129 ++--- .../RoomListHeader/RoomListHeader.test.tsx | 151 +++--- .../RoomListHeader/RoomListHeader.tsx | 95 ++-- .../RoomListHeader/SortOptionsMenu.tsx | 27 +- .../room-list/RoomListHeader/SpaceMenu.tsx | 45 +- .../src/room-list/RoomListHeader/index.ts | 11 + .../RoomListItem/RoomListItem.stories.tsx | 8 +- .../RoomListItem/RoomListItem.test.tsx | 8 +- .../room-list/RoomListItem/RoomListItem.tsx | 8 +- .../RoomListItem/RoomListItemHoverMenu.tsx | 13 +- .../RoomListItemNotificationMenu.tsx | 22 +- .../src/room-list/RoomListItem/index.ts | 2 +- .../RoomListPanel/RoomListPanel.stories.tsx | 149 +++--- .../RoomListPanel/RoomListPanel.test.tsx | 113 ++--- .../room-list/RoomListPanel/RoomListPanel.tsx | 47 +- .../src/room-list/RoomListPanel/index.ts | 8 + .../RoomListPrimaryFilters.stories.tsx | 50 +- .../RoomListPrimaryFilters.tsx | 30 +- .../RoomListPrimaryFilters/index.tsx | 4 +- .../useVisibleFilters.ts | 6 +- .../RoomListSearch/RoomListSearch.stories.tsx | 50 +- .../RoomListSearch/RoomListSearch.test.tsx | 58 +-- .../RoomListSearch/RoomListSearch.tsx | 47 +- .../src/room-list/RoomListSearch/index.ts | 8 + .../src/room-list/RoomListSearch/index.tsx | 2 +- .../room-list/RoomListView/RoomListView.tsx | 94 +++- .../src/room-list/RoomListView/index.tsx | 5 +- .../roomlist/ComposeMenuViewModel.ts | 87 ---- .../roomlist/RoomListHeaderViewModel.ts | 149 ------ .../roomlist/RoomListPanelViewModel.ts | 61 --- .../RoomListPrimaryFiltersViewModel.ts | 93 ---- .../roomlist/RoomListSearchViewModel.ts | 86 ---- .../viewmodels/roomlist/RoomListViewModel.ts | 472 +++++++++++++++--- .../roomlist/RoomListViewViewModel.ts | 118 ----- .../roomlist/SortOptionsMenuViewModel.ts | 62 --- .../viewmodels/roomlist/SpaceMenuViewModel.ts | 120 ----- .../rooms/RoomListPanel/RoomListPanel.tsx | 9 +- 46 files changed, 1240 insertions(+), 1762 deletions(-) create mode 100644 packages/shared-components/src/room-list/RoomListHeader/index.ts create mode 100644 packages/shared-components/src/room-list/RoomListPanel/index.ts create mode 100644 packages/shared-components/src/room-list/RoomListSearch/index.ts delete mode 100644 src/components/viewmodels/roomlist/ComposeMenuViewModel.ts delete mode 100644 src/components/viewmodels/roomlist/RoomListHeaderViewModel.ts delete mode 100644 src/components/viewmodels/roomlist/RoomListPanelViewModel.ts delete mode 100644 src/components/viewmodels/roomlist/RoomListPrimaryFiltersViewModel.ts delete mode 100644 src/components/viewmodels/roomlist/RoomListSearchViewModel.ts delete mode 100644 src/components/viewmodels/roomlist/RoomListViewViewModel.ts delete mode 100644 src/components/viewmodels/roomlist/SortOptionsMenuViewModel.ts delete mode 100644 src/components/viewmodels/roomlist/SpaceMenuViewModel.ts diff --git a/packages/shared-components/jest-sonar.xml b/packages/shared-components/jest-sonar.xml index 2368a86674..807343d725 100644 --- a/packages/shared-components/jest-sonar.xml +++ b/packages/shared-components/jest-sonar.xml @@ -1,163 +1,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + \ No newline at end of file diff --git a/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap b/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap index 85004e4ba5..89b47dfecb 100644 --- a/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap +++ b/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap @@ -23,7 +23,7 @@ exports[`AudioPlayerView renders the audio player in default state 1`] = ` tabindex="-1" >
{}; -function createMockViewModel(snapshot: RoomListViewSnapshot): RoomListViewModel { +function createMockViewModel(snapshot: RoomListSnapshot): RoomListViewModel { return { getSnapshot: () => snapshot, subscribe: () => noop, + showDialPad: false, + showExplore: false, + onSearchClick: () => {}, + onDialPadClick: () => {}, + onExploreClick: () => {}, + onComposeClick: () => {}, + openSpaceHome: () => {}, + inviteInSpace: () => {}, + openSpacePreferences: () => {}, + openSpaceSettings: () => {}, + createChatRoom: () => {}, + createRoom: () => {}, + createVideoRoom: () => {}, + sort: () => {}, onOpenRoom: (roomId: string) => console.log("Open room:", roomId), onMarkAsRead: (roomId: string) => console.log("Mark as read:", roomId), onMarkAsUnread: (roomId: string) => console.log("Mark as unread:", roomId), @@ -121,12 +137,26 @@ function createMockViewModel(snapshot: RoomListViewSnapshot): RoomListViewModel onLeaveRoom: (roomId: string) => console.log("Leave room:", roomId), onSetRoomNotifState: (roomId: string, state: RoomNotifState) => console.log("Set notification state:", roomId, state), + onToggleFilter: (filter) => console.log("Toggle filter:", filter), }; } const mockViewModel: RoomListViewModel = createMockViewModel({ - roomsResult: mockRoomsResult, - activeRoomIndex: undefined, + headerState: { + title: "Test", + isSpace: false, + displayComposeMenu: false, + activeSortOption: SortOption.Activity, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], + roomListState: { + rooms: mockRoomsResult.rooms, + activeRoomIndex: undefined, + spaceId: mockRoomsResult.spaceId, + filterKeys: mockRoomsResult.filterKeys, + }, }); const renderAvatar = (roomItem: RoomListItem): React.ReactElement => { @@ -160,8 +190,21 @@ export const Default: Story = { export const WithSelection: Story = { args: { vm: createMockViewModel({ - roomsResult: mockRoomsResult, - activeRoomIndex: 5, + headerState: { + title: "Test", + isSpace: false, + displayComposeMenu: false, + activeSortOption: SortOption.AToZ, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], + roomListState: { + rooms: mockRoomsResult.rooms, + activeRoomIndex: 5, + spaceId: mockRoomsResult.spaceId, + filterKeys: mockRoomsResult.filterKeys, + }, }), }, }; @@ -169,12 +212,21 @@ export const WithSelection: Story = { export const SmallList: Story = { args: { vm: createMockViewModel({ - roomsResult: { + headerState: { + title: "Test", + isSpace: false, + displayComposeMenu: false, + activeSortOption: SortOption.Activity, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], + roomListState: { spaceId: "!space:server", filterKeys: undefined, rooms: generateMockRooms(5), + activeRoomIndex: undefined, }, - activeRoomIndex: undefined, }), }, }; @@ -182,12 +234,21 @@ export const SmallList: Story = { export const LargeList: Story = { args: { vm: createMockViewModel({ - roomsResult: { + headerState: { + title: "Test", + isSpace: false, + displayComposeMenu: false, + activeSortOption: SortOption.Activity, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], + roomListState: { spaceId: "!space:server", filterKeys: undefined, rooms: generateMockRooms(200), + activeRoomIndex: undefined, }, - activeRoomIndex: undefined, }), }, }; @@ -195,12 +256,21 @@ export const LargeList: Story = { export const EmptyList: Story = { args: { vm: createMockViewModel({ - roomsResult: { + headerState: { + title: "Test", + isSpace: false, + displayComposeMenu: false, + activeSortOption: SortOption.Activity, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], + roomListState: { spaceId: "!space:server", filterKeys: undefined, rooms: [], + activeRoomIndex: undefined, }, - activeRoomIndex: undefined, }), }, }; diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx index f08a3caddd..666a2653dd 100644 --- a/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx +++ b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx @@ -8,34 +8,31 @@ import { render, screen } from "@testing-library/react"; import React from "react"; -import { - RoomList, - type RoomListViewModel, - type RoomListViewSnapshot, - type RoomListViewActions, - type RoomsResult, -} from "./RoomList"; +import { RoomList } from "./RoomList"; +import type { RoomListViewModel, RoomListSnapshot } from "../RoomListView"; import type { RoomListItem } from "../RoomListItem"; import type { NotificationDecorationData } from "../../notifications/NotificationDecoration"; import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu"; import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu"; +import { SortOption } from "../RoomListHeader/SortOptionsMenu"; -function createMockViewModel( - snapshot: RoomListViewSnapshot, - actions: Partial = {}, -): RoomListViewModel { +function createMockViewModel(snapshot: RoomListSnapshot): RoomListViewModel { return { getSnapshot: () => snapshot, subscribe: () => () => {}, - onOpenRoom: actions.onOpenRoom || jest.fn(), - onMarkAsRead: actions.onMarkAsRead || jest.fn(), - onMarkAsUnread: actions.onMarkAsUnread || jest.fn(), - onToggleFavorite: actions.onToggleFavorite || jest.fn(), - onToggleLowPriority: actions.onToggleLowPriority || jest.fn(), - onInvite: actions.onInvite || jest.fn(), - onCopyRoomLink: actions.onCopyRoomLink || jest.fn(), - onLeaveRoom: actions.onLeaveRoom || jest.fn(), - onSetRoomNotifState: actions.onSetRoomNotifState || jest.fn(), + onToggleFilter: jest.fn(), + onSearchClick: jest.fn(), + onDialPadClick: jest.fn(), + onExploreClick: jest.fn(), + onOpenRoom: jest.fn(), + onMarkAsRead: jest.fn(), + onMarkAsUnread: jest.fn(), + onToggleFavorite: jest.fn(), + onToggleLowPriority: jest.fn(), + onInvite: jest.fn(), + onCopyRoomLink: jest.fn(), + onLeaveRoom: jest.fn(), + onSetRoomNotifState: jest.fn(), }; } @@ -102,19 +99,29 @@ describe("RoomList", () => { }, ]; - const mockRoomsResult: RoomsResult = { - spaceId: "!space:server", - filterKeys: undefined, - rooms: mockRooms, - }; - const mockRenderAvatar = jest.fn((roomItem: RoomListItem) => (
{roomItem.name[0]}
)); const mockViewModel = createMockViewModel({ - roomsResult: mockRoomsResult, - activeRoomIndex: undefined, + roomListState: { + rooms: mockRooms, + activeRoomIndex: undefined, + spaceId: "!space:server", + filterKeys: undefined, + }, + headerState: { + title: "Rooms", + isSpace: false, + displayComposeMenu: false, + sortOptionsMenuProps: { + activeSortOption: SortOption.Activity, + sort: jest.fn(), + }, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], }); beforeEach(() => { @@ -152,15 +159,25 @@ describe("RoomList", () => { }); it("handles empty room list", () => { - const emptyResult: RoomsResult = { - spaceId: "!space:server", - filterKeys: undefined, - rooms: [], - }; - const emptyViewModel = createMockViewModel({ - roomsResult: emptyResult, - activeRoomIndex: undefined, + roomListState: { + rooms: [], + activeRoomIndex: undefined, + spaceId: "!space:server", + filterKeys: undefined, + }, + headerState: { + title: "Rooms", + isSpace: false, + displayComposeMenu: false, + sortOptionsMenuProps: { + activeSortOption: "activity" as any, + sort: jest.fn(), + }, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], }); render(); @@ -171,8 +188,24 @@ describe("RoomList", () => { it("passes activeRoomIndex correctly", () => { const vmWithActive = createMockViewModel({ - roomsResult: mockRoomsResult, - activeRoomIndex: 1, + roomListState: { + rooms: mockRooms, + activeRoomIndex: 1, + spaceId: "!space:server", + filterKeys: undefined, + }, + headerState: { + title: "Rooms", + isSpace: false, + displayComposeMenu: false, + sortOptionsMenuProps: { + activeSortOption: "activity" as any, + sort: jest.fn(), + }, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], }); render(); @@ -183,11 +216,25 @@ describe("RoomList", () => { }); it("accepts onKeyDown callback", () => { - const onKeyDown = jest.fn(); const vmWithKeyDown = createMockViewModel({ - roomsResult: mockRoomsResult, - activeRoomIndex: undefined, - onKeyDown, + roomListState: { + rooms: mockRooms, + activeRoomIndex: undefined, + spaceId: "!space:server", + filterKeys: undefined, + }, + headerState: { + title: "Rooms", + isSpace: false, + displayComposeMenu: false, + sortOptionsMenuProps: { + activeSortOption: "activity" as any, + sort: jest.fn(), + }, + }, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], }); render(); diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.tsx index 2a89975186..8703f57a7e 100644 --- a/packages/shared-components/src/room-list/RoomList/RoomList.tsx +++ b/packages/shared-components/src/room-list/RoomList/RoomList.tsx @@ -9,12 +9,12 @@ 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 { RoomListItemView, type RoomListItem } from "../RoomListItem"; import { type RoomNotifState } from "../../notifications/RoomNotifs"; +import type { RoomListViewModel } from "../RoomListView"; /** * Filter key type - opaque string type for filter identifiers @@ -34,52 +34,25 @@ export interface RoomsResult { } /** - * Snapshot for RoomList view state + * State for the room list data (nested within RoomListSnapshot) */ -export interface RoomListViewSnapshot { - /** The rooms result containing the list of rooms */ - roomsResult: RoomsResult; - /** Optional active room index */ +export interface RoomListViewState { + /** Array of room items */ + rooms: RoomListItem[]; + /** Optional active room index for keyboard navigation */ activeRoomIndex?: number; - /** Optional keyboard event handler */ - onKeyDown?: (ev: React.KeyboardEvent) => void; + /** Space ID for context tracking */ + spaceId?: string; + /** Active filter keys for context tracking */ + filterKeys?: FilterKey[]; } -/** - * Actions available for RoomList - */ -export interface RoomListViewActions { - /** Callback to open a room */ - onOpenRoom: (roomId: string) => void; - /** Callback to mark a room as read */ - onMarkAsRead: (roomId: string) => void; - /** Callback to mark a room as unread */ - onMarkAsUnread: (roomId: string) => void; - /** Callback to toggle a room as favourite */ - onToggleFavorite: (roomId: string) => void; - /** Callback to toggle a room as low priority */ - onToggleLowPriority: (roomId: string) => void; - /** Callback to invite users to a room */ - onInvite: (roomId: string) => void; - /** Callback to copy the room link */ - onCopyRoomLink: (roomId: string) => void; - /** Callback to leave a room */ - onLeaveRoom: (roomId: string) => void; - /** Callback to set the room notification state */ - onSetRoomNotifState: (roomId: string, state: RoomNotifState) => void; -} - -/** - * The view model for the room list. - */ -export type RoomListViewModel = ViewModel & RoomListViewActions; - /** * Props for the RoomList component */ export interface RoomListProps { /** - * The view model containing room list data and actions + * The view model containing all room list data and callbacks */ vm: RoomListViewModel; @@ -115,21 +88,12 @@ const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT; */ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element { const snapshot = useViewModel(vm); - const { roomsResult, activeRoomIndex, onKeyDown } = snapshot; - const { - onOpenRoom, - onMarkAsRead, - onMarkAsUnread, - onToggleFavorite, - onToggleLowPriority, - onInvite, - onCopyRoomLink, - onLeaveRoom, - onSetRoomNotifState, - } = vm; + const { roomListState } = snapshot; + const rooms = roomListState.rooms; + const activeRoomIndex = roomListState.activeRoomIndex; const lastSpaceId = useRef(undefined); const lastFilterKeys = useRef(undefined); - const roomCount = roomsResult.rooms.length; + const roomCount = rooms.length; /** * Get the item component for a specific index @@ -150,19 +114,17 @@ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element { const isSelected = activeRoomIndex === index; const callbacks = { - onOpenRoom: () => onOpenRoom(item.id), + onOpenRoom: () => vm.onOpenRoom(item.id), moreOptionsCallbacks: { - onMarkAsRead: () => onMarkAsRead(item.id), - onMarkAsUnread: () => onMarkAsUnread(item.id), - onToggleFavorite: () => onToggleFavorite(item.id), - onToggleLowPriority: () => onToggleLowPriority(item.id), - onInvite: () => onInvite(item.id), - onCopyRoomLink: () => onCopyRoomLink(item.id), - onLeaveRoom: () => onLeaveRoom(item.id), - }, - notificationCallbacks: { - onSetRoomNotifState: (state: RoomNotifState) => onSetRoomNotifState(item.id, state), + onMarkAsRead: () => vm.onMarkAsRead(item.id), + onMarkAsUnread: () => vm.onMarkAsUnread(item.id), + onToggleFavorite: () => vm.onToggleFavorite(item.id), + onToggleLowPriority: () => vm.onToggleLowPriority(item.id), + onInvite: () => vm.onInvite(item.id), + onCopyRoomLink: () => vm.onCopyRoomLink(item.id), + onLeaveRoom: () => vm.onLeaveRoom(item.id), }, + onSetRoomNotifState: (state: RoomNotifState) => vm.onSetRoomNotifState(item.id, state), }; return ( @@ -216,30 +178,19 @@ export function RoomList({ vm, renderAvatar }: RoomListProps): JSX.Element { [activeRoomIndex], ); - /** - * Handle keyboard events - */ - const keyDownCallback = useCallback( - (ev: React.KeyboardEvent): void => { - onKeyDown?.(ev); - }, - [onKeyDown], - ); - return ( true} - onKeyDown={keyDownCallback} increaseViewportBy={{ bottom: EXTENDED_VIEWPORT_HEIGHT, top: EXTENDED_VIEWPORT_HEIGHT, diff --git a/packages/shared-components/src/room-list/RoomList/index.ts b/packages/shared-components/src/room-list/RoomList/index.ts index 107e3f0909..e3c8fa13c8 100644 --- a/packages/shared-components/src/room-list/RoomList/index.ts +++ b/packages/shared-components/src/room-list/RoomList/index.ts @@ -8,9 +8,7 @@ export { RoomList } from "./RoomList"; export type { RoomListProps, - RoomListViewModel, - RoomListViewSnapshot, - RoomListViewActions, + RoomListViewState, RoomsResult, FilterKey } from "./RoomList"; diff --git a/packages/shared-components/src/room-list/RoomListHeader/ComposeMenu.tsx b/packages/shared-components/src/room-list/RoomListHeader/ComposeMenu.tsx index 0ee928d60c..145cc56fbd 100644 --- a/packages/shared-components/src/room-list/RoomListHeader/ComposeMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListHeader/ComposeMenu.tsx @@ -12,14 +12,12 @@ 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"; /** - * Snapshot for ComposeMenu + * Props for ComposeMenu component */ -export type ComposeMenuSnapshot = { +export interface ComposeMenuProps { /** Whether the user can create rooms */ canCreateRoom: boolean; /** Whether the user can create video rooms */ @@ -30,22 +28,24 @@ export type ComposeMenuSnapshot = { createRoom: () => void; /** Create a video room */ createVideoRoom: () => void; -}; +} /** - * Props for ComposeMenu component + * @deprecated Use ComposeMenuProps instead */ -export interface ComposeMenuProps { - /** The view model containing menu data and callbacks */ - vm: ViewModel; -} +export type ComposeMenuSnapshot = ComposeMenuProps; /** * 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 = ({ vm }): JSX.Element => { - const snapshot = useViewModel(vm); +export const ComposeMenu: React.FC = ({ + canCreateRoom, + canCreateVideoRoom, + createChatRoom, + createRoom, + createVideoRoom, +}): JSX.Element => { const [open, setOpen] = useState(false); return ( @@ -62,25 +62,15 @@ export const ComposeMenu: React.FC = ({ vm }): JSX.Element => } > - - {snapshot.canCreateRoom && ( - + + {canCreateRoom && ( + )} - {snapshot.canCreateVideoRoom && ( + {canCreateVideoRoom && ( )} diff --git a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.stories.tsx b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.stories.tsx index a2bedd748c..96de5f2b05 100644 --- a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.stories.tsx @@ -8,11 +8,10 @@ import React from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -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"; +import { RoomListHeader } from "./RoomListHeader"; +import { SortOption } from "./SortOptionsMenu"; +import type { RoomListViewModel, RoomListSnapshot } from "../RoomListView"; +import type { RoomListHeaderState } from "./RoomListHeader"; const meta: Meta = { title: "Room List/RoomListHeader", @@ -23,119 +22,127 @@ const meta: Meta = { export default meta; type Story = StoryObj; -function createMockViewModel(snapshot: T): ViewModel { +const createMockViewModel = (headerState: RoomListHeaderState): RoomListViewModel => { + const snapshot: RoomListSnapshot = { + headerState, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], + roomListState: { + rooms: [], + }, + }; + return { getSnapshot: () => snapshot, - subscribe: () => () => {}, + subscribe: (listener: () => void) => { + return () => {}; + }, + sort: (option: SortOption) => console.log("Sort by:", option), + onToggleFilter: () => {}, + onSearchClick: () => {}, + onDialPadClick: () => {}, + onExploreClick: () => {}, + showDialPad: false, + showExplore: false, + onComposeClick: () => console.log("Compose clicked"), + openSpaceHome: () => console.log("Open space home"), + inviteInSpace: () => console.log("Invite in space"), + openSpacePreferences: () => console.log("Open space preferences"), + openSpaceSettings: () => console.log("Open space settings"), + createChatRoom: () => console.log("Create chat room"), + createRoom: () => console.log("Create room"), + createVideoRoom: () => console.log("Create video room"), + onOpenRoom: () => {}, + onMarkAsRead: () => {}, + onMarkAsUnread: () => {}, + onToggleFavorite: () => {}, + onToggleLowPriority: () => {}, + onInvite: () => {}, + onCopyRoomLink: () => {}, + onLeaveRoom: () => {}, + onSetRoomNotifState: () => {}, }; -} - -const baseSortOptionsViewModel = createMockViewModel({ - activeSortOption: SortOption.Activity, - sort: (option: SortOption) => console.log("Sort by:", option), -}); +}; export const Default: Story = { args: { - vm: createMockViewModel({ + vm: createMockViewModel({ title: "Home", isSpace: false, displayComposeMenu: false, - onComposeClick: () => console.log("Compose clicked"), - sortOptionsMenuVm: baseSortOptionsViewModel, + activeSortOption: SortOption.Activity, }), }, }; export const WithSpaceMenu: Story = { args: { - vm: createMockViewModel({ + vm: createMockViewModel({ title: "My Space", isSpace: true, - displayComposeMenu: false, - spaceMenuVm: createMockViewModel({ + spaceMenuState: { title: "My Space", canInviteInSpace: true, canAccessSpaceSettings: true, - openSpaceHome: () => console.log("Open space home"), - inviteInSpace: () => console.log("Invite in space"), - openSpacePreferences: () => console.log("Open space preferences"), - openSpaceSettings: () => console.log("Open space settings"), - }), - onComposeClick: () => console.log("Compose clicked"), - sortOptionsMenuVm: baseSortOptionsViewModel, + }, + displayComposeMenu: false, + activeSortOption: SortOption.Activity, }), }, }; export const WithComposeMenu: Story = { args: { - vm: createMockViewModel({ + vm: createMockViewModel({ title: "Home", isSpace: false, displayComposeMenu: true, - composeMenuVm: createMockViewModel({ + composeMenuState: { canCreateRoom: true, canCreateVideoRoom: true, - createChatRoom: () => console.log("Create chat room"), - createRoom: () => console.log("Create room"), - createVideoRoom: () => console.log("Create video room"), - }), - sortOptionsMenuVm: baseSortOptionsViewModel, + }, + activeSortOption: SortOption.Activity, }), }, }; export const FullHeader: Story = { args: { - vm: createMockViewModel({ + vm: createMockViewModel({ title: "My Space", isSpace: true, - displayComposeMenu: true, - spaceMenuVm: createMockViewModel({ + spaceMenuState: { title: "My Space", canInviteInSpace: true, canAccessSpaceSettings: true, - openSpaceHome: () => console.log("Open space home"), - inviteInSpace: () => console.log("Invite in space"), - openSpacePreferences: () => console.log("Open space preferences"), - openSpaceSettings: () => console.log("Open space settings"), - }), - composeMenuVm: createMockViewModel({ + }, + displayComposeMenu: true, + composeMenuState: { canCreateRoom: true, canCreateVideoRoom: true, - createChatRoom: () => console.log("Create chat room"), - createRoom: () => console.log("Create room"), - createVideoRoom: () => console.log("Create video room"), - }), - sortOptionsMenuVm: baseSortOptionsViewModel, + }, + activeSortOption: SortOption.Activity, }), }, }; export const LongTitle: Story = { args: { - vm: createMockViewModel({ + vm: createMockViewModel({ title: "This is a very long space name that should be truncated with ellipsis when it overflows", isSpace: true, - displayComposeMenu: true, - spaceMenuVm: createMockViewModel({ + spaceMenuState: { title: "This is a very long space name that should be truncated with ellipsis when it overflows", canInviteInSpace: true, canAccessSpaceSettings: true, - openSpaceHome: () => console.log("Open space home"), - inviteInSpace: () => console.log("Invite in space"), - openSpacePreferences: () => console.log("Open space preferences"), - openSpaceSettings: () => console.log("Open space settings"), - }), - composeMenuVm: createMockViewModel({ + }, + displayComposeMenu: true, + composeMenuState: { canCreateRoom: true, canCreateVideoRoom: true, - createChatRoom: () => console.log("Create chat room"), - createRoom: () => console.log("Create room"), - createVideoRoom: () => console.log("Create video room"), - }), - sortOptionsMenuVm: baseSortOptionsViewModel, + }, + activeSortOption: SortOption.Activity, }), }, decorators: [ diff --git a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx index 5344960b7b..f32cdd96e0 100644 --- a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx +++ b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx @@ -8,62 +8,83 @@ import { render, screen } from "jest-matrix-react"; import React from "react"; -import { RoomListHeader, type RoomListHeaderSnapshot } from "./RoomListHeader"; -import type { SpaceMenuSnapshot } from "./SpaceMenu"; -import type { ComposeMenuSnapshot } from "./ComposeMenu"; -import type { SortOptionsMenuSnapshot } from "./SortOptionsMenu"; +import { RoomListHeader } from "./RoomListHeader"; import { SortOption } from "./SortOptionsMenu"; -import { type ViewModel } from "../../viewmodel/ViewModel"; - -function createMockViewModel(snapshot: T): ViewModel { - return { - getSnapshot: () => snapshot, - subscribe: () => () => {}, - }; -} +import type { RoomListViewModel, RoomListSnapshot } from "../RoomListView"; +import type { RoomListHeaderState } from "./RoomListHeader"; describe("RoomListHeader", () => { - const mockSortOptionsSnapshot: SortOptionsMenuSnapshot = { - activeSortOption: SortOption.Activity, - sort: jest.fn(), + const createMockViewModel = (headerState: RoomListHeaderState): RoomListViewModel => { + const snapshot: RoomListSnapshot = { + headerState, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: [], + roomListState: { + rooms: [], + }, + }; + + return { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + return () => {}; + }, + sort: jest.fn(), + onToggleFilter: jest.fn(), + onSearchClick: jest.fn(), + onDialPadClick: jest.fn(), + onExploreClick: jest.fn(), + showDialPad: false, + showExplore: false, + onComposeClick: jest.fn(), + openSpaceHome: jest.fn(), + inviteInSpace: jest.fn(), + openSpacePreferences: jest.fn(), + openSpaceSettings: jest.fn(), + createChatRoom: jest.fn(), + createRoom: jest.fn(), + createVideoRoom: jest.fn(), + onOpenRoom: jest.fn(), + onMarkAsRead: jest.fn(), + onMarkAsUnread: jest.fn(), + onToggleFavorite: jest.fn(), + onToggleLowPriority: jest.fn(), + onInvite: jest.fn(), + onCopyRoomLink: jest.fn(), + onLeaveRoom: jest.fn(), + onSetRoomNotifState: jest.fn(), + }; }; it("renders title", () => { - const snapshot: RoomListHeaderSnapshot = { + const vm = createMockViewModel({ title: "My Space", isSpace: false, displayComposeMenu: false, - onComposeClick: jest.fn(), - sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot), - }; + activeSortOption: SortOption.Activity, + }); - render(); + render(); expect(screen.getByText("My Space")).toBeInTheDocument(); expect(screen.getByRole("banner")).toBeInTheDocument(); }); it("renders space menu when isSpace is true", () => { - const mockSpaceMenuSnapshot: SpaceMenuSnapshot = { - title: "My Space", - canInviteInSpace: true, - canAccessSpaceSettings: true, - openSpaceHome: jest.fn(), - inviteInSpace: jest.fn(), - openSpacePreferences: jest.fn(), - openSpaceSettings: jest.fn(), - }; - - const snapshot: RoomListHeaderSnapshot = { + const vm = createMockViewModel({ title: "My Space", isSpace: true, - spaceMenuVm: createMockViewModel(mockSpaceMenuSnapshot), + spaceMenuState: { + title: "My Space", + canInviteInSpace: true, + canAccessSpaceSettings: true, + }, displayComposeMenu: false, - onComposeClick: jest.fn(), - sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot), - }; + activeSortOption: SortOption.Activity, + }); - render(); + render(); expect(screen.getByText("My Space")).toBeInTheDocument(); // Space menu chevron button should be present @@ -71,53 +92,46 @@ describe("RoomListHeader", () => { }); it("renders compose menu when displayComposeMenu is true", () => { - const mockComposeMenuSnapshot: ComposeMenuSnapshot = { - canCreateRoom: true, - canCreateVideoRoom: true, - createChatRoom: jest.fn(), - createRoom: jest.fn(), - createVideoRoom: jest.fn(), - }; - - const snapshot: RoomListHeaderSnapshot = { + const vm = createMockViewModel({ title: "My Space", isSpace: false, displayComposeMenu: true, - composeMenuVm: createMockViewModel(mockComposeMenuSnapshot), - sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot), - }; + composeMenuState: { + canCreateRoom: true, + canCreateVideoRoom: true, + }, + activeSortOption: SortOption.Activity, + }); - render(); + render(); // Compose button should be present expect(screen.getByLabelText("New conversation")).toBeInTheDocument(); }); it("renders compose icon button when displayComposeMenu is false", () => { - const snapshot: RoomListHeaderSnapshot = { + const vm = createMockViewModel({ title: "My Space", isSpace: false, displayComposeMenu: false, - onComposeClick: jest.fn(), - sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot), - }; + activeSortOption: SortOption.Activity, + }); - render(); + render(); // Compose icon button should be present expect(screen.getByLabelText("New conversation")).toBeInTheDocument(); }); it("renders sort options menu", () => { - const snapshot: RoomListHeaderSnapshot = { + const vm = createMockViewModel({ title: "My Space", isSpace: false, displayComposeMenu: false, - onComposeClick: jest.fn(), - sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot), - }; + activeSortOption: SortOption.Activity, + }); - render(); + render(); // Sort options menu trigger should be present expect(screen.getByLabelText("Room options")).toBeInTheDocument(); @@ -125,15 +139,15 @@ describe("RoomListHeader", () => { it("truncates long titles with title attribute", () => { const longTitle = "This is a very long space name that should be truncated"; - const snapshot: RoomListHeaderSnapshot = { + + const vm = createMockViewModel({ title: longTitle, isSpace: false, displayComposeMenu: false, - onComposeClick: jest.fn(), - sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot), - }; + activeSortOption: SortOption.Activity, + }); - render(); + render(); const h1 = screen.getByRole("heading", { level: 1 }); expect(h1).toHaveAttribute("title", longTitle); @@ -141,15 +155,14 @@ describe("RoomListHeader", () => { }); it("renders data-testid attribute", () => { - const snapshot: RoomListHeaderSnapshot = { + const vm = createMockViewModel({ title: "My Space", isSpace: false, displayComposeMenu: false, - onComposeClick: jest.fn(), - sortOptionsMenuVm: createMockViewModel(mockSortOptionsSnapshot), - }; + activeSortOption: SortOption.Activity, + }); - render(); + render(); expect(screen.getByTestId("room-list-header")).toBeInTheDocument(); }); diff --git a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.tsx b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.tsx index d602da5414..89b8d77b10 100644 --- a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.tsx +++ b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.tsx @@ -9,49 +9,66 @@ 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 SpaceMenuSnapshot } from "./SpaceMenu"; -import { ComposeMenu, type ComposeMenuSnapshot } from "./ComposeMenu"; -import { SortOptionsMenu, type SortOptionsMenuSnapshot } from "./SortOptionsMenu"; +import { SpaceMenu } from "./SpaceMenu"; +import { ComposeMenu } from "./ComposeMenu"; +import { SortOptionsMenu, SortOption } from "./SortOptionsMenu"; import styles from "./RoomListHeader.module.css"; +import { RoomListViewModel } from "../RoomListView"; +import { useViewModel } from "../../useViewModel"; /** - * Snapshot for RoomListHeader + * State for space menu - pure data, no callbacks */ -export type RoomListHeaderSnapshot = { - /** The title to display in the header */ +export type SpaceMenuState = { + /** The title of the space */ 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) */ - spaceMenuVm?: ViewModel; - /** Whether to display the compose menu */ - displayComposeMenu: boolean; - /** Compose menu view model (only used if displayComposeMenu is true) */ - composeMenuVm?: ViewModel; - /** Callback when compose button is clicked (only used if displayComposeMenu is false) */ - onComposeClick?: () => void; - /** Sort options menu view model */ - sortOptionsMenuVm: ViewModel; + /** Whether the user can invite in the space */ + canInviteInSpace: boolean; + /** Whether the user can access space settings */ + canAccessSpaceSettings: boolean; }; /** - * Props for RoomListHeader component + * State for compose menu - pure data, no callbacks */ -export interface RoomListHeaderProps { - /** The view model containing header data */ - vm: ViewModel; -} +export type ComposeMenuState = { + /** Whether the user can create rooms */ + canCreateRoom: boolean; + /** Whether the user can create video rooms */ + canCreateVideoRoom: boolean; +}; +/** + * State for RoomListHeader - pure data + */ +export type RoomListHeaderState = { + /** Header title */ + title: string; + /** Whether this is a space */ + isSpace: boolean; + /** Space menu state (if this is a space) */ + spaceMenuState?: SpaceMenuState; + /** Whether to display compose menu */ + displayComposeMenu: boolean; + /** Compose menu state (if displayComposeMenu is true) */ + composeMenuState?: ComposeMenuState; + /** Active sort option */ + activeSortOption: SortOption; +}; + +export interface RoomListHeaderProps { + vm: RoomListViewModel; +} /** * 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 = ({ vm }): JSX.Element => { const snapshot = useViewModel(vm); + const { title, isSpace, spaceMenuState, displayComposeMenu, composeMenuState, activeSortOption } = + snapshot.headerState; return ( = ({ vm }): JSX.Eleme data-testid="room-list-header" > -

{snapshot.title}

- {snapshot.isSpace && snapshot.spaceMenuVm && } +

{title}

+ {isSpace && spaceMenuState && ( + + )}
- - {snapshot.displayComposeMenu && snapshot.composeMenuVm ? ( - + + {displayComposeMenu && composeMenuState ? ( + ) : ( - + )} diff --git a/packages/shared-components/src/room-list/RoomListHeader/SortOptionsMenu.tsx b/packages/shared-components/src/room-list/RoomListHeader/SortOptionsMenu.tsx index 521eb8dc7c..8d197e4515 100644 --- a/packages/shared-components/src/room-list/RoomListHeader/SortOptionsMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListHeader/SortOptionsMenu.tsx @@ -9,8 +9,6 @@ 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"; /** @@ -24,21 +22,13 @@ export enum SortOption { /** * Snapshot for SortOptionsMenu */ -export type SortOptionsMenuSnapshot = { +export type SortOptionsMenuProps = { /** 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 */ - vm: ViewModel; -} - const MenuTrigger = (props: React.ComponentProps): JSX.Element => ( @@ -51,17 +41,16 @@ const MenuTrigger = (props: React.ComponentProps): 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 = ({ vm }): JSX.Element => { - const snapshot = useViewModel(vm); +export const SortOptionsMenu: React.FC = ({ activeSortOption, sort }): JSX.Element => { const [open, setOpen] = useState(false); const onActivitySelected = useCallback(() => { - snapshot.sort(SortOption.Activity); - }, [snapshot]); + sort(SortOption.Activity); + }, [sort]); const onAtoZSelected = useCallback(() => { - snapshot.sort(SortOption.AToZ); - }, [snapshot]); + sort(SortOption.AToZ); + }, [sort]); return ( = ({ vm }): JSX.Ele diff --git a/packages/shared-components/src/room-list/RoomListHeader/SpaceMenu.tsx b/packages/shared-components/src/room-list/RoomListHeader/SpaceMenu.tsx index ab0abe86d8..b018322c1d 100644 --- a/packages/shared-components/src/room-list/RoomListHeader/SpaceMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListHeader/SpaceMenu.tsx @@ -13,14 +13,12 @@ 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"; /** - * Snapshot for SpaceMenu + * Props for SpaceMenu component */ -export type SpaceMenuSnapshot = { +export interface SpaceMenuProps { /** The title of the space */ title: string; /** Whether the user can invite in the space */ @@ -35,29 +33,33 @@ export type SpaceMenuSnapshot = { openSpacePreferences: () => void; /** Open the space settings */ openSpaceSettings: () => void; -}; +} /** - * Props for SpaceMenu component + * @deprecated Use SpaceMenuProps instead */ -export interface SpaceMenuProps { - /** The view model containing menu data and callbacks */ - vm: ViewModel; -} +export type SpaceMenuSnapshot = SpaceMenuProps; /** * The space menu for the room list header. * Displays a dropdown menu with space-specific actions. */ -export const SpaceMenu: React.FC = ({ vm }): JSX.Element => { - const snapshot = useViewModel(vm); +export const SpaceMenu: React.FC = ({ + title, + canInviteInSpace, + canAccessSpaceSettings, + openSpaceHome, + inviteInSpace, + openSpacePreferences, + openSpaceSettings, +}): JSX.Element => { const [open, setOpen] = useState(false); return ( = ({ vm }): JSX.Element => { - {snapshot.canInviteInSpace && ( - + {canInviteInSpace && ( + )} - {snapshot.canAccessSpaceSettings && ( + {canAccessSpaceSettings && ( )} diff --git a/packages/shared-components/src/room-list/RoomListHeader/index.ts b/packages/shared-components/src/room-list/RoomListHeader/index.ts new file mode 100644 index 0000000000..9d35a373c0 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright 2025 New Vector 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 { RoomListHeader, type RoomListHeaderProps, type RoomListHeaderState, type SpaceMenuState, type ComposeMenuState } from "./RoomListHeader"; +export { ComposeMenu, type ComposeMenuProps, type ComposeMenuSnapshot } from "./ComposeMenu"; +export { SpaceMenu, type SpaceMenuProps, type SpaceMenuSnapshot } from "./SpaceMenu"; +export { SortOptionsMenu, type SortOptionsMenuProps, SortOption } from "./SortOptionsMenu"; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx index 382ae601aa..14e154beec 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx @@ -10,7 +10,7 @@ import React from "react"; import { RoomListItemView, type RoomListItem, type RoomListItemCallbacks } from "./RoomListItem"; import type { NotificationDecorationData } from "../../notifications/NotificationDecoration"; import type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu"; -import type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu"; +import type { NotificationMenuState } from "./RoomListItemNotificationMenu"; import type { RoomNotifState } from "../../notifications/RoomNotifs"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -85,10 +85,6 @@ const mockMoreOptionsCallbacks: MoreOptionsMenuCallbacks = { onLeaveRoom: () => console.log("Leave room"), }; -const mockNotificationCallbacks: NotificationMenuCallbacks = { - onSetRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state), -}; - const baseItem: RoomListItem = { id: "!test:example.org", name: "Test Room", @@ -105,7 +101,7 @@ const baseItem: RoomListItem = { const baseCallbacks: RoomListItemCallbacks = { onOpenRoom: () => console.log("Opening room"), moreOptionsCallbacks: mockMoreOptionsCallbacks, - notificationCallbacks: mockNotificationCallbacks, + onSetRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state), }; const meta = { diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx index 58aae7f8d2..ec92731b1c 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx @@ -12,7 +12,7 @@ import React from "react"; import { RoomListItemView, type RoomListItem, type RoomListItemCallbacks } from "./RoomListItem"; import type { NotificationDecorationData } from "../../notifications/NotificationDecoration"; import type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu"; -import type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu"; +import type { NotificationMenuState } from "./RoomListItemNotificationMenu"; describe("RoomListItem", () => { const mockNotificationData: NotificationDecorationData = { @@ -51,9 +51,7 @@ describe("RoomListItem", () => { onLeaveRoom: jest.fn(), }; - const mockNotificationCallbacks: NotificationMenuCallbacks = { - onSetRoomNotifState: jest.fn(), - }; + const mockOnSetRoomNotifState = jest.fn(); const mockItem: RoomListItem = { id: "!test:example.org", @@ -71,7 +69,7 @@ describe("RoomListItem", () => { const mockCallbacks: RoomListItemCallbacks = { onOpenRoom: jest.fn(), moreOptionsCallbacks: mockMoreOptionsCallbacks, - notificationCallbacks: mockNotificationCallbacks, + onSetRoomNotifState: mockOnSetRoomNotifState, }; const mockAvatar =
Avatar
; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx index c873c706ca..d439164708 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx @@ -15,9 +15,9 @@ import { type MoreOptionsMenuState, type MoreOptionsMenuCallbacks, type NotificationMenuState, - type NotificationMenuCallbacks, } from "./RoomListItemHoverMenu"; import { RoomListItemContextMenu } from "./RoomListItemContextMenu"; +import { type RoomNotifState } from "../../notifications/RoomNotifs"; import styles from "./RoomListItem.module.css"; /** @@ -55,8 +55,8 @@ export interface RoomListItemCallbacks { onOpenRoom: () => void; /** More options menu callbacks */ moreOptionsCallbacks: MoreOptionsMenuCallbacks; - /** Notification menu callbacks */ - notificationCallbacks: NotificationMenuCallbacks; + /** Set the room notification state */ + onSetRoomNotifState: (state: RoomNotifState) => void; } /** @@ -164,7 +164,7 @@ export const RoomListItemView = memo(function RoomListItemView({ moreOptionsState={item.moreOptionsState} moreOptionsCallbacks={callbacks.moreOptionsCallbacks} notificationState={item.notificationState} - notificationCallbacks={callbacks.notificationCallbacks} + onSetRoomNotifState={callbacks.onSetRoomNotifState} onMenuOpenChange={(isOpen: boolean) => (isOpen ? setIsMenuOpen(true) : closeMenu())} /> ) : ( diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx index 79f26fb7fc..f76537ed66 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx @@ -16,8 +16,8 @@ import { import { RoomListItemNotificationMenu, type NotificationMenuState, - type NotificationMenuCallbacks, } from "./RoomListItemNotificationMenu"; +import { type RoomNotifState } from "../../notifications/RoomNotifs"; /** * Props for RoomListItemHoverMenu component @@ -33,8 +33,8 @@ export interface RoomListItemHoverMenuProps { moreOptionsCallbacks: MoreOptionsMenuCallbacks; /** Notification menu state */ notificationState: NotificationMenuState; - /** Notification menu callbacks */ - notificationCallbacks: NotificationMenuCallbacks; + /** Callback to set room notification state */ + onSetRoomNotifState: (state: RoomNotifState) => void; /** Callback when menu open state changes */ onMenuOpenChange: (isOpen: boolean) => void; } @@ -49,7 +49,7 @@ export const RoomListItemHoverMenu: React.FC = ({ moreOptionsState, moreOptionsCallbacks, notificationState, - notificationCallbacks, + onSetRoomNotifState, onMenuOpenChange, }): JSX.Element => { return ( @@ -64,7 +64,7 @@ export const RoomListItemHoverMenu: React.FC = ({ {showNotificationMenu && ( )} @@ -74,4 +74,5 @@ export const RoomListItemHoverMenu: React.FC = ({ // Re-export types for convenience export type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu"; -export type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu"; +export type { NotificationMenuState } from "./RoomListItemNotificationMenu"; +export type { RoomNotifState } from "../../notifications/RoomNotifs"; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx index 19a0d31234..d322b5e00c 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx @@ -28,22 +28,14 @@ export interface NotificationMenuState { isNotificationMute: boolean; } -/** - * Callbacks for the notification menu - */ -export interface NotificationMenuCallbacks { - /** Set the room notification state */ - onSetRoomNotifState: (state: RoomNotifState) => void; -} - /** * Props for RoomListItemNotificationMenu component */ export interface RoomListItemNotificationMenuProps { /** Notification menu state */ state: NotificationMenuState; - /** Notification menu callbacks */ - callbacks: NotificationMenuCallbacks; + /** Set the room notification state */ + onSetRoomNotifState: (state: RoomNotifState) => void; /** Callback when menu open state changes */ onMenuOpenChange: (isOpen: boolean) => void; } @@ -54,7 +46,7 @@ export interface RoomListItemNotificationMenuProps { */ export function RoomListItemNotificationMenu({ state, - callbacks, + onSetRoomNotifState, onMenuOpenChange, }: RoomListItemNotificationMenuProps): JSX.Element { const [open, setOpen] = useState(false); @@ -84,7 +76,7 @@ export function RoomListItemNotificationMenu({ aria-selected={state.isNotificationAllMessage} hideChevron={true} label={_t("notifications|default_settings")} - onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.AllMessages)} + onSelect={() => onSetRoomNotifState(RoomNotifState.AllMessages)} onClick={(evt) => evt.stopPropagation()} > {state.isNotificationAllMessage && checkComponent} @@ -93,7 +85,7 @@ export function RoomListItemNotificationMenu({ aria-selected={state.isNotificationAllMessageLoud} hideChevron={true} label={_t("notifications|all_messages")} - onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.AllMessagesLoud)} + onSelect={() => onSetRoomNotifState(RoomNotifState.AllMessagesLoud)} onClick={(evt) => evt.stopPropagation()} > {state.isNotificationAllMessageLoud && checkComponent} @@ -102,7 +94,7 @@ export function RoomListItemNotificationMenu({ aria-selected={state.isNotificationMentionOnly} hideChevron={true} label={_t("notifications|mentions_keywords")} - onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.MentionsOnly)} + onSelect={() => onSetRoomNotifState(RoomNotifState.MentionsOnly)} onClick={(evt) => evt.stopPropagation()} > {state.isNotificationMentionOnly && checkComponent} @@ -111,7 +103,7 @@ export function RoomListItemNotificationMenu({ aria-selected={state.isNotificationMute} hideChevron={true} label={_t("notifications|mute_room")} - onSelect={() => callbacks.onSetRoomNotifState(RoomNotifState.Mute)} + onSelect={() => onSetRoomNotifState(RoomNotifState.Mute)} onClick={(evt) => evt.stopPropagation()} > {state.isNotificationMute && checkComponent} diff --git a/packages/shared-components/src/room-list/RoomListItem/index.ts b/packages/shared-components/src/room-list/RoomListItem/index.ts index 7d887eb82b..189bb1cd29 100644 --- a/packages/shared-components/src/room-list/RoomListItem/index.ts +++ b/packages/shared-components/src/room-list/RoomListItem/index.ts @@ -8,4 +8,4 @@ export { RoomListItemView } from "./RoomListItem"; export type { RoomListItem, RoomListItemViewProps, RoomListItemCallbacks } from "./RoomListItem"; export type { MoreOptionsMenuState, MoreOptionsMenuCallbacks } from "./RoomListItemMoreOptionsMenu"; -export type { NotificationMenuState, NotificationMenuCallbacks } from "./RoomListItemNotificationMenu"; +export type { NotificationMenuState } from "./RoomListItemNotificationMenu"; diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx index e5a2dbf52c..55e79cb933 100644 --- a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx @@ -6,21 +6,16 @@ */ import React from "react"; - import type { Meta, StoryObj } from "@storybook/react-vite"; + import type { NotificationDecorationData } from "../../notifications/NotificationDecoration"; -import type { RoomsResult } from "../RoomList"; import type { RoomListItem } from "../RoomListItem"; import type { MoreOptionsMenuState } from "../RoomListItem/RoomListItemMoreOptionsMenu"; import type { NotificationMenuState } from "../RoomListItem/RoomListItemNotificationMenu"; import { SortOption } from "../RoomListHeader/SortOptionsMenu"; -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 { RoomListViewWrapperSnapshot } from "../RoomListView"; -import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters"; +import { RoomListPanel } from "./RoomListPanel"; +import type { Filter } from "../RoomListPrimaryFilters/useVisibleFilters"; +import type { RoomListSnapshot, RoomListViewModel, RoomListHeaderState } from "../RoomListView"; // Mock avatar component const mockAvatar = (roomItem: RoomListItem): React.ReactElement => ( @@ -90,19 +85,12 @@ const generateMockRooms = (count: number): RoomListItem[] => { }); }; -const mockRoomsResult: RoomsResult = { - spaceId: "!space:server", - filterKeys: undefined, - rooms: generateMockRooms(20), -}; - // Create mock filters -const createFilters = (): FilterViewModel[] => { +const createFilters = (): Filter[] => { const filters = ["All", "People", "Rooms", "Favourites", "Unread"]; return filters.map((name, index) => ({ name, active: index === 0, - toggle: () => console.log(`Filter: ${name}`), })); }; @@ -118,67 +106,61 @@ type Story = StoryObj; // Create stable unsubscribe function const noop = (): void => {}; -function createMockViewModel(snapshot: T): ViewModel { +// Create mock ViewModel with public methods +function createMockViewModel(snapshot: RoomListSnapshot): RoomListViewModel { return { getSnapshot: () => snapshot, subscribe: () => noop, + // Public properties + showDialPad: false, + showExplore: false, + // Public callback methods + onToggleFilter: () => {}, + onSearchClick: () => {}, + onDialPadClick: () => {}, + onExploreClick: () => {}, + onComposeClick: () => {}, + openSpaceHome: () => {}, + inviteInSpace: () => {}, + openSpacePreferences: () => {}, + openSpaceSettings: () => {}, + createChatRoom: () => {}, + createRoom: () => {}, + createVideoRoom: () => {}, + sort: () => {}, + onOpenRoom: () => {}, + onMarkAsRead: () => {}, + onMarkAsUnread: () => {}, + onToggleFavorite: () => {}, + onToggleLowPriority: () => {}, + onInvite: () => {}, + onCopyRoomLink: () => {}, + onLeaveRoom: () => {}, + onSetRoomNotifState: () => {}, }; } -// Create stable snapshot for RoomListViewModel -const mockRoomListSnapshot = { - roomsResult: mockRoomsResult, - activeRoomIndex: 0, +const baseHeaderState: RoomListHeaderState = { + title: "Home", + isSpace: false, + displayComposeMenu: false, + activeSortOption: SortOption.Activity, }; -// Create stable RoomListViewModel -const mockRoomListViewModel = { - getSnapshot: () => mockRoomListSnapshot, - subscribe: () => noop, - onOpenRoom: (roomId: string) => console.log("Open room:", roomId), - onMarkAsRead: (roomId: string) => console.log("Mark as read:", roomId), - onMarkAsUnread: (roomId: string) => console.log("Mark as unread:", roomId), - onToggleFavorite: (roomId: string) => console.log("Toggle favorite:", roomId), - onToggleLowPriority: (roomId: string) => console.log("Toggle low priority:", roomId), - onInvite: (roomId: string) => console.log("Invite:", roomId), - onCopyRoomLink: (roomId: string) => console.log("Copy room link:", roomId), - onLeaveRoom: (roomId: string) => console.log("Leave room:", roomId), - onSetRoomNotifState: (roomId: string, state: any) => console.log("Set notification:", roomId, state), +const baseSnapshot: RoomListSnapshot = { + headerState: baseHeaderState, + isLoadingRooms: false, + isRoomListEmpty: false, + filters: createFilters(), + roomListState: { + rooms: generateMockRooms(20), + }, + emptyStateDescription: "Join a room to get started", }; -const baseViewModel: ViewModel = createMockViewModel({ - ariaLabel: "Room list navigation", - searchVm: createMockViewModel({ - onSearchClick: () => console.log("Open search"), - showDialPad: false, - showExplore: true, - onExploreClick: () => console.log("Explore rooms"), - }), - headerVm: createMockViewModel({ - title: "Home", - isSpace: false, - displayComposeMenu: false, - onComposeClick: () => console.log("Compose"), - sortOptionsMenuVm: createMockViewModel({ - activeSortOption: SortOption.Activity, - sort: (option) => console.log(`Sort: ${option}`), - }), - }), - viewVm: createMockViewModel({ - isLoadingRooms: false, - isRoomListEmpty: false, - filtersVm: createMockViewModel({ - filters: createFilters(), - }), - roomListVm: mockRoomListViewModel, - emptyStateTitle: "No rooms", - emptyStateDescription: "Join a room to get started", - }), -}); - export const Default: Story = { args: { - vm: baseViewModel, + vm: createMockViewModel(baseSnapshot), renderAvatar: mockAvatar, }, decorators: [ @@ -192,11 +174,8 @@ export const Default: Story = { export const WithoutSearch: Story = { args: { - vm: createMockViewModel({ - ariaLabel: "Room list navigation", - searchVm: undefined, - headerVm: baseViewModel.getSnapshot().headerVm, - viewVm: baseViewModel.getSnapshot().viewVm, + vm: createMockViewModel({ + ...baseSnapshot, }), renderAvatar: mockAvatar, }, @@ -211,14 +190,9 @@ export const WithoutSearch: Story = { export const Loading: Story = { args: { - vm: createMockViewModel({ - ariaLabel: "Room list navigation", - searchVm: baseViewModel.getSnapshot().searchVm, - headerVm: baseViewModel.getSnapshot().headerVm, - viewVm: createMockViewModel({ - ...baseViewModel.getSnapshot().viewVm.getSnapshot(), - isLoadingRooms: true, - }), + vm: createMockViewModel({ + ...baseSnapshot, + isLoadingRooms: true, }), renderAvatar: mockAvatar, }, @@ -233,16 +207,13 @@ export const Loading: Story = { export const Empty: Story = { args: { - vm: createMockViewModel({ - ariaLabel: "Room list navigation", - searchVm: baseViewModel.getSnapshot().searchVm, - headerVm: baseViewModel.getSnapshot().headerVm, - viewVm: createMockViewModel({ - ...baseViewModel.getSnapshot().viewVm.getSnapshot(), - isRoomListEmpty: true, - emptyStateTitle: "No rooms to display", - emptyStateDescription: "Join a room or start a conversation to get started", - }), + vm: createMockViewModel({ + ...baseSnapshot, + isRoomListEmpty: true, + roomListState: { + rooms: [], + }, + emptyStateDescription: "Join a room or start a conversation to get started", }), renderAvatar: mockAvatar, }, diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx index 6239d17e86..e781f4e852 100644 --- a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx @@ -8,16 +8,13 @@ import { render, screen } from "jest-matrix-react"; import React from "react"; -import { RoomListPanel, type RoomListPanelSnapshot } from "./RoomListPanel"; -import { type ViewModel } from "../../viewmodel/ViewModel"; +import { RoomListPanel } from "./RoomListPanel"; +import { type RoomListViewModel, type RoomListSnapshot } from "../RoomListView"; import { SortOption } from "../RoomListHeader"; import type { RoomListItem } from "../RoomListItem"; -import type { RoomListSearchSnapshot } from "../RoomListSearch"; -import type { RoomListHeaderSnapshot } from "../RoomListHeader"; -import type { RoomListViewWrapperSnapshot } from "../RoomListView"; -import type { RoomListPrimaryFiltersSnapshot } from "../RoomListPrimaryFilters"; -import type { RoomListViewModel, RoomListViewSnapshot } from "../RoomList"; +import type { RoomListSearchState } from "../RoomListSearch"; import type { SortOptionsMenuSnapshot } from "../RoomListHeader/SortOptionsMenu"; +import type { Filter } from "../RoomListPrimaryFilters"; // Mock ResizeObserver which is used by RoomListPrimaryFilters global.ResizeObserver = class ResizeObserver { @@ -27,10 +24,23 @@ global.ResizeObserver = class ResizeObserver { }; describe("RoomListPanel", () => { - function createMockViewModel(snapshot: T): ViewModel { + function createMockViewModel(snapshot: RoomListSnapshot): RoomListViewModel { return { getSnapshot: () => snapshot, subscribe: () => () => {}, + onToggleFilter: jest.fn(), + onSearchClick: jest.fn(), + onDialPadClick: jest.fn(), + onExploreClick: jest.fn(), + onOpenRoom: jest.fn(), + onMarkAsRead: jest.fn(), + onMarkAsUnread: jest.fn(), + onToggleFavorite: jest.fn(), + onToggleLowPriority: jest.fn(), + onInvite: jest.fn(), + onCopyRoomLink: jest.fn(), + onLeaveRoom: jest.fn(), + onSetRoomNotifState: jest.fn(), }; } @@ -38,8 +48,7 @@ describe("RoomListPanel", () => {
{roomItem.name[0]}
)); - const searchSnapshot: RoomListSearchSnapshot = { - onSearchClick: jest.fn(), + const searchState: RoomListSearchState = { showDialPad: false, showExplore: false, }; @@ -49,54 +58,26 @@ describe("RoomListPanel", () => { sort: jest.fn(), }; - const headerSnapshot: RoomListHeaderSnapshot = { - title: "Test Header", - isSpace: false, - displayComposeMenu: false, - onComposeClick: jest.fn(), - sortOptionsMenuVm: createMockViewModel(sortOptionsMenuSnapshot), - }; + const filters: Filter[] = []; - const filtersSnapshot: RoomListPrimaryFiltersSnapshot = { - filters: [], - }; - - const roomListSnapshot: RoomListViewSnapshot = { - roomsResult: { - spaceId: "!space:server", - filterKeys: undefined, - rooms: [], + const mockSnapshot: RoomListSnapshot = { + searchState: searchState, + headerState: { + title: "Test Header", + isSpace: false, + displayComposeMenu: false, + onComposeClick: jest.fn(), + sortOptionsMenuProps: sortOptionsMenuSnapshot, }, - activeRoomIndex: undefined, - }; - - const roomListViewModel: RoomListViewModel = { - getSnapshot: () => roomListSnapshot, - subscribe: () => () => {}, - onOpenRoom: jest.fn(), - onMarkAsRead: jest.fn(), - onMarkAsUnread: jest.fn(), - onToggleFavorite: jest.fn(), - onToggleLowPriority: jest.fn(), - onInvite: jest.fn(), - onCopyRoomLink: jest.fn(), - onLeaveRoom: jest.fn(), - onSetRoomNotifState: jest.fn(), - }; - - const viewSnapshot: RoomListViewWrapperSnapshot = { isLoadingRooms: false, isRoomListEmpty: false, - emptyStateTitle: "No rooms", - filtersVm: createMockViewModel(filtersSnapshot), - roomListVm: roomListViewModel, - }; - - const mockSnapshot: RoomListPanelSnapshot = { - ariaLabel: "Room List", - searchVm: createMockViewModel(searchSnapshot), - headerVm: createMockViewModel(headerSnapshot), - viewVm: createMockViewModel(viewSnapshot), + filters: filters, + roomListState: { + rooms: [], + activeRoomIndex: undefined, + spaceId: "!space:server", + filterKeys: undefined, + }, }; const mockViewModel = createMockViewModel(mockSnapshot); @@ -105,13 +86,13 @@ describe("RoomListPanel", () => { render(); expect(screen.getByText("Test Header")).toBeInTheDocument(); - expect(screen.getByRole("navigation", { name: "Room List" })).toBeInTheDocument(); + expect(screen.getByRole("navigation")).toBeInTheDocument(); }); it("renders without search", () => { - const snapshotWithoutSearch: RoomListPanelSnapshot = { + const snapshotWithoutSearch: RoomListSnapshot = { ...mockSnapshot, - searchVm: undefined, + searchState: undefined, }; const vmWithoutSearch = createMockViewModel(snapshotWithoutSearch); @@ -122,17 +103,12 @@ describe("RoomListPanel", () => { }); it("renders loading state", () => { - const loadingViewSnapshot: RoomListViewWrapperSnapshot = { - ...viewSnapshot, + const loadingSnapshot: RoomListSnapshot = { + ...mockSnapshot, isLoadingRooms: true, isRoomListEmpty: false, }; - const loadingSnapshot: RoomListPanelSnapshot = { - ...mockSnapshot, - viewVm: createMockViewModel(loadingViewSnapshot), - }; - const vmLoading = createMockViewModel(loadingSnapshot); render(); @@ -142,17 +118,12 @@ describe("RoomListPanel", () => { }); it("renders empty state", () => { - const emptyViewSnapshot: RoomListViewWrapperSnapshot = { - ...viewSnapshot, + const emptySnapshot: RoomListSnapshot = { + ...mockSnapshot, isLoadingRooms: false, isRoomListEmpty: true, }; - const emptySnapshot: RoomListPanelSnapshot = { - ...mockSnapshot, - viewVm: createMockViewModel(emptyViewSnapshot), - }; - const vmEmpty = createMockViewModel(emptySnapshot); render(); diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx index 86f4e7fdf1..622b186a5e 100644 --- a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx @@ -7,35 +7,19 @@ 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 RoomListSearchSnapshot } from "../RoomListSearch"; -import { RoomListHeader, type RoomListHeaderSnapshot } from "../RoomListHeader"; -import { RoomListView, type RoomListViewWrapperSnapshot } from "../RoomListView"; +import { RoomListSearch } from "../RoomListSearch"; +import { RoomListHeader } from "../RoomListHeader"; +import { RoomListView, type RoomListViewModel } from "../RoomListView"; import { type RoomListItem } from "../RoomListItem"; import styles from "./RoomListPanel.module.css"; -/** - * Snapshot for RoomListPanel - */ -export type RoomListPanelSnapshot = { - /** Accessibility label for the navigation landmark */ - ariaLabel: string; - /** Optional search view model */ - searchVm?: ViewModel; - /** Header view model */ - headerVm: ViewModel; - /** View model for the main content area */ - viewVm: ViewModel; -}; - /** * Props for RoomListPanel component */ export interface RoomListPanelProps extends React.HTMLAttributes { /** The view model containing all data and callbacks */ - vm: ViewModel; + vm: RoomListViewModel; /** Render function for room avatar */ renderAvatar: (roomItem: RoomListItem) => ReactNode; } @@ -45,20 +29,17 @@ export interface RoomListPanelProps extends React.HTMLAttributes { * Composes search, header, and content areas with a ViewModel pattern. */ export const RoomListPanel: React.FC = ({ vm, renderAvatar, ...props }): JSX.Element => { - const snapshot = useViewModel(vm); - return ( - - {snapshot.searchVm && } - - + + + + ); }; diff --git a/packages/shared-components/src/room-list/RoomListPanel/index.ts b/packages/shared-components/src/room-list/RoomListPanel/index.ts new file mode 100644 index 0000000000..6d6db2c3ef --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPanel/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector 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 { RoomListPanel, type RoomListPanelProps } from "./RoomListPanel"; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx index 4843a42934..60234af260 100644 --- a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx @@ -6,68 +6,54 @@ */ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RoomListPrimaryFilters, type RoomListPrimaryFiltersSnapshot } from "./RoomListPrimaryFilters"; -import type { FilterViewModel } from "./useVisibleFilters"; -import { type ViewModel } from "../../viewmodel/ViewModel"; +import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters"; +import type { Filter } from "./useVisibleFilters"; const meta: Meta = { title: "Room List/RoomListPrimaryFilters", component: RoomListPrimaryFilters, tags: ["autodocs"], + args: { + onToggleFilter: () => {}, + }, }; export default meta; type Story = StoryObj; -function createMockViewModel(snapshot: RoomListPrimaryFiltersSnapshot): ViewModel { - return { - getSnapshot: () => snapshot, - subscribe: () => () => {}, - }; -} - // Mock filter data - simple presentation data only -const createFilters = (selectedIndex: number = 0): FilterViewModel[] => { +const createFilters = (selectedIndex: number = 0): Filter[] => { const filterNames = ["All", "People", "Rooms", "Favourites", "Unread"]; return filterNames.map((name, index) => ({ name, active: index === selectedIndex, - toggle: () => console.log(`Filter toggled: ${name}`), })); }; export const Default: Story = { args: { - vm: createMockViewModel({ - filters: createFilters(0), - }), + filters: createFilters(0), }, }; export const PeopleSelected: Story = { args: { - vm: createMockViewModel({ - filters: createFilters(1), - }), + filters: createFilters(1), }, }; export const FewFilters: Story = { args: { - vm: createMockViewModel({ - filters: [ - { - name: "All", - active: true, - toggle: () => console.log("All toggled"), - }, - { - name: "Unread", - active: false, - toggle: () => console.log("Unread toggled"), - }, - ], - }), + filters: [ + { + name: "All", + active: true, + }, + { + name: "Unread", + active: false, + }, + ], }, }; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx index c9bfae94c0..1f353e8ad9 100644 --- a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx @@ -9,41 +9,35 @@ 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"; -import { useVisibleFilters, type FilterViewModel } from "./useVisibleFilters"; +import { useVisibleFilters, type Filter } from "./useVisibleFilters"; import styles from "./RoomListPrimaryFilters.module.css"; -/** - * Snapshot for RoomListPrimaryFilters - */ -export type RoomListPrimaryFiltersSnapshot = { - /** Array of filter data */ - filters: FilterViewModel[]; -}; - /** * Props for RoomListPrimaryFilters component */ export interface RoomListPrimaryFiltersProps { - /** The view model containing filter data */ - vm: ViewModel; + /** Array of filters to display */ + filters: Filter[]; + /** Callback when a filter is toggled */ + onToggleFilter: (filter: Filter) => void; } /** * The primary filters component for the room list. * Displays a collapsible list of filters with expand/collapse functionality. */ -export const RoomListPrimaryFilters: React.FC = ({ vm }): JSX.Element => { - const snapshot = useViewModel(vm); +export const RoomListPrimaryFilters: React.FC = ({ + filters, + onToggleFilter, +}): JSX.Element | null => { const id = useId(); const [isExpanded, setIsExpanded] = useState(false); const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters(isExpanded); - const filters = useVisibleFilters(snapshot.filters, wrappingIndex); + const visibleFilters = useVisibleFilters(filters, wrappingIndex); return ( = ({ className={styles.list} ref={ref} > - {filters.map((filter, i) => ( - filter.toggle()}> + {visibleFilters.map((filter, i) => ( + onToggleFilter(filter)}> {filter.name} ))} diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx index a843b47e54..52a7085fd5 100644 --- a/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx @@ -6,7 +6,7 @@ */ export { RoomListPrimaryFilters } from "./RoomListPrimaryFilters"; -export type { RoomListPrimaryFiltersProps, RoomListPrimaryFiltersSnapshot } from "./RoomListPrimaryFilters"; +export type { RoomListPrimaryFiltersProps } from "./RoomListPrimaryFilters"; export { useCollapseFilters } from "./useCollapseFilters"; export { useVisibleFilters } from "./useVisibleFilters"; -export type { FilterViewModel } from "./useVisibleFilters"; +export type { Filter } from "./useVisibleFilters"; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts index b68175a532..cc0db3f40a 100644 --- a/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts @@ -7,13 +7,11 @@ import { useEffect, useState } from "react"; -export interface FilterViewModel { +export interface Filter { /** Filter name/label */ name: string; /** Whether the filter is currently active */ active: boolean; - /** Callback when filter is clicked */ - toggle: () => void; } /** @@ -24,7 +22,7 @@ export interface FilterViewModel { * @param filters - the list of filters to sort. * @param wrappingIndex - the index of the first filter that is wrapping. */ -export function useVisibleFilters(filters: FilterViewModel[], wrappingIndex: number): FilterViewModel[] { +export function useVisibleFilters(filters: Filter[], wrappingIndex: number): Filter[] { // By default, the filters are not sorted const [sortedFilters, setSortedFilters] = useState(filters); diff --git a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.stories.tsx b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.stories.tsx index 4d7fcbc5a4..fd782843b8 100644 --- a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.stories.tsx @@ -6,8 +6,7 @@ */ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RoomListSearch, type RoomListSearchSnapshot } from "./RoomListSearch"; -import { type ViewModel } from "../../viewmodel/ViewModel"; +import { RoomListSearch } from "./RoomListSearch"; const meta: Meta = { title: "Room List/RoomListSearch", @@ -18,53 +17,38 @@ const meta: Meta = { export default meta; type Story = StoryObj; -function createMockViewModel(snapshot: RoomListSearchSnapshot): ViewModel { - return { - getSnapshot: () => snapshot, - subscribe: () => () => {}, - }; -} - export const Default: Story = { args: { - vm: createMockViewModel({ - onSearchClick: () => console.log("Open search"), - showDialPad: false, - showExplore: false, - }), + onSearchClick: () => console.log("Open search"), + showDialPad: false, + showExplore: false, }, }; export const WithDialPad: Story = { args: { - vm: createMockViewModel({ - onSearchClick: () => console.log("Open search"), - showDialPad: true, - onDialPadClick: () => console.log("Open dial pad"), - showExplore: false, - }), + onSearchClick: () => console.log("Open search"), + showDialPad: true, + onDialPadClick: () => console.log("Open dial pad"), + showExplore: false, }, }; export const WithExplore: Story = { args: { - vm: createMockViewModel({ - onSearchClick: () => console.log("Open search"), - showDialPad: false, - showExplore: true, - onExploreClick: () => console.log("Explore rooms"), - }), + onSearchClick: () => console.log("Open search"), + showDialPad: false, + showExplore: true, + onExploreClick: () => console.log("Explore rooms"), }, }; export const WithAllActions: Story = { args: { - vm: createMockViewModel({ - onSearchClick: () => console.log("Open search"), - showDialPad: true, - onDialPadClick: () => console.log("Open dial pad"), - showExplore: true, - onExploreClick: () => console.log("Explore rooms"), - }), + onSearchClick: () => console.log("Open search"), + showDialPad: true, + onDialPadClick: () => console.log("Open dial pad"), + showExplore: true, + onExploreClick: () => console.log("Explore rooms"), }, }; diff --git a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx index 868fa27997..a4663410af 100644 --- a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx +++ b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx @@ -9,26 +9,18 @@ import { render, screen } from "jest-matrix-react"; import React from "react"; import userEvent from "@testing-library/user-event"; -import { RoomListSearch, type RoomListSearchSnapshot } from "./RoomListSearch"; -import { type ViewModel } from "../../viewmodel/ViewModel"; - -function createMockViewModel(snapshot: RoomListSearchSnapshot): ViewModel { - return { - getSnapshot: () => snapshot, - subscribe: () => () => {}, - }; -} +import { RoomListSearch, type RoomListSearchProps } from "./RoomListSearch"; describe("RoomListSearch", () => { it("renders search button with shortcut", () => { const onSearchClick = jest.fn(); - const vm = createMockViewModel({ + const props: RoomListSearchProps = { onSearchClick, showDialPad: false, showExplore: false, - }); + }; - render(); + render(); expect(screen.getByRole("search")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument(); @@ -38,13 +30,13 @@ describe("RoomListSearch", () => { it("calls onSearchClick when search button is clicked", async () => { const onSearchClick = jest.fn(); - const vm = createMockViewModel({ + const props: RoomListSearchProps = { onSearchClick, showDialPad: false, showExplore: false, - }); + }; - render(); + render(); await userEvent.click(screen.getByRole("button", { name: /search/i })); expect(onSearchClick).toHaveBeenCalledTimes(1); @@ -52,28 +44,28 @@ describe("RoomListSearch", () => { it("renders dial pad button when showDialPad is true", () => { const onDialPadClick = jest.fn(); - const vm = createMockViewModel({ + const props: RoomListSearchProps = { onSearchClick: jest.fn(), showDialPad: true, onDialPadClick, showExplore: false, - }); + }; - render(); + render(); expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument(); }); it("calls onDialPadClick when dial pad button is clicked", async () => { const onDialPadClick = jest.fn(); - const vm = createMockViewModel({ + const props: RoomListSearchProps = { onSearchClick: jest.fn(), showDialPad: true, onDialPadClick, showExplore: false, - }); + }; - render(); + render(); await userEvent.click(screen.getByRole("button", { name: /dial pad/i })); expect(onDialPadClick).toHaveBeenCalledTimes(1); @@ -81,43 +73,43 @@ describe("RoomListSearch", () => { it("renders explore button when showExplore is true", () => { const onExploreClick = jest.fn(); - const vm = createMockViewModel({ + const props: RoomListSearchProps = { onSearchClick: jest.fn(), showDialPad: false, showExplore: true, onExploreClick, - }); + }; - render(); + render(); expect(screen.getByRole("button", { name: /explore/i })).toBeInTheDocument(); }); it("calls onExploreClick when explore button is clicked", async () => { const onExploreClick = jest.fn(); - const vm = createMockViewModel({ + const props: RoomListSearchProps = { onSearchClick: jest.fn(), showDialPad: false, showExplore: true, onExploreClick, - }); + }; - render(); + render(); await userEvent.click(screen.getByRole("button", { name: /explore/i })); expect(onExploreClick).toHaveBeenCalledTimes(1); }); it("renders all buttons when showDialPad and showExplore are true", () => { - const vm = createMockViewModel({ + const props: RoomListSearchProps = { onSearchClick: jest.fn(), showDialPad: true, onDialPadClick: jest.fn(), showExplore: true, onExploreClick: jest.fn(), - }); + }; - render(); + render(); expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument(); @@ -125,13 +117,13 @@ describe("RoomListSearch", () => { }); it("does not render dial pad or explore buttons when flags are false", () => { - const vm = createMockViewModel({ + const props: RoomListSearchProps = { onSearchClick: jest.fn(), showDialPad: false, showExplore: false, - }); + }; - render(); + render(); expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /dial pad/i })).not.toBeInTheDocument(); diff --git a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.tsx b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.tsx index 43d2f1db99..22206aa7b2 100644 --- a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.tsx +++ b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.tsx @@ -11,43 +11,42 @@ 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"; /** - * Snapshot for RoomListSearch + * State for RoomListSearch - pure data, no callbacks */ -export type RoomListSearchSnapshot = { - /** Callback fired when search button is clicked */ - onSearchClick: () => void; - /** Whether to show the dial pad button */ +export interface RoomListSearchState { showDialPad: boolean; - /** Callback fired when dial pad button is clicked */ - onDialPadClick?: () => void; /** Whether to show the explore rooms button */ showExplore: boolean; - /** Callback fired when explore button is clicked */ - onExploreClick?: () => void; -}; +} /** - * Props for RoomListSearch component + * Props for RoomListSearch component - combines state with callbacks */ -export interface RoomListSearchProps { - /** The view model containing search data */ - vm: ViewModel; +export interface RoomListSearchProps extends RoomListSearchState { + /** Callback fired when search button is clicked */ + onSearchClick: () => void; + /** Callback fired when dial pad button is clicked */ + onDialPadClick: () => void; + /** Callback fired when explore button is clicked */ + onExploreClick: () => void; } /** * 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 = ({ vm }): JSX.Element => { - const snapshot = useViewModel(vm); - +export const RoomListSearch: React.FC = ({ + onSearchClick, + showDialPad, + onDialPadClick, + showExplore, + onExploreClick, +}): JSX.Element => { // Determine keyboard shortcut based on platform const isMac = typeof navigator !== "undefined" && /Mac/.test(navigator.platform); const searchShortcut = isMac ? "⌘ K" : "Ctrl K"; @@ -59,31 +58,31 @@ export const RoomListSearch: React.FC = ({ vm }): JSX.Eleme kind="secondary" size="sm" Icon={SearchIcon} - onClick={snapshot.onSearchClick} + onClick={onSearchClick} > {_t("action|search")} {searchShortcut} - {snapshot.showDialPad && ( + {showDialPad && (