diff --git a/apps/dashboard/app/controllers/application_controller.rb b/apps/dashboard/app/controllers/application_controller.rb
index af082571f0..b29351b9b7 100644
--- a/apps/dashboard/app/controllers/application_controller.rb
+++ b/apps/dashboard/app/controllers/application_controller.rb
@@ -7,6 +7,13 @@ class ApplicationController < ActionController::Base
before_action :set_user, :set_user_configuration, :set_pinned_apps, :set_nav_groups, :set_announcements
before_action :set_my_balances, only: [:index, :new, :featured]
before_action :set_featured_group, :set_custom_navigation
+ before_action :check_required_announcements
+
+ def check_required_announcements
+ return if instance_of?(SettingsController)
+
+ render inline: '', layout: :default if @announcements.select(&:required?).reject(&:completed?).any?
+ end
def set_user
@user = CurrentUser
diff --git a/apps/dashboard/app/controllers/settings_controller.rb b/apps/dashboard/app/controllers/settings_controller.rb
index 350712a0f6..0d0e96b84f 100644
--- a/apps/dashboard/app/controllers/settings_controller.rb
+++ b/apps/dashboard/app/controllers/settings_controller.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
# The Controller for user level settings /dashboard/settings.
+# Current supported settings: profile, announcement
class SettingsController < ApplicationController
include UserSettingStore
- ALLOWED_SETTINGS = [:profile].freeze
+ ALLOWED_SETTINGS = [:profile, { announcements: {} }].freeze
def update
new_settings = read_settings(settings_param)
@@ -23,8 +24,6 @@ def settings_param
end
def read_settings(params)
- {}.tap do |settings|
- params&.each { |key, value| settings[key] = value }
- end
+ params.to_h
end
end
diff --git a/apps/dashboard/app/models/announcement.rb b/apps/dashboard/app/models/announcement.rb
index e9a6b41b98..f2f99d7c35 100644
--- a/apps/dashboard/app/models/announcement.rb
+++ b/apps/dashboard/app/models/announcement.rb
@@ -2,6 +2,7 @@
# Announcements show up on the dashboard to convey a message to users.
class Announcement
+ include UserSettingStore
# List of valid announcement types
TYPES = [:warning, :info, :success, :danger].freeze
@@ -31,10 +32,48 @@ def msg
msg.blank? ? nil : msg
end
+ # The announcement's id. Used when storing that it has been dismissed.
+ # @return [String] the id
+ def id
+ @id ||= begin
+ default_id = Digest::SHA1.hexdigest(msg) if msg
+ opts.fetch(:id, default_id)
+ end
+ end
+
+ # The announcement's button text displayed for required or dismissible announcements
+ # @return [String] the button text
+ def button_text
+ default_text = required? ? I18n.t('dashboard.announcements_required_button') : I18n.t('dashboard.announcements_dismissible_button')
+ opts.fetch(:button_text, default_text).to_s
+ end
+
# Whether this is a valid announcement
# @return [Boolean] whether it is valid
def valid?
- !!msg
+ return false unless msg
+
+ return false if dismissible? && !id
+
+ true
+ end
+
+ # Whether this announcement has been dismissed.
+ # @return [Boolean] whether it has been dismissed
+ def completed?
+ dismissible? && user_settings.dig(:announcements, id.to_s.to_sym).present?
+ end
+
+ # Whether this is a dismissible announcement.
+ # @return [Boolean] whether it is dismissible
+ def dismissible?
+ required? || opts.fetch(:dismissible, true)
+ end
+
+ # Whether this is a required announcement.
+ # @return [Boolean] whether it is required
+ def required?
+ opts.fetch(:required, false)
end
private
diff --git a/apps/dashboard/app/views/layouts/_announcements.html.erb b/apps/dashboard/app/views/layouts/_announcements.html.erb
new file mode 100644
index 0000000000..0cf600d601
--- /dev/null
+++ b/apps/dashboard/app/views/layouts/_announcements.html.erb
@@ -0,0 +1,20 @@
+<% @announcements.select(&:valid?).reject(&:completed?).each do |announcement| %>
+
- <% @announcements.select(&:valid?).each do |announcement| %>
-
- <%= raw OodAppkit.markdown.render(announcement.msg) %>
-
- <% end %>
+ <%= render "layouts/announcements" %>
<%= render "layouts/browser_warning" %>
diff --git a/apps/dashboard/config/locales/en.yml b/apps/dashboard/config/locales/en.yml
index c7c7bc95ef..4f2a1eb0a8 100644
--- a/apps/dashboard/config/locales/en.yml
+++ b/apps/dashboard/config/locales/en.yml
@@ -260,6 +260,10 @@ en:
jobs_scripts_fixed_field: "Fixed Value"
settings_updated: "Settings updated"
+ settings_invalid_request: "Invalid settings submitted"
+
+ announcements_required_button: "Accept"
+ announcements_dismissible_button: "OK"
bc_saved_settings:
title: "Saved Settings"
diff --git a/apps/dashboard/test/fixtures/config/announcements/announcement_id.yml b/apps/dashboard/test/fixtures/config/announcements/announcement_id.yml
new file mode 100644
index 0000000000..232c9b0b88
--- /dev/null
+++ b/apps/dashboard/test/fixtures/config/announcements/announcement_id.yml
@@ -0,0 +1,2 @@
+type: info
+msg: This is yaml
\ No newline at end of file
diff --git a/apps/dashboard/test/fixtures/config/announcements/announcement_md.md b/apps/dashboard/test/fixtures/config/announcements/announcement_md.md
new file mode 100644
index 0000000000..92187e1922
--- /dev/null
+++ b/apps/dashboard/test/fixtures/config/announcements/announcement_md.md
@@ -0,0 +1 @@
+This is md
\ No newline at end of file
diff --git a/apps/dashboard/test/fixtures/config/announcements/announcement_view.yml b/apps/dashboard/test/fixtures/config/announcements/announcement_view.yml
new file mode 100644
index 0000000000..fe1cbc989e
--- /dev/null
+++ b/apps/dashboard/test/fixtures/config/announcements/announcement_view.yml
@@ -0,0 +1,4 @@
+type: danger
+msg: This is the announcement.
+id: view_id
+dismissible: true
\ No newline at end of file
diff --git a/apps/dashboard/test/fixtures/config/announcements/announcement_yml.yml b/apps/dashboard/test/fixtures/config/announcements/announcement_yml.yml
new file mode 100644
index 0000000000..ad5cbf103d
--- /dev/null
+++ b/apps/dashboard/test/fixtures/config/announcements/announcement_yml.yml
@@ -0,0 +1,5 @@
+type: danger
+msg: This is yaml
+id: yml_id
+dismissible: true
+required: true
\ No newline at end of file
diff --git a/apps/dashboard/test/fixtures/file_output/user_settings/announcements.yml b/apps/dashboard/test/fixtures/file_output/user_settings/announcements.yml
new file mode 100644
index 0000000000..b0f92bf406
--- /dev/null
+++ b/apps/dashboard/test/fixtures/file_output/user_settings/announcements.yml
@@ -0,0 +1,3 @@
+---
+announcements:
+ completed_id: '2024-07-11 13:37:03'
diff --git a/apps/dashboard/test/integration/announcement_views_test.rb b/apps/dashboard/test/integration/announcement_views_test.rb
index a2ccee687d..9b15810293 100644
--- a/apps/dashboard/test/integration/announcement_views_test.rb
+++ b/apps/dashboard/test/integration/announcement_views_test.rb
@@ -1,17 +1,34 @@
require 'test_helper'
class AnnouncementViewsTest < ActionDispatch::IntegrationTest
- test "announcement is displayed if exists" do
- f = Tempfile.open(["announcement", ".md"])
- f.write %{Test announcement.}
+ test 'announcement is displayed if exists' do
+ f = Tempfile.open(%w[announcement .md])
+ f.write %(Test announcement.)
f.close
- stub_user_configuration({announcement_path: [f.path]})
+ stub_user_configuration({ announcement_path: [f.path] })
begin
- get "/"
+ get '/'
assert_response :success
- assert_select "div.announcement", "Test announcement."
+ assert_select 'div.announcement div.announcement-body', 'Test announcement.'
+ ensure
+ stub_user_configuration({})
+ end
+ end
+
+ test 'dismissible announcement have a button to close the announcement' do
+ file = "#{Rails.root}/test/fixtures/config/announcements/announcement_view.yml"
+ stub_user_configuration({ announcement_path: [file] })
+
+ begin
+ get '/'
+ assert_response :success
+ assert_select 'div.announcement div.announcement-body', 'This is the announcement.'
+ assert_select 'div.announcement .announcement-button', I18n.t('dashboard.announcements_dismissible_button')
+ form = css_select('div.announcement .announcement-form')
+ assert_equal 1, form.size
+ assert_equal settings_path(action: 'announcement'), form[0]['action']
ensure
stub_user_configuration({})
end
diff --git a/apps/dashboard/test/integration/settings_controller_test.rb b/apps/dashboard/test/integration/settings_controller_test.rb
index 7c2d9a9cfe..64e7479de8 100644
--- a/apps/dashboard/test/integration/settings_controller_test.rb
+++ b/apps/dashboard/test/integration/settings_controller_test.rb
@@ -12,45 +12,102 @@ def setup
@headers = { 'X-CSRF-Token' => @token }
end
- test "should save user_settings when posting settings data" do
- data = {
- settings: {
- profile: "test_profile"
- }
- }
- Dir.mktmpdir {|temp_data_dir|
+ test 'should save and override profile settings when posting profile' do
+ Dir.mktmpdir do |temp_data_dir|
Configuration.stubs(:user_settings_file).returns("#{temp_data_dir}/settings.yml")
+ data = { settings: {} }
+ data[:settings][:profile] = 'first_profile'
post settings_path, params: data, headers: @headers
assert_response :redirect
- assert_equal "test_profile", TestUserSettings.new.user_settings[:profile]
- }
- end
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
+ assert_equal 'first_profile', TestUserSettings.new.user_settings[:profile]
- test "should not save user settings when no data" do
- data = { settings: {} }
+ data[:settings][:profile] = 'override_profile'
+ post settings_path, params: data, headers: @headers
+ assert_response :redirect
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
+ assert_equal 'override_profile', TestUserSettings.new.user_settings[:profile]
+ end
+ end
- Dir.mktmpdir {|temp_data_dir|
+ test 'should allow empty or nil profile settings when posting profile' do
+ Dir.mktmpdir do |temp_data_dir|
Configuration.stubs(:user_settings_file).returns("#{temp_data_dir}/settings.yml")
+ data = { settings: {} }
+
+ data[:settings][:profile] = ''
+ post settings_path, params: data, headers: @headers
+ assert_response :redirect
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
+ assert_equal '', TestUserSettings.new.user_settings[:profile]
+
+ data[:settings][:profile] = nil
post settings_path, params: data, headers: @headers
assert_response :redirect
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
assert_nil TestUserSettings.new.user_settings[:profile]
- }
+ end
+ end
+
+ test 'should save announcement settings and allow multiple announcements when posting announcement' do
+ Dir.mktmpdir do |temp_data_dir|
+ Configuration.stubs(:user_settings_file).returns("#{temp_data_dir}/settings.yml")
+ data = { settings: {} }
+
+ value = Time.now.localtime.strftime('%Y-%m-%d %H:%M:%S')
+ data[:settings] = { announcements: { 'announcement_id' => value } }
+ post settings_path, params: data, headers: @headers
+ assert_response :redirect
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
+ assert_equal value, TestUserSettings.new.user_settings[:announcements][:announcement_id]
+
+ data[:settings] = { announcements: { 'other_announcement_id' => value } }
+ post settings_path, params: data, headers: @headers
+ assert_response :redirect
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
+ assert_equal value, TestUserSettings.new.user_settings[:announcements][:announcement_id]
+ assert_equal value, TestUserSettings.new.user_settings[:announcements][:other_announcement_id]
+ end
+ end
+
+ test 'should not save user_settings when no data' do
+ Dir.mktmpdir do |temp_data_dir|
+ Configuration.stubs(:user_settings_file).returns("#{temp_data_dir}/settings.yml")
+ data = { settings: {} }
+
+ post settings_path, params: data, headers: @headers
+ assert_response :redirect
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
+ assert_empty TestUserSettings.new.user_settings
+ end
end
test "should not save user_settings when parameters are outside the settings namespace" do
- data = { profile: "root_value" }
+ Dir.mktmpdir do |temp_data_dir|
+ Configuration.stubs(:user_settings_file).returns("#{temp_data_dir}/settings.yml")
+ data = { profile: "root_value" }
- Dir.mktmpdir {|temp_data_dir|
+ post settings_path, params: data, headers: @headers
+ assert_response :redirect
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
+ assert_empty TestUserSettings.new.user_settings
+ end
+ end
+
+ test 'should not save user_settings when parameters are not in the allowed list' do
+ Dir.mktmpdir do |temp_data_dir|
Configuration.stubs(:user_settings_file).returns("#{temp_data_dir}/settings.yml")
+ data = { settings: { not_allowed: 'root_value' } }
+
post settings_path, params: data, headers: @headers
assert_response :redirect
- assert_nil TestUserSettings.new.user_settings[:profile]
- }
+ assert_equal I18n.t('dashboard.settings_updated'), flash[:notice]
+ assert_empty TestUserSettings.new.user_settings
+ end
end
class TestUserSettings
include UserSettingStore
end
-
-end
\ No newline at end of file
+end
diff --git a/apps/dashboard/test/models/announcement_test.rb b/apps/dashboard/test/models/announcement_test.rb
new file mode 100644
index 0000000000..9f8b17a501
--- /dev/null
+++ b/apps/dashboard/test/models/announcement_test.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+# Announcement model basic tests
+class AnnouncementTest < ActiveSupport::TestCase
+
+ test 'default values' do
+ target = Announcement.new({})
+
+ assert_equal(:warning, target.type)
+ assert_nil(target.msg)
+ assert_nil(target.id)
+ assert_equal(false, target.completed?)
+ assert_equal(true, target.dismissible?)
+ assert_equal(false, target.required?)
+ end
+
+ test 'default id for announcement file is msg sha1' do
+ path = Rails.root.join('test/fixtures/config/announcements/announcement_id.yml').to_s
+ target = Announcement.new(path)
+
+ assert_equal('9b4ad10e7b43c149e08c1530db6810c7a6bbea13', target.id)
+ end
+
+ test 'can set values with hash' do
+ target = Announcement.new(
+ { type: 'info',
+ msg: 'This is the message',
+ id: '12345',
+ dismissible: true,
+ required: true }
+ )
+
+ assert_equal(:info, target.type)
+ assert_equal('This is the message', target.msg)
+ assert_equal('12345', target.id)
+ assert_equal(false, target.completed?)
+ assert_equal(true, target.dismissible?)
+ assert_equal(true, target.required?)
+ end
+
+ test 'can set values with YAML file' do
+ path = Rails.root.join('test/fixtures/config/announcements/announcement_yml.yml').to_s
+ target = Announcement.new(path)
+
+ assert_equal(:danger, target.type)
+ assert_equal('This is yaml', target.msg)
+ assert_equal('yml_id', target.id)
+ assert_equal(false, target.completed?)
+ assert_equal(true, target.dismissible?)
+ assert_equal(true, target.required?)
+ end
+
+ test 'can set message with MD file' do
+ path = Rails.root.join('test/fixtures/config/announcements/announcement_md.md').to_s
+ target = Announcement.new(path)
+
+ assert_equal(:warning, target.type)
+ assert_equal('This is md', target.msg)
+ assert_equal('77b8dd73d876bb58be9eae133fb8f5c614b95171', target.id)
+ assert_equal(false, target.completed?)
+ assert_equal(true, target.dismissible?)
+ assert_equal(false, target.required?)
+ end
+
+ test 'required announcements are dismissible' do
+ target = Announcement.new({ required: true })
+ assert_equal(true, target.dismissible?)
+ assert_equal(true, target.required?)
+ end
+
+ test 'required takes precedence over dismissible' do
+ target = Announcement.new(
+ { dismissible: false,
+ required: true }
+ )
+ assert_equal(true, target.dismissible?)
+ assert_equal(true, target.required?)
+ end
+
+ test 'valid? should be false if msg is not present' do
+ target = Announcement.new({id: '12345', dismissible: true, required: true})
+ assert_equal(false, target.valid?)
+ end
+
+ test 'valid? should be true if msg is present' do
+ target = Announcement.new({ msg: 'message value' })
+ assert_equal(true, target.valid?)
+ end
+
+ test 'valid? should be true if required? is true and id and msg are present' do
+ target = Announcement.new({ msg: 'message value', id: '12345', required: true })
+ assert_equal(true, target.valid?)
+ end
+
+ test 'completed? should be true if announcement is dismissible and id is stored in the user settings' do
+ file = "#{Rails.root}/test/fixtures/file_output/user_settings/announcements.yml" if file.blank?
+ Configuration.stubs(:user_settings_file).returns(file)
+ target = Announcement.new({ id: 'completed_id', dismissible: false })
+ assert_equal(false, target.completed?)
+
+ target = Announcement.new({ id: 'completed_id', dismissible: true })
+ assert_equal(true, target.completed?)
+ end
+end