This commit is contained in:
Tim Vahlbrock 2024-12-03 22:57:47 +09:00 committed by GitHub
commit 553549ae4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1016 additions and 209 deletions

View file

@ -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<void> => {
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();

View file

@ -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";

View file

@ -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;
}

View file

@ -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;

View file

@ -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<string, UserVote[]>;
members: RoomMember[];
}
export default function PollResultsDialog(props: IProps): JSX.Element {
return (
<BaseDialog
title={props.pollEvent.question.text}
onFinished={() => Modal.closeCurrentModal()}
className="mx_PollResultsDialog"
>
{props.pollEvent.answers.map((answer) => {
const votes = props.votes.get(answer.id) || [];
if (votes.length === 0) return;
return <AnswerEntry key={answer.id} answer={answer} members={props.members} votes={votes} />;
})}
</BaseDialog>
);
}
function AnswerEntry(props: { answer: PollAnswerSubevent; members: RoomMember[]; votes: UserVote[] }): JSX.Element {
const { answer, members, votes } = props;
return (
<div key={answer.id} className="mx_AnswerEntry">
<div className="mx_AnswerEntry_Header">
<span className="mx_AnswerEntry_Header_answerName">{answer.text}</span>
<span className="mx_AnswerEntry_Header_voteCount">
{_t("poll|result_dialog|count_of_votes", { count: votes.length })}
</span>
</div>
{votes.length === 0 && <div>No one voted for this.</div>}
{votes.map((vote) => {
const member = members.find((m) => m.userId === vote.sender);
if (member) return <VoterEntry key={vote.sender} vote={vote} member={member} />;
})}
</div>
);
}
function VoterEntry(props: { vote: UserVote; member: RoomMember }): JSX.Element {
const { vote, member } = props;
return (
<div key={vote.sender} className="mx_VoterEntry">
<div className="mx_VoterEntry_AvatarWrapper">
<MemberAvatar member={member} size="36px" aria-hidden="true" />
</div>
{member.name}
</div>
);
}

View file

@ -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 { _t } from "../../../languageHandler";
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 PollCreateDialog from "../elements/PollCreateDialog";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import { formatList } from "../../../utils/FormattingUtils";
import ErrorDialog from "../dialogs/ErrorDialog";
import PollResultsDialog from "../dialogs/PollResultsDialog";
import PollCreateDialog from "../elements/PollCreateDialog";
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<string, UserVote> = collectUserVotes(allVotes(voteRelations));
const votes: Map<string, number> = countVotes(userVotes, poll);
const highestScore: number = Math.max(...votes.values());
const votes: Map<string, UserVote[]> = 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<IBodyProps, IState> {
this.setState({ selected: newSelected });
}
private totalVotes(collectedVotes: Map<string, number>): number {
private totalVotes(collectedVotes: Map<string, UserVote[]>): 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<IBodyProps, IState> {
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<IBodyProps, IState> {
<span className="mx_MPollBody_edited"> ({_t("common|edited")})</span>
) : null;
const showDetailedVotes = (): void => {
if (!showResults) return;
Modal.createDialog(PollResultsDialog, {
pollEvent,
votes,
members: this.context.getRoom(this.props.mxEvent.getRoomId())?.getJoinedMembers() ?? [],
});
};
return (
<div className="mx_MPollBody">
<h2 data-testid="pollQuestion">
@ -335,7 +346,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
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<IBodyProps, IState> {
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<IBodyProps, IState> {
);
})}
</div>
<div data-testid="totalVotes" className="mx_MPollBody_totalVotes">
{totalText}
<div className="mx_MPollBody_totalVotes">
<span data-testid="totalVotes" onClick={() => showDetailedVotes()}>
{totalText}
</span>
{isFetchingResponses && <Spinner w={16} h={16} />}
</div>
</div>
@ -395,7 +408,7 @@ export function allVotes(voteRelations: Relations): Array<UserVote> {
/**
* 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<string, UserVote>, pollStart: PollStartEvent): Map<string, number> {
const collected = new Map<string, number>();
export function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent): Map<string, UserVote[]> {
const collected = new Map<string, UserVote[]>();
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);
}
}
}

View file

@ -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<PollOptionContentProps> = ({ isWinner, answer, voteCount, displayVoteCount }) => {
const votesText = displayVoteCount ? _t("timeline|m.poll|count_of_votes", { count: voteCount }) : "";
const PollOptionContent: React.FC<PollOptionContentProps> = ({ 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 (
<div className="mx_PollOption_content">
<div className="mx_PollOption_optionText">{answer.text}</div>
<div className="mx_PollOption_optionVoteCount">
{isWinner && <TrophyIcon className="mx_PollOption_winnerIcon" />}
{votesText}
<div className="mx_PollOption_votesWrapper">
{displayVoteCount && members.length <= MAXIMUM_MEMBERS_FOR_FACE_PILE && (
<div className="mx_PollOption_facePile">
<FacePile
members={members.filter((m) => votes.some((v) => v.sender === m.userId))}
size="24px"
overflow={false}
/>
</div>
)}
<span className="mx_PollOption_optionVoteCount">
{isWinner && <TrophyIcon className="mx_PollOption_winnerIcon" />}
{votesText}
</span>
</div>
</div>
);
@ -42,7 +61,7 @@ interface PollOptionProps extends PollOptionContentProps {
children?: ReactNode;
}
const EndedPollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({
const EndedPollOption: React.FC<Omit<PollOptionProps, "votes" | "totalVoteCount">> = ({
isChecked,
children,
answer,
@ -57,7 +76,7 @@ const EndedPollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCo
</div>
);
const ActivePollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({
const ActivePollOption: React.FC<Omit<PollOptionProps, "votes" | "totalVoteCount">> = ({
pollId,
isChecked,
children,
@ -78,7 +97,7 @@ const ActivePollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteC
export const PollOption: React.FC<PollOptionProps> = ({
pollId,
answer,
voteCount,
votes: voteCount,
totalVoteCount,
displayVoteCount,
isEnded,
@ -91,7 +110,7 @@ export const PollOption: React.FC<PollOptionProps> = ({
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 (
<div data-testid={`pollOption-${answer.id}`} className={cls} onClick={() => onOptionSelected?.(answer.id)}>
@ -104,7 +123,7 @@ export const PollOption: React.FC<PollOptionProps> = ({
<PollOptionContent
isWinner={isWinner}
answer={answer}
voteCount={voteCount}
votes={voteCount}
displayVoteCount={displayVoteCount}
/>
</PollOptionWrapper>

View file

@ -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<Props> = ({ event, poll, onClick }) =>
</div>
{!!winningAnswers?.length && (
<div className="mx_PollListItemEnded_answers">
{winningAnswers?.map(({ answer, voteCount }) => (
{winningAnswers?.map(({ answer, votes }) => (
<PollOption
key={answer.id}
answer={answer}
voteCount={voteCount}
votes={votes}
totalVoteCount={totalVoteCount!}
pollId={poll.pollId}
displayVoteCount

View file

@ -1715,6 +1715,12 @@
"options_heading": "Create options",
"options_label": "Option %(number)s",
"options_placeholder": "Write an option",
"result_dialog": {
"count_of_votes": {
"one": "%(count)s vote",
"other": "%(count)s votes"
}
},
"topic_heading": "What is your poll question or topic?",
"topic_label": "Question or topic",
"topic_placeholder": "Write something…",
@ -1724,8 +1730,8 @@
"other": "%(count)s votes cast. Vote to see the results"
},
"total_n_votes_voted": {
"one": "Based on %(count)s vote",
"other": "Based on %(count)s votes"
"one": "Based on %(count)s vote. Click here to see full results",
"other": "Based on %(count)s votes. Click here to see full results"
},
"total_no_votes": "No votes cast",
"total_not_ended": "Results will be visible when the poll is ended",
@ -1876,8 +1882,8 @@
"other": "There are no past polls for the past %(count)s days. Load more polls to view polls for previous months"
},
"final_result": {
"one": "Final result based on %(count)s vote",
"other": "Final result based on %(count)s votes"
"one": "Final result based on %(count)s vote. Click here to see full results",
"other": "Final result based on %(count)s votes. Click here to see full results"
},
"load_more": "Load more polls",
"loading": "Loading polls",

View file

@ -6,26 +6,32 @@ 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 { act, fireEvent, render, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import { act, fireEvent, render, RenderResult, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
import {
MatrixEvent,
Relations,
M_POLL_KIND_DISCLOSED,
M_POLL_KIND_UNDISCLOSED,
M_POLL_RESPONSE,
M_POLL_START,
PollStartEventContent,
PollAnswer,
M_TEXT,
MatrixEvent,
PollAnswer,
PollStartEventContent,
Relations,
} from "matrix-js-sdk/src/matrix";
import React from "react";
import Modal from "../../../../../src/Modal";
import PollResultsDialog from "../../../../../src/components/views/dialogs/PollResultsDialog";
import { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
import MPollBody, {
allVotes,
findTopAnswer,
isPollEnded,
} from "../../../../../src/components/views/messages/MPollBody";
import { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import * as languageHandler from "../../../../../src/languageHandler";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import {
flushPromises,
getMockClientWithEventEmitter,
@ -33,10 +39,6 @@ import {
mockClientMethodsUser,
setupRoomWithPollEvents,
} from "../../../../test-utils";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import * as languageHandler from "../../../../../src/languageHandler";
const CHECKED = "mx_PollOption_checked";
const userId = "@me:example.com";
@ -99,7 +101,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("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('<div class="mx_PollOption_winnerIcon"></div>1 vote');
expect(endedVotesCount(renderResult, "italian")).toBe("0 votes");
expect(endedVotesCount(renderResult, "wings")).toBe('<div class="mx_PollOption_winnerIcon"></div>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('<div class="mx_PollOption_winnerIcon"></div>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('<div class="mx_PollOption_winnerIcon"></div>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('<div class="mx_PollOption_winnerIcon"></div>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('<div class="mx_PollOption_winnerIcon"></div>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('<div class="mx_PollOption_winnerIcon"></div>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<MatrixEvent>): Relations {

View file

@ -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("<MPollEndBody />", () => {
// 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 () => {

View file

@ -46,9 +46,26 @@ exports[`<MPollEndBody /> when poll start event exists in current timeline rende
Socks
</div>
<div
class="mx_PollOption_optionVoteCount"
class="mx_PollOption_votesWrapper"
>
0 votes
<div
class="mx_PollOption_facePile"
>
<div
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
>
<div
class="_stacked-avatars_mcap2_111"
/>
</div>
</div>
<span
class="mx_PollOption_optionVoteCount"
>
0 votes
</span>
</div>
</div>
</div>
@ -78,9 +95,26 @@ exports[`<MPollEndBody /> when poll start event exists in current timeline rende
Shoes
</div>
<div
class="mx_PollOption_optionVoteCount"
class="mx_PollOption_votesWrapper"
>
0 votes
<div
class="mx_PollOption_facePile"
>
<div
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
>
<div
class="_stacked-avatars_mcap2_111"
/>
</div>
</div>
<span
class="mx_PollOption_optionVoteCount"
>
0 votes
</span>
</div>
</div>
</div>
@ -96,9 +130,12 @@ exports[`<MPollEndBody /> when poll start event exists in current timeline rende
</div>
<div
class="mx_MPollBody_totalVotes"
data-testid="totalVotes"
>
Final result based on 0 votes
<span
data-testid="totalVotes"
>
Final result based on 0 votes. Click here to see full results
</span>
<div
class="mx_Spinner"
>