commit
ecf0ba54a2
111 changed files with 982 additions and 478 deletions
11
.github/ISSUE_TEMPLATE/description-de-probleme-ux.md
vendored
Normal file
11
.github/ISSUE_TEMPLATE/description-de-probleme-ux.md
vendored
Normal file
|
@ -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
|
2
Gemfile
2
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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -22,3 +22,7 @@ a {
|
|||
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
turbo-events {
|
||||
display: none;
|
||||
}
|
||||
|
|
3
app/components/application_component.rb
Normal file
3
app/components/application_component.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class ApplicationComponent < ViewComponent::Base
|
||||
include ViewComponent::Translatable
|
||||
end
|
58
app/components/dossiers/message_component.rb
Normal file
58
app/components/dossiers/message_component.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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é
|
|
@ -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')
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
35
app/helpers/turbo_stream_helper.rb
Normal file
35
app/helpers/turbo_stream_helper.rb
Normal file
|
@ -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
|
|
@ -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);
|
38
app/javascript/components/Chartkick.tsx
Normal file
38
app/javascript/components/Chartkick.tsx
Normal file
|
@ -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<HTMLDivElement>(chartClass)
|
||||
: undefined,
|
||||
nextChartId = nextChart?.children[0]?.id,
|
||||
currentSelectorItem = nextSelectorItem.parentElement?.querySelector(
|
||||
'.segmented-control-item-active'
|
||||
),
|
||||
currentChart =
|
||||
nextSelectorItem.parentElement?.parentElement?.querySelector<HTMLDivElement>(
|
||||
'.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);
|
|
@ -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<AnnuaireEducationResult>
|
||||
) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
{...props}
|
||||
scope="annuaire-education"
|
||||
minimumInputLength={3}
|
||||
transformResults={(_, { records }) => 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}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComboAnnuaireEducationSearch;
|
|
@ -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<CommuneResult> & { id: string }) {
|
||||
const group = groupId(id);
|
||||
const [departementValue, setDepartementValue] = useHiddenField(
|
||||
group,
|
||||
|
@ -74,14 +79,14 @@ function ComboCommunesSearch({ id, ...props }) {
|
|||
</div>
|
||||
<ComboDepartementsSearch
|
||||
{...props}
|
||||
id={!codeDepartement ? id : null}
|
||||
id={!codeDepartement ? id : undefined}
|
||||
describedby={departementDescribedBy}
|
||||
placeholder={placeholderDepartement}
|
||||
addForeignDepartement={false}
|
||||
value={departementValue}
|
||||
onChange={(_, result) => {
|
||||
setDepartementValue(result?.nom);
|
||||
setCodeDepartement(result?.code);
|
||||
setDepartementValue(result?.nom ?? '');
|
||||
setCodeDepartement(result?.code ?? '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -112,9 +117,3 @@ function ComboCommunesSearch({ id, ...props }) {
|
|||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ComboCommunesSearch.propTypes = {
|
||||
id: PropTypes.string
|
||||
};
|
||||
|
||||
export default ComboCommunesSearch;
|
|
@ -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<DepartementResult> & {
|
||||
addForeignDepartement: boolean;
|
||||
},
|
||||
'transformResult' | 'transformResults'
|
||||
>;
|
||||
|
||||
export function ComboDepartementsSearch({
|
||||
addForeignDepartement = true,
|
||||
...props
|
||||
}) {
|
||||
}: ComboDepartementsSearchProps) {
|
||||
return (
|
||||
<ComboSearch
|
||||
{...props}
|
||||
|
@ -34,17 +43,12 @@ export function ComboDepartementsSearch({
|
|||
);
|
||||
}
|
||||
|
||||
function ComboDepartementsSearchDefault(params) {
|
||||
export default function ComboDepartementsSearchDefault(
|
||||
params: ComboDepartementsSearchProps
|
||||
) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboDepartementsSearch {...params} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ComboDepartementsSearch.propTypes = {
|
||||
...ComboSearch.propTypes,
|
||||
addForeignDepartement: PropTypes.bool
|
||||
};
|
||||
|
||||
export default ComboDepartementsSearchDefault;
|
|
@ -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 <ComboMultiple group={groupId(id)} id={id} {...props} />;
|
||||
}
|
||||
|
||||
ComboMultipleDropdownList.propTypes = {
|
||||
id: PropTypes.string
|
||||
};
|
||||
|
||||
export default ComboMultipleDropdownList;
|
11
app/javascript/components/ComboMultipleDropdownList.tsx
Normal file
11
app/javascript/components/ComboMultipleDropdownList.tsx
Normal file
|
@ -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 <ComboMultiple id={id} {...props} group={groupId(id)} />;
|
||||
}
|
|
@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
{...props}
|
||||
scope="pays"
|
||||
minimumInputLength={0}
|
||||
transformResult={({ code, value, label }) => [code, value, label]}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComboPaysSearch;
|
|
@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
{...props}
|
||||
scope="regions"
|
||||
minimumInputLength={0}
|
||||
transformResult={({ code, nom }) => [code, nom]}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComboRegionsSearch;
|
|
@ -20,7 +20,7 @@ import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
|
|||
type TransformResults<Result> = (term: string, results: unknown) => Result[];
|
||||
type TransformResult<Result> = (
|
||||
result: Result
|
||||
) => [key: string, value: string, label: string];
|
||||
) => [key: string, value: string, label?: string];
|
||||
|
||||
export type ComboSearchProps<Result> = {
|
||||
onChange?: (value: string | null, result?: Result) => void;
|
||||
|
@ -28,7 +28,7 @@ export type ComboSearchProps<Result> = {
|
|||
scope: string;
|
||||
scopeExtra?: string;
|
||||
minimumInputLength: number;
|
||||
transformResults: TransformResults<Result>;
|
||||
transformResults?: TransformResults<Result>;
|
||||
transformResult: TransformResult<Result>;
|
||||
allowInputValues?: boolean;
|
||||
id?: string;
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -161,6 +161,7 @@ export const TypeDeChampComponent = SortableElement<TypeDeChampProps>(
|
|||
/>
|
||||
<TypeDeChampPieceJustificative
|
||||
isVisible={isFile}
|
||||
isTitreIdentite={isTitreIdentite}
|
||||
directUploadUrl={state.directUploadUrl}
|
||||
filename={typeDeChamp.piece_justificative_template_filename}
|
||||
handler={updateHandlers.piece_justificative_template}
|
||||
|
|
|
@ -5,12 +5,14 @@ import type { Handler } from '../types';
|
|||
|
||||
export function TypeDeChampPieceJustificative({
|
||||
isVisible,
|
||||
isTitreIdentite,
|
||||
url,
|
||||
filename,
|
||||
handler,
|
||||
directUploadUrl
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
isTitreIdentite: boolean;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
handler: Handler<HTMLInputElement>;
|
||||
|
@ -32,6 +34,17 @@ export function TypeDeChampPieceJustificative({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isTitreIdentite) {
|
||||
return (
|
||||
<div className="cell">
|
||||
<p id={`${handler.id}-description`}>
|
||||
Dans le cadre de la RGPD, le titre d'identité sera supprimé lors
|
||||
de l'acceptation du dossier
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
25
app/javascript/controllers/application_controller.ts
Normal file
25
app/javascript/controllers/application_controller.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Controller } from '@hotwired/stimulus';
|
||||
import { debounce } from '@utils';
|
||||
|
||||
export type Detail = Record<string, unknown>;
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
31
app/javascript/controllers/geo_area_controller.tsx
Normal file
31
app/javascript/controllers/geo_area_controller.tsx
Normal file
|
@ -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() }
|
||||
});
|
||||
}
|
||||
}
|
82
app/javascript/controllers/turbo_event_controller.ts
Normal file
82
app/javascript/controllers/turbo_event_controller.ts
Normal file
|
@ -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<typeof MutationAction>;
|
||||
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<typeof Mutation>;
|
||||
|
||||
addEventListener('dom:mutation', (event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const mutation = Mutation.parse(detail);
|
||||
mutate(mutation);
|
||||
});
|
||||
|
||||
const Mutations: Record<MutationAction, (mutation: Mutation) => 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<Element extends HTMLElement = HTMLElement>(
|
||||
mutation: Mutation
|
||||
): Element[] {
|
||||
if ('target' in mutation) {
|
||||
const element = document.querySelector<Element>(`#${mutation.target}`);
|
||||
invariant(element, `Could not find element with id ${mutation.target}`);
|
||||
return [element];
|
||||
} else if ('targets' in mutation) {
|
||||
return [...document.querySelectorAll<Element>(mutation.targets)];
|
||||
}
|
||||
invariant(false, 'Could not find element');
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -54,5 +54,5 @@ function saveMessageContent() {
|
|||
}
|
||||
}
|
||||
|
||||
addEventListener('ds:page:update', scrollMessagerie);
|
||||
addEventListener('ds:page:update', saveMessageContent);
|
||||
addEventListener('DOMContentLoaded', scrollMessagerie);
|
||||
addEventListener('DOMContentLoaded', saveMessageContent);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -20,7 +20,7 @@ function init() {
|
|||
}
|
||||
}
|
||||
|
||||
addEventListener('ds:page:update', init);
|
||||
addEventListener('DOMContentLoaded', init);
|
||||
|
||||
function toggleElement(event) {
|
||||
event.preventDefault();
|
||||
|
|
|
@ -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');
|
||||
});
|
|
@ -42,15 +42,20 @@ export function removeClass(el: HTMLElement, cssClass: string) {
|
|||
el && el.classList.remove(cssClass);
|
||||
}
|
||||
|
||||
export function delegate(
|
||||
export function delegate<E extends Event = Event>(
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
1
app/javascript/types.d.ts
vendored
1
app/javascript/types.d.ts
vendored
|
@ -21,3 +21,4 @@ declare module '@tmcw/togeojson/dist/togeojson.es.js' {
|
|||
}
|
||||
|
||||
declare module 'react-coordinate-input';
|
||||
declare module 'chartkick';
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(/(?<!^|[.-])(?<!<\/strong>)\n/, '')
|
||||
new(subject: const_get(:DEFAULT_SUBJECT), rich_body: trix_rich_body, procedure: procedure)
|
||||
end
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
7
app/validators/email_or_link_validator.rb
Normal file
7
app/validators/email_or_link_validator.rb
Normal file
|
@ -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
|
|
@ -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} ».
|
||||
|
|
|
@ -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} » ?" } }
|
||||
|
|
|
@ -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 })
|
|
@ -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 }
|
|
@ -1,4 +0,0 @@
|
|||
= render_flash(sticky: true)
|
||||
- if @administrateur
|
||||
= remove_element("#procedure-#{@procedure.id}-administrateur-#{@administrateur.id}")
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
- if @administrateur.present?
|
||||
= turbo_stream.remove(@administrateur)
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
- if @commentaire.discarded?
|
||||
= turbo_stream.update @commentaire do
|
||||
= render Dossiers::MessageComponent.new(commentaire: @commentaire, connected_user: @commentaire.instructeur || @commentaire.expert)
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
5
app/views/layouts/_turbo_event.html.haml
Normal file
5
app/views/layouts/_turbo_event.html.haml
Normal file
|
@ -0,0 +1,5 @@
|
|||
%turbo-event{ data: {
|
||||
controller: 'turbo-event',
|
||||
turbo_event_type_value: type,
|
||||
turbo_event_detail_value: detail.to_json
|
||||
} }
|
|
@ -41,3 +41,5 @@
|
|||
= content_for(:footer)
|
||||
|
||||
= yield :charts_js
|
||||
|
||||
%turbo-events
|
||||
|
|
6
app/views/layouts/application.turbo_stream.haml
Normal file
6
app/views/layouts/application.turbo_stream.haml
Normal file
|
@ -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
|
27
app/views/layouts/component_preview.html.haml
Normal file
27
app/views/layouts/component_preview.html.haml
Normal file
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
= turbo_stream.remove('outdated-browser-banner')
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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')
|
|
@ -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: '')
|
||||
|
|
@ -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
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -86,12 +86,6 @@ module.exports = function (api) {
|
|||
{
|
||||
async: false
|
||||
}
|
||||
],
|
||||
isProductionEnv && [
|
||||
'babel-plugin-transform-react-remove-prop-types',
|
||||
{
|
||||
removeImport: true
|
||||
}
|
||||
]
|
||||
].filter(Boolean)
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
7
config/locales/views/instructeurs/commentaires/en.yml
Normal file
7
config/locales/views/instructeurs/commentaires/en.yml
Normal file
|
@ -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"
|
7
config/locales/views/instructeurs/commentaires/fr.yml
Normal file
7
config/locales/views/instructeurs/commentaires/fr.yml
Normal file
|
@ -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é
|
|
@ -2,4 +2,4 @@ en:
|
|||
instructeurs:
|
||||
procedure:
|
||||
archive_pending_html: Archive creation pending<br>(requested %{created_period} ago)
|
||||
archive_ready_html: Download archive<br>(requested %{generated_period} ago)
|
||||
archive_ready_html: Download archive<br>(requested %{generated_period} ago)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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é"
|
||||
|
|
24
config/locales/views/users/procedure_footer/en.yml
Normal file
24
config/locales/views/users/procedure_footer/en.yml
Normal file
|
@ -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"
|
24
config/locales/views/users/procedure_footer/fr.yml
Normal file
24
config/locales/views/users/procedure_footer/fr.yml
Normal file
|
@ -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"
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
5
db/migrate/20220425140107_add_lien_dpo_to_procedure.rb
Normal file
5
db/migrate/20220425140107_add_lien_dpo_to_procedure.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddLienDpoToProcedure < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :procedures, :lien_dpo, :string
|
||||
end
|
||||
end
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue