diff --git a/apps/web/res/css/views/dialogs/_PollCreateDialog.pcss b/apps/web/res/css/views/dialogs/_PollCreateDialog.pcss index ba7d9c644de..673b9699da6 100644 --- a/apps/web/res/css/views/dialogs/_PollCreateDialog.pcss +++ b/apps/web/res/css/views/dialogs/_PollCreateDialog.pcss @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. .mx_PollCreateDialog_busy { position: absolute; inset: 0; - background-color: $overlay-background; + background-color: var(--cpd-color-alpha-gray-1300); z-index: 1; } @@ -19,22 +19,22 @@ Please see LICENSE files in the repository root for full details. font-size: $font-15px; line-height: $font-24px; margin-top: 0; - margin-bottom: 8px; + margin-bottom: var(--cpd-space-2x); &:nth-child(n + 2) { - margin-top: 20px; + margin-top: var(--cpd-space-5x); } } p { - color: $secondary-content; + color: var(--cpd-color-text-secondary); } .mx_PollCreateDialog_option { display: flex; align-items: center; - margin-top: 11px; - margin-bottom: 16px; /* 11px from the top will collapse, so this creates a 16px gap between options */ + margin-top: var(--cpd-space-2-5x); + margin-bottom: var(--cpd-space-4x); /* 11px from the top will collapse, so this creates a 16px gap between options */ .mx_Field { flex: 1; @@ -42,25 +42,61 @@ Please see LICENSE files in the repository root for full details. } .mx_PollCreateDialog_removeOption { - margin-left: 12px; - width: 16px; - height: 16px; + margin-left: var(--cpd-space-3x); + width: var(--cpd-space-4x); + height: var(--cpd-space-4x); padding: var(--cpd-space-0-5x); border-radius: 50%; - background-color: $quinary-content; + background-color: var(--cpd-color-gray-400); cursor: pointer; svg { width: inherit; height: inherit; - color: $secondary-content; + color: var(--cpd-color-text-secondary); } } } .mx_PollCreateDialog_addOption { padding: 0; - margin-bottom: 40px; /* arbitrary to create scrollable area under the poll */ + margin-bottom: var(--cpd-space-10x); /* arbitrary to create scrollable area under the poll */ + } + + .mx_PollCreateDialog_maxSelections { + display: flex; + align-items: center; + gap: var(--cpd-space-4x); + + .mx_PollCreateDialog_maxSelectionsButton { + width: var(--cpd-space-8x); + height: var(--cpd-space-8x); + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--cpd-color-gray-400); + cursor: pointer; + + svg { + width: var(--cpd-space-4x); + height: var(--cpd-space-4x); + color: var(--cpd-color-text-secondary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + .mx_PollCreateDialog_maxSelectionsValue { + font-weight: var(--cpd-font-weight-semibold); + font-size: $font-15px; + min-width: var(--cpd-space-15x); + text-align: center; + } } .mx_AccessibleButton_disabled { diff --git a/apps/web/src/components/views/elements/PollCreateDialog.tsx b/apps/web/src/components/views/elements/PollCreateDialog.tsx index f72c28f26e5..04c1d95fb8e 100644 --- a/apps/web/src/components/views/elements/PollCreateDialog.tsx +++ b/apps/web/src/components/views/elements/PollCreateDialog.tsx @@ -18,7 +18,7 @@ import { type TimelineEvents, } from "matrix-js-sdk/src/matrix"; import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; -import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { CloseIcon, PlusIcon, MinusIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import ScrollableBaseModal, { type IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; import QuestionDialog from "../dialogs/QuestionDialog"; @@ -47,6 +47,7 @@ interface IState extends IScrollableBaseState { busy: boolean; kind: KnownPollKind; autoFocusTarget: FocusTarget; + maxSelections: number; } const MIN_OPTIONS = 2; @@ -65,6 +66,7 @@ function creatingInitialState(): IState { busy: false, kind: M_POLL_KIND_DISCLOSED, autoFocusTarget: FocusTarget.Topic, + maxSelections: 1, }; } @@ -81,6 +83,7 @@ function editingInitialState(editingMxEvent: MatrixEvent): IState { busy: false, kind: poll.kind, autoFocusTarget: FocusTarget.Topic, + maxSelections: poll.maxSelections ?? 1, }; } @@ -115,17 +118,32 @@ export default class PollCreateDialog extends ScrollableBaseModal { const newOptions = arrayFastClone(this.state.options); newOptions.splice(i, 1); - this.setState({ options: newOptions }, () => this.checkCanSubmit()); + const maxOptions = newOptions.filter((op) => op.trim().length > 0).length; + const newMaxSelections = Math.min(this.state.maxSelections, maxOptions); + this.setState({ options: newOptions, maxSelections: newMaxSelections }, () => this.checkCanSubmit()); }; private onOptionAdd = (): void => { const newOptions = arrayFastClone(this.state.options); newOptions.push(""); - this.setState({ options: newOptions, autoFocusTarget: FocusTarget.NewOption }, () => { - // Scroll the button into view after the state update to ensure we don't experience - // a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render. - this.addOptionRef.current?.scrollIntoView?.(); - }); + const maxOptions = newOptions.filter((op) => op.trim().length > 0).length; + const newMaxSelections = Math.min(this.state.maxSelections, maxOptions); + this.setState( + { options: newOptions, maxSelections: newMaxSelections, autoFocusTarget: FocusTarget.NewOption }, + () => { + // Scroll the button into view after the state update to ensure we don't experience + // a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render. + this.addOptionRef.current?.scrollIntoView?.(); + }, + ); + }; + + private onMaxSelectionsChange = (delta: number): void => { + const maxOptions = this.state.options.filter((op) => op.trim().length > 0).length; + const newValue = this.state.maxSelections + delta; + if (newValue >= 1 && newValue <= maxOptions) { + this.setState({ maxSelections: newValue }); + } }; private createEvent(): IPartialEvent { @@ -133,6 +151,7 @@ export default class PollCreateDialog extends ScrollableBaseModal a.trim()).filter((a) => !!a), this.state.kind.name, + this.state.maxSelections, ).serialize(); if (!this.props.editingMxEvent) { @@ -248,6 +267,27 @@ export default class PollCreateDialog extends ScrollableBaseModal {_t("poll|options_add_button")} +

{_t("poll|max_selections_heading")}

+
+ this.onMaxSelectionsChange(-1)} + disabled={this.state.busy || this.state.maxSelections <= 1} + className="mx_PollCreateDialog_maxSelectionsButton" + > + + + {this.state.maxSelections} + this.onMaxSelectionsChange(1)} + disabled={ + this.state.busy || + this.state.maxSelections >= this.state.options.filter((op) => op.trim().length > 0).length + } + className="mx_PollCreateDialog_maxSelectionsButton" + > + + +
{this.state.busy && (
diff --git a/apps/web/src/components/views/messages/MPollBody.tsx b/apps/web/src/components/views/messages/MPollBody.tsx index 83b7f5c415d..347a9d7be50 100644 --- a/apps/web/src/components/views/messages/MPollBody.tsx +++ b/apps/web/src/components/views/messages/MPollBody.tsx @@ -41,8 +41,9 @@ interface IState { poll?: Poll; // poll instance has fetched at least one page of responses pollInitialised: boolean; - selected?: string | null | undefined; // Which option was clicked by the local user + selected?: string[] | null; // Which options were selected by the local user voteRelations?: Relations; // Voting (response) events + isVoting: boolean; // Whether a vote is currently being sent } export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, eventId: string): RelatedRelations { @@ -148,8 +149,9 @@ export default class MPollBody extends React.Component { super(props); this.state = { - selected: null, + selected: [], pollInitialised: false, + isVoting: false, }; } @@ -208,17 +210,28 @@ export default class MPollBody extends React.Component { }; private selectOption(answerId: string): void { - if (this.state.poll?.isEnded) { + if (this.state.poll?.isEnded || this.state.isVoting) { return; } - const userVotes = this.collectUserVotes(); - const userId = this.context.getSafeUserId(); - const myVote = userVotes.get(userId)?.answers[0]; - if (answerId === myVote) { - return; + + const pollEvent = this.state.poll?.pollEvent; + const maxSelections = pollEvent?.maxSelections ?? 1; + + let newSelected: string[]; + const currentSelected = this.state.selected ?? []; + + if (currentSelected.includes(answerId)) { + newSelected = currentSelected.filter((id) => id !== answerId); + } else { + if (currentSelected.length >= maxSelections) { + return; + } + newSelected = [...currentSelected, answerId]; } - const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()!).serialize(); + const response = PollResponseEvent.from(newSelected, this.props.mxEvent.getId()!).serialize(); + + this.setState({ selected: newSelected, isVoting: true }); this.context .sendEvent( @@ -233,9 +246,10 @@ export default class MPollBody extends React.Component { title: _t("poll|error_voting_title"), description: _t("poll|error_voting_description"), }); + }) + .finally(() => { + this.setState({ isVoting: false }); }); - - this.setState({ selected: answerId }); } /** @@ -261,12 +275,12 @@ export default class MPollBody extends React.Component { const newEvents: MatrixEvent[] = relations.filter( (mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!), ); - let newSelected = this.state.selected; + let newSelected: string[] | null | undefined = this.state.selected; if (newEvents.length > 0) { for (const mxEvent of newEvents) { if (mxEvent.getSender() === this.context.getUserId()) { - newSelected = null; + newSelected = []; } } } @@ -298,12 +312,12 @@ export default class MPollBody extends React.Component { const totalVotes = this.totalVotes(votes); const winCount = Math.max(...votes.values()); const userId = this.context.getSafeUserId(); - const myVote = userVotes?.get(userId)?.answers[0]; + const myVotes = userVotes?.get(userId)?.answers ?? []; const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name); // Disclosed: votes are hidden until I vote or the poll ends // Undisclosed: votes are hidden until poll ends - const showResults = poll.isEnded || (disclosed && myVote !== undefined); + const showResults = poll.isEnded || (disclosed && myVotes.length > 0); let totalText: string; if (showResults && poll.undecryptableRelationsCount) { @@ -312,7 +326,7 @@ export default class MPollBody extends React.Component { totalText = _t("right_panel|poll|final_result", { count: totalVotes }); } else if (!disclosed) { totalText = _t("poll|total_not_ended"); - } else if (myVote === undefined) { + } else if (myVotes.length === 0) { if (totalVotes === 0) { totalText = _t("poll|total_no_votes"); } else { @@ -345,7 +359,8 @@ export default class MPollBody extends React.Component { } const checked = - (!poll.isEnded && myVote === answer.id) || (poll.isEnded && answerVotes === winCount); + (!poll.isEnded && myVotes.includes(answer.id)) || + (poll.isEnded && answerVotes === winCount); return ( { totalVoteCount={totalVotes} displayVoteCount={showResults} onOptionSelected={this.selectOption.bind(this)} + maxSelections={pollEvent.maxSelections} /> ); })} @@ -410,7 +426,7 @@ export function allVotes(voteRelations: Relations): Array { export function collectUserVotes( userResponses: Array, userId?: string | null | undefined, - selected?: string | null | undefined, + selected?: string[] | null | undefined, ): Map { const userVotes: Map = new Map(); @@ -421,8 +437,8 @@ export function collectUserVotes( } } - if (selected && userId) { - userVotes.set(userId, new UserVote(0, userId, [selected])); + if (selected && selected.length > 0 && userId) { + userVotes.set(userId, new UserVote(0, userId, selected)); } return userVotes; diff --git a/apps/web/src/components/views/polls/PollOption.tsx b/apps/web/src/components/views/polls/PollOption.tsx index 96c21ce60c5..739710052c0 100644 --- a/apps/web/src/components/views/polls/PollOption.tsx +++ b/apps/web/src/components/views/polls/PollOption.tsx @@ -14,6 +14,7 @@ import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../languageHandler"; import { Icon as TrophyIcon } from "../../../../res/img/element-icons/trophy.svg"; import StyledRadioButton from "../elements/StyledRadioButton"; +import StyledCheckbox from "../elements/StyledCheckbox"; type PollOptionContentProps = { answer: PollAnswerSubevent; @@ -42,6 +43,7 @@ interface PollOptionProps extends PollOptionContentProps { isChecked?: boolean; onOptionSelected?: (id: string) => void; children?: ReactNode; + maxSelections?: number; } const ActivePollOption: React.FC & { children: ReactNode }> = ({ @@ -55,6 +57,7 @@ const ActivePollOption: React.FC & { chi children, answer, onOptionSelected, + maxSelections, }) => { let ariaLabel: string; @@ -77,6 +80,25 @@ const ActivePollOption: React.FC & { chi }); } + const isMultiSelect = maxSelections && maxSelections > 1; + + if (isMultiSelect) { + return ( + onOptionSelected?.(answer.id)} + icon={isChecked ? : undefined} + > + + + ); + } + return ( = ({ isEnded, isChecked, onOptionSelected, + maxSelections, }) => { const cls = classNames({ mx_PollOption: true, @@ -123,6 +146,7 @@ export const PollOption: React.FC = ({ voteCount={voteCount} displayVoteCount={displayVoteCount} onOptionSelected={onOptionSelected} + maxSelections={maxSelections} > { M_POLL_KIND_DISCLOSED.name, ); }); + + it("renders max selections UI and sends max_selections in event", () => { + const dialog = render(); + expect(dialog.container.querySelector(".mx_PollCreateDialog_maxSelections")).toBeTruthy(); + expect(dialog.container.querySelector(".mx_PollCreateDialog_maxSelectionsValue")).toHaveTextContent("1"); + + changeValue(dialog, "Question or topic", "Q"); + changeValue(dialog, "Option 1", "A1"); + changeValue(dialog, "Option 2", "A2"); + + fireEvent.click(dialog.container.querySelectorAll(".mx_PollCreateDialog_maxSelectionsButton")[1]); + + expect(dialog.container.querySelector(".mx_PollCreateDialog_maxSelectionsValue")).toHaveTextContent("2"); + + fireEvent.click(dialog.container.querySelector("button")!); + const [, , , sentEventContent] = mockClient.sendEvent.mock.calls[0] as any; + expect((sentEventContent as any)[M_POLL_START.name].max_selections).toBe(2); + }); + + it("decrements max selections", () => { + const dialog = render(); + changeValue(dialog, "Question or topic", "Q"); + changeValue(dialog, "Option 1", "A1"); + changeValue(dialog, "Option 2", "A2"); + + fireEvent.click(dialog.container.querySelectorAll(".mx_PollCreateDialog_maxSelectionsButton")[1]); + expect(dialog.container.querySelector(".mx_PollCreateDialog_maxSelectionsValue")).toHaveTextContent("2"); + + fireEvent.click(dialog.container.querySelectorAll(".mx_PollCreateDialog_maxSelectionsButton")[0]); + expect(dialog.container.querySelector(".mx_PollCreateDialog_maxSelectionsValue")).toHaveTextContent("1"); + }); + + it("disables decrement button when max selections is 1", () => { + const dialog = render(); + const decrementButton = dialog.container.querySelectorAll( + ".mx_PollCreateDialog_maxSelectionsButton", + )[0] as HTMLButtonElement; + expect(decrementButton).toHaveAttribute("disabled"); + }); + + it("disables increment button when max selections equals number of options", () => { + const dialog = render(); + changeValue(dialog, "Question or topic", "Q"); + changeValue(dialog, "Option 1", "A1"); + changeValue(dialog, "Option 2", "A2"); + + const incrementButton = dialog.container.querySelectorAll( + ".mx_PollCreateDialog_maxSelectionsButton", + )[1] as HTMLButtonElement; + expect(incrementButton).not.toHaveAttribute("disabled"); + + fireEvent.click(incrementButton); + expect(incrementButton).toHaveAttribute("disabled"); + }); + + it("loads max_selections from existing poll when editing", () => { + const previousEvent: MatrixEvent = new MatrixEvent( + PollStartEvent.from("Poll Q", ["Answer 1", "Answer 2"], M_POLL_KIND_DISCLOSED, 2).serialize(), + ); + + const dialog = render( + , + ); + + expect(dialog.container.querySelector(".mx_PollCreateDialog_maxSelectionsValue")).toHaveTextContent("2"); + }); + + it("adjusts max selections when options are removed", () => { + const dialog = render(); + changeValue(dialog, "Question or topic", "Q"); + changeValue(dialog, "Option 1", "A1"); + changeValue(dialog, "Option 2", "A2"); + changeValue(dialog, "Option 3", "A3"); + + fireEvent.click(dialog.container.querySelectorAll(".mx_PollCreateDialog_maxSelectionsButton")[1]); + expect(dialog.container.querySelector(".mx_PollCreateDialog_maxSelectionsValue")).toHaveTextContent("2"); + + const removeButtons = dialog.container.querySelectorAll(".mx_PollCreateDialog_optionRemove"); + fireEvent.click(removeButtons[2]); + + expect(dialog.container.querySelector(".mx_PollCreateDialog_maxSelectionsValue")).toHaveTextContent("1"); + }); }); function createRoom(): Room { diff --git a/apps/web/test/unit-tests/components/views/messages/MPollBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/MPollBody-test.tsx index 360e5be4ae1..7c1e943e511 100644 --- a/apps/web/test/unit-tests/components/views/messages/MPollBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MPollBody-test.tsx @@ -477,6 +477,80 @@ describe("MPollBody", () => { expect(mockClient.sendEvent).not.toHaveBeenCalled(); }); + it("renders checkboxes when poll allows multiple selections", async () => { + const votes: MatrixEvent[] = []; + const renderResult = await newMPollBody(votes, [], undefined, true, true, 2); + expect(renderResult.container.querySelector('input[type="checkbox"]')).toBeInTheDocument(); + }); + + it("renders radio buttons when poll allows single selection", async () => { + const votes: MatrixEvent[] = []; + const renderResult = await newMPollBody(votes, [], undefined, true, true, 1); + expect(renderResult.container.querySelector('input[type="radio"]')).toBeInTheDocument(); + }); + + it("allows selecting multiple options when maxSelections > 1", async () => { + const votes: MatrixEvent[] = []; + const renderResult = await newMPollBody(votes, [], undefined, true, true, 2); + + clickOption(renderResult, "pizza"); + expect(mockClient.sendEvent).toHaveBeenCalledWith( + "#myroom:example.com", + M_POLL_RESPONSE.name, + expect.objectContaining({ + [M_POLL_RESPONSE.name]: { answers: ["pizza"] }, + }), + ); + + clickOption(renderResult, "wings"); + expect(mockClient.sendEvent).toHaveBeenCalledWith( + "#myroom:example.com", + M_POLL_RESPONSE.name, + expect.objectContaining({ + [M_POLL_RESPONSE.name]: { answers: ["pizza", "wings"] }, + }), + ); + }); + + it("allows deselecting an option in multi-select poll", async () => { + const votes: MatrixEvent[] = []; + const renderResult = await newMPollBody(votes, [], undefined, true, true, 2); + + clickOption(renderResult, "pizza"); + clickOption(renderResult, "pizza"); + + expect(mockClient.sendEvent).toHaveBeenCalledWith( + "#myroom:example.com", + M_POLL_RESPONSE.name, + expect.objectContaining({ + [M_POLL_RESPONSE.name]: { answers: [] }, + }), + ); + }); + + it("prevents selecting more than maxSelections options", async () => { + const votes: MatrixEvent[] = []; + const renderResult = await newMPollBody(votes, [], undefined, true, true, 2); + + clickOption(renderResult, "pizza"); + clickOption(renderResult, "wings"); + clickOption(renderResult, "italian"); + + expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); + }); + + it("highlights multiple selections in multi-select poll", async () => { + const votes: MatrixEvent[] = []; + const renderResult = await newMPollBody(votes, [], undefined, true, true, 2); + + clickOption(renderResult, "pizza"); + clickOption(renderResult, "wings"); + + expect(renderResult.container.querySelectorAll('input[value="pizza"]')[0]).toBeChecked(); + expect(renderResult.container.querySelectorAll('input[value="wings"]')[0]).toBeChecked(); + expect(renderResult.container.querySelectorAll('input[value="italian"]')[0]).not.toBeChecked(); + }); + it("finds the top answer among several votes", async () => { // 2 votes for poutine, 1 for pizza. "me" made an invalid vote. const votes = [ @@ -883,12 +957,13 @@ async function newMPollBody( answers?: PollAnswer[], disclosed = true, waitForResponsesLoad = true, + maxSelections = 1, ): Promise { const mxEvent = new MatrixEvent({ type: M_POLL_START.name, event_id: "$mypoll", room_id: "#myroom:example.com", - content: newPollStart(answers, undefined, disclosed), + content: newPollStart(answers, undefined, disclosed, maxSelections), }); const prom = newMPollBodyFromEvent(mxEvent, relationEvents, endEvents); if (waitForResponsesLoad) { @@ -956,7 +1031,12 @@ function endedVotesCount(renderResult: RenderResult, value: string): string { return votesCount(renderResult, value); } -function newPollStart(answers?: PollAnswer[], question?: string, disclosed = true): PollStartEventContent { +function newPollStart( + answers?: PollAnswer[], + question?: string, + disclosed = true, + maxSelections = 1, +): PollStartEventContent { if (!answers) { answers = [ { id: "pizza", [M_TEXT.name]: "Pizza" }, @@ -981,6 +1061,7 @@ function newPollStart(answers?: PollAnswer[], question?: string, disclosed = tru }, kind: disclosed ? M_POLL_KIND_DISCLOSED.name : M_POLL_KIND_UNDISCLOSED.name, answers: answers, + max_selections: maxSelections, }, [M_TEXT.name]: fallback, };