DEV: convert PrivateMessageMap widget to glimmer components (#25837)

* DEV: add map system test for private message map
* DEV: convert PrivateMessageMap to glimmer components
This commit is contained in:
Kelv 2024-03-04 10:24:25 +08:00 committed by GitHub
parent 13230ae4c2
commit 1a76c4e099
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 501 additions and 204 deletions

View File

@ -0,0 +1,215 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import avatar from "discourse/helpers/bound-avatar-template";
import { groupPath } from "discourse/lib/url";
import dIcon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
import and from "truth-helpers/helpers/and";
export default class PrivateMessageMap extends Component {
@service site;
@tracked isEditing = false;
get participantsClasses() {
return !this.isEditing &&
this.site.mobileView &&
this.args.postAttrs.allowedGroups.length > 4
? "participants hide-names"
: "participants";
}
get canInvite() {
return this.args.postAttrs.canInvite;
}
get canRemove() {
return (
this.args.postAttrs.canRemoveAllowedUsers ||
this.args.postAttrs.canRemoveSelfId
);
}
get canShowControls() {
return this.canInvite || this.canRemove;
}
get actionAllowed() {
return this.canRemove ? this.toggleEditing : this.args.showInvite;
}
get actionAllowedLabel() {
if (this.canInvite && this.canRemove) {
return "private_message_info.edit";
}
if (!this.canInvite && this.canRemove) {
return "private_message_info.remove";
}
return "private_message_info.add";
}
@action
toggleEditing() {
this.isEditing = !this.isEditing;
}
<template>
<div class={{this.participantsClasses}}>
{{#each @postAttrs.allowedGroups as |group|}}
<PmMapUserGroup
@model={{group}}
@isEditing={{this.isEditing}}
@canRemoveAllowedUsers={{@postAttrs.canRemoveAllowedUsers}}
@removeAllowedGroup={{@removeAllowedGroup}}
/>
{{/each}}
{{#each @postAttrs.allowedUsers as |user|}}
<PmMapUser
@model={{user}}
@isEditing={{this.isEditing}}
@canRemoveAllowedUsers={{@postAttrs.canRemoveAllowedUsers}}
@canRemoveSelfId={{@postAttrs.canRemoveSelfId}}
@removeAllowedUser={{@removeAllowedUser}}
/>
{{/each}}
</div>
{{#if this.canShowControls}}
<div class="controls">
<DButton
@action={{this.actionAllowed}}
@label={{this.actionAllowedLabel}}
class="btn-default add-remove-participant-btn"
/>
{{#if (and this.canInvite this.isEditing)}}
<DButton
@action={{@showInvite}}
@icon="plus"
class="btn-default add-participant-btn"
/>
{{/if}}
</div>
{{/if}}
</template>
}
class PmMapUserGroup extends Component {
get canRemoveLink() {
return this.args.isEditing && this.args.canRemoveAllowedUsers;
}
get groupUrl() {
return groupPath(this.args.model.name);
}
<template>
<div class="user group">
<a href={{this.groupUrl}} class="group-link">
{{dIcon "users"}}
<span class="group-name">{{@model.name}}</span>
</a>
{{#if this.canRemoveLink}}
<PmRemoveGroupLink
@model={{@model}}
@removeAllowedGroup={{@removeAllowedGroup}}
/>
{{/if}}
</div>
</template>
}
class PmRemoveGroupLink extends Component {
@service dialog;
@action
showConfirmDialog() {
this.dialog.deleteConfirm({
message: I18n.t("private_message_info.remove_allowed_group", {
name: this.args.model.name,
}),
confirmButtonLabel: "private_message_info.remove_group",
didConfirm: () => this.args.removeAllowedGroup(this.args.model),
});
}
<template>
<DButton
class="remove-invited"
@action={{this.showConfirmDialog}}
@icon="times"
/>
</template>
}
class PmMapUser extends Component {
get avatarTitle() {
return this.args.model.name || this.args.model.username;
}
get isCurrentUser() {
return this.args.canRemoveSelfId === this.args.model.id;
}
get canRemoveLink() {
return (
this.args.isEditing &&
(this.args.canRemoveAllowedUsers || this.isCurrentUser)
);
}
<template>
<div class="user">
<a class="user-link" href={{@model.path}}>
<a
class="trigger-user-card"
data-user-card={{@model.username}}
title={{@model.username}}
aria-hidden="true"
>
{{avatar @model.avatar_template "tiny" (hash title=this.avatarTitle)}}
</a>
<span class="username">{{@model.username}}</span>
</a>
{{#if this.canRemoveLink}}
<PmRemoveLink
@model={{@model}}
@isCurrentUser={{this.isCurrentUser}}
@removeAllowedUser={{@removeAllowedUser}}
/>
{{/if}}
</div>
</template>
}
class PmRemoveLink extends Component {
@service dialog;
@action
showConfirmDialog() {
const messageKey = this.args.isCurrentUser
? "private_message_info.leave_message"
: "private_message_info.remove_allowed_user";
this.dialog.deleteConfirm({
message: I18n.t(messageKey, {
name: this.args.model.username,
}),
confirmButtonLabel: this.args.isCurrentUser
? "private_message_info.leave"
: "private_message_info.remove_user",
didConfirm: () => this.args.removeAllowedUser(this.args.model),
});
}
<template>
<DButton
class="remove-invited"
@action={{this.showConfirmDialog}}
@icon="times"
/>
</template>
}

View File

@ -1,203 +0,0 @@
import { h } from "virtual-dom";
import hbs from "discourse/widgets/hbs-compiler";
import { avatarFor, avatarImg } from "discourse/widgets/post";
import { createWidget } from "discourse/widgets/widget";
import getURL from "discourse-common/lib/get-url";
import { makeArray } from "discourse-common/lib/helpers";
import I18n from "discourse-i18n";
createWidget("pm-remove-group-link", {
tagName: "a.remove-invited.no-text.btn-icon.btn",
template: hbs`{{d-icon "times"}}`,
services: ["dialog"],
click() {
this.dialog.deleteConfirm({
message: I18n.t("private_message_info.remove_allowed_group", {
name: this.attrs.name,
}),
confirmButtonLabel: "private_message_info.remove_group",
didConfirm: () => this.sendWidgetAction("removeAllowedGroup", this.attrs),
});
},
});
createWidget("pm-map-user-group", {
tagName: "div.user.group",
transform(attrs) {
return { href: getURL(`/g/${attrs.group.name}`) };
},
template: hbs`
<a href={{transformed.href}} class="group-link">
{{d-icon "users"}}
<span class="group-name">{{attrs.group.name}}</span>
</a>
{{#if attrs.isEditing}}
{{#if attrs.canRemoveAllowedUsers}}
{{pm-remove-group-link attrs=attrs.group}}
{{/if}}
{{/if}}
`,
});
createWidget("pm-remove-link", {
tagName: "a.remove-invited.no-text.btn-icon.btn",
template: hbs`{{d-icon "times"}}`,
services: ["dialog"],
click() {
const messageKey = this.attrs.isCurrentUser
? "leave_message"
: "remove_allowed_user";
this.dialog.deleteConfirm({
message: I18n.t(`private_message_info.${messageKey}`, {
name: this.attrs.user.username,
}),
confirmButtonLabel: this.attrs.isCurrentUser
? "private_message_info.leave"
: "private_message_info.remove_user",
didConfirm: () =>
this.sendWidgetAction("removeAllowedUser", this.attrs.user),
});
},
});
createWidget("pm-map-user", {
tagName: "div.user",
html(attrs) {
const user = attrs.user;
const username = h("span.username", user.username);
let link;
if (this.site && this.site.mobileView) {
const avatar = avatarImg("tiny", {
template: user.avatar_template,
username: user.username,
});
link = h("a", { attributes: { href: user.get("path") } }, [
avatar,
username,
]);
} else {
const avatar = avatarFor("tiny", {
template: user.avatar_template,
username: user.username,
});
link = h(
"a",
{ attributes: { class: "user-link", href: user.get("path") } },
[avatar, username]
);
}
const result = [link];
const isCurrentUser = attrs.canRemoveSelfId === user.get("id");
if (attrs.isEditing && (attrs.canRemoveAllowedUsers || isCurrentUser)) {
result.push(this.attach("pm-remove-link", { user, isCurrentUser }));
}
return result;
},
});
export default createWidget("private-message-map", {
tagName: "section.information.private-message-map",
buildKey: (attrs) => `private-message-map-${attrs.id}`,
defaultState() {
return { isEditing: false };
},
html(attrs) {
const participants = [];
if (attrs.allowedGroups.length) {
participants.push(
attrs.allowedGroups.map((group) => {
return this.attach("pm-map-user-group", {
group,
canRemoveAllowedUsers: attrs.canRemoveAllowedUsers,
isEditing: this.state.isEditing,
});
})
);
}
if (attrs.allowedUsers.length) {
participants.push(
attrs.allowedUsers.map((au) => {
return this.attach("pm-map-user", {
user: au,
canRemoveAllowedUsers: attrs.canRemoveAllowedUsers,
canRemoveSelfId: attrs.canRemoveSelfId,
isEditing: this.state.isEditing,
});
})
);
}
let hideNamesClass = "";
if (
!this.state.isEditing &&
this.site.mobileView &&
makeArray(participants[0]).length > 4
) {
hideNamesClass = ".hide-names";
}
const result = [h(`div.participants${hideNamesClass}`, participants)];
const controls = [];
const canRemove = attrs.canRemoveAllowedUsers || attrs.canRemoveSelfId;
if (attrs.canInvite || canRemove) {
let key;
let action = "toggleEditing";
if (attrs.canInvite && canRemove) {
key = "edit";
} else if (!attrs.canInvite && canRemove) {
key = "remove";
} else {
key = "add";
action = "showInvite";
}
controls.push(
this.attach("button", {
action,
label: `private_message_info.${key}`,
className: "btn btn-default add-remove-participant-btn",
})
);
}
if (attrs.canInvite && this.state.isEditing) {
controls.push(
this.attach("button", {
action: "showInvite",
icon: "plus",
className: "btn btn-default no-text btn-icon add-participant-btn",
})
);
}
if (controls.length) {
result.push(h("div.controls", controls));
}
return result;
},
toggleEditing() {
this.state.isEditing = !this.state.isEditing;
},
});

View File

@ -171,7 +171,7 @@ export default createWidget("topic-map", {
}
if (attrs.showPMMap) {
contents.push(this.attach("private-message-map", attrs));
contents.push(this.buildPrivateMessageMap(attrs));
}
return contents;
},
@ -219,4 +219,25 @@ export default createWidget("topic-map", {
}
);
},
buildPrivateMessageMap(attrs) {
return new RenderGlimmer(
this,
"section.information.private-message-map",
hbs`<TopicMap::PrivateMessageMap
@postAttrs={{@data.postAttrs}}
@showInvite={{@data.showInvite}}
@removeAllowedGroup={{@data.removeAllowedGroup}}
@removeAllowedUser={{@data.removeAllowedUser}}
/>`,
{
postAttrs: attrs,
showInvite: () => this.sendWidgetAction("showInvite"),
removeAllowedGroup: (group) =>
this.sendWidgetAction("removeAllowedGroup", group),
removeAllowedUser: (user) =>
this.sendWidgetAction("removeAllowedUser", user),
}
);
},
});

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
module PageObjects
module Components
class PrivateMessageMap < PageObjects::Components::Base
PRIVATE_MESSAGE_MAP_KLASS = ".private-message-map"
def is_visible?
has_css?(PRIVATE_MESSAGE_MAP_KLASS)
end
def participants_details
find("#{PRIVATE_MESSAGE_MAP_KLASS} .participants").all(".user")
end
def participants_count
participants_details.length
end
def controls
find("#{PRIVATE_MESSAGE_MAP_KLASS} .controls")
end
def toggle_edit_participants_button
controls.click_button(class: "add-remove-participant-btn")
end
def has_add_participants_button?
controls.has_button?(class: "add-participant-btn")
end
def has_no_add_participants_button?
controls.has_no_button?(class: "add-participant-btn")
end
def click_add_participants_button
controls.click_button(class: "add-participant-btn")
end
def click_remove_participant_button(user)
find_link(user.username).sibling(".remove-invited").click
end
def has_participant_details_for?(user)
find("#{PRIVATE_MESSAGE_MAP_KLASS} .participants").has_link?(
class: "user-link",
href: "/u/#{user.username}",
)
end
def has_no_participant_details_for?(user)
find("#{PRIVATE_MESSAGE_MAP_KLASS} .participants").has_no_link?(
class: "user-link",
href: "/u/#{user.username}",
)
end
end
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
module PageObjects
module Modals
class PrivateMessageInvite < PageObjects::Modals::Base
MODAL_SELECTOR = ".add-pm-participants"
BODY_SELECTOR = ".invite.modal-panel"
def select_invitee(user)
select_kit = PageObjects::Components::SelectKit.new(".invite-user-input")
select_kit.expand
select_kit.search(user.username)
select_kit.select_row_by_value(user.username)
end
def has_invitee_already_exists_error?
body.find(".alert-error").has_text?(I18n.t("topic_invite.user_exists"))
end
def click_primary_button
body.find(".btn-primary").click
end
def has_successful_invite_message?
has_content?(I18n.t("js.topic.invite_private.success"))
end
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module PageObjects
module Modals
class PrivateMessageRemoveParticipant < PageObjects::Components::Base
def open?
has_css?("#dialog-holder .dialog-content")
end
def closed?
has_no_css?("#dialog-holder .dialog-content")
end
def body
find("#dialog-holder .dialog-content .dialog-body")
end
def confirm_removal
find("#dialog-holder .dialog-content .dialog-footer .btn-danger").click
end
def cancel
find("#dialog-holder .dialog-content .dialog-footer .btn-default").click
end
end
end
end

View File

@ -7,6 +7,7 @@ module PageObjects
@composer_component = PageObjects::Components::Composer.new
@fast_edit_component = PageObjects::Components::FastEditor.new
@topic_map_component = PageObjects::Components::TopicMap.new
@private_message_map_component = PageObjects::Components::PrivateMessageMap.new
end
def visit_topic(topic, post_number: nil)
@ -205,6 +206,10 @@ module PageObjects
@topic_map_component.is_not_visible?
end
def has_private_message_map?
@private_message_map_component.is_visible?
end
private
def topic_footer_button_id(button)

View File

@ -0,0 +1,146 @@
# frozen_string_literal: true
describe "Topic Map - Private Message", type: :system do
fab!(:user) { Fabricate(:admin, refresh_auto_groups: true) }
fab!(:other_user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:last_post_user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:topic) do
Fabricate(
:private_message_topic,
created_at: 1.day.ago,
user: user,
topic_allowed_users: [
Fabricate.build(:topic_allowed_user, user: user),
Fabricate.build(:topic_allowed_user, user: other_user),
Fabricate.build(:topic_allowed_user, user: last_post_user),
],
)
end
fab!(:original_post) { Fabricate(:post, topic: topic, user: user, created_at: 1.day.ago) }
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:topic_map) { PageObjects::Components::TopicMap.new }
let(:private_message_map) { PageObjects::Components::PrivateMessageMap.new }
let(:private_message_invite_modal) { PageObjects::Modals::PrivateMessageInvite.new }
let(:private_message_remove_participant_modal) do
PageObjects::Modals::PrivateMessageRemoveParticipant.new
end
def avatar_url(user, size)
URI(user.avatar_template_url.gsub("{size}", size.to_s)).path
end
it "updates the various topic stats, avatars" do
freeze_time
sign_in(user)
topic_page.visit_topic(topic)
# topic map appears after OP
expect(topic_page).to have_topic_map
# created avatar display
expect(topic_map.created_details).to have_selector("img[src=\"#{avatar_url(user, 24)}\"]")
expect(topic_map.created_relative_date).to eq "1d"
# replies, user count
expect {
Fabricate(:post, topic: topic, user: user, created_at: 1.day.ago)
sign_in(last_post_user)
topic_page.visit_topic_and_open_composer(topic)
topic_page.send_reply("this is a cool-cat post") # fabricating posts doesn't update the last post details
topic_page.visit_topic(topic)
}.to change(topic_map, :replies_count).by(2).and change(topic_map, :users_count).by(1)
#last reply avatar display
expect(topic_map.last_reply_details).to have_selector(
"img[src=\"#{avatar_url(last_post_user, 24)}\"]",
)
expect(topic_map.last_reply_relative_date).to eq "1m"
# avatars details with post counts
2.times { Fabricate(:post, topic: topic) }
Fabricate(:post, user: user, topic: topic)
Fabricate(:post, user: last_post_user, topic: topic)
page.refresh
avatars = topic_map.avatars_details
expect(avatars.length).to eq 3 # max no. of avatars in a collapsed map
expect(avatars[0]).to have_selector("img[src=\"#{avatar_url(user, 48)}\"]")
expect(avatars[0].find(".post-count").text).to eq "3"
expect(avatars[1]).to have_selector("img[src=\"#{avatar_url(last_post_user, 48)}\"]")
expect(avatars[1].find(".post-count").text).to eq "2"
expect(avatars[2]).to have_no_css(".post-count")
topic_map.expand
expect(topic_map).to have_no_avatars_details_in_map
expect(topic_map.expanded_map_avatars_details.length).to eq 4
# views count
expect {
sign_in(other_user)
topic_page.visit_topic(topic)
page.refresh
}.to change(topic_map, :views_count).by 1
# likes count
expect(topic_map).to have_no_likes
topic_page.click_like_reaction_for(original_post)
expect(topic_map.likes_count).to eq 1
end
it "has private message map that shows correct participants and allows editing of participant invites" do
freeze_time
sign_in(user)
topic_page.visit_topic(topic)
expect(topic_page).to have_private_message_map
# participants' links and avatars
private_message_map
.participants_details
.zip([user, other_user, last_post_user]) do |details, usr|
expect(details).to have_link(usr.username, href: "/u/#{usr.username}")
expect(details.find(".trigger-user-card")).to have_selector(
"img[src=\"#{avatar_url(usr, 24)}\"]",
)
end
# toggle ability to edit participants
private_message_map.toggle_edit_participants_button
expect(private_message_map).to have_add_participants_button
private_message_map.toggle_edit_participants_button
expect(private_message_map).to have_no_add_participants_button
# removing participants
private_message_map.toggle_edit_participants_button
private_message_map.participants_details.each do |details|
expect(details).to have_css(".remove-invited .d-icon-times")
end
private_message_map.click_remove_participant_button(last_post_user)
expect(private_message_remove_participant_modal).to be_open
expect(private_message_remove_participant_modal.body).to have_text(
I18n.t("js.private_message_info.remove_allowed_user", name: last_post_user.username),
)
private_message_remove_participant_modal.cancel
expect(private_message_remove_participant_modal).to be_closed
expect(private_message_map).to have_participant_details_for(last_post_user)
private_message_map.click_remove_participant_button(last_post_user)
expect(private_message_remove_participant_modal).to be_open
private_message_remove_participant_modal.confirm_removal
expect(private_message_map).to have_no_participant_details_for(last_post_user)
# adding participants
expect {
expect(private_message_map).to have_add_participants_button
private_message_map.click_add_participants_button
expect(private_message_invite_modal).to be_open
private_message_invite_modal.select_invitee(other_user)
private_message_invite_modal.click_primary_button
expect(private_message_invite_modal).to have_invitee_already_exists_error
private_message_invite_modal.select_invitee(last_post_user)
private_message_invite_modal.click_primary_button #sends invite
expect(private_message_invite_modal).to have_successful_invite_message
private_message_invite_modal.click_primary_button #closes modal
expect(private_message_invite_modal).to be_closed
}.to change(private_message_map, :participants_count).by 1
end
end