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:
Sam 2024-10-14 08:59:23 +11:00 committed by GitHub
parent 499f29a2a0
commit 647ba1ca7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 341 additions and 135 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -15,8 +15,6 @@
<DateTimeInputRange
@from={{this.startsAt}}
@to={{this.endsAt}}
@toTimeFirst={{true}}
@clearable={{true}}
@timezone={{@model.event.timezone}}
@onChange={{this.onChangeDates}}
/>

View File

@ -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>
}

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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"

View 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)

View File

@ -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