diff --git a/app/controllers/discourse_post_event/invitees_controller.rb b/app/controllers/discourse_post_event/invitees_controller.rb index 59266b6e..b0052124 100644 --- a/app/controllers/discourse_post_event/invitees_controller.rb +++ b/app/controllers/discourse_post_event/invitees_controller.rb @@ -6,23 +6,50 @@ module DiscoursePostEvent event = Event.find(params[:post_id]) guardian.ensure_can_see!(event.post) - event_invitees = event.invitees + filter = params[:filter].downcase if params[:filter] - if params[:filter] + event_invitees = event.invitees + event_invitees = event_invitees.with_status(params[:type].to_sym) if params[:type] + + possible_invitees = [] + if filter.present? && guardian.can_act_on_discourse_post_event?(event) + missing_users = event.missing_users(event_invitees.select(:user_id)) + + if filter + missing_users = missing_users.where("LOWER(username) LIKE :filter", filter: "%#{filter}%") + + custom_order = <<~SQL + CASE + WHEN LOWER(username) = ? THEN 0 + ELSE 1 + END ASC, + LOWER(username) ASC + SQL + + custom_order = ActiveRecord::Base.sanitize_sql_array([custom_order, filter]) + missing_users = missing_users.order(custom_order).limit(10) + else + missing_users = missing_users.order(:username_lower).limit(10) + end + + possible_invitees = missing_users + end + + if filter event_invitees = event_invitees.joins(:user).where( "LOWER(users.username) LIKE :filter", - filter: "%#{params[:filter].downcase}%", + filter: "%#{filter}%", ) end - event_invitees = event_invitees.with_status(params[:type].to_sym) if params[:type] + event_invitees = event_invitees.order(%i[status username_lower]).limit(200) render json: - ActiveModel::ArraySerializer.new( - event_invitees.order(%i[status user_id]).limit(200), - each_serializer: InviteeSerializer, - ).as_json + InviteeListSerializer.new( + invitees: event_invitees, + possible_invitees: possible_invitees, + ) end def update @@ -36,10 +63,20 @@ module DiscoursePostEvent event = Event.find(params[:post_id]) guardian.ensure_can_see!(event.post) - raise Discourse::InvalidAccess if !event.can_user_update_attendance(current_user) + invitee_params = invitee_params(event) - invitee = - Invitee.create_attendance!(current_user.id, params[:post_id], invitee_params[:status]) + user = current_user + if user_id = invitee_params[:user_id] + user = User.find(user_id.to_i) + end + + raise Discourse::InvalidAccess if !event.can_user_update_attendance(user) + + if current_user.id != user.id + raise Discourse::InvalidAccess if !guardian.can_act_on_discourse_post_event?(event) + end + + invitee = Invitee.create_attendance!(user.id, params[:post_id], invitee_params[:status]) render json: InviteeSerializer.new(invitee) end @@ -54,8 +91,12 @@ module DiscoursePostEvent private - def invitee_params - params.require(:invitee).permit(:status) + def invitee_params(event = nil) + if event && guardian.can_act_on_discourse_post_event?(event) + params.require(:invitee).permit(:status, :user_id) + else + params.require(:invitee).permit(:status) + end end end end diff --git a/app/models/discourse_post_event/event.rb b/app/models/discourse_post_event/event.rb index 08d6a0ca..26a353c5 100644 --- a/app/models/discourse_post_event/event.rb +++ b/app/models/discourse_post_event/event.rb @@ -337,11 +337,19 @@ module DiscoursePostEvent end def missing_users(excluded_ids = self.invitees.select(:user_id)) - User - .joins(:groups) - .where("groups.name" => self.raw_invitees) - .where.not(id: excluded_ids) - .distinct + users = User.real.activated.not_silenced.not_suspended.not_staged + + if self.raw_invitees.present? + user_ids = + users + .joins(:groups) + .where("groups.name" => self.raw_invitees) + .where.not(id: excluded_ids) + .select(:id) + User.where(id: user_ids) + else + users.where.not(id: excluded_ids) + end end def update_with_params!(params) diff --git a/app/serializers/discourse_post_event/invitee_list_serializer.rb b/app/serializers/discourse_post_event/invitee_list_serializer.rb new file mode 100644 index 00000000..6c9f02bb --- /dev/null +++ b/app/serializers/discourse_post_event/invitee_list_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module DiscoursePostEvent + class InviteeListSerializer < ApplicationSerializer + root false + attributes :meta + has_many :invitees, serializer: InviteeSerializer, embed: :objects + + def invitees + object[:invitees] + end + + def meta + { + possible_invitees: + ActiveModel::ArraySerializer.new( + possible_invitees, + each_serializer: BasicUserSerializer, + scope: scope, + ), + } + end + + def include_meta? + possible_invitees.present? + end + + def possible_invitees + object[:possible_invitees] + end + end +end diff --git a/assets/javascripts/discourse/components/modal/post-event-builder.hbs b/assets/javascripts/discourse/components/modal/post-event-builder.hbs index a8f4395f..8f9ad853 100644 --- a/assets/javascripts/discourse/components/modal/post-event-builder.hbs +++ b/assets/javascripts/discourse/components/modal/post-event-builder.hbs @@ -15,8 +15,6 @@ diff --git a/assets/javascripts/discourse/components/modal/post-event-invitees.gjs b/assets/javascripts/discourse/components/modal/post-event-invitees.gjs new file mode 100644 index 00000000..7b0dd292 --- /dev/null +++ b/assets/javascripts/discourse/components/modal/post-event-invitees.gjs @@ -0,0 +1,166 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { or } from "truth-helpers"; +import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; +import DButton from "discourse/components/d-button"; +import DModal from "discourse/components/d-modal"; +import concatClass from "discourse/helpers/concat-class"; +import i18n from "discourse-common/helpers/i18n"; +import { debounce } from "discourse-common/utils/decorators"; +import I18n from "discourse-i18n"; +import renderInvitee from "../../helpers/render-invitee"; +import ToggleInvitees from "../toggle-invitees"; + +export default class PostEventInvitees extends Component { + @service store; + + @tracked invitees; + @tracked filter; + @tracked isLoading = false; + @tracked type = "going"; + @tracked possibleInvitees = []; + + constructor() { + super(...arguments); + this._fetchInvitees(); + } + + get hasPossibleInvitees() { + return this.possibleInvitees.length > 0; + } + + get hasResults() { + return this.invitees?.length > 0 || this.hasPossibleInvitees; + } + + get title() { + return I18n.t( + `discourse_calendar.discourse_post_event.invitees_modal.${ + this.args.model.title || "title_invited" + }` + ); + } + + @action + toggleType(type) { + this.type = type; + this._fetchInvitees(this.filter); + } + + @debounce(250) + onFilterChanged(event) { + this.filter = event.target.value; + this._fetchInvitees(this.filter); + } + + @action + async removeInvitee(invitee) { + await invitee.destroyRecord(); + this._fetchInvitees(this.filter); + } + + @action + async addInvitee(user) { + const invitee = this.store.createRecord("discourse-post-event-invitee"); + await invitee.save({ + post_id: this.args.model.event.id, + user_id: user.id, + status: this.type, + }); + + this.invitees.pushObject(invitee); + this.possibleInvitees = this.possibleInvitees.filter( + (i) => i.id !== user.id + ); + } + + async _fetchInvitees(filter) { + try { + this.isLoading = true; + const invitees = await this.store.findAll( + "discourse-post-event-invitee", + { + filter, + post_id: this.args.model.event.id, + type: this.type, + } + ); + + this.possibleInvitees = invitees.resultSetMeta?.possible_invitees || []; + this.invitees = invitees; + } finally { + this.isLoading = false; + } + } + +} diff --git a/assets/javascripts/discourse/components/modal/post-event-invitees.hbs b/assets/javascripts/discourse/components/modal/post-event-invitees.hbs deleted file mode 100644 index e4782fd5..00000000 --- a/assets/javascripts/discourse/components/modal/post-event-invitees.hbs +++ /dev/null @@ -1,43 +0,0 @@ - - <:body> - - - - {{#if this.invitees}} -
    - {{#each this.invitees as |invitee|}} -
  • - {{render-invitee invitee}} - {{#if @model.event.can_act_on_discourse_post_event}} - - {{/if}} -
  • - {{/each}} -
- {{else}} -

- {{i18n - "discourse_calendar.discourse_post_event.models.invitee.no_users" - }} -

- {{/if}} -
- -
\ No newline at end of file diff --git a/assets/javascripts/discourse/components/modal/post-event-invitees.js b/assets/javascripts/discourse/components/modal/post-event-invitees.js deleted file mode 100644 index 0c4f113b..00000000 --- a/assets/javascripts/discourse/components/modal/post-event-invitees.js +++ /dev/null @@ -1,63 +0,0 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { action } from "@ember/object"; -import { inject as service } from "@ember/service"; -import { debounce } from "discourse-common/utils/decorators"; -import I18n from "discourse-i18n"; - -export default class PostEventInvitees extends Component { - @service store; - - @tracked invitees; - @tracked filter; - @tracked isLoading = false; - @tracked type = "going"; - - constructor() { - super(...arguments); - this._fetchInvitees(); - } - - get title() { - return I18n.t( - `discourse_calendar.discourse_post_event.invitees_modal.${ - this.args.model.title || "title_invited" - }` - ); - } - - @action - toggleType(type) { - this.type = type; - this._fetchInvitees(this.filter); - } - - @debounce(250) - onFilterChanged() { - this._fetchInvitees(this.filter); - } - - @action - async removeInvitee(invitee) { - await invitee.destroyRecord(); - this._fetchInvitees(); - } - - async _fetchInvitees(filter) { - try { - this.isLoading = true; - const invitees = await this.store.findAll( - "discourse-post-event-invitee", - { - filter, - post_id: this.args.model.event.id, - type: this.type, - } - ); - - this.invitees = invitees; - } finally { - this.isLoading = false; - } - } -} diff --git a/assets/javascripts/discourse/helpers/render-invitee.js b/assets/javascripts/discourse/helpers/render-invitee.js index cde3cfaa..9a298b77 100644 --- a/assets/javascripts/discourse/helpers/render-invitee.js +++ b/assets/javascripts/discourse/helpers/render-invitee.js @@ -5,13 +5,14 @@ import { formatUsername } from "discourse/lib/utilities"; import { htmlHelper } from "discourse-common/lib/helpers"; export default htmlHelper((invitee) => { - const path = userPath(invitee.user.username); + const user = invitee.user || invitee; + const path = userPath(user.username); const template = ` - + - ${renderAvatar(invitee.user, { imageSize: "medium" })} + ${renderAvatar(user, { imageSize: "medium" })} - ${formatUsername(invitee.user.username)} + ${formatUsername(user.username)} diff --git a/assets/stylesheets/common/discourse-post-event-invitees.scss b/assets/stylesheets/common/discourse-post-event-invitees.scss index 3ee0f5e9..cf273662 100644 --- a/assets/stylesheets/common/discourse-post-event-invitees.scss +++ b/assets/stylesheets/common/discourse-post-event-invitees.scss @@ -8,7 +8,6 @@ .loading-container { height: 40vh; overflow-y: scroll; - padding: 0 1em 9px 1em; .no-users { text-align: center; font-size: var(--font-up-1); @@ -26,9 +25,11 @@ } .filter { width: calc(100% - 2em); - margin: 1em; + margin-bottom: 1em; } - .invitees { + + .invitees, + .possible-invitees { display: flex; margin: 0; flex-direction: column; @@ -74,4 +75,9 @@ } } } + + .possible-invitees { + margin-top: 1em; + background-color: var(--primary-very-low); + } } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index faad3e08..85152b8b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -330,7 +330,7 @@ en: ends_in_duration: "Ends %{duration}" models: invitee: - no_users: "There are no users of this type." + no_users: "No users found" status: unknown: "Not interested" going: "Going" @@ -365,9 +365,11 @@ en: close_event: "Close event" open_event: "Open event" invitees_modal: - title_invited: "List of RSVPed users" + title_invited: "Event Participation" title_participated: "List of users who participated" filter_placeholder: "Filter users" + remove_invitee: "Remove invitee from list" + add_invitee: "Add invitee to list" bulk_invite_modal: confirm: "confirm" text: "Upload CSV file" diff --git a/spec/requests/invitees_controller_spec.rb b/spec/requests/invitees_controller_spec.rb index 1593fed0..d336e2a6 100644 --- a/spec/requests/invitees_controller_spec.rb +++ b/spec/requests/invitees_controller_spec.rb @@ -46,6 +46,7 @@ module DiscoursePostEvent let(:invitee2) { Fabricate(:user, username: "Francisco", name: "Francisco") } let(:invitee3) { Fabricate(:user, username: "Frank", name: "Frank") } let(:invitee4) { Fabricate(:user, username: "Franchesca", name: "Franchesca") } + let!(:random_user) { Fabricate(:user, username: "Franny") } let(:post_event_1) do pe = Fabricate(:event, post: post_1) pe.create_invitees( @@ -59,6 +60,28 @@ module DiscoursePostEvent pe end + context "when user is allowed to act on post event" do + it "returns users extra possible users when filtering the invitees by name" do + get "/discourse-post-event/events/#{post_event_1.id}/invitees.json", + params: { + filter: "Fran", + type: "going", + } + + possible = response.parsed_body[:meta][:possible_invitees].map { |u| u[:username] }.sort + expect(possible).to eq(%w[Francisco Frank Franny]) + + get "/discourse-post-event/events/#{post_event_1.id}/invitees.json", + params: { + filter: "", + type: "going", + } + + possible = response.parsed_body.dig(:meta, :possible_invitees) + expect(possible).to be_blank + end + end + it "returns the correct amount of users when filtering the invitees by name" do get "/discourse-post-event/events/#{post_event_1.id}/invitees.json", params: { @@ -241,6 +264,26 @@ module DiscoursePostEvent context "when the invitee is the event owner" do let(:post_event_2) { Fabricate(:event, post: post_1) } + it "allows inviting other users" do + user = Fabricate(:user) + + post "/discourse-post-event/events/#{post_event_2.id}/invitees.json", + params: { + invitee: { + status: "interested", + user_id: user.id, + }, + } + + post_event_2.reload + + expect(post_event_2.invitees.length).to eq(1) + invitee = post_event_2.invitees.first + expect(invitee.status).to eq(1) + expect(invitee.post_id).to eq(post_1.id) + expect(invitee.user_id).to eq(user.id) + end + it "creates an invitee" do expect(post_event_2.invitees.length).to eq(0) diff --git a/spec/system/post_event_spec.rb b/spec/system/post_event_spec.rb index 792e4014..7b74bd16 100644 --- a/spec/system/post_event_spec.rb +++ b/spec/system/post_event_spec.rb @@ -2,6 +2,7 @@ describe "Post event", type: :system do fab!(:admin) + fab!(:user) { Fabricate(:admin, username: "jane") } let(:composer) { PageObjects::Components::Composer.new } before do @@ -39,5 +40,19 @@ describe "Post event", type: :system do page.find("#dialog-holder .btn-primary").click expect(page).to have_css(".discourse-post-event .status-and-creators .status.public") + + page.find(".going-button").click + page.find(".event-invitees .show-all").click + + page.find(".d-modal input.filter").fill_in(with: "jan") + page.find(".d-modal .add-invitee").click + + topic_page = PageObjects::Pages::Topic.new + + topic = Topic.find(topic_page.current_topic_id) + + event = topic.posts.first.event + + expect(event.invitees.count).to eq(2) end end