diff --git a/Gemfile b/Gemfile index f81ee66b..4f148a2f 100644 --- a/Gemfile +++ b/Gemfile @@ -105,4 +105,6 @@ group :development, :test do gem 'letter_opener' # Use this just in local test environments gem 'brakeman' gem 'guard-brakeman' + gem 'timecop' + gem 'rails-controller-testing' end diff --git a/Gemfile.lock b/Gemfile.lock index bc9c4bd1..3c142622 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -344,6 +344,10 @@ GEM rails-assets-growl (1.3.5) rails-assets-jquery rails-assets-jquery (2.2.4) + rails-controller-testing (1.0.4) + actionpack (>= 5.0.1.x) + actionview (>= 5.0.1.x) + activesupport (>= 5.0.1.x) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -459,6 +463,7 @@ GEM thor (1.0.1) thread_safe (0.3.6) tilt (2.0.10) + timecop (0.9.1) tiny-color-rails (0.0.2) railties (>= 3.0) turbolinks (2.5.4) @@ -553,6 +558,7 @@ DEPENDENCIES rails (~> 5.2) rails-assets-growl! rails-assets-jquery (~> 2.2.0)! + rails-controller-testing rails-i18n (~> 5.0) rails_admin rake @@ -570,6 +576,7 @@ DEPENDENCIES simplecov-rcov spring (~> 2.0) sweetalert-rails + timecop tiny-color-rails tumblr_client! turbolinks (~> 2.5.3) diff --git a/app/assets/javascripts/application.js.erb.coffee b/app/assets/javascripts/application.js.erb.coffee index b135cf66..73e9ef73 100644 --- a/app/assets/javascripts/application.js.erb.coffee +++ b/app/assets/javascripts/application.js.erb.coffee @@ -74,6 +74,16 @@ _ready = -> lineColor: bodyColor density: 23000 + $(".alert-announcement").each -> + aId = $(this)[0].dataset.announcementId + unless (window.localStorage.getItem("announcement#{aId}")) + $(this).toggleClass("hidden") + + $(document).on "click", ".alert-announcement button.close", (evt) -> + announcement = event.target.closest(".alert-announcement") + aId = announcement.dataset.announcementId + window.localStorage.setItem("announcement#{aId}", true) + $('.arctic_scroll').arctic_scroll speed: 500 diff --git a/app/controllers/announcement_controller.rb b/app/controllers/announcement_controller.rb new file mode 100644 index 00000000..ee2bbb24 --- /dev/null +++ b/app/controllers/announcement_controller.rb @@ -0,0 +1,52 @@ +class AnnouncementController < ApplicationController + before_action :authenticate_user! + + def index + @announcements = Announcement.all + end + + def new + @announcement = Announcement.new + end + + def create + @announcement = Announcement.new(announcement_params) + @announcement.user = current_user + if @announcement.save + flash[:success] = "Announcement created successfully." + redirect_to action: :index + else + render 'announcement/new' + end + end + + def edit + @announcement = Announcement.find(params[:id]) + end + + def update + @announcement = Announcement.find(params[:id]) + @announcement.update(announcement_params) + if @announcement.save + flash[:success] = "Announcement updated successfully." + redirect_to announcement_index_path + else + render 'announcement/edit' + end + end + + def destroy + if Announcement.destroy(params[:id]) + flash[:success] = "Announcement deleted successfully." + else + flash[:error] = "Failed to delete announcement." + end + redirect_to announcement_index_path + end + + private + + def announcement_params + params.require(:announcement).permit(:content, :link_text, :link_href, :starts_at, :ends_at) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ccc1e3f9..e5c9919b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,6 +6,7 @@ class ApplicationController < ActionController::Base before_action :configure_permitted_parameters, if: :devise_controller? before_action :check_locale before_action :banned? + before_action :find_active_announcements # check if user wants to read def check_locale @@ -50,6 +51,10 @@ class ApplicationController < ActionController::Base end end + def find_active_announcements + @active_announcements ||= Announcement.find_active + end + include ApplicationHelper protected diff --git a/app/helpers/announcement_helper.rb b/app/helpers/announcement_helper.rb new file mode 100644 index 00000000..b71ddc14 --- /dev/null +++ b/app/helpers/announcement_helper.rb @@ -0,0 +1,2 @@ +module AnnouncementHelper +end diff --git a/app/models/announcement.rb b/app/models/announcement.rb new file mode 100644 index 00000000..3014a117 --- /dev/null +++ b/app/models/announcement.rb @@ -0,0 +1,28 @@ +class Announcement < ApplicationRecord + belongs_to :user + + validates :content, presence: true + validates :starts_at, presence: true + validates :link_href, presence: true, if: -> { link_text.present? } + validate :starts_at, :validate_date_range + + def self.find_active + Rails.cache.fetch "announcement_active", expires_in: 1.minute do + where "starts_at <= :now AND ends_at > :now", now: Time.current + end + end + + def active? + Time.now.utc >= starts_at && Time.now.utc < ends_at + end + + def link_present? + link_text.present? + end + + def validate_date_range + if starts_at > ends_at + errors.add(:starts_at, "Start date must be before end date") + end + end +end diff --git a/app/views/announcement/edit.html.haml b/app/views/announcement/edit.html.haml new file mode 100644 index 00000000..ee7eb3ba --- /dev/null +++ b/app/views/announcement/edit.html.haml @@ -0,0 +1,29 @@ +- provide(:title, generate_title("Edit announcement")) +.container.j2-page + = bootstrap_form_for(@announcement, url: {action: "update"}, method: "PATCH") do |f| + - if @announcement.errors.any? + .row + .col-md-12 + .alert.alert-danger + %strong + = pluralize(@announcement.errors.count, "error") + prohibited this announcement from being saved: + %ul + - @announcement.errors.full_messages.each do |err| + %li= err + .row + .col-md-12 + = f.text_area :content, label: "Content" + .row + .col-md-6 + = f.url_field :link_href, label: "Link URL" + .col-md-6 + = f.datetime_field :link_text, label: "Link text" + .row + .col-md-6 + = f.datetime_field :starts_at, label: "Start time" + .col-md-6 + = f.datetime_field :ends_at, label: "End time" + .row + .col-md-12.text-right + = f.submit class: "btn btn-primary" diff --git a/app/views/announcement/index.html.haml b/app/views/announcement/index.html.haml new file mode 100644 index 00000000..0d117c83 --- /dev/null +++ b/app/views/announcement/index.html.haml @@ -0,0 +1,14 @@ +- provide(:title, generate_title("Announcements")) +.container.j2-page + .row + .col-md-12 + = link_to "Add new", :announcement_new, class: "btn btn-default" + - @announcements.each do |announcement| + .panel.panel-default + .panel-heading + = announcement.starts_at + .panel-body + = announcement.content + .panel-footer + = button_to "Edit", announcement_edit_path(id: announcement.id), method: :get, class: 'btn btn-link' + = button_to "Delete", announcement_destroy_path(id: announcement.id), method: :delete, class: 'btn btn-link', confirm: 'Are you sure you want to delete this announcement?' \ No newline at end of file diff --git a/app/views/announcement/new.html.haml b/app/views/announcement/new.html.haml new file mode 100644 index 00000000..25942f28 --- /dev/null +++ b/app/views/announcement/new.html.haml @@ -0,0 +1,29 @@ +- provide(:title, generate_title("Add new announcement")) +.container.j2-page + = bootstrap_form_for(@announcement, url: {action: "create"}) do |f| + - if @announcement.errors.any? + .row + .col-md-12 + .alert.alert-danger + %strong + = pluralize(@announcement.errors.count, "error") + prohibited this announcement from being saved: + %ul + - @announcement.errors.full_messages.each do |err| + %li= err + .row + .col-md-12 + = f.text_area :content, label: "Content" + .row + .col-md-6 + = f.url_field :link_href, label: "Link URL" + .col-md-6 + = f.datetime_field :link_text, label: "Link text" + .row + .col-md-6 + = f.datetime_field :starts_at, label: "Start time" + .col-md-6 + = f.datetime_field :ends_at, label: "End time" + .row + .col-md-12.text-right + = f.submit class: "btn btn-primary" diff --git a/app/views/layouts/_profile.html.haml b/app/views/layouts/_profile.html.haml index d01c7293..519195db 100644 --- a/app/views/layouts/_profile.html.haml +++ b/app/views/layouts/_profile.html.haml @@ -28,6 +28,10 @@ %a{href: pghero_path} %i.fa.fa-fw.fa-database Database Monitor + %li + %a{href: announcement_index_path} + %i.fa.fa-fw.fa-info + Announcements %li.divider - if current_user.mod? %li diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 12f5e08b..d40a87d8 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -25,6 +25,7 @@ = csrf_meta_tags %body#version1 = render 'layouts/header' + = render 'shared/announcements' = yield = render 'shared/locales' - if Rails.env.development? diff --git a/app/views/shared/_announcements.haml b/app/views/shared/_announcements.haml new file mode 100644 index 00000000..99fa74d6 --- /dev/null +++ b/app/views/shared/_announcements.haml @@ -0,0 +1,8 @@ +.container.announcements + - @active_announcements.each do |announcement| + .alert.alert-announcement.alert-info.alert-dismissable.hidden{ data: { 'announcement-id': announcement.id } } + %button.close{ type: "button", "data-dismiss" => "alert" } + %span{ "aria-hidden" => "true" } × + %p= announcement.content + - if announcement.link_present? + %a.alert-link{ href: announcement.link_href }= announcement.link_text diff --git a/config/routes.rb b/config/routes.rb index e5ac3191..5d3e2464 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,13 @@ Rails.application.routes.draw do mount Sidekiq::Web, at: "/sidekiq" mount PgHero::Engine, at: "/pghero", as: "pghero" + + match "/admin/announcements", to: "announcement#index", via: :get, as: :announcement_index + match "/admin/announcements", to: "announcement#create", via: :post, as: :announcement_create + match "/admin/announcements/new", to: "announcement#new", via: :get, as: :announcement_new + match "/admin/announcements/:id/edit", to: "announcement#edit", via: :get, as: :announcement_edit + match "/admin/announcements/:id", to: "announcement#update", via: :patch, as: :announcement_update + match "/admin/announcements/:id", to: "announcement#destroy", via: :delete, as: :announcement_destroy end # Moderation panel diff --git a/db/migrate/20200419183714_create_announcements.rb b/db/migrate/20200419183714_create_announcements.rb new file mode 100644 index 00000000..1aedd713 --- /dev/null +++ b/db/migrate/20200419183714_create_announcements.rb @@ -0,0 +1,14 @@ +class CreateAnnouncements < ActiveRecord::Migration[5.2] + def change + create_table :announcements do |t| + t.text :content, null: false + t.string :link_text + t.string :link_href + t.datetime :starts_at, null: false + t.datetime :ends_at, null: false + t.belongs_to :user, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 02d34dfe..1b8db168 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -15,6 +15,18 @@ ActiveRecord::Schema.define(version: 2020_04_19_185535) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "announcements", force: :cascade do |t| + t.text "content", null: false + t.string "link_text" + t.string "link_href" + t.datetime "starts_at", null: false + t.datetime "ends_at", null: false + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_announcements_on_user_id" + end + create_table "answers", id: :serial, force: :cascade do |t| t.text "content" t.integer "question_id" diff --git a/spec/controllers/announcement_controller_spec.rb b/spec/controllers/announcement_controller_spec.rb new file mode 100644 index 00000000..4af41374 --- /dev/null +++ b/spec/controllers/announcement_controller_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe AnnouncementController, type: :controller do + let(:user) { FactoryBot.create(:user, roles: [:administrator]) } + + describe "#index" do + subject { get :index } + + context "user signed in" do + before(:each) { sign_in(user) } + + it "renders the index template" do + subject + expect(response).to render_template(:index) + end + + context "no announcements" do + it "@announcements is empty" do + subject + expect(assigns(:announcements)).to be_blank + end + end + + context "one announcement" do + let!(:announcement) { Announcement.create(content: "I am announcement", user: user, starts_at: Time.current, ends_at: Time.current + 2.days) } + + it "includes the announcement in the @announcements assign" do + subject + expect(assigns(:announcements)).to include(announcement) + end + end + end + end + + describe "#new" do + subject { get :new } + + context "user signed in" do + before(:each) { sign_in(user) } + + it "renders the new template" do + subject + expect(response).to render_template(:new) + end + end + end + + describe "#create" do + let :announcement_params do + { + announcement: { + content: "I like dogs!", + starts_at: Time.current, + ends_at: Time.current + 2.days + } + } + end + + subject { post :create, params: announcement_params } + + context "user signed in" do + before(:each) { sign_in(user) } + + it "creates an announcement" do + expect { subject }.to change { Announcement.count }.by(1) + end + + it "redirects to announcement#index" do + subject + expect(response).to redirect_to(:announcement_index) + end + end + end + + describe "#edit" do + let! :announcement do + Announcement.create(content: "Dogs are pretty cool, I guess", + starts_at: Time.current + 3.days, + ends_at: Time.current + 10.days, + user: user) + end + + subject { get :edit, params: { id: announcement.id } } + + context "user signed in" do + before(:each) { sign_in(user) } + + it "renders the edit template" do + subject + expect(response).to render_template(:edit) + end + end + end + + describe "#update" do + let :announcement_params do + { + content: "The trebuchet is the superior siege weapon" + } + end + + let! :announcement do + Announcement.create(content: "Dogs are pretty cool, I guess", + starts_at: Time.current + 3.days, + ends_at: Time.current + 10.days, + user: user) + end + + subject do + patch :update, params: { + id: announcement.id, + announcement: announcement_params + } + end + + context "user signed in" do + before(:each) { sign_in(user) } + + it "updates the announcement" do + subject + updated = Announcement.find announcement.id + expect(updated.content).to eq(announcement_params[:content]) + end + + it "redirects to announcement#index" do + subject + expect(response).to redirect_to(:announcement_index) + end + end + end + + describe "#destroy" do + let! :announcement do + Announcement.create(content: "Dogs are pretty cool, I guess", + starts_at: Time.current + 3.days, + ends_at: Time.current + 10.days, + user: user) + end + + subject { delete :destroy, params: { id: announcement.id } } + + context "user signed in" do + before(:each) { sign_in(user) } + + it "deletes the announcement" do + expect { subject }.to change { Announcement.count }.by(-1) + end + + it "redirects to announcement#index" do + subject + expect(response).to redirect_to(:announcement_index) + end + end + end +end + diff --git a/spec/models/announcement_spec.rb b/spec/models/announcement_spec.rb new file mode 100644 index 00000000..35a76d78 --- /dev/null +++ b/spec/models/announcement_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe(Announcement, type: :model) do + let!(:user) { FactoryBot.create :user } + let!(:me) do + Announcement.new( + content: "Raccoon", + starts_at: Time.current, + ends_at: Time.current + 1.day, + user: user + ) + end + + describe "#active?" do + it "returns true when the current time is between starts_at and ends_at" do + expect(me.active?).to be(true) + end + + it "returns false when the current time is before starts_at" do + Timecop.freeze(me.starts_at - 1.second) + expect(me.active?).to be(false) + Timecop.return + end + + it "returns false when the current time is after ends_at" do + Timecop.freeze(me.ends_at) + expect(me.active?).to be(false) + Timecop.return + end + end + + describe "#link_present?" do + it "returns true if a link is present" do + me.link_text = "Very good dogs" + me.link_href = "https://www.reddit.com/r/rarepuppers/" + expect(me.link_present?).to be(true) + end + + it "returns false if a link is not present" do + me.link_text = nil + me.link_href = nil + expect(me.link_present?).to be(false) + end + end +end \ No newline at end of file