Skip to content

Commit

Permalink
Allow voting for multiple options without change perm (#78)
Browse files Browse the repository at this point in the history
* Make help text sticky
* Add 'vote' button for multiple votes at once w/o vote change perm
* Add poll option to prevent users from changing their vote
  • Loading branch information
dsevillamartin authored Jul 21, 2023
1 parent 19f4708 commit b326cff
Show file tree
Hide file tree
Showing 11 changed files with 185 additions and 39 deletions.
15 changes: 15 additions & 0 deletions js/src/forum/components/CreatePollModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default class CreatePollModal extends Modal {

this.publicPoll = Stream(false);
this.hideVotes = Stream(false);
this.allowChangeVote = Stream(true);
this.allowMultipleVotes = Stream(false);
this.maxVotes = Stream(0);

Expand All @@ -39,6 +40,7 @@ export default class CreatePollModal extends Modal {
this.question(poll.question);
this.publicPoll(poll.publicPoll);
this.hideVotes(poll.hideVotes);
this.allowChangeVote(poll.allowChangeVote);
this.allowMultipleVotes(poll.allowMultipleVotes);
this.maxVotes(poll.maxVotes || 0);

Expand Down Expand Up @@ -156,6 +158,16 @@ export default class CreatePollModal extends Modal {
20
);

items.add(
'allow-change-vote',
<div className="Form-group">
<Switch state={this.allowChangeVote()} onchange={this.allowChangeVote}>
{app.translator.trans('fof-polls.forum.modal.allow_change_vote_label')}
</Switch>
</div>,
20
);

items.add(
'allow-multiple-votes',
<div className="Form-group">
Expand Down Expand Up @@ -256,6 +268,8 @@ export default class CreatePollModal extends Modal {
question: this.question(),
endDate: this.dateToTimestamp(this.endDate()),
publicPoll: this.publicPoll(),
hideVotes: this.hideVotes(),
allowChangeVote: this.allowChangeVote(),
allowMultipleVotes: this.allowMultipleVotes(),
maxVotes: this.maxVotes(),
options: [],
Expand Down Expand Up @@ -301,6 +315,7 @@ export default class CreatePollModal extends Modal {

promise.then(this.hide.bind(this), (err) => {
console.error(err);
this.onerror(err);
this.loaded();
});
} else {
Expand Down
2 changes: 2 additions & 0 deletions js/src/forum/components/EditPollModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default class EditPollModal extends CreatePollModal {
this.publicPoll = Stream(this.poll.publicPoll());
this.allowMultipleVotes = Stream(this.poll.allowMultipleVotes());
this.hideVotes = Stream(this.poll.hideVotes());
this.allowChangeVote = Stream(this.poll.allowChangeVote());
this.maxVotes = Stream(this.poll.maxVotes() || 0);

if (this.endDate() && dayjs(this.poll.endDate()).isAfter(dayjs())) {
Expand Down Expand Up @@ -97,6 +98,7 @@ export default class EditPollModal extends CreatePollModal {
endDate: this.dateToTimestamp(this.endDate()),
publicPoll: this.publicPoll(),
hideVotes: this.hideVotes(),
allowChangeVote: this.allowChangeVote(),
allowMultipleVotes: this.allowMultipleVotes(),
maxVotes: this.maxVotes(),
options,
Expand Down
144 changes: 115 additions & 29 deletions js/src/forum/components/PostPoll.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Button from 'flarum/common/components/Button';
import LogInModal from 'flarum/forum/components/LogInModal';
import ListVotersModal from './ListVotersModal';
import classList from 'flarum/common/utils/classList';
import ItemList from 'flarum/common/utils/ItemList';
import Tooltip from 'flarum/common/components/Tooltip';
import EditPollModal from './EditPollModal';

Expand All @@ -13,6 +14,23 @@ export default class PostPoll extends Component {
super.oninit(vnode);

this.loadingOptions = false;

this.useSubmitUI = !this.attrs.poll?.canChangeVote() && this.attrs.poll?.allowMultipleVotes();
this.pendingSubmit = false;
this.pendingOptions = null;
}

oncreate(vnode) {
super.oncreate(vnode);

this.preventClose = this.preventClose.bind(this);
window.addEventListener('beforeunload', this.preventClose);
}

onremove(vnode) {
super.onremove(vnode);

window.removeEventListener('beforeunload', this.preventClose);
}

view() {
Expand All @@ -22,6 +40,8 @@ export default class PostPoll extends Component {

if (maxVotes === 0) maxVotes = options.length;

const infoItems = this.infoItems(maxVotes);

return (
<div className="Post-poll" data-id={poll.id()}>
<div className="PollHeading">
Expand All @@ -45,41 +65,79 @@ export default class PostPoll extends Component {
)}
</div>

<div className="PollOptions">{options.map(this.viewOption.bind(this))}</div>
<div>
<div className="PollOptions">{options.map(this.viewOption.bind(this))}</div>

<div className="helpText PollInfoText">
{app.session.user && !poll.canVote() && !poll.hasEnded() && (
<span>
<i className="icon fas fa-times-circle" />
{app.translator.trans('fof-polls.forum.no_permission')}
</span>
)}
{poll.endDate() && (
<span>
<i class="icon fas fa-clock" />
{poll.hasEnded()
? app.translator.trans('fof-polls.forum.poll_ended')
: app.translator.trans('fof-polls.forum.days_remaining', { time: dayjs(poll.endDate()).fromNow() })}
</span>
)}
<div className="Poll-sticky">
{!infoItems.isEmpty() && <div className="helpText PollInfoText">{infoItems.toArray()}</div>}

{poll.canVote() && (
<span>
<i className="icon fas fa-poll" />
{app.translator.trans('fof-polls.forum.max_votes_allowed', { max: maxVotes })}
</span>
)}
{this.useSubmitUI && this.pendingSubmit && (
<Button className="Button Button--primary Poll-submit" loading={this.loadingOptions} onclick={this.onsubmit.bind(this)}>
{app.translator.trans('fof-polls.forum.poll.submit_button')}
</Button>
)}
</div>
</div>
</div>
);
}

infoItems(maxVotes) {
const items = new ItemList();
const poll = this.attrs.poll;
const hasVoted = poll.myVotes()?.length > 0;

if (app.session.user && !poll.canVote() && !poll.hasEnded()) {
items.add(
'no-permission',
<span>
<i className="icon fas fa-times-circle fa-fw" />
{app.translator.trans('fof-polls.forum.no_permission')}
</span>
);
}

if (poll.endDate()) {
items.add(
'end-date',
<span>
<i class="icon fas fa-clock fa-fw" />
{poll.hasEnded()
? app.translator.trans('fof-polls.forum.poll_ended')
: app.translator.trans('fof-polls.forum.days_remaining', { time: dayjs(poll.endDate()).fromNow() })}
</span>
);
}

if (poll.canVote()) {
items.add(
'max-votes',
<span>
<i className="icon fas fa-poll fa-fw" />
{app.translator.trans('fof-polls.forum.max_votes_allowed', { max: maxVotes })}
</span>
);

if (!poll.canChangeVote()) {
items.add(
'cannot-change-vote',
<span>
<i className={`icon fas fa-${hasVoted ? 'times' : 'exclamation'}-circle fa-fw`} />
{app.translator.trans('fof-polls.forum.poll.cannot_change_vote')}
</span>
);
}
}

return items;
}

viewOption(opt) {
const poll = this.attrs.poll;
const hasVoted = poll.myVotes()?.length > 0;
const totalVotes = poll.voteCount();

const voted = poll.myVotes()?.some?.((vote) => vote.option() === opt);
const voted = this.pendingOptions ? this.pendingOptions.has(opt.id()) : poll.myVotes()?.some?.((vote) => vote.option() === opt);
const votes = opt.voteCount();
const percent = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;

Expand All @@ -88,9 +146,11 @@ export default class PostPoll extends Component {
const isDisabled = this.loadingOptions || (hasVoted && !poll.canChangeVote());
const width = canSeeVoteCount ? percent : (Number(voted) / (poll.myVotes()?.length || 1)) * 100;

const showCheckmark = !app.session.user || (!poll.hasEnded() && poll.canVote() && (!hasVoted || poll.canChangeVote()));

const bar = (
<div className="PollBar" data-selected={voted}>
{((!poll.hasEnded() && poll.canVote()) || !app.session.user) && (
{showCheckmark && (
<label className="checkbox">
<input onchange={this.changeVote.bind(this, opt)} type="checkbox" checked={voted} disabled={isDisabled} />
<span className="checkmark" />
Expand Down Expand Up @@ -133,7 +193,7 @@ export default class PostPoll extends Component {
return;
}

const optionIds = new Set(this.attrs.poll.myVotes().map?.((v) => v.option().id()));
const optionIds = this.pendingOptions || new Set(this.attrs.poll.myVotes().map?.((v) => v.option().id()));
const isUnvoting = optionIds.delete(option.id());
const allowsMultiple = this.attrs.poll.allowMultipleVotes();

Expand All @@ -145,6 +205,23 @@ export default class PostPoll extends Component {
optionIds.add(option.id());
}

if (this.useSubmitUI) {
this.pendingOptions = optionIds.size ? optionIds : null;
this.pendingSubmit = !!this.pendingOptions;
return;
}

return this.submit(optionIds, null, () => (evt.target.checked = isUnvoting));
}

onsubmit() {
return this.submit(this.pendingOptions, () => {
this.pendingOptions = null;
this.pendingSubmit = false;
});
}

submit(optionIds, cb, onerror) {
this.loadingOptions = true;
m.redraw();

Expand All @@ -160,11 +237,10 @@ export default class PostPoll extends Component {
})
.then((res) => {
app.store.pushPayload(res);

// m.redraw();
cb?.();
})
.catch(() => {
evt.target.checked = isUnvoting;
.catch((err) => {
onerror?.(err);
})
.finally(() => {
this.loadingOptions = false;
Expand Down Expand Up @@ -198,4 +274,14 @@ export default class PostPoll extends Component {
vnode.attrs.tooltipVisible = false;
vnode.state.updateVisibility();
}

/**
* Alert before navigating away using browser's 'beforeunload' event
*/
preventClose(e) {
if (this.pendingOptions) {
e.preventDefault();
return true;
}
}
}
1 change: 1 addition & 0 deletions js/src/forum/models/Poll.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default class Poll extends Model {

publicPoll = Model.attribute('publicPoll');
hideVotes = Model.attribute('hideVotes');
allowChangeVote = Model.attribute('allowChangeVote');
allowMultipleVotes = Model.attribute('allowMultipleVotes');
maxVotes = Model.attribute('maxVotes');

Expand Down
28 changes: 24 additions & 4 deletions resources/less/forum.less
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@
align-items: start;
}

.Poll-sticky {
position: sticky;
bottom: 0;
padding: 10px 0;
margin-left: 15px;
margin-top: 10px;
display: flex;
align-items: flex-start;
column-gap: 15px;
z-index: 999;
background-color: var(--body-bg);
.box-shadow(inset 0px 2px 0px 0px fade(@text-color, 20%));

&:empty {
display: none;
}

.PollInfoText {
flex-grow: 1;
margin-bottom: 0;
}
}

& + & {
margin-top: 2em;
}
Expand Down Expand Up @@ -318,15 +341,12 @@
}

.PollInfoText {
margin-left: 15px;
margin-top: 20px;

span {
display: block;
}

.icon {
width: 15px;
margin-right: 2.5px;
}
}

Expand Down
5 changes: 5 additions & 0 deletions resources/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ fof-polls:
public_poll: View voters
max_votes_allowed: "Poll allows voting for {max, plural, one {# option} other {# options}}."

poll:
cannot_change_vote: You cannot change your vote after voting.
submit_button: Vote

composer_discussion:
add_poll: => fof-polls.forum.moderation.add
edit_poll: => fof-polls.forum.moderation.edit
Expand All @@ -41,6 +45,7 @@ fof-polls:
max_votes_label: Max votes per user
max_votes_help: Set to 0 to allow users to vote for all options.
hide_votes_label: Hide votes until poll ends
allow_change_vote_label: Allow users to change their vote
question_placeholder: Question
submit: Submit

Expand Down
2 changes: 1 addition & 1 deletion src/Access/PollPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public function vote(User $actor, Poll $poll)

public function changeVote(User $actor, Poll $poll)
{
if ($actor->hasPermission('polls.changeVote')) {
if ($poll->allow_change_vote && $actor->hasPermission('polls.changeVote')) {
return $this->allow();
}
}
Expand Down
Loading

0 comments on commit b326cff

Please sign in to comment.