Merge pull request #9638 from colinux/poc-release-notes

ETQ super-admin je peux informer les administrateurs, instructeurs et experts des évolutions du site
This commit is contained in:
Colin Darie 2023-11-09 09:00:01 +00:00 committed by GitHub
commit 7a4456efeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 598 additions and 6 deletions

View file

@ -2,6 +2,8 @@ class ApplicationComponent < ViewComponent::Base
include ViewComponent::Translatable
include FlipperHelper
delegate :rich_text_area_tag, to: :helpers
def current_user
controller.current_user
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class ReleaseNote::FormComponent < ApplicationComponent
attr_reader :release_note
def initialize(release_note:)
@release_note = release_note
end
private
def categories_fieldset_class
class_names(
"fr-fieldset--error": categories_error?
)
end
def categories_error?
release_note.errors.key?(:categories)
end
def categories_errors_describedby_id
return nil if !categories_error?
dom_id(release_note, "categories_errors")
end
def categories_full_messages_errors
release_note.errors.full_messages_for(:categories)
end
end

View file

@ -0,0 +1,4 @@
---
en:
delete: Delete this note
new: New note

View file

@ -0,0 +1,4 @@
---
fr:
delete: Supprimer cette note
new: Ajouter une note

View file

@ -0,0 +1,53 @@
= form_for [:super_admins, release_note] do |f|
%fieldset#release_notes_fieldset.fr-fieldset{ 'data-controller': 'trix' }
.fr-fieldset__element
= render Dsfr::InputComponent.new(form: f, attribute: :released_on, input_type: :date_field) do |c|
- c.with_label { ReleaseNote.human_attribute_name(:released_on) }
.fr-fieldset__element
.fr-toggle
= f.check_box :published, class: "fr-toggle-input", id: dom_id(release_note, :published)
%label.fr-toggle__label{ for: dom_id(release_note, :published), data: { fr_checked_label: "Publié", fr_unchecked_label: "Brouillon" } }
Publier
.fr-fieldset__element
%fieldset.fr-fieldset{ "aria-labelledby": token_list(dom_id(release_note, "category_legend"), categories_errors_describedby_id), class: categories_fieldset_class, role: categories_error? ? "group" : nil }
%legend.fr-fieldset__legend.fr-fieldset__legend--regular{ id: dom_id(release_note, :category_legend) }
= ReleaseNote.human_attribute_name(:categories)
= render EditableChamp::AsteriskMandatoryComponent.new
- ReleaseNote::CATEGORIES.each do |category|
.fr-fieldset__element.fr-fieldset__element--inline
.fr-checkbox-group
= f.check_box :categories, { multiple: true, include_hidden: false, id: dom_id(release_note, "category_#{category}"), "aria-describedby" => "#{dom_id(release_note, "category_#{category}_messages")}" }, category, nil
%label.fr-label{ for: dom_id(release_note, "category_#{category}") }
= category.humanize
- if categories_error?
.fr-messages-group{ id: "checkboxes-error-messages", aria_live: "assertive" }
- if categories_full_messages_errors.one?
%p.fr-message.fr-message--error{ id: categories_errors_describedby_id }= categories_full_messages_errors.first
- else
.fr-error-text{ id: categories_errors_describedby_id }
%ul.list-style-type-none.fr-pl-0
- categories_full_messages_errors.map do |error_message|
%li= error_message
.fr-fieldset__element
.fr-input-group
= render Dsfr::InputComponent.new(form: f, attribute: :body, input_type: :rich_text_area)
.fr-fieldset__element
%ul.fr-btns-group.fr-btns-group--inline.fr-btns-group--icon-left
%li= f.button "Valider", class: "fr-btn fr-icon-check-line"
- if release_note.persisted?
%li= link_to t(".new"), new_super_admins_release_note_path(date: release_note.released_on), class: "fr-btn fr-btn--secondary fr-icon-add-line"
- if release_note.persisted?
%li
= link_to t('.delete'), super_admins_release_note_path(release_note),
class: "fr-btn fr-btn--secondary fr-icon-delete-line",
data: { method: :delete, confirm: "Supprimer cette note ?" }

View file

@ -0,0 +1,45 @@
class ReleaseNotesController < ApplicationController
before_action :ensure_access_allowed!
def index
@categories = params[:categories].presence || infer_default_categories
# Paginate per group of dates, then show all announces for theses dates
@paginated_groups = ReleaseNote.published
.for_categories(@categories)
.select(:released_on)
.group(:released_on)
.order(released_on: :desc)
.page(params[:page]).per(5)
@announces = ReleaseNote.where(released_on: @paginated_groups.map(&:released_on))
.with_rich_text_body
.for_categories(@categories)
.order(released_on: :desc, id: :asc)
render "scrollable_list" if params[:page].present?
end
private
def infer_default_categories
if administrateur_signed_in?
['administrateur', 'usager', current_administrateur.api_tokens.exists? ? 'api' : nil]
elsif instructeur_signed_in?
['instructeur', 'expert']
elsif expert_signed_in?
['expert']
else
['usager']
end
end
def ensure_access_allowed!
return if administrateur_signed_in?
return if instructeur_signed_in?
return if expert_signed_in?
flash[:alert] = t('release_notes.index.forbidden')
redirect_to root_path
end
end

View file

@ -0,0 +1,62 @@
class SuperAdmins::ReleaseNotesController < ApplicationController
before_action :authenticate_super_admin!
before_action :set_note, only: [:edit, :update, :destroy]
def nav_bar_profile
:superadmin
end
def index
@release_notes = ReleaseNote
.order(released_on: :desc, id: :asc)
.with_rich_text_body
end
def show
# allows refreshing a submitted page in error
redirect_to edit_super_admins_release_note_path(params[:id])
end
def new
@release_note = ReleaseNote.new(released_on: params[:date].presence || Date.current, published: true)
end
def create
@release_note = ReleaseNote.new(release_note_params)
if @release_note.save
redirect_to edit_super_admins_release_note_path(@release_note), notice: t('.success')
else
flash.now[:alert] = [t('.error'), @release_note.errors.full_messages].flatten
render :new
end
end
def edit
@release_note = ReleaseNote.find(params[:id])
end
def update
if @release_note.update(release_note_params)
redirect_to edit_super_admins_release_note_path(@release_note), notice: t('.success')
else
flash.now[:alert] = [t('.error'), @release_note.errors.full_messages].flatten
render :edit
end
end
def destroy
@release_note.destroy!
redirect_to super_admins_release_notes_path, notice: t('.success')
end
private
def release_note_params
params.require(:release_note).permit(:released_on, :published, :body, categories: [])
end
def set_note
@release_note = ReleaseNote.find(params[:id])
end
end

View file

@ -0,0 +1,18 @@
module ReleaseNotesHelper
def announce_category_badge(category)
color_class = case category.to_sym
when :administrateur
'fr-background-flat--blue-france fr-text-inverted--blue-france'
when :instructeur
'fr-background-contrast--yellow-tournesol'
when :expert
'fr-background-contrast--purple-glycine'
when :usager
'fr-background-contrast--green-emeraude'
when :api
'fr-background-contrast--blue-ecume'
end
content_tag(:span, ReleaseNote.human_attribute_name("categories.#{category}"), class: "fr-badge #{color_class}")
end
end

View file

@ -0,0 +1,17 @@
class ReleaseNote < ApplicationRecord
has_rich_text :body
CATEGORIES = [
'administrateur',
'instructeur',
'expert',
'usager',
'api'
]
validates :categories, presence: true, inclusion: { in: CATEGORIES }
validates :body, presence: true
scope :published, -> { where(published: true, released_on: ..Date.current) }
scope :for_categories, -> (categories) { where("categories && ARRAY[?]::varchar[]", categories) }
end

View file

@ -36,7 +36,7 @@
%span.fr-icon-refresh-line.fr-icon--sm
= t('go_gestionnaire', scope: [:layouts])
- if super_admin_signed_in?
- if super_admin_signed_in? && nav_bar_profile != :superadmin
%li
= link_to manager_root_path, class: "fr-nav__link" do
%span.fr-icon-shield-line.fr-icon--sm

View file

@ -99,4 +99,8 @@
- if current_user.expert && current_expert.avis_summary[:total] > 0
= render partial: 'layouts/header/avis_tab', locals: { current_expert: current_expert }
- if content_for?(:navigation_principale)
.fr-container
= yield(:navigation_principale)
= yield(:notice_info)

View file

@ -24,8 +24,9 @@ as defined by the routes in the `admin/` namespace
<hr />
<%= link_to "Delayed Jobs", manager_delayed_job_path, class: "navigation__link" %>
<%= link_to "Features", manager_flipper_path, class: "navigation__link" %>
<%= link_to "Maintenance Tasks", manager_maintenance_tasks_path, class: "navigation__link" %>
<%= link_to "Features", manager_flipper_path, class: "navigation__link" %>
<%= link_to "Annonces", super_admins_release_notes_path, class: "navigation__link" %>
<%= link_to "Import data via CSV", manager_import_procedure_tags_path, class: "navigation__link" %>
<% if Rails.application.secrets.sendinblue[:enabled] && ENV["SAML_IDP_ENABLED"] == "enabled" %>
<%= link_to "Sendinblue", ENV.fetch("SENDINBLUE_LOGIN_URL"), class: "navigation__link", target: '_blank' %>

View file

@ -0,0 +1,10 @@
.fr-mb-4w
%h3= l(notes[0].released_on, format: :long)
- notes.each do |note|
.fr-mb-4w.fr-px-2w.fr-py-2w.fr-background-alt--grey
%p
- note.categories.each do |category|
= announce_category_badge(category)
= note.body

View file

@ -0,0 +1,5 @@
= render partial: 'announce', collection: announces.group_by(&:released_on).values, as: :notes
- if !paginated_groups.last_page?
= turbo_frame_tag "announces-page-#{paginated_groups.next_page}", loading: :lazy, src: next_page_path(paginated_groups) do
= link_to t('.previous_page'), next_page_path(paginated_groups), class: "fr-btn fr-btn--secondary"

View file

@ -0,0 +1,27 @@
.fr-container.fr-my-5w
%h2.fr-mb-5w= t('.title', app_name: APPLICATION_NAME)
.fr-grid-row.fr-grid-row--gutters{ data: { turbo: 'true' } }
.fr-col-md-3
= form_with(url: release_notes_path, method: :get, data: { turbo_frame: 'announces', controller: 'autosubmit' }, html: { role: 'search' }) do |f|
%fieldset.sidebar-filter
%legend
%ul
%li.fr-py-2w.fr-pl-2w
%fieldset.fr-fieldset{ "aria-labelledby": "sidebar_category_legend" }
%legend.fr-fieldset__legend{ id: "sidebar_category_legend" }
= ReleaseNote.human_attribute_name(:categories)
- ReleaseNote::CATEGORIES.each do |category|
.fr-fieldset__element
.fr-checkbox-group
= f.check_box :categories, { multiple: true, include_hidden: false, id: "filter_category_#{category}", checked: @categories.include?(category) }, category, nil
%label.fr-label{ for: "filter_category_#{category}" }
= ReleaseNote.human_attribute_name("categories.#{category}")
.fr-col-md-9
= turbo_frame_tag "announces", data: { turbo_action: :advance } do
- if @announces.any?
= render "page", announces: @announces, paginated_groups: @paginated_groups
- else
%p.fr-my-4w.text-center= t('.no_content')

View file

@ -0,0 +1,2 @@
= turbo_frame_tag "announces-page-#{@paginated_groups.current_page}" do
= render 'page', announces: @announces, paginated_groups: @paginated_groups

View file

@ -0,0 +1,17 @@
- content_for(:navigation_principale) do
.fr-container
%nav.fr-nav#header-navigation{ role: "navigation", aria: { label: 'Menu principal annonces' } }
%ul.fr-nav__list
%li.fr-nav__item
= link_to "Toutes les annonces", super_admins_release_notes_path, class: "fr-nav__link", target: "_self", aria: { current: action == :index ? "page" : nil }
%li.fr-nav__item
= link_to("Nouvelle annonce", new_super_admins_release_note_path(date: @release_note&.released_on), class: "fr-nav__link", target: "_self", aria: { current: action == :new ? "page" : nil })
- if action == :edit
%li.fr-nav__item
= link_to "Annonce", '', class: "fr-nav__link", target: "_self", aria: { current: "page" }
%li.fr-nav__item
= link_to "Annonces publiées", release_notes_path, class: "fr-nav__link", target: "_self"

View file

@ -0,0 +1 @@
= turbo_stream.append 'release_notes_fieldset', render(ReleaseNote::NoteFormComponent.new(note: @release_note))

View file

@ -0,0 +1,8 @@
= render "main_navigation", action: :edit
.fr-container.fr-my-5w
.fr-grid-row.fr-grid-row--center
.fr-col-lg-10
%h1.fr-h2 Annonce
= render ReleaseNote::FormComponent.new(release_note: @release_note)

View file

@ -0,0 +1,31 @@
= render "main_navigation", action: :index
.fr-container.fr-my-5w
.fr-grid-row.fr-grid-row--center
.fr-col-lg-10
%h1.fr-h2 Liste des annonces
-# haml-lint:disable ApplicationNameLinter
= link_to "Releases sur GitHub", "https://github.com/demarches-simplifiees/demarches-simplifiees.fr/releases", **external_link_attributes
-# haml-lint:enable ApplicationNameLinter
.fr-table
%table
%thead
%th Annoncé le
%th Publié ?
%th Notes
%th Actions
%tbody
- @release_notes.each do |note|
%tr
%td= l(note.released_on) if note.released_on
%td
- if note.published?
%span.fr-badge.fr-badge--success.fr-badge--no-icon Publié
- else
%span.fr-badge.fr-badge--warning.fr-badge--no-icon Brouillon
%td= note.body.to_plain_text.truncate_words(12)
%td
= link_to 'Modifier', edit_super_admins_release_note_path(note), class: 'fr-btn fr-btn--secondary'

View file

@ -0,0 +1,7 @@
= render "main_navigation", action: :new
.fr-container.fr-my-5w
.fr-grid-row.fr-grid-row--center
.fr-col-lg-10
%h1.fr-h2 Nouvelle Annonce
= render ReleaseNote::FormComponent.new(release_note: @release_note)

View file

@ -86,10 +86,10 @@ module TPS
# @see https://guides.rubyonrails.org/configuring.html#custom-configuration
config.x.clamav.enabled = ENV.fetch("CLAMAV_ENABLED", "enabled") == "enabled"
config.view_component.generate_sidecar = true
config.view_component.generate_locale = true
config.view_component.generate_distinct_locale_files = true
config.view_component.generate_preview = true
config.view_component.generate.sidecar = true
config.view_component.generate.locale = true
config.view_component.generate.distinct_locale_files = true
config.view_component.generate.preview = true
config.view_component.show_previews_source = true
config.view_component.default_preview_layout = 'component_preview'
config.view_component.preview_paths << "#{Rails.root}/spec/components/previews"

View file

@ -0,0 +1,31 @@
---
en:
release_notes:
index:
title: Whats new on %{app_name} ? 🚀
forbidden: You are not authorized to view the News page.
no_content: No announcement here.
page:
previous_page: Previous announcements
super_admins:
release_notes:
create: &save
success: Release note was successfully saved.
error: Release note was not saved.
update:
<<: *save
destroy:
success: Release note was successfully deleted.
activerecord:
attributes:
release_note:
body: Announce
categories: Categories
released_on: Publication date
release_note/categories:
administrateur: Administrator
instructeur: Instructor
expert: Expert
api: API
usager: User

View file

@ -0,0 +1,31 @@
---
fr:
release_notes:
index:
title: Quoi de neuf sur %{app_name} ? 🚀
forbidden: Vous nêtes pas autorisé(e) à consulter la page des Nouveautés.
no_content: Aucune nouveauté annoncée de ce côté là.
page:
previous_page: Annonces précédentes
super_admins:
release_notes:
create: &save
success: Lannonce a été sauvegardée.
error: Lannonce na pas pu être sauvegardée.
update:
<<: *save
destroy:
success: La note a été supprimée.
activerecord:
attributes:
release_note:
body: Annonce
categories: Catégories
released_on: Date de publication
release_note/categories:
administrateur: Administrateur
instructeur: Instructeur
expert: Expert
api: API
usager: Usager

View file

@ -16,6 +16,7 @@ en:
instructeur: instructor
administrateur: admin
gestionnaire: admins group manager
superadmin: super-admin
expert: expert
user: user
guest: guest

View file

@ -16,6 +16,7 @@ fr:
instructeur: instructeur
administrateur: administrateur
gestionnaire: gestionnaire
superadmin: super-admin
expert: expert
user: usager
guest: invité

View file

@ -125,6 +125,10 @@ Rails.application.routes.draw do
passwords: 'super_admins/passwords'
}
namespace :super_admins do
resources :release_notes
end
get 'super_admins/edit_otp', to: 'super_admins#edit_otp', as: 'edit_super_admin_otp'
put 'super_admins/enable_otp', to: 'super_admins#enable_otp', as: 'enable_super_admin_otp'
@ -630,6 +634,8 @@ Rails.application.routes.draw do
end
end
resources :release_notes, only: [:index]
if Rails.env.test?
scope 'test/api_geo' do
get 'regions' => 'api_geo_test#regions'

View file

@ -0,0 +1,15 @@
class CreateReleaseNotes < ActiveRecord::Migration[7.0]
def change
create_table :release_notes do |t|
t.date :released_on
t.boolean :published, default: false, null: false
t.string :categories, array: true, default: []
t.timestamps
end
add_index :release_notes, :released_on
add_index :release_notes, :published
add_index :release_notes, :categories, using: :gin
end
end

View file

@ -0,0 +1,5 @@
class AddAnnouncesSeenAtToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :announces_seen_at, :datetime, null: true, default: nil, precision: 6
end
end

View file

@ -896,6 +896,17 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_26_161609) do
t.index ["procedure_id"], name: "index_refused_mails_on_procedure_id"
end
create_table "release_notes", force: :cascade do |t|
t.string "categories", default: [], array: true
t.datetime "created_at", null: false
t.boolean "published", default: false, null: false
t.date "released_on"
t.datetime "updated_at", null: false
t.index ["categories"], name: "index_release_notes_on_categories", using: :gin
t.index ["published"], name: "index_release_notes_on_published"
t.index ["released_on"], name: "index_release_notes_on_released_on"
end
create_table "safe_mailers", force: :cascade do |t|
t.datetime "created_at", precision: 6, null: false
t.string "forced_delivery_method"
@ -1021,6 +1032,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_26_161609) do
create_table "users", id: :serial, force: :cascade do |t|
t.datetime "confirmation_sent_at", precision: 6
t.datetime "blocked_at", precision: 6
t.datetime "announces_seen_at", precision: 6
t.text "blocked_reason"
t.string "confirmation_token"
t.datetime "confirmed_at", precision: 6

View file

@ -0,0 +1,51 @@
require 'rails_helper'
RSpec.describe ReleaseNotesController, type: :controller do
let!(:note_admin) { create(:release_note, categories: ['administrateur'], body: "Pour l'admin", released_on: Date.new(2023, 10, 15)) }
let!(:note_instructeur) { create(:release_note, categories: ['instructeur'], body: "Pour l'instructeur", released_on: Date.new(2023, 10, 13)) }
let(:user) { nil }
let(:admin) { create(:user, administrateur: build(:administrateur)) }
let(:instructeur) { create(:user, instructeur: build(:instructeur)) }
before do
allow(controller).to receive(:current_user).and_return(user)
end
describe 'GET index' do
subject { get :index }
describe 'filtering' do
before { subject }
context 'user is admininistrateur' do
let(:user) { admin }
it { is_expected.to have_http_status(:ok) }
it { expect(assigns(:announces)).to eq([note_admin]) }
end
context 'user is instructeur' do
let(:user) { instructeur }
it { is_expected.to have_http_status(:ok) }
it { expect(assigns(:announces)).to eq([note_instructeur]) }
end
context 'user is expert' do
let(:user) { create(:user, expert: build(:expert)) }
it { expect(assigns(:announces)).to eq([]) }
end
end
describe 'acl' do
before { subject }
context 'user is normal' do
let(:user) { create(:user) }
it { is_expected.to be_redirection }
end
context 'no user' do
it { is_expected.to be_redirection }
end
end
end
end

View file

@ -0,0 +1,82 @@
require 'rails_helper'
describe SuperAdmins::ReleaseNotesController, type: :controller do
let(:super_admin) { create(:super_admin) }
before do
sign_in super_admin if super_admin.present?
end
describe "acl" do
context 'when user is not signed as super admin' do
let(:super_admin) { nil }
let!(:release_note) { create(:release_note, published: false) }
it 'is not allowed to post' do
expect { post :create, params: { release_note: { released_on: Date.current, published: "0", body: "bam" } } }.not_to change(ReleaseNote, :count)
expect(response.status).to eq(302)
expect(flash[:alert]).to be_present
end
it 'is not allowed to put' do
expect {
put :update, params: {
id: release_note.id,
release_note: {
released_on: Date.current,
published: "1",
categories: release_note.categories,
body: "hacked body"
}
}
}.not_to change { release_note.reload.body }
expect(response.status).to eq(302)
expect(flash[:alert]).to be_present
end
it 'is not allowed to index' do
get :index
expect(response.status).to eq(302)
expect(flash[:alert]).to be_present
end
it 'is not allowed to destroy' do
delete :destroy, params: { id: release_note.id }
expect(response.status).to eq(302)
expect(flash[:alert]).to be_present
expect(release_note.reload).to be_persisted
end
end
context 'when user is signed as super admin' do
let(:release_note) { create(:release_note, published: false) }
it 'is allowed to post' do
expect { post :create, params: { release_note: { categories: ['api'], released_on: Date.current, published: "0", body: "bam" } } }.to change(ReleaseNote, :count).by(1)
expect(flash[:notice]).to be_present
end
it 'is allowed to put' do
put :update, params: {
id: release_note.id,
release_note: {
released_on: Date.current,
published: "1",
categories: release_note.categories,
body: "new body"
}
}
release_note.reload
expect(release_note.body.to_plain_text).to eq("new body")
expect(release_note.published).to be_truthy
end
it 'is allowed to destroy' do
delete :destroy, params: { id: release_note.id }
expect(flash[:notice]).to be_present
expect { release_note.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View file

@ -0,0 +1,8 @@
FactoryBot.define do
factory :release_note do
body { "Sample release note body" }
categories { ReleaseNote::CATEGORIES.sample(1) }
published { true }
released_on { 1.day.ago.to_date }
end
end