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 'skylight'
|
||||||
gem 'spreadsheet_architect'
|
gem 'spreadsheet_architect'
|
||||||
gem 'strong_migrations' # lint database migrations
|
gem 'strong_migrations' # lint database migrations
|
||||||
|
gem 'turbo-rails'
|
||||||
gem 'typhoeus'
|
gem 'typhoeus'
|
||||||
|
gem 'view_component'
|
||||||
gem 'warden'
|
gem 'warden'
|
||||||
gem 'webpacker'
|
gem 'webpacker'
|
||||||
gem 'zipline'
|
gem 'zipline'
|
||||||
|
|
|
@ -723,6 +723,8 @@ GEM
|
||||||
timecop (0.9.4)
|
timecop (0.9.4)
|
||||||
timeout (0.1.1)
|
timeout (0.1.1)
|
||||||
ttfunk (1.7.0)
|
ttfunk (1.7.0)
|
||||||
|
turbo-rails (0.8.3)
|
||||||
|
rails (>= 6.0.0)
|
||||||
typhoeus (1.4.0)
|
typhoeus (1.4.0)
|
||||||
ethon (>= 0.9.0)
|
ethon (>= 0.9.0)
|
||||||
tzinfo (2.0.4)
|
tzinfo (2.0.4)
|
||||||
|
@ -739,6 +741,9 @@ GEM
|
||||||
activemodel (>= 3.0.0)
|
activemodel (>= 3.0.0)
|
||||||
public_suffix
|
public_suffix
|
||||||
vcr (6.0.0)
|
vcr (6.0.0)
|
||||||
|
view_component (2.53.0)
|
||||||
|
activesupport (>= 5.0.0, < 8.0)
|
||||||
|
method_source (~> 1.0)
|
||||||
virtus (2.0.0)
|
virtus (2.0.0)
|
||||||
axiom-types (~> 0.1)
|
axiom-types (~> 0.1)
|
||||||
coercible (~> 1.0)
|
coercible (~> 1.0)
|
||||||
|
@ -899,8 +904,10 @@ DEPENDENCIES
|
||||||
spring-commands-rspec
|
spring-commands-rspec
|
||||||
strong_migrations
|
strong_migrations
|
||||||
timecop
|
timecop
|
||||||
|
turbo-rails
|
||||||
typhoeus
|
typhoeus
|
||||||
vcr
|
vcr
|
||||||
|
view_component
|
||||||
warden
|
warden
|
||||||
web-console
|
web-console
|
||||||
webdrivers (~> 4.0)
|
webdrivers (~> 4.0)
|
||||||
|
|
|
@ -22,3 +22,7 @@ a {
|
||||||
|
|
||||||
text-decoration: none;
|
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
|
end
|
||||||
|
|
||||||
def procedure_params
|
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?
|
permited_params = if @procedure&.locked?
|
||||||
params.require(:procedure).permit(*editable_params)
|
params.require(:procedure).permit(*editable_params)
|
||||||
else
|
else
|
||||||
|
|
|
@ -113,22 +113,6 @@ module Experts
|
||||||
end
|
end
|
||||||
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
|
def bilans_bdf
|
||||||
if avis.dossier.etablissement&.entreprise_bilans_bdf.present?
|
if avis.dossier.etablissement&.entreprise_bilans_bdf.present?
|
||||||
extension = params[:format]
|
extension = params[:format]
|
||||||
|
|
|
@ -1,21 +1,33 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Instructeurs
|
module Instructeurs
|
||||||
class CommentairesController < ProceduresController
|
class CommentairesController < ProceduresController
|
||||||
|
after_action :mark_messagerie_as_read
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
commentaire = Dossier.find(params[:dossier_id]).commentaires.find(params[:id])
|
if commentaire.sent_by?(current_instructeur) || commentaire.sent_by?(current_expert)
|
||||||
if commentaire.sent_by?(current_instructeur)
|
commentaire.soft_delete!
|
||||||
commentaire.piece_jointe.purge_later if commentaire.piece_jointe.attached?
|
|
||||||
commentaire.discard!
|
flash.notice = t('.notice')
|
||||||
commentaire.update!(body: '')
|
|
||||||
flash[:notice] = t('views.shared.commentaires.destroy.notice')
|
|
||||||
else
|
else
|
||||||
flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.acl')
|
flash.alert = t('.alert_acl')
|
||||||
end
|
end
|
||||||
redirect_to(messagerie_instructeur_dossier_path(params[:procedure_id], params[:dossier_id]))
|
|
||||||
rescue Discard::RecordNotDiscarded
|
rescue Discard::RecordNotDiscarded
|
||||||
flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.already_discarded')
|
# i18n-tasks-use t('instructeurs.commentaires.destroy.alert_already_discarded')
|
||||||
redirect_to(messagerie_instructeur_dossier_path(params[:procedure_id], params[:dossier_id]))
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -84,7 +84,7 @@ class RootController < ApplicationController
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_back(fallback_location: root_path) }
|
format.html { redirect_back(fallback_location: root_path) }
|
||||||
format.js { render js: helpers.remove_element('#outdated-browser-banner') }
|
format.turbo_stream
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ class ArchiveDashboard < Administrate::BaseDashboard
|
||||||
created_at: Field::DateTime,
|
created_at: Field::DateTime,
|
||||||
updated_at: Field::DateTime,
|
updated_at: Field::DateTime,
|
||||||
status: Field::String,
|
status: Field::String,
|
||||||
file: Field::HasOne
|
file: AttachmentField
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
# COLLECTION_ATTRIBUTES
|
# COLLECTION_ATTRIBUTES
|
||||||
|
@ -24,7 +24,8 @@ class ArchiveDashboard < Administrate::BaseDashboard
|
||||||
:id,
|
:id,
|
||||||
:created_at,
|
:created_at,
|
||||||
:updated_at,
|
:updated_at,
|
||||||
:status
|
:status,
|
||||||
|
:file
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
# SHOW_PAGE_ATTRIBUTES
|
# SHOW_PAGE_ATTRIBUTES
|
||||||
|
@ -33,14 +34,6 @@ class ArchiveDashboard < Administrate::BaseDashboard
|
||||||
:id,
|
:id,
|
||||||
:created_at,
|
:created_at,
|
||||||
:updated_at,
|
:updated_at,
|
||||||
:status,
|
:status
|
||||||
:file
|
|
||||||
].freeze
|
].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
|
end
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
require "administrate/field/base"
|
require "administrate/field/base"
|
||||||
|
|
||||||
class AttachmentField < Administrate::Field::Base
|
class AttachmentField < Administrate::Field::Base
|
||||||
|
include ActionView::Helpers::NumberHelper
|
||||||
def to_s
|
def to_s
|
||||||
data.filename.to_s
|
"#{data.filename} (#{number_to_human_size(data.byte_size)})"
|
||||||
end
|
end
|
||||||
|
|
||||||
def blob_path
|
def blob_path
|
||||||
|
|
|
@ -12,20 +12,4 @@ module CommentaireHelper
|
||||||
I18n.t('helpers.commentaire.reply_in_mailbox')
|
I18n.t('helpers.commentaire.reply_in_mailbox')
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -7,7 +7,9 @@ module ConservationDeDonneesHelper
|
||||||
|
|
||||||
def conservation_dans_ds(procedure)
|
def conservation_dans_ds(procedure)
|
||||||
if procedure.duree_conservation_dossiers_dans_ds.present?
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -74,4 +74,12 @@ module ProcedureHelper
|
||||||
.includes(:groupe_instructeur)
|
.includes(:groupe_instructeur)
|
||||||
.exists?(groupe_instructeur: current_instructeur.groupe_instructeurs)
|
.exists?(groupe_instructeur: current_instructeur.groupe_instructeurs)
|
||||||
end
|
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
|
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 React from 'react';
|
||||||
import { QueryClientProvider } from 'react-query';
|
import { QueryClientProvider } from 'react-query';
|
||||||
|
|
||||||
import ComboSearch from './ComboSearch';
|
import ComboSearch, { ComboSearchProps } from './ComboSearch';
|
||||||
import { queryClient } from './shared/queryClient';
|
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 (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ComboSearch
|
<ComboSearch
|
||||||
|
{...props}
|
||||||
scope="annuaire-education"
|
scope="annuaire-education"
|
||||||
minimumInputLength={3}
|
minimumInputLength={3}
|
||||||
transformResults={(_, { records }) => records}
|
transformResults={transformResults}
|
||||||
transformResult={({
|
transformResult={({
|
||||||
fields: {
|
fields: {
|
||||||
identifiant_de_l_etablissement: id,
|
identifiant_de_l_etablissement: id,
|
||||||
|
@ -18,10 +34,7 @@ function ComboAnnuaireEducationSearch(props) {
|
||||||
nom_commune
|
nom_commune
|
||||||
}
|
}
|
||||||
}) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]}
|
}) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ComboAnnuaireEducationSearch;
|
|
|
@ -1,19 +1,21 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { QueryClientProvider } from 'react-query';
|
import { QueryClientProvider } from 'react-query';
|
||||||
import { matchSorter } from 'match-sorter';
|
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 { queryClient } from './shared/queryClient';
|
||||||
import { ComboDepartementsSearch } from './ComboDepartementsSearch';
|
import { ComboDepartementsSearch } from './ComboDepartementsSearch';
|
||||||
import { useHiddenField, groupId } from './shared/hooks';
|
import { useHiddenField, groupId } from './shared/hooks';
|
||||||
|
|
||||||
|
type CommuneResult = { code: string; nom: string; codesPostaux: string[] };
|
||||||
|
|
||||||
// Avoid hiding similar matches for precise queries (like "Sainte Marie")
|
// Avoid hiding similar matches for precise queries (like "Sainte Marie")
|
||||||
function searchResultsLimit(term) {
|
function searchResultsLimit(term: string) {
|
||||||
return term.length > 5 ? 10 : 5;
|
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.
|
// A single result may have several associated postal codes.
|
||||||
// To make the search results more precise, we want to generate
|
// To make the search results more precise, we want to generate
|
||||||
// an actual result for each postal code.
|
// an actual result for each postal code.
|
||||||
|
@ -44,13 +46,16 @@ const placeholderDepartements = [
|
||||||
['77 – Seine-et-Marne', 'Melun'],
|
['77 – Seine-et-Marne', 'Melun'],
|
||||||
['22 – Côtes d’Armor', 'Saint-Brieuc'],
|
['22 – Côtes d’Armor', 'Saint-Brieuc'],
|
||||||
['47 – Lot-et-Garonne', 'Agen']
|
['47 – Lot-et-Garonne', 'Agen']
|
||||||
];
|
] as const;
|
||||||
const [placeholderDepartement, placeholderCommune] =
|
const [placeholderDepartement, placeholderCommune] =
|
||||||
placeholderDepartements[
|
placeholderDepartements[
|
||||||
Math.floor(Math.random() * (placeholderDepartements.length - 1))
|
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 group = groupId(id);
|
||||||
const [departementValue, setDepartementValue] = useHiddenField(
|
const [departementValue, setDepartementValue] = useHiddenField(
|
||||||
group,
|
group,
|
||||||
|
@ -74,14 +79,14 @@ function ComboCommunesSearch({ id, ...props }) {
|
||||||
</div>
|
</div>
|
||||||
<ComboDepartementsSearch
|
<ComboDepartementsSearch
|
||||||
{...props}
|
{...props}
|
||||||
id={!codeDepartement ? id : null}
|
id={!codeDepartement ? id : undefined}
|
||||||
describedby={departementDescribedBy}
|
describedby={departementDescribedBy}
|
||||||
placeholder={placeholderDepartement}
|
placeholder={placeholderDepartement}
|
||||||
addForeignDepartement={false}
|
addForeignDepartement={false}
|
||||||
value={departementValue}
|
value={departementValue}
|
||||||
onChange={(_, result) => {
|
onChange={(_, result) => {
|
||||||
setDepartementValue(result?.nom);
|
setDepartementValue(result?.nom ?? '');
|
||||||
setCodeDepartement(result?.code);
|
setCodeDepartement(result?.code ?? '');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -112,9 +117,3 @@ function ComboCommunesSearch({ id, ...props }) {
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ComboCommunesSearch.propTypes = {
|
|
||||||
id: PropTypes.string
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ComboCommunesSearch;
|
|
|
@ -1,14 +1,16 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { QueryClientProvider } from 'react-query';
|
import { QueryClientProvider } from 'react-query';
|
||||||
import { matchSorter } from 'match-sorter';
|
import { matchSorter } from 'match-sorter';
|
||||||
|
|
||||||
import ComboSearch from './ComboSearch';
|
import ComboSearch, { ComboSearchProps } from './ComboSearch';
|
||||||
import { queryClient } from './shared/queryClient';
|
import { queryClient } from './shared/queryClient';
|
||||||
|
|
||||||
|
type DepartementResult = { code: string; nom: string };
|
||||||
|
|
||||||
const extraTerms = [{ code: '99', nom: 'Etranger' }];
|
const extraTerms = [{ code: '99', nom: 'Etranger' }];
|
||||||
|
|
||||||
function expandResultsWithForeignDepartement(term, results) {
|
function expandResultsWithForeignDepartement(term: string, result: unknown) {
|
||||||
|
const results = result as DepartementResult[];
|
||||||
return [
|
return [
|
||||||
...results,
|
...results,
|
||||||
...matchSorter(extraTerms, term, {
|
...matchSorter(extraTerms, term, {
|
||||||
|
@ -17,10 +19,17 @@ function expandResultsWithForeignDepartement(term, results) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ComboDepartementsSearchProps = Omit<
|
||||||
|
ComboSearchProps<DepartementResult> & {
|
||||||
|
addForeignDepartement: boolean;
|
||||||
|
},
|
||||||
|
'transformResult' | 'transformResults'
|
||||||
|
>;
|
||||||
|
|
||||||
export function ComboDepartementsSearch({
|
export function ComboDepartementsSearch({
|
||||||
addForeignDepartement = true,
|
addForeignDepartement = true,
|
||||||
...props
|
...props
|
||||||
}) {
|
}: ComboDepartementsSearchProps) {
|
||||||
return (
|
return (
|
||||||
<ComboSearch
|
<ComboSearch
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -34,17 +43,12 @@ export function ComboDepartementsSearch({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComboDepartementsSearchDefault(params) {
|
export default function ComboDepartementsSearchDefault(
|
||||||
|
params: ComboDepartementsSearchProps
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ComboDepartementsSearch {...params} />
|
<ComboDepartementsSearch {...params} />
|
||||||
</QueryClientProvider>
|
</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 React from 'react';
|
||||||
import { QueryClientProvider } from 'react-query';
|
import { QueryClientProvider } from 'react-query';
|
||||||
|
|
||||||
import ComboSearch from './ComboSearch';
|
import ComboSearch, { ComboSearchProps } from './ComboSearch';
|
||||||
import { queryClient } from './shared/queryClient';
|
import { queryClient } from './shared/queryClient';
|
||||||
|
|
||||||
function ComboPaysSearch(props) {
|
export default function ComboPaysSearch(
|
||||||
|
props: ComboSearchProps<{ code: string; value: string; label: string }>
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ComboSearch
|
<ComboSearch
|
||||||
|
{...props}
|
||||||
scope="pays"
|
scope="pays"
|
||||||
minimumInputLength={0}
|
minimumInputLength={0}
|
||||||
transformResult={({ code, value, label }) => [code, value, label]}
|
transformResult={({ code, value, label }) => [code, value, label]}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ComboPaysSearch;
|
|
|
@ -1,20 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { QueryClientProvider } from 'react-query';
|
import { QueryClientProvider } from 'react-query';
|
||||||
|
|
||||||
import ComboSearch from './ComboSearch';
|
import ComboSearch, { ComboSearchProps } from './ComboSearch';
|
||||||
import { queryClient } from './shared/queryClient';
|
import { queryClient } from './shared/queryClient';
|
||||||
|
|
||||||
function ComboRegionsSearch(props) {
|
export default function ComboRegionsSearch(
|
||||||
|
props: ComboSearchProps<{ code: string; nom: string }>
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ComboSearch
|
<ComboSearch
|
||||||
|
{...props}
|
||||||
scope="regions"
|
scope="regions"
|
||||||
minimumInputLength={0}
|
minimumInputLength={0}
|
||||||
transformResult={({ code, nom }) => [code, nom]}
|
transformResult={({ code, nom }) => [code, nom]}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ComboRegionsSearch;
|
|
|
@ -20,7 +20,7 @@ import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
|
||||||
type TransformResults<Result> = (term: string, results: unknown) => Result[];
|
type TransformResults<Result> = (term: string, results: unknown) => Result[];
|
||||||
type TransformResult<Result> = (
|
type TransformResult<Result> = (
|
||||||
result: Result
|
result: Result
|
||||||
) => [key: string, value: string, label: string];
|
) => [key: string, value: string, label?: string];
|
||||||
|
|
||||||
export type ComboSearchProps<Result> = {
|
export type ComboSearchProps<Result> = {
|
||||||
onChange?: (value: string | null, result?: Result) => void;
|
onChange?: (value: string | null, result?: Result) => void;
|
||||||
|
@ -28,7 +28,7 @@ export type ComboSearchProps<Result> = {
|
||||||
scope: string;
|
scope: string;
|
||||||
scopeExtra?: string;
|
scopeExtra?: string;
|
||||||
minimumInputLength: number;
|
minimumInputLength: number;
|
||||||
transformResults: TransformResults<Result>;
|
transformResults?: TransformResults<Result>;
|
||||||
transformResult: TransformResult<Result>;
|
transformResult: TransformResult<Result>;
|
||||||
allowInputValues?: boolean;
|
allowInputValues?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
|
@ -37,9 +37,7 @@ export function useFeatureCollection(
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: callback(features)
|
features: callback(features)
|
||||||
}));
|
}));
|
||||||
ajax({ url, type: 'GET' })
|
ajax({ url, type: 'GET' }).catch(() => null);
|
||||||
.then(() => fire(document, 'ds:page:update'))
|
|
||||||
.catch(() => null);
|
|
||||||
},
|
},
|
||||||
[url, setFeatureCollection]
|
[url, setFeatureCollection]
|
||||||
);
|
);
|
||||||
|
|
|
@ -161,6 +161,7 @@ export const TypeDeChampComponent = SortableElement<TypeDeChampProps>(
|
||||||
/>
|
/>
|
||||||
<TypeDeChampPieceJustificative
|
<TypeDeChampPieceJustificative
|
||||||
isVisible={isFile}
|
isVisible={isFile}
|
||||||
|
isTitreIdentite={isTitreIdentite}
|
||||||
directUploadUrl={state.directUploadUrl}
|
directUploadUrl={state.directUploadUrl}
|
||||||
filename={typeDeChamp.piece_justificative_template_filename}
|
filename={typeDeChamp.piece_justificative_template_filename}
|
||||||
handler={updateHandlers.piece_justificative_template}
|
handler={updateHandlers.piece_justificative_template}
|
||||||
|
|
|
@ -5,12 +5,14 @@ import type { Handler } from '../types';
|
||||||
|
|
||||||
export function TypeDeChampPieceJustificative({
|
export function TypeDeChampPieceJustificative({
|
||||||
isVisible,
|
isVisible,
|
||||||
|
isTitreIdentite,
|
||||||
url,
|
url,
|
||||||
filename,
|
filename,
|
||||||
handler,
|
handler,
|
||||||
directUploadUrl
|
directUploadUrl
|
||||||
}: {
|
}: {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
isTitreIdentite: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
handler: Handler<HTMLInputElement>;
|
handler: Handler<HTMLInputElement>;
|
||||||
|
@ -32,6 +34,17 @@ export function TypeDeChampPieceJustificative({
|
||||||
</div>
|
</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;
|
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
|
removeClass
|
||||||
} from '@utils';
|
} from '@utils';
|
||||||
|
|
||||||
const AUTOSAVE_DEBOUNCE_DELAY = gon.autosave.debounce_delay;
|
const AUTOSAVE_DEBOUNCE_DELAY = window?.gon?.autosave?.debounce_delay;
|
||||||
const AUTOSAVE_STATUS_VISIBLE_DURATION = gon.autosave.status_visible_duration;
|
const AUTOSAVE_STATUS_VISIBLE_DURATION =
|
||||||
|
window?.gon?.autosave?.status_visible_duration;
|
||||||
|
|
||||||
// Create a controller responsible for queuing autosave operations.
|
// Create a controller responsible for queuing autosave operations.
|
||||||
const autoSaveController = new AutoSaveController();
|
const autoSaveController = new AutoSaveController();
|
||||||
|
|
|
@ -54,5 +54,5 @@ function saveMessageContent() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener('ds:page:update', scrollMessagerie);
|
addEventListener('DOMContentLoaded', scrollMessagerie);
|
||||||
addEventListener('ds:page:update', saveMessageContent);
|
addEventListener('DOMContentLoaded', saveMessageContent);
|
||||||
|
|
|
@ -14,7 +14,7 @@ function expandProcedureDescription() {
|
||||||
descBody.classList.remove('read-more-collapsed');
|
descBody.classList.remove('read-more-collapsed');
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener('ds:page:update', updateReadMoreVisibility);
|
addEventListener('DOMContentLoaded', updateReadMoreVisibility);
|
||||||
addEventListener('resize', updateReadMoreVisibility);
|
addEventListener('resize', updateReadMoreVisibility);
|
||||||
|
|
||||||
delegate('click', '.read-more-button', expandProcedureDescription);
|
delegate('click', '.read-more-button', expandProcedureDescription);
|
||||||
|
|
|
@ -101,7 +101,7 @@ class ButtonExpand {
|
||||||
|
|
||||||
if (document.querySelector('#contact-form')) {
|
if (document.querySelector('#contact-form')) {
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
'ds:page:update',
|
'DOMContentLoaded',
|
||||||
function () {
|
function () {
|
||||||
var buttons = document.querySelectorAll(
|
var buttons = document.querySelectorAll(
|
||||||
'button[aria-expanded][aria-controls], button.button-without-hint'
|
'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 * as ActiveStorage from '@rails/activestorage';
|
||||||
import 'whatwg-fetch'; // window.fetch polyfill
|
import 'whatwg-fetch'; // window.fetch polyfill
|
||||||
import { Application } from '@hotwired/stimulus';
|
import { Application } from '@hotwired/stimulus';
|
||||||
|
import { Turbo } from '@hotwired/turbo-rails';
|
||||||
|
|
||||||
import '../shared/page-update-event';
|
|
||||||
import '../shared/activestorage/ujs';
|
import '../shared/activestorage/ujs';
|
||||||
import '../shared/remote-poller';
|
import '../shared/remote-poller';
|
||||||
import '../shared/safari-11-file-xhr-workaround';
|
import '../shared/safari-11-file-xhr-workaround';
|
||||||
|
@ -17,6 +17,8 @@ import {
|
||||||
ReactController,
|
ReactController,
|
||||||
registerComponents
|
registerComponents
|
||||||
} from '../controllers/react_controller';
|
} 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/dropdown';
|
||||||
import '../new_design/form-validation';
|
import '../new_design/form-validation';
|
||||||
|
@ -28,7 +30,6 @@ import '../new_design/messagerie';
|
||||||
import '../new_design/dossiers/auto-save';
|
import '../new_design/dossiers/auto-save';
|
||||||
import '../new_design/dossiers/auto-upload';
|
import '../new_design/dossiers/auto-upload';
|
||||||
|
|
||||||
import '../new_design/champs/carte';
|
|
||||||
import '../new_design/champs/linked-drop-down-list';
|
import '../new_design/champs/linked-drop-down-list';
|
||||||
import '../new_design/champs/repetition';
|
import '../new_design/champs/repetition';
|
||||||
import '../new_design/champs/drop-down-list';
|
import '../new_design/champs/drop-down-list';
|
||||||
|
@ -89,9 +90,12 @@ const DS = {
|
||||||
// Start Rails helpers
|
// Start Rails helpers
|
||||||
Rails.start();
|
Rails.start();
|
||||||
ActiveStorage.start();
|
ActiveStorage.start();
|
||||||
|
Turbo.session.drive = false;
|
||||||
|
|
||||||
const Stimulus = Application.start();
|
const Stimulus = Application.start();
|
||||||
Stimulus.register('react', ReactController);
|
Stimulus.register('react', ReactController);
|
||||||
|
Stimulus.register('turbo-event', TurboEventController);
|
||||||
|
Stimulus.register('geo-area', GeoAreaController);
|
||||||
|
|
||||||
// Expose globals
|
// Expose globals
|
||||||
window.DS = window.DS || DS;
|
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
|
// 2. Create each kind of error on a different line
|
||||||
// (so that Sentry knows they are different kind of errors, from
|
// (so that Sentry knows they are different kind of errors, from
|
||||||
// the line they were created.)
|
// 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 matches = message.match(/ Status: ([0-9]{1,3})/);
|
||||||
const status = matches ? parseInt(matches[1], 10) : undefined;
|
const status = matches ? parseInt(matches[1], 10) : undefined;
|
||||||
|
|
||||||
|
|
|
@ -60,7 +60,7 @@ export default class Uploader {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.directUpload.create((errorMsg, attributes) => {
|
this.directUpload.create((errorMsg, attributes) => {
|
||||||
if (errorMsg) {
|
if (errorMsg) {
|
||||||
const error = errorFromDirectUploadMessage(errorMsg.message);
|
const error = errorFromDirectUploadMessage(errorMsg);
|
||||||
reject(error);
|
reject(error);
|
||||||
} else {
|
} else {
|
||||||
resolve(attributes.signed_id);
|
resolve(attributes.signed_id);
|
||||||
|
|
|
@ -20,7 +20,7 @@ function init() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener('ds:page:update', init);
|
addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
function toggleElement(event) {
|
function toggleElement(event) {
|
||||||
event.preventDefault();
|
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);
|
el && el.classList.remove(cssClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function delegate(
|
export function delegate<E extends Event = Event>(
|
||||||
eventNames: string,
|
eventNames: string,
|
||||||
selector: string,
|
selector: string,
|
||||||
callback: () => void
|
callback: (event: E) => void
|
||||||
) {
|
) {
|
||||||
eventNames
|
eventNames
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.forEach((eventName) =>
|
.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 'react-coordinate-input';
|
||||||
|
declare module 'chartkick';
|
||||||
|
|
|
@ -120,7 +120,7 @@ class APIEntreprise::API
|
||||||
# rubocop:disable DS/ApplicationName
|
# rubocop:disable DS/ApplicationName
|
||||||
params = {
|
params = {
|
||||||
context: "demarches-simplifiees.fr",
|
context: "demarches-simplifiees.fr",
|
||||||
recipient: siret_or_siren,
|
recipient: ENV.fetch('API_ENTREPRISE_DEFAULT_SIRET'),
|
||||||
object: "procedure_id: #{procedure_id}",
|
object: "procedure_id: #{procedure_id}",
|
||||||
non_diffusables: true
|
non_diffusables: true
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,6 +89,12 @@ class Commentaire < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def soft_delete!
|
||||||
|
piece_jointe.purge_later if piece_jointe.attached?
|
||||||
|
discard!
|
||||||
|
update! body: ''
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def notify
|
def notify
|
||||||
|
|
|
@ -33,7 +33,7 @@ module MailTemplateConcern
|
||||||
module ClassMethods
|
module ClassMethods
|
||||||
def default_for_procedure(procedure)
|
def default_for_procedure(procedure)
|
||||||
template_name = default_template_name_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/, '')
|
trix_rich_body = rich_body.gsub(/(?<!^|[.-])(?<!<\/strong>)\n/, '')
|
||||||
new(subject: const_get(:DEFAULT_SUBJECT), rich_body: trix_rich_body, procedure: procedure)
|
new(subject: const_get(:DEFAULT_SUBJECT), rich_body: trix_rich_body, procedure: procedure)
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,6 +15,8 @@
|
||||||
# instructeur_id :bigint
|
# instructeur_id :bigint
|
||||||
#
|
#
|
||||||
class DossierOperationLog < ApplicationRecord
|
class DossierOperationLog < ApplicationRecord
|
||||||
|
self.ignored_columns = [:instructeur_id]
|
||||||
|
|
||||||
enum operation: {
|
enum operation: {
|
||||||
changer_groupe_instructeur: 'changer_groupe_instructeur',
|
changer_groupe_instructeur: 'changer_groupe_instructeur',
|
||||||
passer_en_instruction: 'passer_en_instruction',
|
passer_en_instruction: 'passer_en_instruction',
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
# juridique_required :boolean default(TRUE)
|
# juridique_required :boolean default(TRUE)
|
||||||
# libelle :string
|
# libelle :string
|
||||||
# lien_demarche :string
|
# lien_demarche :string
|
||||||
|
# lien_dpo :string
|
||||||
# lien_notice :string
|
# lien_notice :string
|
||||||
# lien_site_web :string
|
# lien_site_web :string
|
||||||
# monavis_embed :text
|
# monavis_embed :text
|
||||||
|
@ -266,6 +267,7 @@ class Procedure < ApplicationRecord
|
||||||
validate :check_juridique
|
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 :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 :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
|
validates_with MonAvisEmbedValidator
|
||||||
|
|
||||||
FILE_MAX_SIZE = 20.megabytes
|
FILE_MAX_SIZE = 20.megabytes
|
||||||
|
@ -464,6 +466,7 @@ class Procedure < ApplicationRecord
|
||||||
procedure.closed_at = nil
|
procedure.closed_at = nil
|
||||||
procedure.unpublished_at = nil
|
procedure.unpublished_at = nil
|
||||||
procedure.published_at = nil
|
procedure.published_at = nil
|
||||||
|
procedure.auto_archive_on = nil
|
||||||
procedure.lien_notice = nil
|
procedure.lien_notice = nil
|
||||||
procedure.published_revision = nil
|
procedure.published_revision = nil
|
||||||
procedure.draft_revision.procedure = procedure
|
procedure.draft_revision.procedure = procedure
|
||||||
|
|
|
@ -3,16 +3,18 @@ class ProcedureExportService
|
||||||
|
|
||||||
def initialize(procedure, dossiers)
|
def initialize(procedure, dossiers)
|
||||||
@procedure = procedure
|
@procedure = procedure
|
||||||
@dossiers = dossiers.downloadable_sorted_batch
|
@dossiers = dossiers
|
||||||
@tables = [:dossiers, :etablissements, :avis] + champs_repetables_options
|
@tables = [:dossiers, :etablissements, :avis] + champs_repetables_options
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_csv
|
def to_csv
|
||||||
|
@dossiers = @dossiers.downloadable_sorted_batch
|
||||||
io = StringIO.new(SpreadsheetArchitect.to_csv(options_for(:dossiers, :csv)))
|
io = StringIO.new(SpreadsheetArchitect.to_csv(options_for(:dossiers, :csv)))
|
||||||
create_blob(io, :csv)
|
create_blob(io, :csv)
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_xlsx
|
def to_xlsx
|
||||||
|
@dossiers = @dossiers.downloadable_sorted_batch
|
||||||
# We recursively build multi page spreadsheet
|
# We recursively build multi page spreadsheet
|
||||||
io = @tables.reduce(nil) do |package, table|
|
io = @tables.reduce(nil) do |package, table|
|
||||||
SpreadsheetArchitect.to_axlsx_package(options_for(table, :xlsx), package)
|
SpreadsheetArchitect.to_axlsx_package(options_for(table, :xlsx), package)
|
||||||
|
@ -21,6 +23,7 @@ class ProcedureExportService
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_ods
|
def to_ods
|
||||||
|
@dossiers = @dossiers.downloadable_sorted_batch
|
||||||
# We recursively build multi page spreadsheet
|
# We recursively build multi page spreadsheet
|
||||||
io = StringIO.new(@tables.reduce(nil) do |spreadsheet, table|
|
io = StringIO.new(@tables.reduce(nil) do |spreadsheet, table|
|
||||||
SpreadsheetArchitect.to_rodf_spreadsheet(options_for(table, :ods), spreadsheet)
|
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),
|
= form_for procedure.administrateurs.new(user: User.new),
|
||||||
url: { controller: 'procedure_administrateurs' },
|
url: { controller: 'procedure_administrateurs' },
|
||||||
html: { class: 'form', id: "procedure-#{procedure.id}-new_administrateur" } ,
|
html: { class: 'form', id: "new_administrateur" },
|
||||||
remote: true do |f|
|
data: { turbo: true } do |f|
|
||||||
= f.label :email do
|
= f.label :email do
|
||||||
Ajouter un administrateur
|
Ajouter un administrateur
|
||||||
%p.notice Renseignez l’email d’un administrateur déjà enregistré sur #{APPLICATION_NAME} pour lui permettre de modifier « #{procedure.libelle} ».
|
%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= administrateur.email
|
||||||
%td= try_format_datetime(administrateur.created_at)
|
%td= try_format_datetime(administrateur.created_at)
|
||||||
%td= administrateur.registration_state
|
%td= administrateur.registration_state
|
||||||
|
@ -6,8 +6,8 @@
|
||||||
- if administrateur == current_administrateur
|
- if administrateur == current_administrateur
|
||||||
C’est vous !
|
C’est vous !
|
||||||
- else
|
- else
|
||||||
= link_to 'Retirer',
|
= button_to 'Retirer',
|
||||||
admin_procedure_administrateur_path(@procedure, administrateur),
|
admin_procedure_administrateur_path(procedure, administrateur),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
'data-confirm': "Retirer « #{administrateur.email} » des administrateurs de « #{@procedure.libelle} » ?",
|
class: 'button',
|
||||||
remote: true
|
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= 'Adresse email'
|
||||||
%th= 'Enregistré le'
|
%th= 'Enregistré le'
|
||||||
%th= 'État'
|
%th= 'État'
|
||||||
%tbody{ id: "procedure-#{@procedure.id}-administrateurs" }
|
%tbody#administrateurs
|
||||||
= render partial: 'administrateur', collection: @procedure.administrateurs.order('users.email')
|
= render partial: 'administrateur', collection: @procedure.administrateurs.order('users.email'), locals: { procedure: @procedure }
|
||||||
%tfoot
|
%tfoot
|
||||||
%tr
|
%tr
|
||||||
%th{ colspan: 4 }
|
%th{ colspan: 4 }
|
||||||
|
|
|
@ -57,6 +57,13 @@
|
||||||
= f.label :deliberation, 'Importer le texte'
|
= f.label :deliberation, 'Importer le texte'
|
||||||
= text_upload_and_render f, @procedure.deliberation
|
= 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
|
%h3.header-subsection Notice explicative de la démarche
|
||||||
|
|
||||||
%p.notice
|
%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"}"] }
|
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
|
.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
|
= link_to apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'button', id: "preview-procedure" do
|
||||||
%span.icon.preview
|
%span.icon.preview
|
||||||
Prévisualiser
|
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
|
%ul.messages-list
|
||||||
- @dossier.commentaires.with_attached_piece_jointe.each do |commentaire|
|
- @dossier.commentaires.with_attached_piece_jointe.each do |commentaire|
|
||||||
%li
|
%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" }
|
%script{ type: "text/javascript" }
|
||||||
window.print();
|
window.print();
|
||||||
|
|
|
@ -16,6 +16,6 @@
|
||||||
%br
|
%br
|
||||||
Certaines parties du site ne fonctionneront pas correctement.
|
Certaines parties du site ne fonctionneront pas correctement.
|
||||||
.site-banner-actions
|
.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" }
|
%a.btn.button.primary{ href: "https://browser-update.org/fr/update.html", target: "_blank", rel: "noopener" }
|
||||||
Mettre à jour mon navigateur
|
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)
|
= content_for(:footer)
|
||||||
|
|
||||||
= yield :charts_js
|
= 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
|
- if editing
|
||||||
= link_to '#', data: { geo_area: geo_area.id } do
|
= link_to '#', data: { action: 'geo-area#onClick' } do
|
||||||
= geo_area_label(geo_area)
|
= 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
|
- else
|
||||||
= link_to '#', data: { geo_area: geo_area.id } do
|
= link_to '#', data: { action: 'geo-area#onClick' } do
|
||||||
= geo_area_label(geo_area)
|
= geo_area_label(geo_area)
|
||||||
- if geo_area.description.present?
|
- if geo_area.description.present?
|
||||||
%span
|
%span
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
.messagerie.container
|
.messagerie.container
|
||||||
%ul.messages-list
|
%ul.messages-list
|
||||||
- dossier.commentaires.with_attached_piece_jointe.each do |commentaire|
|
- dossier.commentaires.with_attached_piece_jointe.each do |commentaire|
|
||||||
%li.message{ class: commentaire_is_from_me_class(commentaire, connected_user) }
|
%li.message{ class: commentaire_is_from_me_class(commentaire, connected_user), id: dom_id(commentaire) }
|
||||||
= 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) }
|
= 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?
|
- if dossier.messagerie_available?
|
||||||
= render partial: "shared/dossiers/messages/form", locals: { commentaire: new_commentaire, form_url: form_url, dossier: dossier }
|
= 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?
|
- if service.present?
|
||||||
.footer-row.footer-columns
|
.footer-row.footer-columns
|
||||||
.footer-column
|
.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
|
%ul
|
||||||
%li
|
%li
|
||||||
= service.nom
|
= service.nom
|
||||||
|
@ -14,37 +14,49 @@
|
||||||
= string_to_html(service.adresse, wrapper_tag = 'span')
|
= string_to_html(service.adresse, wrapper_tag = 'span')
|
||||||
|
|
||||||
.footer-column
|
.footer-column
|
||||||
%p.footer-header Poser une question sur votre dossier :
|
%p.footer-header= I18n.t('users.procedure_footer.contact.header')
|
||||||
%ul
|
%ul
|
||||||
%li
|
%li
|
||||||
- if dossier.present? && dossier.messagerie_available?
|
- if dossier.present? && dossier.messagerie_available?
|
||||||
Directement
|
= I18n.t('users.procedure_footer.contact.in_app_mail.prefix')
|
||||||
= link_to "par la messagerie", messagerie_dossier_path(dossier)
|
= link_to I18n.t('users.procedure_footer.contact.in_app_mail.link'), messagerie_dossier_path(dossier)
|
||||||
- else
|
- else
|
||||||
Par email :
|
= I18n.t('users.procedure_footer.contact.email.prefix')
|
||||||
= link_to service.email, "mailto:#{service.email}"
|
= link_to service.email, "mailto:#{service.email}"
|
||||||
|
|
||||||
- if service.telephone.present?
|
- if service.telephone.present?
|
||||||
%li
|
%li
|
||||||
Par téléphone :
|
= I18n.t('users.procedure_footer.contact.phone.prefix')
|
||||||
= link_to service.telephone, service.telephone_url
|
= link_to service.telephone, service.telephone_url
|
||||||
|
|
||||||
%li
|
%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')
|
= simple_format(horaires, {}, wrapper_tag: 'span')
|
||||||
|
|
||||||
%li
|
%li
|
||||||
Statistiques :
|
= I18n.t('users.procedure_footer.contact.stats.prefix')
|
||||||
= link_to "voir les statistiques de la démarche", statistiques_path(procedure.path)
|
= link_to I18n.t('users.procedure_footer.contact.stats.cta'), statistiques_path(procedure.path)
|
||||||
|
|
||||||
|
|
||||||
- politiques = politiques_conservation_de_donnees(procedure)
|
- politiques = politiques_conservation_de_donnees(procedure)
|
||||||
- if politiques.present?
|
- if politiques.present?
|
||||||
.footer-column
|
.footer-column
|
||||||
%p.footer-header Conservation des données :
|
%p.footer-header= I18n.t('users.procedure_footer.legals.header')
|
||||||
%ul
|
%ul
|
||||||
- politiques.each do |politique|
|
- politiques.each do |politique|
|
||||||
%li= 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 }
|
= 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')
|
%h3.tab-title= t('views.users.dossiers.show.latest_message.latest_message')
|
||||||
|
|
||||||
.message.inverted-background
|
.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
|
= link_to messagerie_dossier_url(dossier, anchor: 'new_commentaire'), class: 'button send' do
|
||||||
%span.icon.reply
|
%span.icon.reply
|
||||||
|
|
|
@ -86,12 +86,6 @@ module.exports = function (api) {
|
||||||
{
|
{
|
||||||
async: false
|
async: false
|
||||||
}
|
}
|
||||||
],
|
|
||||||
isProductionEnv && [
|
|
||||||
'babel-plugin-transform-react-remove-prop-types',
|
|
||||||
{
|
|
||||||
removeImport: true
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
};
|
};
|
||||||
|
|
|
@ -81,5 +81,13 @@ module TPS
|
||||||
# Custom Configuration
|
# Custom Configuration
|
||||||
# @see https://guides.rubyonrails.org/configuring.html#custom-configuration
|
# @see https://guides.rubyonrails.org/configuring.html#custom-configuration
|
||||||
config.x.clamav.enabled = ENV.fetch("CLAMAV_ENABLED", "enabled") == "enabled"
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -157,3 +157,6 @@ INVISIBLE_CAPTCHA_SECRET="kikooloool"
|
||||||
|
|
||||||
# Clamav antivirus usage
|
# Clamav antivirus usage
|
||||||
CLAMAV_ENABLED="disabled"
|
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/images
|
||||||
- app/assets/fonts
|
- app/assets/fonts
|
||||||
- app/assets/videos
|
- app/assets/videos
|
||||||
|
- app/components
|
||||||
|
|
||||||
## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`:
|
## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`:
|
||||||
## If specified, this settings takes priority over `exclude`, but `exclude` still applies.
|
## If specified, this settings takes priority over `exclude`, but `exclude` still applies.
|
||||||
|
|
|
@ -127,12 +127,6 @@ en:
|
||||||
submit_dossier: Submit the file
|
submit_dossier: Submit the file
|
||||||
save_changes: Save the changes of the file
|
save_changes: Save the changes of the file
|
||||||
messages:
|
messages:
|
||||||
message_issuer:
|
|
||||||
automatic_email: "Automatic email"
|
|
||||||
you: "You"
|
|
||||||
message:
|
|
||||||
reply: "Reply"
|
|
||||||
guest: "Guest"
|
|
||||||
form:
|
form:
|
||||||
send_message: "Send message"
|
send_message: "Send message"
|
||||||
attachment_size: "(attachment size max : 20 Mo)"
|
attachment_size: "(attachment size max : 20 Mo)"
|
||||||
|
|
|
@ -122,12 +122,6 @@ fr:
|
||||||
submit_dossier: Déposer le dossier
|
submit_dossier: Déposer le dossier
|
||||||
save_changes: Enregistrer les modifications du dossier
|
save_changes: Enregistrer les modifications du dossier
|
||||||
messages:
|
messages:
|
||||||
message_issuer:
|
|
||||||
automatic_email: "Email automatique"
|
|
||||||
you: "Vous"
|
|
||||||
message:
|
|
||||||
reply: "Répondre"
|
|
||||||
guest: "Invité"
|
|
||||||
form:
|
form:
|
||||||
send_message: "Envoyer le message"
|
send_message: "Envoyer le message"
|
||||||
attachment_size: "(taille max : 20 Mo)"
|
attachment_size: "(taille max : 20 Mo)"
|
||||||
|
|
|
@ -12,8 +12,8 @@ en:
|
||||||
url: "https://numerique.gouv.fr"
|
url: "https://numerique.gouv.fr"
|
||||||
footer:
|
footer:
|
||||||
accessibilite:
|
accessibilite:
|
||||||
label: "Accessibility: not compliant"
|
label: "Accessibility: partially compliant"
|
||||||
title: "Accessibility: not compliant"
|
title: "Accessibility: partially compliant"
|
||||||
url: "https://doc.demarches-simplifiees.fr/declaration-daccessibilite"
|
url: "https://doc.demarches-simplifiees.fr/declaration-daccessibilite"
|
||||||
aide:
|
aide:
|
||||||
label: "Help"
|
label: "Help"
|
||||||
|
|
|
@ -12,8 +12,8 @@ fr:
|
||||||
url: "https://numerique.gouv.fr"
|
url: "https://numerique.gouv.fr"
|
||||||
footer:
|
footer:
|
||||||
accessibilite:
|
accessibilite:
|
||||||
label: "Accessibilité : non conforme"
|
label: "Accessibilité : partiellement conforme"
|
||||||
title: "Accessibilité : non conforme"
|
title: "Accessibilité : partiellement conforme"
|
||||||
url: "https://doc.demarches-simplifiees.fr/declaration-daccessibilite"
|
url: "https://doc.demarches-simplifiees.fr/declaration-daccessibilite"
|
||||||
aide:
|
aide:
|
||||||
label: "Aide"
|
label: "Aide"
|
||||||
|
|
|
@ -17,6 +17,7 @@ fr:
|
||||||
declarative_with_state/en_instruction: En instruction
|
declarative_with_state/en_instruction: En instruction
|
||||||
declarative_with_state/accepte: Accepté
|
declarative_with_state/accepte: Accepté
|
||||||
api_particulier_token: Jeton API Particulier
|
api_particulier_token: Jeton API Particulier
|
||||||
|
lien_dpo: Contact du DPO
|
||||||
errors:
|
errors:
|
||||||
models:
|
models:
|
||||||
procedure:
|
procedure:
|
||||||
|
@ -27,3 +28,5 @@ fr:
|
||||||
format: 'Le champ %{message}'
|
format: 'Le champ %{message}'
|
||||||
draft_types_de_champ_private:
|
draft_types_de_champ_private:
|
||||||
format: 'L’annotation privée %{message}'
|
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:
|
instructeurs:
|
||||||
procedure:
|
procedure:
|
||||||
archive_pending_html: Archive creation pending<br>(requested %{created_period} ago)
|
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"
|
already_user: "I already have an account"
|
||||||
create: 'Create an account'
|
create: 'Create an account'
|
||||||
signin: 'Sign in'
|
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'
|
already_user: 'J’ai déjà un compte'
|
||||||
create: 'Créer un compte'
|
create: 'Créer un compte'
|
||||||
signin: 'Connexion'
|
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
|
end
|
||||||
|
|
||||||
namespace :commencer do
|
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 '/test/:path', action: 'commencer_test', as: :test
|
||||||
get '/:path', action: 'commencer'
|
get '/:path', action: 'commencer'
|
||||||
get '/:path/dossier_vide', action: 'dossier_vide_pdf', as: :dossier_vide
|
get '/:path/dossier_vide', action: 'dossier_vide_pdf', as: :dossier_vide
|
||||||
|
@ -310,7 +310,6 @@ Rails.application.routes.draw do
|
||||||
get 'instruction'
|
get 'instruction'
|
||||||
get 'messagerie'
|
get 'messagerie'
|
||||||
post 'commentaire' => 'avis#create_commentaire'
|
post 'commentaire' => 'avis#create_commentaire'
|
||||||
delete 'delete_commentaire' => 'avis#delete_commentaire'
|
|
||||||
post 'avis' => 'avis#create_avis'
|
post 'avis' => 'avis#create_avis'
|
||||||
get 'bilans_bdf'
|
get 'bilans_bdf'
|
||||||
get 'telecharger_pjs' => 'avis#telecharger_pjs'
|
get 'telecharger_pjs' => 'avis#telecharger_pjs'
|
||||||
|
|
|
@ -2,8 +2,8 @@ class AddAdministrateurForeignKeyToAdministrateursProcedure < ActiveRecord::Migr
|
||||||
include Database::MigrationHelpers
|
include Database::MigrationHelpers
|
||||||
|
|
||||||
def up
|
def up
|
||||||
delete_orphans :administrateurs_procedures, :administrateurs_procedures
|
delete_orphans :administrateurs_procedures, :administrateurs
|
||||||
add_foreign_key :administrateurs_procedures, :administrateurs_procedures
|
add_foreign_key :administrateurs_procedures, :administrateurs
|
||||||
end
|
end
|
||||||
|
|
||||||
def down
|
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -645,6 +645,7 @@ ActiveRecord::Schema.define(version: 2022_04_07_081538) do
|
||||||
t.boolean "juridique_required", default: true
|
t.boolean "juridique_required", default: true
|
||||||
t.string "libelle"
|
t.string "libelle"
|
||||||
t.string "lien_demarche"
|
t.string "lien_demarche"
|
||||||
|
t.string "lien_dpo"
|
||||||
t.string "lien_notice"
|
t.string "lien_notice"
|
||||||
t.string "lien_site_web"
|
t.string "lien_site_web"
|
||||||
t.text "monavis_embed"
|
t.text "monavis_embed"
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"@headlessui/react": "^1.5.0",
|
"@headlessui/react": "^1.5.0",
|
||||||
"@heroicons/react": "^1.0.6",
|
"@heroicons/react": "^1.0.6",
|
||||||
"@hotwired/stimulus": "^3.0.1",
|
"@hotwired/stimulus": "^3.0.1",
|
||||||
|
"@hotwired/turbo-rails": "^7.1.1",
|
||||||
"@mapbox/mapbox-gl-draw": "^1.3.0",
|
"@mapbox/mapbox-gl-draw": "^1.3.0",
|
||||||
"@popperjs/core": "^2.11.4",
|
"@popperjs/core": "^2.11.4",
|
||||||
"@rails/actiontext": "^6.1.4-1",
|
"@rails/actiontext": "^6.1.4-1",
|
||||||
|
@ -17,7 +18,6 @@
|
||||||
"@sentry/browser": "6.12.0",
|
"@sentry/browser": "6.12.0",
|
||||||
"@tmcw/togeojson": "^4.3.0",
|
"@tmcw/togeojson": "^4.3.0",
|
||||||
"babel-plugin-macros": "^2.8.0",
|
"babel-plugin-macros": "^2.8.0",
|
||||||
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
|
||||||
"chartkick": "^4.1.1",
|
"chartkick": "^4.1.1",
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
"debounce": "^1.2.1",
|
"debounce": "^1.2.1",
|
||||||
|
@ -29,7 +29,6 @@
|
||||||
"is-hotkey": "^0.2.0",
|
"is-hotkey": "^0.2.0",
|
||||||
"maplibre-gl": "^1.15.2",
|
"maplibre-gl": "^1.15.2",
|
||||||
"match-sorter": "^6.2.0",
|
"match-sorter": "^6.2.0",
|
||||||
"prop-types": "^15.7.2",
|
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-coordinate-input": "^1.0.0",
|
"react-coordinate-input": "^1.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
|
@ -42,7 +41,8 @@
|
||||||
"use-debounce": "^5.2.0",
|
"use-debounce": "^5.2.0",
|
||||||
"webpack": "^4.46.0",
|
"webpack": "^4.46.0",
|
||||||
"webpack-cli": "^3.3.12",
|
"webpack-cli": "^3.3.12",
|
||||||
"whatwg-fetch": "^3.0.0"
|
"whatwg-fetch": "^3.0.0",
|
||||||
|
"zod": "^3.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@2fd/graphdoc": "^2.4.0",
|
"@2fd/graphdoc": "^2.4.0",
|
||||||
|
|
|
@ -1,21 +1,28 @@
|
||||||
describe 'shared/dossiers/messages/message.html.haml', type: :view do
|
RSpec.describe Dossiers::MessageComponent, type: :component do
|
||||||
before { view.extend DossierHelper }
|
let(:component) do
|
||||||
|
described_class.new(
|
||||||
subject { render 'shared/dossiers/messages/message.html.haml', commentaire: commentaire, messagerie_seen_at: seen_at, connected_user: dossier.user, show_reply_button: true }
|
commentaire: commentaire,
|
||||||
|
connected_user: connected_user,
|
||||||
|
messagerie_seen_at: seen_at,
|
||||||
|
show_reply_button: true
|
||||||
|
)
|
||||||
|
end
|
||||||
let(:dossier) { create(:dossier, :en_construction) }
|
let(:dossier) { create(:dossier, :en_construction) }
|
||||||
let(:commentaire) { create(:commentaire, dossier: dossier) }
|
let(:commentaire) { create(:commentaire, dossier: dossier) }
|
||||||
|
let(:connected_user) { dossier.user }
|
||||||
let(:seen_at) { commentaire.created_at + 1.hour }
|
let(:seen_at) { commentaire.created_at + 1.hour }
|
||||||
|
|
||||||
|
subject { render_inline(component).to_html }
|
||||||
|
|
||||||
it { is_expected.to have_button("Répondre") }
|
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 }
|
let(:seen_at) { commentaire.created_at + 1.hour }
|
||||||
|
|
||||||
it { is_expected.not_to have_css(".highlighted") }
|
it { is_expected.not_to have_css(".highlighted") }
|
||||||
end
|
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 }
|
let(:seen_at) { commentaire.created_at - 1.hour }
|
||||||
|
|
||||||
it { is_expected.to have_css(".highlighted") }
|
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(:instructeur) { create(:instructeur) }
|
||||||
let(:procedure) { create(:procedure) }
|
let(:procedure) { create(:procedure) }
|
||||||
let(:dossier) { create(:dossier, :en_construction, commentaires: [commentaire], procedure: 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(:connected_user) { instructeur }
|
||||||
let(:form_url) { instructeur_commentaire_path(commentaire.dossier.procedure, commentaire.dossier, commentaire) }
|
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
|
context 'on a procedure where commentaire had been written by connected instructeur' do
|
||||||
let(:commentaire) { create(:commentaire, instructeur: instructeur, body: 'Second message') }
|
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) }
|
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("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
|
end
|
||||||
|
|
||||||
context 'on a procedure where commentaire had been written by connected an user' do
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with an expert message' do
|
describe '#commentaire_from_guest?' do
|
||||||
describe 'delete message button for expert' do
|
let!(:guest) { create(:invite, dossier: dossier) }
|
||||||
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) }
|
|
||||||
|
|
||||||
before do
|
subject { component.send(:commentaire_from_guest?) }
|
||||||
assign(:avis, avis)
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
context 'on a procedure where commentaire had been written by connected expert' do
|
context 'when formatting the first day of the month' do
|
||||||
let(:commentaire) { create(:commentaire, expert: expert, body: 'Second message') }
|
let(:present_date) { Time.zone.local(2018, 9, 1, 10, 5, 0) }
|
||||||
|
it 'includes the ordinal' do
|
||||||
it { is_expected.to have_selector("form[action=\"#{form_url}\"]") }
|
expect(subject).to eq 'le 1er septembre à 10 h 05'
|
||||||
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}\"]") }
|
|
||||||
end
|
end
|
||||||
end
|
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