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

Add multiple choice polls #9519

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 214 additions & 4 deletions cypress/e2e/polls/polls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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();
};
Expand All @@ -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,
Expand Down Expand Up @@ -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<string>("@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<string>("@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');
});
});
});
});
3 changes: 3 additions & 0 deletions res/css/views/dialogs/_PollCreateDialog.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
}

Expand Down
3 changes: 2 additions & 1 deletion res/css/views/elements/_StyledRadioButton.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 14 additions & 6 deletions res/css/views/messages/_MPollBody.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/components/views/elements/PollCreateDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -51,6 +52,7 @@ interface IState extends IScrollableBaseState {
question: string;
options: string[];
busy: boolean;
max_selections?: number;
kind: KNOWN_POLL_KIND;
autoFocusTarget: FocusTarget;
}
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -138,11 +141,27 @@ export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState
});
};

private maxSelections = () => {
// 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 <option
key={number}
value={number}
>
{ number }
</option>;
});
return options;
};

private createEvent(): IPartialEvent<object> {
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) {
Expand Down Expand Up @@ -270,6 +289,15 @@ export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState
this.state.busy &&
<div className="mx_PollCreateDialog_busy"><Spinner /></div>
}
<h2>{ _t("Number of votes per person") }</h2>
<Field
className="mx_PollCreateDialog_maxSelections"
element="select"
value={String(this.state.max_selections)}
onChange={this.onMaxSelectionsChange}
>
{ this.maxSelections() }
</Field>
</div>;
}

Expand All @@ -282,6 +310,10 @@ export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState
),
});
};

onMaxSelectionsChange = (e: ChangeEvent<HTMLSelectElement>) => {
this.setState({ max_selections: Number(e.target.value) });
};
}

function pollTypeNotes(kind: KNOWN_POLL_KIND): string {
Expand Down
Loading