diff --git a/app/assets/javascripts/discourse/app/components/topic-map/private-message-map.gjs b/app/assets/javascripts/discourse/app/components/topic-map/private-message-map.gjs new file mode 100644 index 00000000000..8093c2391f4 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-map/private-message-map.gjs @@ -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; + } + + + + {{#each @postAttrs.allowedGroups as |group|}} + + {{/each}} + {{#each @postAttrs.allowedUsers as |user|}} + + {{/each}} + + + {{#if this.canShowControls}} + + + + {{#if (and this.canInvite this.isEditing)}} + + {{/if}} + + {{/if}} + +} + +class PmMapUserGroup extends Component { + get canRemoveLink() { + return this.args.isEditing && this.args.canRemoveAllowedUsers; + } + + get groupUrl() { + return groupPath(this.args.model.name); + } + + + + + {{dIcon "users"}} + {{@model.name}} + + {{#if this.canRemoveLink}} + + {{/if}} + + +} + +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), + }); + } + + + + +} + +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) + ); + } + + + + + + {{avatar @model.avatar_template "tiny" (hash title=this.avatarTitle)}} + + {{@model.username}} + + + {{#if this.canRemoveLink}} + + {{/if}} + + +} + +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), + }); + } + + + +} diff --git a/app/assets/javascripts/discourse/app/widgets/private-message-map.js b/app/assets/javascripts/discourse/app/widgets/private-message-map.js deleted file mode 100644 index 9cbc26274fc..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/private-message-map.js +++ /dev/null @@ -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` - - {{d-icon "users"}} - {{attrs.group.name}} - - {{#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; - }, -}); diff --git a/app/assets/javascripts/discourse/app/widgets/topic-map.js b/app/assets/javascripts/discourse/app/widgets/topic-map.js index f27a4a06ea1..6a3c482ffbd 100644 --- a/app/assets/javascripts/discourse/app/widgets/topic-map.js +++ b/app/assets/javascripts/discourse/app/widgets/topic-map.js @@ -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``, + { + postAttrs: attrs, + showInvite: () => this.sendWidgetAction("showInvite"), + removeAllowedGroup: (group) => + this.sendWidgetAction("removeAllowedGroup", group), + removeAllowedUser: (user) => + this.sendWidgetAction("removeAllowedUser", user), + } + ); + }, }); diff --git a/spec/system/page_objects/components/private_message_map.rb b/spec/system/page_objects/components/private_message_map.rb new file mode 100644 index 00000000000..381fab94598 --- /dev/null +++ b/spec/system/page_objects/components/private_message_map.rb @@ -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 diff --git a/spec/system/page_objects/modals/private_message_invite.rb b/spec/system/page_objects/modals/private_message_invite.rb new file mode 100644 index 00000000000..84a12916b0c --- /dev/null +++ b/spec/system/page_objects/modals/private_message_invite.rb @@ -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 diff --git a/spec/system/page_objects/modals/private_message_remove_participant.rb b/spec/system/page_objects/modals/private_message_remove_participant.rb new file mode 100644 index 00000000000..d4d96ecb979 --- /dev/null +++ b/spec/system/page_objects/modals/private_message_remove_participant.rb @@ -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 diff --git a/spec/system/page_objects/pages/topic.rb b/spec/system/page_objects/pages/topic.rb index bf37806a188..2514bfae4ac 100644 --- a/spec/system/page_objects/pages/topic.rb +++ b/spec/system/page_objects/pages/topic.rb @@ -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) diff --git a/spec/system/private_message_map_spec.rb b/spec/system/private_message_map_spec.rb new file mode 100644 index 00000000000..4567e7198da --- /dev/null +++ b/spec/system/private_message_map_spec.rb @@ -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