diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 310cbd25b..584751af9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -364,4 +364,20 @@ class ApplicationController < ActionController::Base def set_customizable_view_path prepend_view_path "app/custom_views" 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 diff --git a/app/controllers/champs/carte_controller.rb b/app/controllers/champs/carte_controller.rb index 0d5f58024..0bc927f5a 100644 --- a/app/controllers/champs/carte_controller.rb +++ b/app/controllers/champs/carte_controller.rb @@ -2,7 +2,6 @@ class Champs::CarteController < ApplicationController before_action :authenticate_logged_user! def index - @selector = ".carte-#{params[:champ_id]}" @champ = policy_scope(Champ).find(params[:champ_id]) @focus = params[:focus].present? end diff --git a/app/controllers/champs/dossier_link_controller.rb b/app/controllers/champs/dossier_link_controller.rb index 1ba3823e1..b41e26e6b 100644 --- a/app/controllers/champs/dossier_link_controller.rb +++ b/app/controllers/champs/dossier_link_controller.rb @@ -2,12 +2,7 @@ class Champs::DossierLinkController < ApplicationController before_action :authenticate_logged_user! def show - @position = params[:position] - - 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 + @champ = policy_scope(Champ).find(params[:champ_id]) + @linked_dossier_id = read_param_value(@champ.input_name, 'value') end end diff --git a/app/controllers/champs/repetition_controller.rb b/app/controllers/champs/repetition_controller.rb index 33b817836..8ed626fb2 100644 --- a/app/controllers/champs/repetition_controller.rb +++ b/app/controllers/champs/repetition_controller.rb @@ -3,13 +3,6 @@ class Champs::RepetitionController < ApplicationController def show @champ = policy_scope(Champ).includes(:champs).find(params[:champ_id]) - @position = params[:position] - @champ.add_row - - if @champ.private? - @attribute = "dossier[champs_private_attributes][#{@position}][champs_attributes]" - else - @attribute = "dossier[champs_attributes][#{@position}][champs_attributes]" - end + @champs = @champ.add_row end end diff --git a/app/controllers/champs/siret_controller.rb b/app/controllers/champs/siret_controller.rb index aaeaeac5b..1b1b33edd 100644 --- a/app/controllers/champs/siret_controller.rb +++ b/app/controllers/champs/siret_controller.rb @@ -2,9 +2,9 @@ class Champs::SiretController < ApplicationController before_action :authenticate_logged_user! def show - @position = params[:position] - extract_siret - find_etablisement + @champ = policy_scope(Champ).find(params[:champ_id]) + @siret = read_param_value(@champ.input_name, 'value') + @etablissement = @champ.etablissement if @siret.empty? return clear_siret_and_etablissement @@ -34,22 +34,6 @@ class Champs::SiretController < ApplicationController 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 APIEntrepriseService.create_etablissement(@champ, @siret, current_user.id) end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6572b58bb..5a7a9872a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -60,15 +60,6 @@ module ApplicationHelper 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) script = "(function() {"; script << "var el = document.querySelector('#{selector}');" diff --git a/app/javascript/components/MapEditor/hooks.ts b/app/javascript/components/MapEditor/hooks.ts index 9380a28fc..d292d5e1b 100644 --- a/app/javascript/components/MapEditor/hooks.ts +++ b/app/javascript/components/MapEditor/hooks.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { getJSON, ajax, fire } from '@utils'; +import { httpRequest, fire } from '@utils'; import type { Feature, FeatureCollection, Geometry } from 'geojson'; export const SOURCE_SELECTION_UTILISATEUR = 'selection_utilisateur'; @@ -37,7 +37,9 @@ export function useFeatureCollection( type: 'FeatureCollection', features: callback(features) })); - ajax({ url, type: 'GET' }).catch(() => null); + httpRequest(url) + .js() + .catch(() => null); }, [url, setFeatureCollection] ); @@ -99,7 +101,10 @@ export function useFeatureCollection( try { const newFeatures: Feature[] = []; 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 (source == SOURCE_SELECTION_UTILISATEUR) { data.feature.lid = feature.id; @@ -128,9 +133,15 @@ export function useFeatureCollection( for (const feature of features) { const id = feature.properties?.id; if (id) { - await getJSON(`${url}/${id}`, { feature }, 'patch'); + await httpRequest(`${url}/${id}`, { + method: 'patch', + json: { feature } + }).json(); } 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 (source == SOURCE_SELECTION_UTILISATEUR) { data.feature.lid = feature.id; @@ -157,7 +168,7 @@ export function useFeatureCollection( const deletedFeatures = []; for (const feature of features) { const id = feature.properties?.id; - await getJSON(`${url}/${id}`, null, 'delete'); + await httpRequest(`${url}/${id}`, { method: 'delete' }).json(); deletedFeatures.push(feature); } removeFeatures(deletedFeatures, external); diff --git a/app/javascript/components/TypesDeChampEditor/OperationsQueue.ts b/app/javascript/components/TypesDeChampEditor/OperationsQueue.ts index 0da09cebc..a6b1c4f40 100644 --- a/app/javascript/components/TypesDeChampEditor/OperationsQueue.ts +++ b/app/javascript/components/TypesDeChampEditor/OperationsQueue.ts @@ -1,4 +1,4 @@ -import { getJSON } from '@utils'; +import { httpRequest } from '@utils'; import invariant from 'tiny-invariant'; type Operation = { @@ -45,7 +45,7 @@ export class OperationsQueue { const url = `${this.baseUrl}${path}`; try { - const data = await getJSON(url, payload, method); + const data = await httpRequest(url, { method, json: payload }).json(); resolve(data); } catch (e) { handleError(e as OperationError, reject); diff --git a/app/javascript/components/shared/queryClient.ts b/app/javascript/components/shared/queryClient.ts index 8c7d8804b..ebf797c62 100644 --- a/app/javascript/components/shared/queryClient.ts +++ b/app/javascript/components/shared/queryClient.ts @@ -1,5 +1,5 @@ import { QueryClient, QueryFunction } from 'react-query'; -import { getJSON, isNumeric } from '@utils'; +import { httpRequest, isNumeric } from '@utils'; import { matchSorter } from 'match-sorter'; 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}`; } -function buildOptions(): [RequestInit, AbortController | null] { - if (window.AbortController) { - const controller = new AbortController(); - const signal = controller.signal; - return [{ signal }, controller]; - } - return [{}, null]; -} - const defaultQueryFn: QueryFunction = async ({ - queryKey: [scope, term, extra] + queryKey: [scope, term, extra], + signal }) => { 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 [options, controller] = buildOptions(); - 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() - }); + return httpRequest(url, { csrf: false, signal }).json(); }; let paysCache: { label: string }[]; -async function getPays(): Promise<{ label: string }[]> { +async function getPays(signal?: AbortSignal): Promise<{ label: string }[]> { if (!paysCache) { - paysCache = await getJSON('/api/pays', null); + const data = await httpRequest('/api/pays', { signal }).json< + typeof paysCache + >(); + if (data) { + paysCache = data; + } } return paysCache; } diff --git a/app/javascript/shared/polyfills.js b/app/javascript/shared/polyfills.js index 229729779..3bc4a25a4 100644 --- a/app/javascript/shared/polyfills.js +++ b/app/javascript/shared/polyfills.js @@ -16,7 +16,27 @@ import 'yet-another-abortcontroller-polyfill'; import './polyfills/insertAdjacentElement'; import './polyfills/dataset'; -// IE 11 has no baseURI +// IE 11 has no baseURI (required by turbo) if (document.baseURI == undefined) { 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); +} diff --git a/app/javascript/shared/remote-poller.js b/app/javascript/shared/remote-poller.js index f4faa6c02..77984c900 100644 --- a/app/javascript/shared/remote-poller.js +++ b/app/javascript/shared/remote-poller.js @@ -1,4 +1,4 @@ -import { ajax, delegate } from '@utils'; +import { httpRequest, delegate } from '@utils'; addEventListener('DOMContentLoaded', () => { attachementPoller.deactivate(); @@ -69,7 +69,9 @@ class RemotePoller { for (let url of urls) { // 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.) - ajax({ url, type: 'get' }).catch(() => {}); + httpRequest(url) + .js() + .catch(() => {}); } } diff --git a/app/javascript/shared/utils.ts b/app/javascript/shared/utils.ts index 1c0c31ca4..609e2f27b 100644 --- a/app/javascript/shared/utils.ts +++ b/app/javascript/shared/utils.ts @@ -1,8 +1,9 @@ import Rails from '@rails/ujs'; import debounce from 'debounce'; +import { session } from '@hotwired/turbo'; export { debounce }; -export const { fire, csrfToken } = Rails; +export const { fire, csrfToken, cspNonce } = Rails; export function show(el: HTMLElement) { 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; constructor(response: Response) { @@ -102,18 +103,126 @@ class ResponseError extends Error { } } -export function getJSON(url: string, data: unknown, method = 'GET') { - const { query, ...options } = fetchOptions(data, method); +const FETCH_TIMEOUT = 30 * 1000; // 30 sec - return fetch(`${url}${query}`, options).then((response) => { - if (response.ok) { - if (response.status === 204) { +// 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 => { + if (accept && init.headers instanceof Headers) { + init.headers.set('accept', accept); + } + return fetch(url, init) + .then((response) => { + clearTimeout(timer); + + if (response.ok) { + 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(): Promise { + const response = await request(init, 'application/json'); + if (response.status == 204) { return null; } return response.json(); + }, + async turbo(): Promise { + const response = await request(init, 'text/vnd.turbo-stream.html'); + if (response.status != 204) { + const stream = await response.text(); + session.renderStreamMessage(stream); + } + }, + async js(): Promise { + 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); + } } - throw new ResponseError(response); - }); + }; +} + +function createAbortController(controller?: AbortController) { + if (controller) { + return controller; + } else if (window.AbortController) { + return new AbortController(); + } + return; } export function scrollTo(container: HTMLElement, scrollTo: HTMLElement) { @@ -162,53 +271,3 @@ export function timeoutable( }); 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; - } = { - 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); - } 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 { - return Object.keys(obj).reduce(function (query, key, i) { - return [ - query, - i === 0 ? '?' : '&', - encodeURIComponent(key), - '=', - encodeURIComponent(obj[key]) - ].join(''); - }, ''); -} diff --git a/app/jobs/cron/purge_unused_admin_job.rb b/app/jobs/cron/purge_unused_admin_job.rb new file mode 100644 index 000000000..d58f479d4 --- /dev/null +++ b/app/jobs/cron/purge_unused_admin_job.rb @@ -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 diff --git a/app/models/administrateur.rb b/app/models/administrateur.rb index a35e77098..d6bbbe258 100644 --- a/app/models/administrateur.rb +++ b/app/models/administrateur.rb @@ -12,6 +12,8 @@ class Administrateur < ApplicationRecord include ActiveRecord::SecureToken + UNUSED_ADMIN_THRESHOLD = 6.months + has_and_belongs_to_many :instructeurs has_and_belongs_to_many :procedures has_many :services @@ -23,6 +25,14 @@ class Administrateur < ApplicationRecord 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 :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) Administrateur.find_by(users: { email: email }) end diff --git a/app/models/champ.rb b/app/models/champ.rb index a4ead1f60..da1919a83 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -137,6 +137,23 @@ class Champ < ApplicationRecord "#{html_id}-input" 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 "#{html_id}-label" end @@ -169,6 +186,14 @@ class Champ < ApplicationRecord "#{stable_id}-#{id}" end + def champs_attributes_accessor + if private? + "champs_private_attributes" + else + "champs_attributes" + end + end + def needs_dossier_id? !dossier_id && parent_id end diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index 25ed1700d..929d8f004 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -28,12 +28,15 @@ class Champs::RepetitionChamp < Champ end def add_row + added_champs = [] transaction do row = (blank? ? -1 : champs.last.row) + 1 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 + self.champs << added_champs end + added_champs end def blank? diff --git a/app/views/champs/carte/index.js.erb b/app/views/champs/carte/index.js.erb index 37ef6c3a7..1cd381cd7 100644 --- a/app/views/champs/carte/index.js.erb +++ b/app/views/champs/carte/index.js.erb @@ -1,6 +1,6 @@ <%= 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', locals: { champ: @champ, editing: true }) %> diff --git a/app/views/champs/dossier_link/show.js.erb b/app/views/champs/dossier_link/show.js.erb index dece3758a..cdf84195a 100644 --- a/app/views/champs/dossier_link/show.js.erb +++ b/app/views/champs/dossier_link/show.js.erb @@ -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', - locals: { id: @dossier_id }) %> + locals: { id: @linked_dossier_id }) %> diff --git a/app/views/champs/piece_justificative/show.js.erb b/app/views/champs/piece_justificative/show.js.erb index c8bbaf377..ea8cb9ecb 100644 --- a/app/views/champs/piece_justificative/show.js.erb +++ b/app/views/champs/piece_justificative/show.js.erb @@ -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 %> <% if attachment.virus_scanner.pending? %> diff --git a/app/views/champs/repetition/_show.html.haml b/app/views/champs/repetition/_show.html.haml deleted file mode 100644 index 98ba3f239..000000000 --- a/app/views/champs/repetition/_show.html.haml +++ /dev/null @@ -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 diff --git a/app/views/champs/repetition/show.js.erb b/app/views/champs/repetition/show.js.erb index af552c482..1ac03b11c 100644 --- a/app/views/champs/repetition/show.js.erb +++ b/app/views/champs/repetition/show.js.erb @@ -1,3 +1,3 @@ -<%= append_to_element(".repetition-#{@position}", - partial: 'champs/repetition/show', - locals: { champ: @champ, attribute: @attribute }) %> +<%= fields_for @champ.input_name, @champ do |form| %> + <%= append_to_element("##{@champ.input_group_id} .repetition", partial: 'shared/dossiers/editable_champs/repetition_row', locals: { form: form, champ: @champ, row: @champs }) %> +<% end %> diff --git a/app/views/champs/siret/show.js.erb b/app/views/champs/siret/show.js.erb index f0ab71e4f..85d3c51c9 100644 --- a/app/views/champs/siret/show.js.erb +++ b/app/views/champs/siret/show.js.erb @@ -1,6 +1,3 @@ -<%= render_to_element(".siret-info-#{@position}", +<%= render_to_element("##{@champ.input_group_id} .siret-info", partial: 'shared/champs/siret/etablissement', - locals: { - siret: @siret, - attribute: @attribute, - etablissement: @etablissement }) %> + locals: { siret: @siret, etablissement: @etablissement }) %> diff --git a/app/views/instructeurs/dossiers/annotations_privees.html.haml b/app/views/instructeurs/dossiers/annotations_privees.html.haml index 4d3181b7e..bfa9e05a7 100644 --- a/app/views/instructeurs/dossiers/annotations_privees.html.haml +++ b/app/views/instructeurs/dossiers/annotations_privees.html.haml @@ -6,10 +6,9 @@ - if @dossier.champs_private.present? %section = 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| - - champ = champ_form.object - = render partial: "shared/dossiers/editable_champs/editable_champ", - locals: { champ: champ, form: champ_form, seen_at: @annotations_privees_seen_at } + - @dossier.champs_private.each do |champ| + = fields_for champ.input_name, champ do |form| + = render partial: "shared/dossiers/editable_champs/editable_champ", locals: { form: form, champ: champ, seen_at: @annotations_privees_seen_at } .send-wrapper = f.submit 'Sauvegarder', class: 'button primary send', data: { disable: true } diff --git a/app/views/manager/application/_navigation.html.erb b/app/views/manager/application/_navigation.html.erb index b7879e718..a82853123 100644 --- a/app/views/manager/application/_navigation.html.erb +++ b/app/views/manager/application/_navigation.html.erb @@ -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 "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' %> <% end %> diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index 23a7b1362..cbf21eb76 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -35,10 +35,9 @@ dossier.procedure.groupe_instructeurs.order(:label).map { |gi| [gi.label, gi.id] }, { include_blank: dossier.brouillon? } - = f.fields_for :champs, dossier.champs do |champ_form| - - champ = champ_form.object - = render partial: "shared/dossiers/editable_champs/editable_champ", - locals: { champ: champ, form: champ_form } + - dossier.champs.each do |champ| + = fields_for champ.input_name, champ do |form| + = render partial: "shared/dossiers/editable_champs/editable_champ", locals: { form: form, champ: champ } - if !dossier.for_procedure_preview? .dossier-edit-sticky-footer diff --git a/app/views/shared/dossiers/editable_champs/_carte.html.haml b/app/views/shared/dossiers/editable_champs/_carte.html.haml index 6e7db1be6..41fa07ac7 100644 --- a/app/views/shared/dossiers/editable_champs/_carte.html.haml +++ b/app/views/shared/dossiers/editable_champs/_carte.html.haml @@ -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 = render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ, editing: true } diff --git a/app/views/shared/dossiers/editable_champs/_dossier_link.html.haml b/app/views/shared/dossiers/editable_champs/_dossier_link.html.haml index 2ac650300..f3a72c1ed 100644 --- a/app/views/shared/dossiers/editable_champs/_dossier_link.html.haml +++ b/app/views/shared/dossiers/editable_champs/_dossier_link.html.haml @@ -1,11 +1,11 @@ -.dossier-link{ class: "dossier-link-#{form.index}" } +.dossier-link = form.number_field :value, id: champ.input_id, aria: { describedby: champ.describedby_id }, placeholder: "Numéro de dossier", autocomplete: 'off', 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 = render partial: 'shared/champs/dossier_link/help_block', locals: { id: champ.value } diff --git a/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml b/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml index d374cd0cb..18b47c2df 100644 --- a/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml +++ b/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml @@ -19,4 +19,4 @@ = 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? - = 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 } diff --git a/app/views/shared/dossiers/_drop_down_other_input.html.haml b/app/views/shared/dossiers/editable_champs/_drop_down_other_input.html.haml similarity index 100% rename from app/views/shared/dossiers/_drop_down_other_input.html.haml rename to app/views/shared/dossiers/editable_champs/_drop_down_other_input.html.haml diff --git a/app/views/shared/dossiers/editable_champs/_editable_champ.html.haml b/app/views/shared/dossiers/editable_champs/_editable_champ.html.haml index c449fd4ba..3ce3a193b 100644 --- a/app/views/shared/dossiers/editable_champs/_editable_champ.html.haml +++ b/app/views/shared/dossiers/editable_champs/_editable_champ.html.haml @@ -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 } - if champ.type_champ == "titre_identite" %p.notice Carte nationale d’identité (uniquement le recto), passeport, titre de séjour ou autre justificatif d’identité. 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 } diff --git a/app/views/shared/dossiers/editable_champs/_repetition.html.haml b/app/views/shared/dossiers/editable_champs/_repetition.html.haml index 2f694ce63..010289cb0 100644 --- a/app/views/shared/dossiers/editable_champs/_repetition.html.haml +++ b/app/views/shared/dossiers/editable_champs/_repetition.html.haml @@ -1,24 +1,9 @@ -%div{ class: "repetition-#{form.index}" } +.repetition - champ.rows.each do |champs| - - 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 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 + = render partial: 'shared/dossiers/editable_champs/repetition_row', locals: { form: form, champ: champ, row: champs } - 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 Ajouter un élément pour « #{champ.libelle} » - else diff --git a/app/views/shared/dossiers/editable_champs/_repetition_row.html.haml b/app/views/shared/dossiers/editable_champs/_repetition_row.html.haml new file mode 100644 index 000000000..d416a3665 --- /dev/null +++ b/app/views/shared/dossiers/editable_champs/_repetition_row.html.haml @@ -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 diff --git a/app/views/shared/dossiers/editable_champs/_siret.html.haml b/app/views/shared/dossiers/editable_champs/_siret.html.haml index 2fdcb3e9c..402a20428 100644 --- a/app/views/shared/dossiers/editable_champs/_siret.html.haml +++ b/app/views/shared/dossiers/editable_champs/_siret.html.haml @@ -2,11 +2,11 @@ id: champ.input_id, aria: { describedby: champ.describedby_id }, 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?, pattern: "[0-9]{14}", title: "Le numéro de SIRET doit comporter exactement 14 chiffres" .spinner.right.hidden -.siret-info{ class: "siret-info-#{form.index}" } +.siret-info - if champ.etablissement.present? = render partial: 'shared/dossiers/editable_champs/etablissement_titre', locals: { etablissement: champ.etablissement } diff --git a/config/env.example b/config/env.example index 56276ac9a..b3090af82 100644 --- a/config/env.example +++ b/config/env.example @@ -84,19 +84,12 @@ MATOMO_HOST="matomo.example.org" MAILJET_API_KEY="" MAILJET_SECRET_KEY="" -# Alternate SMTP Provider: SendInBlue -SENDINBLUE_ENABLED="disabled" +# Alternate SMTP Provider: SendInBlue/DoList SENDINBLUE_CLIENT_KEY="" SENDINBLUE_SMTP_KEY="" SENDINBLUE_USER_NAME="" # 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) # When enabled, all emails will be sent using this provider MAILTRAP_ENABLED="disabled" diff --git a/config/env.example.optional b/config/env.example.optional index 08e47f706..e19e0d6b5 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -119,3 +119,12 @@ MATOMO_IFRAME_URL="https://matomo.example.org/index.php?module=CoreAdminHome&act # DOLIST_ACCOUNT_ID="" # DOLIST_NO_REPLY_EMAIL="" # 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" diff --git a/config/environments/production.rb b/config/environments/production.rb index 91b050442..bf8f75a4d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -81,24 +81,8 @@ Rails.application.configure do elsif ENV['MAILCATCHER_ENABLED'] == 'enabled' config.action_mailer.delivery_method = :mailcatcher else - - sendinblue_weigth = case [ENV['SENDINBLUE_ENABLED'] == 'enabled', ENV['SENDINBLUE_BALANCING'] == 'enabled'] - 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 + sendinblue_weigth = ENV.fetch('SENDINBLUE_BALANCING_VALUE') { 0 }.to_i + dolist_weigth = ENV.fetch('DOLIST_BALANCING_VALUE') { 0 }.to_i ActionMailer::Base.add_delivery_method :balancer, BalancerDeliveryMethod config.action_mailer.balancer_settings = { diff --git a/config/initializers/sendinblue.rb b/config/initializers/sendinblue.rb index 206b3905d..379f0b203 100644 --- a/config/initializers/sendinblue.rb +++ b/config/initializers/sendinblue.rb @@ -1,4 +1,4 @@ -if ENV.fetch('SENDINBLUE_ENABLED') == 'enabled' +if ENV.key?('SENDINBLUE_BALANCING_VALUE') require 'sib-api-v3-sdk' ActiveSupport.on_load(:action_mailer) do diff --git a/config/routes.rb b/config/routes.rb index 655a3247e..34400251f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -141,18 +141,17 @@ Rails.application.routes.draw do end namespace :champs do - get ':position/siret', to: 'siret#show', as: :siret - get ':position/dossier_link', to: 'dossier_link#show', as: :dossier_link - post ':position/carte', to: 'carte#show', as: :carte + get ':champ_id/siret', to: 'siret#show', as: :siret + get ':champ_id/dossier_link', to: 'dossier_link#show', as: :dossier_link + 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 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' delete ':champ_id/carte/features/:id', to: 'carte#destroy' - post ':position/repetition', to: 'repetition#show', as: :repetition - put 'piece_justificative/:champ_id', to: 'piece_justificative#update', as: :piece_justificative + put ':champ_id/piece_justificative', to: 'piece_justificative#update', as: :piece_justificative end resources :attachments, only: [:show, :destroy] diff --git a/config/secrets.yml b/config/secrets.yml index 213c41d98..35a8a5c42 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -53,7 +53,7 @@ defaults: &defaults client_secret: <%= ENV['HELPSCOUT_CLIENT_SECRET'] %> webhook_secret: <%= ENV['HELPSCOUT_WEBHOOK_SECRET'] %> sendinblue: - enabled: <%= ENV['SENDINBLUE_ENABLED'] == 'enabled' %> + enabled: <%= ENV.key?('SENDINBLUE_BALANCING_VALUE') %> username: <%= ENV['SENDINBLUE_USER_NAME'] %> client_key: <%= ENV['SENDINBLUE_CLIENT_KEY'] %> smtp_key: <%= ENV['SENDINBLUE_SMTP_KEY'] %> diff --git a/patches/@hotwired+turbo+7.1.0.patch b/patches/@hotwired+turbo+7.1.0.patch deleted file mode 100644 index 8b41c74a0..000000000 --- a/patches/@hotwired+turbo+7.1.0.patch +++ /dev/null @@ -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"; diff --git a/spec/controllers/champs/dossier_link_controller_spec.rb b/spec/controllers/champs/dossier_link_controller_spec.rb index e76610703..319fad897 100644 --- a/spec/controllers/champs/dossier_link_controller_spec.rb +++ b/spec/controllers/champs/dossier_link_controller_spec.rb @@ -1,22 +1,26 @@ describe Champs::DossierLinkController, type: :controller do let(:user) { create(:user) } - let(:procedure) { create(:procedure, :published) } + let(:procedure) { create(:procedure, :published, :with_dossier_link) } describe '#show' do let(:dossier) { create(:dossier, user: user, procedure: procedure) } + let(:champ) { dossier.champs.first } context 'when user is connected' do render_views before { sign_in user } + let(:champs_attributes) do + champ_attributes = [] + champ_attributes[champ.id] = { value: dossier_id } + champ_attributes + end let(:params) do { + champ_id: champ.id, dossier: { - champs_attributes: { - '1' => { value: dossier_id.to_s } - } - }, - position: '1' + champs_attributes: champs_attributes + } } end 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(procedure.libelle) 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 @@ -42,14 +46,14 @@ describe Champs::DossierLinkController, type: :controller do it 'renders error message' do 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 context 'when user is not connected' do before do - get :show, params: { position: '1' }, format: :js, xhr: true + get :show, params: { champ_id: champ.id }, format: :js, xhr: true end it { expect(response.code).to eq('401') } diff --git a/spec/controllers/champs/siret_controller_spec.rb b/spec/controllers/champs/siret_controller_spec.rb index f23d6b1c8..820d4703d 100644 --- a/spec/controllers/champs/siret_controller_spec.rb +++ b/spec/controllers/champs/siret_controller_spec.rb @@ -1,23 +1,22 @@ describe Champs::SiretController, type: :controller do let(:user) { create(:user) } - let(:procedure) do - tdc_siret = build(:type_de_champ_siret, procedure: nil) - create(:procedure, :published, types_de_champ: [tdc_siret]) - end + let(:procedure) { create(:procedure, :published, :with_siret) } describe '#show' do let(:dossier) { create(:dossier, user: user, procedure: procedure) } let(:champ) { dossier.champs.first } + let(:champs_attributes) do + champ_attributes = [] + champ_attributes[champ.id] = { value: siret } + champ_attributes + end let(:params) do { champ_id: champ.id, dossier: { - champs_attributes: { - '1' => { value: siret.to_s } - } - }, - position: '1' + champs_attributes: champs_attributes + } } end let(:siret) { '' } @@ -47,7 +46,7 @@ describe Champs::SiretController, type: :controller do end 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 = ""') end end @@ -120,7 +119,7 @@ describe Champs::SiretController, type: :controller do end 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') } end diff --git a/spec/factories/procedure.rb b/spec/factories/procedure.rb index 8f7268493..ae3aed1e0 100644 --- a/spec/factories/procedure.rb +++ b/spec/factories/procedure.rb @@ -154,6 +154,12 @@ FactoryBot.define do 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 after(:build) do |procedure, _evaluator| build(:type_de_champ_yes_no, procedure: procedure) diff --git a/spec/models/administrateur_spec.rb b/spec/models/administrateur_spec.rb index ce21fb67a..9da4b2c0c 100644 --- a/spec/models/administrateur_spec.rb +++ b/spec/models/administrateur_spec.rb @@ -163,4 +163,34 @@ describe Administrateur, type: :model do 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 diff --git a/spec/system/users/brouillon_spec.rb b/spec/system/users/brouillon_spec.rb index d590b43ed..9ceca4599 100644 --- a/spec/system/users/brouillon_spec.rb +++ b/spec/system/users/brouillon_spec.rb @@ -113,7 +113,7 @@ describe 'The user' do 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') end @@ -124,7 +124,7 @@ describe 'The user' do expect(page).to have_content('Supprimer', count: 2) - within '.row-1' do + within '.repetition .row:first-child' do click_on 'Supprimer l’élément' end