Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Poll history - ended polls list items (#10119)
Browse files Browse the repository at this point in the history
* wip

* remove dupe

* use poll model relations in all cases

* update mpollbody tests to use poll instance

* update poll fetching login in pinned messages card

* add pinned polls to room polls state

* add spinner while relations are still loading

* handle no poll in end poll dialog

* strict errors

* render a poll body that errors for poll end events

* add fetching logic to pollend tile

* extract poll testing utilities

* test mpollend

* strict fix

* more strict fix

* strict fix for forwardref

* add filter component

* update poll test utils

* add unstyled filter tab group

* filtertabgroup snapshot

* lint

* update test util setupRoomWithPollEvents to allow testing multiple polls in one room

* style filter tabs

* test error message for past polls

* sort polls list by latest

* extract poll option display components from pollbody

* add ended poll list item component

* use named export for polllistitem

* test POllListItemEnded

* comments

* strict fixes

* extract poll option display components

* strict fixes

* strict
  • Loading branch information
Kerry authored Feb 20, 2023
1 parent 7e5122b commit a06163e
Show file tree
Hide file tree
Showing 15 changed files with 472 additions and 18 deletions.
1 change: 1 addition & 0 deletions res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
@import "./components/views/context_menus/_KebabContextMenu.pcss";
@import "./components/views/dialogs/polls/_PollListItem.pcss";
@import "./components/views/dialogs/polls/_PollListItemEnded.pcss";
@import "./components/views/elements/_FilterDropdown.pcss";
@import "./components/views/elements/_FilterTabGroup.pcss";
@import "./components/views/elements/_LearnMore.pcss";
Expand Down
60 changes: 60 additions & 0 deletions res/css/components/views/dialogs/polls/_PollListItemEnded.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

.mx_PollListItemEnded {
width: 100%;
display: flex;
flex-direction: column;
color: $primary-content;
}

.mx_PollListItemEnded_title {
display: grid;
justify-content: left;
align-items: center;
grid-gap: $spacing-8;
grid-template-columns: min-content 1fr min-content;
grid-template-rows: auto;
}

.mx_PollListItemEnded_icon {
height: 14px;
width: 14px;
color: $quaternary-content;
padding-left: $spacing-8;
}

.mx_PollListItemEnded_date {
font-size: $font-12px;
color: $secondary-content;
}

.mx_PollListItemEnded_question {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.mx_PollListItemEnded_answers {
display: grid;
grid-gap: $spacing-8;
margin-top: $spacing-12;
}

.mx_PollListItemEnded_voteCount {
// 6px to match PollOption padding
margin: $spacing-8 0 0 6px;
}
1 change: 0 additions & 1 deletion res/css/components/views/polls/_PollOption.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ limitations under the License.
border: 1px solid $quinary-content;
border-radius: 8px;
padding: 6px 12px;
max-width: 550px;
background-color: $background;

.mx_StyledRadioButton_content,
Expand Down
4 changes: 4 additions & 0 deletions res/css/views/dialogs/polls/_PollHistoryList.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ limitations under the License.
grid-gap: $spacing-20;
padding-right: $spacing-64;
margin: $spacing-32 0;

&.mx_PollHistoryList_list_ENDED {
grid-gap: $spacing-32;
}
}

.mx_PollHistoryList_noResults {
Expand Down
1 change: 1 addition & 0 deletions res/css/views/messages/_MPollBody.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ limitations under the License.
display: grid;
grid-gap: $spacing-16;
margin-bottom: $spacing-8;
max-width: 550px;
}
7 changes: 6 additions & 1 deletion src/components/views/dialogs/polls/PollHistoryDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({ roomId, ma
return (
<BaseDialog title={_t("Polls history")} onFinished={onFinished}>
<div className="mx_PollHistoryDialog_content">
<PollHistoryList pollStartEvents={pollStartEvents} filter={filter} onFilterChange={setFilter} />
<PollHistoryList
pollStartEvents={pollStartEvents}
polls={polls}
filter={filter}
onFilterChange={setFilter}
/>
</div>
</BaseDialog>
);
Expand Down
27 changes: 19 additions & 8 deletions src/components/views/dialogs/polls/PollHistoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,22 @@ limitations under the License.
*/

import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix";

import PollListItem from "./PollListItem";
import { _t } from "../../../../languageHandler";
import { FilterTabGroup } from "../../elements/FilterTabGroup";
import { PollHistoryFilter } from "./types";
import { PollListItem } from "./PollListItem";
import { PollListItemEnded } from "./PollListItemEnded";
import { FilterTabGroup } from "../../elements/FilterTabGroup";

type PollHistoryListProps = {
pollStartEvents: MatrixEvent[];
polls: Map<string, Poll>;
filter: PollHistoryFilter;
onFilterChange: (filter: PollHistoryFilter) => void;
};
export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvents, filter, onFilterChange }) => {
export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvents, polls, filter, onFilterChange }) => {
return (
<div className="mx_PollHistoryList">
<FilterTabGroup<PollHistoryFilter>
Expand All @@ -40,10 +43,18 @@ export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvent
]}
/>
{!!pollStartEvents.length ? (
<ol className="mx_PollHistoryList_list">
{pollStartEvents.map((pollStartEvent) => (
<PollListItem key={pollStartEvent.getId()!} event={pollStartEvent} />
))}
<ol className={classNames("mx_PollHistoryList_list", `mx_PollHistoryList_list_${filter}`)}>
{pollStartEvents.map((pollStartEvent) =>
filter === "ACTIVE" ? (
<PollListItem key={pollStartEvent.getId()!} event={pollStartEvent} />
) : (
<PollListItemEnded
key={pollStartEvent.getId()!}
event={pollStartEvent}
poll={polls.get(pollStartEvent.getId()!)!}
/>
),
)}
</ol>
) : (
<span className="mx_PollHistoryList_noResults">
Expand Down
4 changes: 1 addition & 3 deletions src/components/views/dialogs/polls/PollListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ interface Props {
event: MatrixEvent;
}

const PollListItem: React.FC<Props> = ({ event }) => {
export const PollListItem: React.FC<Props> = ({ event }) => {
const pollEvent = event.unstableExtensibleEvent as unknown as PollStartEvent;
if (!pollEvent) {
return null;
Expand All @@ -39,5 +39,3 @@ const PollListItem: React.FC<Props> = ({ event }) => {
</li>
);
};

export default PollListItem;
127 changes: 127 additions & 0 deletions src/components/views/dialogs/polls/PollListItemEnded.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { useEffect, useState } from "react";
import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { MatrixEvent, Poll, PollEvent } from "matrix-js-sdk/src/matrix";
import { Relations } from "matrix-js-sdk/src/models/relations";

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 { PollOption } from "../../polls/PollOption";
import { Caption } from "../../typography/Caption";

interface Props {
event: MatrixEvent;
poll: Poll;
}

type EndedPollState = {
winningAnswers: {
answer: PollAnswerSubevent;
voteCount: number;
}[];
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());

return {
totalVoteCount,
winningAnswers: poll.pollEvent.answers
.filter((answer) => votes.get(answer.id) === winCount)
.map((answer) => ({
answer,
voteCount: votes.get(answer.id) || 0,
})),
};
};

/**
* Get deduplicated and validated poll responses
* Will use cached responses from Poll instance when existing
* Updates on changes to Poll responses (paging relations or from sync)
* Returns winning answers and total vote count
*/
const usePollVotes = (poll: Poll): Partial<EndedPollState> => {
const [results, setResults] = useState({ totalVoteCount: 0 });

useEffect(() => {
const getResponses = async (): Promise<void> => {
const responseRelations = await poll.getResponses();
setResults(getWinningAnswers(poll, responseRelations));
};
const onPollResponses = (responseRelations: Relations): void =>
setResults(getWinningAnswers(poll, responseRelations));
poll.on(PollEvent.Responses, onPollResponses);

getResponses();

return () => {
poll.off(PollEvent.Responses, onPollResponses);
};
}, [poll]);

return results;
};

/**
* Render an ended poll with the winning answer and vote count
* @param event - the poll start MatrixEvent
* @param poll - Poll instance
*/
export const PollListItemEnded: React.FC<Props> = ({ event, poll }) => {
const pollEvent = poll.pollEvent;
const { winningAnswers, totalVoteCount } = usePollVotes(poll);
if (!pollEvent) {
return null;
}
const formattedDate = formatLocalDateShort(event.getTs());

return (
<li data-testid={`pollListItem-${event.getId()!}`} className="mx_PollListItemEnded">
<div className="mx_PollListItemEnded_title">
<PollIcon className="mx_PollListItemEnded_icon" />
<span className="mx_PollListItemEnded_question">{pollEvent.question.text}</span>
<Caption>{formattedDate}</Caption>
</div>
{!!winningAnswers?.length && (
<div className="mx_PollListItemEnded_answers">
{winningAnswers?.map(({ answer, voteCount }) => (
<PollOption
key={answer.id}
answer={answer}
voteCount={voteCount}
totalVoteCount={totalVoteCount!}
pollId={poll.pollId}
displayVoteCount
isChecked
isEnded
/>
))}
</div>
)}
<div className="mx_PollListItemEnded_voteCount">
<Caption>{_t("Final result based on %(count)s votes", { count: totalVoteCount })}</Caption>
</div>
</li>
);
};
4 changes: 2 additions & 2 deletions src/components/views/messages/MPollBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ export function allVotes(voteRelations: Relations): Array<UserVote> {
* @param {string?} selected Local echo selected option for the userId
* @returns a Map of user ID to their vote info
*/
function collectUserVotes(
export function collectUserVotes(
userResponses: Array<UserVote>,
userId?: string | null | undefined,
selected?: string | null | undefined,
Expand All @@ -405,7 +405,7 @@ function collectUserVotes(
return userVotes;
}

function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent): Map<string, number> {
export function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent): Map<string, number> {
const collected = new Map<string, number>();

for (const response of userVotes.values()) {
Expand Down
2 changes: 1 addition & 1 deletion test/components/views/dialogs/polls/PollListItem-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import React from "react";
import { render } from "@testing-library/react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";

import PollListItem from "../../../../../src/components/views/dialogs/polls/PollListItem";
import { PollListItem } from "../../../../../src/components/views/dialogs/polls/PollListItem";
import { makePollStartEvent, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../../../../test-utils";

describe("<PollListItem />", () => {
Expand Down
Loading

0 comments on commit a06163e

Please sign in to comment.