diff --git a/.github/ISSUE_TEMPLATE/description-de-probleme-ux.md b/.github/ISSUE_TEMPLATE/description-de-probleme-ux.md new file mode 100644 index 000000000..203ce73cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/description-de-probleme-ux.md @@ -0,0 +1,11 @@ +# contexte ; + +ex: ETQ usager, lorsque je dépose mon dossier, celui ci peut rester en brouillon sans que je comprenne qu'il n'est pas encore déposé au sens administratif + +# difficultés + +ex: sur ce même contexte, je ne comprends pas pourquoi ma démarche est bloquée + +# opportunités + +ex: améliorer le taux de conversion dépot de dossier brouillon > dépot de dossier en construction diff --git a/Gemfile b/Gemfile index 4d6db2cab..592bc32ba 100644 --- a/Gemfile +++ b/Gemfile @@ -84,7 +84,9 @@ gem 'sib-api-v3-sdk' gem 'skylight' gem 'spreadsheet_architect' gem 'strong_migrations' # lint database migrations +gem 'turbo-rails' gem 'typhoeus' +gem 'view_component' gem 'warden' gem 'webpacker' gem 'zipline' diff --git a/Gemfile.lock b/Gemfile.lock index ae2bd915b..0387b05f1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -723,6 +723,8 @@ GEM timecop (0.9.4) timeout (0.1.1) ttfunk (1.7.0) + turbo-rails (0.8.3) + rails (>= 6.0.0) typhoeus (1.4.0) ethon (>= 0.9.0) tzinfo (2.0.4) @@ -739,6 +741,9 @@ GEM activemodel (>= 3.0.0) public_suffix vcr (6.0.0) + view_component (2.53.0) + activesupport (>= 5.0.0, < 8.0) + method_source (~> 1.0) virtus (2.0.0) axiom-types (~> 0.1) coercible (~> 1.0) @@ -899,8 +904,10 @@ DEPENDENCIES spring-commands-rspec strong_migrations timecop + turbo-rails typhoeus vcr + view_component warden web-console webdrivers (~> 4.0) diff --git a/app/assets/stylesheets/custom_reset.scss b/app/assets/stylesheets/custom_reset.scss index b59355b03..e3486d5c1 100644 --- a/app/assets/stylesheets/custom_reset.scss +++ b/app/assets/stylesheets/custom_reset.scss @@ -22,3 +22,7 @@ a { text-decoration: none; } + +turbo-events { + display: none; +} diff --git a/app/components/application_component.rb b/app/components/application_component.rb new file mode 100644 index 000000000..5235b0900 --- /dev/null +++ b/app/components/application_component.rb @@ -0,0 +1,3 @@ +class ApplicationComponent < ViewComponent::Base + include ViewComponent::Translatable +end diff --git a/app/components/dossiers/message_component.rb b/app/components/dossiers/message_component.rb new file mode 100644 index 000000000..8e2a185a1 --- /dev/null +++ b/app/components/dossiers/message_component.rb @@ -0,0 +1,58 @@ +class Dossiers::MessageComponent < ApplicationComponent + def initialize(commentaire:, connected_user:, messagerie_seen_at: nil, show_reply_button: false) + @commentaire = commentaire + @connected_user = connected_user + @messagerie_seen_at = messagerie_seen_at + @show_reply_button = show_reply_button + end + + attr_reader :commentaire, :connected_user, :messagerie_seen_at + + private + + def show_reply_button? + @show_reply_button + end + + def highlight_if_unseen_class + helpers.highlight_if_unseen_class(@messagerie_seen_at, commentaire.created_at) + end + + def icon_path + if commentaire.sent_by_system? + 'icons/mail.svg' + elsif commentaire.sent_by?(connected_user) + 'icons/account-circle.svg' + else + 'icons/blue-person.svg' + end + end + + def commentaire_issuer + if commentaire.sent_by_system? + t('.automatic_email') + elsif commentaire.sent_by?(connected_user) + t('.you') + else + commentaire.redacted_email + end + end + + def commentaire_from_guest? + commentaire.dossier.invites.map(&:email).include?(commentaire.email) + end + + def commentaire_date + is_current_year = (commentaire.created_at.year == Time.zone.today.year) + l(commentaire.created_at, format: is_current_year ? :message_date : :message_date_with_year) + end + + def commentaire_body + if commentaire.discarded? + t('.deleted_body') + else + body_formatted = commentaire.sent_by_system? ? commentaire.body : simple_format(commentaire.body) + sanitize(body_formatted) + end + end +end diff --git a/app/components/dossiers/message_component/message_component.en.yml b/app/components/dossiers/message_component/message_component.en.yml new file mode 100644 index 000000000..a24af5c49 --- /dev/null +++ b/app/components/dossiers/message_component/message_component.en.yml @@ -0,0 +1,9 @@ +--- +en: + reply: Reply + guest: Guest + delete_button: Delete this message + confirm: Are you sure you want to delete this message ? + automatic_email: Automatic email + you: You + deleted_body: Message deleted diff --git a/app/components/dossiers/message_component/message_component.fr.yml b/app/components/dossiers/message_component/message_component.fr.yml new file mode 100644 index 000000000..4386b2ea2 --- /dev/null +++ b/app/components/dossiers/message_component/message_component.fr.yml @@ -0,0 +1,9 @@ +--- +fr: + reply: Répondre + guest: Invité + delete_button: Supprimer le message + confirm: Êtes-vous sûr de vouloir supprimer ce message ? + automatic_email: Email automatique + you: Vous + deleted_body: Message supprimé diff --git a/app/components/dossiers/message_component/message_component.html.haml b/app/components/dossiers/message_component/message_component.html.haml new file mode 100644 index 000000000..c19f78791 --- /dev/null +++ b/app/components/dossiers/message_component/message_component.html.haml @@ -0,0 +1,26 @@ += image_tag(icon_path, class: 'person-icon', alt: '') + +.width-100 + %h2 + %span.mail + = commentaire_issuer + - if commentaire_from_guest? + %span.guest= t('.guest') + %span.date{ class: highlight_if_unseen_class } + = commentaire_date + .rich-text= commentaire_body + + .message-extras.flex.justify-start + - if commentaire.soft_deletable?(connected_user) + = button_to instructeur_commentaire_path(commentaire.dossier.procedure, commentaire.dossier, commentaire), method: :delete, class: 'button danger', form: { data: { turbo: true, turbo_confirm: t('.confirm') } } do + %span.icon.delete + = t('.delete_button') + + - if commentaire.piece_jointe.attached? + .attachment-link + = render partial: "shared/attachment/show", locals: { attachment: commentaire.piece_jointe.attachment } + + - if show_reply_button? + = button_tag type: 'button', class: 'button small message-answer-button', onclick: 'document.querySelector("#commentaire_body").focus()' do + %span.icon.reply + = t('.reply') diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index f40976d1d..2388508db 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -254,7 +254,7 @@ module Administrateurs end def procedure_params - editable_params = [:libelle, :description, :organisation, :direction, :lien_site_web, :cadre_juridique, :deliberation, :notice, :web_hook_url, :declarative_with_state, :logo, :auto_archive_on, :monavis_embed, :api_entreprise_token, :duree_conservation_dossiers_dans_ds, :zone_id] + editable_params = [:libelle, :description, :organisation, :direction, :lien_site_web, :cadre_juridique, :deliberation, :notice, :web_hook_url, :declarative_with_state, :logo, :auto_archive_on, :monavis_embed, :api_entreprise_token, :duree_conservation_dossiers_dans_ds, :zone_id, :lien_dpo] permited_params = if @procedure&.locked? params.require(:procedure).permit(*editable_params) else diff --git a/app/controllers/experts/avis_controller.rb b/app/controllers/experts/avis_controller.rb index a298d4485..35ff9c99d 100644 --- a/app/controllers/experts/avis_controller.rb +++ b/app/controllers/experts/avis_controller.rb @@ -113,22 +113,6 @@ module Experts end end - def delete_commentaire - commentaire = avis.dossier.commentaires.find(params[:commentaire]) - if commentaire.sent_by?(current_expert) - commentaire.piece_jointe.purge_later if commentaire.piece_jointe.attached? - commentaire.discard! - commentaire.update!(body: '') - flash[:notice] = t('views.shared.commentaires.destroy.notice') - else - flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.acl') - end - redirect_to(messagerie_expert_avis_path(avis.procedure, avis)) - rescue Discard::RecordNotDiscarded - flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.already_discarded') - redirect_to(messagerie_expert_avis_path(avis.procedure, avis)) - end - def bilans_bdf if avis.dossier.etablissement&.entreprise_bilans_bdf.present? extension = params[:format] diff --git a/app/controllers/instructeurs/commentaires_controller.rb b/app/controllers/instructeurs/commentaires_controller.rb index fe36bfc1a..cfe81f0ec 100644 --- a/app/controllers/instructeurs/commentaires_controller.rb +++ b/app/controllers/instructeurs/commentaires_controller.rb @@ -1,21 +1,33 @@ -# frozen_string_literal: true - module Instructeurs class CommentairesController < ProceduresController + after_action :mark_messagerie_as_read + def destroy - commentaire = Dossier.find(params[:dossier_id]).commentaires.find(params[:id]) - if commentaire.sent_by?(current_instructeur) - commentaire.piece_jointe.purge_later if commentaire.piece_jointe.attached? - commentaire.discard! - commentaire.update!(body: '') - flash[:notice] = t('views.shared.commentaires.destroy.notice') + if commentaire.sent_by?(current_instructeur) || commentaire.sent_by?(current_expert) + commentaire.soft_delete! + + flash.notice = t('.notice') else - flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.acl') + flash.alert = t('.alert_acl') end - redirect_to(messagerie_instructeur_dossier_path(params[:procedure_id], params[:dossier_id])) rescue Discard::RecordNotDiscarded - flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.already_discarded') - redirect_to(messagerie_instructeur_dossier_path(params[:procedure_id], params[:dossier_id])) + # i18n-tasks-use t('instructeurs.commentaires.destroy.alert_already_discarded') + flash.alert = t('.alert_already_discarded') + end + + private + + def mark_messagerie_as_read + if commentaire.sent_by?(current_instructeur) + current_instructeur.mark_tab_as_seen(commentaire.dossier, :messagerie) + end + end + + def commentaire + @commentaire ||= Dossier + .find(params[:dossier_id]) + .commentaires + .find(params[:id]) end end end diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 8a35c774c..0e1335ef3 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -84,7 +84,7 @@ class RootController < ApplicationController respond_to do |format| format.html { redirect_back(fallback_location: root_path) } - format.js { render js: helpers.remove_element('#outdated-browser-banner') } + format.turbo_stream end end diff --git a/app/dashboards/archive_dashboard.rb b/app/dashboards/archive_dashboard.rb index 50d53ea38..a18e8790b 100644 --- a/app/dashboards/archive_dashboard.rb +++ b/app/dashboards/archive_dashboard.rb @@ -12,7 +12,7 @@ class ArchiveDashboard < Administrate::BaseDashboard created_at: Field::DateTime, updated_at: Field::DateTime, status: Field::String, - file: Field::HasOne + file: AttachmentField }.freeze # COLLECTION_ATTRIBUTES @@ -24,7 +24,8 @@ class ArchiveDashboard < Administrate::BaseDashboard :id, :created_at, :updated_at, - :status + :status, + :file ].freeze # SHOW_PAGE_ATTRIBUTES @@ -33,14 +34,6 @@ class ArchiveDashboard < Administrate::BaseDashboard :id, :created_at, :updated_at, - :status, - :file + :status ].freeze - - # Overwrite this method to customize how users are displayed - # across all pages of the admin dashboard. - # - def display_resource(archive) - "Archive : #{archive&.file.&byte_size}" - end end diff --git a/app/fields/attachment_field.rb b/app/fields/attachment_field.rb index 2022fb355..4a09d2d7e 100644 --- a/app/fields/attachment_field.rb +++ b/app/fields/attachment_field.rb @@ -1,8 +1,9 @@ require "administrate/field/base" class AttachmentField < Administrate::Field::Base + include ActionView::Helpers::NumberHelper def to_s - data.filename.to_s + "#{data.filename} (#{number_to_human_size(data.byte_size)})" end def blob_path diff --git a/app/helpers/commentaire_helper.rb b/app/helpers/commentaire_helper.rb index 2518c8506..8ba5bfab8 100644 --- a/app/helpers/commentaire_helper.rb +++ b/app/helpers/commentaire_helper.rb @@ -12,20 +12,4 @@ module CommentaireHelper I18n.t('helpers.commentaire.reply_in_mailbox') end end - - def commentaire_is_from_guest(commentaire) - commentaire.dossier.invites.map(&:email).include?(commentaire.email) - end - - def commentaire_date(commentaire) - is_current_year = (commentaire.created_at.year == Time.zone.today.year) - template = is_current_year ? :message_date : :message_date_with_year - I18n.l(commentaire.created_at, format: template) - end - - def pretty_commentaire(commentaire) - return t('views.shared.commentaires.destroy.deleted_body') if commentaire.discarded? - body_formatted = commentaire.sent_by_system? ? commentaire.body : simple_format(commentaire.body) - sanitize(body_formatted) - end end diff --git a/app/helpers/conservation_de_donnees_helper.rb b/app/helpers/conservation_de_donnees_helper.rb index e3aeeba18..634eb5559 100644 --- a/app/helpers/conservation_de_donnees_helper.rb +++ b/app/helpers/conservation_de_donnees_helper.rb @@ -7,7 +7,9 @@ module ConservationDeDonneesHelper def conservation_dans_ds(procedure) if procedure.duree_conservation_dossiers_dans_ds.present? - "Dans #{APPLICATION_NAME} : #{procedure.duree_conservation_dossiers_dans_ds} mois" + I18n.t('users.procedure_footer.legals.data_retention', + application_name: APPLICATION_NAME, + duree_conservation_dossiers_dans_ds: procedure.duree_conservation_dossiers_dans_ds) end end end diff --git a/app/helpers/procedure_helper.rb b/app/helpers/procedure_helper.rb index 5cfc94452..118169430 100644 --- a/app/helpers/procedure_helper.rb +++ b/app/helpers/procedure_helper.rb @@ -74,4 +74,12 @@ module ProcedureHelper .includes(:groupe_instructeur) .exists?(groupe_instructeur: current_instructeur.groupe_instructeurs) end + + def url_or_email_to_lien_dpo(procedure) + URI::MailTo.build([procedure.lien_dpo, "subject="]).to_s + rescue URI::InvalidComponentError + uri = URI.parse(procedure.lien_dpo) + return "//#{uri}" if uri.scheme.nil? + uri.to_s + end end diff --git a/app/helpers/turbo_stream_helper.rb b/app/helpers/turbo_stream_helper.rb new file mode 100644 index 000000000..40699c66e --- /dev/null +++ b/app/helpers/turbo_stream_helper.rb @@ -0,0 +1,35 @@ +module TurboStreamHelper + def turbo_stream + TagBuilder.new(self) + end + + class TagBuilder < Turbo::Streams::TagBuilder + def dispatch(type, detail) + append_all('turbo-events', partial: 'layouts/turbo_event', locals: { type: type, detail: detail }) + end + + def show(target, delay: nil) + dispatch('dom:mutation', { action: :show, target: target, delay: delay }.compact) + end + + def show_all(targets, delay: nil) + dispatch('dom:mutation', { action: :show, targets: targets, delay: delay }.compact) + end + + def hide(target, delay: nil) + dispatch('dom:mutation', { action: :hide, target: target, delay: delay }.compact) + end + + def hide_all(targets, delay: nil) + dispatch('dom:mutation', { action: :hide, targets: targets, delay: delay }.compact) + end + + def focus(target) + dispatch('dom:mutation', { action: :focus, target: target }) + end + + def focus_all(targets) + dispatch('dom:mutation', { action: :focus, targets: targets }) + end + end +end diff --git a/app/javascript/components/Chartkick.jsx b/app/javascript/components/Chartkick.jsx deleted file mode 100644 index aef586b53..000000000 --- a/app/javascript/components/Chartkick.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import Chartkick from 'chartkick'; -import Highcharts from 'highcharts'; -import { toggle, delegate } from '@utils'; - -export default function () { - return null; -} - -function toggleChart(event) { - const nextSelectorItem = event.target, - chartClass = event.target.dataset.toggleChart, - nextChart = document.querySelector(chartClass), - nextChartId = nextChart.children[0].id, - currentSelectorItem = nextSelectorItem.parentElement.querySelector( - '.segmented-control-item-active' - ), - currentChart = nextSelectorItem.parentElement.parentElement.querySelector( - '.chart:not(.hidden)' - ); - - // Change the current selector and the next selector states - currentSelectorItem.classList.toggle('segmented-control-item-active'); - nextSelectorItem.classList.toggle('segmented-control-item-active'); - - // Hide the currently shown chart and show the new one - toggle(currentChart); - toggle(nextChart); - - // Reflow needed, see https://github.com/highcharts/highcharts/issues/1979 - Chartkick.charts[nextChartId].getChartObject().reflow(); -} - -delegate('click', '[data-toggle-chart]', toggleChart); - -Chartkick.use(Highcharts); diff --git a/app/javascript/components/Chartkick.tsx b/app/javascript/components/Chartkick.tsx new file mode 100644 index 000000000..e274930d9 --- /dev/null +++ b/app/javascript/components/Chartkick.tsx @@ -0,0 +1,38 @@ +import Chartkick from 'chartkick'; +import Highcharts from 'highcharts'; +import { toggle, delegate } from '@utils'; + +export default function () { + return null; +} + +function toggleChart(event: MouseEvent) { + const nextSelectorItem = event.target as HTMLButtonElement, + chartClass = nextSelectorItem.dataset.toggleChart, + nextChart = chartClass + ? document.querySelector(chartClass) + : undefined, + nextChartId = nextChart?.children[0]?.id, + currentSelectorItem = nextSelectorItem.parentElement?.querySelector( + '.segmented-control-item-active' + ), + currentChart = + nextSelectorItem.parentElement?.parentElement?.querySelector( + '.chart:not(.hidden)' + ); + + // Change the current selector and the next selector states + currentSelectorItem?.classList.toggle('segmented-control-item-active'); + nextSelectorItem.classList.toggle('segmented-control-item-active'); + + // Hide the currently shown chart and show the new one + currentChart && toggle(currentChart); + nextChart && toggle(nextChart); + + // Reflow needed, see https://github.com/highcharts/highcharts/issues/1979 + nextChartId && Chartkick.charts[nextChartId]?.getChartObject()?.reflow(); +} + +delegate('click', '[data-toggle-chart]', toggleChart); + +Chartkick.use(Highcharts); diff --git a/app/javascript/components/ComboAnnuaireEducationSearch.jsx b/app/javascript/components/ComboAnnuaireEducationSearch.tsx similarity index 50% rename from app/javascript/components/ComboAnnuaireEducationSearch.jsx rename to app/javascript/components/ComboAnnuaireEducationSearch.tsx index a0bc4c969..23fc46ec4 100644 --- a/app/javascript/components/ComboAnnuaireEducationSearch.jsx +++ b/app/javascript/components/ComboAnnuaireEducationSearch.tsx @@ -1,16 +1,32 @@ import React from 'react'; import { QueryClientProvider } from 'react-query'; -import ComboSearch from './ComboSearch'; +import ComboSearch, { ComboSearchProps } from './ComboSearch'; import { queryClient } from './shared/queryClient'; -function ComboAnnuaireEducationSearch(props) { +type AnnuaireEducationResult = { + fields: { + identifiant_de_l_etablissement: string; + nom_etablissement: string; + nom_commune: string; + }; +}; + +function transformResults(_: unknown, result: unknown) { + const results = result as { records: AnnuaireEducationResult[] }; + return results.records as AnnuaireEducationResult[]; +} + +export default function ComboAnnuaireEducationSearch( + props: ComboSearchProps +) { return ( records} + transformResults={transformResults} transformResult={({ fields: { identifiant_de_l_etablissement: id, @@ -18,10 +34,7 @@ function ComboAnnuaireEducationSearch(props) { nom_commune } }) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]} - {...props} /> ); } - -export default ComboAnnuaireEducationSearch; diff --git a/app/javascript/components/ComboCommunesSearch.jsx b/app/javascript/components/ComboCommunesSearch.tsx similarity index 85% rename from app/javascript/components/ComboCommunesSearch.jsx rename to app/javascript/components/ComboCommunesSearch.tsx index 85d1b54c3..b8e723ee2 100644 --- a/app/javascript/components/ComboCommunesSearch.jsx +++ b/app/javascript/components/ComboCommunesSearch.tsx @@ -1,19 +1,21 @@ import React from 'react'; import { QueryClientProvider } from 'react-query'; import { matchSorter } from 'match-sorter'; -import PropTypes from 'prop-types'; -import ComboSearch from './ComboSearch'; +import ComboSearch, { ComboSearchProps } from './ComboSearch'; import { queryClient } from './shared/queryClient'; import { ComboDepartementsSearch } from './ComboDepartementsSearch'; import { useHiddenField, groupId } from './shared/hooks'; +type CommuneResult = { code: string; nom: string; codesPostaux: string[] }; + // Avoid hiding similar matches for precise queries (like "Sainte Marie") -function searchResultsLimit(term) { +function searchResultsLimit(term: string) { return term.length > 5 ? 10 : 5; } -function expandResultsWithMultiplePostalCodes(term, results) { +function expandResultsWithMultiplePostalCodes(term: string, result: unknown) { + const results = result as CommuneResult[]; // A single result may have several associated postal codes. // To make the search results more precise, we want to generate // an actual result for each postal code. @@ -44,13 +46,16 @@ const placeholderDepartements = [ ['77 – Seine-et-Marne', 'Melun'], ['22 – Côtes d’Armor', 'Saint-Brieuc'], ['47 – Lot-et-Garonne', 'Agen'] -]; +] as const; const [placeholderDepartement, placeholderCommune] = placeholderDepartements[ Math.floor(Math.random() * (placeholderDepartements.length - 1)) ]; -function ComboCommunesSearch({ id, ...props }) { +export default function ComboCommunesSearch({ + id, + ...props +}: ComboSearchProps & { id: string }) { const group = groupId(id); const [departementValue, setDepartementValue] = useHiddenField( group, @@ -74,14 +79,14 @@ function ComboCommunesSearch({ id, ...props }) { { - setDepartementValue(result?.nom); - setCodeDepartement(result?.code); + setDepartementValue(result?.nom ?? ''); + setCodeDepartement(result?.code ?? ''); }} /> @@ -112,9 +117,3 @@ function ComboCommunesSearch({ id, ...props }) { ); } - -ComboCommunesSearch.propTypes = { - id: PropTypes.string -}; - -export default ComboCommunesSearch; diff --git a/app/javascript/components/ComboDepartementsSearch.jsx b/app/javascript/components/ComboDepartementsSearch.tsx similarity index 61% rename from app/javascript/components/ComboDepartementsSearch.jsx rename to app/javascript/components/ComboDepartementsSearch.tsx index dab35ebb4..6b314b357 100644 --- a/app/javascript/components/ComboDepartementsSearch.jsx +++ b/app/javascript/components/ComboDepartementsSearch.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { QueryClientProvider } from 'react-query'; import { matchSorter } from 'match-sorter'; -import ComboSearch from './ComboSearch'; +import ComboSearch, { ComboSearchProps } from './ComboSearch'; import { queryClient } from './shared/queryClient'; +type DepartementResult = { code: string; nom: string }; + const extraTerms = [{ code: '99', nom: 'Etranger' }]; -function expandResultsWithForeignDepartement(term, results) { +function expandResultsWithForeignDepartement(term: string, result: unknown) { + const results = result as DepartementResult[]; return [ ...results, ...matchSorter(extraTerms, term, { @@ -17,10 +19,17 @@ function expandResultsWithForeignDepartement(term, results) { ]; } +type ComboDepartementsSearchProps = Omit< + ComboSearchProps & { + addForeignDepartement: boolean; + }, + 'transformResult' | 'transformResults' +>; + export function ComboDepartementsSearch({ addForeignDepartement = true, ...props -}) { +}: ComboDepartementsSearchProps) { return ( ); } - -ComboDepartementsSearch.propTypes = { - ...ComboSearch.propTypes, - addForeignDepartement: PropTypes.bool -}; - -export default ComboDepartementsSearchDefault; diff --git a/app/javascript/components/ComboMultipleDropdownList.jsx b/app/javascript/components/ComboMultipleDropdownList.jsx deleted file mode 100644 index b918a5603..000000000 --- a/app/javascript/components/ComboMultipleDropdownList.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { groupId } from './shared/hooks'; -import ComboMultiple from './ComboMultiple'; - -function ComboMultipleDropdownList({ id, ...props }) { - return ; -} - -ComboMultipleDropdownList.propTypes = { - id: PropTypes.string -}; - -export default ComboMultipleDropdownList; diff --git a/app/javascript/components/ComboMultipleDropdownList.tsx b/app/javascript/components/ComboMultipleDropdownList.tsx new file mode 100644 index 000000000..a1cbd188b --- /dev/null +++ b/app/javascript/components/ComboMultipleDropdownList.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { groupId } from './shared/hooks'; +import ComboMultiple, { ComboMultipleProps } from './ComboMultiple'; + +export default function ComboMultipleDropdownList({ + id, + ...props +}: ComboMultipleProps & { id: string }) { + return ; +} diff --git a/app/javascript/components/ComboPaysSearch.jsx b/app/javascript/components/ComboPaysSearch.tsx similarity index 68% rename from app/javascript/components/ComboPaysSearch.jsx rename to app/javascript/components/ComboPaysSearch.tsx index d44fde47c..c69aaaa4b 100644 --- a/app/javascript/components/ComboPaysSearch.jsx +++ b/app/javascript/components/ComboPaysSearch.tsx @@ -1,20 +1,20 @@ import React from 'react'; import { QueryClientProvider } from 'react-query'; -import ComboSearch from './ComboSearch'; +import ComboSearch, { ComboSearchProps } from './ComboSearch'; import { queryClient } from './shared/queryClient'; -function ComboPaysSearch(props) { +export default function ComboPaysSearch( + props: ComboSearchProps<{ code: string; value: string; label: string }> +) { return ( [code, value, label]} - {...props} /> ); } - -export default ComboPaysSearch; diff --git a/app/javascript/components/ComboRegionsSearch.jsx b/app/javascript/components/ComboRegionsSearch.tsx similarity index 69% rename from app/javascript/components/ComboRegionsSearch.jsx rename to app/javascript/components/ComboRegionsSearch.tsx index e031cc5dd..a1afcc95c 100644 --- a/app/javascript/components/ComboRegionsSearch.jsx +++ b/app/javascript/components/ComboRegionsSearch.tsx @@ -1,20 +1,20 @@ import React from 'react'; import { QueryClientProvider } from 'react-query'; -import ComboSearch from './ComboSearch'; +import ComboSearch, { ComboSearchProps } from './ComboSearch'; import { queryClient } from './shared/queryClient'; -function ComboRegionsSearch(props) { +export default function ComboRegionsSearch( + props: ComboSearchProps<{ code: string; nom: string }> +) { return ( [code, nom]} - {...props} /> ); } - -export default ComboRegionsSearch; diff --git a/app/javascript/components/ComboSearch.tsx b/app/javascript/components/ComboSearch.tsx index f3abd6a94..3ec7f9767 100644 --- a/app/javascript/components/ComboSearch.tsx +++ b/app/javascript/components/ComboSearch.tsx @@ -20,7 +20,7 @@ import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks'; type TransformResults = (term: string, results: unknown) => Result[]; type TransformResult = ( result: Result -) => [key: string, value: string, label: string]; +) => [key: string, value: string, label?: string]; export type ComboSearchProps = { onChange?: (value: string | null, result?: Result) => void; @@ -28,7 +28,7 @@ export type ComboSearchProps = { scope: string; scopeExtra?: string; minimumInputLength: number; - transformResults: TransformResults; + transformResults?: TransformResults; transformResult: TransformResult; allowInputValues?: boolean; id?: string; diff --git a/app/javascript/components/MapEditor/hooks.ts b/app/javascript/components/MapEditor/hooks.ts index bc74f63c3..8c941ac58 100644 --- a/app/javascript/components/MapEditor/hooks.ts +++ b/app/javascript/components/MapEditor/hooks.ts @@ -37,9 +37,7 @@ export function useFeatureCollection( type: 'FeatureCollection', features: callback(features) })); - ajax({ url, type: 'GET' }) - .then(() => fire(document, 'ds:page:update')) - .catch(() => null); + ajax({ url, type: 'GET' }).catch(() => null); }, [url, setFeatureCollection] ); diff --git a/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.tsx b/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.tsx index 4fd8c31c7..4d5a2b34a 100644 --- a/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.tsx +++ b/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.tsx @@ -161,6 +161,7 @@ export const TypeDeChampComponent = SortableElement( /> ; @@ -32,6 +34,17 @@ export function TypeDeChampPieceJustificative({ ); } + + if (isTitreIdentite) { + return ( +
+

+ Dans le cadre de la RGPD, le titre d'identité sera supprimé lors + de l'acceptation du dossier +

+
+ ); + } return null; } diff --git a/app/javascript/controllers/application_controller.ts b/app/javascript/controllers/application_controller.ts new file mode 100644 index 000000000..3ef5e014f --- /dev/null +++ b/app/javascript/controllers/application_controller.ts @@ -0,0 +1,25 @@ +import { Controller } from '@hotwired/stimulus'; +import { debounce } from '@utils'; + +export type Detail = Record; + +export class ApplicationController extends Controller { + #debounced = new Map<() => void, () => void>(); + + protected debounce(fn: () => void, interval: number): void { + let debounced = this.#debounced.get(fn); + if (!debounced) { + debounced = debounce(fn.bind(this), interval); + this.#debounced.set(fn, debounced); + } + debounced(); + } + + protected globalDispatch(type: string, detail: Detail): void { + this.dispatch(type, { + detail, + prefix: '', + target: document.documentElement + }); + } +} diff --git a/app/javascript/controllers/geo_area_controller.tsx b/app/javascript/controllers/geo_area_controller.tsx new file mode 100644 index 000000000..fd73a236e --- /dev/null +++ b/app/javascript/controllers/geo_area_controller.tsx @@ -0,0 +1,31 @@ +import { ApplicationController } from './application_controller'; + +export class GeoAreaController extends ApplicationController { + static values = { + id: Number + }; + static targets = ['description']; + + declare readonly idValue: number; + declare readonly descriptionTarget: HTMLInputElement; + + onFocus() { + this.globalDispatch('map:feature:focus', { id: this.idValue }); + } + + onClick(event: MouseEvent) { + event.preventDefault(); + this.globalDispatch('map:feature:focus', { id: this.idValue }); + } + + onInput() { + this.debounce(this.updateDescription, 200); + } + + private updateDescription(): void { + this.globalDispatch('map:feature:update', { + id: this.idValue, + properties: { description: this.descriptionTarget.value.trim() } + }); + } +} diff --git a/app/javascript/controllers/turbo_event_controller.ts b/app/javascript/controllers/turbo_event_controller.ts new file mode 100644 index 000000000..f66b63ffd --- /dev/null +++ b/app/javascript/controllers/turbo_event_controller.ts @@ -0,0 +1,82 @@ +import invariant from 'tiny-invariant'; +import { z } from 'zod'; + +import { ApplicationController, Detail } from './application_controller'; + +export class TurboEventController extends ApplicationController { + static values = { + type: String, + detail: Object + }; + + declare readonly typeValue: string; + declare readonly detailValue: Detail; + + connect(): void { + this.globalDispatch(this.typeValue, this.detailValue); + this.element.remove(); + } +} + +const MutationAction = z.enum(['show', 'hide', 'focus']); +type MutationAction = z.infer; +const Mutation = z.union([ + z.object({ + action: MutationAction, + delay: z.number().optional(), + target: z.string() + }), + z.object({ + action: MutationAction, + delay: z.number().optional(), + targets: z.string() + }) +]); +type Mutation = z.infer; + +addEventListener('dom:mutation', (event) => { + const detail = (event as CustomEvent).detail; + const mutation = Mutation.parse(detail); + mutate(mutation); +}); + +const Mutations: Record void> = { + hide: (mutation) => { + for (const element of findElements(mutation)) { + element.classList.add('hidden'); + } + }, + show: (mutation) => { + for (const element of findElements(mutation)) { + element.classList.remove('hidden'); + } + }, + focus: (mutation) => { + for (const element of findElements(mutation)) { + element.focus(); + } + } +}; + +function mutate(mutation: Mutation) { + const fn = Mutations[mutation.action]; + invariant(fn, `Could not find mutation ${mutation.action}`); + if (mutation.delay) { + setTimeout(() => fn(mutation), mutation.delay); + } else { + fn(mutation); + } +} + +function findElements( + mutation: Mutation +): Element[] { + if ('target' in mutation) { + const element = document.querySelector(`#${mutation.target}`); + invariant(element, `Could not find element with id ${mutation.target}`); + return [element]; + } else if ('targets' in mutation) { + return [...document.querySelectorAll(mutation.targets)]; + } + invariant(false, 'Could not find element'); +} diff --git a/app/javascript/new_design/champs/carte.js b/app/javascript/new_design/champs/carte.js deleted file mode 100644 index dbb569c50..000000000 --- a/app/javascript/new_design/champs/carte.js +++ /dev/null @@ -1,40 +0,0 @@ -import { delegate, fire, debounce } from '@utils'; - -const inputHandlers = new Map(); - -addEventListener('ds:page:update', () => { - const inputs = document.querySelectorAll('.areas input[data-geo-area]'); - - for (const input of inputs) { - input.addEventListener('focus', (event) => { - const id = parseInt(event.target.dataset.geoArea); - fire(document, 'map:feature:focus', { id }); - }); - } -}); - -delegate('click', '.areas a[data-geo-area]', (event) => { - event.preventDefault(); - const id = parseInt(event.target.dataset.geoArea); - fire(document, 'map:feature:focus', { id }); -}); - -delegate('input', '.areas input[data-geo-area]', (event) => { - const id = parseInt(event.target.dataset.geoArea); - - let handler = inputHandlers.get(id); - if (!handler) { - handler = debounce(() => { - const input = document.querySelector(`input[data-geo-area="${id}"]`); - if (input) { - fire(document, 'map:feature:update', { - id, - properties: { description: input.value.trim() } - }); - } - }, 200); - inputHandlers.set(id, handler); - } - - handler(); -}); diff --git a/app/javascript/new_design/dossiers/auto-save.js b/app/javascript/new_design/dossiers/auto-save.js index bc57c9b86..e437cdf7f 100644 --- a/app/javascript/new_design/dossiers/auto-save.js +++ b/app/javascript/new_design/dossiers/auto-save.js @@ -10,8 +10,9 @@ import { removeClass } from '@utils'; -const AUTOSAVE_DEBOUNCE_DELAY = gon.autosave.debounce_delay; -const AUTOSAVE_STATUS_VISIBLE_DURATION = gon.autosave.status_visible_duration; +const AUTOSAVE_DEBOUNCE_DELAY = window?.gon?.autosave?.debounce_delay; +const AUTOSAVE_STATUS_VISIBLE_DURATION = + window?.gon?.autosave?.status_visible_duration; // Create a controller responsible for queuing autosave operations. const autoSaveController = new AutoSaveController(); diff --git a/app/javascript/new_design/messagerie.js b/app/javascript/new_design/messagerie.js index 0a3e01b39..6f1a010da 100644 --- a/app/javascript/new_design/messagerie.js +++ b/app/javascript/new_design/messagerie.js @@ -54,5 +54,5 @@ function saveMessageContent() { } } -addEventListener('ds:page:update', scrollMessagerie); -addEventListener('ds:page:update', saveMessageContent); +addEventListener('DOMContentLoaded', scrollMessagerie); +addEventListener('DOMContentLoaded', saveMessageContent); diff --git a/app/javascript/new_design/procedure-context.js b/app/javascript/new_design/procedure-context.js index 4ec90a6f2..753811c76 100644 --- a/app/javascript/new_design/procedure-context.js +++ b/app/javascript/new_design/procedure-context.js @@ -14,7 +14,7 @@ function expandProcedureDescription() { descBody.classList.remove('read-more-collapsed'); } -addEventListener('ds:page:update', updateReadMoreVisibility); +addEventListener('DOMContentLoaded', updateReadMoreVisibility); addEventListener('resize', updateReadMoreVisibility); delegate('click', '.read-more-button', expandProcedureDescription); diff --git a/app/javascript/new_design/support.js b/app/javascript/new_design/support.js index 8c590ae00..923f8417e 100644 --- a/app/javascript/new_design/support.js +++ b/app/javascript/new_design/support.js @@ -101,7 +101,7 @@ class ButtonExpand { if (document.querySelector('#contact-form')) { window.addEventListener( - 'ds:page:update', + 'DOMContentLoaded', function () { var buttons = document.querySelectorAll( 'button[aria-expanded][aria-controls], button.button-without-hint' diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 89b5c3e7a..ef4934024 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -3,8 +3,8 @@ import Rails from '@rails/ujs'; import * as ActiveStorage from '@rails/activestorage'; import 'whatwg-fetch'; // window.fetch polyfill import { Application } from '@hotwired/stimulus'; +import { Turbo } from '@hotwired/turbo-rails'; -import '../shared/page-update-event'; import '../shared/activestorage/ujs'; import '../shared/remote-poller'; import '../shared/safari-11-file-xhr-workaround'; @@ -17,6 +17,8 @@ import { ReactController, registerComponents } from '../controllers/react_controller'; +import { TurboEventController } from '../controllers/turbo_event_controller'; +import { GeoAreaController } from '../controllers/geo_area_controller'; import '../new_design/dropdown'; import '../new_design/form-validation'; @@ -28,7 +30,6 @@ import '../new_design/messagerie'; import '../new_design/dossiers/auto-save'; import '../new_design/dossiers/auto-upload'; -import '../new_design/champs/carte'; import '../new_design/champs/linked-drop-down-list'; import '../new_design/champs/repetition'; import '../new_design/champs/drop-down-list'; @@ -89,9 +90,12 @@ const DS = { // Start Rails helpers Rails.start(); ActiveStorage.start(); +Turbo.session.drive = false; const Stimulus = Application.start(); Stimulus.register('react', ReactController); +Stimulus.register('turbo-event', TurboEventController); +Stimulus.register('geo-area', GeoAreaController); // Expose globals window.DS = window.DS || DS; diff --git a/app/javascript/shared/activestorage/file-upload-error.ts b/app/javascript/shared/activestorage/file-upload-error.ts index 7265ee67e..cd7aeb292 100644 --- a/app/javascript/shared/activestorage/file-upload-error.ts +++ b/app/javascript/shared/activestorage/file-upload-error.ts @@ -63,7 +63,9 @@ export default class FileUploadError extends Error { // 2. Create each kind of error on a different line // (so that Sentry knows they are different kind of errors, from // the line they were created.) -export function errorFromDirectUploadMessage(message: string) { +export function errorFromDirectUploadMessage(messageOrError: string | Error) { + const message = + typeof messageOrError == 'string' ? messageOrError : messageOrError.message; const matches = message.match(/ Status: ([0-9]{1,3})/); const status = matches ? parseInt(matches[1], 10) : undefined; diff --git a/app/javascript/shared/activestorage/uploader.ts b/app/javascript/shared/activestorage/uploader.ts index abbe6ac80..8fd789ab8 100644 --- a/app/javascript/shared/activestorage/uploader.ts +++ b/app/javascript/shared/activestorage/uploader.ts @@ -60,7 +60,7 @@ export default class Uploader { return new Promise((resolve, reject) => { this.directUpload.create((errorMsg, attributes) => { if (errorMsg) { - const error = errorFromDirectUploadMessage(errorMsg.message); + const error = errorFromDirectUploadMessage(errorMsg); reject(error); } else { resolve(attributes.signed_id); diff --git a/app/javascript/shared/franceconnect.js b/app/javascript/shared/franceconnect.js index 748121209..c739002d0 100644 --- a/app/javascript/shared/franceconnect.js +++ b/app/javascript/shared/franceconnect.js @@ -20,7 +20,7 @@ function init() { } } -addEventListener('ds:page:update', init); +addEventListener('DOMContentLoaded', init); function toggleElement(event) { event.preventDefault(); diff --git a/app/javascript/shared/page-update-event.js b/app/javascript/shared/page-update-event.js deleted file mode 100644 index 7a2c2ace2..000000000 --- a/app/javascript/shared/page-update-event.js +++ /dev/null @@ -1,9 +0,0 @@ -import { fire } from '@utils'; - -addEventListener('DOMContentLoaded', function () { - fire(document, 'ds:page:update'); -}); - -addEventListener('ajax:success', function () { - fire(document, 'ds:page:update'); -}); diff --git a/app/javascript/shared/utils.ts b/app/javascript/shared/utils.ts index 0882f088c..1c0c31ca4 100644 --- a/app/javascript/shared/utils.ts +++ b/app/javascript/shared/utils.ts @@ -42,15 +42,20 @@ export function removeClass(el: HTMLElement, cssClass: string) { el && el.classList.remove(cssClass); } -export function delegate( +export function delegate( eventNames: string, selector: string, - callback: () => void + callback: (event: E) => void ) { eventNames .split(' ') .forEach((eventName) => - Rails.delegate(document, selector, eventName, callback) + Rails.delegate( + document, + selector, + eventName, + callback as (event: Event) => void + ) ); } diff --git a/app/javascript/types.d.ts b/app/javascript/types.d.ts index 200e8886e..097ebf38e 100644 --- a/app/javascript/types.d.ts +++ b/app/javascript/types.d.ts @@ -21,3 +21,4 @@ declare module '@tmcw/togeojson/dist/togeojson.es.js' { } declare module 'react-coordinate-input'; +declare module 'chartkick'; diff --git a/app/lib/api_entreprise/api.rb b/app/lib/api_entreprise/api.rb index 821c383af..297ad0669 100644 --- a/app/lib/api_entreprise/api.rb +++ b/app/lib/api_entreprise/api.rb @@ -120,7 +120,7 @@ class APIEntreprise::API # rubocop:disable DS/ApplicationName params = { context: "demarches-simplifiees.fr", - recipient: siret_or_siren, + recipient: ENV.fetch('API_ENTREPRISE_DEFAULT_SIRET'), object: "procedure_id: #{procedure_id}", non_diffusables: true } diff --git a/app/models/commentaire.rb b/app/models/commentaire.rb index 61a48be6d..05103e367 100644 --- a/app/models/commentaire.rb +++ b/app/models/commentaire.rb @@ -89,6 +89,12 @@ class Commentaire < ApplicationRecord end end + def soft_delete! + piece_jointe.purge_later if piece_jointe.attached? + discard! + update! body: '' + end + private def notify diff --git a/app/models/concerns/mail_template_concern.rb b/app/models/concerns/mail_template_concern.rb index d4368957e..d1e1f2d10 100644 --- a/app/models/concerns/mail_template_concern.rb +++ b/app/models/concerns/mail_template_concern.rb @@ -33,7 +33,7 @@ module MailTemplateConcern module ClassMethods def default_for_procedure(procedure) template_name = default_template_name_for_procedure(procedure) - rich_body = ActionController::Base.new.render_to_string(template: template_name) + rich_body = ActionController::Base.render template: template_name trix_rich_body = rich_body.gsub(/(?)\n/, '') new(subject: const_get(:DEFAULT_SUBJECT), rich_body: trix_rich_body, procedure: procedure) end diff --git a/app/models/dossier_operation_log.rb b/app/models/dossier_operation_log.rb index 9a348680f..709ae4e2c 100644 --- a/app/models/dossier_operation_log.rb +++ b/app/models/dossier_operation_log.rb @@ -15,6 +15,8 @@ # instructeur_id :bigint # class DossierOperationLog < ApplicationRecord + self.ignored_columns = [:instructeur_id] + enum operation: { changer_groupe_instructeur: 'changer_groupe_instructeur', passer_en_instruction: 'passer_en_instruction', diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 49ffade30..fad4b088a 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -28,6 +28,7 @@ # juridique_required :boolean default(TRUE) # libelle :string # lien_demarche :string +# lien_dpo :string # lien_notice :string # lien_site_web :string # monavis_embed :text @@ -266,6 +267,7 @@ class Procedure < ApplicationRecord validate :check_juridique validates :path, presence: true, format: { with: /\A[a-z0-9_\-]{3,200}\z/ }, uniqueness: { scope: [:path, :closed_at, :hidden_at, :unpublished_at], case_sensitive: false } validates :duree_conservation_dossiers_dans_ds, allow_nil: false, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_DUREE_CONSERVATION } + validates :lien_dpo, email_or_link: true, allow_nil: true validates_with MonAvisEmbedValidator FILE_MAX_SIZE = 20.megabytes @@ -464,6 +466,7 @@ class Procedure < ApplicationRecord procedure.closed_at = nil procedure.unpublished_at = nil procedure.published_at = nil + procedure.auto_archive_on = nil procedure.lien_notice = nil procedure.published_revision = nil procedure.draft_revision.procedure = procedure diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 907426fd6..ffc99dbfd 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -3,16 +3,18 @@ class ProcedureExportService def initialize(procedure, dossiers) @procedure = procedure - @dossiers = dossiers.downloadable_sorted_batch + @dossiers = dossiers @tables = [:dossiers, :etablissements, :avis] + champs_repetables_options end def to_csv + @dossiers = @dossiers.downloadable_sorted_batch io = StringIO.new(SpreadsheetArchitect.to_csv(options_for(:dossiers, :csv))) create_blob(io, :csv) end def to_xlsx + @dossiers = @dossiers.downloadable_sorted_batch # We recursively build multi page spreadsheet io = @tables.reduce(nil) do |package, table| SpreadsheetArchitect.to_axlsx_package(options_for(table, :xlsx), package) @@ -21,6 +23,7 @@ class ProcedureExportService end def to_ods + @dossiers = @dossiers.downloadable_sorted_batch # We recursively build multi page spreadsheet io = StringIO.new(@tables.reduce(nil) do |spreadsheet, table| SpreadsheetArchitect.to_rodf_spreadsheet(options_for(table, :ods), spreadsheet) diff --git a/app/validators/email_or_link_validator.rb b/app/validators/email_or_link_validator.rb new file mode 100644 index 000000000..7b0358256 --- /dev/null +++ b/app/validators/email_or_link_validator.rb @@ -0,0 +1,7 @@ +class EmailOrLinkValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + URI.parse(value) + rescue URI::InvalidURIError + record.errors.add(attribute, :invalid_uri_or_email) + end +end diff --git a/app/views/administrateurs/procedure_administrateurs/_add_admin_form.html.haml b/app/views/administrateurs/procedure_administrateurs/_add_admin_form.html.haml index c59f2f75d..3f744264a 100644 --- a/app/views/administrateurs/procedure_administrateurs/_add_admin_form.html.haml +++ b/app/views/administrateurs/procedure_administrateurs/_add_admin_form.html.haml @@ -1,7 +1,7 @@ = form_for procedure.administrateurs.new(user: User.new), url: { controller: 'procedure_administrateurs' }, - html: { class: 'form', id: "procedure-#{procedure.id}-new_administrateur" } , - remote: true do |f| + html: { class: 'form', id: "new_administrateur" }, + data: { turbo: true } do |f| = f.label :email do Ajouter un administrateur %p.notice Renseignez l’email d’un administrateur déjà enregistré sur #{APPLICATION_NAME} pour lui permettre de modifier « #{procedure.libelle} ». diff --git a/app/views/administrateurs/procedure_administrateurs/_administrateur.html.haml b/app/views/administrateurs/procedure_administrateurs/_administrateur.html.haml index ca8fb3310..dce0a1a83 100644 --- a/app/views/administrateurs/procedure_administrateurs/_administrateur.html.haml +++ b/app/views/administrateurs/procedure_administrateurs/_administrateur.html.haml @@ -1,4 +1,4 @@ -%tr{ id: "procedure-#{@procedure.id}-administrateur-#{administrateur.id}" } +%tr{ id: dom_id(administrateur) } %td= administrateur.email %td= try_format_datetime(administrateur.created_at) %td= administrateur.registration_state @@ -6,8 +6,8 @@ - if administrateur == current_administrateur C’est vous ! - else - = link_to 'Retirer', - admin_procedure_administrateur_path(@procedure, administrateur), + = button_to 'Retirer', + admin_procedure_administrateur_path(procedure, administrateur), method: :delete, - 'data-confirm': "Retirer « #{administrateur.email} » des administrateurs de « #{@procedure.libelle} » ?", - remote: true + class: 'button', + form: { data: { turbo: true, turbo_confirm: "Retirer « #{administrateur.email} » des administrateurs de « #{procedure.libelle} » ?" } } diff --git a/app/views/administrateurs/procedure_administrateurs/create.js.haml b/app/views/administrateurs/procedure_administrateurs/create.js.haml deleted file mode 100644 index 610fbd95b..000000000 --- a/app/views/administrateurs/procedure_administrateurs/create.js.haml +++ /dev/null @@ -1,9 +0,0 @@ -= render_flash(sticky: true) -- if @administrateur - = append_to_element("#procedure-#{@procedure.id}-administrateurs", - partial: 'administrateur', - locals: { administrateur: @administrateur }) - = render_to_element("#procedure-#{@procedure.id}-new_administrateur", - partial: 'add_admin_form', - outer: true, - locals: { procedure: @procedure }) diff --git a/app/views/administrateurs/procedure_administrateurs/create.turbo_stream.haml b/app/views/administrateurs/procedure_administrateurs/create.turbo_stream.haml new file mode 100644 index 000000000..b8af77c66 --- /dev/null +++ b/app/views/administrateurs/procedure_administrateurs/create.turbo_stream.haml @@ -0,0 +1,3 @@ +- if @administrateur.present? + = turbo_stream.append "administrateurs", partial: 'administrateur', locals: { procedure: @procedure, administrateur: @administrateur } + = turbo_stream.replace "new_administrateur", partial: 'add_admin_form', locals: { procedure: @procedure } diff --git a/app/views/administrateurs/procedure_administrateurs/destroy.js.haml b/app/views/administrateurs/procedure_administrateurs/destroy.js.haml deleted file mode 100644 index b56486490..000000000 --- a/app/views/administrateurs/procedure_administrateurs/destroy.js.haml +++ /dev/null @@ -1,4 +0,0 @@ -= render_flash(sticky: true) -- if @administrateur - = remove_element("#procedure-#{@procedure.id}-administrateur-#{@administrateur.id}") - diff --git a/app/views/administrateurs/procedure_administrateurs/destroy.turbo_stream.haml b/app/views/administrateurs/procedure_administrateurs/destroy.turbo_stream.haml new file mode 100644 index 000000000..4b4645f2d --- /dev/null +++ b/app/views/administrateurs/procedure_administrateurs/destroy.turbo_stream.haml @@ -0,0 +1,2 @@ +- if @administrateur.present? + = turbo_stream.remove(@administrateur) diff --git a/app/views/administrateurs/procedure_administrateurs/index.html.haml b/app/views/administrateurs/procedure_administrateurs/index.html.haml index d3545a511..9fbccb6bd 100644 --- a/app/views/administrateurs/procedure_administrateurs/index.html.haml +++ b/app/views/administrateurs/procedure_administrateurs/index.html.haml @@ -10,8 +10,8 @@ %th= 'Adresse email' %th= 'Enregistré le' %th= 'État' - %tbody{ id: "procedure-#{@procedure.id}-administrateurs" } - = render partial: 'administrateur', collection: @procedure.administrateurs.order('users.email') + %tbody#administrateurs + = render partial: 'administrateur', collection: @procedure.administrateurs.order('users.email'), locals: { procedure: @procedure } %tfoot %tr %th{ colspan: 4 } diff --git a/app/views/administrateurs/procedures/_informations.html.haml b/app/views/administrateurs/procedures/_informations.html.haml index c97170cda..0ec13a30b 100644 --- a/app/views/administrateurs/procedures/_informations.html.haml +++ b/app/views/administrateurs/procedures/_informations.html.haml @@ -57,6 +57,13 @@ = f.label :deliberation, 'Importer le texte' = text_upload_and_render f, @procedure.deliberation +%h3.header-subsection + RGPD +%p.notice + Pour certaines démarches, veuillez indiquer soit un mail le mail de contact de votre délégué à la protection des données, soit un lien web pointant vers les informations + += f.label :lien_dpo, 'Lien ou email pour contacter le Délégué à la Protection des Données (DPO)' += f.text_field :lien_dpo, class: 'form-control' %h3.header-subsection Notice explicative de la démarche %p.notice diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index aac9ca636..a52761878 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -4,6 +4,10 @@ metadatas: ["Créée le #{@procedure.created_at.strftime('%d/%m/%Y')} - n° #{@procedure.id}", "#{@procedure.close? ? "Close le #{@procedure.closed_at.strftime('%d/%m/%Y')}" : @procedure.locked? ? "Publiée - #{procedure_lien(@procedure)}" : "Brouillon"}"] } .container.procedure-admin-container + = link_to @procedure.active_revision.draft? ? commencer_dossier_vide_test_path(path: @procedure.path) : commencer_dossier_vide_path(path: @procedure.path), target: "_blank", rel: "noopener", class: 'button', id: "pdf-procedure" do + %span.icon.printer + PDF + = link_to apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'button', id: "preview-procedure" do %span.icon.preview Prévisualiser diff --git a/app/views/instructeurs/commentaires/destroy.turbo_stream.haml b/app/views/instructeurs/commentaires/destroy.turbo_stream.haml new file mode 100644 index 000000000..d84c39d0d --- /dev/null +++ b/app/views/instructeurs/commentaires/destroy.turbo_stream.haml @@ -0,0 +1,3 @@ +- if @commentaire.discarded? + = turbo_stream.update @commentaire do + = render Dossiers::MessageComponent.new(commentaire: @commentaire, connected_user: @commentaire.instructeur || @commentaire.expert) diff --git a/app/views/instructeurs/dossiers/print.html.haml b/app/views/instructeurs/dossiers/print.html.haml index 656085d58..12c2fa5d0 100644 --- a/app/views/instructeurs/dossiers/print.html.haml +++ b/app/views/instructeurs/dossiers/print.html.haml @@ -49,7 +49,7 @@ %ul.messages-list - @dossier.commentaires.with_attached_piece_jointe.each do |commentaire| %li - = render partial: "shared/dossiers/messages/message", locals: { commentaire: commentaire, connected_user: current_instructeur, messagerie_seen_at: nil, show_reply_button: false } + = render Dossiers::MessageComponent.new(commentaire: commentaire, connected_user: current_instructeur) %script{ type: "text/javascript" } window.print(); diff --git a/app/views/layouts/_outdated_browser_banner.html.haml b/app/views/layouts/_outdated_browser_banner.html.haml index 5efc631c2..e1ad0350a 100644 --- a/app/views/layouts/_outdated_browser_banner.html.haml +++ b/app/views/layouts/_outdated_browser_banner.html.haml @@ -16,6 +16,6 @@ %br Certaines parties du site ne fonctionneront pas correctement. .site-banner-actions - = button_to 'Ignorer', dismiss_outdated_browser_path, method: :post, remote: true, class: 'button btn', title: 'Ne plus afficher cet avertissement pendant une semaine' + = button_to 'Ignorer', dismiss_outdated_browser_path, method: :post, form: { data: { turbo: true } }, class: 'button btn', title: 'Ne plus afficher cet avertissement pendant une semaine' %a.btn.button.primary{ href: "https://browser-update.org/fr/update.html", target: "_blank", rel: "noopener" } Mettre à jour mon navigateur diff --git a/app/views/layouts/_turbo_event.html.haml b/app/views/layouts/_turbo_event.html.haml new file mode 100644 index 000000000..e7ddff930 --- /dev/null +++ b/app/views/layouts/_turbo_event.html.haml @@ -0,0 +1,5 @@ +%turbo-event{ data: { + controller: 'turbo-event', + turbo_event_type_value: type, + turbo_event_detail_value: detail.to_json +} } diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 52f4934b4..ed9d71b76 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -41,3 +41,5 @@ = content_for(:footer) = yield :charts_js + + %turbo-events diff --git a/app/views/layouts/application.turbo_stream.haml b/app/views/layouts/application.turbo_stream.haml new file mode 100644 index 000000000..343964e1f --- /dev/null +++ b/app/views/layouts/application.turbo_stream.haml @@ -0,0 +1,6 @@ +- if flash.any? + = turbo_stream.replace 'flash_messages', partial: 'layouts/flash_messages' + = turbo_stream.hide 'flash_messages', delay: 10000 + - flash.clear + += yield diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml new file mode 100644 index 000000000..cc9cebe04 --- /dev/null +++ b/app/views/layouts/component_preview.html.haml @@ -0,0 +1,27 @@ +!!! 5 +%html{ lang: html_lang, class: yield(:root_class) } + %head + %meta{ "http-equiv": "Content-Type", content: "text/html; charset=UTF-8" } + %meta{ "http-equiv": "X-UA-Compatible", content: "IE=edge" } + %meta{ name: "viewport", content: "width=device-width, initial-scale=1" } + = csrf_meta_tags + + %title + = content_for?(:title) ? "#{yield(:title)} · #{APPLICATION_NAME}" : APPLICATION_NAME + + = favicon_link_tag(image_url("#{FAVICON_16PX_SRC}"), type: "image/png", sizes: "16x16") + = favicon_link_tag(image_url("#{FAVICON_32PX_SRC}"), type: "image/png", sizes: "32x32") + = favicon_link_tag(image_url("#{FAVICON_96PX_SRC}"), type: "image/png", sizes: "96x96") + + = javascript_packs_with_chunks_tag 'application', defer: true + + = preload_link_tag(asset_url("Muli-Regular.woff2")) + = preload_link_tag(asset_url("Muli-Bold.woff2")) + + = stylesheet_link_tag 'application', media: 'all' + + %body{ class: browser.platform.ios? ? 'ios' : nil } + .page-wrapper + %main.m-6 + = content_for?(:content) ? yield(:content) : yield + %turbo-events diff --git a/app/views/root/dismiss_outdated_browser.turbo_stream.haml b/app/views/root/dismiss_outdated_browser.turbo_stream.haml new file mode 100644 index 000000000..e95546cc5 --- /dev/null +++ b/app/views/root/dismiss_outdated_browser.turbo_stream.haml @@ -0,0 +1 @@ += turbo_stream.remove('outdated-browser-banner') diff --git a/app/views/shared/champs/carte/_geo_area.html.haml b/app/views/shared/champs/carte/_geo_area.html.haml index 1915fbd4f..7f945f5d8 100644 --- a/app/views/shared/champs/carte/_geo_area.html.haml +++ b/app/views/shared/champs/carte/_geo_area.html.haml @@ -1,10 +1,10 @@ -%li{ class: editing ? 'mb-1' : 'flex column mb-2' } +%li{ class: editing ? 'mb-1' : 'flex column mb-2', data: { controller: 'geo-area', geo_area_id_value: geo_area.id } } - if editing - = link_to '#', data: { geo_area: geo_area.id } do + = link_to '#', data: { action: 'geo-area#onClick' } do = geo_area_label(geo_area) - = text_field_tag :description, geo_area.description, data: { geo_area: geo_area.id }, placeholder: 'Description', class: 'no-margin' + = text_field_tag :description, geo_area.description, data: { action: 'focus->geo-area#onFocus input->geo-area#onInput', geo_area_target: 'description' }, placeholder: 'Description', class: 'no-margin' - else - = link_to '#', data: { geo_area: geo_area.id } do + = link_to '#', data: { action: 'geo-area#onClick' } do = geo_area_label(geo_area) - if geo_area.description.present? %span diff --git a/app/views/shared/dossiers/_messagerie.html.haml b/app/views/shared/dossiers/_messagerie.html.haml index 039f01043..e72046db4 100644 --- a/app/views/shared/dossiers/_messagerie.html.haml +++ b/app/views/shared/dossiers/_messagerie.html.haml @@ -1,8 +1,8 @@ .messagerie.container %ul.messages-list - dossier.commentaires.with_attached_piece_jointe.each do |commentaire| - %li.message{ class: commentaire_is_from_me_class(commentaire, connected_user) } - = render partial: "shared/dossiers/messages/message", locals: { commentaire: commentaire, connected_user: connected_user, messagerie_seen_at: messagerie_seen_at, show_reply_button: show_reply_button(commentaire, connected_user) } + %li.message{ class: commentaire_is_from_me_class(commentaire, connected_user), id: dom_id(commentaire) } + = render Dossiers::MessageComponent.new(commentaire: commentaire, connected_user: connected_user, messagerie_seen_at: messagerie_seen_at, show_reply_button: show_reply_button(commentaire, connected_user)) - if dossier.messagerie_available? = render partial: "shared/dossiers/messages/form", locals: { commentaire: new_commentaire, form_url: form_url, dossier: dossier } diff --git a/app/views/shared/dossiers/messages/_message.html.haml b/app/views/shared/dossiers/messages/_message.html.haml deleted file mode 100644 index dececc1b6..000000000 --- a/app/views/shared/dossiers/messages/_message.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -= render partial: 'shared/dossiers/messages/message_icon', locals: { commentaire: commentaire, connected_user: connected_user } - -.width-100 - %h2 - %span.mail - = render partial: 'shared/dossiers/messages/message_issuer', locals: { commentaire: commentaire, connected_user: connected_user } - - if commentaire_is_from_guest(commentaire) - %span.guest= t('views.shared.dossiers.messages.message.guest') - %span.date{ class: highlight_if_unseen_class(messagerie_seen_at, commentaire.created_at) } - = commentaire_date(commentaire) - .rich-text= pretty_commentaire(commentaire) - - .message-extras.flex.justify-start - - if commentaire.soft_deletable?(connected_user) - - path = connected_user.is_a?(Instructeur) ? instructeur_commentaire_path(commentaire.dossier.procedure, commentaire.dossier, commentaire) : delete_commentaire_expert_avis_path(@avis.procedure, @avis, commentaire: commentaire) - = button_to path, method: :delete, class: 'button danger', data: { confirm: t('views.shared.commentaires.destroy.confirm') } do - %span.icon.delete - = t('views.shared.commentaires.destroy.button') - - - if commentaire.piece_jointe.attached? - .attachment-link - = render partial: "shared/attachment/show", locals: { attachment: commentaire.piece_jointe.attachment } - - - if show_reply_button - = button_tag type: 'button', class: 'button small message-answer-button', onclick: 'document.querySelector("#commentaire_body").focus()' do - %span.icon.reply - = t('views.shared.dossiers.messages.message.reply') diff --git a/app/views/shared/dossiers/messages/_message_icon.html.haml b/app/views/shared/dossiers/messages/_message_icon.html.haml deleted file mode 100644 index 849337641..000000000 --- a/app/views/shared/dossiers/messages/_message_icon.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -- if commentaire.sent_by_system? - = image_tag('icons/mail.svg', class: 'person-icon', alt: '') -- elsif commentaire.sent_by?(connected_user) - = image_tag('icons/account-circle.svg', class: 'person-icon', alt: '') -- else - = image_tag('icons/blue-person.svg', class: 'person-icon', alt: '') - diff --git a/app/views/shared/dossiers/messages/_message_issuer.html.haml b/app/views/shared/dossiers/messages/_message_issuer.html.haml deleted file mode 100644 index 87a12da64..000000000 --- a/app/views/shared/dossiers/messages/_message_issuer.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- if commentaire.sent_by_system? - = t('views.shared.dossiers.messages.message_issuer.automatic_email') -- elsif commentaire.sent_by?(connected_user) - = t('views.shared.dossiers.messages.message_issuer.you') -- else - = commentaire.redacted_email diff --git a/app/views/users/_procedure_footer.html.haml b/app/views/users/_procedure_footer.html.haml index 65c5f2f30..465ea060d 100644 --- a/app/views/users/_procedure_footer.html.haml +++ b/app/views/users/_procedure_footer.html.haml @@ -4,7 +4,7 @@ - if service.present? .footer-row.footer-columns .footer-column - %p.footer-header Cette démarche est gérée par : + %p.footer-header= I18n.t('users.procedure_footer.managed_by.header') %ul %li = service.nom @@ -14,37 +14,49 @@ = string_to_html(service.adresse, wrapper_tag = 'span') .footer-column - %p.footer-header Poser une question sur votre dossier : + %p.footer-header= I18n.t('users.procedure_footer.contact.header') %ul %li - if dossier.present? && dossier.messagerie_available? - Directement - = link_to "par la messagerie", messagerie_dossier_path(dossier) + = I18n.t('users.procedure_footer.contact.in_app_mail.prefix') + = link_to I18n.t('users.procedure_footer.contact.in_app_mail.link'), messagerie_dossier_path(dossier) - else - Par email : + = I18n.t('users.procedure_footer.contact.email.prefix') = link_to service.email, "mailto:#{service.email}" - if service.telephone.present? %li - Par téléphone : + = I18n.t('users.procedure_footer.contact.phone.prefix') = link_to service.telephone, service.telephone_url %li - - horaires = "Horaires : #{formatted_horaires(service.horaires)}" + - horaires = "#{I18n.t('users.procedure_footer.contact.schedule.prefix')}#{formatted_horaires(service.horaires)}" = simple_format(horaires, {}, wrapper_tag: 'span') %li - Statistiques : - = link_to "voir les statistiques de la démarche", statistiques_path(procedure.path) + = I18n.t('users.procedure_footer.contact.stats.prefix') + = link_to I18n.t('users.procedure_footer.contact.stats.cta'), statistiques_path(procedure.path) - politiques = politiques_conservation_de_donnees(procedure) - if politiques.present? .footer-column - %p.footer-header Conservation des données : + %p.footer-header= I18n.t('users.procedure_footer.legals.header') %ul - politiques.each do |politique| %li= politique + - if procedure.deliberation.attached? + %li + = link_to url_for(procedure.deliberation), target: '_blank', rel: 'noopener' do + = I18n.t("users.procedure_footer.legals.terms") + - else + %li + = link_to I18n.t("users.procedure_footer.legals.terms"), procedure.cadre_juridique, target: '_blank', rel: 'noopener' + + - if procedure.lien_dpo.present? + %li + = link_to url_or_email_to_lien_dpo(procedure), target: '_blank', rel: 'noopener' do + = I18n.t("users.procedure_footer.legals.dpo") = render partial: 'users/general_footer_row', locals: { dossier: dossier } diff --git a/app/views/users/dossiers/show/_latest_message.html.haml b/app/views/users/dossiers/show/_latest_message.html.haml index 73ec2e106..6fc81ff8e 100644 --- a/app/views/users/dossiers/show/_latest_message.html.haml +++ b/app/views/users/dossiers/show/_latest_message.html.haml @@ -4,7 +4,7 @@ %h3.tab-title= t('views.users.dossiers.show.latest_message.latest_message') .message.inverted-background - = render partial: "shared/dossiers/messages/message", locals: { commentaire: latest_message, connected_user: current_user, messagerie_seen_at: nil, show_reply_button: false } + = render Dossiers::MessageComponent.new(commentaire: latest_message, connected_user: current_user) = link_to messagerie_dossier_url(dossier, anchor: 'new_commentaire'), class: 'button send' do %span.icon.reply diff --git a/babel.config.js b/babel.config.js index 92513dd6a..d2e2823bb 100644 --- a/babel.config.js +++ b/babel.config.js @@ -86,12 +86,6 @@ module.exports = function (api) { { async: false } - ], - isProductionEnv && [ - 'babel-plugin-transform-react-remove-prop-types', - { - removeImport: true - } ] ].filter(Boolean) }; diff --git a/config/application.rb b/config/application.rb index e24e39038..5fce62b9e 100644 --- a/config/application.rb +++ b/config/application.rb @@ -81,5 +81,13 @@ module TPS # Custom Configuration # @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.show_previews_source = true + config.view_component.default_preview_layout = 'component_preview' + config.view_component.preview_paths << "#{Rails.root}/spec/components/previews" end end diff --git a/config/env.example b/config/env.example index c850f6de5..56276ac9a 100644 --- a/config/env.example +++ b/config/env.example @@ -157,3 +157,6 @@ INVISIBLE_CAPTCHA_SECRET="kikooloool" # Clamav antivirus usage CLAMAV_ENABLED="disabled" + +# Siret number used for API Entreprise, by default we use SIRET from dinum +API_ENTREPRISE_DEFAULT_SIRET="put_your_own_siret" diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index a4e992c04..702153547 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -68,6 +68,7 @@ search: - app/assets/images - app/assets/fonts - app/assets/videos + - app/components ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`: ## If specified, this settings takes priority over `exclude`, but `exclude` still applies. diff --git a/config/locales/en.yml b/config/locales/en.yml index b12a43ed0..bf6db58e3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -127,12 +127,6 @@ en: submit_dossier: Submit the file save_changes: Save the changes of the file messages: - message_issuer: - automatic_email: "Automatic email" - you: "You" - message: - reply: "Reply" - guest: "Guest" form: send_message: "Send message" attachment_size: "(attachment size max : 20 Mo)" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index e3574b2ef..177b44dd4 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -122,12 +122,6 @@ fr: submit_dossier: Déposer le dossier save_changes: Enregistrer les modifications du dossier messages: - message_issuer: - automatic_email: "Email automatique" - you: "Vous" - message: - reply: "Répondre" - guest: "Invité" form: send_message: "Envoyer le message" attachment_size: "(taille max : 20 Mo)" diff --git a/config/locales/links.en.yml b/config/locales/links.en.yml index 474d7b672..b3e41e3b4 100644 --- a/config/locales/links.en.yml +++ b/config/locales/links.en.yml @@ -12,8 +12,8 @@ en: url: "https://numerique.gouv.fr" footer: accessibilite: - label: "Accessibility: not compliant" - title: "Accessibility: not compliant" + label: "Accessibility: partially compliant" + title: "Accessibility: partially compliant" url: "https://doc.demarches-simplifiees.fr/declaration-daccessibilite" aide: label: "Help" diff --git a/config/locales/links.fr.yml b/config/locales/links.fr.yml index f55690670..a3d0ed9a5 100644 --- a/config/locales/links.fr.yml +++ b/config/locales/links.fr.yml @@ -12,8 +12,8 @@ fr: url: "https://numerique.gouv.fr" footer: accessibilite: - label: "Accessibilité : non conforme" - title: "Accessibilité : non conforme" + label: "Accessibilité : partiellement conforme" + title: "Accessibilité : partiellement conforme" url: "https://doc.demarches-simplifiees.fr/declaration-daccessibilite" aide: label: "Aide" diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 752936742..1c3f1b9e1 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -17,6 +17,7 @@ fr: declarative_with_state/en_instruction: En instruction declarative_with_state/accepte: Accepté api_particulier_token: Jeton API Particulier + lien_dpo: Contact du DPO errors: models: procedure: @@ -27,3 +28,5 @@ fr: format: 'Le champ %{message}' draft_types_de_champ_private: format: 'L’annotation privée %{message}' + lien_dpo: + invalid_uri_or_email: "Veuillez saisir un mail ou un lien" diff --git a/config/locales/views/instructeurs/commentaires/en.yml b/config/locales/views/instructeurs/commentaires/en.yml new file mode 100644 index 000000000..6915ce6d4 --- /dev/null +++ b/config/locales/views/instructeurs/commentaires/en.yml @@ -0,0 +1,7 @@ +en: + instructeurs: + commentaires: + destroy: + notice: Your message had been deleted + alert_acl: "Can not destroy message: it does not belong to you" + alert_already_discarded: "Can not destroy message: it was already destroyed" diff --git a/config/locales/views/instructeurs/commentaires/fr.yml b/config/locales/views/instructeurs/commentaires/fr.yml new file mode 100644 index 000000000..fd2b20b78 --- /dev/null +++ b/config/locales/views/instructeurs/commentaires/fr.yml @@ -0,0 +1,7 @@ +fr: + instructeurs: + commentaires: + destroy: + notice: Votre message a été supprimé + alert_acl: Impossible de supprimer le message, celui ci ne vous appartient pas + alert_already_discarded: Ce message a déjà été supprimé diff --git a/config/locales/views/instructeurs/en.yml b/config/locales/views/instructeurs/en.yml index 9ebb913db..49ea80d96 100644 --- a/config/locales/views/instructeurs/en.yml +++ b/config/locales/views/instructeurs/en.yml @@ -2,4 +2,4 @@ en: instructeurs: procedure: archive_pending_html: Archive creation pending
(requested %{created_period} ago) - archive_ready_html: Download archive
(requested %{generated_period} ago) \ No newline at end of file + archive_ready_html: Download archive
(requested %{generated_period} ago) diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 5770dd40f..f1bb0982c 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -20,12 +20,3 @@ en: already_user: "I already have an account" create: 'Create an account' signin: 'Sign in' - commentaires: - destroy: - button: 'Destroy this message' - confirm: "Are you sure you want to destroy this message ?" - deleted_body: Message deleted - notice: 'Your message had been deleted' - alert_reasons: - acl: "Can not destroy message: it does not belong to you" - already_discarded: "Can not destroy message: it was already destroyed" diff --git a/config/locales/views/shared/fr.yml b/config/locales/views/shared/fr.yml index 8325949e8..4053b3cd3 100644 --- a/config/locales/views/shared/fr.yml +++ b/config/locales/views/shared/fr.yml @@ -20,12 +20,3 @@ fr: already_user: 'J’ai déjà un compte' create: 'Créer un compte' signin: 'Connexion' - commentaires: - destroy: - button: 'Supprimer le message' - confirm: "Êtes-vous sûr de vouloir supprimer ce message ?" - deleted_body: Message supprimé - notice: 'Votre message a été supprimé' - alert_reasons: - acl: "Impossible de supprimer le message, celui ci ne vous appartient pas" - already_discarded: "Ce message a déjà été supprimé" diff --git a/config/locales/views/users/procedure_footer/en.yml b/config/locales/views/users/procedure_footer/en.yml new file mode 100644 index 000000000..31c54b19d --- /dev/null +++ b/config/locales/views/users/procedure_footer/en.yml @@ -0,0 +1,24 @@ +en: + users: + procedure_footer: + managed_by: + header: 'This procedure is managed by :' + contact: + header: 'Ask a question about your file :' + in_app_mail: + prefix: 'Directly :' + link: "via the chat" + email: + prefix: 'By mail :' + phone: + prefix: 'By phone :' + schedule: + prefix: 'Hours : ' + stats: + prefix: 'Stats :' + cta: "see the procedure's stats" + legals: + header: "Legals :" + data_retention: "Within %{application_name} : %{duree_conservation_dossiers_dans_ds} months" + terms: "Laws regarding this data collection" + dpo: "Contact the Data Protection Officer" diff --git a/config/locales/views/users/procedure_footer/fr.yml b/config/locales/views/users/procedure_footer/fr.yml new file mode 100644 index 000000000..b8a085703 --- /dev/null +++ b/config/locales/views/users/procedure_footer/fr.yml @@ -0,0 +1,24 @@ +fr: + users: + procedure_footer: + managed_by: + header: 'Cette démarche est gérée par :' + contact: + header: 'Poser une question sur votre dossier :' + in_app_mail: + prefix: 'Directement :' + link: "par la messagerie" + email: + prefix: 'Par email :' + phone: + prefix: 'Par téléphone :' + schedule: + prefix: 'Horaires : ' + stats: + prefix: 'Statistiques :' + cta: "voir les statistiques de la démarche" + legals: + header: "Cadre juridique :" + data_retention: "Dans %{application_name} : %{duree_conservation_dossiers_dans_ds} mois" + terms: "Texte cadrant la demande d'information" + dpo: "Contacter le Délégué à la Protection des Données" diff --git a/config/routes.rb b/config/routes.rb index 4dcfbe044..655a3247e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -246,7 +246,7 @@ Rails.application.routes.draw do end namespace :commencer do - get '/test/:path/dossier_vide', action: 'dossier_vide_pdf_test', as: :dossier_vide_test + get '/test/:path/dossier_vide', action: :dossier_vide_pdf_test, as: :dossier_vide_test get '/test/:path', action: 'commencer_test', as: :test get '/:path', action: 'commencer' get '/:path/dossier_vide', action: 'dossier_vide_pdf', as: :dossier_vide @@ -310,7 +310,6 @@ Rails.application.routes.draw do get 'instruction' get 'messagerie' post 'commentaire' => 'avis#create_commentaire' - delete 'delete_commentaire' => 'avis#delete_commentaire' post 'avis' => 'avis#create_avis' get 'bilans_bdf' get 'telecharger_pjs' => 'avis#telecharger_pjs' diff --git a/db/migrate/20220301160753_add_administrateur_foreign_key_to_administrateurs_procedure.rb b/db/migrate/20220301160753_add_administrateur_foreign_key_to_administrateurs_procedure.rb index 944940df1..ec60fcbad 100644 --- a/db/migrate/20220301160753_add_administrateur_foreign_key_to_administrateurs_procedure.rb +++ b/db/migrate/20220301160753_add_administrateur_foreign_key_to_administrateurs_procedure.rb @@ -2,8 +2,8 @@ class AddAdministrateurForeignKeyToAdministrateursProcedure < ActiveRecord::Migr include Database::MigrationHelpers def up - delete_orphans :administrateurs_procedures, :administrateurs_procedures - add_foreign_key :administrateurs_procedures, :administrateurs_procedures + delete_orphans :administrateurs_procedures, :administrateurs + add_foreign_key :administrateurs_procedures, :administrateurs end def down diff --git a/db/migrate/20220425140107_add_lien_dpo_to_procedure.rb b/db/migrate/20220425140107_add_lien_dpo_to_procedure.rb new file mode 100644 index 000000000..891b0215a --- /dev/null +++ b/db/migrate/20220425140107_add_lien_dpo_to_procedure.rb @@ -0,0 +1,5 @@ +class AddLienDpoToProcedure < ActiveRecord::Migration[6.1] + def change + add_column :procedures, :lien_dpo, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index d59c95dd9..63fc7466c 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: 2022_04_07_081538) do +ActiveRecord::Schema.define(version: 2022_04_25_140107) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -645,6 +645,7 @@ ActiveRecord::Schema.define(version: 2022_04_07_081538) do t.boolean "juridique_required", default: true t.string "libelle" t.string "lien_demarche" + t.string "lien_dpo" t.string "lien_notice" t.string "lien_site_web" t.text "monavis_embed" diff --git a/package.json b/package.json index 1b064f9d4..43e7642ac 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "@headlessui/react": "^1.5.0", "@heroicons/react": "^1.0.6", "@hotwired/stimulus": "^3.0.1", + "@hotwired/turbo-rails": "^7.1.1", "@mapbox/mapbox-gl-draw": "^1.3.0", "@popperjs/core": "^2.11.4", "@rails/actiontext": "^6.1.4-1", @@ -17,7 +18,6 @@ "@sentry/browser": "6.12.0", "@tmcw/togeojson": "^4.3.0", "babel-plugin-macros": "^2.8.0", - "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "chartkick": "^4.1.1", "core-js": "^3.6.5", "debounce": "^1.2.1", @@ -29,7 +29,6 @@ "is-hotkey": "^0.2.0", "maplibre-gl": "^1.15.2", "match-sorter": "^6.2.0", - "prop-types": "^15.7.2", "react": "^18.0.0", "react-coordinate-input": "^1.0.0", "react-dom": "^18.0.0", @@ -42,7 +41,8 @@ "use-debounce": "^5.2.0", "webpack": "^4.46.0", "webpack-cli": "^3.3.12", - "whatwg-fetch": "^3.0.0" + "whatwg-fetch": "^3.0.0", + "zod": "^3.14.4" }, "devDependencies": { "@2fd/graphdoc": "^2.4.0", diff --git a/spec/views/shared/dossiers/messages/message.html.haml_spec.rb b/spec/components/dossiers/message_component_spec.rb similarity index 51% rename from spec/views/shared/dossiers/messages/message.html.haml_spec.rb rename to spec/components/dossiers/message_component_spec.rb index 106b11178..9fb4b9d99 100644 --- a/spec/views/shared/dossiers/messages/message.html.haml_spec.rb +++ b/spec/components/dossiers/message_component_spec.rb @@ -1,21 +1,28 @@ -describe 'shared/dossiers/messages/message.html.haml', type: :view do - before { view.extend DossierHelper } - - subject { render 'shared/dossiers/messages/message.html.haml', commentaire: commentaire, messagerie_seen_at: seen_at, connected_user: dossier.user, show_reply_button: true } - +RSpec.describe Dossiers::MessageComponent, type: :component do + let(:component) do + described_class.new( + commentaire: commentaire, + connected_user: connected_user, + messagerie_seen_at: seen_at, + show_reply_button: true + ) + end let(:dossier) { create(:dossier, :en_construction) } let(:commentaire) { create(:commentaire, dossier: dossier) } + let(:connected_user) { dossier.user } let(:seen_at) { commentaire.created_at + 1.hour } + subject { render_inline(component).to_html } + it { is_expected.to have_button("Répondre") } - context "with a seen_at after commentaire created_at" do + context 'with a seen_at after commentaire created_at' do let(:seen_at) { commentaire.created_at + 1.hour } it { is_expected.not_to have_css(".highlighted") } end - context "with a seen_at after commentaire created_at" do + context 'with a seen_at after commentaire created_at' do let(:seen_at) { commentaire.created_at - 1.hour } it { is_expected.to have_css(".highlighted") } @@ -51,8 +58,8 @@ describe 'shared/dossiers/messages/message.html.haml', type: :view do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure) } let(:dossier) { create(:dossier, :en_construction, commentaires: [commentaire], procedure: procedure) } - subject { render 'shared/dossiers/messages/message.html.haml', commentaire: commentaire, messagerie_seen_at: seen_at, connected_user: instructeur, show_reply_button: true } - let(:form_url) { instructeur_commentaire_path(commentaire.dossier.procedure, commentaire.dossier, commentaire) } + let(:connected_user) { instructeur } + let(:form_url) { component.helpers.instructeur_commentaire_path(commentaire.dossier.procedure, commentaire.dossier, commentaire) } context 'on a procedure where commentaire had been written by connected instructeur' do let(:commentaire) { create(:commentaire, instructeur: instructeur, body: 'Second message') } @@ -64,7 +71,7 @@ describe 'shared/dossiers/messages/message.html.haml', type: :view do let(:commentaire) { create(:commentaire, instructeur: instructeur, body: 'Second message', discarded_at: 2.days.ago) } it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") } - it { is_expected.not_to have_selector(".rich-text", text: I18n.t(t('views.shared.commentaires.destroy.deleted_body'))) } + it { is_expected.to have_selector(".rich-text", text: component.t('.deleted_body')) } end context 'on a procedure where commentaire had been written by connected an user' do @@ -87,49 +94,48 @@ describe 'shared/dossiers/messages/message.html.haml', type: :view do end end - context 'with an expert message' do - describe 'delete message button for expert' do - let(:expert) { create(:expert) } - let(:procedure) { create(:procedure) } - let(:dossier) { create(:dossier, :en_construction, commentaires: [commentaire], procedure: procedure) } - let(:experts_procedure) { create(:experts_procedure, procedure: procedure, expert: expert) } - let!(:avis) { create(:avis, email: nil, experts_procedure: experts_procedure) } - subject { render 'shared/dossiers/messages/message.html.haml', commentaire: commentaire, messagerie_seen_at: seen_at, connected_user: expert, show_reply_button: true } - let(:form_url) { delete_commentaire_expert_avis_path(avis.procedure, avis, commentaire: commentaire) } + describe '#commentaire_from_guest?' do + let!(:guest) { create(:invite, dossier: dossier) } - before do - assign(:avis, avis) + subject { component.send(:commentaire_from_guest?) } + + context 'when the commentaire sender is not a guest' do + let(:commentaire) { create(:commentaire, dossier: dossier, email: "michel@pref.fr") } + it { is_expected.to be false } + end + + context 'when the commentaire sender is a guest on this dossier' do + let(:commentaire) { create(:commentaire, dossier: dossier, email: guest.email) } + it { is_expected.to be true } + end + end + + describe '#commentaire_date' do + let(:present_date) { Time.zone.local(2018, 9, 2, 10, 5, 0) } + let(:creation_date) { present_date } + let(:commentaire) do + Timecop.freeze(creation_date) { create(:commentaire, email: "michel@pref.fr") } + end + + subject do + Timecop.freeze(present_date) { component.send(:commentaire_date) } + end + + it 'doesn’t include the creation year' do + expect(subject).to eq 'le 2 septembre à 10 h 05' + end + + context 'when displaying a commentaire created on a previous year' do + let(:creation_date) { present_date.prev_year } + it 'includes the creation year' do + expect(subject).to eq 'le 2 septembre 2017 à 10 h 05' end + end - context 'on a procedure where commentaire had been written by connected expert' do - let(:commentaire) { create(:commentaire, expert: expert, body: 'Second message') } - - it { is_expected.to have_selector("form[action=\"#{form_url}\"]") } - end - - context 'on a procedure where commentaire had been written by connected expert and discarded' do - let(:commentaire) { create(:commentaire, expert: expert, body: 'Second message', discarded_at: 2.days.ago) } - - it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") } - it { is_expected.not_to have_selector(".rich-text", text: I18n.t(t('views.shared.commentaires.destroy.deleted_body'))) } - end - - context 'on a procedure where commentaire had been written by connected an user' do - let(:commentaire) { create(:commentaire, email: create(:user).email, body: 'Second message') } - - it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") } - end - - context 'on a procedure where commentaire had been written by connected an instructeur' do - let(:commentaire) { create(:commentaire, instructeur: create(:instructeur), body: 'Second message') } - - it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") } - end - - context 'on a procedure where commentaire had been written another expert' do - let(:commentaire) { create(:commentaire, expert: create(:expert), body: 'Second message') } - - it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") } + context 'when formatting the first day of the month' do + let(:present_date) { Time.zone.local(2018, 9, 1, 10, 5, 0) } + it 'includes the ordinal' do + expect(subject).to eq 'le 1er septembre à 10 h 05' end end end diff --git a/spec/components/previews/dossiers/message_component_preview.rb b/spec/components/previews/dossiers/message_component_preview.rb new file mode 100644 index 000000000..e85ac03d1 --- /dev/null +++ b/spec/components/previews/dossiers/message_component_preview.rb @@ -0,0 +1,31 @@ +class Dossiers::MessageComponentPreview < ViewComponent::Preview + def with_default_commentaire + render Dossiers::MessageComponent.new(commentaire: commentaire, connected_user: user) + end + + def with_discarded_commentaire + render Dossiers::MessageComponent.new(commentaire: discarded_commentaire, connected_user: user) + end + + private + + def user + User.new email: "usager@example.com", locale: I18n.locale + end + + def commentaire + Commentaire.new body: 'Hello world!', email: user.email, dossier: dossier, created_at: 2.days.ago + end + + def discarded_commentaire + Commentaire.new body: 'Hello world!', email: user.email, dossier: dossier, created_at: 2.days.ago, discarded_at: 1.day.ago + end + + def dossier + Dossier.new(id: 47882, state: :en_instruction, procedure: procedure, user: user) + end + + def procedure + Procedure.new id: 1234, libelle: 'Dotation d’Équipement des Territoires Ruraux - Exercice 2019' + end +end diff --git a/spec/controllers/administrateurs/procedure_administrateurs_controller_spec.rb b/spec/controllers/administrateurs/procedure_administrateurs_controller_spec.rb index 2094a8d54..15122c393 100644 --- a/spec/controllers/administrateurs/procedure_administrateurs_controller_spec.rb +++ b/spec/controllers/administrateurs/procedure_administrateurs_controller_spec.rb @@ -2,6 +2,7 @@ describe Administrateurs::ProcedureAdministrateursController, type: :controller let(:signed_in_admin) { create(:administrateur) } let(:other_admin) { create(:administrateur) } let(:procedure) { create(:procedure, administrateurs: [signed_in_admin, other_admin]) } + render_views before do sign_in(signed_in_admin.user) @@ -9,7 +10,7 @@ describe Administrateurs::ProcedureAdministrateursController, type: :controller describe '#destroy' do subject do - delete :destroy, params: { procedure_id: procedure.id, id: admin_to_remove.id }, format: :js, xhr: true + delete :destroy, params: { procedure_id: procedure.id, id: admin_to_remove.id }, format: :turbo_stream end context 'when removing another admin' do @@ -17,8 +18,8 @@ describe Administrateurs::ProcedureAdministrateursController, type: :controller it 'removes the admin from the procedure' do subject - expect(response.status).to eq(200) - expect(flash[:notice]).to be_present + expect(response).to have_http_status(:ok) + expect(subject.body).to include('alert-success') expect(admin_to_remove.procedures.reload).not_to include(procedure) end end @@ -28,8 +29,8 @@ describe Administrateurs::ProcedureAdministrateursController, type: :controller it 'denies the right for an admin to remove itself' do subject - expect(response.status).to eq(200) - expect(flash[:alert]).to be_present + expect(response).to have_http_status(:ok) + expect(subject.body).to include('alert-danger') expect(admin_to_remove.procedures.reload).to include(procedure) end end diff --git a/spec/controllers/concerns/devise_populated_resource_spec.rb b/spec/controllers/concerns/devise_populated_resource_spec.rb index 32c01f8ac..3050f8324 100644 --- a/spec/controllers/concerns/devise_populated_resource_spec.rb +++ b/spec/controllers/concerns/devise_populated_resource_spec.rb @@ -1,6 +1,7 @@ describe DevisePopulatedResource, type: :controller do controller(Devise::PasswordsController) do include DevisePopulatedResource + layout false end let(:user) { create(:user) } diff --git a/spec/controllers/instructeurs/commentaires_controller_spec.rb b/spec/controllers/instructeurs/commentaires_controller_spec.rb index b21ade81c..465adf50d 100644 --- a/spec/controllers/instructeurs/commentaires_controller_spec.rb +++ b/spec/controllers/instructeurs/commentaires_controller_spec.rb @@ -4,33 +4,33 @@ describe Instructeurs::CommentairesController, type: :controller do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, :published, :for_individual, instructeurs: [instructeur]) } let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure: procedure) } + render_views before { sign_in(instructeur.user) } describe 'destroy' do + render_views + context 'when it works' do let(:commentaire) { create(:commentaire, instructeur: instructeur, dossier: dossier) } - subject { delete :destroy, params: { dossier_id: dossier.id, procedure_id: procedure.id, id: commentaire.id } } + subject { delete :destroy, params: { dossier_id: dossier.id, procedure_id: procedure.id, id: commentaire.id }, format: :turbo_stream } - it 'redirect to dossier' do - expect(subject).to redirect_to(messagerie_instructeur_dossier_path(dossier.procedure, dossier)) - end - it 'flash success' do - subject - expect(flash[:notice]).to eq(I18n.t('views.shared.commentaires.destroy.notice')) + it 'respond with OK and flash' do + expect(subject).to have_http_status(:ok) + expect(subject.body).to include('Message supprimé') + expect(subject.body).to include('alert-success') + expect(subject.body).to include('Votre message a été supprimé') end end context 'when dossier had been discarded' do let(:commentaire) { create(:commentaire, instructeur: instructeur, dossier: dossier, discarded_at: 2.hours.ago) } - subject { delete :destroy, params: { dossier_id: dossier.id, procedure_id: procedure.id, id: commentaire.id } } + subject { delete :destroy, params: { dossier_id: dossier.id, procedure_id: procedure.id, id: commentaire.id }, format: :turbo_stream } - it 'redirect to dossier' do - expect(subject).to redirect_to(messagerie_instructeur_dossier_path(dossier.procedure, dossier)) - end - it 'flash success' do - subject - expect(flash[:alert]).to eq(I18n.t('views.shared.commentaires.destroy.alert_reasons.already_discarded')) + it 'respond with OK and flash' do + expect(subject).to have_http_status(:ok) + expect(subject.body).to include('alert-danger') + expect(subject.body).to include('Ce message a déjà été supprimé') end end end diff --git a/spec/controllers/users/commencer_controller_spec.rb b/spec/controllers/users/commencer_controller_spec.rb index 39730b912..1f42717cc 100644 --- a/spec/controllers/users/commencer_controller_spec.rb +++ b/spec/controllers/users/commencer_controller_spec.rb @@ -160,4 +160,41 @@ describe Users::CommencerController, type: :controller do end end end + + describe '#dossier_vide_pdf' do + before { get :dossier_vide_pdf, params: { path: procedure.path } } + + context 'published procedure' do + let(:procedure) { create(:procedure, :published, :with_service, :with_path) } + + it 'works' do + expect(response).to have_http_status(:success) + end + end + context 'not published procedure' do + let(:procedure) { create(:procedure, :with_service, :with_path) } + + it 'redirects to procedure not found' do + expect(response).to have_http_status(302) + end + end + end + + describe '#dossier_vide_test_pdf' do + before { get :dossier_vide_pdf_test, params: { path: procedure.path } } + + context 'not published procedure' do + let(:procedure) { create(:procedure, :with_service, :with_path) } + + it 'works' do + expect(response).to have_http_status(:success) + end + end + context 'published procedure' do + let(:procedure) { create(:procedure, :published, :with_service, :with_path) } + it 'redirect to procedure not found' do + expect(response).to have_http_status(302) + end + end + end end diff --git a/spec/helpers/commentaire_helper_spec.rb b/spec/helpers/commentaire_helper_spec.rb index e54b1a860..13a0710a6 100644 --- a/spec/helpers/commentaire_helper_spec.rb +++ b/spec/helpers/commentaire_helper_spec.rb @@ -28,51 +28,4 @@ RSpec.describe CommentaireHelper, type: :helper do it { is_expected.to include('Répondre') } end end - - describe '.commentaire_is_from_guest' do - let(:dossier) { create(:dossier, :en_instruction) } - let!(:guest) { create(:invite, dossier: dossier) } - - subject { commentaire_is_from_guest(commentaire) } - - context 'when the commentaire sender is not a guest' do - let(:commentaire) { create(:commentaire, dossier: dossier, email: "michel@pref.fr") } - it { is_expected.to be false } - end - - context 'when the commentaire sender is a guest on this dossier' do - let(:commentaire) { create(:commentaire, dossier: dossier, email: guest.email) } - it { is_expected.to be true } - end - end - - describe '.commentaire_date' do - let(:present_date) { Time.zone.local(2018, 9, 2, 10, 5, 0) } - let(:creation_date) { present_date } - let(:commentaire) do - Timecop.freeze(creation_date) { create(:commentaire, email: "michel@pref.fr") } - end - - subject do - Timecop.freeze(present_date) { commentaire_date(commentaire) } - end - - it 'doesn’t include the creation year' do - expect(subject).to eq 'le 2 septembre à 10 h 05' - end - - context 'when displaying a commentaire created on a previous year' do - let(:creation_date) { present_date.prev_year } - it 'includes the creation year' do - expect(subject).to eq 'le 2 septembre 2017 à 10 h 05' - end - end - - context 'when formatting the first day of the month' do - let(:present_date) { Time.zone.local(2018, 9, 1, 10, 5, 0) } - it 'includes the ordinal' do - expect(subject).to eq 'le 1er septembre à 10 h 05' - end - end - end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index d2a3f4ecf..758791972 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -603,12 +603,13 @@ describe Procedure do end describe 'procedure status is reset' do - let(:procedure) { create(:procedure, :closed, received_mail: received_mail, service: service) } + let(:procedure) { create(:procedure, :closed, received_mail: received_mail, service: service, auto_archive_on: 3.weeks.from_now) } it 'Not published nor closed' do expect(subject.closed_at).to be_nil expect(subject.published_at).to be_nil expect(subject.unpublished_at).to be_nil + expect(subject.auto_archive_on).to be_nil expect(subject.aasm_state).to eq "brouillon" expect(subject.path).not_to be_nil end @@ -1184,6 +1185,13 @@ describe Procedure do end end + describe 'lien_dpo' do + it { expect(build(:procedure).valid?).to be(true) } + it { expect(build(:procedure, lien_dpo: 'dpo@ministere.amere').valid?).to be(true) } + it { expect(build(:procedure, lien_dpo: 'https://legal.fr/contact_dpo').valid?).to be(true) } + it { expect(build(:procedure, lien_dpo: 'askjdlad l akdj asd ').valid?).to be(false) } + end + private def create_dossier_with_pj_of_size(size, procedure) diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index b58e5553d..3528d4433 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -13,6 +13,7 @@ require 'rspec/rails' require 'axe-rspec' require 'devise' require 'shoulda-matchers' +require 'view_component/test_helpers' # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are @@ -124,4 +125,6 @@ RSpec.configure do |config| config.include Devise::Test::ControllerHelpers, type: :controller config.include Devise::Test::ControllerHelpers, type: :view config.include Devise::Test::IntegrationHelpers, type: :system + config.include ViewComponent::TestHelpers, type: :component + config.include Capybara::RSpecMatchers, type: :component end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index 1021691b6..d8211a8b2 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -1,10 +1,12 @@ require 'csv' describe ProcedureExportService do - describe 'to_data' do - let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } + let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } + let(:service) { ProcedureExportService.new(procedure, procedure.dossiers) } + + describe 'to_xlsx' do subject do - ProcedureExportService.new(procedure, procedure.dossiers) + service .to_xlsx .open { |f| SimpleXlsxReader.open(f.path) } end @@ -406,4 +408,46 @@ describe ProcedureExportService do end end end + + describe 'to_zip' do + subject { service.to_zip } + context 'without files' do + it 'does not raises in_batches' do + expect { subject }.not_to raise_error(NoMethodError) + end + + it 'returns an empty blob' do + expect(subject).to be_an_instance_of(ActiveStorage::Blob) + end + end + + context 'with files (and http calls)' do + let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } + + before do + allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") + end + + it 'returns a blob with valid files' do + VCR.use_cassette('archive/new_file_to_get_200') do + subject + + File.write('tmp.zip', subject.download, mode: 'wb') + File.open('tmp.zip') do |fd| + files = ZipTricks::FileReader.read_zip_structure(io: fd) + structure = [ + "#{service.send(:base_filename)}/", + "#{service.send(:base_filename)}/dossier-#{dossier.id}/", + "#{service.send(:base_filename)}/dossier-#{dossier.id}/pieces_justificatives/", + "#{service.send(:base_filename)}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(ActiveStorage::Attachment.where(record_type: "Champ").first)}", + "#{service.send(:base_filename)}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(PiecesJustificativesService.generate_dossier_export(dossier))}" + ] + expect(files.size).to eq(structure.size) + expect(files.map(&:filename)).to match_array(structure) + end + FileUtils.remove_entry_secure('tmp.zip') + end + end + end + end end diff --git a/spec/views/users/_procedure_footer.html.haml_spec.rb b/spec/views/users/_procedure_footer.html.haml_spec.rb index 4fc40831d..87605fd18 100644 --- a/spec/views/users/_procedure_footer.html.haml_spec.rb +++ b/spec/views/users/_procedure_footer.html.haml_spec.rb @@ -27,4 +27,43 @@ describe 'users/procedure_footer.html.haml', type: :view do it { is_expected.to have_link("Accessibilité") } it { is_expected.not_to have_text('téléphone') } end + + describe '#cadre_juridique' do + context 'when an external link is provided' do + before { dossier.procedure.update(cadre_juridique: "http://google.fr") } + it { is_expected.to have_link("Texte cadrant la demande d'information", href: 'http://google.fr') } + end + + context 'when there is deliberation attached' do + before { dossier.procedure.update(cadre_juridique: nil, deliberation: fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf')) } + it { is_expected.to have_link("Texte cadrant la demande d'information") } + end + end + + describe '#lien_dpo' do + context "when there is not lien_dpo" do + before { dossier.procedure.update(lien_dpo: nil) } + it { is_expected.not_to have_text('Contacter le Délégué à la Protection des Données') } + end + + context "when there is a lien_dpo with an email" do + before { dossier.procedure.update(lien_dpo: 'dpo@beta.gouv.fr') } + it { is_expected.to have_selector('a[href="mailto:dpo@beta.gouv.fr?subject="]') } + end + + context "when there is a lien_dpo with a schemaless link" do + before { dossier.procedure.update(lien_dpo: 'beta.gouv.fr') } + it { is_expected.to have_link('Contacter le Délégué à la Protection des Données', href: '//beta.gouv.fr') } + end + + context "when there is a lien_dpo with a link with http:// schema" do + before { dossier.procedure.update(lien_dpo: 'http://beta.gouv.fr') } + it { is_expected.to have_link('Contacter le Délégué à la Protection des Données', href: 'http://beta.gouv.fr') } + end + + context "when there is a lien_dpo with a link with https:// schema" do + before { dossier.procedure.update(lien_dpo: 'https://beta.gouv.fr') } + it { is_expected.to have_link('Contacter le Délégué à la Protection des Données', href: 'https://beta.gouv.fr') } + end + end end diff --git a/yarn.lock b/yarn.lock index f47219e4c..f06ec9512 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1274,6 +1274,19 @@ resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.0.1.tgz#141f15645acaa3b133b7c247cad58ae252ffae85" integrity sha512-oHsJhgY2cip+K2ED7vKUNd2P+BEswVhrCYcJ802DSsblJFv7mPFVk3cQKvm2vHgHeDVdnj7oOKrBbzp1u8D+KA== +"@hotwired/turbo-rails@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-7.1.1.tgz#35c03b92b5c86f0137ed08bef843d955ec9bbe83" + integrity sha512-ZXpxUjCfkdbuXfoGrsFK80qsVzACs8xCfie9rt2jMTSN6o1olXVA0Nrk8u02yNEwSiVJm/4QSOa8cUcMj6VQjg== + dependencies: + "@hotwired/turbo" "^7.1.0" + "@rails/actioncable" "^7.0" + +"@hotwired/turbo@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.1.0.tgz#27e44e0e3dc5bd1d4bda0766d579cf5a14091cd7" + integrity sha512-Q8kGjqwPqER+CtpQudbH+3Zgs2X4zb6pBAlr6NsKTXadg45pAOvxI9i4QpuHbwSzR2+x87HUm+rot9F/Pe8rxA== + "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" @@ -1949,6 +1962,11 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503" integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg== +"@rails/actioncable@^7.0": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.0.2.tgz#69a6d999f4087e0537dd38fe0963db1f4305d650" + integrity sha512-G26maXW1Kx0LxQdmNNuNjQlRO/QlXNr3QfuwKiOKb5FZQGYe2OwtHTGXBAjSoiu4dW36XYMT/+L1Wo1Yov4ZXA== + "@rails/actiontext@^6.1.4-1": version "6.1.4" resolved "https://registry.yarnpkg.com/@rails/actiontext/-/actiontext-6.1.4.tgz#ed8c7d2b68d66205301f4538ce65d04c48031f6b" @@ -3399,11 +3417,6 @@ babel-plugin-polyfill-regenerator@^0.3.0: dependencies: "@babel/helper-define-polyfill-provider" "^0.3.0" -babel-plugin-transform-react-remove-prop-types@^0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a" - integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== - backoff@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/backoff/-/backoff-2.5.0.tgz#f616eda9d3e4b66b8ca7fca79f695722c5f8e26f" @@ -14014,3 +14027,8 @@ zip-stream@^4.1.0: archiver-utils "^2.1.0" compress-commons "^4.1.0" readable-stream "^3.6.0" + +zod@^3.14.4: + version "3.14.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.14.4.tgz#e678fe9e5469f4663165a5c35c8f3dc66334a5d6" + integrity sha512-U9BFLb2GO34Sfo9IUYp0w3wJLlmcyGoMd75qU9yf+DrdGA4kEx6e+l9KOkAlyUO0PSQzZCa3TR4qVlcmwqSDuw==