Merge pull request #7240 from betagouv/main

2022-05-04-01
This commit is contained in:
Paul Chavard 2022-05-04 10:26:12 +02:00 committed by GitHub
commit 308e48b611
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 365 additions and 286 deletions

View file

@ -364,4 +364,20 @@ class ApplicationController < ActionController::Base
def set_customizable_view_path def set_customizable_view_path
prepend_view_path "app/custom_views" prepend_view_path "app/custom_views"
end end
# Extract a value from params based on the "path"
#
# params: { dossiers: { champs_attributes: { 1234 => { value: "hello" } } } }
#
# Usage: read_param_value("dossiers[champs_attributes][1234]", "value")
def read_param_value(path, name)
parts = path.split(/\[|\]\[|\]/) + [name]
parts.reduce(params) do |value, part|
if part.to_i != 0
value[part.to_i] || value[part]
else
value[part]
end
end
end
end end

View file

@ -2,7 +2,6 @@ class Champs::CarteController < ApplicationController
before_action :authenticate_logged_user! before_action :authenticate_logged_user!
def index def index
@selector = ".carte-#{params[:champ_id]}"
@champ = policy_scope(Champ).find(params[:champ_id]) @champ = policy_scope(Champ).find(params[:champ_id])
@focus = params[:focus].present? @focus = params[:focus].present?
end end

View file

@ -2,12 +2,7 @@ class Champs::DossierLinkController < ApplicationController
before_action :authenticate_logged_user! before_action :authenticate_logged_user!
def show def show
@position = params[:position] @champ = policy_scope(Champ).find(params[:champ_id])
@linked_dossier_id = read_param_value(@champ.input_name, 'value')
if params[:dossier].key?(:champs_attributes)
@dossier_id = params[:dossier][:champs_attributes][params[:position]][:value]
else
@dossier_id = params[:dossier][:champs_private_attributes][params[:position]][:value]
end
end end
end end

View file

@ -3,13 +3,6 @@ class Champs::RepetitionController < ApplicationController
def show def show
@champ = policy_scope(Champ).includes(:champs).find(params[:champ_id]) @champ = policy_scope(Champ).includes(:champs).find(params[:champ_id])
@position = params[:position] @champs = @champ.add_row
@champ.add_row
if @champ.private?
@attribute = "dossier[champs_private_attributes][#{@position}][champs_attributes]"
else
@attribute = "dossier[champs_attributes][#{@position}][champs_attributes]"
end
end end
end end

View file

@ -2,9 +2,9 @@ class Champs::SiretController < ApplicationController
before_action :authenticate_logged_user! before_action :authenticate_logged_user!
def show def show
@position = params[:position] @champ = policy_scope(Champ).find(params[:champ_id])
extract_siret @siret = read_param_value(@champ.input_name, 'value')
find_etablisement @etablissement = @champ.etablissement
if @siret.empty? if @siret.empty?
return clear_siret_and_etablissement return clear_siret_and_etablissement
@ -34,22 +34,6 @@ class Champs::SiretController < ApplicationController
private private
def extract_siret
if params[:dossier].key?(:champs_attributes)
@siret = params[:dossier][:champs_attributes][@position][:value]
@attribute = "dossier[champs_attributes][#{@position}][etablissement_attributes]"
else
@siret = params[:dossier][:champs_private_attributes][@position][:value]
@attribute = "dossier[champs_private_attributes][#{@position}][etablissement_attributes]"
end
end
def find_etablisement
@champ = policy_scope(Champ).find(params[:champ_id])
@etablissement = @champ.etablissement
@procedure_id = @champ.dossier.procedure.id
end
def find_etablissement_with_siret def find_etablissement_with_siret
APIEntrepriseService.create_etablissement(@champ, @siret, current_user.id) APIEntrepriseService.create_etablissement(@champ, @siret, current_user.id)
end end

View file

@ -60,15 +60,6 @@ module ApplicationHelper
end end
end end
def render_champ(champ)
champ_selector = "##{champ.input_group_id}"
form_html = render 'shared/dossiers/edit', dossier: champ.dossier
champ_html = Nokogiri::HTML.fragment(form_html).at_css(champ_selector).to_s
# rubocop:disable Rails/OutputSafety
raw("document.querySelector('#{champ_selector}').outerHTML = \"#{escape_javascript(champ_html)}\";")
# rubocop:enable Rails/OutputSafety
end
def remove_element(selector, timeout: 0, inner: false) def remove_element(selector, timeout: 0, inner: false)
script = "(function() {"; script = "(function() {";
script << "var el = document.querySelector('#{selector}');" script << "var el = document.querySelector('#{selector}');"

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { getJSON, ajax, fire } from '@utils'; import { httpRequest, fire } from '@utils';
import type { Feature, FeatureCollection, Geometry } from 'geojson'; import type { Feature, FeatureCollection, Geometry } from 'geojson';
export const SOURCE_SELECTION_UTILISATEUR = 'selection_utilisateur'; export const SOURCE_SELECTION_UTILISATEUR = 'selection_utilisateur';
@ -37,7 +37,9 @@ export function useFeatureCollection(
type: 'FeatureCollection', type: 'FeatureCollection',
features: callback(features) features: callback(features)
})); }));
ajax({ url, type: 'GET' }).catch(() => null); httpRequest(url)
.js()
.catch(() => null);
}, },
[url, setFeatureCollection] [url, setFeatureCollection]
); );
@ -99,7 +101,10 @@ export function useFeatureCollection(
try { try {
const newFeatures: Feature[] = []; const newFeatures: Feature[] = [];
for (const feature of features) { for (const feature of features) {
const data = await getJSON(url, { feature, source }, 'post'); const data = await httpRequest(url, {
method: 'post',
json: { feature, source }
}).json<{ feature: Feature & { lid?: string | number } }>();
if (data) { if (data) {
if (source == SOURCE_SELECTION_UTILISATEUR) { if (source == SOURCE_SELECTION_UTILISATEUR) {
data.feature.lid = feature.id; data.feature.lid = feature.id;
@ -128,9 +133,15 @@ export function useFeatureCollection(
for (const feature of features) { for (const feature of features) {
const id = feature.properties?.id; const id = feature.properties?.id;
if (id) { if (id) {
await getJSON(`${url}/${id}`, { feature }, 'patch'); await httpRequest(`${url}/${id}`, {
method: 'patch',
json: { feature }
}).json();
} else { } else {
const data = await getJSON(url, { feature, source }, 'post'); const data = await httpRequest(url, {
method: 'post',
json: { feature, source }
}).json<{ feature: Feature & { lid?: string | number } }>();
if (data) { if (data) {
if (source == SOURCE_SELECTION_UTILISATEUR) { if (source == SOURCE_SELECTION_UTILISATEUR) {
data.feature.lid = feature.id; data.feature.lid = feature.id;
@ -157,7 +168,7 @@ export function useFeatureCollection(
const deletedFeatures = []; const deletedFeatures = [];
for (const feature of features) { for (const feature of features) {
const id = feature.properties?.id; const id = feature.properties?.id;
await getJSON(`${url}/${id}`, null, 'delete'); await httpRequest(`${url}/${id}`, { method: 'delete' }).json();
deletedFeatures.push(feature); deletedFeatures.push(feature);
} }
removeFeatures(deletedFeatures, external); removeFeatures(deletedFeatures, external);

View file

@ -1,4 +1,4 @@
import { getJSON } from '@utils'; import { httpRequest } from '@utils';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';
type Operation = { type Operation = {
@ -45,7 +45,7 @@ export class OperationsQueue {
const url = `${this.baseUrl}${path}`; const url = `${this.baseUrl}${path}`;
try { try {
const data = await getJSON(url, payload, method); const data = await httpRequest(url, { method, json: payload }).json();
resolve(data); resolve(data);
} catch (e) { } catch (e) {
handleError(e as OperationError, reject); handleError(e as OperationError, reject);

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryFunction } from 'react-query'; import { QueryClient, QueryFunction } from 'react-query';
import { getJSON, isNumeric } from '@utils'; import { httpRequest, isNumeric } from '@utils';
import { matchSorter } from 'match-sorter'; import { matchSorter } from 'match-sorter';
type Gon = { type Gon = {
@ -57,39 +57,27 @@ function buildURL(scope: string, term: string, extra?: string) {
return `${api_geo_url}/${scope}?nom=${term}&limit=${API_GEO_QUERY_LIMIT}`; return `${api_geo_url}/${scope}?nom=${term}&limit=${API_GEO_QUERY_LIMIT}`;
} }
function buildOptions(): [RequestInit, AbortController | null] {
if (window.AbortController) {
const controller = new AbortController();
const signal = controller.signal;
return [{ signal }, controller];
}
return [{}, null];
}
const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({ const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({
queryKey: [scope, term, extra] queryKey: [scope, term, extra],
signal
}) => { }) => {
if (scope == 'pays') { if (scope == 'pays') {
return matchSorter(await getPays(), term, { keys: ['label'] }); return matchSorter(await getPays(signal), term, { keys: ['label'] });
} }
const url = buildURL(scope, term, extra); const url = buildURL(scope, term, extra);
const [options, controller] = buildOptions(); return httpRequest(url, { csrf: false, signal }).json();
const promise = fetch(url, options).then((response) => {
if (response.ok) {
return response.json();
}
throw new Error(`Error fetching from "${scope}" API`);
});
return Object.assign(promise, {
cancel: () => controller && controller.abort()
});
}; };
let paysCache: { label: string }[]; let paysCache: { label: string }[];
async function getPays(): Promise<{ label: string }[]> { async function getPays(signal?: AbortSignal): Promise<{ label: string }[]> {
if (!paysCache) { if (!paysCache) {
paysCache = await getJSON('/api/pays', null); const data = await httpRequest('/api/pays', { signal }).json<
typeof paysCache
>();
if (data) {
paysCache = data;
}
} }
return paysCache; return paysCache;
} }

View file

@ -16,7 +16,27 @@ import 'yet-another-abortcontroller-polyfill';
import './polyfills/insertAdjacentElement'; import './polyfills/insertAdjacentElement';
import './polyfills/dataset'; import './polyfills/dataset';
// IE 11 has no baseURI // IE 11 has no baseURI (required by turbo)
if (document.baseURI == undefined) { if (document.baseURI == undefined) {
document.baseURI = document.URL; document.baseURI = document.URL;
} }
// IE 11 has no children on DocumentFragment (required by turbo)
function polyfillChildren(proto) {
Object.defineProperty(proto, 'children', {
get: function () {
const children = [];
for (const node of this.childNodes) {
if (node.nodeType == 1) {
children.push(node);
}
}
return children;
}
});
}
const fragment = document.createDocumentFragment();
if (fragment.children == undefined) {
polyfillChildren(DocumentFragment.prototype);
}

View file

@ -1,4 +1,4 @@
import { ajax, delegate } from '@utils'; import { httpRequest, delegate } from '@utils';
addEventListener('DOMContentLoaded', () => { addEventListener('DOMContentLoaded', () => {
attachementPoller.deactivate(); attachementPoller.deactivate();
@ -69,7 +69,9 @@ class RemotePoller {
for (let url of urls) { for (let url of urls) {
// Start the request. The JS payload in the response will update the page. // Start the request. The JS payload in the response will update the page.
// (Errors are ignored, because background tasks shouldn't report errors to the user.) // (Errors are ignored, because background tasks shouldn't report errors to the user.)
ajax({ url, type: 'get' }).catch(() => {}); httpRequest(url)
.js()
.catch(() => {});
} }
} }

View file

@ -1,8 +1,9 @@
import Rails from '@rails/ujs'; import Rails from '@rails/ujs';
import debounce from 'debounce'; import debounce from 'debounce';
import { session } from '@hotwired/turbo';
export { debounce }; export { debounce };
export const { fire, csrfToken } = Rails; export const { fire, csrfToken, cspNonce } = Rails;
export function show(el: HTMLElement) { export function show(el: HTMLElement) {
el && el.classList.remove('hidden'); el && el.classList.remove('hidden');
@ -93,7 +94,7 @@ export function ajax(options: Rails.AjaxOptions) {
}); });
} }
class ResponseError extends Error { export class ResponseError extends Error {
response: Response; response: Response;
constructor(response: Response) { constructor(response: Response) {
@ -102,18 +103,126 @@ class ResponseError extends Error {
} }
} }
export function getJSON(url: string, data: unknown, method = 'GET') { const FETCH_TIMEOUT = 30 * 1000; // 30 sec
const { query, ...options } = fetchOptions(data, method);
// Perform an HTTP request using `fetch` API,
// and handle the result depending on the MIME type.
//
// Usage:
//
// Execute a GET request, and return the response as parsed JSON
// const parsedJson = await httpRequest(url).json();
//
// Execute a POST request with some JSON payload
// const parsedJson = await httpRequest(url, { method: 'POST', json: '{ "foo": 1 }').json();
//
// Execute a GET request, and apply the Turbo stream in the Response
// await httpRequest(url).turbo();
//
// Execute a GET request, and interpret the JavaScript code in the Response
// DEPRECATED: Don't use this in new code; instead let the server respond with a turbo stream
// await httpRequest(url).js();
//
export function httpRequest(
url: string,
{
csrf = true,
timeout = FETCH_TIMEOUT,
json,
controller,
...init
}: RequestInit & {
csrf?: boolean;
json?: unknown;
timeout?: number | false;
controller?: AbortController;
} = {}
) {
const headers = new Headers(init.headers);
if (csrf) {
headers.set('x-csrf-token', csrfToken() ?? '');
headers.set('x-requested-with', 'XMLHttpRequest');
init.credentials = 'same-origin';
}
init.headers = headers;
init.method = init.method?.toUpperCase() ?? 'GET';
if (json) {
headers.set('content-type', 'application/json');
init.body = JSON.stringify(json);
}
let timer: number;
if (!init.signal) {
controller = createAbortController(controller);
if (controller) {
init.signal = controller.signal;
if (timeout != false) {
timer = setTimeout(() => controller?.abort(), timeout);
}
}
}
const request = (init: RequestInit, accept?: string): Promise<Response> => {
if (accept && init.headers instanceof Headers) {
init.headers.set('accept', accept);
}
return fetch(url, init)
.then((response) => {
clearTimeout(timer);
return fetch(`${url}${query}`, options).then((response) => {
if (response.ok) { if (response.ok) {
if (response.status === 204) { return response;
} else if (response.status == 401) {
location.reload(); // reload whole page so Devise will redirect to sign-in
}
throw new ResponseError(response);
})
.catch((error) => {
clearTimeout(timer);
throw error;
});
};
return {
async json<T>(): Promise<T | null> {
const response = await request(init, 'application/json');
if (response.status == 204) {
return null; return null;
} }
return response.json(); return response.json();
},
async turbo(): Promise<void> {
const response = await request(init, 'text/vnd.turbo-stream.html');
if (response.status != 204) {
const stream = await response.text();
session.renderStreamMessage(stream);
} }
throw new ResponseError(response); },
}); async js(): Promise<void> {
const response = await request(init, 'text/javascript');
if (response.status != 204) {
const script = document.createElement('script');
const nonce = cspNonce();
if (nonce) {
script.setAttribute('nonce', nonce);
}
script.text = await response.text();
document.head.appendChild(script);
document.head.removeChild(script);
}
}
};
}
function createAbortController(controller?: AbortController) {
if (controller) {
return controller;
} else if (window.AbortController) {
return new AbortController();
}
return;
} }
export function scrollTo(container: HTMLElement, scrollTo: HTMLElement) { export function scrollTo(container: HTMLElement, scrollTo: HTMLElement) {
@ -162,53 +271,3 @@ export function timeoutable<T>(
}); });
return Promise.race([promise, timeoutPromise]); return Promise.race([promise, timeoutPromise]);
} }
const FETCH_TIMEOUT = 30 * 1000; // 30 sec
function fetchOptions(data: unknown, method = 'GET') {
const options: RequestInit & {
query: string;
headers: Record<string, string>;
} = {
query: '',
method: method.toUpperCase(),
headers: {
accept: 'application/json',
'x-csrf-token': csrfToken() ?? '',
'x-requested-with': 'XMLHttpRequest'
},
credentials: 'same-origin'
};
if (data) {
if (options.method === 'GET') {
options.query = objectToQuerystring(data as Record<string, string>);
} else {
options.headers['content-type'] = 'application/json';
options.body = JSON.stringify(data);
}
}
if (window.AbortController) {
const controller = new AbortController();
options.signal = controller.signal;
setTimeout(() => {
controller.abort();
}, FETCH_TIMEOUT);
}
return options;
}
function objectToQuerystring(obj: Record<string, string>): string {
return Object.keys(obj).reduce(function (query, key, i) {
return [
query,
i === 0 ? '?' : '&',
encodeURIComponent(key),
'=',
encodeURIComponent(obj[key])
].join('');
}, '');
}

View file

@ -0,0 +1,7 @@
class Cron::PurgeUnusedAdminJob < Cron::CronJob
self.schedule_expression = "every monday at 5 am"
def perform(*args)
Administrateur.unused.destroy_all
end
end

View file

@ -12,6 +12,8 @@
class Administrateur < ApplicationRecord class Administrateur < ApplicationRecord
include ActiveRecord::SecureToken include ActiveRecord::SecureToken
UNUSED_ADMIN_THRESHOLD = 6.months
has_and_belongs_to_many :instructeurs has_and_belongs_to_many :instructeurs
has_and_belongs_to_many :procedures has_and_belongs_to_many :procedures
has_many :services has_many :services
@ -23,6 +25,14 @@ class Administrateur < ApplicationRecord
scope :inactive, -> { joins(:user).where(users: { last_sign_in_at: nil }) } scope :inactive, -> { joins(:user).where(users: { last_sign_in_at: nil }) }
scope :with_publiees_ou_closes, -> { joins(:procedures).where(procedures: { aasm_state: [:publiee, :close, :depubliee] }) } scope :with_publiees_ou_closes, -> { joins(:procedures).where(procedures: { aasm_state: [:publiee, :close, :depubliee] }) }
scope :unused, -> do
joins(:user)
.where.missing(:services)
.left_outer_joins(:administrateurs_procedures) # needed to bypass procedure hidden default scope
.where(administrateurs_procedures: { procedure_id: nil })
.where("users.last_sign_in_at < ? ", UNUSED_ADMIN_THRESHOLD.ago)
end
def self.by_email(email) def self.by_email(email)
Administrateur.find_by(users: { email: email }) Administrateur.find_by(users: { email: email })
end end

View file

@ -137,6 +137,23 @@ class Champ < ApplicationRecord
"#{html_id}-input" "#{html_id}-input"
end end
# A predictable string to use when generating an input name for this champ.
#
# Rail's FormBuilder can auto-generate input names, using the form "dossier[champs_attributes][5]",
# where [5] is the index of the field in the form.
# However the field index makes it difficult to render a single field, independent from the ordering of the others.
#
# Luckily, this is only used to make the name unique, but the actual value is ignored when Rails parses nested
# attributes. So instead of the field index, this method uses the champ id; which gives us an independent and
# predictable input name.
def input_name
if parent_id
"#{parent.input_name}[#{champs_attributes_accessor}][#{id}]"
else
"dossier[#{champs_attributes_accessor}][#{id}]"
end
end
def labelledby_id def labelledby_id
"#{html_id}-label" "#{html_id}-label"
end end
@ -169,6 +186,14 @@ class Champ < ApplicationRecord
"#{stable_id}-#{id}" "#{stable_id}-#{id}"
end end
def champs_attributes_accessor
if private?
"champs_private_attributes"
else
"champs_attributes"
end
end
def needs_dossier_id? def needs_dossier_id?
!dossier_id && parent_id !dossier_id && parent_id
end end

View file

@ -28,12 +28,15 @@ class Champs::RepetitionChamp < Champ
end end
def add_row def add_row
added_champs = []
transaction do transaction do
row = (blank? ? -1 : champs.last.row) + 1 row = (blank? ? -1 : champs.last.row) + 1
type_de_champ.types_de_champ.each do |type_de_champ| type_de_champ.types_de_champ.each do |type_de_champ|
self.champs << type_de_champ.champ.build(row: row) added_champs << type_de_champ.champ.build(row: row)
end end
self.champs << added_champs
end end
added_champs
end end
def blank? def blank?

View file

@ -1,6 +1,6 @@
<%= render_flash(timeout: 5000, fixed: true) %> <%= render_flash(timeout: 5000, fixed: true) %>
<%= render_to_element("#{@selector} + .geo-areas", <%= render_to_element("##{@champ.input_group_id} .geo-areas",
partial: 'shared/champs/carte/geo_areas', partial: 'shared/champs/carte/geo_areas',
locals: { champ: @champ, editing: true }) %> locals: { champ: @champ, editing: true }) %>

View file

@ -1,3 +1,3 @@
<%= render_to_element(".dossier-link-#{@position} .help-block", <%= render_to_element("##{@champ.input_group_id} .help-block",
partial: 'shared/champs/dossier_link/help_block', partial: 'shared/champs/dossier_link/help_block',
locals: { id: @dossier_id }) %> locals: { id: @linked_dossier_id }) %>

View file

@ -1,4 +1,6 @@
<%= render_champ(@champ) %> <%= fields_for @champ.input_name, @champ do |form| %>
<%= render_to_element("##{@champ.input_group_id}", partial: "shared/dossiers/editable_champs/editable_champ", locals: { champ: @champ, form: form }, outer: true) %>
<% end %>
<% attachment = @champ.piece_justificative_file.attachment %> <% attachment = @champ.piece_justificative_file.attachment %>
<% if attachment.virus_scanner.pending? %> <% if attachment.virus_scanner.pending? %>

View file

@ -1,16 +0,0 @@
- champs = champ.rows.last
- if champs.present?
- index = (champ.rows.size - 1) * champs.size
- row_dom_id = "row-#{SecureRandom.hex(4)}"
%div{ class: "row row-#{champs.first.row}", id: row_dom_id }
-# Tell the controller which DOM element should be removed once the row deletion is successful
= hidden_field_tag 'deleted_row_dom_ids[]', row_dom_id, disabled: true
- champs.each.with_index(index) do |champ, index|
= fields_for "#{attribute}[#{index}]", champ do |form|
= render partial: "shared/dossiers/editable_champs/editable_champ", locals: { champ: champ, form: form }
= form.hidden_field :id
= form.hidden_field :_destroy, disabled: true
.flex.row-reverse
%button.button.danger.remove-row
Supprimer lélément

View file

@ -1,3 +1,3 @@
<%= append_to_element(".repetition-#{@position}", <%= fields_for @champ.input_name, @champ do |form| %>
partial: 'champs/repetition/show', <%= append_to_element("##{@champ.input_group_id} .repetition", partial: 'shared/dossiers/editable_champs/repetition_row', locals: { form: form, champ: @champ, row: @champs }) %>
locals: { champ: @champ, attribute: @attribute }) %> <% end %>

View file

@ -1,6 +1,3 @@
<%= render_to_element(".siret-info-#{@position}", <%= render_to_element("##{@champ.input_group_id} .siret-info",
partial: 'shared/champs/siret/etablissement', partial: 'shared/champs/siret/etablissement',
locals: { locals: { siret: @siret, etablissement: @etablissement }) %>
siret: @siret,
attribute: @attribute,
etablissement: @etablissement }) %>

View file

@ -6,10 +6,9 @@
- if @dossier.champs_private.present? - if @dossier.champs_private.present?
%section %section
= form_for @dossier, url: annotations_instructeur_dossier_path(@dossier.procedure, @dossier), html: { class: 'form' } do |f| = form_for @dossier, url: annotations_instructeur_dossier_path(@dossier.procedure, @dossier), html: { class: 'form' } do |f|
= f.fields_for :champs_private, f.object.champs_private do |champ_form| - @dossier.champs_private.each do |champ|
- champ = champ_form.object = fields_for champ.input_name, champ do |form|
= render partial: "shared/dossiers/editable_champs/editable_champ", = render partial: "shared/dossiers/editable_champs/editable_champ", locals: { form: form, champ: champ, seen_at: @annotations_privees_seen_at }
locals: { champ: champ, form: champ_form, seen_at: @annotations_privees_seen_at }
.send-wrapper .send-wrapper
= f.submit 'Sauvegarder', class: 'button primary send', data: { disable: true } = f.submit 'Sauvegarder', class: 'button primary send', data: { disable: true }

View file

@ -25,7 +25,7 @@ as defined by the routes in the `admin/` namespace
<%= link_to "Delayed Jobs", manager_delayed_job_path, class: "navigation__link" %> <%= link_to "Delayed Jobs", manager_delayed_job_path, class: "navigation__link" %>
<%= link_to "Features", manager_flipper_path, class: "navigation__link" %> <%= link_to "Features", manager_flipper_path, class: "navigation__link" %>
<% if ENV["SENDINBLUE_ENABLED"] == "enabled" && ENV["SAML_IDP_ENABLED"] == "enabled" %> <% if Rails.application.secrets.sendinblue[:enabled] && ENV["SAML_IDP_ENABLED"] == "enabled" %>
<%= link_to "Sendinblue", ENV.fetch("SENDINBLUE_LOGIN_URL"), class: "navigation__link", target: '_blank' %> <%= link_to "Sendinblue", ENV.fetch("SENDINBLUE_LOGIN_URL"), class: "navigation__link", target: '_blank' %>
<% end %> <% end %>
</nav> </nav>

View file

@ -35,10 +35,9 @@
dossier.procedure.groupe_instructeurs.order(:label).map { |gi| [gi.label, gi.id] }, dossier.procedure.groupe_instructeurs.order(:label).map { |gi| [gi.label, gi.id] },
{ include_blank: dossier.brouillon? } { include_blank: dossier.brouillon? }
= f.fields_for :champs, dossier.champs do |champ_form| - dossier.champs.each do |champ|
- champ = champ_form.object = fields_for champ.input_name, champ do |form|
= render partial: "shared/dossiers/editable_champs/editable_champ", = render partial: "shared/dossiers/editable_champs/editable_champ", locals: { form: form, champ: champ }
locals: { champ: champ, form: champ_form }
- if !dossier.for_procedure_preview? - if !dossier.for_procedure_preview?
.dossier-edit-sticky-footer .dossier-edit-sticky-footer

View file

@ -1,4 +1,4 @@
= react_component("MapEditor", { featureCollection: champ.to_feature_collection, url: champs_carte_features_path(champ), options: champ.render_options }, class: "carte-#{champ.id}") = react_component("MapEditor", featureCollection: champ.to_feature_collection, url: champs_carte_features_path(champ), options: champ.render_options)
.geo-areas .geo-areas
= render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ, editing: true } = render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ, editing: true }

View file

@ -1,11 +1,11 @@
.dossier-link{ class: "dossier-link-#{form.index}" } .dossier-link
= form.number_field :value, = form.number_field :value,
id: champ.input_id, id: champ.input_id,
aria: { describedby: champ.describedby_id }, aria: { describedby: champ.describedby_id },
placeholder: "Numéro de dossier", placeholder: "Numéro de dossier",
autocomplete: 'off', autocomplete: 'off',
required: champ.mandatory?, required: champ.mandatory?,
data: { remote: true, url: champs_dossier_link_path(form.index) } data: { remote: true, url: champs_dossier_link_path(champ.id) }
.help-block .help-block
= render partial: 'shared/champs/dossier_link/help_block', locals: { id: champ.value } = render partial: 'shared/champs/dossier_link/help_block', locals: { id: champ.value }

View file

@ -19,4 +19,4 @@
= form.select :value, champ.options, { selected: champ.selected}, required: champ.mandatory?, id: champ.input_id, aria: { describedby: champ.describedby_id } = form.select :value, champ.options, { selected: champ.selected}, required: champ.mandatory?, id: champ.input_id, aria: { describedby: champ.describedby_id }
- if champ.drop_down_other? - if champ.drop_down_other?
= render partial: "shared/dossiers/drop_down_other_input", locals: { form: form, champ: champ } = render partial: "shared/dossiers/editable_champs/drop_down_other_input", locals: { form: form, champ: champ }

View file

@ -8,5 +8,6 @@
= render partial: 'shared/dossiers/editable_champs/champ_label', locals: { form: form, champ: champ, seen_at: defined?(seen_at) ? seen_at : nil } = render partial: 'shared/dossiers/editable_champs/champ_label', locals: { form: form, champ: champ, seen_at: defined?(seen_at) ? seen_at : nil }
- if champ.type_champ == "titre_identite" - if champ.type_champ == "titre_identite"
%p.notice Carte nationale didentité (uniquement le recto), passeport, titre de séjour ou autre justificatif didentité. Formats acceptés : jpg/png %p.notice Carte nationale didentité (uniquement le recto), passeport, titre de séjour ou autre justificatif didentité. Formats acceptés : jpg/png
= render partial: "shared/dossiers/editable_champs/#{champ.type_champ}",
locals: { champ: champ, form: form } = form.hidden_field :id, value: champ.id
= render partial: "shared/dossiers/editable_champs/#{champ.type_champ}", locals: { form: form, champ: champ }

View file

@ -1,24 +1,9 @@
%div{ class: "repetition-#{form.index}" } .repetition
- champ.rows.each do |champs| - champ.rows.each do |champs|
- row_dom_id = "row-#{SecureRandom.hex(4)}" = render partial: 'shared/dossiers/editable_champs/repetition_row', locals: { form: form, champ: champ, row: champs }
%div{ class: "row row-#{champs.first.row}", id: row_dom_id }
-# Tell the controller which DOM element should be removed once the row deletion is successful
= hidden_field_tag 'deleted_row_dom_ids[]', row_dom_id, disabled: true
- champs.each do |champ|
= form.fields_for :champs, champ do |form|
= render partial: 'shared/dossiers/editable_champs/editable_champ', locals: { champ: form.object, form: form }
= form.hidden_field :_destroy, disabled: true
.flex.row-reverse
- if champ.persisted?
%button.button.danger.remove-row{ type: :button }
Supprimer lélément
- else
%button.button.danger{ type: :button }
Supprimer lélément
- if champ.persisted? - if champ.persisted?
= link_to champs_repetition_path(form.index), class: 'button add-row', data: { remote: true, disable: true, method: 'POST', params: { champ_id: champ&.id }.to_query } do = link_to champs_repetition_path(champ.id), class: 'button add-row', data: { remote: true, disable: true, method: 'POST' } do
%span.icon.add %span.icon.add
Ajouter un élément pour « #{champ.libelle} » Ajouter un élément pour « #{champ.libelle} »
- else - else

View file

@ -0,0 +1,13 @@
- row_dom_id = "row-#{SecureRandom.hex(4)}"
.row{ id: row_dom_id }
-# Tell the controller which DOM element should be removed once the row deletion is successful
= hidden_field_tag 'deleted_row_dom_ids[]', row_dom_id, disabled: true
- row.each do |champ|
= fields_for champ.input_name, champ do |form|
= render partial: 'shared/dossiers/editable_champs/editable_champ', locals: { form: form, champ: champ }
= form.hidden_field :_destroy, disabled: true
.flex.row-reverse
%button.button.danger.remove-row{ type: :button }
Supprimer lélément

View file

@ -2,11 +2,11 @@
id: champ.input_id, id: champ.input_id,
aria: { describedby: champ.describedby_id }, aria: { describedby: champ.describedby_id },
placeholder: champ.libelle, placeholder: champ.libelle,
data: { remote: true, debounce: true, url: champs_siret_path(form.index), params: { champ_id: champ&.id }.to_query, spinner: true }, data: { remote: true, debounce: true, url: champs_siret_path(champ.id), spinner: true },
required: champ.mandatory?, required: champ.mandatory?,
pattern: "[0-9]{14}", pattern: "[0-9]{14}",
title: "Le numéro de SIRET doit comporter exactement 14 chiffres" title: "Le numéro de SIRET doit comporter exactement 14 chiffres"
.spinner.right.hidden .spinner.right.hidden
.siret-info{ class: "siret-info-#{form.index}" } .siret-info
- if champ.etablissement.present? - if champ.etablissement.present?
= render partial: 'shared/dossiers/editable_champs/etablissement_titre', locals: { etablissement: champ.etablissement } = render partial: 'shared/dossiers/editable_champs/etablissement_titre', locals: { etablissement: champ.etablissement }

View file

@ -84,19 +84,12 @@ MATOMO_HOST="matomo.example.org"
MAILJET_API_KEY="" MAILJET_API_KEY=""
MAILJET_SECRET_KEY="" MAILJET_SECRET_KEY=""
# Alternate SMTP Provider: SendInBlue # Alternate SMTP Provider: SendInBlue/DoList
SENDINBLUE_ENABLED="disabled"
SENDINBLUE_CLIENT_KEY="" SENDINBLUE_CLIENT_KEY=""
SENDINBLUE_SMTP_KEY="" SENDINBLUE_SMTP_KEY=""
SENDINBLUE_USER_NAME="" SENDINBLUE_USER_NAME=""
# SENDINBLUE_LOGIN_URL="https://app.sendinblue.com/account/saml/login/truc" # SENDINBLUE_LOGIN_URL="https://app.sendinblue.com/account/saml/login/truc"
# Ratio of emails sent using SendInBlue
# When enabled, N % of emails will be sent using SendInBlue
# (and the others using the default SMTP provider)
SENDINBLUE_BALANCING="disabled"
SENDINBLUE_BALANCING_VALUE="50"
# Alternate SMTP Provider: Mailtrap (mail catcher for staging environments) # Alternate SMTP Provider: Mailtrap (mail catcher for staging environments)
# When enabled, all emails will be sent using this provider # When enabled, all emails will be sent using this provider
MAILTRAP_ENABLED="disabled" MAILTRAP_ENABLED="disabled"

View file

@ -119,3 +119,12 @@ MATOMO_IFRAME_URL="https://matomo.example.org/index.php?module=CoreAdminHome&act
# DOLIST_ACCOUNT_ID="" # DOLIST_ACCOUNT_ID=""
# DOLIST_NO_REPLY_EMAIL="" # DOLIST_NO_REPLY_EMAIL=""
# DOLIST_API_KEY="" # DOLIST_API_KEY=""
# Ratio of emails sent using SendInBlue
# When present, N % of emails will be sent using SendInBlue
# (and the others using the default SMTP provider)
SENDINBLUE_BALANCING_VALUE="50"
# Ratio of emails sent using DoList
# When present, N % of emails will be sent using DoList
# (and the others using the default SMTP provider)
DOLIST_BALANCING_VALUE="50"

View file

@ -81,24 +81,8 @@ Rails.application.configure do
elsif ENV['MAILCATCHER_ENABLED'] == 'enabled' elsif ENV['MAILCATCHER_ENABLED'] == 'enabled'
config.action_mailer.delivery_method = :mailcatcher config.action_mailer.delivery_method = :mailcatcher
else else
sendinblue_weigth = ENV.fetch('SENDINBLUE_BALANCING_VALUE') { 0 }.to_i
sendinblue_weigth = case [ENV['SENDINBLUE_ENABLED'] == 'enabled', ENV['SENDINBLUE_BALANCING'] == 'enabled'] dolist_weigth = ENV.fetch('DOLIST_BALANCING_VALUE') { 0 }.to_i
in [false, _]
0
in [true, false]
100
else
ENV.fetch('SENDINBLUE_BALANCING_VALUE').to_i
end
dolist_weigth = case [ENV['DOLIST_ENABLED'] == 'enabled', ENV['DOLIST_BALANCING'] == 'enabled']
in [false, _]
0
in [true, false]
100
else
ENV.fetch('DOLIST_BALANCING_VALUE').to_i
end
ActionMailer::Base.add_delivery_method :balancer, BalancerDeliveryMethod ActionMailer::Base.add_delivery_method :balancer, BalancerDeliveryMethod
config.action_mailer.balancer_settings = { config.action_mailer.balancer_settings = {

View file

@ -1,4 +1,4 @@
if ENV.fetch('SENDINBLUE_ENABLED') == 'enabled' if ENV.key?('SENDINBLUE_BALANCING_VALUE')
require 'sib-api-v3-sdk' require 'sib-api-v3-sdk'
ActiveSupport.on_load(:action_mailer) do ActiveSupport.on_load(:action_mailer) do

View file

@ -141,18 +141,17 @@ Rails.application.routes.draw do
end end
namespace :champs do namespace :champs do
get ':position/siret', to: 'siret#show', as: :siret get ':champ_id/siret', to: 'siret#show', as: :siret
get ':position/dossier_link', to: 'dossier_link#show', as: :dossier_link get ':champ_id/dossier_link', to: 'dossier_link#show', as: :dossier_link
post ':position/carte', to: 'carte#show', as: :carte post ':champ_id/carte', to: 'carte#show', as: :carte
post ':champ_id/repetition', to: 'repetition#show', as: :repetition
get ':champ_id/carte/features', to: 'carte#index', as: :carte_features get ':champ_id/carte/features', to: 'carte#index', as: :carte_features
post ':champ_id/carte/features', to: 'carte#create' post ':champ_id/carte/features', to: 'carte#create'
post ':champ_id/carte/features/import', to: 'carte#import'
patch ':champ_id/carte/features/:id', to: 'carte#update' patch ':champ_id/carte/features/:id', to: 'carte#update'
delete ':champ_id/carte/features/:id', to: 'carte#destroy' delete ':champ_id/carte/features/:id', to: 'carte#destroy'
post ':position/repetition', to: 'repetition#show', as: :repetition put ':champ_id/piece_justificative', to: 'piece_justificative#update', as: :piece_justificative
put 'piece_justificative/:champ_id', to: 'piece_justificative#update', as: :piece_justificative
end end
resources :attachments, only: [:show, :destroy] resources :attachments, only: [:show, :destroy]

View file

@ -53,7 +53,7 @@ defaults: &defaults
client_secret: <%= ENV['HELPSCOUT_CLIENT_SECRET'] %> client_secret: <%= ENV['HELPSCOUT_CLIENT_SECRET'] %>
webhook_secret: <%= ENV['HELPSCOUT_WEBHOOK_SECRET'] %> webhook_secret: <%= ENV['HELPSCOUT_WEBHOOK_SECRET'] %>
sendinblue: sendinblue:
enabled: <%= ENV['SENDINBLUE_ENABLED'] == 'enabled' %> enabled: <%= ENV.key?('SENDINBLUE_BALANCING_VALUE') %>
username: <%= ENV['SENDINBLUE_USER_NAME'] %> username: <%= ENV['SENDINBLUE_USER_NAME'] %>
client_key: <%= ENV['SENDINBLUE_CLIENT_KEY'] %> client_key: <%= ENV['SENDINBLUE_CLIENT_KEY'] %>
smtp_key: <%= ENV['SENDINBLUE_SMTP_KEY'] %> smtp_key: <%= ENV['SENDINBLUE_SMTP_KEY'] %>

View file

@ -1,28 +0,0 @@
diff --git a/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js b/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js
index 963422f..7820263 100644
--- a/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js
+++ b/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js
@@ -551,7 +551,8 @@ class StreamMessage {
}, []);
}
get templateChildren() {
- return Array.from(this.templateElement.content.children);
+ const content = this.templateElement.content;
+ return content.children ? Array.from(content.children) : Array.from(content.childNodes).filter((tag) => tag.tagName);
}
}
StreamMessage.contentType = "text/vnd.turbo-stream.html";
diff --git a/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js b/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js
index 101db1f..a63cfbe 100644
--- a/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js
+++ b/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js
@@ -557,7 +557,8 @@ Copyright © 2021 Basecamp, LLC
}, []);
}
get templateChildren() {
- return Array.from(this.templateElement.content.children);
+ const content = this.templateElement.content;
+ return content.children ? Array.from(content.children) : Array.from(content.childNodes).filter((tag) => tag.tagName);
}
}
StreamMessage.contentType = "text/vnd.turbo-stream.html";

View file

@ -1,22 +1,26 @@
describe Champs::DossierLinkController, type: :controller do describe Champs::DossierLinkController, type: :controller do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:procedure) { create(:procedure, :published) } let(:procedure) { create(:procedure, :published, :with_dossier_link) }
describe '#show' do describe '#show' do
let(:dossier) { create(:dossier, user: user, procedure: procedure) } let(:dossier) { create(:dossier, user: user, procedure: procedure) }
let(:champ) { dossier.champs.first }
context 'when user is connected' do context 'when user is connected' do
render_views render_views
before { sign_in user } before { sign_in user }
let(:champs_attributes) do
champ_attributes = []
champ_attributes[champ.id] = { value: dossier_id }
champ_attributes
end
let(:params) do let(:params) do
{ {
champ_id: champ.id,
dossier: { dossier: {
champs_attributes: { champs_attributes: champs_attributes
'1' => { value: dossier_id.to_s }
} }
},
position: '1'
} }
end end
let(:dossier_id) { dossier.id } let(:dossier_id) { dossier.id }
@ -30,7 +34,7 @@ describe Champs::DossierLinkController, type: :controller do
expect(response.body).to include('Dossier en brouillon') expect(response.body).to include('Dossier en brouillon')
expect(response.body).to include(procedure.libelle) expect(response.body).to include(procedure.libelle)
expect(response.body).to include(procedure.organisation) expect(response.body).to include(procedure.organisation)
expect(response.body).to include('.dossier-link-1 .help-block') expect(response.body).to include("##{champ.input_group_id} .help-block")
end end
end end
@ -42,14 +46,14 @@ describe Champs::DossierLinkController, type: :controller do
it 'renders error message' do it 'renders error message' do
expect(response.body).to include('Ce dossier est inconnu') expect(response.body).to include('Ce dossier est inconnu')
expect(response.body).to include('.dossier-link-1 .help-block') expect(response.body).to include("##{champ.input_group_id} .help-block")
end end
end end
end end
context 'when user is not connected' do context 'when user is not connected' do
before do before do
get :show, params: { position: '1' }, format: :js, xhr: true get :show, params: { champ_id: champ.id }, format: :js, xhr: true
end end
it { expect(response.code).to eq('401') } it { expect(response.code).to eq('401') }

View file

@ -1,23 +1,22 @@
describe Champs::SiretController, type: :controller do describe Champs::SiretController, type: :controller do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:procedure) do let(:procedure) { create(:procedure, :published, :with_siret) }
tdc_siret = build(:type_de_champ_siret, procedure: nil)
create(:procedure, :published, types_de_champ: [tdc_siret])
end
describe '#show' do describe '#show' do
let(:dossier) { create(:dossier, user: user, procedure: procedure) } let(:dossier) { create(:dossier, user: user, procedure: procedure) }
let(:champ) { dossier.champs.first } let(:champ) { dossier.champs.first }
let(:champs_attributes) do
champ_attributes = []
champ_attributes[champ.id] = { value: siret }
champ_attributes
end
let(:params) do let(:params) do
{ {
champ_id: champ.id, champ_id: champ.id,
dossier: { dossier: {
champs_attributes: { champs_attributes: champs_attributes
'1' => { value: siret.to_s }
} }
},
position: '1'
} }
end end
let(:siret) { '' } let(:siret) { '' }
@ -47,7 +46,7 @@ describe Champs::SiretController, type: :controller do
end end
it 'clears any information or error message' do it 'clears any information or error message' do
expect(response.body).to include('.siret-info-1') expect(response.body).to include("##{champ.input_group_id} .siret-info")
expect(response.body).to include('innerHTML = ""') expect(response.body).to include('innerHTML = ""')
end end
end end
@ -120,7 +119,7 @@ describe Champs::SiretController, type: :controller do
end end
context 'when user is not signed in' do context 'when user is not signed in' do
subject! { get :show, params: { position: '1' }, format: :js, xhr: true } subject! { get :show, params: { champ_id: champ.id }, format: :js, xhr: true }
it { expect(response.code).to eq('401') } it { expect(response.code).to eq('401') }
end end

View file

@ -154,6 +154,12 @@ FactoryBot.define do
end end
end end
trait :with_siret do
after(:build) do |procedure, _evaluator|
build(:type_de_champ_siret, procedure: procedure)
end
end
trait :with_yes_no do trait :with_yes_no do
after(:build) do |procedure, _evaluator| after(:build) do |procedure, _evaluator|
build(:type_de_champ_yes_no, procedure: procedure) build(:type_de_champ_yes_no, procedure: procedure)

View file

@ -163,4 +163,34 @@ describe Administrateur, type: :model do
end end
end end
end end
describe 'unused' do
subject { Administrateur.unused }
let(:new_admin) { create(:administrateur) }
let(:unused_admin) { create(:administrateur) }
before do
new_admin.user.update(last_sign_in_at: (6.months - 1.day).ago)
unused_admin.user.update(last_sign_in_at: (6.months + 1.day).ago)
end
it { is_expected.to match([unused_admin]) }
context 'with a hidden procedure' do
let(:procedure) { create(:procedure, hidden_at: 1.month.ago) }
before { unused_admin.procedures << procedure }
it { is_expected.to be_empty }
end
context 'with a service' do
let(:service) { create(:service) }
before { unused_admin.services << service }
it { is_expected.to be_empty }
end
end
end end

View file

@ -113,7 +113,7 @@ describe 'The user' do
click_on 'Ajouter un élément pour' click_on 'Ajouter un élément pour'
within '.row-1' do within '.repetition .row:first-child' do
fill_in('sub type de champ', with: 'un autre texte') fill_in('sub type de champ', with: 'un autre texte')
end end
@ -124,7 +124,7 @@ describe 'The user' do
expect(page).to have_content('Supprimer', count: 2) expect(page).to have_content('Supprimer', count: 2)
within '.row-1' do within '.repetition .row:first-child' do
click_on 'Supprimer lélément' click_on 'Supprimer lélément'
end end