Merge pull request #7204 from betagouv/main

2022-04-27-01
This commit is contained in:
mfo 2022-04-27 16:33:07 +02:00 committed by GitHub
commit ecf0ba54a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
111 changed files with 982 additions and 478 deletions

View 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

View file

@ -84,7 +84,9 @@ gem 'sib-api-v3-sdk'
gem 'skylight'
gem 'spreadsheet_architect'
gem 'strong_migrations' # lint database migrations
gem 'turbo-rails'
gem 'typhoeus'
gem 'view_component'
gem 'warden'
gem 'webpacker'
gem 'zipline'

View file

@ -723,6 +723,8 @@ GEM
timecop (0.9.4)
timeout (0.1.1)
ttfunk (1.7.0)
turbo-rails (0.8.3)
rails (>= 6.0.0)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.4)
@ -739,6 +741,9 @@ GEM
activemodel (>= 3.0.0)
public_suffix
vcr (6.0.0)
view_component (2.53.0)
activesupport (>= 5.0.0, < 8.0)
method_source (~> 1.0)
virtus (2.0.0)
axiom-types (~> 0.1)
coercible (~> 1.0)
@ -899,8 +904,10 @@ DEPENDENCIES
spring-commands-rspec
strong_migrations
timecop
turbo-rails
typhoeus
vcr
view_component
warden
web-console
webdrivers (~> 4.0)

View file

@ -22,3 +22,7 @@ a {
text-decoration: none;
}
turbo-events {
display: none;
}

View file

@ -0,0 +1,3 @@
class ApplicationComponent < ViewComponent::Base
include ViewComponent::Translatable
end

View 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

View file

@ -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

View file

@ -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é

View file

@ -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')

View file

@ -254,7 +254,7 @@ module Administrateurs
end
def procedure_params
editable_params = [:libelle, :description, :organisation, :direction, :lien_site_web, :cadre_juridique, :deliberation, :notice, :web_hook_url, :declarative_with_state, :logo, :auto_archive_on, :monavis_embed, :api_entreprise_token, :duree_conservation_dossiers_dans_ds, :zone_id]
editable_params = [:libelle, :description, :organisation, :direction, :lien_site_web, :cadre_juridique, :deliberation, :notice, :web_hook_url, :declarative_with_state, :logo, :auto_archive_on, :monavis_embed, :api_entreprise_token, :duree_conservation_dossiers_dans_ds, :zone_id, :lien_dpo]
permited_params = if @procedure&.locked?
params.require(:procedure).permit(*editable_params)
else

View file

@ -113,22 +113,6 @@ module Experts
end
end
def delete_commentaire
commentaire = avis.dossier.commentaires.find(params[:commentaire])
if commentaire.sent_by?(current_expert)
commentaire.piece_jointe.purge_later if commentaire.piece_jointe.attached?
commentaire.discard!
commentaire.update!(body: '')
flash[:notice] = t('views.shared.commentaires.destroy.notice')
else
flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.acl')
end
redirect_to(messagerie_expert_avis_path(avis.procedure, avis))
rescue Discard::RecordNotDiscarded
flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.already_discarded')
redirect_to(messagerie_expert_avis_path(avis.procedure, avis))
end
def bilans_bdf
if avis.dossier.etablissement&.entreprise_bilans_bdf.present?
extension = params[:format]

View file

@ -1,21 +1,33 @@
# frozen_string_literal: true
module Instructeurs
class CommentairesController < ProceduresController
after_action :mark_messagerie_as_read
def destroy
commentaire = Dossier.find(params[:dossier_id]).commentaires.find(params[:id])
if commentaire.sent_by?(current_instructeur)
commentaire.piece_jointe.purge_later if commentaire.piece_jointe.attached?
commentaire.discard!
commentaire.update!(body: '')
flash[:notice] = t('views.shared.commentaires.destroy.notice')
if commentaire.sent_by?(current_instructeur) || commentaire.sent_by?(current_expert)
commentaire.soft_delete!
flash.notice = t('.notice')
else
flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.acl')
flash.alert = t('.alert_acl')
end
redirect_to(messagerie_instructeur_dossier_path(params[:procedure_id], params[:dossier_id]))
rescue Discard::RecordNotDiscarded
flash[:alert] = I18n.t('views.shared.commentaires.destroy.alert_reasons.already_discarded')
redirect_to(messagerie_instructeur_dossier_path(params[:procedure_id], params[:dossier_id]))
# i18n-tasks-use t('instructeurs.commentaires.destroy.alert_already_discarded')
flash.alert = t('.alert_already_discarded')
end
private
def mark_messagerie_as_read
if commentaire.sent_by?(current_instructeur)
current_instructeur.mark_tab_as_seen(commentaire.dossier, :messagerie)
end
end
def commentaire
@commentaire ||= Dossier
.find(params[:dossier_id])
.commentaires
.find(params[:id])
end
end
end

View file

@ -84,7 +84,7 @@ class RootController < ApplicationController
respond_to do |format|
format.html { redirect_back(fallback_location: root_path) }
format.js { render js: helpers.remove_element('#outdated-browser-banner') }
format.turbo_stream
end
end

View file

@ -12,7 +12,7 @@ class ArchiveDashboard < Administrate::BaseDashboard
created_at: Field::DateTime,
updated_at: Field::DateTime,
status: Field::String,
file: Field::HasOne
file: AttachmentField
}.freeze
# COLLECTION_ATTRIBUTES
@ -24,7 +24,8 @@ class ArchiveDashboard < Administrate::BaseDashboard
:id,
:created_at,
:updated_at,
:status
:status,
:file
].freeze
# SHOW_PAGE_ATTRIBUTES
@ -33,14 +34,6 @@ class ArchiveDashboard < Administrate::BaseDashboard
:id,
:created_at,
:updated_at,
:status,
:file
:status
].freeze
# Overwrite this method to customize how users are displayed
# across all pages of the admin dashboard.
#
def display_resource(archive)
"Archive : #{archive&.file.&byte_size}"
end
end

View file

@ -1,8 +1,9 @@
require "administrate/field/base"
class AttachmentField < Administrate::Field::Base
include ActionView::Helpers::NumberHelper
def to_s
data.filename.to_s
"#{data.filename} (#{number_to_human_size(data.byte_size)})"
end
def blob_path

View file

@ -12,20 +12,4 @@ module CommentaireHelper
I18n.t('helpers.commentaire.reply_in_mailbox')
end
end
def commentaire_is_from_guest(commentaire)
commentaire.dossier.invites.map(&:email).include?(commentaire.email)
end
def commentaire_date(commentaire)
is_current_year = (commentaire.created_at.year == Time.zone.today.year)
template = is_current_year ? :message_date : :message_date_with_year
I18n.l(commentaire.created_at, format: template)
end
def pretty_commentaire(commentaire)
return t('views.shared.commentaires.destroy.deleted_body') if commentaire.discarded?
body_formatted = commentaire.sent_by_system? ? commentaire.body : simple_format(commentaire.body)
sanitize(body_formatted)
end
end

View file

@ -7,7 +7,9 @@ module ConservationDeDonneesHelper
def conservation_dans_ds(procedure)
if procedure.duree_conservation_dossiers_dans_ds.present?
"Dans #{APPLICATION_NAME} : #{procedure.duree_conservation_dossiers_dans_ds} mois"
I18n.t('users.procedure_footer.legals.data_retention',
application_name: APPLICATION_NAME,
duree_conservation_dossiers_dans_ds: procedure.duree_conservation_dossiers_dans_ds)
end
end
end

View file

@ -74,4 +74,12 @@ module ProcedureHelper
.includes(:groupe_instructeur)
.exists?(groupe_instructeur: current_instructeur.groupe_instructeurs)
end
def url_or_email_to_lien_dpo(procedure)
URI::MailTo.build([procedure.lien_dpo, "subject="]).to_s
rescue URI::InvalidComponentError
uri = URI.parse(procedure.lien_dpo)
return "//#{uri}" if uri.scheme.nil?
uri.to_s
end
end

View 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

View file

@ -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);

View 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);

View file

@ -1,16 +1,32 @@
import React from 'react';
import { QueryClientProvider } from 'react-query';
import ComboSearch from './ComboSearch';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
function ComboAnnuaireEducationSearch(props) {
type AnnuaireEducationResult = {
fields: {
identifiant_de_l_etablissement: string;
nom_etablissement: string;
nom_commune: string;
};
};
function transformResults(_: unknown, result: unknown) {
const results = result as { records: AnnuaireEducationResult[] };
return results.records as AnnuaireEducationResult[];
}
export default function ComboAnnuaireEducationSearch(
props: ComboSearchProps<AnnuaireEducationResult>
) {
return (
<QueryClientProvider client={queryClient}>
<ComboSearch
{...props}
scope="annuaire-education"
minimumInputLength={3}
transformResults={(_, { records }) => records}
transformResults={transformResults}
transformResult={({
fields: {
identifiant_de_l_etablissement: id,
@ -18,10 +34,7 @@ function ComboAnnuaireEducationSearch(props) {
nom_commune
}
}) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]}
{...props}
/>
</QueryClientProvider>
);
}
export default ComboAnnuaireEducationSearch;

View file

@ -1,19 +1,21 @@
import React from 'react';
import { QueryClientProvider } from 'react-query';
import { matchSorter } from 'match-sorter';
import PropTypes from 'prop-types';
import ComboSearch from './ComboSearch';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
import { ComboDepartementsSearch } from './ComboDepartementsSearch';
import { useHiddenField, groupId } from './shared/hooks';
type CommuneResult = { code: string; nom: string; codesPostaux: string[] };
// Avoid hiding similar matches for precise queries (like "Sainte Marie")
function searchResultsLimit(term) {
function searchResultsLimit(term: string) {
return term.length > 5 ? 10 : 5;
}
function expandResultsWithMultiplePostalCodes(term, results) {
function expandResultsWithMultiplePostalCodes(term: string, result: unknown) {
const results = result as CommuneResult[];
// A single result may have several associated postal codes.
// To make the search results more precise, we want to generate
// an actual result for each postal code.
@ -44,13 +46,16 @@ const placeholderDepartements = [
['77 Seine-et-Marne', 'Melun'],
['22 Côtes dArmor', 'Saint-Brieuc'],
['47 Lot-et-Garonne', 'Agen']
];
] as const;
const [placeholderDepartement, placeholderCommune] =
placeholderDepartements[
Math.floor(Math.random() * (placeholderDepartements.length - 1))
];
function ComboCommunesSearch({ id, ...props }) {
export default function ComboCommunesSearch({
id,
...props
}: ComboSearchProps<CommuneResult> & { id: string }) {
const group = groupId(id);
const [departementValue, setDepartementValue] = useHiddenField(
group,
@ -74,14 +79,14 @@ function ComboCommunesSearch({ id, ...props }) {
</div>
<ComboDepartementsSearch
{...props}
id={!codeDepartement ? id : null}
id={!codeDepartement ? id : undefined}
describedby={departementDescribedBy}
placeholder={placeholderDepartement}
addForeignDepartement={false}
value={departementValue}
onChange={(_, result) => {
setDepartementValue(result?.nom);
setCodeDepartement(result?.code);
setDepartementValue(result?.nom ?? '');
setCodeDepartement(result?.code ?? '');
}}
/>
</div>
@ -112,9 +117,3 @@ function ComboCommunesSearch({ id, ...props }) {
</QueryClientProvider>
);
}
ComboCommunesSearch.propTypes = {
id: PropTypes.string
};
export default ComboCommunesSearch;

View file

@ -1,14 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { QueryClientProvider } from 'react-query';
import { matchSorter } from 'match-sorter';
import ComboSearch from './ComboSearch';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
type DepartementResult = { code: string; nom: string };
const extraTerms = [{ code: '99', nom: 'Etranger' }];
function expandResultsWithForeignDepartement(term, results) {
function expandResultsWithForeignDepartement(term: string, result: unknown) {
const results = result as DepartementResult[];
return [
...results,
...matchSorter(extraTerms, term, {
@ -17,10 +19,17 @@ function expandResultsWithForeignDepartement(term, results) {
];
}
type ComboDepartementsSearchProps = Omit<
ComboSearchProps<DepartementResult> & {
addForeignDepartement: boolean;
},
'transformResult' | 'transformResults'
>;
export function ComboDepartementsSearch({
addForeignDepartement = true,
...props
}) {
}: ComboDepartementsSearchProps) {
return (
<ComboSearch
{...props}
@ -34,17 +43,12 @@ export function ComboDepartementsSearch({
);
}
function ComboDepartementsSearchDefault(params) {
export default function ComboDepartementsSearchDefault(
params: ComboDepartementsSearchProps
) {
return (
<QueryClientProvider client={queryClient}>
<ComboDepartementsSearch {...params} />
</QueryClientProvider>
);
}
ComboDepartementsSearch.propTypes = {
...ComboSearch.propTypes,
addForeignDepartement: PropTypes.bool
};
export default ComboDepartementsSearchDefault;

View file

@ -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;

View 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)} />;
}

View file

@ -1,20 +1,20 @@
import React from 'react';
import { QueryClientProvider } from 'react-query';
import ComboSearch from './ComboSearch';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
function ComboPaysSearch(props) {
export default function ComboPaysSearch(
props: ComboSearchProps<{ code: string; value: string; label: string }>
) {
return (
<QueryClientProvider client={queryClient}>
<ComboSearch
{...props}
scope="pays"
minimumInputLength={0}
transformResult={({ code, value, label }) => [code, value, label]}
{...props}
/>
</QueryClientProvider>
);
}
export default ComboPaysSearch;

View file

@ -1,20 +1,20 @@
import React from 'react';
import { QueryClientProvider } from 'react-query';
import ComboSearch from './ComboSearch';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
function ComboRegionsSearch(props) {
export default function ComboRegionsSearch(
props: ComboSearchProps<{ code: string; nom: string }>
) {
return (
<QueryClientProvider client={queryClient}>
<ComboSearch
{...props}
scope="regions"
minimumInputLength={0}
transformResult={({ code, nom }) => [code, nom]}
{...props}
/>
</QueryClientProvider>
);
}
export default ComboRegionsSearch;

View file

@ -20,7 +20,7 @@ import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
type TransformResults<Result> = (term: string, results: unknown) => Result[];
type TransformResult<Result> = (
result: Result
) => [key: string, value: string, label: string];
) => [key: string, value: string, label?: string];
export type ComboSearchProps<Result> = {
onChange?: (value: string | null, result?: Result) => void;
@ -28,7 +28,7 @@ export type ComboSearchProps<Result> = {
scope: string;
scopeExtra?: string;
minimumInputLength: number;
transformResults: TransformResults<Result>;
transformResults?: TransformResults<Result>;
transformResult: TransformResult<Result>;
allowInputValues?: boolean;
id?: string;

View file

@ -37,9 +37,7 @@ export function useFeatureCollection(
type: 'FeatureCollection',
features: callback(features)
}));
ajax({ url, type: 'GET' })
.then(() => fire(document, 'ds:page:update'))
.catch(() => null);
ajax({ url, type: 'GET' }).catch(() => null);
},
[url, setFeatureCollection]
);

View file

@ -161,6 +161,7 @@ export const TypeDeChampComponent = SortableElement<TypeDeChampProps>(
/>
<TypeDeChampPieceJustificative
isVisible={isFile}
isTitreIdentite={isTitreIdentite}
directUploadUrl={state.directUploadUrl}
filename={typeDeChamp.piece_justificative_template_filename}
handler={updateHandlers.piece_justificative_template}

View file

@ -5,12 +5,14 @@ import type { Handler } from '../types';
export function TypeDeChampPieceJustificative({
isVisible,
isTitreIdentite,
url,
filename,
handler,
directUploadUrl
}: {
isVisible: boolean;
isTitreIdentite: boolean;
url?: string;
filename?: string;
handler: Handler<HTMLInputElement>;
@ -32,6 +34,17 @@ export function TypeDeChampPieceJustificative({
</div>
);
}
if (isTitreIdentite) {
return (
<div className="cell">
<p id={`${handler.id}-description`}>
Dans le cadre de la RGPD, le titre d&apos;identité sera supprimé lors
de l&apos;acceptation du dossier
</p>
</div>
);
}
return null;
}

View 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
});
}
}

View 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() }
});
}
}

View 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');
}

View file

@ -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();
});

View file

@ -10,8 +10,9 @@ import {
removeClass
} from '@utils';
const AUTOSAVE_DEBOUNCE_DELAY = gon.autosave.debounce_delay;
const AUTOSAVE_STATUS_VISIBLE_DURATION = gon.autosave.status_visible_duration;
const AUTOSAVE_DEBOUNCE_DELAY = window?.gon?.autosave?.debounce_delay;
const AUTOSAVE_STATUS_VISIBLE_DURATION =
window?.gon?.autosave?.status_visible_duration;
// Create a controller responsible for queuing autosave operations.
const autoSaveController = new AutoSaveController();

View file

@ -54,5 +54,5 @@ function saveMessageContent() {
}
}
addEventListener('ds:page:update', scrollMessagerie);
addEventListener('ds:page:update', saveMessageContent);
addEventListener('DOMContentLoaded', scrollMessagerie);
addEventListener('DOMContentLoaded', saveMessageContent);

View file

@ -14,7 +14,7 @@ function expandProcedureDescription() {
descBody.classList.remove('read-more-collapsed');
}
addEventListener('ds:page:update', updateReadMoreVisibility);
addEventListener('DOMContentLoaded', updateReadMoreVisibility);
addEventListener('resize', updateReadMoreVisibility);
delegate('click', '.read-more-button', expandProcedureDescription);

View file

@ -101,7 +101,7 @@ class ButtonExpand {
if (document.querySelector('#contact-form')) {
window.addEventListener(
'ds:page:update',
'DOMContentLoaded',
function () {
var buttons = document.querySelectorAll(
'button[aria-expanded][aria-controls], button.button-without-hint'

View file

@ -3,8 +3,8 @@ import Rails from '@rails/ujs';
import * as ActiveStorage from '@rails/activestorage';
import 'whatwg-fetch'; // window.fetch polyfill
import { Application } from '@hotwired/stimulus';
import { Turbo } from '@hotwired/turbo-rails';
import '../shared/page-update-event';
import '../shared/activestorage/ujs';
import '../shared/remote-poller';
import '../shared/safari-11-file-xhr-workaround';
@ -17,6 +17,8 @@ import {
ReactController,
registerComponents
} from '../controllers/react_controller';
import { TurboEventController } from '../controllers/turbo_event_controller';
import { GeoAreaController } from '../controllers/geo_area_controller';
import '../new_design/dropdown';
import '../new_design/form-validation';
@ -28,7 +30,6 @@ import '../new_design/messagerie';
import '../new_design/dossiers/auto-save';
import '../new_design/dossiers/auto-upload';
import '../new_design/champs/carte';
import '../new_design/champs/linked-drop-down-list';
import '../new_design/champs/repetition';
import '../new_design/champs/drop-down-list';
@ -89,9 +90,12 @@ const DS = {
// Start Rails helpers
Rails.start();
ActiveStorage.start();
Turbo.session.drive = false;
const Stimulus = Application.start();
Stimulus.register('react', ReactController);
Stimulus.register('turbo-event', TurboEventController);
Stimulus.register('geo-area', GeoAreaController);
// Expose globals
window.DS = window.DS || DS;

View file

@ -63,7 +63,9 @@ export default class FileUploadError extends Error {
// 2. Create each kind of error on a different line
// (so that Sentry knows they are different kind of errors, from
// the line they were created.)
export function errorFromDirectUploadMessage(message: string) {
export function errorFromDirectUploadMessage(messageOrError: string | Error) {
const message =
typeof messageOrError == 'string' ? messageOrError : messageOrError.message;
const matches = message.match(/ Status: ([0-9]{1,3})/);
const status = matches ? parseInt(matches[1], 10) : undefined;

View file

@ -60,7 +60,7 @@ export default class Uploader {
return new Promise((resolve, reject) => {
this.directUpload.create((errorMsg, attributes) => {
if (errorMsg) {
const error = errorFromDirectUploadMessage(errorMsg.message);
const error = errorFromDirectUploadMessage(errorMsg);
reject(error);
} else {
resolve(attributes.signed_id);

View file

@ -20,7 +20,7 @@ function init() {
}
}
addEventListener('ds:page:update', init);
addEventListener('DOMContentLoaded', init);
function toggleElement(event) {
event.preventDefault();

View file

@ -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');
});

View file

@ -42,15 +42,20 @@ export function removeClass(el: HTMLElement, cssClass: string) {
el && el.classList.remove(cssClass);
}
export function delegate(
export function delegate<E extends Event = Event>(
eventNames: string,
selector: string,
callback: () => void
callback: (event: E) => void
) {
eventNames
.split(' ')
.forEach((eventName) =>
Rails.delegate(document, selector, eventName, callback)
Rails.delegate(
document,
selector,
eventName,
callback as (event: Event) => void
)
);
}

View file

@ -21,3 +21,4 @@ declare module '@tmcw/togeojson/dist/togeojson.es.js' {
}
declare module 'react-coordinate-input';
declare module 'chartkick';

View file

@ -120,7 +120,7 @@ class APIEntreprise::API
# rubocop:disable DS/ApplicationName
params = {
context: "demarches-simplifiees.fr",
recipient: siret_or_siren,
recipient: ENV.fetch('API_ENTREPRISE_DEFAULT_SIRET'),
object: "procedure_id: #{procedure_id}",
non_diffusables: true
}

View file

@ -89,6 +89,12 @@ class Commentaire < ApplicationRecord
end
end
def soft_delete!
piece_jointe.purge_later if piece_jointe.attached?
discard!
update! body: ''
end
private
def notify

View file

@ -33,7 +33,7 @@ module MailTemplateConcern
module ClassMethods
def default_for_procedure(procedure)
template_name = default_template_name_for_procedure(procedure)
rich_body = ActionController::Base.new.render_to_string(template: template_name)
rich_body = ActionController::Base.render template: template_name
trix_rich_body = rich_body.gsub(/(?<!^|[.-])(?<!<\/strong>)\n/, '')
new(subject: const_get(:DEFAULT_SUBJECT), rich_body: trix_rich_body, procedure: procedure)
end

View file

@ -15,6 +15,8 @@
# instructeur_id :bigint
#
class DossierOperationLog < ApplicationRecord
self.ignored_columns = [:instructeur_id]
enum operation: {
changer_groupe_instructeur: 'changer_groupe_instructeur',
passer_en_instruction: 'passer_en_instruction',

View file

@ -28,6 +28,7 @@
# juridique_required :boolean default(TRUE)
# libelle :string
# lien_demarche :string
# lien_dpo :string
# lien_notice :string
# lien_site_web :string
# monavis_embed :text
@ -266,6 +267,7 @@ class Procedure < ApplicationRecord
validate :check_juridique
validates :path, presence: true, format: { with: /\A[a-z0-9_\-]{3,200}\z/ }, uniqueness: { scope: [:path, :closed_at, :hidden_at, :unpublished_at], case_sensitive: false }
validates :duree_conservation_dossiers_dans_ds, allow_nil: false, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_DUREE_CONSERVATION }
validates :lien_dpo, email_or_link: true, allow_nil: true
validates_with MonAvisEmbedValidator
FILE_MAX_SIZE = 20.megabytes
@ -464,6 +466,7 @@ class Procedure < ApplicationRecord
procedure.closed_at = nil
procedure.unpublished_at = nil
procedure.published_at = nil
procedure.auto_archive_on = nil
procedure.lien_notice = nil
procedure.published_revision = nil
procedure.draft_revision.procedure = procedure

View file

@ -3,16 +3,18 @@ class ProcedureExportService
def initialize(procedure, dossiers)
@procedure = procedure
@dossiers = dossiers.downloadable_sorted_batch
@dossiers = dossiers
@tables = [:dossiers, :etablissements, :avis] + champs_repetables_options
end
def to_csv
@dossiers = @dossiers.downloadable_sorted_batch
io = StringIO.new(SpreadsheetArchitect.to_csv(options_for(:dossiers, :csv)))
create_blob(io, :csv)
end
def to_xlsx
@dossiers = @dossiers.downloadable_sorted_batch
# We recursively build multi page spreadsheet
io = @tables.reduce(nil) do |package, table|
SpreadsheetArchitect.to_axlsx_package(options_for(table, :xlsx), package)
@ -21,6 +23,7 @@ class ProcedureExportService
end
def to_ods
@dossiers = @dossiers.downloadable_sorted_batch
# We recursively build multi page spreadsheet
io = StringIO.new(@tables.reduce(nil) do |spreadsheet, table|
SpreadsheetArchitect.to_rodf_spreadsheet(options_for(table, :ods), spreadsheet)

View 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

View file

@ -1,7 +1,7 @@
= form_for procedure.administrateurs.new(user: User.new),
url: { controller: 'procedure_administrateurs' },
html: { class: 'form', id: "procedure-#{procedure.id}-new_administrateur" } ,
remote: true do |f|
html: { class: 'form', id: "new_administrateur" },
data: { turbo: true } do |f|
= f.label :email do
Ajouter un administrateur
%p.notice Renseignez lemail dun administrateur déjà enregistré sur #{APPLICATION_NAME} pour lui permettre de modifier « #{procedure.libelle} ».

View file

@ -1,4 +1,4 @@
%tr{ id: "procedure-#{@procedure.id}-administrateur-#{administrateur.id}" }
%tr{ id: dom_id(administrateur) }
%td= administrateur.email
%td= try_format_datetime(administrateur.created_at)
%td= administrateur.registration_state
@ -6,8 +6,8 @@
- if administrateur == current_administrateur
Cest vous !
- else
= link_to 'Retirer',
admin_procedure_administrateur_path(@procedure, administrateur),
= button_to 'Retirer',
admin_procedure_administrateur_path(procedure, administrateur),
method: :delete,
'data-confirm': "Retirer « #{administrateur.email} » des administrateurs de « #{@procedure.libelle} » ?",
remote: true
class: 'button',
form: { data: { turbo: true, turbo_confirm: "Retirer « #{administrateur.email} » des administrateurs de « #{procedure.libelle} » ?" } }

View file

@ -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 })

View file

@ -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 }

View file

@ -1,4 +0,0 @@
= render_flash(sticky: true)
- if @administrateur
= remove_element("#procedure-#{@procedure.id}-administrateur-#{@administrateur.id}")

View file

@ -0,0 +1,2 @@
- if @administrateur.present?
= turbo_stream.remove(@administrateur)

View file

@ -10,8 +10,8 @@
%th= 'Adresse email'
%th= 'Enregistré le'
%th= 'État'
%tbody{ id: "procedure-#{@procedure.id}-administrateurs" }
= render partial: 'administrateur', collection: @procedure.administrateurs.order('users.email')
%tbody#administrateurs
= render partial: 'administrateur', collection: @procedure.administrateurs.order('users.email'), locals: { procedure: @procedure }
%tfoot
%tr
%th{ colspan: 4 }

View file

@ -57,6 +57,13 @@
= f.label :deliberation, 'Importer le texte'
= text_upload_and_render f, @procedure.deliberation
%h3.header-subsection
RGPD
%p.notice
Pour certaines démarches, veuillez indiquer soit un mail le mail de contact de votre délégué à la protection des données, soit un lien web pointant vers les informations
= f.label :lien_dpo, 'Lien ou email pour contacter le Délégué à la Protection des Données (DPO)'
= f.text_field :lien_dpo, class: 'form-control'
%h3.header-subsection Notice explicative de la démarche
%p.notice

View file

@ -4,6 +4,10 @@
metadatas: ["Créée le #{@procedure.created_at.strftime('%d/%m/%Y')} - n° #{@procedure.id}", "#{@procedure.close? ? "Close le #{@procedure.closed_at.strftime('%d/%m/%Y')}" : @procedure.locked? ? "Publiée - #{procedure_lien(@procedure)}" : "Brouillon"}"] }
.container.procedure-admin-container
= link_to @procedure.active_revision.draft? ? commencer_dossier_vide_test_path(path: @procedure.path) : commencer_dossier_vide_path(path: @procedure.path), target: "_blank", rel: "noopener", class: 'button', id: "pdf-procedure" do
%span.icon.printer
PDF
= link_to apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'button', id: "preview-procedure" do
%span.icon.preview
Prévisualiser

View file

@ -0,0 +1,3 @@
- if @commentaire.discarded?
= turbo_stream.update @commentaire do
= render Dossiers::MessageComponent.new(commentaire: @commentaire, connected_user: @commentaire.instructeur || @commentaire.expert)

View file

@ -49,7 +49,7 @@
%ul.messages-list
- @dossier.commentaires.with_attached_piece_jointe.each do |commentaire|
%li
= render partial: "shared/dossiers/messages/message", locals: { commentaire: commentaire, connected_user: current_instructeur, messagerie_seen_at: nil, show_reply_button: false }
= render Dossiers::MessageComponent.new(commentaire: commentaire, connected_user: current_instructeur)
%script{ type: "text/javascript" }
window.print();

View file

@ -16,6 +16,6 @@
%br
Certaines parties du site ne fonctionneront pas correctement.
.site-banner-actions
= button_to 'Ignorer', dismiss_outdated_browser_path, method: :post, remote: true, class: 'button btn', title: 'Ne plus afficher cet avertissement pendant une semaine'
= button_to 'Ignorer', dismiss_outdated_browser_path, method: :post, form: { data: { turbo: true } }, class: 'button btn', title: 'Ne plus afficher cet avertissement pendant une semaine'
%a.btn.button.primary{ href: "https://browser-update.org/fr/update.html", target: "_blank", rel: "noopener" }
Mettre à jour mon navigateur

View file

@ -0,0 +1,5 @@
%turbo-event{ data: {
controller: 'turbo-event',
turbo_event_type_value: type,
turbo_event_detail_value: detail.to_json
} }

View file

@ -41,3 +41,5 @@
= content_for(:footer)
= yield :charts_js
%turbo-events

View 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

View 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

View file

@ -0,0 +1 @@
= turbo_stream.remove('outdated-browser-banner')

View file

@ -1,10 +1,10 @@
%li{ class: editing ? 'mb-1' : 'flex column mb-2' }
%li{ class: editing ? 'mb-1' : 'flex column mb-2', data: { controller: 'geo-area', geo_area_id_value: geo_area.id } }
- if editing
= link_to '#', data: { geo_area: geo_area.id } do
= link_to '#', data: { action: 'geo-area#onClick' } do
= geo_area_label(geo_area)
= text_field_tag :description, geo_area.description, data: { geo_area: geo_area.id }, placeholder: 'Description', class: 'no-margin'
= text_field_tag :description, geo_area.description, data: { action: 'focus->geo-area#onFocus input->geo-area#onInput', geo_area_target: 'description' }, placeholder: 'Description', class: 'no-margin'
- else
= link_to '#', data: { geo_area: geo_area.id } do
= link_to '#', data: { action: 'geo-area#onClick' } do
= geo_area_label(geo_area)
- if geo_area.description.present?
%span

View file

@ -1,8 +1,8 @@
.messagerie.container
%ul.messages-list
- dossier.commentaires.with_attached_piece_jointe.each do |commentaire|
%li.message{ class: commentaire_is_from_me_class(commentaire, connected_user) }
= render partial: "shared/dossiers/messages/message", locals: { commentaire: commentaire, connected_user: connected_user, messagerie_seen_at: messagerie_seen_at, show_reply_button: show_reply_button(commentaire, connected_user) }
%li.message{ class: commentaire_is_from_me_class(commentaire, connected_user), id: dom_id(commentaire) }
= render Dossiers::MessageComponent.new(commentaire: commentaire, connected_user: connected_user, messagerie_seen_at: messagerie_seen_at, show_reply_button: show_reply_button(commentaire, connected_user))
- if dossier.messagerie_available?
= render partial: "shared/dossiers/messages/form", locals: { commentaire: new_commentaire, form_url: form_url, dossier: dossier }

View file

@ -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')

View file

@ -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: '')

View file

@ -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

View file

@ -4,7 +4,7 @@
- if service.present?
.footer-row.footer-columns
.footer-column
%p.footer-header Cette démarche est gérée par :
%p.footer-header= I18n.t('users.procedure_footer.managed_by.header')
%ul
%li
= service.nom
@ -14,37 +14,49 @@
= string_to_html(service.adresse, wrapper_tag = 'span')
.footer-column
%p.footer-header Poser une question sur votre dossier :
%p.footer-header= I18n.t('users.procedure_footer.contact.header')
%ul
%li
- if dossier.present? && dossier.messagerie_available?
Directement
= link_to "par la messagerie", messagerie_dossier_path(dossier)
= I18n.t('users.procedure_footer.contact.in_app_mail.prefix')
= link_to I18n.t('users.procedure_footer.contact.in_app_mail.link'), messagerie_dossier_path(dossier)
- else
Par email :
= I18n.t('users.procedure_footer.contact.email.prefix')
= link_to service.email, "mailto:#{service.email}"
- if service.telephone.present?
%li
Par téléphone :
= I18n.t('users.procedure_footer.contact.phone.prefix')
= link_to service.telephone, service.telephone_url
%li
- horaires = "Horaires : #{formatted_horaires(service.horaires)}"
- horaires = "#{I18n.t('users.procedure_footer.contact.schedule.prefix')}#{formatted_horaires(service.horaires)}"
= simple_format(horaires, {}, wrapper_tag: 'span')
%li
Statistiques :
= link_to "voir les statistiques de la démarche", statistiques_path(procedure.path)
= I18n.t('users.procedure_footer.contact.stats.prefix')
= link_to I18n.t('users.procedure_footer.contact.stats.cta'), statistiques_path(procedure.path)
- politiques = politiques_conservation_de_donnees(procedure)
- if politiques.present?
.footer-column
%p.footer-header Conservation des données :
%p.footer-header= I18n.t('users.procedure_footer.legals.header')
%ul
- politiques.each do |politique|
%li= politique
- if procedure.deliberation.attached?
%li
= link_to url_for(procedure.deliberation), target: '_blank', rel: 'noopener' do
= I18n.t("users.procedure_footer.legals.terms")
- else
%li
= link_to I18n.t("users.procedure_footer.legals.terms"), procedure.cadre_juridique, target: '_blank', rel: 'noopener'
- if procedure.lien_dpo.present?
%li
= link_to url_or_email_to_lien_dpo(procedure), target: '_blank', rel: 'noopener' do
= I18n.t("users.procedure_footer.legals.dpo")
= render partial: 'users/general_footer_row', locals: { dossier: dossier }

View file

@ -4,7 +4,7 @@
%h3.tab-title= t('views.users.dossiers.show.latest_message.latest_message')
.message.inverted-background
= render partial: "shared/dossiers/messages/message", locals: { commentaire: latest_message, connected_user: current_user, messagerie_seen_at: nil, show_reply_button: false }
= render Dossiers::MessageComponent.new(commentaire: latest_message, connected_user: current_user)
= link_to messagerie_dossier_url(dossier, anchor: 'new_commentaire'), class: 'button send' do
%span.icon.reply

View file

@ -86,12 +86,6 @@ module.exports = function (api) {
{
async: false
}
],
isProductionEnv && [
'babel-plugin-transform-react-remove-prop-types',
{
removeImport: true
}
]
].filter(Boolean)
};

View file

@ -81,5 +81,13 @@ module TPS
# Custom Configuration
# @see https://guides.rubyonrails.org/configuring.html#custom-configuration
config.x.clamav.enabled = ENV.fetch("CLAMAV_ENABLED", "enabled") == "enabled"
config.view_component.generate_sidecar = true
config.view_component.generate_locale = true
config.view_component.generate_distinct_locale_files = true
config.view_component.generate_preview = true
config.view_component.show_previews_source = true
config.view_component.default_preview_layout = 'component_preview'
config.view_component.preview_paths << "#{Rails.root}/spec/components/previews"
end
end

View file

@ -157,3 +157,6 @@ INVISIBLE_CAPTCHA_SECRET="kikooloool"
# Clamav antivirus usage
CLAMAV_ENABLED="disabled"
# Siret number used for API Entreprise, by default we use SIRET from dinum
API_ENTREPRISE_DEFAULT_SIRET="put_your_own_siret"

View file

@ -68,6 +68,7 @@ search:
- app/assets/images
- app/assets/fonts
- app/assets/videos
- app/components
## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`:
## If specified, this settings takes priority over `exclude`, but `exclude` still applies.

View file

@ -127,12 +127,6 @@ en:
submit_dossier: Submit the file
save_changes: Save the changes of the file
messages:
message_issuer:
automatic_email: "Automatic email"
you: "You"
message:
reply: "Reply"
guest: "Guest"
form:
send_message: "Send message"
attachment_size: "(attachment size max : 20 Mo)"

View file

@ -122,12 +122,6 @@ fr:
submit_dossier: Déposer le dossier
save_changes: Enregistrer les modifications du dossier
messages:
message_issuer:
automatic_email: "Email automatique"
you: "Vous"
message:
reply: "Répondre"
guest: "Invité"
form:
send_message: "Envoyer le message"
attachment_size: "(taille max : 20 Mo)"

View file

@ -12,8 +12,8 @@ en:
url: "https://numerique.gouv.fr"
footer:
accessibilite:
label: "Accessibility: not compliant"
title: "Accessibility: not compliant"
label: "Accessibility: partially compliant"
title: "Accessibility: partially compliant"
url: "https://doc.demarches-simplifiees.fr/declaration-daccessibilite"
aide:
label: "Help"

View file

@ -12,8 +12,8 @@ fr:
url: "https://numerique.gouv.fr"
footer:
accessibilite:
label: "Accessibilité : non conforme"
title: "Accessibilité : non conforme"
label: "Accessibilité : partiellement conforme"
title: "Accessibilité : partiellement conforme"
url: "https://doc.demarches-simplifiees.fr/declaration-daccessibilite"
aide:
label: "Aide"

View file

@ -17,6 +17,7 @@ fr:
declarative_with_state/en_instruction: En instruction
declarative_with_state/accepte: Accepté
api_particulier_token: Jeton API Particulier
lien_dpo: Contact du DPO
errors:
models:
procedure:
@ -27,3 +28,5 @@ fr:
format: 'Le champ %{message}'
draft_types_de_champ_private:
format: 'Lannotation privée %{message}'
lien_dpo:
invalid_uri_or_email: "Veuillez saisir un mail ou un lien"

View 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"

View 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é

View file

@ -2,4 +2,4 @@ en:
instructeurs:
procedure:
archive_pending_html: Archive creation pending<br>(requested %{created_period} ago)
archive_ready_html: Download archive<br>(requested %{generated_period} ago)
archive_ready_html: Download archive<br>(requested %{generated_period} ago)

View file

@ -20,12 +20,3 @@ en:
already_user: "I already have an account"
create: 'Create an account'
signin: 'Sign in'
commentaires:
destroy:
button: 'Destroy this message'
confirm: "Are you sure you want to destroy this message ?"
deleted_body: Message deleted
notice: 'Your message had been deleted'
alert_reasons:
acl: "Can not destroy message: it does not belong to you"
already_discarded: "Can not destroy message: it was already destroyed"

View file

@ -20,12 +20,3 @@ fr:
already_user: 'Jai déjà un compte'
create: 'Créer un compte'
signin: 'Connexion'
commentaires:
destroy:
button: 'Supprimer le message'
confirm: "Êtes-vous sûr de vouloir supprimer ce message ?"
deleted_body: Message supprimé
notice: 'Votre message a été supprimé'
alert_reasons:
acl: "Impossible de supprimer le message, celui ci ne vous appartient pas"
already_discarded: "Ce message a déjà été supprimé"

View 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"

View 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"

View file

@ -246,7 +246,7 @@ Rails.application.routes.draw do
end
namespace :commencer do
get '/test/:path/dossier_vide', action: 'dossier_vide_pdf_test', as: :dossier_vide_test
get '/test/:path/dossier_vide', action: :dossier_vide_pdf_test, as: :dossier_vide_test
get '/test/:path', action: 'commencer_test', as: :test
get '/:path', action: 'commencer'
get '/:path/dossier_vide', action: 'dossier_vide_pdf', as: :dossier_vide
@ -310,7 +310,6 @@ Rails.application.routes.draw do
get 'instruction'
get 'messagerie'
post 'commentaire' => 'avis#create_commentaire'
delete 'delete_commentaire' => 'avis#delete_commentaire'
post 'avis' => 'avis#create_avis'
get 'bilans_bdf'
get 'telecharger_pjs' => 'avis#telecharger_pjs'

View file

@ -2,8 +2,8 @@ class AddAdministrateurForeignKeyToAdministrateursProcedure < ActiveRecord::Migr
include Database::MigrationHelpers
def up
delete_orphans :administrateurs_procedures, :administrateurs_procedures
add_foreign_key :administrateurs_procedures, :administrateurs_procedures
delete_orphans :administrateurs_procedures, :administrateurs
add_foreign_key :administrateurs_procedures, :administrateurs
end
def down

View file

@ -0,0 +1,5 @@
class AddLienDpoToProcedure < ActiveRecord::Migration[6.1]
def change
add_column :procedures, :lien_dpo, :string
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_04_07_081538) do
ActiveRecord::Schema.define(version: 2022_04_25_140107) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -645,6 +645,7 @@ ActiveRecord::Schema.define(version: 2022_04_07_081538) do
t.boolean "juridique_required", default: true
t.string "libelle"
t.string "lien_demarche"
t.string "lien_dpo"
t.string "lien_notice"
t.string "lien_site_web"
t.text "monavis_embed"

View file

@ -5,6 +5,7 @@
"@headlessui/react": "^1.5.0",
"@heroicons/react": "^1.0.6",
"@hotwired/stimulus": "^3.0.1",
"@hotwired/turbo-rails": "^7.1.1",
"@mapbox/mapbox-gl-draw": "^1.3.0",
"@popperjs/core": "^2.11.4",
"@rails/actiontext": "^6.1.4-1",
@ -17,7 +18,6 @@
"@sentry/browser": "6.12.0",
"@tmcw/togeojson": "^4.3.0",
"babel-plugin-macros": "^2.8.0",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"chartkick": "^4.1.1",
"core-js": "^3.6.5",
"debounce": "^1.2.1",
@ -29,7 +29,6 @@
"is-hotkey": "^0.2.0",
"maplibre-gl": "^1.15.2",
"match-sorter": "^6.2.0",
"prop-types": "^15.7.2",
"react": "^18.0.0",
"react-coordinate-input": "^1.0.0",
"react-dom": "^18.0.0",
@ -42,7 +41,8 @@
"use-debounce": "^5.2.0",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"whatwg-fetch": "^3.0.0"
"whatwg-fetch": "^3.0.0",
"zod": "^3.14.4"
},
"devDependencies": {
"@2fd/graphdoc": "^2.4.0",

View file

@ -1,21 +1,28 @@
describe 'shared/dossiers/messages/message.html.haml', type: :view do
before { view.extend DossierHelper }
subject { render 'shared/dossiers/messages/message.html.haml', commentaire: commentaire, messagerie_seen_at: seen_at, connected_user: dossier.user, show_reply_button: true }
RSpec.describe Dossiers::MessageComponent, type: :component do
let(:component) do
described_class.new(
commentaire: commentaire,
connected_user: connected_user,
messagerie_seen_at: seen_at,
show_reply_button: true
)
end
let(:dossier) { create(:dossier, :en_construction) }
let(:commentaire) { create(:commentaire, dossier: dossier) }
let(:connected_user) { dossier.user }
let(:seen_at) { commentaire.created_at + 1.hour }
subject { render_inline(component).to_html }
it { is_expected.to have_button("Répondre") }
context "with a seen_at after commentaire created_at" do
context 'with a seen_at after commentaire created_at' do
let(:seen_at) { commentaire.created_at + 1.hour }
it { is_expected.not_to have_css(".highlighted") }
end
context "with a seen_at after commentaire created_at" do
context 'with a seen_at after commentaire created_at' do
let(:seen_at) { commentaire.created_at - 1.hour }
it { is_expected.to have_css(".highlighted") }
@ -51,8 +58,8 @@ describe 'shared/dossiers/messages/message.html.haml', type: :view do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:procedure) }
let(:dossier) { create(:dossier, :en_construction, commentaires: [commentaire], procedure: procedure) }
subject { render 'shared/dossiers/messages/message.html.haml', commentaire: commentaire, messagerie_seen_at: seen_at, connected_user: instructeur, show_reply_button: true }
let(:form_url) { instructeur_commentaire_path(commentaire.dossier.procedure, commentaire.dossier, commentaire) }
let(:connected_user) { instructeur }
let(:form_url) { component.helpers.instructeur_commentaire_path(commentaire.dossier.procedure, commentaire.dossier, commentaire) }
context 'on a procedure where commentaire had been written by connected instructeur' do
let(:commentaire) { create(:commentaire, instructeur: instructeur, body: 'Second message') }
@ -64,7 +71,7 @@ describe 'shared/dossiers/messages/message.html.haml', type: :view do
let(:commentaire) { create(:commentaire, instructeur: instructeur, body: 'Second message', discarded_at: 2.days.ago) }
it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") }
it { is_expected.not_to have_selector(".rich-text", text: I18n.t(t('views.shared.commentaires.destroy.deleted_body'))) }
it { is_expected.to have_selector(".rich-text", text: component.t('.deleted_body')) }
end
context 'on a procedure where commentaire had been written by connected an user' do
@ -87,49 +94,48 @@ describe 'shared/dossiers/messages/message.html.haml', type: :view do
end
end
context 'with an expert message' do
describe 'delete message button for expert' do
let(:expert) { create(:expert) }
let(:procedure) { create(:procedure) }
let(:dossier) { create(:dossier, :en_construction, commentaires: [commentaire], procedure: procedure) }
let(:experts_procedure) { create(:experts_procedure, procedure: procedure, expert: expert) }
let!(:avis) { create(:avis, email: nil, experts_procedure: experts_procedure) }
subject { render 'shared/dossiers/messages/message.html.haml', commentaire: commentaire, messagerie_seen_at: seen_at, connected_user: expert, show_reply_button: true }
let(:form_url) { delete_commentaire_expert_avis_path(avis.procedure, avis, commentaire: commentaire) }
describe '#commentaire_from_guest?' do
let!(:guest) { create(:invite, dossier: dossier) }
before do
assign(:avis, avis)
subject { component.send(:commentaire_from_guest?) }
context 'when the commentaire sender is not a guest' do
let(:commentaire) { create(:commentaire, dossier: dossier, email: "michel@pref.fr") }
it { is_expected.to be false }
end
context 'when the commentaire sender is a guest on this dossier' do
let(:commentaire) { create(:commentaire, dossier: dossier, email: guest.email) }
it { is_expected.to be true }
end
end
describe '#commentaire_date' do
let(:present_date) { Time.zone.local(2018, 9, 2, 10, 5, 0) }
let(:creation_date) { present_date }
let(:commentaire) do
Timecop.freeze(creation_date) { create(:commentaire, email: "michel@pref.fr") }
end
subject do
Timecop.freeze(present_date) { component.send(:commentaire_date) }
end
it 'doesnt include the creation year' do
expect(subject).to eq 'le 2 septembre à 10 h 05'
end
context 'when displaying a commentaire created on a previous year' do
let(:creation_date) { present_date.prev_year }
it 'includes the creation year' do
expect(subject).to eq 'le 2 septembre 2017 à 10 h 05'
end
end
context 'on a procedure where commentaire had been written by connected expert' do
let(:commentaire) { create(:commentaire, expert: expert, body: 'Second message') }
it { is_expected.to have_selector("form[action=\"#{form_url}\"]") }
end
context 'on a procedure where commentaire had been written by connected expert and discarded' do
let(:commentaire) { create(:commentaire, expert: expert, body: 'Second message', discarded_at: 2.days.ago) }
it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") }
it { is_expected.not_to have_selector(".rich-text", text: I18n.t(t('views.shared.commentaires.destroy.deleted_body'))) }
end
context 'on a procedure where commentaire had been written by connected an user' do
let(:commentaire) { create(:commentaire, email: create(:user).email, body: 'Second message') }
it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") }
end
context 'on a procedure where commentaire had been written by connected an instructeur' do
let(:commentaire) { create(:commentaire, instructeur: create(:instructeur), body: 'Second message') }
it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") }
end
context 'on a procedure where commentaire had been written another expert' do
let(:commentaire) { create(:commentaire, expert: create(:expert), body: 'Second message') }
it { is_expected.not_to have_selector("form[action=\"#{form_url}\"]") }
context 'when formatting the first day of the month' do
let(:present_date) { Time.zone.local(2018, 9, 1, 10, 5, 0) }
it 'includes the ordinal' do
expect(subject).to eq 'le 1er septembre à 10 h 05'
end
end
end

Some files were not shown because too many files have changed in this diff Show more