diff --git a/playwright/e2e/polls/polls.spec.ts b/playwright/e2e/polls/polls.spec.ts index 4fd81955810..6dedaacb411 100644 --- a/playwright/e2e/polls/polls.spec.ts +++ b/playwright/e2e/polls/polls.spec.ts @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { test, expect } from "../../element-web-test"; -import { Bot } from "../../pages/bot"; +import type { Locator, Page } from "@playwright/test"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; -import type { Locator, Page } from "@playwright/test"; +import { expect, test } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; test.describe("Polls", () => { type CreatePollOptions = { @@ -59,6 +59,33 @@ test.describe("Polls", () => { ).toContainText(`${votes} vote`); }; + const getPollResultsDialog = (page: Page): Locator => { + return page.locator(".mx_PollResultsDialog"); + }; + + const getPollResultsDialogOption = (page: Page, optionText: string): Locator => { + return getPollResultsDialog(page).locator(".mx_AnswerEntry").filter({ hasText: optionText }); + }; + + const expectDetailedPollOptionVoteCount = async ( + page: Page, + pollId: string, + optionText: string, + votes: number, + optLocator?: Locator, + ): Promise => { + await expect( + getPollResultsDialogOption(page, optionText) + .locator(".mx_AnswerEntry_Header") + .locator(".mx_AnswerEntry_Header_answerName"), + ).toContainText(optionText); + await expect( + getPollResultsDialogOption(page, optionText) + .locator(".mx_AnswerEntry_Header") + .locator(".mx_AnswerEntry_Header_voteCount"), + ).toContainText(`${votes} vote`); + }; + const botVoteForOption = async ( page: Page, bot: Bot, @@ -219,6 +246,70 @@ test.describe("Polls", () => { await expect(page.locator(".mx_ErrorDialog")).toBeAttached(); }); + test("should allow to view detailed results after voting", async ({ page, app, bot, user }) => { + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + // wait until Bob joined + await expect(page.getByText("BotBob joined the room")).toBeAttached(); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 + //cy.get(".mx_CompoundDialog").percySnapshotElement("Polls Composer"); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe?"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + await expect(getPollTile(page, pollId)).toMatchScreenshot("Polls_Timeline_tile_no_votes.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + // Bot votes 'Maybe' in the poll + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]); + + // no votes shown until I vote, check bots vote has arrived + await expect( + page.locator(".mx_MPollBody_totalVotes").getByText("1 vote cast. Vote to see the results"), + ).toBeAttached(); + + // vote 'Maybe' + await getPollOption(page, pollId, pollParams.options[2]).click(); + // both me and bot have voted Maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2); + + // click the 'vote to see results' message + await page + .locator(".mx_MPollBody_totalVotes") + .getByText("Based on 2 votes. Click here to see full results") + .click(); + + // expect the detailed results to be shown + await expect(getPollResultsDialog(page)).toBeAttached(); + + // expect results to be correctly shown + await expectDetailedPollOptionVoteCount(page, pollId, pollParams.options[2], 2); + const voterEntries = getPollResultsDialogOption(page, pollParams.options[2]).locator(".mx_VoterEntry"); + expect((await voterEntries.all()).length).toBe(2); + expect(voterEntries.filter({ hasText: bot.credentials.displayName })).not.toBeNull(); + expect(voterEntries.filter({ hasText: user.displayName })).not.toBeNull(); + + // close the dialog + await page.locator(".mx_Dialog").getByRole("button", { name: "Close" }).click(); + + // expect the dialog to be closed + await expect(getPollResultsDialog(page)).not.toBeAttached(); + }); + test("should be displayed correctly in thread panel", async ({ page, app, user, bot, homeserver }) => { const botCharlie = new Bot(page, homeserver, { displayName: "BotCharlie" }); await botCharlie.prepareClient(); diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 0fcdf6dee6e..fd6697307b0 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -18,6 +18,7 @@ @import "./components/views/dialogs/polls/_PollDetailHeader.pcss"; @import "./components/views/dialogs/polls/_PollListItem.pcss"; @import "./components/views/dialogs/polls/_PollListItemEnded.pcss"; +@import "./components/views/dialogs/polls/_PollResultsDialog.pcss"; @import "./components/views/elements/_AppPermission.pcss"; @import "./components/views/elements/_AppWarning.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; diff --git a/res/css/components/views/dialogs/polls/_PollResultsDialog.pcss b/res/css/components/views/dialogs/polls/_PollResultsDialog.pcss new file mode 100644 index 00000000000..d6f0efdf6de --- /dev/null +++ b/res/css/components/views/dialogs/polls/_PollResultsDialog.pcss @@ -0,0 +1,24 @@ +.mx_AnswerEntry:not(:last-child) { + margin-bottom: $spacing-8; +} + +.mx_AnswerEntry_Header { + display: flex; + align-items: center; + margin-bottom: $spacing-8; +} + +.mx_AnswerEntry_Header_answerName { + font-weight: bolder; + flex-grow: 1; +} + +.mx_VoterEntry { + display: flex; + align-items: center; + margin-left: $spacing-16; +} + +.mx_VoterEntry_AvatarWrapper { + margin-right: $spacing-8; +} diff --git a/res/css/components/views/polls/_PollOption.pcss b/res/css/components/views/polls/_PollOption.pcss index 4ef6c225224..aa1963fe8ae 100644 --- a/res/css/components/views/polls/_PollOption.pcss +++ b/res/css/components/views/polls/_PollOption.pcss @@ -35,6 +35,14 @@ Please see LICENSE files in the repository root for full details. justify-content: space-between; } +.mx_PollOption_votesWrapper { + display: flex; +} + +.mx_PollOption_facePile { + margin-right: $spacing-8; +} + .mx_PollOption_optionVoteCount { color: $secondary-content; font-size: $font-12px; diff --git a/src/components/views/dialogs/PollResultsDialog.tsx b/src/components/views/dialogs/PollResultsDialog.tsx new file mode 100644 index 00000000000..8a9fb3bb286 --- /dev/null +++ b/src/components/views/dialogs/PollResultsDialog.tsx @@ -0,0 +1,70 @@ +/* +Copyright 2024 Tim Vahlbrock + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { PollAnswerSubevent, PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; +import { RoomMember } from "matrix-js-sdk/src/matrix"; +import React from "react"; + +import { _t } from "../../../languageHandler"; +import Modal from "../../../Modal"; +import MemberAvatar from "../avatars/MemberAvatar"; +import { UserVote } from "../messages/MPollBody"; +import BaseDialog from "./BaseDialog"; + +interface IProps { + pollEvent: PollStartEvent; + votes: Map; + members: RoomMember[]; +} + +export default function PollResultsDialog(props: IProps): JSX.Element { + return ( + Modal.closeCurrentModal()} + className="mx_PollResultsDialog" + > + {props.pollEvent.answers.map((answer) => { + const votes = props.votes.get(answer.id) || []; + if (votes.length === 0) return; + + return ; + })} + + ); +} + +function AnswerEntry(props: { answer: PollAnswerSubevent; members: RoomMember[]; votes: UserVote[] }): JSX.Element { + const { answer, members, votes } = props; + return ( +
+
+ {answer.text} + + {_t("poll|result_dialog|count_of_votes", { count: votes.length })} + +
+ {votes.length === 0 &&
No one voted for this.
} + {votes.map((vote) => { + const member = members.find((m) => m.userId === vote.sender); + if (member) return ; + })} +
+ ); +} + +function VoterEntry(props: { vote: UserVote; member: RoomMember }): JSX.Element { + const { vote, member } = props; + return ( +
+
+
+ {member.name} +
+ ); +} diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 9e173e5f4a3..59392c46ce5 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -6,34 +6,35 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent"; +import { PollAnswerSubevent, PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { logger } from "matrix-js-sdk/src/logger"; import { - MatrixEvent, - MatrixClient, - Relations, - Poll, - PollEvent, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, + MatrixClient, + MatrixEvent, + Poll, + PollEvent, + Relations, TimelineEvents, } from "matrix-js-sdk/src/matrix"; import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations"; -import { PollStartEvent, PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; -import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent"; +import React, { ReactNode } from "react"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Modal from "../../../Modal"; -import { IBodyProps } from "./IBodyProps"; import { formatList } from "../../../utils/FormattingUtils"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import ErrorDialog from "../dialogs/ErrorDialog"; -import { GetRelationsForEvent } from "../rooms/EventTile"; +import PollResultsDialog from "../dialogs/PollResultsDialog"; import PollCreateDialog from "../elements/PollCreateDialog"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Spinner from "../elements/Spinner"; import { PollOption } from "../polls/PollOption"; +import { GetRelationsForEvent } from "../rooms/EventTile"; +import { IBodyProps } from "./IBodyProps"; interface IState { poll?: Poll; @@ -81,12 +82,12 @@ export function findTopAnswer(pollEvent: MatrixEvent, voteRelations: Relations): const userVotes: Map = collectUserVotes(allVotes(voteRelations)); - const votes: Map = countVotes(userVotes, poll); - const highestScore: number = Math.max(...votes.values()); + const votes: Map = countVotes(userVotes, poll); + const highestScore: number = Math.max(...Array.from(votes.values()).map((votes) => votes.length)); const bestAnswerIds: string[] = []; - for (const [answerId, score] of votes) { - if (score == highestScore) { + for (const [answerId, answerVotes] of votes) { + if (answerVotes.length == highestScore) { bestAnswerIds.push(answerId); } } @@ -273,10 +274,10 @@ export default class MPollBody extends React.Component { this.setState({ selected: newSelected }); } - private totalVotes(collectedVotes: Map): number { + private totalVotes(collectedVotes: Map): number { let sum = 0; for (const v of collectedVotes.values()) { - sum += v; + sum += v.length; } return sum; } @@ -294,7 +295,7 @@ export default class MPollBody extends React.Component { const userVotes = this.collectUserVotes(); const votes = countVotes(userVotes, pollEvent); const totalVotes = this.totalVotes(votes); - const winCount = Math.max(...votes.values()); + const winCount = Math.max(...Array.from(votes.values()).map((votes) => votes.length)); const userId = this.context.getSafeUserId(); const myVote = userVotes?.get(userId)?.answers[0]; const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name); @@ -324,6 +325,16 @@ export default class MPollBody extends React.Component { ({_t("common|edited")}) ) : null; + const showDetailedVotes = (): void => { + if (!showResults) return; + + Modal.createDialog(PollResultsDialog, { + pollEvent, + votes, + members: this.context.getRoom(this.props.mxEvent.getRoomId())?.getJoinedMembers() ?? [], + }); + }; + return (

@@ -335,7 +346,7 @@ export default class MPollBody extends React.Component { let answerVotes = 0; if (showResults) { - answerVotes = votes.get(answer.id) ?? 0; + answerVotes = votes.get(answer.id)?.length ?? 0; } const checked = @@ -348,7 +359,7 @@ export default class MPollBody extends React.Component { answer={answer} isChecked={checked} isEnded={poll.isEnded} - voteCount={answerVotes} + votes={votes.get(answer.id) ?? []} totalVoteCount={totalVotes} displayVoteCount={showResults} onOptionSelected={this.selectOption.bind(this)} @@ -356,8 +367,10 @@ export default class MPollBody extends React.Component { ); })}

-
- {totalText} +
+ showDetailedVotes()}> + {totalText} + {isFetchingResponses && }
@@ -395,7 +408,7 @@ export function allVotes(voteRelations: Relations): Array { /** * Figure out the correct vote for each user. * @param userResponses current vote responses in the poll - * @param {string?} userId The userId for which the `selected` option will apply to. + * @param {string?} user The userId for which the `selected` option will apply to. * Should be set to the current user ID. * @param {string?} selected Local echo selected option for the userId * @returns a Map of user ID to their vote info @@ -421,19 +434,17 @@ export function collectUserVotes( return userVotes; } -export function countVotes(userVotes: Map, pollStart: PollStartEvent): Map { - const collected = new Map(); +export function countVotes(userVotes: Map, pollStart: PollStartEvent): Map { + const collected = new Map(); for (const response of userVotes.values()) { const tempResponse = PollResponseEvent.from(response.answers, "$irrelevant"); tempResponse.validateAgainst(pollStart); if (!tempResponse.spoiled) { for (const answerId of tempResponse.answerIds) { - if (collected.has(answerId)) { - collected.set(answerId, collected.get(answerId)! + 1); - } else { - collected.set(answerId, 1); - } + const previousVotes = collected.get(answerId) ?? []; + previousVotes.push(response); + collected.set(answerId, previousVotes); } } } diff --git a/src/components/views/polls/PollOption.tsx b/src/components/views/polls/PollOption.tsx index c84653c2a12..c379f88b151 100644 --- a/src/components/views/polls/PollOption.tsx +++ b/src/components/views/polls/PollOption.tsx @@ -6,28 +6,47 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; import classNames from "classnames"; import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; +import React, { ReactNode, useContext } from "react"; -import { _t } from "../../../languageHandler"; import { Icon as TrophyIcon } from "../../../../res/img/element-icons/trophy.svg"; +import RoomContext from "../../../contexts/RoomContext"; +import { _t } from "../../../languageHandler"; +import FacePile from "../elements/FacePile"; import StyledRadioButton from "../elements/StyledRadioButton"; +import { UserVote } from "../messages/MPollBody"; + +const MAXIMUM_MEMBERS_FOR_FACE_PILE = 5; type PollOptionContentProps = { answer: PollAnswerSubevent; - voteCount: number; + votes: UserVote[]; displayVoteCount?: boolean; isWinner?: boolean; }; -const PollOptionContent: React.FC = ({ isWinner, answer, voteCount, displayVoteCount }) => { - const votesText = displayVoteCount ? _t("timeline|m.poll|count_of_votes", { count: voteCount }) : ""; +const PollOptionContent: React.FC = ({ isWinner, answer, votes, displayVoteCount }) => { + const votesText = displayVoteCount ? _t("timeline|m.poll|count_of_votes", { count: votes.length }) : ""; + const room = useContext(RoomContext).room; + const members = room?.getJoinedMembers() || []; + return (
{answer.text}
-
- {isWinner && } - {votesText} +
+ {displayVoteCount && members.length <= MAXIMUM_MEMBERS_FOR_FACE_PILE && ( +
+ votes.some((v) => v.sender === m.userId))} + size="24px" + overflow={false} + /> +
+ )} + + {isWinner && } + {votesText} +
); @@ -42,7 +61,7 @@ interface PollOptionProps extends PollOptionContentProps { children?: ReactNode; } -const EndedPollOption: React.FC> = ({ +const EndedPollOption: React.FC> = ({ isChecked, children, answer, @@ -57,7 +76,7 @@ const EndedPollOption: React.FC ); -const ActivePollOption: React.FC> = ({ +const ActivePollOption: React.FC> = ({ pollId, isChecked, children, @@ -78,7 +97,7 @@ const ActivePollOption: React.FC = ({ pollId, answer, - voteCount, + votes: voteCount, totalVoteCount, displayVoteCount, isEnded, @@ -91,7 +110,7 @@ export const PollOption: React.FC = ({ mx_PollOption_ended: isEnded, }); const isWinner = isEnded && isChecked; - const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount) / totalVoteCount); + const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount.length) / totalVoteCount); const PollOptionWrapper = isEnded ? EndedPollOption : ActivePollOption; return (
onOptionSelected?.(answer.id)}> @@ -104,7 +123,7 @@ export const PollOption: React.FC = ({ diff --git a/src/components/views/polls/pollHistory/PollListItemEnded.tsx b/src/components/views/polls/pollHistory/PollListItemEnded.tsx index e2f80e8eba5..23c27335b5d 100644 --- a/src/components/views/polls/pollHistory/PollListItemEnded.tsx +++ b/src/components/views/polls/pollHistory/PollListItemEnded.tsx @@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { useEffect, useState } from "react"; +import { Tooltip } from "@vector-im/compound-web"; import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { MatrixEvent, Poll, PollEvent, Relations } from "matrix-js-sdk/src/matrix"; -import { Tooltip } from "@vector-im/compound-web"; +import React, { useEffect, useState } from "react"; import { Icon as PollIcon } from "../../../../../res/img/element-icons/room/composer/poll.svg"; -import { _t } from "../../../../languageHandler"; import { formatLocalDateShort } from "../../../../DateUtils"; -import { allVotes, collectUserVotes, countVotes } from "../../messages/MPollBody"; +import { _t } from "../../../../languageHandler"; +import { allVotes, collectUserVotes, countVotes, UserVote } from "../../messages/MPollBody"; import { PollOption } from "../../polls/PollOption"; import { Caption } from "../../typography/Caption"; @@ -27,23 +27,23 @@ interface Props { type EndedPollState = { winningAnswers: { answer: PollAnswerSubevent; - voteCount: number; + votes: UserVote[]; }[]; totalVoteCount: number; }; const getWinningAnswers = (poll: Poll, responseRelations: Relations): EndedPollState => { const userVotes = collectUserVotes(allVotes(responseRelations)); const votes = countVotes(userVotes, poll.pollEvent); - const totalVoteCount = [...votes.values()].reduce((sum, vote) => sum + vote, 0); - const winCount = Math.max(...votes.values()); + const totalVoteCount = [...votes.values()].reduce((sum, vote) => sum + vote.length, 0); + const winCount = Math.max(...Array.from(votes.values()).map((v) => v.length)); return { totalVoteCount, winningAnswers: poll.pollEvent.answers - .filter((answer) => votes.get(answer.id) === winCount) + .filter((answer) => votes.get(answer.id)?.length === winCount) .map((answer) => ({ answer, - voteCount: votes.get(answer.id) || 0, + votes: votes.get(answer.id) || [], })), }; }; @@ -100,11 +100,11 @@ export const PollListItemEnded: React.FC = ({ event, poll, onClick }) =>
{!!winningAnswers?.length && (
- {winningAnswers?.map(({ answer, voteCount }) => ( + {winningAnswers?.map(({ answer, votes }) => ( { expect(votesCount(renderResult, "poutine")).toBe("1 vote"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); expect(votesCount(renderResult, "wings")).toBe("1 vote"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 4 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 4 votes. Click here to see full results", + ); }); it("ignores end poll events from unauthorised users", async () => { @@ -118,7 +122,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "poutine")).toBe("1 vote"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); expect(votesCount(renderResult, "wings")).toBe("1 vote"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 4 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 4 votes. Click here to see full results", + ); }); it("hides scores if I have not voted", async () => { @@ -159,7 +165,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "poutine")).toBe("1 vote"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); expect(votesCount(renderResult, "wings")).toBe("1 vote"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 2 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 2 votes. Click here to see full results", + ); }); it("uses my local vote", async () => { @@ -180,7 +188,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "italian")).toBe("1 vote"); expect(votesCount(renderResult, "wings")).toBe("0 votes"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 4 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 4 votes. Click here to see full results", + ); }); it("overrides my other votes with my local vote", async () => { @@ -202,7 +212,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "italian")).toBe("1 vote"); expect(votesCount(renderResult, "wings")).toBe("1 vote"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 2 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 2 votes. Click here to see full results", + ); // And my vote is highlighted expect(voteButton(renderResult, "wings").className.includes(CHECKED)).toBe(true); @@ -234,7 +246,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "italian")).toBe("0 votes"); expect(votesCount(renderResult, "wings")).toBe("1 vote"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 1 vote"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 1 vote. Click here to see full results", + ); }); it("doesn't cancel my local vote if someone else votes", async () => { @@ -266,7 +280,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "italian")).toBe("0 votes"); expect(votesCount(renderResult, "wings")).toBe("1 vote"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 2 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 2 votes. Click here to see full results", + ); // And my vote is highlighted expect(voteButton(renderResult, "pizza").className.includes(CHECKED)).toBe(true); @@ -293,7 +309,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "poutine")).toBe("0 votes"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); expect(votesCount(renderResult, "wings")).toBe("1 vote"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 2 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 2 votes. Click here to see full results", + ); }); it("allows un-voting by passing an empty vote", async () => { @@ -307,7 +325,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "poutine")).toBe("0 votes"); expect(votesCount(renderResult, "italian")).toBe("1 vote"); expect(votesCount(renderResult, "wings")).toBe("0 votes"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 1 vote"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 1 vote. Click here to see full results", + ); }); it("allows re-voting after un-voting", async () => { @@ -322,7 +342,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "poutine")).toBe("0 votes"); expect(votesCount(renderResult, "italian")).toBe("2 votes"); expect(votesCount(renderResult, "wings")).toBe("0 votes"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 2 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 2 votes. Click here to see full results", + ); }); it("treats any invalid answer as a spoiled ballot", async () => { @@ -340,7 +362,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "poutine")).toBe("0 votes"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); expect(votesCount(renderResult, "wings")).toBe("0 votes"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 0 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 0 votes. Click here to see full results", + ); }); it("allows re-voting after a spoiled ballot", async () => { @@ -357,7 +381,9 @@ describe("MPollBody", () => { expect(votesCount(renderResult, "poutine")).toBe("1 vote"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); expect(votesCount(renderResult, "wings")).toBe("0 votes"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 1 vote"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Based on 1 vote. Click here to see full results", + ); }); it("renders nothing if poll has no answers", async () => { @@ -425,7 +451,9 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe("1 vote"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Final result based on 5 votes. Click here to see full results", + ); }); it("sends a vote event when I choose an option", async () => { @@ -526,7 +554,9 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "poutine")).toBe('
1 vote'); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe('
1 vote'); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 2 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Final result based on 2 votes. Click here to see full results", + ); }); it("counts a single vote as normal if the poll is ended", async () => { @@ -537,7 +567,9 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "poutine")).toBe('
1 vote'); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe("0 votes"); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 1 vote"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Final result based on 1 vote. Click here to see full results", + ); }); it("shows ended vote counts of different numbers", async () => { @@ -557,7 +589,9 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Final result based on 5 votes. Click here to see full results", + ); }); it("ignores votes that arrived after poll ended", async () => { @@ -577,7 +611,9 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Final result based on 5 votes. Click here to see full results", + ); }); it("counts votes that arrived after an unauthorised poll end event", async () => { @@ -601,7 +637,9 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Final result based on 5 votes. Click here to see full results", + ); }); }); @@ -629,7 +667,9 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe( + "Final result based on 5 votes. Click here to see full results", + ); }); it("highlights the winning vote in an ended poll", async () => { @@ -865,6 +905,21 @@ describe("MPollBody", () => { const { container } = await newMPollBody(votes, ends, undefined, false); expect(container).toMatchSnapshot(); }); + + it("opens the full results dialog when the total votes link is clicked", async () => { + const votes = [ + responseEvent("@ed:example.com", "pizza", 12), + responseEvent("@rf:example.com", "pizza", 12), + responseEvent("@th:example.com", "wings", 13), + ]; + const ends = [newPollEndEvent("@me:example.com", 25)]; + const renderResult = await newMPollBody(votes, ends); + const createDialogSpy = jest.spyOn(Modal, "createDialog"); + + fireEvent.click(renderResult.getByTestId("totalVotes")); + + expect(createDialogSpy).toHaveBeenCalledWith(PollResultsDialog, expect.anything()); + }); }); function newVoteRelations(relationEvents: Array): Relations { diff --git a/test/unit-tests/components/views/messages/MPollEndBody-test.tsx b/test/unit-tests/components/views/messages/MPollEndBody-test.tsx index 7bd2ad0a085..70778178c1f 100644 --- a/test/unit-tests/components/views/messages/MPollEndBody-test.tsx +++ b/test/unit-tests/components/views/messages/MPollEndBody-test.tsx @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React from "react"; import { render, waitFor } from "jest-matrix-react"; -import { EventTimeline, MatrixEvent, Room, M_TEXT } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { EventTimeline, M_TEXT, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import React from "react"; import { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps"; import { MPollEndBody } from "../../../../../src/components/views/messages/MPollEndBody"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; +import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { flushPromises, getMockClientWithEventEmitter, @@ -133,7 +133,9 @@ describe("", () => { // quick check for poll tile expect(getByTestId("pollQuestion").innerHTML).toEqual("Question?"); - expect(getByTestId("totalVotes").innerHTML).toEqual("Final result based on 0 votes"); + expect(getByTestId("totalVotes").innerHTML).toEqual( + "Final result based on 0 votes. Click here to see full results", + ); }); it("does not render a poll tile when end event is invalid", async () => { diff --git a/test/unit-tests/components/views/messages/__snapshots__/MPollBody-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/MPollBody-test.tsx.snap index b24f80146d6..3ec4f5791c7 100644 --- a/test/unit-tests/components/views/messages/__snapshots__/MPollBody-test.tsx.snap +++ b/test/unit-tests/components/views/messages/__snapshots__/MPollBody-test.tsx.snap @@ -30,9 +30,26 @@ exports[`MPollBody renders a finished poll 1`] = ` Pizza
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -62,9 +79,26 @@ exports[`MPollBody renders a finished poll 1`] = ` Poutine
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -94,12 +128,29 @@ exports[`MPollBody renders a finished poll 1`] = ` Italian
- 2 votes + class="mx_PollOption_facePile" + > +
+
+
+
+ +
+ 2 votes +
@@ -129,9 +180,26 @@ exports[`MPollBody renders a finished poll 1`] = ` Wings
- 1 vote +
+
+
+
+
+ + 1 vote +
@@ -147,9 +215,12 @@ exports[`MPollBody renders a finished poll 1`] = `
- Final result based on 3 votes + + Final result based on 3 votes. Click here to see full results +
@@ -185,12 +256,29 @@ exports[`MPollBody renders a finished poll with multiple winners 1`] = ` Pizza
- 2 votes + class="mx_PollOption_facePile" + > +
+
+
+
+ +
+ 2 votes +
@@ -220,9 +308,26 @@ exports[`MPollBody renders a finished poll with multiple winners 1`] = ` Poutine
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -252,9 +357,26 @@ exports[`MPollBody renders a finished poll with multiple winners 1`] = ` Italian
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -284,12 +406,29 @@ exports[`MPollBody renders a finished poll with multiple winners 1`] = ` Wings
- 2 votes + class="mx_PollOption_facePile" + > +
+
+
+
+ +
+ 2 votes +
@@ -305,9 +444,12 @@ exports[`MPollBody renders a finished poll with multiple winners 1`] = `
- Final result based on 4 votes + + Final result based on 4 votes. Click here to see full results +
@@ -343,9 +485,26 @@ exports[`MPollBody renders a finished poll with no votes 1`] = ` Pizza
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -375,9 +534,26 @@ exports[`MPollBody renders a finished poll with no votes 1`] = ` Poutine
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -407,9 +583,26 @@ exports[`MPollBody renders a finished poll with no votes 1`] = ` Italian
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -439,9 +632,26 @@ exports[`MPollBody renders a finished poll with no votes 1`] = ` Wings
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -457,9 +667,12 @@ exports[`MPollBody renders a finished poll with no votes 1`] = `
- Final result based on 0 votes + + Final result based on 0 votes. Click here to see full results +
@@ -505,8 +718,12 @@ exports[`MPollBody renders a poll that I have not voted in 1`] = ` Pizza
+ class="mx_PollOption_votesWrapper" + > + +
+ class="mx_PollOption_votesWrapper" + > + +
+ class="mx_PollOption_votesWrapper" + > + +
@@ -637,8 +862,12 @@ exports[`MPollBody renders a poll that I have not voted in 1`] = ` Wings
+ class="mx_PollOption_votesWrapper" + > + +
- 3 votes cast. Vote to see the results + + 3 votes cast. Vote to see the results +
@@ -705,9 +937,26 @@ exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] = Pizza
- 1 vote +
+
+
+
+
+ + 1 vote +
@@ -751,9 +1000,26 @@ exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] = Poutine
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -797,9 +1063,26 @@ exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] = Italian
- 3 votes +
+
+
+
+
+ + 3 votes +
@@ -843,9 +1126,26 @@ exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] = Wings
- 1 vote +
+
+
+
+
+ + 1 vote +
@@ -865,9 +1165,12 @@ exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] =
- Based on 5 votes + + Based on 5 votes. Click here to see full results +
@@ -913,8 +1216,12 @@ exports[`MPollBody renders a poll with no votes 1`] = ` Pizza
+ class="mx_PollOption_votesWrapper" + > + +
+ class="mx_PollOption_votesWrapper" + > + +
+ class="mx_PollOption_votesWrapper" + > + +
+ class="mx_PollOption_votesWrapper" + > + +
- No votes cast + + No votes cast +
@@ -1113,9 +1435,26 @@ exports[`MPollBody renders a poll with only non-local votes 1`] = ` Pizza
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -1159,9 +1498,26 @@ exports[`MPollBody renders a poll with only non-local votes 1`] = ` Poutine
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -1205,9 +1561,26 @@ exports[`MPollBody renders a poll with only non-local votes 1`] = ` Italian
- 2 votes +
+
+
+
+
+ + 2 votes +
@@ -1251,9 +1624,26 @@ exports[`MPollBody renders a poll with only non-local votes 1`] = ` Wings
- 1 vote +
+
+
+
+
+ + 1 vote +
@@ -1273,9 +1663,12 @@ exports[`MPollBody renders a poll with only non-local votes 1`] = `
- Based on 3 votes + + Based on 3 votes. Click here to see full results +
@@ -1311,12 +1704,29 @@ exports[`MPollBody renders an undisclosed, finished poll 1`] = ` Pizza
- 2 votes + class="mx_PollOption_facePile" + > +
+
+
+
+ +
+ 2 votes +
@@ -1346,9 +1756,26 @@ exports[`MPollBody renders an undisclosed, finished poll 1`] = ` Poutine
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -1378,9 +1805,26 @@ exports[`MPollBody renders an undisclosed, finished poll 1`] = ` Italian
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -1410,12 +1854,29 @@ exports[`MPollBody renders an undisclosed, finished poll 1`] = ` Wings
- 2 votes + class="mx_PollOption_facePile" + > +
+
+
+
+ +
+ 2 votes +
@@ -1431,9 +1892,12 @@ exports[`MPollBody renders an undisclosed, finished poll 1`] = `
- Final result based on 4 votes + + Final result based on 4 votes. Click here to see full results +
@@ -1479,8 +1943,12 @@ exports[`MPollBody renders an undisclosed, unfinished poll 1`] = ` Pizza
+ class="mx_PollOption_votesWrapper" + > + +
@@ -1523,8 +1991,12 @@ exports[`MPollBody renders an undisclosed, unfinished poll 1`] = ` Poutine
+ class="mx_PollOption_votesWrapper" + > + +
+ class="mx_PollOption_votesWrapper" + > + +
+ class="mx_PollOption_votesWrapper" + > + +
- Results will be visible when the poll is ended + + Results will be visible when the poll is ended +
diff --git a/test/unit-tests/components/views/messages/__snapshots__/MPollEndBody-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/MPollEndBody-test.tsx.snap index ac2603d89d2..131bfb1cd84 100644 --- a/test/unit-tests/components/views/messages/__snapshots__/MPollEndBody-test.tsx.snap +++ b/test/unit-tests/components/views/messages/__snapshots__/MPollEndBody-test.tsx.snap @@ -46,9 +46,26 @@ exports[` when poll start event exists in current timeline rende Socks
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -78,9 +95,26 @@ exports[` when poll start event exists in current timeline rende Shoes
- 0 votes +
+
+
+
+
+ + 0 votes +
@@ -96,9 +130,12 @@ exports[` when poll start event exists in current timeline rende
- Final result based on 0 votes + + Final result based on 0 votes. Click here to see full results +