diff --git a/app/controllers/catalog_controller.rb b/app/controllers/catalog_controller.rb index 86eca4ecdc..9c92a15841 100644 --- a/app/controllers/catalog_controller.rb +++ b/app/controllers/catalog_controller.rb @@ -183,6 +183,10 @@ def default_solr_doc_params(id = nil) def index @presenter = HomeTextPresenter.new(current_user) + unless has_search_parameters? + @presenter.primary_messages = ContentBlock.active.primary + @presenter.secondary_messages = ContentBlock.active.secondary + end super end diff --git a/app/controllers/content_blocks_controller.rb b/app/controllers/content_blocks_controller.rb new file mode 100644 index 0000000000..2d4b6bc5a7 --- /dev/null +++ b/app/controllers/content_blocks_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class ContentBlocksController < ApplicationController + before_action :set_content_block, only: %i[create update destroy] + authorize_resource + + # GET /content_blocks + def index + @unexpired_blocks = ContentBlock.unexpired + @expired_blocks = ContentBlock.expired + end + + # POST /content_blocks + def create + @content_block.save! + redirect_to content_blocks_path + end + + # PATCH/PUT /content_blocks/1 + def update + @content_block.save! + redirect_to content_blocks_path + end + + # DELETE /content_blocks/1 + def destroy + @content_block.destroy + redirect_to content_blocks_path + end + + private + + # Use callbacks to share common setup or constraints between actions. + def set_content_block + @content_block = params[:id] ? ContentBlock.find(params[:id]) : ContentBlock.new + return unless params[:content_block] + + start_at = params[:content_block][:start_at].in_time_zone('America/Los_Angeles') + end_at = params[:content_block][:end_at].in_time_zone('America/Los_Angeles').end_of_day + @content_block.attributes = { end_at: end_at, start_at: start_at, ordinal: params[:content_block][:ordinal], value: params[:content_block][:value] } + end +end diff --git a/app/javascript/argo.js b/app/javascript/argo.js index 0caf6cabd9..f8bf661849 100644 --- a/app/javascript/argo.js +++ b/app/javascript/argo.js @@ -1,5 +1,8 @@ import Form from 'modules/apo_form' import CollectionEditor from 'controllers/collection_editor' +import ContentBlockNew from 'controllers/content_block_new' +import ContentBlockEdit from 'controllers/content_block_edit' + import BulkActions from 'controllers/bulk_actions' import BulkUpload from 'controllers/bulk_upload' import FacetFilter from 'controllers/facet_filter' @@ -46,6 +49,8 @@ export default class Argo { application.register("facet-filter", FacetFilter) application.register("workflow-grid", WorkflowGrid) application.register("collection-editor", CollectionEditor) + application.register("content-block-new", ContentBlockNew) + application.register("content-block-edit", ContentBlockEdit) application.register("tokens", Tokens) } diff --git a/app/javascript/controllers/content_block_edit.js b/app/javascript/controllers/content_block_edit.js new file mode 100644 index 0000000000..2bb83e8f36 --- /dev/null +++ b/app/javascript/controllers/content_block_edit.js @@ -0,0 +1,43 @@ +import { Controller } from 'stimulus' + +export default class extends Controller { + static targets = [ "value", "ordinal", "startAt", "endAt" ] + + display(event) { + event.preventDefault() + + // Save the old HTML so we can cancel. + this.existingHTML = this.element.innerHTML + + // Display the editor + let template = document.getElementById('edit-row') + this.element.innerHTML = template.innerHTML + + // Populate the form with the values for this row + this.valueTarget.value = this.data.get('value') + this.ordinalTarget.value = this.data.get('ordinal') + this.startAtTarget.value = this.data.get('start_at') + this.endAtTarget.value = this.data.get('end_at') + } + + save(event) { + // remove the new form fields + document.querySelector('[data-target="content-block-new.form"').remove() + + // Update the form so it updates the current item. + let form = document.querySelector('[data-target="content-block-form"]') + form.action = this.data.get('url') + + // Set the patch method on the form + var input = document.createElement("input"); + input.type = 'hidden' + input.name = '_method' + input.value = 'patch' + form.appendChild(input) + } + + cancel(event) { + event.preventDefault() + this.element.innerHTML = this.existingHTML + } +} diff --git a/app/javascript/controllers/content_block_new.js b/app/javascript/controllers/content_block_new.js new file mode 100644 index 0000000000..d482009fce --- /dev/null +++ b/app/javascript/controllers/content_block_new.js @@ -0,0 +1,23 @@ +import { Controller } from 'stimulus' + +export default class extends Controller { + static targets = [ "button", "form", "headerRow" ] + + display(event) { + event.preventDefault() + this.formTarget.classList.remove('d-none') + // The header row doesn't display if there are no existing records, so display it now. + this.headerRowTarget.classList.remove('d-none') + this.buttonTarget.classList.add('d-none') + } + + cancel(event) { + event.preventDefault() + this.hideForm() + } + + hideForm() { + this.formTarget.classList.add('d-none') + this.buttonTarget.classList.remove('d-none') + } +} diff --git a/app/javascript/style/application.scss b/app/javascript/style/application.scss index 6130f2bc0e..b457a9ed55 100644 --- a/app/javascript/style/application.scss +++ b/app/javascript/style/application.scss @@ -3,6 +3,11 @@ @import 'variables'; @import 'bootstrap/scss/bootstrap'; @import 'bootstrap-overrides'; + +$fa-font-path: '~@fortawesome/fontawesome-free/webfonts'; +@import "@fortawesome/fontawesome-free/scss/fontawesome.scss"; +@import "@fortawesome/fontawesome-free/scss/solid.scss"; + // Override before importing Blacklight $logo-image: "../images/logo.png"; @import 'blacklight-frontend/app/assets/stylesheets/blacklight/blacklight'; diff --git a/app/javascript/style/bootstrap-overrides.scss b/app/javascript/style/bootstrap-overrides.scss index c82f326548..902e505ced 100644 --- a/app/javascript/style/bootstrap-overrides.scss +++ b/app/javascript/style/bootstrap-overrides.scss @@ -32,3 +32,14 @@ color: $body-color; opacity: 1.0; } + +.alert-warning { + @include alert-variant(theme-color-level('warning', $alert-bg-level), $gamboge, $body-color); + + .fa-exclamation-circle { + color: $gamboge; + font-size: 2em; + margin-right: .5em; + vertical-align: middle; + } +} diff --git a/app/javascript/style/variables.scss b/app/javascript/style/variables.scss index 732135ba19..05bc4637be 100644 --- a/app/javascript/style/variables.scss +++ b/app/javascript/style/variables.scss @@ -8,6 +8,7 @@ $barley-corn: #b3995d; $floral-white: #f9f6ef; $tahuna-sands: #d2c295; +$gamboge: #eaab01; $tea: #b6b1a9; $silver: #c5c5c5; diff --git a/app/models/content_block.rb b/app/models/content_block.rb new file mode 100644 index 0000000000..7eccd3567a --- /dev/null +++ b/app/models/content_block.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ContentBlock < ApplicationRecord + ORDINAL_STRING = { 1 => 'Primary', 2 => 'Secondary' }.freeze + scope :expired, -> { where('end_at < ?', current_time) } + scope :unexpired, -> { where('end_at >= ?', current_time) } + scope :active, -> { where('start_at < ? AND end_at >= ?', current_time, current_time) } + scope :primary, -> { where(ordinal: 1) } + scope :secondary, -> { where(ordinal: 2) } + + validates :ordinal, presence: true, inclusion: { in: [1, 2] } + + def self.current_time + Time.now.in_time_zone('America/Los_Angeles') + end + + def ordinal_string + ORDINAL_STRING.fetch(ordinal) + end + + def pacific_start + start_at.in_time_zone('America/Los_Angeles') + end + + def pacific_end + end_at.in_time_zone('America/Los_Angeles') + end +end diff --git a/app/presenters/home_text_presenter.rb b/app/presenters/home_text_presenter.rb index e09b62e1ec..d2320f71ef 100644 --- a/app/presenters/home_text_presenter.rb +++ b/app/presenters/home_text_presenter.rb @@ -4,6 +4,8 @@ class HomeTextPresenter def initialize(current_user) @current_user = current_user + @primary_messages = [] + @secondary_messages = [] end # @return [Boolean] true if this user has permissions to see anything in Argo @@ -11,6 +13,8 @@ def view_something? is_admin? || is_manager? || is_viewer? || permitted_apos.any? end + attr_accessor :primary_messages, :secondary_messages + private attr_reader :current_user diff --git a/app/views/catalog/_home_text.html.erb b/app/views/catalog/_home_text.html.erb index 4453c43bbe..0637a06cdf 100644 --- a/app/views/catalog/_home_text.html.erb +++ b/app/views/catalog/_home_text.html.erb @@ -9,4 +9,23 @@ You do not appear to have permission to view any items in Argo. Please contact an administrator.

<% end %> + + <% @presenter.primary_messages.each do |block| %> + + <% end %> + + <% if @presenter.secondary_messages.present? %> +

Messages from the Argo team

+ + <% end %> diff --git a/app/views/content_blocks/index.html.erb b/app/views/content_blocks/index.html.erb new file mode 100644 index 0000000000..eed4e1b719 --- /dev/null +++ b/app/views/content_blocks/index.html.erb @@ -0,0 +1,124 @@ +

Message alerts

+ +

Primary messages will appear in a yellow box at the top of the homepage. Secondary messages + will display below the yellow primary messages boxes in a bulleted list. Max 1,000 characters per message.

+<%= form_with(model: ContentBlock.new, local: true, data: { controller: 'content-block-new', target: 'content-block-form' }) do |form| %> + + + + + + + + + + + + + + + <% @unexpired_blocks.each do |block| %> + + + + + + + + <% end %> + + + + + + + + + + + + + +
Message alertsPrimary/SecondaryStart dateExpiration date
<%= block.value %><%= block.ordinal_string %><%= block.pacific_start.to_date %><%= block.pacific_end.to_date %> + + <%= link_to block, method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn' do %> + + <% end %> +
+ <%= form.label :value, 'Text', class: 'sr-only' %> + <%= form.text_area :value, class: 'form-control' %> + + <%= form.label :ordinal, 'Primary/Secondary', class: 'sr-only' %> + <%= form.select :ordinal, [['Primary', 1], ['Secondary', 2]], {}, class: 'form-control' %> + + <%= form.label :start_at, 'Start date', class: 'sr-only' %> + <%= form.date_field :start_at, value: Time.zone.today, class: 'form-control' %> + + <%= form.label :end_at, 'Expiration date', class: 'sr-only' %> + <%= form.date_field :end_at, value: Time.zone.today + 3.months, class: 'form-control' %> + + + <%= form.submit 'Save', class: 'btn btn-primary' %> +
+ + +<% end %> + + + + + + + + + + + + + <% @expired_blocks.each do |block| %> + + + + + + + <% end %> + +
Expired message alertsPrimary/SecondaryStart dateExpiration date
<%= block.value %><%= block.ordinal_string %><%= block.start_at.to_date %><%= block.end_at.to_date %>
diff --git a/app/views/shared/_user_util_links.html.erb b/app/views/shared/_user_util_links.html.erb index a24f978c68..d54b06f73e 100644 --- a/app/views/shared/_user_util_links.html.erb +++ b/app/views/shared/_user_util_links.html.erb @@ -23,9 +23,16 @@ - <% elsif can? :impersonate, User %> - <% end %> diff --git a/config/routes.rb b/config/routes.rb index 4518951c44..6826b3d6da 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,8 @@ Rails.application.routes.draw do get '/is_it_working' => 'ok_computer/ok_computer#show', defaults: { check: 'default' } + resources :content_blocks, only: %i[new create edit update destroy index] + resources :bulk_actions, except: %i[edit show update] do member do get :file diff --git a/db/migrate/20200514171819_create_content_blocks.rb b/db/migrate/20200514171819_create_content_blocks.rb new file mode 100644 index 0000000000..6635d1ce02 --- /dev/null +++ b/db/migrate/20200514171819_create_content_blocks.rb @@ -0,0 +1,12 @@ +class CreateContentBlocks < ActiveRecord::Migration[5.2] + def change + create_table :content_blocks do |t| + t.text :value, null: false + t.datetime :start_at, null: false + t.datetime :end_at, null: false + t.integer :ordinal, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d234123300..f1b20f460c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2018_05_10_050959) do +ActiveRecord::Schema.define(version: 2020_05_14_171819) do create_table "bookmarks", force: :cascade do |t| t.integer "user_id", null: false @@ -37,6 +37,15 @@ t.index ["user_id"], name: "index_bulk_actions_on_user_id" end + create_table "content_blocks", force: :cascade do |t| + t.text "value", null: false + t.datetime "start_at", null: false + t.datetime "end_at", null: false + t.integer "ordinal", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "delayed_jobs", force: :cascade do |t| t.integer "priority", default: 0, null: false t.integer "attempts", default: 0, null: false diff --git a/package.json b/package.json index c82d1aa0a5..aa39272ff0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@fortawesome/fontawesome-free": "^5.13.0", "@github/time-elements": "^3.0.7", "@rails/webpacker": "^5.1.1", "blacklight-frontend": "^7.7.0", diff --git a/spec/factories/content_blocks.rb b/spec/factories/content_blocks.rb new file mode 100644 index 0000000000..c8add4f925 --- /dev/null +++ b/spec/factories/content_blocks.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :content_block do + value { 'MyText' } + start_at { '2020-05-14 12:18:19' } + end_at { '2020-05-14 12:18:19' } + ordinal { 1 } + end +end diff --git a/spec/models/content_block_spec.rb b/spec/models/content_block_spec.rb new file mode 100644 index 0000000000..50b91a4f0a --- /dev/null +++ b/spec/models/content_block_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ContentBlock, type: :model do + describe '.active' do + subject { described_class.active } + + let!(:active1) do + create(:content_block, start_at: 1.day.ago, end_at: 2.days.from_now) + end + + let!(:active2) do + create(:content_block, start_at: 2.days.ago, end_at: 3.days.from_now) + end + + before do + # Past + create(:content_block, start_at: 3.days.ago, end_at: 2.days.ago) + # Future + create(:content_block, start_at: 3.days.from_now, end_at: 6.days.from_now) + end + + it { is_expected.to eq [active1, active2] } + end + + describe '.primary' do + subject { described_class.primary } + + let!(:primary1) do + create(:content_block, ordinal: 1) + end + + let!(:primary2) do + create(:content_block, ordinal: 1) + end + + before do + # A secondary block + create(:content_block, ordinal: 2) + end + + it { is_expected.to eq [primary1, primary2] } + end + + describe '.secondary' do + subject { described_class.secondary } + + let!(:secondary1) do + create(:content_block, ordinal: 2) + end + + let!(:secondary2) do + create(:content_block, ordinal: 2) + end + + before do + # A primary block + create(:content_block, ordinal: 1) + end + + it { is_expected.to eq [secondary1, secondary2] } + end +end diff --git a/spec/requests/content_blocks_spec.rb b/spec/requests/content_blocks_spec.rb new file mode 100644 index 0000000000..a79a23b264 --- /dev/null +++ b/spec/requests/content_blocks_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'ContentBlocks', type: :request do + let(:user) { create(:user) } + let(:content_block) { create(:content_block) } + + before do + sign_in user, groups: ['sdr:administrator-role'] + end + + describe 'GET /content_blocks' do + before do + create(:content_block) + create(:content_block) + end + + it 'displays the content blocks' do + get content_blocks_path + expect(response.body).to include 'Message alerts' + end + end + + describe 'POST /content_blocks' do + let(:valid_params) do + { + content_block: { + start_at: '2020-05-05', end_at: '2020-08-15', value: 'New text', ordinal: 1 + } + } + end + + it 'creates a new content block' do + expect { post content_blocks_path, params: valid_params }.to change(ContentBlock, :count).by(1) + expect(response).to redirect_to content_blocks_path + end + end + + describe 'PATCH /content_blocks/:id' do + let(:valid_params) do + { + content_block: { + start_at: '2020-05-05', end_at: '2020-08-15', value: 'New text', ordinal: 1 + } + } + end + + it 'redirects to the show page' do + patch content_block_path(content_block.to_param), params: valid_params + expect(response).to redirect_to content_blocks_path + expect(content_block.reload.value).to eq 'New text' + end + end + + describe 'DELETE /content_blocks/:id' do + let!(:content_block) { create(:content_block) } + + it 'removes the content block' do + expect { delete content_block_path(content_block.to_param) }.to change(ContentBlock, :count).by(-1) + expect(response).to redirect_to content_blocks_path + end + end +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb index 8b5dff8875..6dcbd0d117 100644 --- a/spec/support/factory_bot.rb +++ b/spec/support/factory_bot.rb @@ -4,6 +4,10 @@ config.include FactoryBot::Syntax::Methods config.before(:suite) do - FactoryBot.lint + conn = ActiveRecord::Base.connection + conn.transaction do + FactoryBot.lint + raise ActiveRecord::Rollback + end end end diff --git a/spec/views/catalog/_home_text.html.erb_spec.rb b/spec/views/catalog/_home_text.html.erb_spec.rb index 09abfd0c40..e78685c491 100644 --- a/spec/views/catalog/_home_text.html.erb_spec.rb +++ b/spec/views/catalog/_home_text.html.erb_spec.rb @@ -9,7 +9,9 @@ end context 'as someone who can view something' do - let(:presenter) { instance_double(HomeTextPresenter, view_something?: true) } + let(:presenter) do + instance_double(HomeTextPresenter, view_something?: true, primary_messages: [], secondary_messages: []) + end it 'shows the home page text' do expect(rendered).to have_css 'p', text: 'Enter one or more search terms ' \ @@ -18,7 +20,9 @@ end context 'as one who cannot view anything' do - let(:presenter) { instance_double(HomeTextPresenter, view_something?: false) } + let(:presenter) do + instance_double(HomeTextPresenter, view_something?: false, primary_messages: [], secondary_messages: []) + end it 'shows an access denied error' do expect(rendered).to have_css 'p', text: 'You do not appear to have ' \ diff --git a/yarn.lock b/yarn.lock index a2f925fbd4..38856910c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -778,6 +778,11 @@ resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" integrity sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw== +"@fortawesome/fontawesome-free@^5.13.0": + version "5.13.0" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz#fcb113d1aca4b471b709e8c9c168674fbd6e06d9" + integrity sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg== + "@github/time-elements@^3.0.7": version "3.1.0" resolved "https://registry.yarnpkg.com/@github/time-elements/-/time-elements-3.1.0.tgz#879723eec7d486e3ebaf738a29f7746ec2c2caf4"