DEV: Reimplement widget UI as glimmer components (#243)

This aims to be a 1:1 port of functionality and HTML structure. In future, this plugin may benefit from a refactor to use float-kit.

Also unskips the system spec and makes improvements to prevent flakiness.
This commit is contained in:
David Taylor 2025-06-10 16:47:12 +01:00 committed by GitHub
parent 983027da53
commit d9af83fd7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 395 additions and 388 deletions

View File

@ -0,0 +1,125 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import routeAction from "discourse/helpers/route-action";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import closeOnClickOutside from "discourse/modifiers/close-on-click-outside";
import { i18n } from "discourse-i18n";
import VoteButton from "./vote-button";
import VoteCount from "./vote-count";
import VoteOptions from "./vote-options";
export default class VoteBox extends Component {
@service siteSettings;
@service currentUser;
@tracked votesAlert;
@tracked allowClick = true;
@tracked initialVote = false;
@tracked showOptions = false;
@action
addVote() {
let topic = this.args.topic;
return ajax("/voting/vote", {
type: "POST",
data: {
topic_id: topic.id,
},
})
.then((result) => {
topic.vote_count = result.vote_count;
topic.user_voted = true;
this.currentUser.votes_exceeded = !result.can_vote;
this.currentUser.votes_left = result.votes_left;
if (result.alert) {
this.votesAlert = result.votes_left;
}
this.allowClick = true;
this.showOptions = false;
})
.catch(popupAjaxError);
}
@action
removeVote() {
const topic = this.args.topic;
return ajax("/voting/unvote", {
type: "POST",
data: {
topic_id: topic.id,
},
})
.then((result) => {
topic.vote_count = result.vote_count;
topic.user_voted = false;
this.currentUser.votes_exceeded = !result.can_vote;
this.currentUser.votes_left = result.votes_left;
this.allowClick = true;
this.showOptions = false;
})
.catch(popupAjaxError);
}
@action
showVoteOptions() {
this.showOptions = true;
}
@action
closeVoteOptions() {
this.showOptions = false;
}
@action
closeVotesAlert() {
this.votesAlert = null;
}
<template>
<div
class={{concatClass
"voting-wrapper"
(if this.siteSettings.topic_voting_show_who_voted "show-pointer")
}}
>
<VoteCount @topic={{@topic}} @showLogin={{routeAction "showLogin"}} />
<VoteButton
@topic={{@topic}}
@allowClick={{this.allowClick}}
@showVoteOptions={{this.showVoteOptions}}
@addVote={{this.addVote}}
@showLogin={{routeAction "showLogin"}}
/>
{{#if this.showOptions}}
<VoteOptions
@topic={{@topic}}
@removeVote={{this.removeVote}}
{{closeOnClickOutside this.closeVoteOptions (hash)}}
/>
{{/if}}
{{#if this.votesAlert}}
<div
class="voting-popup-menu vote-options popup-menu"
{{closeOnClickOutside this.closeVotesAlert (hash)}}
>
{{htmlSafe
(i18n
"topic_voting.votes_left"
count=this.votesAlert
path="/my/activity/votes"
)
}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,104 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import cookie from "discourse/lib/cookie";
import { applyBehaviorTransformer } from "discourse/lib/transformer";
import { i18n } from "discourse-i18n";
export default class VoteBox extends Component {
@service siteSettings;
@service currentUser;
get wrapperClasses() {
const classes = [];
const { topic } = this.args;
if (topic.closed) {
classes.push("voting-closed");
} else {
if (!topic.user_voted) {
classes.push("nonvote");
} else {
if (this.currentUser && this.currentUser.votes_exceeded) {
classes.push("vote-limited nonvote");
} else {
classes.push("vote");
}
}
}
if (this.siteSettings.topic_voting_show_who_voted) {
classes.push("show-pointer");
}
return classes.join(" ");
}
get buttonContent() {
const { topic } = this.args;
if (this.currentUser) {
if (topic.closed) {
return i18n("topic_voting.voting_closed_title");
}
if (topic.user_voted) {
return i18n("topic_voting.voted_title");
}
if (this.currentUser.votes_exceeded) {
return i18n("topic_voting.voting_limit");
}
return i18n("topic_voting.vote_title");
}
if (topic.vote_count) {
return i18n("topic_voting.anonymous_button", {
count: topic.vote_count,
});
}
return i18n("topic_voting.anonymous_button", { count: 1 });
}
@action
click() {
applyBehaviorTransformer("topic-vote-button-click", () => {
if (!this.currentUser) {
cookie("destination_url", window.location.href, { path: "/" });
this.args.showLogin();
return;
}
const { topic } = this.args;
if (
!topic.closed &&
!topic.user_voted &&
!this.currentUser.votes_exceeded
) {
this.args.addVote();
}
if (topic.user_voted || this.currentUser.votes_exceeded) {
this.args.showVoteOptions();
}
});
}
<template>
<div class={{this.wrapperClasses}}>
<DButton
@translatedTitle={{if
this.currentUser
(i18n
"topic_voting.votes_left_button_title"
count=this.currentUser.votes_left
)
""
}}
@translatedLabel={{this.buttonContent}}
class="btn-primary vote-button"
@action={{this.click}}
/>
</div>
</template>
}

View File

@ -0,0 +1,95 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import AsyncContent from "discourse/components/async-content";
import SmallUserList from "discourse/components/small-user-list";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import cookie from "discourse/lib/cookie";
import { bind } from "discourse/lib/decorators";
import getURL from "discourse/lib/get-url";
import closeOnClickOutside from "discourse/modifiers/close-on-click-outside";
export default class VoteBox extends Component {
@service siteSettings;
@service currentUser;
@tracked showWhoVoted = false;
@bind
async loadWhoVoted() {
return ajax("/voting/who", {
type: "GET",
data: {
topic_id: this.args.topic.id,
},
}).then((users) =>
users.map((user) => {
return {
template: user.avatar_template,
username: user.username,
post_url: user.post_url,
url: getURL("/u/") + user.username.toLowerCase(),
};
})
);
}
@action
click(event) {
event.preventDefault();
event.stopPropagation();
if (!this.currentUser) {
cookie("destination_url", window.location.href, { path: "/" });
this.args.showLogin();
return;
}
if (this.showWhoVoted) {
this.showWhoVoted = false;
} else if (this.siteSettings.topic_voting_show_who_voted) {
this.showWhoVoted = true;
}
}
@action
clickOutside() {
this.showWhoVoted = false;
}
<template>
<div
class={{concatClass
"vote-count-wrapper"
(if (eq @topic.vote_count 0) "no-votes")
}}
{{on "click" this.click}}
role="button"
>
<div class="vote-count">
{{@topic.vote_count}}
</div>
</div>
{{#if this.showWhoVoted}}
<div
class="who-voted popup-menu voting-popup-menu"
{{closeOnClickOutside
this.clickOutside
(hash secondaryTargetSelector=".vote-count-wrapper")
}}
>
<AsyncContent @asyncData={{this.loadWhoVoted}}>
<:content as |voters|>
<SmallUserList @users={{voters}} class="regular-votes" />
</:content>
</AsyncContent>
</div>
{{/if}}
</template>
}

View File

@ -0,0 +1,29 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { service } from "@ember/service";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
export default class VoteBox extends Component {
@service currentUser;
<template>
<div class="vote-options voting-popup-menu popup-menu" ...attributes>
{{#if @topic.user_voted}}
<div
role="button"
class="remove-vote vote-option"
{{on "click" @removeVote}}
>
{{icon "xmark"}}
{{i18n "topic_voting.remove_vote"}}
</div>
{{else if this.currentUser.votes_exceeded}}
<div>{{i18n "topic_voting.reached_limit"}}</div>
<p>
<a href="/my/activity/votes">{{i18n "topic_voting.list_votes"}}</a>
</p>
{{/if}}
</div>
</template>
}

View File

@ -1,7 +1,7 @@
import Component from "@ember/component";
import { classNames, tagName } from "@ember-decorators/component";
import MountWidget from "discourse/components/mount-widget";
import routeAction from "discourse/helpers/route-action";
import VoteBox from "../../components/vote-box";
@tagName("div")
@classNames("topic-above-post-stream-outlet", "topic-title-voting")
@ -11,10 +11,8 @@ export default class TopicTitleVoting extends Component {
{{#if this.model.postStream.loaded}}
{{#if this.model.postStream.firstPostPresent}}
<div class="voting title-voting">
{{! template-lint-disable no-capital-arguments }}
<MountWidget
@widget="vote-box"
@args={{this.model}}
<VoteBox
@topic={{this.model}}
@showLogin={{routeAction "showLogin"}}
/>
</div>

View File

@ -1,18 +1,21 @@
import Component from "@ember/component";
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { classNames, tagName } from "@ember-decorators/component";
import { service } from "@ember/service";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
@tagName("")
@classNames("user-activity-bottom-outlet", "user-voted-topics")
export default class UserVotedTopics extends Component {
@service siteSettings;
<template>
{{#if this.siteSettings.topic_voting_show_votes_on_profile}}
<LinkTo @route="userActivity.votes">
{{icon "heart"}}
{{i18n "topic_voting.vote_title_plural"}}
</LinkTo>
<li class="user-nav__activity-votes">
<LinkTo @route="userActivity.votes">
{{icon "heart"}}
{{i18n "topic_voting.vote_title_plural"}}
</LinkTo>
</li>
{{/if}}
</template>
}

View File

@ -6,7 +6,7 @@ export default {
name: "discourse-topic-voting",
initialize() {
withPluginApi("0.8.32", (api) => {
withPluginApi((api) => {
const siteSettings = api.container.lookup("service:site-settings");
if (siteSettings.topic_voting_enabled) {
const pageSearchController = api.container.lookup(
@ -57,16 +57,11 @@ export default {
},
});
}
});
withPluginApi("0.11.7", (api) => {
const siteSettings = api.container.lookup("service:site-settings");
if (siteSettings.topic_voting_enabled) {
api.addSearchSuggestion("order:votes");
}
});
withPluginApi("2.1.0", (api) => {
api.registerValueTransformer(
"category-available-views",
({ value, context }) => {

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import { withPluginApi } from "discourse/lib/plugin-api";
import { i18n } from "discourse-i18n";
@ -35,6 +36,23 @@ function initialize(api) {
},
{ priority: -100 }
);
api.modifyClass(
"model:topic",
(Superclass) =>
class extends Superclass {
@tracked vote_count;
@tracked user_voted;
}
);
api.modifyClass(
"model:user",
(Superclass) =>
class extends Superclass {
@tracked votes_exceeded;
@tracked votes_left;
}
);
}
export default {

View File

@ -1,19 +0,0 @@
import { iconNode } from "discourse/lib/icon-library";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
export default createWidget("remove-vote", {
tagName: "div.remove-vote",
buildClasses() {
return "vote-option";
},
html() {
return [iconNode("xmark"), i18n("topic_voting.remove_vote")];
},
click() {
this.sendWidgetAction("removeVote");
},
});

View File

@ -1,104 +0,0 @@
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import RawHtml from "discourse/widgets/raw-html";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
export default createWidget("vote-box", {
tagName: "div.voting-wrapper",
buildKey: () => "vote-box",
buildClasses() {
if (this.siteSettings.topic_voting_show_who_voted) {
return "show-pointer";
}
},
defaultState() {
return { allowClick: true, initialVote: false };
},
html(attrs, state) {
let voteCount = this.attach("vote-count", attrs);
let voteButton = this.attach("vote-button", attrs);
let voteOptions = this.attach("vote-options", attrs);
let contents = [voteCount, voteButton, voteOptions];
if (state.votesAlert > 0) {
const html =
"<div class='voting-popup-menu vote-options popup-menu'>" +
i18n("topic_voting.votes_left", {
count: state.votesAlert,
path: this.currentUser.get("path") + "/activity/votes",
}) +
"</div>";
contents.push(new RawHtml({ html }));
}
return contents;
},
hideVotesAlert() {
if (this.state.votesAlert) {
this.state.votesAlert = null;
this.scheduleRerender();
}
},
click() {
this.hideVotesAlert();
},
clickOutside() {
this.hideVotesAlert();
},
addVote() {
let topic = this.attrs;
let state = this.state;
return ajax("/voting/vote", {
type: "POST",
data: {
topic_id: topic.id,
},
})
.then((result) => {
topic.set("vote_count", result.vote_count);
topic.set("user_voted", true);
this.currentUser.setProperties({
votes_exceeded: !result.can_vote,
votes_left: result.votes_left,
});
if (result.alert) {
state.votesAlert = result.votes_left;
}
topic.set("who_voted", result.who_voted);
state.allowClick = true;
this.scheduleRerender();
})
.catch(popupAjaxError);
},
removeVote() {
let topic = this.attrs;
let state = this.state;
return ajax("/voting/unvote", {
type: "POST",
data: {
topic_id: topic.id,
},
})
.then((result) => {
topic.set("vote_count", result.vote_count);
topic.set("user_voted", false);
this.currentUser.setProperties({
votes_exceeded: !result.can_vote,
votes_left: result.votes_left,
});
topic.set("who_voted", result.who_voted);
state.allowClick = true;
this.scheduleRerender();
})
.catch(popupAjaxError);
},
});

View File

@ -1,101 +0,0 @@
import { h } from "virtual-dom";
import cookie from "discourse/lib/cookie";
import { applyBehaviorTransformer } from "discourse/lib/transformer";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
export default createWidget("vote-button", {
tagName: "div",
buildClasses(attrs) {
let buttonClass = "";
if (attrs.closed) {
buttonClass = "voting-closed";
} else {
if (!attrs.user_voted) {
buttonClass = "nonvote";
} else {
if (this.currentUser && this.currentUser.votes_exceeded) {
buttonClass = "vote-limited nonvote";
} else {
buttonClass = "vote";
}
}
}
if (this.siteSettings.topic_voting_show_who_voted) {
buttonClass += " show-pointer";
}
return buttonClass;
},
buildButtonTitle(attrs) {
if (this.currentUser) {
if (attrs.closed) {
return i18n("topic_voting.voting_closed_title");
}
if (attrs.user_voted) {
return i18n("topic_voting.voted_title");
}
if (this.currentUser.votes_exceeded) {
return i18n("topic_voting.voting_limit");
}
return i18n("topic_voting.vote_title");
}
if (attrs.vote_count) {
return i18n("topic_voting.anonymous_button", {
count: attrs.vote_count,
});
}
return i18n("topic_voting.anonymous_button", { count: 1 });
},
html(attrs) {
return h(
"button",
{
attributes: {
title: this.currentUser
? i18n("topic_voting.votes_left_button_title", {
count: this.currentUser.votes_left,
})
: "",
},
className: "btn btn-primary vote-button",
},
this.buildButtonTitle(attrs)
);
},
click() {
applyBehaviorTransformer("topic-vote-button-click", () => {
if (!this.currentUser) {
this.sendWidgetAction("showLogin");
cookie("destination_url", window.location.href, { path: "/" });
return;
}
if (
!this.attrs.closed &&
this.parentWidget.state.allowClick &&
!this.attrs.user_voted &&
!this.currentUser.votes_exceeded
) {
this.parentWidget.state.allowClick = false;
this.parentWidget.state.initialVote = true;
this.sendWidgetAction("addVote");
}
if (this.attrs.user_voted || this.currentUser.votes_exceeded) {
document.querySelector(".vote-options").classList.toggle("hidden");
}
});
},
clickOutside() {
document.querySelector(".vote-options").classList.add("hidden");
this.parentWidget.state.initialVote = false;
},
});

View File

@ -1,90 +0,0 @@
import { h } from "virtual-dom";
import { ajax } from "discourse/lib/ajax";
import cookie from "discourse/lib/cookie";
import getURL from "discourse/lib/get-url";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("vote-count", {
tagName: "div.vote-count-wrapper",
buildKey: () => "vote-count",
buildClasses() {
if (this.attrs.vote_count === 0) {
return "no-votes";
}
},
defaultState() {
return { whoVotedUsers: null };
},
html(attrs) {
let voteCount = h("div.vote-count", attrs.vote_count.toString());
let whoVoted = null;
if (
this.siteSettings.topic_voting_show_who_voted &&
this.state.whoVotedUsers &&
this.state.whoVotedUsers.length > 0
) {
whoVoted = this.attach("small-user-list", {
users: this.state.whoVotedUsers,
addSelf: attrs.liked,
listClassName: "regular-votes",
});
}
let buffer = [voteCount];
if (whoVoted) {
buffer.push(h("div.who-voted.popup-menu.voting-popup-menu", [whoVoted]));
}
return buffer;
},
click() {
if (!this.currentUser) {
this.sendWidgetAction("showLogin");
cookie("destination_url", window.location.href, { path: "/" });
return;
}
if (
this.siteSettings.topic_voting_show_who_voted &&
this.attrs.vote_count > 0
) {
if (this.state.whoVotedUsers === null) {
return this.getWhoVoted();
} else {
const whoVotedElement = document.querySelector(".who-voted");
whoVotedElement.style.display =
whoVotedElement.style.display === "none" ? "block" : "none";
}
}
},
clickOutside() {
const whoVotedElement = document.querySelector(".who-voted");
if (whoVotedElement) {
whoVotedElement.style.display = "none";
}
},
getWhoVoted() {
return ajax("/voting/who", {
type: "GET",
data: {
topic_id: this.attrs.id,
},
}).then((users) => {
this.state.whoVotedUsers = users.map(whoVotedAvatars);
});
},
});
function whoVotedAvatars(user) {
return {
template: user.avatar_template,
username: user.username,
post_url: user.post_url,
url: getURL("/u/") + user.username.toLowerCase(),
};
}

View File

@ -1,36 +0,0 @@
import { h } from "virtual-dom";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
export default createWidget("vote-options", {
tagName: "div.vote-options",
buildClasses() {
return "voting-popup-menu popup-menu hidden";
},
html(attrs) {
let contents = [];
if (attrs.user_voted) {
contents.push(this.attach("remove-vote", attrs));
} else if (
this.currentUser &&
this.currentUser.votes_exceeded &&
!attrs.user_voted
) {
contents.push([
h("div", i18n("topic_voting.reached_limit")),
h(
"p",
h(
"a",
{ href: this.currentUser.get("path") + "/activity/votes" },
i18n("topic_voting.list_votes")
)
),
]);
}
return contents;
},
});

View File

@ -16,31 +16,23 @@ RSpec.describe "Topic voting", type: :system, js: true do
fab!(:admin_page) { PageObjects::Pages::AdminSiteSettings.new }
before do
SiteSetting.topic_voting_enabled = false
admin.activate
user.activate
SiteSetting.topic_voting_enabled = true
sign_in(admin)
end
skip "enables voting in category topics and votes" do
it "enables voting in category topics and votes" do
category_page.visit(category1)
expect(category_page).to have_no_css(category_page.votes)
# enables voting
admin_page.visit_filtered_plugin_setting("topic%20voting%20enabled").toggle_setting(
"topic_voting_enabled",
"Allow users to vote on topics?",
)
# enable voting in category
category_page
.visit_settings(category1)
.toggle_setting("enable-topic-voting", "Allow users to vote on topics in this category")
.save_settings
.back_to_category
# voting
try_until_success { expect(Category.can_vote?(category1.id)).to eq(true) }
# make a vote
category_page.visit(category1)
expect(category_page).to have_css(category_page.votes)
expect(category_page).to have_css(category_page.topic_with_vote_count(0), count: 2)
@ -50,14 +42,12 @@ RSpec.describe "Topic voting", type: :system, js: true do
topic_page.vote
expect(topic_page.vote_popup).to have_text("You have 9 votes left, see your votes")
expect(topic_page.vote_count).to have_text("1")
topic_page.click_vote_popup_activity
# visit user activity page
topic_page.click_vote_popup_activity
expect(user_page.active_user_primary_navigation).to have_text("Activity")
expect(user_page.active_user_secondary_navigation).to have_text("Votes")
expect(page).to have_css(".topic-list-body tr[data-topic-id=\"#{topic1.id}\"]")
expect(find(".topic-list-body tr[data-topic-id=\"#{topic1.id}\"] a.voted")).to have_text(
"1 vote",
)
expect(page).to have_css(".topic-list-body tr[data-topic-id=\"#{topic1.id}\"]", text: "1 vote")
find(".topic-list-body tr[data-topic-id=\"#{topic1.id}\"] a.raw-link").click
# unvoting