Allow admins to enable and disable holidays (#283)

* DEV: Add backend functionality to enable/disable holidays

This will add two backend endpoints, one to disable holidays and
another to enable holidays.

I also introduced a new `Holiday` service that is responsible for
getting the holidays and attaching a new `disabled` attribute to the
holidays. The `#index` action has been updated to use this new service,
so it will return this new `disabled` attribute.

* DEV: Only add enabled holidays to the calendar

I updated this job so that it will use the new `Holiday` service, which
will return the holidays like before but with a new `disabled` field,
which this job will use to only add enabled holidays to the calendar.

* FEATURE: Allow admins to disable/enable holidays

The main thing I added here is a new component `admin-holiday-list-item`
that is responsible for displaying a holiday, and an enable or disable
button and the corresponding functionality.
This commit is contained in:
Shaun 2022-06-28 22:43:20 +00:00 committed by GitHub
parent e771e7c01b
commit d31e07c01c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 560 additions and 19 deletions

View File

@ -1,19 +1,41 @@
# frozen_string_literal: true
require "holidays"
module Admin::DiscourseCalendar
class AdminHolidaysController < AdminDiscourseCalendarController
def index
region_code = params[:region_code]
begin
holidays = Holidays.year_holidays([region_code], Time.current.beginning_of_year)
holidays = DiscourseCalendar::Holiday.find_holidays_for(region_code: region_code)
rescue Holidays::InvalidRegion
return render_json_error(I18n.t("system_messages.discourse_calendar_holiday_region_invalid"), 422)
end
render json: { region_code: region_code, holidays: holidays }
end
def disable
DiscourseCalendar::DisabledHoliday.create!(disabled_holiday_params)
CalendarEvent.destroy_by(
description: disabled_holiday_params[:holiday_name],
region: disabled_holiday_params[:region_code]
)
render json: success_json
end
def enable
if DiscourseCalendar::DisabledHoliday.destroy_by(disabled_holiday_params).present?
render json: success_json
else
render_json_error(I18n.t("system_messages.discourse_calendar_enable_holiday_failed"), 422)
end
end
private
def disabled_holiday_params
params.require(:disabled_holiday).permit(:holiday_name, :region_code)
end
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module DiscourseCalendar
class DisabledHoliday < ActiveRecord::Base
validates :holiday_name, presence: true
validates :region_code, presence: true
end
end
# == Schema Information
#
# Table name: discourse_calendar_disabled_holidays
#
# id :bigint not null, primary key
# holiday_name :string not null
# region_code :string not null
# disabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_disabled_holidays_on_holiday_name_and_region_code (holiday_name,region_code)
#

View File

@ -371,3 +371,21 @@ module DiscoursePostEvent
end
end
end
# == Schema Information
#
# Table name: discourse_post_event_events
#
# id :bigint not null, primary key
# status :integer default(0), not null
# original_starts_at :datetime not null
# original_ends_at :datetime
# deleted_at :datetime
# raw_invitees :string is an Array
# name :string
# url :string(1000)
# custom_fields :jsonb not null
# reminders :string
# recurrence :string
# timezone :string
#

View File

@ -69,3 +69,20 @@ module DiscoursePostEvent
end
end
end
# == Schema Information
#
# Table name: discourse_post_event_invitees
#
# id :bigint not null, primary key
# post_id :integer not null
# user_id :integer not null
# status :integer
# created_at :datetime not null
# updated_at :datetime not null
# notified :boolean default(FALSE), not null
#
# Indexes
#
# discourse_post_event_invitees_post_id_user_id_idx (post_id,user_id) UNIQUE
#

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require "holidays"
module DiscourseCalendar
class Holiday
def self.find_holidays_for(
region_code:,
start_date: Date.current.beginning_of_year,
end_date: Date.current.end_of_year,
show_holiday_observed_on_dates: false)
holidays = Holidays.between(
start_date,
end_date,
[region_code],
show_holiday_observed_on_dates ? :observed : [])
holidays.map do |holiday|
holiday[:disabled] = DiscourseCalendar::DisabledHoliday
.where(region_code: region_code)
.exists?(holiday_name: holiday[:name])
end
holidays
end
end
end

View File

@ -0,0 +1,47 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default Component.extend({
tagName: "tr",
classNameBindings: ["isHolidayDisabled:disabled"],
loading: false,
isHolidayDisabled: false,
@action
disableHoliday(holiday, region_code) {
if (this.loading) {
return;
}
this.set("loading", true);
return ajax({
url: `/admin/discourse-calendar/holidays/disable`,
type: "POST",
data: { disabled_holiday: { holiday_name: holiday.name, region_code } },
})
.then(() => this.set("isHolidayDisabled", true))
.catch(popupAjaxError)
.finally(() => this.set("loading", false));
},
@action
enableHoliday(holiday, region_code) {
if (this.loading) {
return;
}
this.set("loading", true);
return ajax({
url: `/admin/discourse-calendar/holidays/enable`,
type: "DELETE",
data: { disabled_holiday: { holiday_name: holiday.name, region_code } },
})
.then(() => this.set("isHolidayDisabled", false))
.catch(popupAjaxError)
.finally(() => this.set("loading", false));
},
});

View File

@ -1,12 +1,23 @@
<h3>{{i18n "discourse_calendar.holidays"}}</h3>
<h3>
{{i18n "discourse_calendar.holidays.header_title"}}
</h3>
{{region-input
value=this.selectedRegion
onChange=(action "getHolidays")
}}
<p class="desc">
{{i18n "discourse_calendar.holidays.pick_region_description"}}
<br><br>
{{i18n "discourse_calendar.holidays.disabled_holidays_description"}}
</p>
{{conditional-loading-spinner condition=loading}}
{{#if model.holidays}}
{{admin-holidays-list holidays=model.holidays}}
{{admin-holidays-list
holidays=model.holidays
region_code=this.selectedRegion
}}
{{/if}}

View File

@ -0,0 +1,15 @@
<td>{{holiday.date}}</td>
<td>{{holiday.name}}</td>
<td>
{{#if isHolidayDisabled}}
{{d-button
action=(action "enableHoliday" holiday region_code)
label="discourse_calendar.enable_holiday"
}}
{{else}}
{{d-button
action=(action "disableHoliday" holiday region_code)
label="discourse_calendar.disable_holiday"
}}
{{/if}}
</td>

View File

@ -2,16 +2,17 @@
<thead>
<tr>
<td>{{i18n "discourse_calendar.date"}}</td>
<td>{{i18n "discourse_calendar.holiday"}}</td>
<td colspan="2">{{i18n "discourse_calendar.holiday"}}</td>
</tr>
</thead>
<tbody>
{{#each @holidays as |holiday|}}
<tr>
<td>{{holiday.date}}</td>
<td>{{holiday.name}}</td>
</tr>
{{admin-holidays-list-item
holiday=holiday
isHolidayDisabled=holiday.disabled
region_code=region_code
}}
{{/each}}
</tbody>
</table>

View File

@ -1,3 +1,9 @@
.region-input {
width: 50%;
}
.disabled td {
background-color: var(--primary-very-low);
color: var(--primary-medium);
font-style: italic;
}

View File

@ -20,8 +20,13 @@ en:
discourse_calendar:
invite_user_notification: "%{username} invited you to: %{description}"
on_holiday: "On Holiday"
disable_holiday: "Disable"
enable_holiday: "Enable"
holiday: "Holiday"
holidays: "Holidays"
holidays:
header_title: "Holidays"
pick_region_description: "Pick a region to see the holidays for that region."
disabled_holidays_description: "Disabled holidays will be excluded from the staff holiday calendar."
date: "Date"
add_to_calendar: "Add to Google Calendar"
region:

View File

@ -11,6 +11,7 @@ en:
title: Event started
system_messages:
discourse_calendar_holiday_region_invalid: "The holiday region you provided does not exist."
discourse_calendar_enable_holiday_failed: "This holiday could not be enabled, it's already enabled or it's not disabled."
discourse_post_event_bulk_invite_succeeded:
title: "Event - Bulk Invite Succeeded"
subject_template: "Bulk invite processed successfully"

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateDisabledHolidays < ActiveRecord::Migration[7.0]
def change
create_table :discourse_calendar_disabled_holidays do |t|
t.string :holiday_name, null: false
t.string :region_code, null: false
t.boolean :disabled, null: false, default: true
t.timestamps
end
add_index :discourse_calendar_disabled_holidays, [:holiday_name, :region_code], name: 'index_disabled_holidays_on_holiday_name_and_region_code'
end
end

View File

@ -40,9 +40,13 @@ module Jobs
.delete_all
regions_and_user_ids.each do |region, user_ids|
Holidays
.between(Date.today, 6.months.from_now, [region], :observed)
.filter { |holiday| (1..5) === holiday[:date].wday }
DiscourseCalendar::Holiday.find_holidays_for(
region_code: region,
start_date: Date.today,
end_date: 6.months.from_now,
show_holiday_observed_on_dates: true
)
.filter { |holiday| (1..5) === holiday[:date].wday && holiday[:disabled] === false }
.each do |holiday|
user_ids.each do |user_id|

View File

@ -85,13 +85,15 @@ after_initialize do
end
end
# DISCOURSE CALENDAR
# DISCOURSE CALENDAR HOLIDAYS
add_admin_route 'admin.calendar', 'calendar'
%w[
../app/controllers/admin/admin_discourse_calendar_controller.rb
../app/controllers/admin/discourse_calendar/admin_holidays_controller.rb
../app/models/discourse_calendar/disabled_holiday.rb
../app/services/discourse_calendar/holiday.rb
].each { |path| load File.expand_path(path, __FILE__) }
Discourse::Application.routes.append do
@ -99,6 +101,8 @@ after_initialize do
get '/admin/plugins/calendar' => 'admin/plugins#index', constraints: StaffConstraint.new
get '/admin/discourse-calendar/holiday-regions/:region_code/holidays' => 'admin/discourse_calendar/admin_holidays#index', constraints: StaffConstraint.new
post '/admin/discourse-calendar/holidays/disable' => 'admin/discourse_calendar/admin_holidays#disable', constraints: StaffConstraint.new
delete '/admin/discourse-calendar/holidays/enable' => 'admin/discourse_calendar/admin_holidays#enable', constraints: StaffConstraint.new
end
# DISCOURSE POST EVENT

View File

@ -84,6 +84,37 @@ describe DiscourseCalendar::CreateHolidayEvents do
expect(CalendarEvent.exists?(username: frenchy.username)).to eq(false)
end
context "when there are disabled holidays" do
let(:france_assomption) { { holiday_name: "Assomption", region_code: "fr" } }
let(:france_toussaint) { { holiday_name: "Toussaint", region_code: "fr" } }
before do
DiscourseCalendar::DisabledHoliday.create!(france_assomption)
DiscourseCalendar::DisabledHoliday.create!(france_toussaint)
end
it "only adds enabled holidays to the calendar" do
frenchy
freeze_time Time.zone.local(2019, 7, 1)
subject.execute(nil)
expect(CalendarEvent.pluck(:region, :description, :start_date, :username)).to eq([
["fr", "Armistice 1918", "2019-11-11", frenchy.username],
["fr", "Noël", "2019-12-25", frenchy.username],
["fr", "Jour de l'an", "2020-01-01", frenchy.username]
])
end
it "doesn't add disabled holidays to the calendar" do
frenchy
freeze_time Time.zone.local(2019, 7, 1)
subject.execute(nil)
expect(CalendarEvent.pluck(:description)).not_to include(france_assomption[:holiday_name])
expect(CalendarEvent.pluck(:description)).not_to include(france_toussaint[:holiday_name])
end
end
context "when user_options.timezone column exists" do
it "uses the user TZ when available" do
frenchy.user_option.update!(timezone: "Europe/Paris")

View File

@ -24,8 +24,8 @@ module Admin::DiscourseCalendar
get "/admin/discourse-calendar/holiday-regions/mx/holidays.json"
expect(response.parsed_body["holidays"]).to include(
{ "date" => "2022-01-01", "name" => "Año nuevo", "regions" => ["mx"] },
{ "date" => "2022-09-16", "name" => "Día de la Independencia", "regions" => ["mx"] }
{ "date" => "2022-01-01", "name" => "Año nuevo", "regions" => ["mx"], "disabled" => false },
{ "date" => "2022-09-16", "name" => "Día de la Independencia", "regions" => ["mx"], "disabled" => false }
)
end
@ -65,5 +65,126 @@ module Admin::DiscourseCalendar
end
end
end
describe "#disable" do
context "when the calendar plugin is enabled" do
let(:calendar_enabled) { true }
let(:dia_de_la_independencia) do
{ holiday_name: "Día de la Independencia", region_code: "mx" }
end
context "when an admin is signed in" do
before do
sign_in(admin)
end
it "disables the holiday in the specified region and returns a 200 status code" do
post "/admin/discourse-calendar/holidays/disable.json",
params: {
disabled_holiday: dia_de_la_independencia
}
disabled_holiday = DiscourseCalendar::DisabledHoliday.last
expect(disabled_holiday.holiday_name).to eq(dia_de_la_independencia[:holiday_name])
expect(disabled_holiday.region_code).to eq(dia_de_la_independencia[:region_code])
expect(disabled_holiday.disabled).to eq(true)
expect(response.status).to eq(200)
end
it "returns a 400 (bad request) status code when the parameters are not valid" do
post "/admin/discourse-calendar/holidays/disable.json",
params: { disabled_holiday: {} }
expect(response.status).to eq(400)
end
context "when a holiday has been added to the calendar" do
let(:calendar_post) { create_post(raw: "[calendar]\n[/calendar]") }
let(:australia_new_years_day) do
{ holiday_name: "New Year's Day", date: "2022-01-01", region_code: "au" }
end
let(:australia_day) do
{ holiday_name: "Australia Day", date: "2022-01-26", region_code: "au" }
end
before do
CalendarEvent.create!(
topic_id: calendar_post.topic_id,
description: australia_new_years_day[:holiday_name],
start_date: australia_new_years_day[:date],
region: australia_new_years_day[:region_code]
)
CalendarEvent.create!(
topic_id: calendar_post.topic_id,
description: australia_day[:holiday_name],
start_date: australia_day[:date],
region: australia_day[:region_code]
)
end
it "removes disabled holidays from the calendar" do
post "/admin/discourse-calendar/holidays/disable.json",
params: {
disabled_holiday: {
holiday_name: australia_new_years_day[:holiday_name],
region_code: australia_new_years_day[:region_code],
}
}
expect(CalendarEvent.where(
description: australia_new_years_day[:holiday_name],
region: australia_new_years_day[:region_code]
).count).to eq(0)
expect(CalendarEvent.where(
description: australia_day[:holiday_name],
region: australia_day[:region_code]
).count).to eq(1)
end
end
end
end
end
describe "#enable" do
context "when the calendar plugin is enabled" do
let(:calendar_enabled) { true }
context "when an admin is signed in" do
before do
sign_in(admin)
end
context "when there is a disabled holiday" do
let(:hong_kong_labour_day) { { holiday_name: "Labour Day", region_code: "hk" } }
before do
DiscourseCalendar::DisabledHoliday.create!(hong_kong_labour_day)
end
it "enables a holiday (by deleting its 'disabled' record) and returns a 200 status code" do
expect(DiscourseCalendar::DisabledHoliday.count).to eq(1)
delete "/admin/discourse-calendar/holidays/enable.json",
params: {
disabled_holiday: hong_kong_labour_day
}
expect(DiscourseCalendar::DisabledHoliday.count).to eq(0)
expect(response.status).to eq(200)
end
end
it "returns a 422 (unprocessable enity) status code when a holiday can't be enabled" do
delete "/admin/discourse-calendar/holidays/enable.json",
params: { disabled_holiday: { holiday_name: "Not disabled holiday", region_code: "NA" } }
expect(response.status).to eq(422)
end
end
end
end
end
end

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
require "rails_helper"
module DiscourseCalendar
describe Holiday do
describe ".find_holidays_for" do
before do
DisabledHoliday.create!(holiday_name: "New Year's Day", region_code: "sg")
DisabledHoliday.create!(holiday_name: "Chinese New Year", region_code: "sg")
end
let(:holidays) do
Holiday.find_holidays_for(
region_code: "sg",
start_date: "2022-01-01",
end_date: "2022-04-30"
)
end
it "returns a list of holidays indicating whether a holiday is disabled or not" do
expect(holidays).to include(a_hash_including(
{ name: "New Year's Day", regions: [:sg], disabled: true }
))
expect(holidays).to include(a_hash_including(
{ name: "Chinese New Year", regions: [:sg], disabled: true }
))
expect(holidays).to include(a_hash_including(
{ name: "Good Friday", regions: [:sg], disabled: false }
))
end
describe "dates holidays are observed on" do
let(:holidays) { Holiday.find_holidays_for(
region_code: "sg",
start_date: "2021-12-31",
end_date: "2022-05-31",
show_holiday_observed_on_dates: show_holiday_observed_on_dates) }
context "when `show_holiday_observed_on_dates` is set to true" do
let(:show_holiday_observed_on_dates) { true }
it "returns the holidays with the date the holidays are observed on" do
expect(holidays).to include(a_hash_including(
{ name: "New Year's Day", date: Date.new(2021, 12, 31), regions: [:sg] }
))
expect(holidays).to include(a_hash_including(
{ name: "Labour Day", date: Date.new(2022, 5, 2), regions: [:sg] }
))
end
end
context "when `show_holiday_observed_on_dates` is set to false" do
let(:show_holiday_observed_on_dates) { false }
it "returns the holidays with the actual holiday dates" do
expect(holidays).to include(a_hash_including(
{ name: "New Year's Day", date: Date.new(2022, 1, 1), regions: [:sg] }
))
expect(holidays).to include(a_hash_including(
{ name: "Labour Day", date: Date.new(2022, 5, 1), regions: [:sg] }
))
end
end
end
end
end
end

View File

@ -1,9 +1,9 @@
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
import { click, visit } from "@ember/test-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
acceptance("Admin - Calendar", function (needs) {
acceptance("Admin - Discourse Calendar - Holidays", function (needs) {
needs.user();
needs.settings({
calendar_enabled: true,
@ -19,6 +19,14 @@ acceptance("Admin - Calendar", function (needs) {
],
});
});
server.post("/admin/discourse-calendar/holidays/disable", () => {
return helper.response({ success: "OK" });
});
server.delete("/admin/discourse-calendar/holidays/enable", () => {
return helper.response({ success: "OK" });
});
});
test("viewing holidays for a selected region", async (assert) => {
@ -46,4 +54,24 @@ acceptance("Admin - Calendar", function (needs) {
"it displays holiday dates"
);
});
test("disabling and enabling a holiday", async (assert) => {
const regions = selectKit(".region-input");
await visit("/admin/plugins/calendar");
await regions.expand();
await regions.selectRowByValue("ca");
await click("table tr:first-child button");
assert.ok(
query("table tr.disabled:first-child"),
"after clicking the disable button, it adds a .disabled CSS class"
);
await click("table tr.disabled:first-child button");
assert.ok(
query("table tr:first-child"),
"after clicking the enable button, it removes the .disabled CSS class"
);
});
});

View File

@ -0,0 +1,71 @@
import componentTest, {
setupRenderingTest,
} from "discourse/tests/helpers/component-test";
import { discourseModule, query } from "discourse/tests/helpers/qunit-helpers";
import hbs from "htmlbars-inline-precompile";
discourseModule(
"Integration | Component | admin-holidays-list-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`{{admin-holidays-list-item
holiday=holiday
region_code=region_code
isHolidayDisabled=holiday.disabled
}}`;
componentTest(
"when a holiday is disabled, it displays an enable button and adds a disabled CSS class",
{
template,
beforeEach() {
this.set("holiday", {
date: "2022-01-01",
name: "New Year's Day",
disabled: true,
});
this.set("region_code", "sg");
},
async test(assert) {
assert.equal(
query("button").innerText,
"Enable",
"it displays an enable button"
);
assert.ok(query(".disabled"), "it adds a 'disabled' CSS class");
},
}
);
componentTest(
"when a holiday is enabled, it displays a disable button and does not add a disabled CSS class",
{
template,
beforeEach() {
this.set("holiday", {
date: "2022-01-01",
name: "New Year's Day",
disabled: false,
});
this.set("region_code", "au");
},
async test(assert) {
assert.equal(
query("button").innerText,
"Disable",
"it displays a disable button"
);
assert.notOk(
query(".disabled"),
"it does not add a 'disabled' CSS class"
);
},
}
);
}
);