FIX: update voter information upon remote change (#28168)

Fixes issue with polls not being fully updated by remote vote contributions in (semi-) real-time.

This was down to too great a focus on tracking local state and not accommodating a more data down approach with responsive getters.

This is now implemented.

I've tried hard to minimise the changes whilst making sure the paradigm is properly followed through.
This commit is contained in:
Robert 2024-08-02 07:50:33 +01:00 committed by GitHub
parent 11369018b6
commit 26c4d1398a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 264 additions and 194 deletions

View File

@ -1,5 +1,4 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
@ -9,13 +8,6 @@ import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
export default class PollOptionsDropdownComponent extends Component {
@tracked rank;
constructor() {
super(...arguments);
this.rank = this.args.rank;
}
@action
onRegisterApi(api) {
this.dMenu = api;
@ -24,15 +16,13 @@ export default class PollOptionsDropdownComponent extends Component {
@action
selectRank(option, rank) {
this.args.sendRank(option, rank);
this.rank =
rank === 0 ? I18n.t("poll.options.ranked_choice.abstain") : rank;
this.dMenu.close();
}
get rankLabel() {
return this.rank === 0
return this.args.rank === 0
? I18n.t("poll.options.ranked_choice.abstain")
: this.rank;
: this.args.rank;
}
<template>

View File

@ -27,7 +27,7 @@ export default class PollOptionsComponent extends Component {
}
<template>
<ul class={{concatClass (if @isRankedChoice "ranked-choice-poll-options")}}>
{{#each @options as |option|}}
{{#each @options key="rank" as |option|}}
{{#if @isRankedChoice}}
<PollOptionRankedChoice
@option={{option}}

View File

@ -40,8 +40,13 @@ export default class PollResultsStandardComponent extends Component {
const chosen = (this.args.vote || []).includes(option.id);
option.percentage = per;
option.chosen = chosen;
let voters = this.args.isPublic ? this.args.voters[option.id] || [] : [];
let voters = this.args.isPublic
? this.args.voters[option.id]?.voters || []
: [];
option.voters = [...voters];
option.loading = this.args.isPublic
? this.args.voters[option.id]?.loading || false
: false;
});
return ordered;

View File

@ -27,40 +27,26 @@ const REGULAR = "regular";
const RANKED_CHOICE = "ranked_choice";
const ON_VOTE = "on_vote";
const ON_CLOSE = "on_close";
const CLOSED_STATUS = "closed";
const OPEN_STATUS = "open";
export default class PollComponent extends Component {
@service currentUser;
@service siteSettings;
@service appEvents;
@service dialog;
@service router;
@service modal;
@tracked isStaff = this.currentUser && this.currentUser.staff;
@tracked vote = this.args.attrs.vote || [];
@tracked titleHTML = htmlSafe(this.args.attrs.titleHTML);
@tracked topicArchived = this.args.attrs.post.get("topic.archived");
@tracked options = [];
@tracked poll = this.args.attrs.poll;
@tracked voters = this.poll.voters || 0;
@tracked preloadedVoters = this.args.preloadedVoters || [];
@tracked isRankedChoice = this.poll.type === RANKED_CHOICE;
@tracked rankedChoiceOutcome = this.poll.ranked_choice_outcome || [];
@tracked isMultiVoteType = this.isRankedChoice || this.isMultiple;
@tracked staffOnly = this.poll.results === STAFF_ONLY;
@tracked isMultiple = this.poll.type === MULTIPLE;
@tracked isNumber = this.poll.type === NUMBER;
@tracked showingResults = false;
@tracked preloadedVoters = this.defaultPreloadedVoters();
@tracked hasSavedVote = this.args.attrs.hasSavedVote;
@tracked status = this.poll.status;
@tracked
showResults =
this.hasSavedVote ||
this.showingResults ||
(this.topicArchived && !this.staffOnly) ||
(this.closed && !this.staffOnly);
post = this.args.attrs.post;
isMe =
this.currentUser && this.args.attrs.post.user_id === this.currentUser.id;
checkUserGroups = (user, poll) => {
const pollGroups =
@ -75,59 +61,7 @@ export default class PollComponent extends Component {
return userGroups && pollGroups.some((g) => userGroups.includes(g));
};
castVotes = (option) => {
if (!this.canCastVotes) {
return;
}
if (!this.currentUser) {
return;
}
return ajax("/polls/vote", {
type: "PUT",
data: {
post_id: this.args.attrs.post.id,
poll_name: this.poll.name,
options: this.vote,
},
})
.then(({ poll }) => {
this.options = [...poll.options];
this.hasSavedVote = true;
this.rankedChoiceOutcome = poll.ranked_choice_outcome || [];
this.poll.setProperties(poll);
this.appEvents.trigger(
"poll:voted",
poll,
this.args.attrs.post,
this.args.attrs.vote
);
const voters = poll.voters;
this.voters = [Number(voters)][0];
if (this.poll.results !== "on_close") {
this.showResults = true;
}
if (this.poll.results === "staff_only") {
if (this.currentUser && this.currentUser.staff) {
this.showResults = true;
} else {
this.showResults = false;
}
}
})
.catch((error) => {
if (error) {
if (!this.isMultiple && !this.isRankedChoice) {
this._toggleOption(option);
}
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_casting_votes"));
}
});
};
areRanksValid = (arr) => {
let ranks = new Set(); // Using a Set to keep track of unique ranks
let hasNonZeroDuplicate = false;
@ -146,76 +80,188 @@ export default class PollComponent extends Component {
return !hasNonZeroDuplicate;
};
_toggleOption = (option, rank = 0) => {
let options = this.options;
let vote = this.vote;
_toggleOption = (option, rank = 0) => {
if (this.isMultiple) {
const chosenIdx = vote.indexOf(option.id);
const chosenIdx = this.vote.indexOf(option.id);
if (chosenIdx !== -1) {
vote.splice(chosenIdx, 1);
this.vote.splice(chosenIdx, 1);
} else {
vote.push(option.id);
this.vote.push(option.id);
}
} else if (this.isRankedChoice) {
options.forEach((candidate, i) => {
const chosenIdx = vote.findIndex(
this.options.forEach((candidate) => {
const chosenIdx = this.vote.findIndex(
(object) => object.digest === candidate.id
);
if (chosenIdx === -1) {
vote.push({
this.vote.push({
digest: candidate.id,
rank: candidate.id === option ? rank : 0,
});
} else {
if (candidate.id === option) {
vote[chosenIdx].rank = rank;
options[i].rank = rank;
this.vote[chosenIdx].rank = rank;
}
}
});
} else {
vote = [option.id];
this.vote = [option.id];
}
this.vote = [...vote];
this.options = [...options];
this.vote = [...this.vote];
};
constructor() {
super(...arguments);
this.id = this.args.attrs.id;
this.post = this.args.attrs.post;
this.options = this.poll.options;
this.groupableUserFields = this.args.attrs.groupableUserFields;
this.rankedChoiceDropdownContent = [];
if (this.isRankedChoice) {
this.rankedChoiceDropdownContent.push({
id: 0,
name: I18n.t("poll.options.ranked_choice.abstain"),
defaultPreloadedVoters() {
const preloadedVoters = {};
if (this.poll.public && this.args.preloadedVoters) {
Object.keys(this.args.preloadedVoters).forEach((key) => {
preloadedVoters[key] = {
voters: this.args.preloadedVoters[key],
loading: false,
};
});
}
this.options.forEach((option, i) => {
option.rank = 0;
if (this.isRankedChoice) {
this.rankedChoiceDropdownContent.push({
id: i + 1,
name: (i + 1).toString(),
});
this.args.attrs.vote.forEach((vote) => {
if (vote.digest === option.id) {
option.rank = vote.rank;
}
});
this.options.forEach((option) => {
if (!preloadedVoters[option.id]) {
preloadedVoters[option.id] = {
voters: [],
loading: false,
};
}
});
return preloadedVoters;
}
get id() {
return this.args.attrs.id;
}
get post() {
return this.args.attrs.post;
}
get groupableUserFields() {
return this.args.attrs.groupableUserFields;
}
get isStaff() {
return this.currentUser?.staff;
}
get titleHTML() {
return htmlSafe(this.args.attrs.titleHTML);
}
get topicArchived() {
return this.post.get("topic.archived");
}
get isRankedChoice() {
return this.poll.type === RANKED_CHOICE;
}
get staffOnly() {
return this.poll.results === STAFF_ONLY;
}
get isMultiple() {
return this.poll.type === MULTIPLE;
}
get isNumber() {
return this.poll.type === NUMBER;
}
get isMe() {
return this.currentUser && this.post.user_id === this.currentUser.id;
}
get status() {
return this.poll.get("status");
}
@action
async castVotes(option) {
if (!this.canCastVotes) {
return;
}
if (!this.currentUser) {
return;
}
try {
const poll = await ajax("/polls/vote", {
type: "PUT",
data: {
post_id: this.post.id,
poll_name: this.poll.name,
options: this.vote,
},
});
this.hasSavedVote = true;
this.poll.setProperties(poll);
this.appEvents.trigger("poll:voted", poll, this.post, this.vote);
if (this.poll.results !== ON_CLOSE) {
this.showResults = true;
}
if (this.poll.results === STAFF_ONLY) {
if (this.currentUser && this.currentUser.staff) {
this.showResults = true;
} else {
this.showResults = false;
}
}
} catch (error) {
if (error) {
if (!this.isMultiple && !this.isRankedChoice) {
this._toggleOption(option);
}
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_casting_votes"));
}
}
}
get options() {
let enrichedOptions = this.poll.get("options");
if (this.isRankedChoice) {
enrichedOptions.forEach((candidate) => {
const chosenIdx = this.vote.findIndex(
(object) => object.digest === candidate.id
);
if (chosenIdx === -1) {
candidate.rank = 0;
} else {
candidate.rank = this.vote[chosenIdx].rank;
}
});
}
return enrichedOptions;
}
get voters() {
return this.poll.get("voters");
}
get rankedChoiceOutcome() {
return this.poll.get("ranked_choice_outcome") || [];
}
get min() {
let min = parseInt(this.args.attrs.poll.min, 10);
let min = parseInt(this.poll.min, 10);
if (isNaN(min) || min < 0) {
min = 1;
}
@ -224,8 +270,8 @@ export default class PollComponent extends Component {
}
get max() {
let max = parseInt(this.args.attrs.poll.max, 10);
const numOptions = this.args.attrs.poll.options.length;
let max = parseInt(this.poll.max, 10);
const numOptions = this.poll.options.length;
if (isNaN(max) || max > numOptions) {
max = numOptions;
}
@ -233,19 +279,37 @@ export default class PollComponent extends Component {
}
get closed() {
return this.status === "closed" || this.isAutomaticallyClosed;
return this.status === CLOSED_STATUS || this.isAutomaticallyClosed;
}
get rankedChoiceDropdownContent() {
let rankedChoiceDropdownContent = [];
rankedChoiceDropdownContent.push({
id: 0,
name: I18n.t("poll.options.ranked_choice.abstain"),
});
this.poll.options.forEach((option, i) => {
option.rank = 0;
rankedChoiceDropdownContent.push({
id: i + 1,
name: (i + 1).toString(),
});
});
return rankedChoiceDropdownContent;
}
get isAutomaticallyClosed() {
const poll = this.poll;
return (
(poll.close ?? false) &&
moment.utc(poll.close, "YYYY-MM-DD HH:mm:ss Z") <= moment()
(this.poll.close ?? false) &&
moment.utc(this.poll.close, "YYYY-MM-DD HH:mm:ss Z") <= moment()
);
}
get hasVoted() {
return this.vote && this.vote.length > 0;
return this.vote?.length > 0;
}
get hideResultsDisabled() {
@ -257,10 +321,12 @@ export default class PollComponent extends Component {
if (this.closed) {
return;
}
if (!this.currentUser) {
// unlikely, handled by template logic
return;
}
if (!this.checkUserGroups(this.currentUser, this.poll)) {
return;
}
@ -287,12 +353,11 @@ export default class PollComponent extends Component {
@action
toggleResults() {
const showResults = !this.showResults;
this.showResults = showResults;
this.showResults = !this.showResults;
}
get canCastVotes() {
if (this.closed || this.showingResults || !this.currentUser) {
if (this.closed || !this.currentUser) {
return false;
}
@ -304,7 +369,7 @@ export default class PollComponent extends Component {
if (this.isRankedChoice) {
return (
this.options.length === this.vote.length &&
this.options.length === this.vote?.length &&
this.areRanksValid(this.vote)
);
}
@ -363,11 +428,7 @@ export default class PollComponent extends Component {
}
get isCheckbox() {
if (this.isMultiple) {
return true;
} else {
return false;
}
return this.isMultiple;
}
get resultsWidgetTypeClass() {
@ -393,26 +454,21 @@ export default class PollComponent extends Component {
@action
updatedVoters() {
this.preloadedVoters = this.args.preloadedVoters;
this.options = [...this.args.options];
if (this.isRankedChoice) {
this.options.forEach((candidate) => {
let specificVote = this.vote.find(
(vote) => vote.digest === candidate.id
);
let rank = specificVote ? specificVote.rank : 0;
candidate.rank = rank;
});
}
this.preloadedVoters = this.defaultPreloadedVoters();
}
@action
fetchVoters(optionId) {
let votersCount;
this.loading = true;
let options = this.options;
options.find((option) => option.id === optionId).loading = true;
this.options = [...options];
let preloadedVoters = this.preloadedVoters;
Object.keys(preloadedVoters).forEach((key) => {
if (key === optionId) {
preloadedVoters[key].loading = true;
}
});
this.preloadedVoters = Object.assign(preloadedVoters);
votersCount = this.options.find((option) => option.id === optionId).votes;
@ -427,7 +483,7 @@ export default class PollComponent extends Component {
})
.then((result) => {
const voters = optionId
? this.preloadedVoters[optionId]
? this.preloadedVoters[optionId].voters
: this.preloadedVoters;
const newVoters = optionId ? result.voters[optionId] : result.voters;
if (this.isRankedChoice) {
@ -444,16 +500,14 @@ export default class PollComponent extends Component {
if (this.poll.type === REGULAR) {
Object.keys(this.preloadedVoters).forEach((otherOptionId) => {
if (optionId !== otherOptionId) {
this.preloadedVoters[otherOptionId] = this.preloadedVoters[
otherOptionId
].filter((voter) => !votersSet.has(voter.username));
this.preloadedVoters[otherOptionId].voters =
this.preloadedVoters[otherOptionId].voters.filter(
(voter) => !votersSet.has(voter.username)
);
}
});
}
}
this.preloadedVoters[optionId] = [
...new Set([...this.preloadedVoters[optionId], ...newVoters]),
];
})
.catch((error) => {
if (error) {
@ -463,8 +517,9 @@ export default class PollComponent extends Component {
}
})
.finally(() => {
options.find((option) => option.id === optionId).loading = false;
this.options = [...options];
preloadedVoters = this.preloadedVoters;
preloadedVoters[optionId].loading = false;
this.preloadedVoters = Object.assign(preloadedVoters);
});
}
@ -488,13 +543,10 @@ export default class PollComponent extends Component {
option.rank = 0;
});
}
this.options = [...poll.options];
this.poll.setProperties(poll);
this.rankedChoiceOutcome = poll.ranked_choice_outcome || [];
this.vote = [];
this.voters = poll.voters;
this.vote = Object.assign([]);
this.hasSavedVote = false;
this.appEvents.trigger("poll:voted", poll, this.post, this.vote);
this.showResults = false;
})
.catch((error) => popupAjaxError(error));
}
@ -508,7 +560,7 @@ export default class PollComponent extends Component {
this.dialog.yesNoConfirm({
message: I18n.t(this.closed ? "poll.open.confirm" : "poll.close.confirm"),
didConfirm: () => {
const status = this.closed ? "open" : "closed";
const status = this.closed ? OPEN_STATUS : CLOSED_STATUS;
ajax("/polls/toggle_status", {
type: "PUT",
data: {
@ -518,13 +570,13 @@ export default class PollComponent extends Component {
},
})
.then(() => {
this.poll.status = status;
this.status = status;
this.poll.set("status", status);
if (
this.poll.results === "on_close" ||
this.poll.results === ON_CLOSE ||
this.poll.results === "always"
) {
this.showResults = this.status === "closed";
this.showResults = this.status === CLOSED_STATUS;
}
})
.catch((error) => {

View File

@ -25,11 +25,10 @@ export default createWidget("discourse-poll", {
new RenderGlimmer(
this,
"div.poll",
hbs`<Poll @attrs={{@data.attrs}} @preloadedVoters={{@data.preloadedVoters}} @options={{@data.options}} />`,
hbs`<Poll @attrs={{@data.attrs}} @preloadedVoters={{@data.preloadedVoters}} />`,
{
attrs,
preloadedVoters: attrs.poll.preloaded_voters,
options: attrs.poll.options,
}
),
];

View File

@ -46,6 +46,46 @@ module("Poll | Component | poll", function (hooks) {
});
});
test("shows vote", async function (assert) {
this.setProperties({
attributes: EmberObject.create({
post: EmberObject.create({
id: 42,
topic: {
archived: false,
},
user_id: 29,
}),
poll: EmberObject.create({
name: "poll",
type: "regular",
status: "closed",
results: "always",
options: [
{ id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 1 },
{ id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 },
],
voters: 1,
chart_type: "bar",
}),
vote: [],
groupableUserFields: [],
}),
preloadedVoters: [],
});
await render(
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} />`
);
assert.deepEqual(
Array.from(queryAll(".results li .option p")).map(
(span) => span.innerText
),
["100% yes", "0% no"]
);
});
test("can vote", async function (assert) {
this.setProperties({
attributes: EmberObject.create({
@ -72,14 +112,10 @@ module("Poll | Component | poll", function (hooks) {
groupableUserFields: [],
}),
preloadedVoters: [],
options: [
{ id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 },
{ id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 },
],
});
await render(
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} @options={{this.options}} />`
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} />`
);
requests = 0;
@ -89,10 +125,6 @@ module("Poll | Component | poll", function (hooks) {
);
assert.strictEqual(requests, 1);
assert.strictEqual(count(".chosen"), 1);
assert.deepEqual(
Array.from(queryAll(".chosen span")).map((span) => span.innerText),
["100%", "yes"]
);
await click(".toggle-results");
assert.strictEqual(
@ -128,14 +160,10 @@ module("Poll | Component | poll", function (hooks) {
groupableUserFields: [],
}),
preloadedVoters: [],
options: [
{ id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 },
{ id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 },
],
});
await render(
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} @options={{this.options}} />`
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} />`
);
requests = 0;
@ -178,13 +206,9 @@ module("Poll | Component | poll", function (hooks) {
groupableUserFields: [],
}),
preloadedVoters: [],
options: [
{ id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 },
{ id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 },
],
});
await render(
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} @options={{this.options}} />`
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} />`
);
assert.ok(exists(".poll-buttons .cast-votes:disabled"));