Skip to content

Commit ca67337

Browse files
authored
Merge pull request #13237 from thibpoullain/feature/banner-system
ETQ super-admin, je peux publier un bandeau d'en-tête sur le site depuis le manager
2 parents 717f403 + 99000c0 commit ca67337

22 files changed

Lines changed: 426 additions & 27 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Bannières de site (Banner model) — surcharge le rendu DSFR fr-notice
2+
// avec une palette orange pastel via les variables DSFR --background-contrast-warning
3+
// et --text-default-warning. La spécificité (.fr-notice.banner-notice = 0,2,0)
4+
// l'emporte sur .fr-notice--info (0,1,0).
5+
6+
.fr-notice.banner-notice {
7+
background-color: var(--background-contrast-warning);
8+
color: var(--text-default-warning);
9+
10+
.fr-notice__title {
11+
color: var(--text-default-warning);
12+
}
13+
14+
.fr-notice__body::before {
15+
background-color: var(--text-default-warning);
16+
}
17+
18+
a {
19+
color: var(--text-default-warning);
20+
text-decoration: underline;
21+
22+
&:hover {
23+
color: var(--text-active-warning);
24+
}
25+
}
26+
}

app/components/dsfr/notice_component.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,18 @@ class Dsfr::NoticeComponent < ApplicationComponent
66
renders_one :desc
77
renders_one :link
88

9-
attr_reader :data_attributes
9+
attr_reader :data_attributes, :extra_class_names
1010

11-
def initialize(closable: false, state: 'info', data_attributes: {})
11+
def initialize(closable: false, state: 'info', data_attributes: {}, extra_class_names: nil)
1212
@closable = closable
1313
@data_attributes = data_attributes
1414
@state = state
15+
@extra_class_names = extra_class_names
1516
end
1617

1718
def options
1819
attrs = notice_data_attributes
19-
attrs.merge(class: class_names(attrs[:class], "fr-notice", "fr-notice--#{@state}"))
20+
attrs.merge(class: class_names(attrs[:class], "fr-notice", "fr-notice--#{@state}", @extra_class_names))
2021
end
2122

2223
def closable?

app/components/manager/otp_attempt_input_component.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
# frozen_string_literal: true
22

33
class Manager::OtpAttemptInputComponent < ApplicationComponent
4+
# `id` lets a page render several inputs (e.g. one per inline form) without
5+
# duplicating the DOM id; the submitted param name stays `otp_attempt`.
6+
def initialize(id: "otp_attempt")
7+
@id = id
8+
end
9+
10+
attr_reader :id
11+
412
def render?
513
SUPER_ADMIN_OTP_ENABLED
614
end
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
<div class="field-unit__label field-unit--required">
2-
<%= label_tag :otp_attempt, t('.label') %>
2+
<%= label_tag id, t('.label') %>
33
</div>
44

55
<div class="field-unit__field">
66
<%= text_field_tag :otp_attempt, nil,
7+
id: id,
78
inputmode: "numeric",
89
pattern: "[0-9]*",
910
autocomplete: "one-time-code",
1011
autocapitalize: "off",
1112
spellcheck: "false",
1213
required: true,
13-
"aria-describedby": "otp_attempt_hint" %>
14+
"aria-describedby": "#{id}_hint" %>
1415
</div>
1516

16-
<div id="otp_attempt_hint" class="field-unit__hint"><%= t('.hint') %></div>
17+
<div id="<%= id %>_hint" class="field-unit__hint"><%= t('.hint') %></div>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
module Manager
4+
class BannersController < Manager::ApplicationController
5+
include RequiresFreshSuperAdminOtp
6+
7+
before_action :verify_fresh_super_admin_otp!, only: [:update]
8+
9+
def index
10+
@banners = Banner.order(:id)
11+
end
12+
13+
def update
14+
@banner = Banner.find(params[:id])
15+
@banner.update(banner_params)
16+
redirect_to manager_banners_path, notice: "Bannière mise à jour"
17+
end
18+
19+
private
20+
21+
def banner_params
22+
params.require(:banner).permit(:content)
23+
end
24+
end
25+
end

app/helpers/application_helper.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ def download_details(attachment)
143143
"#{attachment.filename.extension.upcase}#{number_to_human_size(attachment.byte_size)}"
144144
end
145145

146+
def banner_for(target)
147+
Banner.cached_for(target.to_s)
148+
end
149+
150+
def sanitize_banner(content)
151+
sanitize(content, tags: Banner::SANITIZE_TAGS)
152+
end
153+
146154
def dsfr_icon(classes, *options)
147155
sm = options.include?(:sm)
148156
mr = options.include?(:mr)

app/models/banner.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
class Banner < ApplicationRecord
4+
TARGETS = {
5+
global: "global",
6+
instructeurs_only: "instructeurs_only",
7+
usagers_only: "usagers_only",
8+
login_page: "login_page",
9+
manager_login: "manager_login",
10+
}.freeze
11+
12+
TARGET_LABELS = {
13+
"global" => "Bannière globale",
14+
"instructeurs_only" => "Bannière instructeurs",
15+
"usagers_only" => "Bannière usagers",
16+
"login_page" => "Page de connexion",
17+
"manager_login" => "Connexion manager",
18+
}.freeze
19+
20+
TARGET_DESCRIPTIONS = {
21+
"global" => "Visible par tous les utilisateurs sur toutes les pages",
22+
"instructeurs_only" => "Visible uniquement par les instructeurs connectés",
23+
"usagers_only" => "Visible par tous les profils non-instructeurs (usagers, administrateurs, experts, gestionnaires)",
24+
"login_page" => "Visible sur les pages de connexion et d'inscription (/users/sign_in, /users/sign_up)",
25+
"manager_login" => "Visible sur la page de connexion super admin (/super_admins/sign_in)",
26+
}.freeze
27+
28+
SANITIZE_TAGS = %w[a strong em b i br u abbr sub sup].freeze
29+
30+
enum :target, TARGETS
31+
32+
validates :target, presence: true, uniqueness: true
33+
34+
def active?
35+
content.present?
36+
end
37+
38+
def self.cached_for(target)
39+
Rails.cache.fetch("banner/#{target}", expires_in: 1.minute) do
40+
find_by(target: target)
41+
end
42+
rescue ActiveRecord::StatementInvalid
43+
nil
44+
end
45+
46+
after_commit :bust_cache
47+
48+
private
49+
50+
def bust_cache
51+
Rails.cache.delete("banner/#{target}")
52+
end
53+
end
Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
1-
<% if ENV['BANNER_MESSAGE'].present? %>
2-
<div class="site-banner fr-text-default--warning fr-background-contrast--warning">
3-
<div class="fr-container text-center">
4-
<%= sanitize(ENV['BANNER_MESSAGE']) %>
5-
</div>
6-
</div>
1+
<% nav_profile = controller.try(:nav_bar_profile) || :guest %>
2+
3+
<% banner = banner_for(:global) %>
4+
5+
<% if banner&.active? %>
6+
<%= render Dsfr::NoticeComponent.new(closable: false, extra_class_names: 'banner-notice') do |c| %>
7+
<% c.with_title do %>
8+
<%= sanitize_banner(banner.content) %>
9+
<% end %>
10+
<% end %>
711
<% end %>
812

9-
<% if ENV['ADMINISTRATION_BANNER_MESSAGE'].present? && instructeur_signed_in? %>
10-
<div class="site-banner fr-text-default--warning fr-background-contrast--warning">
11-
<div class="fr-container text-center">
12-
<%= sanitize(ENV['ADMINISTRATION_BANNER_MESSAGE']) %>
13-
</div>
14-
</div>
13+
<% if nav_profile == :instructeur %>
14+
<% banner = banner_for(:instructeurs_only) %>
15+
<% if banner&.active? %>
16+
<%= render Dsfr::NoticeComponent.new(closable: false, extra_class_names: 'banner-notice') do |c| %>
17+
<% c.with_title do %>
18+
<%= sanitize_banner(banner.content) %>
19+
<% end %>
20+
<% end %>
21+
<% end %>
1522
<% end %>
1623

17-
<% if ENV['USAGER_BANNER_MESSAGE'].present? && !instructeur_signed_in? %>
18-
<div class="site-banner fr-text-default--warning fr-background-contrast--warning">
19-
<div class="fr-container text-center">
20-
<%= sanitize(ENV['USAGER_BANNER_MESSAGE']) %>
21-
</div>
22-
</div>
24+
<% if nav_profile != :instructeur %>
25+
<% banner = banner_for(:usagers_only) %>
26+
<% if banner&.active? %>
27+
<%= render Dsfr::NoticeComponent.new(closable: false, extra_class_names: 'banner-notice') do |c| %>
28+
<% c.with_title do %>
29+
<%= sanitize_banner(banner.content) %>
30+
<% end %>
31+
<% end %>
32+
<% end %>
2333
<% end %>

app/views/manager/application/_navigation.html.erb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
<hr>
1414

15-
<% Administrate::Namespace.new(namespace).resources.select { |resource| !resource.to_s.in?(%w(confirmation_urls administrateur_confirmations)) }.each do |resource| %>
15+
<% Administrate::Namespace.new(namespace).resources.select { |resource| !resource.to_s.in?(%w(confirmation_urls administrateur_confirmations banners)) }.each do |resource| %>
1616
<%= link_to(
1717
display_resource_name(resource),
1818
resource_index_route(resource),
@@ -25,6 +25,7 @@
2525
<%= link_to "Delayed Jobs", manager_delayed_job_path, class: "navigation__link" %>
2626
<%= link_to "Sidekiq", manager_sidekiq_web_path, class: "navigation__link" %>
2727
<%= link_to "Maintenance Tasks", manager_maintenance_tasks_path, class: "navigation__link" %>
28+
<%= link_to "Bannières", manager_banners_path, class: "navigation__link" %>
2829
<%= link_to "Features", manager_flipper_path, class: "navigation__link" %>
2930
<%= link_to "Annonces", super_admins_release_notes_path, class: "navigation__link" %>
3031
<%= link_to "Import data via CSV", manager_import_procedure_tags_path, class: "navigation__link" %>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<% content_for(:title) { "Bannières du site" } %>
2+
3+
<header class="main-content__header">
4+
<h1 class="main-content__page-title"><%= content_for(:title) %></h1>
5+
</header>
6+
7+
<section class="main-content__body">
8+
<p>
9+
Messages d'information affichés en haut des pages et sur les écrans de
10+
connexion. Les modifications sont appliquées immédiatement, sans
11+
redéploiement.
12+
<strong><%= @banners.count(&:active?) %> sur <%= @banners.size %></strong>
13+
bannières actuellement actives.
14+
</p>
15+
16+
<p>
17+
Pour publier une bannière, renseignez son contenu et enregistrez. Pour la
18+
retirer, videz le contenu et enregistrez.
19+
</p>
20+
21+
<p>
22+
<strong>Balises HTML autorisées :</strong>
23+
<%= safe_join(Banner::SANITIZE_TAGS.map { tag.code("<#{it}>") }, ' ') %>.
24+
Toute autre balise est automatiquement supprimée à l'affichage.
25+
</p>
26+
27+
<% example = "<strong>Maintenance prévue</strong> le 10 avril à 22h. Plus d'infos sur le <a href=\"https://demarches-simplifiees.fr\">portail officiel</a>." %>
28+
29+
<p>Exemple : <code><%= example %></code></p>
30+
</section>
31+
32+
<% @banners.each_with_index do |banner, index| %>
33+
<% if index.positive? %>
34+
<hr>
35+
<% end %>
36+
37+
<section class="main-content__body">
38+
<h2><%= Banner::TARGET_LABELS[banner.target] %></h2>
39+
40+
<% if banner.active? %>
41+
<p class="flash-success">
42+
<strong>Active</strong> — actuellement affichée sur le site
43+
</p>
44+
<% else %>
45+
<p class="flash-notice">
46+
<strong>Inactive</strong> — masquée tant que le contenu est vide
47+
</p>
48+
<% end %>
49+
50+
<p><%= Banner::TARGET_DESCRIPTIONS[banner.target] %></p>
51+
52+
<%= form_with model: banner, url: manager_banner_path(banner), method: :patch do |f| %>
53+
<%= f.label :content, "Contenu de la bannière" %>
54+
<%= f.text_area :content, rows: 3, placeholder: "Vider le champ pour désactiver la bannière" %>
55+
56+
<% if SUPER_ADMIN_OTP_ENABLED %>
57+
<div class="field-unit field-unit--required fr-mt-2v">
58+
<%= render Manager::OtpAttemptInputComponent.new(id: "otp_attempt_#{banner.target}") %>
59+
</div>
60+
<% end %>
61+
62+
<%= f.submit "Enregistrer", class: "button fr-mt-2v" %>
63+
64+
<p class="attribute-data fr-mt-2v">
65+
Modifié le <%= l(banner.updated_at, format: :long) %>
66+
</p>
67+
<% end %>
68+
</section>
69+
<% end %>

0 commit comments

Comments
 (0)