diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index 00b944ce9d9..c69753d6e5c 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -30,8 +30,10 @@ describe("Polls", () => { type CreatePollOptions = { title: string; options: string[]; + multiSelect?: boolean; + maxSelections?: string; }; - const createPoll = ({ title, options }: CreatePollOptions) => { + const createPoll = ({ title, options, multiSelect = false, maxSelections }: CreatePollOptions) => { if (options.length < 2) { throw new Error('Poll must have at least two options'); } @@ -47,6 +49,20 @@ describe("Polls", () => { } cy.get(optionId).scrollIntoView().type(option); }); + + if (multiSelect) { + if (!maxSelections) { + cy.get('#mx_Field_2') + .find('option') + .its('length') + .then((length) => { + // select last option + cy.get('#mx_Field_2').select(length - 1); + }); + } else { + cy.get('#mx_Field_2').select(maxSelections); + } + } }); cy.get('.mx_Dialog button[type="submit"]').click(); }; @@ -65,10 +81,33 @@ describe("Polls", () => { }); }; - const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string): void => { + const bots = []; + const botVotes = []; + + const botMultiVote = (bot: MatrixClient, optionId: string): void => { + // populate botVotes with array(s) for each individual bot holding their answer(s) + // allows for testing multiple bot votes + if (!bots.includes(bot.credentials)) { + bots.push(bot.credentials); + botVotes.push([]); + } + if (!botVotes[bots.indexOf(bot.credentials)].includes(optionId)) { + botVotes[bots.indexOf(bot.credentials)].push(optionId); + } + }; + + const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string, + type = "radio"): void => { getPollOption(pollId, optionText).within(ref => { - cy.get('input[type="radio"]').invoke('attr', 'value').then(optionId => { - const pollVote = PollResponseEvent.from([optionId], pollId).serialize(); + cy.get(`input[type=${type}]`).invoke('attr', 'value').then(optionId => { + let answer = []; + if (type === "radio") { + answer = [optionId]; + } else if (!botVotes.includes(optionId)) { + botMultiVote(bot, optionId); + answer = botVotes[bots.indexOf(bot.credentials)]; + } + const pollVote = PollResponseEvent.from(answer, pollId).serialize(); bot.sendEvent( roomId, pollVote.type, @@ -338,4 +377,175 @@ describe("Polls", () => { }); }); }); + + describe("Multiple choice polls", () => { + beforeEach(() => { + botVotes.length = 0; + bots.length = 0; + }); + + it("should be creatable and allow voting for multiple options", () => { + let botBob: MatrixClient; + let botCharlie: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + botBob = _bot; + }); + cy.getBot(synapse, { displayName: "BotCharlie" }).then(_bot => { + botCharlie = _bot; + }); + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, botBob.getUserId()); + cy.inviteUser(roomId, botCharlie.getUserId()); + cy.visit('/#/room/' + roomId); + // wait until bots joined + cy.contains(".mx_TextualEvent", "BotBob and one other were invited and joined").should("exist"); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + cy.get('.mx_CompoundDialog').percySnapshotElement('Polls Composer'); + + const pollParams = { + title: 'Does the polls feature work with multiple selections?', + options: ['Yes', 'Indeed', 'Definitely'], + multiSelect: true, + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + getPollTile(pollId).percySnapshotElement('Polls Timeline tile - no votes', + { percyCSS: hideTimestampCSS }); + + // selected max possible number of votes + cy.get('.mx_MPollBody_totalVotes').should('contain', 'you have 3 votes remaining'); + + // Bob votes 'Yes' in the poll + botVoteForOption(botBob, roomId, pollId, pollParams.options[0], "checkbox"); + + // Charlie votes for 'Indeed' + botVoteForOption(botCharlie, roomId, pollId, pollParams.options[1], "checkbox"); + + // no votes shown until I vote, check bots vote has arrived + cy.get('.mx_MPollBody_totalVotes').should('contain', '2 votes cast'); + + // I vote 'Definitely' + getPollOption(pollId, pollParams.options[2]).click('topLeft'); + + // 1 vote for each option + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + expectPollOptionVoteCount(pollId, pollParams.options[1], 1); + expectPollOptionVoteCount(pollId, pollParams.options[2], 1); + + // I vote 'Yes' + getPollOption(pollId, pollParams.options[0]).click('left'); + // I vote 'Indeed' + getPollOption(pollId, pollParams.options[1]).click('bottom'); + + // I have no votes left + cy.get('.mx_MPollBody_totalVotes').should('contain', 'Based on 5 votes - you have no votes remaining'); + + // Charlie votes for 'Yes' + botVoteForOption(botCharlie, roomId, pollId, pollParams.options[0], "checkbox"); + + // each participant has voted for 'Yes' + expectPollOptionVoteCount(pollId, pollParams.options[0], 3); + // Charlie and I voted 'Indeed' + expectPollOptionVoteCount(pollId, pollParams.options[1], 2); + // I voted for 'Definitely' + expectPollOptionVoteCount(pollId, pollParams.options[2], 1); + }); + }); + + it("should have the correct number of possible selections", () => { + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does this count an empty option?', + options: ['Nah', 'Nope', ' '], + multiSelect: true, + }; + createPoll(pollParams); + + cy.get('.mx_MPollBody_totalVotes').should('contain', 'you have 2 votes remaining'); + }); + + it("should allow deselecting votes to vote for another option", () => { + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + cy.get('.mx_CompoundDialog').percySnapshotElement('Polls Composer'); + + const pollParams = { + title: 'Can I deselect my votes?', + options: ['Yes', 'Indeed', 'Definitely'], + multiSelect: true, + maxSelections: '2', + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // vote 'Yes' + getPollOption(pollId, pollParams.options[0]).click('topRight'); + + // 1 vote for 'Yes' + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // check correct number of remaining votes + cy.get('.mx_MPollBody_totalVotes').should('contain', 'you have 1 vote remaining'); + + // also vote for 'Indeed' + getPollOption(pollId, pollParams.options[1]).click('center'); + + // 1 vote for 'Indeed' + expectPollOptionVoteCount(pollId, pollParams.options[1], 1); + // check correct number of remaining votes + cy.get('.mx_MPollBody_totalVotes').should('contain', 'you have no votes remaining'); + + // no further votes allowed + getPollOption(pollId, pollParams.options[2]).click('center'); + // no vote for 'Definitely' + expectPollOptionVoteCount(pollId, pollParams.options[2], 0); + + // deselect 'Indeed' + getPollOption(pollId, pollParams.options[1]).click('left'); + // no votes for 'Indeed' + expectPollOptionVoteCount(pollId, pollParams.options[1], 0); + // check correct number of remaining votes + cy.get('.mx_MPollBody_totalVotes').should('contain', 'you have 1 vote remaining'); + + // vote for 'Definitely' + getPollOption(pollId, pollParams.options[2]).click('right'); + // 1 vote for 'Definitely' + expectPollOptionVoteCount(pollId, pollParams.options[2], 1); + // check correct number of remaining votes + cy.get('.mx_MPollBody_totalVotes').should('contain', 'you have no votes remaining'); + }); + }); + }); }); diff --git a/res/css/views/dialogs/_PollCreateDialog.pcss b/res/css/views/dialogs/_PollCreateDialog.pcss index b49fda4db06..7db8b01715c 100644 --- a/res/css/views/dialogs/_PollCreateDialog.pcss +++ b/res/css/views/dialogs/_PollCreateDialog.pcss @@ -79,6 +79,9 @@ limitations under the License. .mx_PollCreateDialog_addOption { padding: 0; + } + + .mx_PollCreateDialog_maxSelections { margin-bottom: 40px; /* arbitrary to create scrollable area under the poll */ } diff --git a/res/css/views/elements/_StyledRadioButton.pcss b/res/css/views/elements/_StyledRadioButton.pcss index cc7339ac005..70bee5e7638 100644 --- a/res/css/views/elements/_StyledRadioButton.pcss +++ b/res/css/views/elements/_StyledRadioButton.pcss @@ -46,7 +46,8 @@ limitations under the License. width: $font-16px; } - input[type="radio"] { + input[type="radio"], + input[type="checkbox"] { /* Remove the OS's representation */ margin: 0; padding: 0; diff --git a/res/css/views/messages/_MPollBody.pcss b/res/css/views/messages/_MPollBody.pcss index 7eeb7e2518c..ad2c8f6cce6 100644 --- a/res/css/views/messages/_MPollBody.pcss +++ b/res/css/views/messages/_MPollBody.pcss @@ -55,16 +55,21 @@ limitations under the License. max-width: 550px; background-color: $background; - .mx_StyledRadioButton, .mx_MPollBody_endedOption { + .mx_StyledRadioButton, + .mx_StyledCheckbox, + .mx_MPollBody_endedOption { margin-bottom: 8px; } - .mx_StyledRadioButton_content, .mx_MPollBody_endedOption { + .mx_StyledRadioButton_content, + .mx_StyledCheckbox_content, + .mx_MPollBody_endedOption { padding-top: 2px; margin-right: 0px; } - .mx_StyledRadioButton_spacer { + .mx_StyledRadioButton_spacer, + .mx_StyledCheckbox_spacer { display: none; } @@ -110,12 +115,15 @@ limitations under the License. } /* options not actionable in these states */ - .mx_MPollBody_option_checked, .mx_MPollBody_option_ended { + .mx_MPollBody_option_ended { pointer-events: none; } - .mx_StyledRadioButton_checked, .mx_MPollBody_endedOptionWinner { - input[type="radio"] + div { + .mx_StyledRadioButton_checked, + .mx_StyledCheckbox_checked, + .mx_MPollBody_endedOptionWinner { + input[type="radio"] + div, + input[type="checkbox"] + div { border-width: 2px; border-color: $accent; background-color: $accent; diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index 3d4cc9826f2..f4f3b77fe17 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -25,6 +25,7 @@ import { PollStartEvent, } from "matrix-events-sdk"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { range } from "lodash"; import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; import { IDialogProps } from "../dialogs/IDialogProps"; @@ -51,6 +52,7 @@ interface IState extends IScrollableBaseState { question: string; options: string[]; busy: boolean; + max_selections?: number; kind: KNOWN_POLL_KIND; autoFocusTarget: FocusTarget; } @@ -85,6 +87,7 @@ function editingInitialState(editingMxEvent: MatrixEvent): IState { question: poll.question.text, options: poll.answers.map(ans => ans.text), busy: false, + max_selections: poll.maxSelections, kind: poll.kind, autoFocusTarget: FocusTarget.Topic, }; @@ -138,11 +141,27 @@ export default class PollCreateDialog extends ScrollableBaseModal { + // defaults to 1 + const limit = this.state.options.map(a => a.trim()).filter(a => !!a).length || 1; + const count = range(1, limit + 1); + const options = count.map((number) => { + return ; + }); + return options; + }; + private createEvent(): IPartialEvent { const pollStart = PollStartEvent.from( this.state.question.trim(), this.state.options.map(a => a.trim()).filter(a => !!a), this.state.kind, + this.state.max_selections, ).serialize(); if (!this.props.editingMxEvent) { @@ -270,6 +289,15 @@ export default class PollCreateDialog extends ScrollableBaseModal } +

{ _t("Number of votes per person") }

+ + { this.maxSelections() } + ; } @@ -282,6 +310,10 @@ export default class PollCreateDialog extends ScrollableBaseModal) => { + this.setState({ max_selections: Number(e.target.value) }); + }; } function pollTypeNotes(kind: KNOWN_POLL_KIND): string { diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index 35422366822..ef1ca6b475b 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -21,6 +21,7 @@ import classnames from 'classnames'; export enum CheckboxStyle { Solid = "solid", Outline = "outline", + Circle = "circle", // for polls, uses radio button styling } interface IProps extends React.InputHTMLAttributes { @@ -49,15 +50,25 @@ export default class StyledCheckbox extends React.PureComponent /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const { children, className, kind = CheckboxStyle.Solid, inputRef, ...otherProps } = this.props; - const newClassName = classnames( - "mx_Checkbox", - className, - { + const additionalClasses = kind === "circle" + ? { + "mx_StyledRadioButton": true, + "mx_StyledRadioButton_disabled": this.props.disabled, + "mx_StyledRadioButton_enabled": !this.props.disabled, + "mx_StyledRadioButton_checked": this.props.checked, + } + : { + "mx_Checkbox": true, "mx_Checkbox_hasKind": kind, [`mx_Checkbox_kind_${kind}`]: kind, - }, + }; + + const newClassName = classnames( + className, + additionalClasses, ); - return + + const checkbox = {...otherProps} type="checkbox" /> -