Merge pull request #7147 from betagouv/main

2022-04-12-01
This commit is contained in:
Paul Chavard 2022-04-12 15:28:31 +02:00 committed by GitHub
commit 5b6f6afa20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 950 additions and 481 deletions

View file

@ -59,7 +59,6 @@ gem 'openid_connect'
gem 'pg' gem 'pg'
gem 'phonelib' gem 'phonelib'
gem 'prawn-rails' # PDF Generation gem 'prawn-rails' # PDF Generation
gem 'prawn-svg'
gem 'premailer-rails' gem 'premailer-rails'
gem 'puma' # Use Puma as the app server gem 'puma' # Use Puma as the app server
gem 'pundit' gem 'pundit'

View file

@ -431,7 +431,7 @@ GEM
ruby2_keywords (~> 0.0.1) ruby2_keywords (~> 0.0.1)
netrc (0.11.0) netrc (0.11.0)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.13.3) nokogiri (1.13.4)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
racc (~> 1.4) racc (~> 1.4)
open4 (1.3.4) open4 (1.3.4)
@ -459,9 +459,6 @@ GEM
prawn prawn
prawn-table prawn-table
rails (>= 3.1.0) rails (>= 3.1.0)
prawn-svg (0.31.0)
css_parser (~> 1.6)
prawn (>= 0.11.1, < 3)
prawn-table (0.2.2) prawn-table (0.2.2)
prawn (>= 1.3.0, < 3.0.0) prawn (>= 1.3.0, < 3.0.0)
premailer (1.14.2) premailer (1.14.2)
@ -840,7 +837,6 @@ DEPENDENCIES
pg pg
phonelib phonelib
prawn-rails prawn-rails
prawn-svg
premailer-rails premailer-rails
pry-byebug pry-byebug
puma puma

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -1,16 +1,26 @@
# doc: https://github.com/france-connect/Documentation-AgentConnect # doc: https://github.com/france-connect/Documentation-AgentConnect
class AgentConnect::AgentController < ApplicationController class AgentConnect::AgentController < ApplicationController
before_action :redirect_to_login_if_fc_aborted, only: [:callback] before_action :redirect_to_login_if_fc_aborted, only: [:callback]
before_action :check_state, only: [:callback]
STATE_COOKIE_NAME = :agentConnect_state
NONCE_COOKIE_NAME = :agentConnect_nonce
def index def index
end end
def login def login
redirect_to AgentConnectService.authorization_uri uri, state, nonce = AgentConnectService.authorization_uri
cookies.encrypted[STATE_COOKIE_NAME] = state
cookies.encrypted[NONCE_COOKIE_NAME] = nonce
redirect_to uri
end end
def callback def callback
user_info = AgentConnectService.user_info(params[:code]) user_info = AgentConnectService.user_info(params[:code], cookies.encrypted[NONCE_COOKIE_NAME])
cookies.encrypted[NONCE_COOKIE_NAME] = nil
instructeur = Instructeur.find_by(agent_connect_id: user_info['sub']) instructeur = Instructeur.find_by(agent_connect_id: user_info['sub'])
@ -50,4 +60,13 @@ class AgentConnect::AgentController < ApplicationController
flash.alert = t('errors.messages.france_connect.connexion') flash.alert = t('errors.messages.france_connect.connexion')
redirect_to(new_user_session_path) redirect_to(new_user_session_path)
end end
def check_state
if cookies.encrypted[STATE_COOKIE_NAME] != params[:state]
flash.alert = t('errors.messages.france_connect.connexion')
redirect_to(new_user_session_path)
else
cookies.encrypted[STATE_COOKIE_NAME] = nil
end
end
end end

View file

@ -1,13 +1,14 @@
class API::V2::Context < GraphQL::Query::Context class API::V2::Context < GraphQL::Query::Context
def has_fragment?(name) # This method is used to check if a given fragment is used in the given query.
if self["has_fragment_#{name}"] # We need that in order to maintain backward compatibility for Types de Champ
true # that we extended in later iterations of our schema.
else def has_fragment?(fragment_name)
visitor = HasFragment.new(self.query.selected_operation, name) self[:has_fragment] ||= Hash.new do |hash, fragment_name|
visitor = HasFragment.new(query.document, fragment_name)
visitor.visit visitor.visit
self["has_fragment_#{name}"] = visitor.found hash[fragment_name] = visitor.found
self["has_fragment_#{name}"]
end end
self[:has_fragment][fragment_name]
end end
def internal_use? def internal_use?
@ -36,17 +37,28 @@ class API::V2::Context < GraphQL::Query::Context
self[:authorized][demarche.id] self[:authorized][demarche.id]
end end
# This is a query AST visitor that we use to check
# if a fragment with a given name is used in the given document.
# We check for both inline and standalone fragments.
class HasFragment < GraphQL::Language::Visitor class HasFragment < GraphQL::Language::Visitor
def initialize(document, name) def initialize(document, fragment_name)
super(document) super(document)
@name = name.to_s @fragment_name = fragment_name.to_s
@found = false @found = false
end end
attr_reader :found attr_reader :found
def on_inline_fragment(node, parent) def on_inline_fragment(node, parent)
if node.type.name == @name if node.type.name == @fragment_name
@found = true
end
super
end
def on_fragment_definition(node, parent)
if node.type.name == @fragment_name
@found = true @found = true
end end

View file

@ -1,6 +1,14 @@
export default class Flash { import invariant from 'tiny-invariant';
constructor(isAnnotation) {
this.element = document.querySelector('#flash_messages'); export class Flash {
element: HTMLDivElement;
isAnnotation: boolean;
timeout?: number;
constructor(isAnnotation: boolean) {
const element = document.querySelector<HTMLDivElement>('#flash_messages');
invariant(element, 'Flash element is required');
this.element = element;
this.isAnnotation = isAnnotation; this.isAnnotation = isAnnotation;
} }
success() { success() {
@ -10,13 +18,13 @@ export default class Flash {
this.add('Formulaire enregistré.'); this.add('Formulaire enregistré.');
} }
} }
error(message) { error(message: string) {
this.add(message, true); this.add(message, true);
} }
clear() { clear() {
this.element.innerHTML = ''; this.element.innerHTML = '';
} }
add(message, isError) { add(message: string, isError = false) {
const html = `<div id="flash_message" class="center"> const html = `<div id="flash_message" class="center">
<div class="alert alert-fixed ${ <div class="alert alert-fixed ${
isError ? 'alert-danger' : 'alert-success' isError ? 'alert-danger' : 'alert-success'

View file

@ -1,9 +1,21 @@
import { getJSON } from '@utils'; import { getJSON } from '@utils';
import invariant from 'tiny-invariant';
export default class OperationsQueue { type Operation = {
constructor(baseUrl) { path: string;
method: string;
payload: unknown;
resolve: (value: unknown) => void;
reject: () => void;
};
export class OperationsQueue {
queue: Operation[];
isRunning = false;
baseUrl: string;
constructor(baseUrl: string) {
this.queue = []; this.queue = [];
this.isRunning = false;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
} }
@ -11,6 +23,7 @@ export default class OperationsQueue {
if (this.queue.length > 0) { if (this.queue.length > 0) {
this.isRunning = true; this.isRunning = true;
const operation = this.queue.shift(); const operation = this.queue.shift();
invariant(operation, 'Operation is required');
await this.exec(operation); await this.exec(operation);
this.run(); this.run();
} else { } else {
@ -18,7 +31,7 @@ export default class OperationsQueue {
} }
} }
enqueue(operation) { enqueue(operation: Omit<Operation, 'resolve' | 'reject'>) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.queue.push({ ...operation, resolve, reject }); this.queue.push({ ...operation, resolve, reject });
if (!this.isRunning) { if (!this.isRunning) {
@ -27,7 +40,7 @@ export default class OperationsQueue {
}); });
} }
async exec(operation) { async exec(operation: Operation) {
const { path, method, payload, resolve, reject } = operation; const { path, method, payload, resolve, reject } = operation;
const url = `${this.baseUrl}${path}`; const url = `${this.baseUrl}${path}`;
@ -35,12 +48,19 @@ export default class OperationsQueue {
const data = await getJSON(url, payload, method); const data = await getJSON(url, payload, method);
resolve(data); resolve(data);
} catch (e) { } catch (e) {
handleError(e, reject); handleError(e as OperationError, reject);
} }
} }
} }
async function handleError({ response, message }, reject) { class OperationError extends Error {
response?: Response;
}
async function handleError(
{ response, message }: OperationError,
reject: (error: string) => void
) {
if (response) { if (response) {
try { try {
const { const {

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function DescriptionInput({ isVisible, handler }) { import type { Handler } from '../types';
export function DescriptionInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLTextAreaElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell"> <div className="cell">
@ -20,10 +27,3 @@ function DescriptionInput({ isVisible, handler }) {
} }
return null; return null;
} }
DescriptionInput.propTypes = {
isVisible: PropTypes.bool,
handler: PropTypes.object
};
export default DescriptionInput;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function LibelleInput({ isVisible, handler }) { import type { Handler } from '../types';
export function LibelleInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell libelle"> <div className="cell libelle">
@ -19,10 +26,3 @@ function LibelleInput({ isVisible, handler }) {
} }
return null; return null;
} }
LibelleInput.propTypes = {
handler: PropTypes.object,
isVisible: PropTypes.bool
};
export default LibelleInput;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function MandatoryInput({ isVisible, handler }) { import type { Handler } from '../types';
export function MandatoryInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell"> <div className="cell">
@ -19,10 +26,3 @@ function MandatoryInput({ isVisible, handler }) {
} }
return null; return null;
} }
MandatoryInput.propTypes = {
handler: PropTypes.object,
isVisible: PropTypes.bool
};
export default MandatoryInput;

View file

@ -1,8 +1,17 @@
import React from 'react'; import React, { MouseEventHandler } from 'react';
import PropTypes from 'prop-types';
import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/solid'; import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/solid';
function MoveButton({ isEnabled, icon, title, onClick }) { export function MoveButton({
isEnabled,
icon,
title,
onClick
}: {
isEnabled: boolean;
icon: string;
title: string;
onClick: MouseEventHandler<HTMLButtonElement>;
}) {
return ( return (
<button <button
className="button small move" className="button small move"
@ -18,12 +27,3 @@ function MoveButton({ isEnabled, icon, title, onClick }) {
</button> </button>
); );
} }
MoveButton.propTypes = {
isEnabled: PropTypes.bool,
icon: PropTypes.string,
title: PropTypes.string,
onClick: PropTypes.func
};
export default MoveButton;

View file

@ -1,24 +1,40 @@
import React from 'react'; import React, { Dispatch } from 'react';
import PropTypes from 'prop-types'; import { SortableElement, SortableHandle } from 'react-sortable-hoc';
import { sortableElement, sortableHandle } from 'react-sortable-hoc';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { TrashIcon } from '@heroicons/react/outline'; import { TrashIcon } from '@heroicons/react/outline';
import DescriptionInput from './DescriptionInput'; import type { Action, TypeDeChamp, State, Handler } from '../types';
import LibelleInput from './LibelleInput'; import { DescriptionInput } from './DescriptionInput';
import MandatoryInput from './MandatoryInput'; import { LibelleInput } from './LibelleInput';
import MoveButton from './MoveButton'; import { MandatoryInput } from './MandatoryInput';
import TypeDeChampCarteOption from './TypeDeChampCarteOption'; import { MoveButton } from './MoveButton';
import TypeDeChampCarteOptions from './TypeDeChampCarteOptions'; import { TypeDeChampCarteOption } from './TypeDeChampCarteOption';
import TypeDeChampDropDownOptions from './TypeDeChampDropDownOptions'; import { TypeDeChampCarteOptions } from './TypeDeChampCarteOptions';
import TypeDeChampDropDownOther from './TypeDeChampDropDownOther'; import { TypeDeChampDropDownOptions } from './TypeDeChampDropDownOptions';
import TypeDeChampPieceJustificative from './TypeDeChampPieceJustificative'; import { TypeDeChampDropDownOther } from './TypeDeChampDropDownOther';
import TypeDeChampRepetitionOptions from './TypeDeChampRepetitionOptions'; import { TypeDeChampPieceJustificative } from './TypeDeChampPieceJustificative';
import TypeDeChampTypesSelect from './TypeDeChampTypesSelect'; import { TypeDeChampRepetitionOptions } from './TypeDeChampRepetitionOptions';
import TypeDeChampDropDownSecondary from './TypeDeChampDropDownSecondary'; import { TypeDeChampTypesSelect } from './TypeDeChampTypesSelect';
import { TypeDeChampDropDownSecondary } from './TypeDeChampDropDownSecondary';
const TypeDeChamp = sortableElement( type TypeDeChampProps = {
({ typeDeChamp, dispatch, idx: index, isFirstItem, isLastItem, state }) => { typeDeChamp: TypeDeChamp;
dispatch: Dispatch<Action>;
idx: number;
isFirstItem: boolean;
isLastItem: boolean;
state: State;
};
export const TypeDeChampComponent = SortableElement<TypeDeChampProps>(
({
typeDeChamp,
dispatch,
idx: index,
isFirstItem,
isLastItem,
state
}: TypeDeChampProps) => {
const isDropDown = [ const isDropDown = [
'drop_down_list', 'drop_down_list',
'multiple_drop_down_list', 'multiple_drop_down_list',
@ -175,23 +191,25 @@ const TypeDeChamp = sortableElement(
} }
); );
TypeDeChamp.propTypes = { const DragHandle = SortableHandle(() => (
dispatch: PropTypes.func,
idx: PropTypes.number,
isFirstItem: PropTypes.bool,
isLastItem: PropTypes.bool,
state: PropTypes.object,
typeDeChamp: PropTypes.object
};
const DragHandle = sortableHandle(() => (
<div <div
className="handle small icon-only icon move-handle" className="handle small icon-only icon move-handle"
title="Déplacer le champ vers le haut ou vers le bas" title="Déplacer le champ vers le haut ou vers le bas"
/> />
)); ));
function createUpdateHandler(dispatch, typeDeChamp, field, index, prefix) { type HandlerInputElement =
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement;
function createUpdateHandler(
dispatch: Dispatch<Action>,
typeDeChamp: TypeDeChamp,
field: keyof TypeDeChamp,
index: number,
prefix?: string
): Handler<HandlerInputElement> {
return { return {
id: `${prefix ? `${prefix}-` : ''}champ-${index}-${field}`, id: `${prefix ? `${prefix}-` : ''}champ-${index}-${field}`,
name: field, name: field,
@ -209,25 +227,22 @@ function createUpdateHandler(dispatch, typeDeChamp, field, index, prefix) {
}; };
} }
function getValue(obj, path) { function createUpdateHandlers(
const [, optionsPath] = path.split('.'); dispatch: Dispatch<Action>,
if (optionsPath) { typeDeChamp: TypeDeChamp,
return (obj.editable_options || {})[optionsPath]; index: number,
} prefix?: string
return obj[path]; ) {
}
function createUpdateHandlers(dispatch, typeDeChamp, index, prefix) {
return FIELDS.reduce((handlers, field) => { return FIELDS.reduce((handlers, field) => {
handlers[field] = createUpdateHandler( handlers[field] = createUpdateHandler(
dispatch, dispatch,
typeDeChamp, typeDeChamp,
field, field as keyof TypeDeChamp,
index, index,
prefix prefix
); );
return handlers; return handlers;
}, {}); }, {} as Record<string, Handler<HandlerInputElement>>);
} }
const OPTIONS_FIELDS = { const OPTIONS_FIELDS = {
@ -242,7 +257,7 @@ const OPTIONS_FIELDS = {
'options.natura_2000': 'Natura 2000', 'options.natura_2000': 'Natura 2000',
'options.zones_humides': 'Zones humides dimportance internationale', 'options.zones_humides': 'Zones humides dimportance internationale',
'options.znieff': 'ZNIEFF' 'options.znieff': 'ZNIEFF'
}; } as const;
export const FIELDS = [ export const FIELDS = [
'description', 'description',
@ -257,10 +272,20 @@ export const FIELDS = [
'drop_down_secondary_libelle', 'drop_down_secondary_libelle',
'drop_down_secondary_description', 'drop_down_secondary_description',
...Object.keys(OPTIONS_FIELDS) ...Object.keys(OPTIONS_FIELDS)
]; ] as const;
function readValue(input) { function getValue(obj: TypeDeChamp, path: string) {
return input.type === 'checkbox' ? input.checked : input.value; const [, optionsPath] = path.split('.');
if (optionsPath) {
return (obj.editable_options || {})[optionsPath];
}
return obj[path as keyof TypeDeChamp] as string;
}
function readValue(input: HandlerInputElement) {
return input.type === 'checkbox' && 'checked' in input
? input.checked
: input.value;
} }
const EXCLUDE_FROM_REPETITION = [ const EXCLUDE_FROM_REPETITION = [
@ -269,5 +294,3 @@ const EXCLUDE_FROM_REPETITION = [
'repetition', 'repetition',
'siret' 'siret'
]; ];
export default TypeDeChamp;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampCarteOption({ label, handler }) { import type { Handler } from '../types';
export function TypeDeChampCarteOption({
label,
handler
}: {
label: string;
handler: Handler<HTMLInputElement>;
}) {
return ( return (
<label htmlFor={handler.id}> <label htmlFor={handler.id}>
<input <input
@ -16,10 +23,3 @@ function TypeDeChampCarteOption({ label, handler }) {
</label> </label>
); );
} }
TypeDeChampCarteOption.propTypes = {
label: PropTypes.string,
handler: PropTypes.object
};
export default TypeDeChampCarteOption;

View file

@ -1,21 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampCarteOptions({ isVisible, children }) {
if (isVisible) {
return (
<div className="cell">
<label>Utilisation de la cartographie</label>
<div className="carte-options">{children}</div>
</div>
);
}
return null;
}
TypeDeChampCarteOptions.propTypes = {
isVisible: PropTypes.bool,
children: PropTypes.node
};
export default TypeDeChampCarteOptions;

View file

@ -0,0 +1,19 @@
import React, { ReactNode } from 'react';
export function TypeDeChampCarteOptions({
isVisible,
children
}: {
isVisible: boolean;
children: ReactNode;
}) {
if (isVisible) {
return (
<div className="cell">
<label>Utilisation de la cartographie</label>
<div className="carte-options">{children}</div>
</div>
);
}
return null;
}

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampDropDownOptions({ isVisible, handler }) { import type { Handler } from '../types';
export function TypeDeChampDropDownOptions({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLTextAreaElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell"> <div className="cell">
@ -21,11 +28,3 @@ function TypeDeChampDropDownOptions({ isVisible, handler }) {
} }
return null; return null;
} }
TypeDeChampDropDownOptions.propTypes = {
isVisible: PropTypes.bool,
value: PropTypes.string,
handler: PropTypes.object
};
export default TypeDeChampDropDownOptions;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampDropDownOther({ isVisible, handler }) { import type { Handler } from '../types';
export function TypeDeChampDropDownOther({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell"> <div className="cell">
@ -21,10 +28,3 @@ function TypeDeChampDropDownOther({ isVisible, handler }) {
} }
return null; return null;
} }
TypeDeChampDropDownOther.propTypes = {
isVisible: PropTypes.bool,
handler: PropTypes.object
};
export default TypeDeChampDropDownOther;

View file

@ -1,10 +1,15 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
export default function TypeDeChampDropDownSecondary({ import type { Handler } from '../types';
export function TypeDeChampDropDownSecondary({
isVisible, isVisible,
libelleHandler, libelleHandler,
descriptionHandler descriptionHandler
}: {
isVisible: boolean;
libelleHandler: Handler<HTMLInputElement>;
descriptionHandler: Handler<HTMLTextAreaElement>;
}) { }) {
if (isVisible) { if (isVisible) {
return ( return (
@ -33,9 +38,3 @@ export default function TypeDeChampDropDownSecondary({
} }
return null; return null;
} }
TypeDeChampDropDownSecondary.propTypes = {
isVisible: PropTypes.bool,
libelleHandler: PropTypes.object,
descriptionHandler: PropTypes.object
};

View file

@ -1,13 +1,20 @@
import React from 'react'; import React, { ChangeEvent } from 'react';
import PropTypes from 'prop-types';
import Uploader from '../../../shared/activestorage/uploader';
function TypeDeChampPieceJustificative({ import Uploader from '../../../shared/activestorage/uploader';
import type { Handler } from '../types';
export function TypeDeChampPieceJustificative({
isVisible, isVisible,
url, url,
filename, filename,
handler, handler,
directUploadUrl directUploadUrl
}: {
isVisible: boolean;
url?: string;
filename?: string;
handler: Handler<HTMLInputElement>;
directUploadUrl: string;
}) { }) {
if (isVisible) { if (isVisible) {
const hasFile = !!filename; const hasFile = !!filename;
@ -28,15 +35,15 @@ function TypeDeChampPieceJustificative({
return null; return null;
} }
TypeDeChampPieceJustificative.propTypes = { function FileInformation({
isVisible: PropTypes.bool, isVisible,
url: PropTypes.string, url,
filename: PropTypes.string, filename
handler: PropTypes.object, }: {
directUploadUrl: PropTypes.string isVisible: boolean;
}; url?: string;
filename?: string;
function FileInformation({ isVisible, url, filename }) { }) {
if (isVisible) { if (isVisible) {
return ( return (
<> <>
@ -50,32 +57,29 @@ function FileInformation({ isVisible, url, filename }) {
return null; return null;
} }
FileInformation.propTypes = { function onFileChange(
isVisible: PropTypes.bool, handler: Handler<HTMLInputElement>,
url: PropTypes.string, directUploadUrl: string
filename: PropTypes.string ): (event: ChangeEvent<HTMLInputElement>) => void {
};
function onFileChange(handler, directUploadUrl) {
return async ({ target }) => { return async ({ target }) => {
const file = target.files[0]; const file = (target.files ?? [])[0];
if (file) { if (file) {
const signedId = await uploadFile(target, file, directUploadUrl); const signedId = await uploadFile(target, file, directUploadUrl);
handler.onChange({ handler.onChange({
target: { target: { value: signedId }
value: signedId } as ChangeEvent<HTMLInputElement>);
}
});
} }
}; };
} }
function uploadFile(input, file, directUploadUrl) { function uploadFile(
input: HTMLInputElement,
file: File,
directUploadUrl: string
) {
const controller = new Uploader(input, file, directUploadUrl); const controller = new Uploader(input, file, directUploadUrl);
return controller.start().then((signedId) => { return controller.start().then((signedId) => {
input.value = null; input.value = '';
return signedId; return signedId;
}); });
} }
export default TypeDeChampPieceJustificative;

View file

@ -1,15 +1,19 @@
import React, { useReducer } from 'react'; import React, { useReducer } from 'react';
import PropTypes from 'prop-types';
import { PlusIcon } from '@heroicons/react/outline'; import { PlusIcon } from '@heroicons/react/outline';
import { SortableContainer, addChampLabel } from '../utils'; import { SortableContainer, addChampLabel } from '../utils';
import TypeDeChamp from './TypeDeChamp'; import { TypeDeChampComponent } from './TypeDeChamp';
import typeDeChampsReducer from '../typeDeChampsReducer'; import typeDeChampsReducer from '../typeDeChampsReducer';
import type { State, TypeDeChamp } from '../types';
function TypeDeChampRepetitionOptions({ export function TypeDeChampRepetitionOptions({
isVisible, isVisible,
state: parentState, state: parentState,
typeDeChamp typeDeChamp
}: {
isVisible: boolean;
state: State;
typeDeChamp: TypeDeChamp;
}) { }) {
const [state, dispatch] = useReducer(typeDeChampsReducer, parentState); const [state, dispatch] = useReducer(typeDeChampsReducer, parentState);
@ -23,7 +27,7 @@ function TypeDeChampRepetitionOptions({
useDragHandle useDragHandle
> >
{state.typeDeChamps.map((typeDeChamp, index) => ( {state.typeDeChamps.map((typeDeChamp, index) => (
<TypeDeChamp <TypeDeChampComponent
dispatch={dispatch} dispatch={dispatch}
idx={index} idx={index}
index={index} index={index}
@ -54,11 +58,3 @@ function TypeDeChampRepetitionOptions({
} }
return null; return null;
} }
TypeDeChampRepetitionOptions.propTypes = {
isVisible: PropTypes.bool,
state: PropTypes.object,
typeDeChamp: PropTypes.object
};
export default TypeDeChampRepetitionOptions;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampTypesSelect({ handler, options }) { import type { Handler } from '../types';
export function TypeDeChampTypesSelect({
handler,
options
}: {
handler: Handler<HTMLSelectElement>;
options: [label: string, type: string][];
}) {
return ( return (
<div className="cell"> <div className="cell">
<select <select
@ -20,10 +27,3 @@ function TypeDeChampTypesSelect({ handler, options }) {
</div> </div>
); );
} }
TypeDeChampTypesSelect.propTypes = {
handler: PropTypes.object,
options: PropTypes.array
};
export default TypeDeChampTypesSelect;

View file

@ -1,12 +1,20 @@
import React, { useReducer } from 'react'; import React, { useReducer } from 'react';
import PropTypes from 'prop-types';
import { PlusIcon, ArrowCircleDownIcon } from '@heroicons/react/outline'; import { PlusIcon, ArrowCircleDownIcon } from '@heroicons/react/outline';
import { SortableContainer, addChampLabel } from '../utils'; import { SortableContainer, addChampLabel } from '../utils';
import TypeDeChamp from './TypeDeChamp'; import { TypeDeChampComponent } from './TypeDeChamp';
import typeDeChampsReducer from '../typeDeChampsReducer'; import typeDeChampsReducer from '../typeDeChampsReducer';
import type { TypeDeChamp, State } from '../types';
function TypeDeChamps({ state: rootState, typeDeChamps }) { type TypeDeChampsProps = {
state: State;
typeDeChamps: TypeDeChamp[];
};
export function TypeDeChamps({
state: rootState,
typeDeChamps
}: TypeDeChampsProps) {
const [state, dispatch] = useReducer(typeDeChampsReducer, { const [state, dispatch] = useReducer(typeDeChampsReducer, {
...rootState, ...rootState,
typeDeChamps typeDeChamps
@ -24,7 +32,7 @@ function TypeDeChamps({ state: rootState, typeDeChamps }) {
useDragHandle useDragHandle
> >
{state.typeDeChamps.map((typeDeChamp, index) => ( {state.typeDeChamps.map((typeDeChamp, index) => (
<TypeDeChamp <TypeDeChampComponent
dispatch={dispatch} dispatch={dispatch}
idx={index} idx={index}
index={index} index={index}
@ -67,10 +75,3 @@ function TypeDeChamps({ state: rootState, typeDeChamps }) {
</div> </div>
); );
} }
TypeDeChamps.propTypes = {
state: PropTypes.object,
typeDeChamps: PropTypes.array
};
export default TypeDeChamps;

View file

@ -1,46 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Flash from './Flash';
import OperationsQueue from './OperationsQueue';
import TypeDeChamps from './components/TypeDeChamps';
class TypesDeChampEditor extends Component {
constructor(props) {
super(props);
const defaultTypeDeChampAttributes = {
type_champ: 'text',
types_de_champ: [],
private: props.isAnnotation,
libelle: `${
props.isAnnotation ? 'Nouvelle annotation' : 'Nouveau champ'
} ${props.typeDeChampsTypes[0][0]}`
};
this.state = {
flash: new Flash(props.isAnnotation),
queue: new OperationsQueue(props.baseUrl),
defaultTypeDeChampAttributes,
typeDeChampsTypes: props.typeDeChampsTypes,
directUploadUrl: props.directUploadUrl,
isAnnotation: props.isAnnotation,
continuerUrl: props.continuerUrl
};
}
render() {
return (
<TypeDeChamps state={this.state} typeDeChamps={this.props.typeDeChamps} />
);
}
}
TypesDeChampEditor.propTypes = {
baseUrl: PropTypes.string,
continuerUrl: PropTypes.string,
directUploadUrl: PropTypes.string,
isAnnotation: PropTypes.bool,
typeDeChamps: PropTypes.array,
typeDeChampsTypes: PropTypes.array
};
export default TypesDeChampEditor;

View file

@ -0,0 +1,54 @@
import React from 'react';
import { Flash } from './Flash';
import { OperationsQueue } from './OperationsQueue';
import { TypeDeChamps } from './components/TypeDeChamps';
import { TypeDeChamp } from './types';
type TypesDeChampEditorProps = {
baseUrl: string;
continuerUrl: string;
directUploadUrl: string;
isAnnotation: boolean;
typeDeChamps: TypeDeChamp[];
typeDeChampsTypes: [label: string, type: string][];
};
export type State = Omit<TypesDeChampEditorProps, 'baseUrl'> & {
flash: Flash;
queue: OperationsQueue;
defaultTypeDeChampAttributes: Pick<
TypeDeChamp,
| 'type_champ'
| 'types_de_champ'
| 'libelle'
| 'private'
| 'parent_id'
| 'mandatory'
>;
prefix?: string;
};
export default function TypesDeChampEditor(props: TypesDeChampEditorProps) {
const defaultTypeDeChampAttributes: Omit<TypeDeChamp, 'id'> = {
type_champ: 'text',
types_de_champ: [],
mandatory: false,
private: props.isAnnotation,
libelle: `${props.isAnnotation ? 'Nouvelle annotation' : 'Nouveau champ'} ${
props.typeDeChampsTypes[0][0]
}`
};
const state: State = {
flash: new Flash(props.isAnnotation),
queue: new OperationsQueue(props.baseUrl),
defaultTypeDeChampAttributes,
typeDeChamps: [],
typeDeChampsTypes: props.typeDeChampsTypes,
directUploadUrl: props.directUploadUrl,
isAnnotation: props.isAnnotation,
continuerUrl: props.continuerUrl
};
return <TypeDeChamps state={state} typeDeChamps={props.typeDeChamps} />;
}

View file

@ -1,51 +0,0 @@
export function createTypeDeChampOperation(typeDeChamp, queue) {
return queue
.enqueue({
path: '',
method: 'post',
payload: { type_de_champ: typeDeChamp }
})
.then((data) => {
handleResponseData(typeDeChamp, data);
});
}
export function destroyTypeDeChampOperation(typeDeChamp, queue) {
return queue.enqueue({
path: `/${typeDeChamp.id}`,
method: 'delete',
payload: {}
});
}
export function moveTypeDeChampOperation(typeDeChamp, index, queue) {
return queue.enqueue({
path: `/${typeDeChamp.id}/move`,
method: 'patch',
payload: { position: index }
});
}
export function updateTypeDeChampOperation(typeDeChamp, queue) {
return queue
.enqueue({
path: `/${typeDeChamp.id}`,
method: 'patch',
payload: { type_de_champ: typeDeChamp }
})
.then((data) => {
handleResponseData(typeDeChamp, data);
});
}
function handleResponseData(typeDeChamp, { type_de_champ }) {
for (let field of RESPONSE_FIELDS) {
typeDeChamp[field] = type_de_champ[field];
}
}
const RESPONSE_FIELDS = [
'id',
'piece_justificative_template_filename',
'piece_justificative_template_url'
];

View file

@ -0,0 +1,71 @@
import type { TypeDeChamp, OperationsQueue } from './types';
export function createTypeDeChampOperation(
typeDeChamp: Omit<TypeDeChamp, 'id'>,
queue: OperationsQueue
) {
return queue
.enqueue({
path: '',
method: 'post',
payload: { type_de_champ: typeDeChamp }
})
.then((data) => {
handleResponseData(typeDeChamp, data as ResponseData);
});
}
export function destroyTypeDeChampOperation(
typeDeChamp: TypeDeChamp,
queue: OperationsQueue
) {
return queue.enqueue({
path: `/${typeDeChamp.id}`,
method: 'delete',
payload: {}
});
}
export function moveTypeDeChampOperation(
typeDeChamp: TypeDeChamp,
index: number,
queue: OperationsQueue
) {
return queue.enqueue({
path: `/${typeDeChamp.id}/move`,
method: 'patch',
payload: { position: index }
});
}
export function updateTypeDeChampOperation(
typeDeChamp: TypeDeChamp,
queue: OperationsQueue
) {
return queue
.enqueue({
path: `/${typeDeChamp.id}`,
method: 'patch',
payload: { type_de_champ: typeDeChamp }
})
.then((data) => {
handleResponseData(typeDeChamp, data as ResponseData);
});
}
type ResponseData = { type_de_champ: Record<string, string> };
function handleResponseData(
typeDeChamp: Partial<TypeDeChamp>,
{ type_de_champ }: ResponseData
) {
for (const field of RESPONSE_FIELDS) {
typeDeChamp[field] = type_de_champ[field];
}
}
const RESPONSE_FIELDS = [
'id',
'piece_justificative_template_filename',
'piece_justificative_template_url'
] as const;

View file

@ -5,36 +5,103 @@ import {
moveTypeDeChampOperation, moveTypeDeChampOperation,
updateTypeDeChampOperation updateTypeDeChampOperation
} from './operations'; } from './operations';
import type { TypeDeChamp, State, Flash, OperationsQueue } from './types';
export default function typeDeChampsReducer(state, { type, params, done }) { type AddNewTypeDeChampAction = {
switch (type) { type: 'addNewTypeDeChamp';
done: () => void;
};
type AddNewRepetitionTypeDeChampAction = {
type: 'addNewRepetitionTypeDeChamp';
params: { typeDeChamp: TypeDeChamp };
done: () => void;
};
type UpdateTypeDeChampAction = {
type: 'updateTypeDeChamp';
params: {
typeDeChamp: TypeDeChamp;
field: keyof TypeDeChamp;
value: string | boolean;
};
done: () => void;
};
type RemoveTypeDeChampAction = {
type: 'removeTypeDeChamp';
params: { typeDeChamp: TypeDeChamp };
};
type MoveTypeDeChampUpAction = {
type: 'moveTypeDeChampUp';
params: { typeDeChamp: TypeDeChamp };
};
type MoveTypeDeChampDownAction = {
type: 'moveTypeDeChampDown';
params: { typeDeChamp: TypeDeChamp };
};
type OnSortTypeDeChampsAction = {
type: 'onSortTypeDeChamps';
params: { oldIndex: number; newIndex: number };
};
type RefreshAction = {
type: 'refresh';
};
export type Action =
| AddNewTypeDeChampAction
| AddNewRepetitionTypeDeChampAction
| UpdateTypeDeChampAction
| RemoveTypeDeChampAction
| MoveTypeDeChampUpAction
| MoveTypeDeChampDownAction
| OnSortTypeDeChampsAction
| RefreshAction;
export default function typeDeChampsReducer(
state: State,
action: Action
): State {
switch (action.type) {
case 'addNewTypeDeChamp': case 'addNewTypeDeChamp':
return addNewTypeDeChamp(state, state.typeDeChamps, done); return addNewTypeDeChamp(state, state.typeDeChamps, action.done);
case 'addNewRepetitionTypeDeChamp': case 'addNewRepetitionTypeDeChamp':
return addNewRepetitionTypeDeChamp( return addNewRepetitionTypeDeChamp(
state, state,
state.typeDeChamps, state.typeDeChamps,
params.typeDeChamp, action.params,
done action.done
); );
case 'updateTypeDeChamp': case 'updateTypeDeChamp':
return updateTypeDeChamp(state, state.typeDeChamps, params, done); return updateTypeDeChamp(
state,
state.typeDeChamps,
action.params,
action.done
);
case 'removeTypeDeChamp': case 'removeTypeDeChamp':
return removeTypeDeChamp(state, state.typeDeChamps, params); return removeTypeDeChamp(state, state.typeDeChamps, action.params);
case 'moveTypeDeChampUp': case 'moveTypeDeChampUp':
return moveTypeDeChampUp(state, state.typeDeChamps, params); return moveTypeDeChampUp(state, state.typeDeChamps, action.params);
case 'moveTypeDeChampDown': case 'moveTypeDeChampDown':
return moveTypeDeChampDown(state, state.typeDeChamps, params); return moveTypeDeChampDown(state, state.typeDeChamps, action.params);
case 'onSortTypeDeChamps': case 'onSortTypeDeChamps':
return onSortTypeDeChamps(state, state.typeDeChamps, params); return onSortTypeDeChamps(state, state.typeDeChamps, action.params);
case 'refresh': case 'refresh':
return { ...state, typeDeChamps: [...state.typeDeChamps] }; return { ...state, typeDeChamps: [...state.typeDeChamps] };
default:
throw new Error(`Unknown action "${type}"`);
} }
} }
function addTypeDeChamp(state, typeDeChamps, insertAfter, done) { function addTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
insertAfter: { index: number; target: HTMLDivElement } | null,
done: () => void
) {
const typeDeChamp = { const typeDeChamp = {
...state.defaultTypeDeChampAttributes ...state.defaultTypeDeChampAttributes
}; };
@ -44,7 +111,7 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) {
if (insertAfter) { if (insertAfter) {
// Move the champ to the correct position server-side // Move the champ to the correct position server-side
await moveTypeDeChampOperation( await moveTypeDeChampOperation(
typeDeChamp, typeDeChamp as TypeDeChamp,
insertAfter.index, insertAfter.index,
state.queue state.queue
); );
@ -52,7 +119,7 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) {
state.flash.success(); state.flash.success();
done(); done();
if (insertAfter) { if (insertAfter) {
insertAfter.target.nextElementSibling.scrollIntoView({ insertAfter.target.nextElementSibling?.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'start', block: 'start',
inline: 'nearest' inline: 'nearest'
@ -61,7 +128,10 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) {
}) })
.catch((message) => state.flash.error(message)); .catch((message) => state.flash.error(message));
let newTypeDeChamps = [...typeDeChamps, typeDeChamp]; let newTypeDeChamps: TypeDeChamp[] = [
...typeDeChamps,
typeDeChamp as TypeDeChamp
];
if (insertAfter) { if (insertAfter) {
// Move the champ to the correct position client-side // Move the champ to the correct position client-side
newTypeDeChamps = arrayMove( newTypeDeChamps = arrayMove(
@ -77,11 +147,20 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) {
}; };
} }
function addNewTypeDeChamp(state, typeDeChamps, done) { function addNewTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
done: () => void
) {
return addTypeDeChamp(state, typeDeChamps, findItemToInsertAfter(), done); return addTypeDeChamp(state, typeDeChamps, findItemToInsertAfter(), done);
} }
function addNewRepetitionTypeDeChamp(state, typeDeChamps, typeDeChamp, done) { function addNewRepetitionTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: AddNewRepetitionTypeDeChampAction['params'],
done: () => void
) {
return addTypeDeChamp( return addTypeDeChamp(
{ {
...state, ...state,
@ -97,10 +176,10 @@ function addNewRepetitionTypeDeChamp(state, typeDeChamps, typeDeChamp, done) {
} }
function updateTypeDeChamp( function updateTypeDeChamp(
state, state: State,
typeDeChamps, typeDeChamps: TypeDeChamp[],
{ typeDeChamp, field, value }, { typeDeChamp, field, value }: UpdateTypeDeChampAction['params'],
done done: () => void
) { ) {
if (field == 'type_champ' && !typeDeChamp.drop_down_list_value) { if (field == 'type_champ' && !typeDeChamp.drop_down_list_value) {
switch (value) { switch (value) {
@ -117,9 +196,9 @@ function updateTypeDeChamp(
if (field.startsWith('options.')) { if (field.startsWith('options.')) {
const [, optionsField] = field.split('.'); const [, optionsField] = field.split('.');
typeDeChamp.editable_options = typeDeChamp.editable_options || {}; typeDeChamp.editable_options = typeDeChamp.editable_options || {};
typeDeChamp.editable_options[optionsField] = value; typeDeChamp.editable_options[optionsField] = value as string;
} else { } else {
typeDeChamp[field] = value; Object.assign(typeDeChamp, { [field]: value });
} }
getUpdateHandler(typeDeChamp, state)(done); getUpdateHandler(typeDeChamp, state)(done);
@ -130,7 +209,11 @@ function updateTypeDeChamp(
}; };
} }
function removeTypeDeChamp(state, typeDeChamps, { typeDeChamp }) { function removeTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: RemoveTypeDeChampAction['params']
) {
destroyTypeDeChampOperation(typeDeChamp, state.queue) destroyTypeDeChampOperation(typeDeChamp, state.queue)
.then(() => state.flash.success()) .then(() => state.flash.success())
.catch((message) => state.flash.error(message)); .catch((message) => state.flash.error(message));
@ -141,7 +224,11 @@ function removeTypeDeChamp(state, typeDeChamps, { typeDeChamp }) {
}; };
} }
function moveTypeDeChampUp(state, typeDeChamps, { typeDeChamp }) { function moveTypeDeChampUp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: MoveTypeDeChampUpAction['params']
) {
const oldIndex = typeDeChamps.indexOf(typeDeChamp); const oldIndex = typeDeChamps.indexOf(typeDeChamp);
const newIndex = oldIndex - 1; const newIndex = oldIndex - 1;
@ -155,7 +242,11 @@ function moveTypeDeChampUp(state, typeDeChamps, { typeDeChamp }) {
}; };
} }
function moveTypeDeChampDown(state, typeDeChamps, { typeDeChamp }) { function moveTypeDeChampDown(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: MoveTypeDeChampDownAction['params']
) {
const oldIndex = typeDeChamps.indexOf(typeDeChamp); const oldIndex = typeDeChamps.indexOf(typeDeChamp);
const newIndex = oldIndex + 1; const newIndex = oldIndex + 1;
@ -169,7 +260,11 @@ function moveTypeDeChampDown(state, typeDeChamps, { typeDeChamp }) {
}; };
} }
function onSortTypeDeChamps(state, typeDeChamps, { oldIndex, newIndex }) { function onSortTypeDeChamps(
state: State,
typeDeChamps: TypeDeChamp[],
{ oldIndex, newIndex }: OnSortTypeDeChampsAction['params']
) {
moveTypeDeChampOperation(typeDeChamps[oldIndex], newIndex, state.queue) moveTypeDeChampOperation(typeDeChamps[oldIndex], newIndex, state.queue)
.then(() => state.flash.success()) .then(() => state.flash.success())
.catch((message) => state.flash.error(message)); .catch((message) => state.flash.error(message));
@ -180,24 +275,27 @@ function onSortTypeDeChamps(state, typeDeChamps, { oldIndex, newIndex }) {
}; };
} }
function arrayRemove(array, item) { function arrayRemove<T>(array: T[], item: T) {
array = Array.from(array); array = Array.from(array);
array.splice(array.indexOf(item), 1); array.splice(array.indexOf(item), 1);
return array; return array;
} }
function arrayMove(array, from, to) { function arrayMove<T>(array: T[], from: number, to: number) {
array = Array.from(array); array = Array.from(array);
array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]); array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]);
return array; return array;
} }
const updateHandlers = new WeakMap(); const updateHandlers = new WeakMap();
function getUpdateHandler(typeDeChamp, { queue, flash }) { function getUpdateHandler(
typeDeChamp: TypeDeChamp,
{ queue, flash }: { queue: OperationsQueue; flash: Flash }
) {
let handler = updateHandlers.get(typeDeChamp); let handler = updateHandlers.get(typeDeChamp);
if (!handler) { if (!handler) {
handler = debounce( handler = debounce(
(done) => (done: () => void) =>
updateTypeDeChampOperation(typeDeChamp, queue) updateTypeDeChampOperation(typeDeChamp, queue)
.then(() => { .then(() => {
flash.success(); flash.success();
@ -217,7 +315,7 @@ function findItemToInsertAfter() {
if (target) { if (target) {
return { return {
target, target,
index: parseInt(target.dataset.index) + 1 index: parseInt(target.dataset.index ?? '0') + 1
}; };
} else { } else {
return null; return null;
@ -225,11 +323,12 @@ function findItemToInsertAfter() {
} }
function getLastVisibleTypeDeChamp() { function getLastVisibleTypeDeChamp() {
const typeDeChamps = document.querySelectorAll('[data-in-view]'); const typeDeChamps =
document.querySelectorAll<HTMLDivElement>('[data-in-view]');
const target = typeDeChamps[typeDeChamps.length - 1]; const target = typeDeChamps[typeDeChamps.length - 1];
if (target) { if (target) {
const parentTarget = target.closest('[data-repetition]'); const parentTarget = target.closest<HTMLDivElement>('[data-repetition]');
if (parentTarget) { if (parentTarget) {
return parentTarget; return parentTarget;
} }

View file

@ -0,0 +1,27 @@
import type { ChangeEventHandler } from 'react';
export type { Flash } from './Flash';
export type { OperationsQueue } from './OperationsQueue';
export type { State } from '.';
export { Action } from './typeDeChampsReducer';
export type TypeDeChamp = {
id: string;
libelle: string;
type_champ: string;
private: boolean;
mandatory: boolean;
types_de_champ: TypeDeChamp[];
parent_id?: string;
piece_justificative_template_filename?: string;
piece_justificative_template_url?: string;
drop_down_list_value?: string;
editable_options?: Record<string, string>;
};
export type Handler<Element extends HTMLElement> = {
id: string;
name: string;
value: string;
onChange: ChangeEventHandler<Element>;
};

View file

@ -1,14 +0,0 @@
import React from 'react';
import { sortableContainer } from 'react-sortable-hoc';
export const SortableContainer = sortableContainer(({ children }) => {
return <ul>{children}</ul>;
});
export function addChampLabel(isAnnotation) {
if (isAnnotation) {
return 'Ajouter une annotation';
} else {
return 'Ajouter un champ';
}
}

View file

@ -0,0 +1,16 @@
import React, { ReactNode } from 'react';
import { SortableContainer as SortableContainerWrapper } from 'react-sortable-hoc';
export const SortableContainer = SortableContainerWrapper(
({ children }: { children: ReactNode }) => {
return <ul>{children}</ul>;
}
);
export function addChampLabel(isAnnotation: boolean) {
if (isAnnotation) {
return 'Ajouter une annotation';
} else {
return 'Ajouter un champ';
}
}

View file

@ -18,7 +18,10 @@ export const FAILURE_CONNECTIVITY = 'file-upload-failure-connectivity';
Represent an error during a file upload. Represent an error during a file upload.
*/ */
export default class FileUploadError extends Error { export default class FileUploadError extends Error {
constructor(message, status, code) { status?: number;
code?: string;
constructor(message: string, status: number | undefined, code?: string) {
super(message); super(message);
this.name = 'FileUploadError'; this.name = 'FileUploadError';
@ -27,9 +30,9 @@ export default class FileUploadError extends Error {
// Prevent the constructor stacktrace from being included. // Prevent the constructor stacktrace from being included.
// (it messes up with Sentry issues grouping) // (it messes up with Sentry issues grouping)
if (Error.captureStackTrace) { if ('captureStackTrace' in Error) {
// V8-only // V8-only
Error.captureStackTrace(this, this.constructor); //Error.captureStackTrace(this, this.constructor);
} else { } else {
this.stack = new Error().stack; this.stack = new Error().stack;
} }
@ -40,7 +43,7 @@ export default class FileUploadError extends Error {
See FAILURE_* constants for values. See FAILURE_* constants for values.
*/ */
get failureReason() { get failureReason() {
let isNetworkError = this.code && this.code != ERROR_CODE_READ; const isNetworkError = this.code && this.code != ERROR_CODE_READ;
if (isNetworkError && this.status != 0) { if (isNetworkError && this.status != 0) {
return FAILURE_SERVER; return FAILURE_SERVER;
@ -60,9 +63,9 @@ export default class FileUploadError extends Error {
// 2. Create each kind of error on a different line // 2. Create each kind of error on a different line
// (so that Sentry knows they are different kind of errors, from // (so that Sentry knows they are different kind of errors, from
// the line they were created.) // the line they were created.)
export function errorFromDirectUploadMessage(message) { export function errorFromDirectUploadMessage(message: string) {
let matches = message.match(/ Status: ([0-9]{1,3})/); const matches = message.match(/ Status: ([0-9]{1,3})/);
let status = matches ? parseInt(matches[1], 10) : undefined; const status = matches ? parseInt(matches[1], 10) : undefined;
// prettier-ignore // prettier-ignore
if (message.includes('Error reading')) { if (message.includes('Error reading')) {

View file

@ -15,13 +15,13 @@ const COMPLETE_CLASS = 'direct-upload--complete';
be found. be found.
*/ */
export default class ProgressBar { export default class ProgressBar {
static init(input, id, file) { static init(input: HTMLInputElement, id: string, file: File) {
clearErrors(input); clearErrors(input);
const html = this.render(id, file.name); const html = this.render(id, file.name);
input.insertAdjacentHTML('beforebegin', html); input.insertAdjacentHTML('beforebegin', html);
} }
static start(id) { static start(id: string) {
const element = getDirectUploadElement(id); const element = getDirectUploadElement(id);
if (element) { if (element) {
element.classList.remove(PENDING_CLASS); element.classList.remove(PENDING_CLASS);
@ -29,15 +29,15 @@ export default class ProgressBar {
} }
} }
static progress(id, progress) { static progress(id: string, progress: number) {
const element = getDirectUploadProgressElement(id); const element = getDirectUploadProgressElement(id);
if (element) { if (element) {
element.style.width = `${progress}%`; element.style.width = `${progress}%`;
element.setAttribute('aria-valuenow', progress); element.setAttribute('aria-valuenow', `${progress}`);
} }
} }
static error(id, error) { static error(id: string, error: string) {
const element = getDirectUploadElement(id); const element = getDirectUploadElement(id);
if (element) { if (element) {
element.classList.add(ERROR_CLASS); element.classList.add(ERROR_CLASS);
@ -45,60 +45,63 @@ export default class ProgressBar {
} }
} }
static end(id) { static end(id: string) {
const element = getDirectUploadElement(id); const element = getDirectUploadElement(id);
if (element) { if (element) {
element.classList.add(COMPLETE_CLASS); element.classList.add(COMPLETE_CLASS);
} }
} }
static render(id, filename) { static render(id: string, filename: string) {
return `<div id="direct-upload-${id}" class="direct-upload ${PENDING_CLASS}" data-direct-upload-id="${id}"> return `<div id="direct-upload-${id}" class="direct-upload ${PENDING_CLASS}" data-direct-upload-id="${id}">
<div role="progressbar" aria-valuemin="0" aria-valuemax="100" class="direct-upload__progress" style="width: 0%"></div> <div role="progressbar" aria-valuemin="0" aria-valuemax="100" class="direct-upload__progress" style="width: 0%"></div>
<span class="direct-upload__filename">${filename}</span> <span class="direct-upload__filename">${filename}</span>
</div>`; </div>`;
} }
constructor(input, id, file) { id: string;
this.constructor.init(input, id, file);
constructor(input: HTMLInputElement, id: string, file: File) {
ProgressBar.init(input, id, file);
this.id = id; this.id = id;
} }
start() { start() {
this.constructor.start(this.id); ProgressBar.start(this.id);
} }
progress(progress) { progress(progress: number) {
this.constructor.progress(this.id, progress); ProgressBar.progress(this.id, progress);
} }
error(error) { error(error: string) {
this.constructor.error(this.id, error); ProgressBar.error(this.id, error);
} }
end() { end() {
this.constructor.end(this.id); ProgressBar.end(this.id);
} }
destroy() { destroy() {
const element = getDirectUploadElement(this.id); const element = getDirectUploadElement(this.id);
element?.remove();
}
}
function clearErrors(input: HTMLInputElement) {
const errorElements =
input.parentElement?.querySelectorAll(`.${ERROR_CLASS}`) ?? [];
for (const element of errorElements) {
element.remove(); element.remove();
} }
} }
function clearErrors(input) { function getDirectUploadElement(id: string) {
const errorElements = input.parentElement.querySelectorAll(`.${ERROR_CLASS}`); return document.querySelector<HTMLDivElement>(`#direct-upload-${id}`);
for (let element of errorElements) {
element.remove();
}
} }
function getDirectUploadElement(id) { function getDirectUploadProgressElement(id: string) {
return document.getElementById(`direct-upload-${id}`); return document.querySelector<HTMLDivElement>(
}
function getDirectUploadProgressElement(id) {
return document.querySelector(
`#direct-upload-${id} .direct-upload__progress` `#direct-upload-${id} .direct-upload__progress`
); );
} }

View file

@ -11,9 +11,18 @@ import FileUploadError, {
used to track lifecycle and progress of an upload. used to track lifecycle and progress of an upload.
*/ */
export default class Uploader { export default class Uploader {
constructor(input, file, directUploadUrl, autoAttachUrl) { directUpload: DirectUpload;
progressBar: ProgressBar;
autoAttachUrl?: string;
constructor(
input: HTMLInputElement,
file: File,
directUploadUrl: string,
autoAttachUrl?: string
) {
this.directUpload = new DirectUpload(file, directUploadUrl, this); this.directUpload = new DirectUpload(file, directUploadUrl, this);
this.progressBar = new ProgressBar(input, this.directUpload.id, file); this.progressBar = new ProgressBar(input, this.directUpload.id + '', file);
this.autoAttachUrl = autoAttachUrl; this.autoAttachUrl = autoAttachUrl;
} }
@ -26,10 +35,10 @@ export default class Uploader {
this.progressBar.start(); this.progressBar.start();
try { try {
let blobSignedId = await this._upload(); const blobSignedId = await this._upload();
if (this.autoAttachUrl) { if (this.autoAttachUrl) {
await this._attach(blobSignedId); await this._attach(blobSignedId, this.autoAttachUrl);
// On response, the attachment HTML fragment will replace the progress bar. // On response, the attachment HTML fragment will replace the progress bar.
} else { } else {
this.progressBar.end(); this.progressBar.end();
@ -38,7 +47,7 @@ export default class Uploader {
return blobSignedId; return blobSignedId;
} catch (error) { } catch (error) {
this.progressBar.error(error.message); this.progressBar.error((error as Error).message);
throw error; throw error;
} }
} }
@ -47,11 +56,11 @@ export default class Uploader {
Upload the file using the DirectUpload instance, and return the blob signed_id. Upload the file using the DirectUpload instance, and return the blob signed_id.
Throws a FileUploadError on failure. Throws a FileUploadError on failure.
*/ */
async _upload() { async _upload(): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.directUpload.create((errorMsg, attributes) => { this.directUpload.create((errorMsg, attributes) => {
if (errorMsg) { if (errorMsg) {
let error = errorFromDirectUploadMessage(errorMsg); const error = errorFromDirectUploadMessage(errorMsg.message);
reject(error); reject(error);
} else { } else {
resolve(attributes.signed_id); resolve(attributes.signed_id);
@ -65,9 +74,9 @@ export default class Uploader {
Throws a FileUploadError on failure (containing the first validation Throws a FileUploadError on failure (containing the first validation
error message, if any). error message, if any).
*/ */
async _attach(blobSignedId) { async _attach(blobSignedId: string, autoAttachUrl: string) {
const attachmentRequest = { const attachmentRequest = {
url: this.autoAttachUrl, url: autoAttachUrl,
type: 'PUT', type: 'PUT',
data: `blob_signed_id=${blobSignedId}` data: `blob_signed_id=${blobSignedId}`
}; };
@ -75,23 +84,27 @@ export default class Uploader {
try { try {
await ajax(attachmentRequest); await ajax(attachmentRequest);
} catch (e) { } catch (e) {
let message = e.response && e.response.errors && e.response.errors[0]; const error = e as {
response?: { errors: string[] };
xhr?: XMLHttpRequest;
};
const message = error.response?.errors && error.response.errors[0];
throw new FileUploadError( throw new FileUploadError(
message || 'Error attaching file.', message || 'Error attaching file.',
e.xhr.status, error.xhr?.status,
ERROR_CODE_ATTACH ERROR_CODE_ATTACH
); );
} }
} }
uploadRequestDidProgress(event) { uploadRequestDidProgress(event: ProgressEvent) {
const progress = (event.loaded / event.total) * 100; const progress = (event.loaded / event.total) * 100;
if (progress) { if (progress) {
this.progressBar.progress(progress); this.progressBar.progress(progress);
} }
} }
directUploadWillStoreFileWithXHR(xhr) { directUploadWillStoreFileWithXHR(xhr: XMLHttpRequest) {
xhr.upload.addEventListener('progress', (event) => xhr.upload.addEventListener('progress', (event) =>
this.uploadRequestDidProgress(event) this.uploadRequestDidProgress(event)
); );

View file

@ -1,7 +1,6 @@
class ActiveStorage::DownloadableFile class ActiveStorage::DownloadableFile
def self.create_list_from_dossiers(dossiers, for_expert = false) def self.create_list_from_dossiers(dossiers, for_expert = false)
dossiers PiecesJustificativesService.generate_dossier_export(dossiers) +
.map { |d| pj_and_path(d.id, PiecesJustificativesService.generate_dossier_export(d)) } +
PiecesJustificativesService.liste_documents(dossiers, for_expert) PiecesJustificativesService.liste_documents(dossiers, for_expert)
end end

View file

@ -485,9 +485,10 @@ class Dossier < ApplicationRecord
end end
def motivation def motivation
return nil if !termine? if termine?
traitement&.motivation || read_attribute(:motivation) traitement&.motivation || read_attribute(:motivation)
end end
end
def update_search_terms def update_search_terms
self.search_terms = [ self.search_terms = [

View file

@ -1,4 +1,6 @@
class AgentConnectService class AgentConnectService
include OpenIDConnect
def self.enabled? def self.enabled?
ENV.fetch("AGENT_CONNECT_ENABLED", "enabled") == "enabled" ENV.fetch("AGENT_CONNECT_ENABLED", "enabled") == "enabled"
end end
@ -6,19 +8,41 @@ class AgentConnectService
def self.authorization_uri def self.authorization_uri
client = AgentConnectClient.new client = AgentConnectClient.new
client.authorization_uri( state = SecureRandom.hex(16)
nonce = SecureRandom.hex(16)
uri = client.authorization_uri(
scope: [:openid, :email], scope: [:openid, :email],
state: SecureRandom.hex(16), state: state,
nonce: SecureRandom.hex(16), nonce: nonce,
acr_values: 'eidas1' acr_values: 'eidas1'
) )
[uri, state, nonce]
end end
def self.user_info(code) def self.user_info(code, nonce)
client = AgentConnectClient.new(code) client = AgentConnectClient.new(code)
client.access_token!(client_auth_method: :secret) access_token = client.access_token!(client_auth_method: :secret)
discover = find_discover
id_token = ResponseObject::IdToken.decode(access_token.id_token, discover.jwks)
id_token.verify!(
client_id: AGENT_CONNECT[:identifier],
issuer: discover.issuer,
nonce: nonce
)
access_token
.userinfo! .userinfo!
.raw_attributes .raw_attributes
end end
private
def self.find_discover
Discovery::Provider::Config.discover!("#{AGENT_CONNECT_BASE_URL}/api/v2")
end
end end

View file

@ -107,21 +107,44 @@ class PiecesJustificativesService
end end
end end
def self.generate_dossier_export(dossier) def self.generate_dossier_export(dossiers)
return [] if dossiers.empty?
pdfs = []
procedure = dossiers.first.procedure
tdc_by_id = TypeDeChamp
.joins(:revisions)
.where(revisions: { id: procedure.revisions })
.to_a
.group_by(&:id)
dossiers
.includes(:champs, :champs_private, :commentaires, :individual,
:traitement, :etablissement,
user: :france_connect_information, avis: :expert)
.find_each do |dossier|
pdf = ApplicationController pdf = ApplicationController
.render(template: 'dossiers/show', formats: [:pdf], .render(template: 'dossiers/show', formats: [:pdf],
assigns: { assigns: {
include_infos_administration: true, include_infos_administration: true,
dossier: dossier dossier: dossier,
procedure: procedure,
tdc_by_id: tdc_by_id
}) })
FakeAttachment.new( a = FakeAttachment.new(
file: StringIO.new(pdf), file: StringIO.new(pdf),
filename: "export-#{dossier.id}.pdf", filename: "export-#{dossier.id}.pdf",
name: 'pdf_export_for_instructeur', name: 'pdf_export_for_instructeur',
id: dossier.id, id: dossier.id,
created_at: dossier.updated_at created_at: dossier.updated_at
) )
pdfs << ActiveStorage::DownloadableFile.pj_and_path(dossier.id, a)
end
pdfs
end end
private private
@ -142,6 +165,7 @@ class PiecesJustificativesService
ActiveStorage::Attachment ActiveStorage::Attachment
.includes(:blob) .includes(:blob)
.where(record_type: "Champ", record_id: champ_id_dossier_id.keys) .where(record_type: "Champ", record_id: champ_id_dossier_id.keys)
.filter { |a| safe_attachment(a) }
.map do |a| .map do |a|
dossier_id = champ_id_dossier_id[a.record_id] dossier_id = champ_id_dossier_id[a.record_id]
ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a)
@ -158,6 +182,7 @@ class PiecesJustificativesService
ActiveStorage::Attachment ActiveStorage::Attachment
.includes(:blob) .includes(:blob)
.where(record_type: "Commentaire", record_id: commentaire_id_dossier_id.keys) .where(record_type: "Commentaire", record_id: commentaire_id_dossier_id.keys)
.filter { |a| safe_attachment(a) }
.map do |a| .map do |a|
dossier_id = commentaire_id_dossier_id[a.record_id] dossier_id = commentaire_id_dossier_id[a.record_id]
ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a)
@ -189,6 +214,7 @@ class PiecesJustificativesService
ActiveStorage::Attachment ActiveStorage::Attachment
.includes(:blob) .includes(:blob)
.where(record_type: "Dossier", name: "justificatif_motivation", record_id: dossiers) .where(record_type: "Dossier", name: "justificatif_motivation", record_id: dossiers)
.filter { |a| safe_attachment(a) }
.map do |a| .map do |a|
dossier_id = a.record_id dossier_id = a.record_id
ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a)
@ -239,4 +265,10 @@ class PiecesJustificativesService
.where(record_type: "BillSignature", record_id: bill_ids) .where(record_type: "BillSignature", record_id: bill_ids)
.map { |bill| ActiveStorage::DownloadableFile.bill_and_path(bill) } .map { |bill| ActiveStorage::DownloadableFile.bill_and_path(bill) }
end end
def self.safe_attachment(attachment)
attachment
.blob
.metadata[:virus_scan_result] == ActiveStorage::VirusScanner::SAFE
end
end end

View file

@ -181,7 +181,7 @@ prawn_document(page_size: "A4") do |pdf|
italic: Rails.root.join('lib/prawn/fonts/marianne/marianne-thin.ttf' ), italic: Rails.root.join('lib/prawn/fonts/marianne/marianne-thin.ttf' ),
}) })
pdf.font 'marianne' pdf.font 'marianne'
pdf.svg IO.read(DOSSIER_PDF_EXPORT_LOGO_SRC), width: 300, position: :center pdf.image DOSSIER_PDF_EXPORT_LOGO_SRC, width: 300, position: :center
pdf.move_down(40) pdf.move_down(40)
render_in_2_columns(pdf, 'Démarche', @dossier.procedure.libelle) render_in_2_columns(pdf, 'Démarche', @dossier.procedure.libelle)

View file

@ -130,28 +130,30 @@ def add_identite_etablissement(pdf, etablissement)
end end
def add_single_champ(pdf, champ) def add_single_champ(pdf, champ)
tdc = @tdc_by_id[champ.type_de_champ_id].first
case champ.type case champ.type
when 'Champs::PieceJustificativeChamp', 'Champs::TitreIdentiteChamp' when 'Champs::PieceJustificativeChamp', 'Champs::TitreIdentiteChamp'
return return
when 'Champs::HeaderSectionChamp' when 'Champs::HeaderSectionChamp'
add_section_title(pdf, champ.libelle) add_section_title(pdf, tdc.libelle)
when 'Champs::ExplicationChamp' when 'Champs::ExplicationChamp'
format_in_2_lines(pdf, champ.libelle, champ.description) format_in_2_lines(pdf, tdc.libelle, tdc.description)
when 'Champs::CarteChamp' when 'Champs::CarteChamp'
format_in_2_lines(pdf, champ.libelle, champ.to_feature_collection.to_json) format_in_2_lines(pdf, tdc.libelle, champ.to_feature_collection.to_json)
when 'Champs::SiretChamp' when 'Champs::SiretChamp'
pdf.font 'marianne', style: :bold do pdf.font 'marianne', style: :bold do
pdf.text champ.libelle pdf.text tdc.libelle
end end
if champ.etablissement.present? if champ.etablissement.present?
add_identite_etablissement(pdf, champ.etablissement) add_identite_etablissement(pdf, champ.etablissement)
end end
when 'Champs::NumberChamp' when 'Champs::NumberChamp'
value = champ.to_s.empty? ? 'Non communiqué' : number_with_delimiter(champ.to_s) value = champ.to_s.empty? ? 'Non communiqué' : number_with_delimiter(champ.to_s)
format_in_2_lines(pdf, champ.libelle, value) format_in_2_lines(pdf, tdc.libelle, value)
else else
value = champ.to_s.empty? ? 'Non communiqué' : champ.to_s value = champ.to_s.empty? ? 'Non communiqué' : champ.to_s
format_in_2_lines(pdf, champ.libelle, value) format_in_2_lines(pdf, tdc.libelle, value)
end end
end end
@ -205,6 +207,9 @@ def add_etats_dossier(pdf, dossier)
end end
prawn_document(page_size: "A4") do |pdf| prawn_document(page_size: "A4") do |pdf|
@procedure ||= @dossier.procedure
@tdc_by_id ||= @dossier.champs.map(&:type_de_champ).group_by(&:id)
pdf.font_families.update( 'marianne' => { pdf.font_families.update( 'marianne' => {
normal: Rails.root.join('lib/prawn/fonts/marianne/marianne-regular.ttf' ), normal: Rails.root.join('lib/prawn/fonts/marianne/marianne-regular.ttf' ),
bold: Rails.root.join('lib/prawn/fonts/marianne/marianne-bold.ttf' ), bold: Rails.root.join('lib/prawn/fonts/marianne/marianne-bold.ttf' ),
@ -212,12 +217,12 @@ prawn_document(page_size: "A4") do |pdf|
pdf.font 'marianne' pdf.font 'marianne'
pdf.pad_bottom(40) do pdf.pad_bottom(40) do
pdf.svg IO.read(DOSSIER_PDF_EXPORT_LOGO_SRC), width: 300, position: :center pdf.image DOSSIER_PDF_EXPORT_LOGO_SRC, width: 300, position: :center
end end
format_in_2_columns(pdf, 'Dossier Nº', @dossier.id.to_s) format_in_2_columns(pdf, 'Dossier Nº', @dossier.id.to_s)
format_in_2_columns(pdf, 'Démarche', @dossier.procedure.libelle) format_in_2_columns(pdf, 'Démarche', @procedure.libelle)
format_in_2_columns(pdf, 'Organisme', @dossier.procedure.organisation_name) format_in_2_columns(pdf, 'Organisme', @procedure.organisation_name)
add_etat_dossier(pdf, @dossier) add_etat_dossier(pdf, @dossier)

View file

@ -69,7 +69,7 @@ DS_ENV="staging"
# PROCEDURE_DEFAULT_LOGO_SRC="republique-francaise-logo.svg" # PROCEDURE_DEFAULT_LOGO_SRC="republique-francaise-logo.svg"
# Instance customization: PDF export logo ---> to be put in "app/assets/images" # Instance customization: PDF export logo ---> to be put in "app/assets/images"
# DOSSIER_PDF_EXPORT_LOGO_SRC="app/assets/images/header/logo-ds-wide.svg" # DOSSIER_PDF_EXPORT_LOGO_SRC="app/assets/images/header/logo-ds-wide.png"
# Instance customization: watermark for identity documents # Instance customization: watermark for identity documents
# WATERMARK_FILE="" # WATERMARK_FILE=""

View file

@ -17,4 +17,4 @@ MAILER_FOOTER_LOGO_SRC = ENV.fetch("MAILER_FOOTER_LOGO_SRC", "mailer/instructeur
PROCEDURE_DEFAULT_LOGO_SRC = ENV.fetch("PROCEDURE_DEFAULT_LOGO_SRC", "republique-francaise-logo.svg") PROCEDURE_DEFAULT_LOGO_SRC = ENV.fetch("PROCEDURE_DEFAULT_LOGO_SRC", "republique-francaise-logo.svg")
# Logo in PDF export of a "Dossier" # Logo in PDF export of a "Dossier"
DOSSIER_PDF_EXPORT_LOGO_SRC = ENV.fetch("DOSSIER_PDF_EXPORT_LOGO_SRC", "app/assets/images/header/logo-ds-wide.svg") DOSSIER_PDF_EXPORT_LOGO_SRC = ENV.fetch("DOSSIER_PDF_EXPORT_LOGO_SRC", "app/assets/images/header/logo-ds-wide.png")

View file

@ -35,7 +35,7 @@
"react-intersection-observer": "^8.31.0", "react-intersection-observer": "^8.31.0",
"react-popper": "^2.2.5", "react-popper": "^2.2.5",
"react-query": "^3.34.19", "react-query": "^3.34.19",
"react-sortable-hoc": "^1.11.0", "react-sortable-hoc": "^2.0.0",
"tiny-invariant": "^1.2.0", "tiny-invariant": "^1.2.0",
"trix": "^1.2.3", "trix": "^1.2.3",
"use-debounce": "^5.2.0", "use-debounce": "^5.2.0",
@ -49,6 +49,7 @@
"@types/geojson": "^7946.0.8", "@types/geojson": "^7946.0.8",
"@types/is-hotkey": "^0.1.7", "@types/is-hotkey": "^0.1.7",
"@types/mapbox__mapbox-gl-draw": "^1.2.3", "@types/mapbox__mapbox-gl-draw": "^1.2.3",
"@types/rails__activestorage": "^7.0.1",
"@types/rails__ujs": "^6.0.1", "@types/rails__ujs": "^6.0.1",
"@types/react": "^17.0.43", "@types/react": "^17.0.43",
"@types/react-dom": "^17.0.14", "@types/react-dom": "^17.0.14",

View file

@ -1,15 +1,40 @@
describe AgentConnect::AgentController, type: :controller do describe AgentConnect::AgentController, type: :controller do
describe '#login' do
let(:uri) { 'https://agent-connect.fr' }
let(:state) { 'state' }
let(:nonce) { 'nonce' }
before do
expect(AgentConnectService).to receive(:authorization_uri).and_return([uri, state, nonce])
get :login
end
it do
expect(state_cookie).to eq(state)
expect(nonce_cookie).to eq(nonce)
expect(response).to redirect_to(uri)
end
end
describe '#callback' do describe '#callback' do
let(:email) { 'i@email.com' } let(:email) { 'i@email.com' }
subject { get :callback, params: { code: code } } let(:original_state) { 'original_state' }
let(:nonce) { 'nonce' }
subject { get :callback, params: { code: code, state: state } }
before do
cookies.encrypted[controller.class::STATE_COOKIE_NAME] = original_state
cookies.encrypted[controller.class::NONCE_COOKIE_NAME] = nonce
end
context 'when the callback code is correct' do context 'when the callback code is correct' do
let(:code) { 'correct' } let(:code) { 'correct' }
let(:state) { original_state }
let(:user_info) { { 'sub' => 'sub', 'email' => ' I@email.com' } } let(:user_info) { { 'sub' => 'sub', 'email' => ' I@email.com' } }
context 'and user_info returns some info' do context 'and user_info returns some info' do
before do before do
expect(AgentConnectService).to receive(:user_info).and_return(user_info) expect(AgentConnectService).to receive(:user_info).with(code, nonce).and_return(user_info)
end end
context 'and the instructeur does not have an account yet' do context 'and the instructeur does not have an account yet' do
@ -26,6 +51,8 @@ describe AgentConnect::AgentController, type: :controller do
expect(last_user.confirmed_at).to be_present expect(last_user.confirmed_at).to be_present
expect(last_user.instructeur.agent_connect_id).to eq('sub') expect(last_user.instructeur.agent_connect_id).to eq('sub')
expect(response).to redirect_to(instructeur_procedures_path) expect(response).to redirect_to(instructeur_procedures_path)
expect(state_cookie).to be_nil
expect(nonce_cookie).to be_nil
end end
end end
@ -77,8 +104,22 @@ describe AgentConnect::AgentController, type: :controller do
end end
end end
context 'when the callback state is not the original' do
let(:code) { 'correct' }
let(:state) { 'another state' }
before { subject }
it 'aborts the processus' do
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0)
expect(response).to redirect_to(new_user_session_path)
end
end
context 'when the callback code is blank' do context 'when the callback code is blank' do
let(:code) { '' } let(:code) { '' }
let(:state) { original_state }
it 'aborts the processus' do it 'aborts the processus' do
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0) expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0)
@ -87,4 +128,12 @@ describe AgentConnect::AgentController, type: :controller do
end end
end end
end end
def state_cookie
cookies.encrypted[controller.class::STATE_COOKIE_NAME]
end
def nonce_cookie
cookies.encrypted[controller.class::NONCE_COOKIE_NAME]
end
end end

View file

@ -26,6 +26,16 @@ RSpec.describe Types::DossierType, type: :graphql do
end end
end end
describe 'dossier with champs' do
let(:procedure) { create(:procedure, :published, :with_commune, :with_address) }
let(:dossier) { create(:dossier, :accepte, :with_populated_champs, procedure: procedure) }
let(:query) { DOSSIER_WITH_CHAMPS_QUERY }
let(:variables) { { number: dossier.id } }
it { expect(data[:dossier][:champs][0][:__typename]).to eq "CommuneChamp" }
it { expect(data[:dossier][:champs][1][:__typename]).to eq "AddressChamp" }
end
DOSSIER_QUERY = <<-GRAPHQL DOSSIER_QUERY = <<-GRAPHQL
query($number: Int!) { query($number: Int!) {
dossier(number: $number) { dossier(number: $number) {
@ -48,4 +58,35 @@ RSpec.describe Types::DossierType, type: :graphql do
} }
} }
GRAPHQL GRAPHQL
DOSSIER_WITH_CHAMPS_QUERY = <<-GRAPHQL
query($number: Int!) {
dossier(number: $number) {
id
number
champs {
id
label
__typename
...CommuneChampFragment
... on AddressChamp {
address {
...AddressFragment
}
}
}
}
}
fragment CommuneChampFragment on CommuneChamp {
commune {
...CommuneFragment
}
}
fragment CommuneFragment on Commune {
code
}
fragment AddressFragment on Address {
cityName
}
GRAPHQL
end end

View file

@ -23,6 +23,16 @@ describe PiecesJustificativesService do
it { expect(subject).to match_array([pj_champ.call(dossier).piece_justificative_file.attachment]) } it { expect(subject).to match_array([pj_champ.call(dossier).piece_justificative_file.attachment]) }
end end
context 'with a pj not safe on a champ' do
let(:procedure) { create(:procedure, :with_piece_justificative) }
let(:dossier) { create(:dossier, procedure: procedure) }
let(:pj_champ) { -> (d) { d.champs.find { |c| c.type == 'Champs::PieceJustificativeChamp' } } }
before { attach_file_to_champ(pj_champ.call(dossier), safe = false) }
it { expect(subject).to be_empty }
end
context 'with a private pj champ' do context 'with a private pj champ' do
let(:procedure) { create(:procedure) } let(:procedure) { create(:procedure) }
let(:dossier) { create(:dossier, procedure: procedure) } let(:dossier) { create(:dossier, procedure: procedure) }
@ -64,12 +74,26 @@ describe PiecesJustificativesService do
let(:dossier) { create(:dossier) } let(:dossier) { create(:dossier) }
let(:witness) { create(:dossier) } let(:witness) { create(:dossier) }
let!(:commentaire) { create(:commentaire, :with_file, dossier: dossier) } let!(:commentaire) { create(:commentaire, dossier: dossier) }
let!(:witness_commentaire) { create(:commentaire, :with_file, dossier: witness) } let!(:witness_commentaire) { create(:commentaire, dossier: witness) }
before do
attach_file(commentaire.piece_jointe)
attach_file(witness_commentaire.piece_jointe)
end
it { expect(subject).to match_array(dossier.commentaires.first.piece_jointe.attachment) } it { expect(subject).to match_array(dossier.commentaires.first.piece_jointe.attachment) }
end end
context 'with a pj not safe on a commentaire' do
let(:dossier) { create(:dossier) }
let!(:commentaire) { create(:commentaire, dossier: dossier) }
before { attach_file(commentaire.piece_jointe, safe = false) }
it { expect(subject).to be_empty }
end
context 'with a motivation' do context 'with a motivation' do
let(:dossier) { create(:dossier, :with_justificatif) } let(:dossier) { create(:dossier, :with_justificatif) }
let!(:witness) { create(:dossier, :with_justificatif) } let!(:witness) { create(:dossier, :with_justificatif) }
@ -77,6 +101,14 @@ describe PiecesJustificativesService do
it { expect(subject).to match_array(dossier.justificatif_motivation.attachment) } it { expect(subject).to match_array(dossier.justificatif_motivation.attachment) }
end end
context 'with a motivation not safe' do
let(:dossier) { create(:dossier) }
before { attach_file(dossier.justificatif_motivation, safe = false) }
it { expect(subject).to be_empty }
end
context 'with an attestation' do context 'with an attestation' do
let(:dossier) { create(:dossier, :with_attestation) } let(:dossier) { create(:dossier, :with_attestation) }
let!(:witness) { create(:dossier, :with_attestation) } let!(:witness) { create(:dossier, :with_attestation) }
@ -160,19 +192,27 @@ describe PiecesJustificativesService do
describe '.generate_dossier_export' do describe '.generate_dossier_export' do
let(:dossier) { create(:dossier) } let(:dossier) { create(:dossier) }
subject { PiecesJustificativesService.generate_dossier_export(dossier) } subject { PiecesJustificativesService.generate_dossier_export(Dossier.where(id: dossier.id)) }
it "doesn't update dossier" do it "doesn't update dossier" do
expect { subject }.not_to change { dossier.updated_at } expect { subject }.not_to change { dossier.updated_at }
end end
end end
def attach_file_to_champ(champ) def attach_file_to_champ(champ, safe = true)
attach_file(champ.piece_justificative_file) attach_file(champ.piece_justificative_file, safe)
end end
def attach_file(attachable) def attach_file(attachable, safe = true)
attachable to_be_attached = {
.attach(io: StringIO.new("toto"), filename: "toto.png", content_type: "image/png") io: StringIO.new("toto"),
filename: "toto.png", content_type: "image/png"
}
if safe
to_be_attached[:metadata] = { virus_scan_result: ActiveStorage::VirusScanner::SAFE }
end
attachable.attach(to_be_attached)
end end
end end

View file

@ -90,8 +90,22 @@ describe 'Inviting an expert:' do
let(:commentaire) { create(:commentaire, instructeur: instructeur, dossier: dossier) } let(:commentaire) { create(:commentaire, instructeur: instructeur, dossier: dossier) }
before do before do
champ.piece_justificative_file.attach(io: File.open(path), filename: "piece_justificative_0.pdf", content_type: "application/pdf") champ
dossier.champs_private << create(:champ_piece_justificative, :with_piece_justificative_file, private: true, dossier: dossier) .piece_justificative_file
.attach(io: File.open(path),
filename: "piece_justificative_0.pdf",
content_type: "application/pdf",
metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE })
dossier.champs_private << create(:champ_piece_justificative, private: true, dossier: dossier)
dossier.champs_private
.first
.piece_justificative_file
.attach(io: File.open(path),
filename: "piece_justificative_0.pdf",
content_type: "application/pdf",
metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE })
end end
scenario 'An Expert can download an archive containing attachments without any private champ, bill signature and operations logs' do scenario 'An Expert can download an archive containing attachments without any private champ, bill signature and operations logs' do
@ -102,7 +116,7 @@ describe 'Inviting an expert:' do
click_on '1 avis à donner' click_on '1 avis à donner'
click_on avis.dossier.user.email click_on avis.dossier.user.email
find(:css, '.attached').click find(:css, '[aria-controls=print-pj-menu]').click
click_on 'Télécharger le dossier et toutes ses pièces jointes' click_on 'Télécharger le dossier et toutes ses pièces jointes'
# For some reason, clicking the download link does not trigger the download in the headless browser ; # For some reason, clicking the download link does not trigger the download in the headless browser ;
# So we need to go to the download link directly # So we need to go to the download link directly

View file

@ -194,14 +194,18 @@ describe 'Instructing a dossier:', js: true do
before do before do
dossier.passer_en_instruction!(instructeur: instructeur) dossier.passer_en_instruction!(instructeur: instructeur)
champ.piece_justificative_file.attach(io: File.open(path), filename: "piece_justificative_0.pdf", content_type: "application/pdf") champ.piece_justificative_file
.attach(io: File.open(path),
filename: "piece_justificative_0.pdf",
content_type: "application/pdf",
metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE })
log_in(instructeur.email, password) log_in(instructeur.email, password)
visit instructeur_dossier_path(procedure, dossier) visit instructeur_dossier_path(procedure, dossier)
end end
scenario 'A instructeur can download an archive containing a single attachment' do scenario 'A instructeur can download an archive containing a single attachment' do
find(:css, '.attached').click find(:css, '[aria-controls=print-pj-menu]').click
click_on 'Télécharger le dossier et toutes ses pièces jointes' click_on 'Télécharger le dossier et toutes ses pièces jointes'
# For some reason, clicking the download link does not trigger the download in the headless browser ; # For some reason, clicking the download link does not trigger the download in the headless browser ;
# So we need to go to the download link directly # So we need to go to the download link directly
@ -219,7 +223,12 @@ describe 'Instructing a dossier:', js: true do
end end
scenario 'A instructeur can download an archive containing several identical attachments' do scenario 'A instructeur can download an archive containing several identical attachments' do
commentaire.piece_jointe.attach(io: File.open(path), filename: "piece_justificative_0.pdf", content_type: "application/pdf") commentaire
.piece_jointe
.attach(io: File.open(path),
filename: "piece_justificative_0.pdf",
content_type: "application/pdf",
metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE })
visit telecharger_pjs_instructeur_dossier_path(procedure, dossier) visit telecharger_pjs_instructeur_dossier_path(procedure, dossier)
DownloadHelpers.wait_for_download DownloadHelpers.wait_for_download

View file

@ -2459,6 +2459,11 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df"
integrity sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ== integrity sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==
"@types/rails__activestorage@^7.0.1":
version "7.0.1"
resolved "https://registry.yarnpkg.com/@types/rails__activestorage/-/rails__activestorage-7.0.1.tgz#7e60320fdb376d34051733e3f8f0df0a8ff24077"
integrity sha512-H8mjwqFNweMfvgg8U+rdsonqjouETdqZxEOfzARiA4ag8jtLHFhjfAgFF6gWMnzVpXgQPrJw1boiknx9rV23Eg==
"@types/rails__ujs@^6.0.1": "@types/rails__ujs@^6.0.1":
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/@types/rails__ujs/-/rails__ujs-6.0.1.tgz#83c5aa1dad88ca869de05a9523eff58041ab307a" resolved "https://registry.yarnpkg.com/@types/rails__ujs/-/rails__ujs-6.0.1.tgz#83c5aa1dad88ca869de05a9523eff58041ab307a"
@ -11294,10 +11299,10 @@ react-query@^3.34.19:
broadcast-channel "^3.4.1" broadcast-channel "^3.4.1"
match-sorter "^6.0.2" match-sorter "^6.0.2"
react-sortable-hoc@^1.11.0: react-sortable-hoc@^2.0.0:
version "1.11.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-1.11.0.tgz#fe4022362bbafc4b836f5104b9676608a40a278f" resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz#f6780d8aa4b922a21f3e754af542f032677078b7"
integrity sha512-v1CDCvdfoR3zLGNp6qsBa4J1BWMEVH25+UKxF/RvQRh+mrB+emqtVHMgZ+WreUiKJoEaiwYoScaueIKhMVBHUg== integrity sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg==
dependencies: dependencies:
"@babel/runtime" "^7.2.0" "@babel/runtime" "^7.2.0"
invariant "^2.2.4" invariant "^2.2.4"