FEATURE: allow event editors to control list of users on the event (#614)
Previously event editors could remove people from an event but had no way of acting on behalf of users in the event and adding them. That meant that for events to properly show up in agenda and so on a user must actively click a button. In some cases (company ran events) the event manager may prefer controlling attendance.
This commit is contained in:
parent
499f29a2a0
commit
647ba1ca7f
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -15,8 +15,6 @@
|
|||
<DateTimeInputRange
|
||||
@from={{this.startsAt}}
|
||||
@to={{this.endsAt}}
|
||||
@toTimeFirst={{true}}
|
||||
@clearable={{true}}
|
||||
@timezone={{@model.event.timezone}}
|
||||
@onChange={{this.onChangeDates}}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
<template>
|
||||
<DModal
|
||||
@title={{this.title}}
|
||||
@closeModal={{@closeModal}}
|
||||
class={{concatClass
|
||||
(or @model.extraClass "invited")
|
||||
"post-event-invitees-modal"
|
||||
}}
|
||||
>
|
||||
<:body>
|
||||
<input
|
||||
{{on "input" this.onFilterChanged}}
|
||||
type="text"
|
||||
placeholder={{i18n
|
||||
"discourse_calendar.discourse_post_event.invitees_modal.filter_placeholder"
|
||||
}}
|
||||
class="filter"
|
||||
/>
|
||||
|
||||
<ToggleInvitees @viewType={{this.type}} @toggle={{this.toggleType}} />
|
||||
<ConditionalLoadingSpinner @condition={{this.isLoading}}>
|
||||
{{#if this.hasResults}}
|
||||
<ul class="invitees">
|
||||
{{#each this.invitees as |invitee|}}
|
||||
<li class="invitee">
|
||||
{{renderInvitee invitee}}
|
||||
{{#if @model.event.can_act_on_discourse_post_event}}
|
||||
<DButton
|
||||
class="remove-invitee"
|
||||
@icon="trash-alt"
|
||||
@action={{fn this.removeInvitee invitee}}
|
||||
title={{i18n
|
||||
"discourse_calendar.discourse_post_event.invitees_modal.remove_invitee"
|
||||
}}
|
||||
/>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if this.hasPossibleInvitees}}
|
||||
<ul class="possible-invitees">
|
||||
{{#each this.possibleInvitees as |invitee|}}
|
||||
<li class="invitee">
|
||||
{{renderInvitee invitee}}
|
||||
<DButton
|
||||
class="add-invitee"
|
||||
@icon="plus"
|
||||
@action={{fn this.addInvitee invitee}}
|
||||
title={{i18n
|
||||
"discourse_calendar.discourse_post_event.invitees_modal.add_invitee"
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<p class="no-users">
|
||||
{{i18n
|
||||
"discourse_calendar.discourse_post_event.models.invitee.no_users"
|
||||
}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</ConditionalLoadingSpinner>
|
||||
</:body>
|
||||
</DModal>
|
||||
</template>
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
<DModal
|
||||
@title={{this.title}}
|
||||
@closeModal={{@closeModal}}
|
||||
class={{concat-class
|
||||
(or @model.extraClass "invited")
|
||||
"post-event-invitees-modal"
|
||||
}}
|
||||
>
|
||||
<:body>
|
||||
<Input
|
||||
@value={{this.filter}}
|
||||
{{on "input" this.onFilterChanged}}
|
||||
class="filter"
|
||||
placeholder={{i18n
|
||||
"discourse_calendar.discourse_post_event.invitees_modal.filter_placeholder"
|
||||
}}
|
||||
/>
|
||||
<ToggleInvitees @viewType={{this.type}} @toggle={{this.toggleType}} />
|
||||
<ConditionalLoadingSpinner @condition={{this.isLoading}}>
|
||||
{{#if this.invitees}}
|
||||
<ul class="invitees">
|
||||
{{#each this.invitees as |invitee|}}
|
||||
<li class="invitee">
|
||||
{{render-invitee invitee}}
|
||||
{{#if @model.event.can_act_on_discourse_post_event}}
|
||||
<DButton
|
||||
@icon="trash-alt"
|
||||
@action={{fn this.removeInvitee invitee}}
|
||||
/>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="no-users">
|
||||
{{i18n
|
||||
"discourse_calendar.discourse_post_event.models.invitee.no_users"
|
||||
}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</ConditionalLoadingSpinner>
|
||||
</:body>
|
||||
</DModal>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = `
|
||||
<a href="${path}" data-user-card="${invitee.user.username}">
|
||||
<a href="${path}" data-user-card="${user.username}">
|
||||
<span class="user">
|
||||
${renderAvatar(invitee.user, { imageSize: "medium" })}
|
||||
${renderAvatar(user, { imageSize: "medium" })}
|
||||
<span class="username">
|
||||
${formatUsername(invitee.user.username)}
|
||||
${formatUsername(user.username)}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue