Skip to content

Commit

Permalink
More features & breaking v2 changes (#74)
Browse files Browse the repository at this point in the history
* Move voters button next to heading & fix no permission error after poll ends

* Create endpoint for new poll to add to existing post (refs #67, #16)

* Drop 'newPollVote' websocket message (fixes #38)

* Remove deprecated single vote API endpoint

* Drop ReFlar/polls database compatibility

* Rename 'canSeeVotes' to 'canseeVoters'

* Don't show if post is hidden unless revealing content, adjust some styling

* Add visibility scoping to polls, prohibit actions if user cannot view post

* Reduce quirkiness with poll option tooltips & disable voting while voting

* Move 'canStartPolls' global attribute to ForumSerializer

* Don't check for existence of post when poll is created during post creation

* Fix check for being able to start poll in non-existing post

* Fix frontend not allowing clearing existing poll's end date

* Separate edit permission into own polls & polls in own posts
  • Loading branch information
dsevillamartin authored Jul 12, 2023
1 parent 1d54e7d commit 533b54b
Show file tree
Hide file tree
Showing 33 changed files with 592 additions and 606 deletions.
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
"require": {
"flarum/core": "^1.3.0"
},
"replace": {
"reflar/polls": "^1.3.4"
"conflict": {
"reflar/polls": "*"
},
"authors": [
{
Expand Down
23 changes: 14 additions & 9 deletions extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

use Flarum\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Post\Event\Saving;
Expand All @@ -34,10 +34,10 @@
new Extend\Locales(__DIR__.'/resources/locale'),

(new Extend\Routes('api'))
->post('/fof/polls', 'fof.polls.create', Controllers\CreatePollController::class)
->get('/fof/polls/{id}', 'fof.polls.show', Controllers\ShowPollController::class)
->patch('/fof/polls/{id}', 'fof.polls.edit', Controllers\EditPollController::class)
->delete('/fof/polls/{id}', 'fof.polls.delete', Controllers\DeletePollController::class)
->patch('/fof/polls/{id}/vote', 'fof.polls.vote', Controllers\VotePollController::class)
->patch('/fof/polls/{id}/votes', 'fof.polls.votes', Controllers\MultipleVotesPollController::class),

(new Extend\Model(Post::class))
Expand All @@ -58,13 +58,14 @@
}),

(new Extend\ApiSerializer(PostSerializer::class))
->hasMany('polls', PollSerializer::class),
->hasMany('polls', PollSerializer::class)
->attribute('canStartPoll', function (PostSerializer $serializer, Post $post): bool {
return $serializer->getActor()->can('startPoll', $post);
}),

(new Extend\ApiSerializer(UserSerializer::class))
->attributes(function (UserSerializer $serializer): array {
return [
'canStartPolls' => $serializer->getActor()->can('discussion.polls.start'), // used for discussion composer
];
(new Extend\ApiSerializer(ForumSerializer::class))
->attribute('canStartPolls', function (ForumSerializer $serializer): bool {
return $serializer->getActor()->can('discussion.polls.start');
}),

(new Extend\ApiController(Controller\ListDiscussionsController::class))
Expand Down Expand Up @@ -94,8 +95,12 @@
->command(Console\RefreshVoteCountCommand::class),

(new Extend\Policy())
->modelPolicy(Poll::class, Access\PollPolicy::class),
->modelPolicy(Poll::class, Access\PollPolicy::class)
->modelPolicy(Post::class, Access\PostPolicy::class),

(new Extend\Settings())
->serializeToForum('allowPollOptionImage', 'fof-polls.allowOptionImage', 'boolval'),

(new Extend\ModelVisibility(Poll::class))
->scope(Access\ScopePollVisibility::class),
];
8 changes: 8 additions & 0 deletions js/src/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ app.initializers.add('fof/polls', () => {
},
'start'
)
.registerPermission(
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('fof-polls.admin.permissions.self_post_edit'),
permission: 'polls.selfPostEdit',
},
'start'
)
.registerPermission(
{
icon: 'fas fa-signal',
Expand Down
2 changes: 1 addition & 1 deletion js/src/forum/addComposerItems.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const addToComposer = (composer) => {
// Add button to DiscussionComposer header
extend(composer.prototype, 'headerItems', function (items) {
const discussion = this.composer.body?.attrs?.discussion;
const canStartPoll = discussion?.canStartPoll() ?? app.session.user?.canStartPolls();
const canStartPoll = discussion?.canStartPoll() ?? app.forum.canStartPolls();

if (canStartPoll) {
items.add(
Expand Down
6 changes: 3 additions & 3 deletions js/src/forum/addPollsToPost.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default () => {
extend(CommentPost.prototype, 'content', function (content) {
const post = this.attrs.post;

if (post.polls()) {
if ((!post.isHidden() || this.revealContent) && post.polls()) {
for (const poll of post.polls()) {
if (poll) {
content.push(<PostPoll post={post} poll={poll} />);
Expand All @@ -26,8 +26,8 @@ export default () => {
(poll) =>
poll && [
poll.data?.attributes,
poll.options()?.map((option) => option?.data?.attributes),
poll.myVotes()?.map((vote) => vote.option()?.id()),
poll.options().map?.((option) => option?.data?.attributes),
poll.myVotes().map?.((vote) => vote.option()?.id()),
]
);

Expand Down
44 changes: 44 additions & 0 deletions js/src/forum/addPostControls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import app from 'flarum/forum/app';

import { extend } from 'flarum/common/extend';
import PostControls from 'flarum/forum/utils/PostControls';
import CreatePollModal from './components/CreatePollModal';
import Button from 'flarum/common/components/Button';

export default () => {
const createPoll = (post) =>
app.modal.show(CreatePollModal, {
onsubmit: (data) =>
app.store
.createRecord('polls')
.save(
{
...data,
relationships: {
post,
},
},
{
data: {
include: 'options,myVotes,myVotes.option',
},
}
)
.then((poll) => {
post.rawRelationship('polls')?.push?.({ type: 'polls', id: poll.id() });

return poll;
}),
});

extend(PostControls, 'moderationControls', function (items, post) {
if (!post.isHidden() && post.canStartPoll()) {
items.add(
'addPoll',
<Button icon="fas fa-poll" onclick={createPoll.bind(this, post)}>
{app.translator.trans('fof-polls.forum.moderation.add')}
</Button>
);
}
});
};
15 changes: 12 additions & 3 deletions js/src/forum/components/CreatePollModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,18 @@ export default class CreatePollModal extends Modal {
return;
}

this.attrs.onsubmit(data);
const promise = this.attrs.onsubmit(data);

app.modal.close();
if (promise instanceof Promise) {
this.loading = true;

promise.then(this.hide.bind(this), (err) => {
console.error(err);
this.loaded();
});
} else {
app.modal.close();
}
}

formatDate(date, def = false) {
Expand All @@ -289,7 +298,7 @@ export default class CreatePollModal extends Modal {
dateToTimestamp(date) {
const dayjsDate = dayjs(date);

if (!dayjsDate.isValid()) return null;
if (!dayjsDate.isValid()) return false;

return dayjsDate.format();
}
Expand Down
106 changes: 57 additions & 49 deletions js/src/forum/components/PostPoll.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,28 @@ import EditPollModal from './EditPollModal';
export default class PostPoll extends Component {
oninit(vnode) {
super.oninit(vnode);
this.poll = this.attrs.poll;

this.updateData();
this.loadingOptions = false;
}

view() {
const poll = this.poll;
const poll = this.attrs.poll;
const options = poll.options() || [];
let maxVotes = poll.allowMultipleVotes() ? poll.maxVotes() : 1;

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

return (
<div className="Post-poll">
<div className="Post-poll" data-id={poll.id()}>
<div className="PollHeading">
<h3 className="PollHeading-title">{poll.question()}</h3>

{poll.canSeeVoters() && (
<Tooltip text={app.translator.trans('fof-polls.forum.public_poll')}>
<Button className="Button PollHeading-voters" onclick={this.showVoters.bind(this)} icon="fas fa-poll" />
</Tooltip>
)}

{poll.canEdit() && (
<Tooltip text={app.translator.trans('fof-polls.forum.moderation.edit')}>
<Button className="Button PollHeading-edit" onclick={app.modal.show.bind(app.modal, EditPollModal, { poll })} icon="fas fa-pen" />
Expand All @@ -38,20 +45,10 @@ export default class PostPoll extends Component {
)}
</div>

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

{poll.canSeeVotes()
? Button.component(
{
className: 'Button Button--primary PublicPollButton',
onclick: () => this.showVoters(),
},
app.translator.trans('fof-polls.forum.public_poll')
)
: ''}
<div className="PollOptions">{options.map(this.viewOption.bind(this))}</div>

<div className="helpText PollInfoText">
{app.session.user && !poll.canVote() && (
{app.session.user && !poll.canVote() && !poll.hasEnded() && (
<span>
<i className="icon fas fa-times-circle" />
{app.translator.trans('fof-polls.forum.no_permission')}
Expand All @@ -78,21 +75,23 @@ export default class PostPoll extends Component {
}

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

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

// isNaN(null) is false, so we have to check type directly now that API always returns the field
const canSeeVoteCount = typeof votes === 'number';
const isDisabled = this.loadingOptions || (hasVoted && !poll.canChangeVote());

const poll = (
const bar = (
<div className="PollBar" data-selected={voted}>
{((!this.poll.hasEnded() && this.poll.canVote()) || !app.session.user) && (
{((!poll.hasEnded() && poll.canVote()) || !app.session.user) && (
<label className="checkbox">
<input onchange={this.changeVote.bind(this, opt)} type="checkbox" checked={voted} disabled={hasVoted && !this.poll.canChangeVote()} />
<input onchange={this.changeVote.bind(this, opt)} type="checkbox" checked={voted} disabled={isDisabled} />
<span className="checkmark" />
</label>
)}
Expand All @@ -111,38 +110,31 @@ export default class PostPoll extends Component {
);

return (
<div className={classList('PollOption', hasVoted && 'PollVoted', this.poll.hasEnded() && 'PollEnded', opt.imageUrl() && 'PollOption-hasImage')}>
<Tooltip tooltipVisible={canSeeVoteCount ? undefined : false} text={app.translator.trans('fof-polls.forum.tooltip.votes', { count: votes })}>
{poll}
</Tooltip>
<div
className={classList('PollOption', hasVoted && 'PollVoted', poll.hasEnded() && 'PollEnded', opt.imageUrl() && 'PollOption-hasImage')}
data-id={opt.id()}
>
{canSeeVoteCount ? (
<Tooltip text={app.translator.trans('fof-polls.forum.tooltip.votes', { count: votes })} onremove={this.hideOptionTooltip}>
{bar}
</Tooltip>
) : (
bar
)}
</div>
);
}

updateData() {
this.options = this.poll.options() || [];
this.myVotes = this.poll.myVotes() || [];
}

onError(evt, error) {
evt.target.checked = false;

throw error;
}

changeVote(option, evt) {
if (!app.session.user) {
app.modal.show(LogInModal);
evt.target.checked = false;
return;
}

// // if we click on our current vote, we want to "un-vote"
// if (this.myVotes.some((vote) => vote.option() === option)) option = null;

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

if (!allowsMultiple) {
optionIds.clear();
Expand All @@ -152,10 +144,13 @@ export default class PostPoll extends Component {
optionIds.add(option.id());
}

this.loadingOptions = true;
m.redraw();

return app
.request({
method: 'PATCH',
url: `${app.forum.attribute('apiUrl')}/fof/polls/${this.poll.id()}/votes`,
url: `${app.forum.attribute('apiUrl')}/fof/polls/${this.attrs.poll.id()}/votes`,
body: {
data: {
optionIds: Array.from(optionIds),
Expand All @@ -165,28 +160,41 @@ export default class PostPoll extends Component {
.then((res) => {
app.store.pushPayload(res);

this.updateData();

m.redraw();
// m.redraw();
})
.catch(() => {
evt.target.checked = isUnvoting;
})
.finally(() => {
this.loadingOptions = false;

m.redraw();
});
}

showVoters() {
// Load all the votes only when opening the votes list
app.modal.show(ListVotersModal, {
poll: this.poll,
poll: this.attrs.poll,
post: this.attrs.post,
});
}

deletePoll() {
if (confirm(app.translator.trans('fof-polls.forum.moderation.delete_confirm'))) {
this.poll.delete().then(() => {
this.attrs.poll.delete().then(() => {
m.redraw.sync();
});
}
}

/**
* Attempting to use the `tooltipVisible` attr on the Tooltip component set to 'false' when no vote count
* caused the tooltip to break on click. This is a workaround to hide the tooltip when no vote count is available,
* called on 'onremove' of the Tooltip component. It doesn't always work as intended either, but it does the job.
*/
hideOptionTooltip(vnode) {
vnode.attrs.tooltipVisible = false;
vnode.state.updateVisibility();
}
}
Loading

0 comments on commit 533b54b

Please sign in to comment.