commit
5b6f6afa20
48 changed files with 950 additions and 481 deletions
1
Gemfile
1
Gemfile
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
BIN
app/assets/images/header/logo-ds-wide.png
Normal file
BIN
app/assets/images/header/logo-ds-wide.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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'
|
|
@ -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 {
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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 d’importance internationale',
|
'options.zones_humides': 'Zones humides d’importance 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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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
|
|
||||||
};
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
54
app/javascript/components/TypesDeChampEditor/index.tsx
Normal file
54
app/javascript/components/TypesDeChampEditor/index.tsx
Normal 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} />;
|
||||||
|
}
|
|
@ -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'
|
|
||||||
];
|
|
71
app/javascript/components/TypesDeChampEditor/operations.ts
Normal file
71
app/javascript/components/TypesDeChampEditor/operations.ts
Normal 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;
|
|
@ -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;
|
||||||
}
|
}
|
27
app/javascript/components/TypesDeChampEditor/types.ts
Normal file
27
app/javascript/components/TypesDeChampEditor/types.ts
Normal 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>;
|
||||||
|
};
|
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
16
app/javascript/components/TypesDeChampEditor/utils.tsx
Normal file
16
app/javascript/components/TypesDeChampEditor/utils.tsx
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')) {
|
|
@ -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`
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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)
|
||||||
);
|
);
|
|
@ -1,8 +1,7 @@
|
||||||
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
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -485,8 +485,9 @@ 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -107,21 +107,44 @@ class PiecesJustificativesService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.generate_dossier_export(dossier)
|
def self.generate_dossier_export(dossiers)
|
||||||
pdf = ApplicationController
|
return [] if dossiers.empty?
|
||||||
.render(template: 'dossiers/show', formats: [:pdf],
|
|
||||||
assigns: {
|
|
||||||
include_infos_administration: true,
|
|
||||||
dossier: dossier
|
|
||||||
})
|
|
||||||
|
|
||||||
FakeAttachment.new(
|
pdfs = []
|
||||||
file: StringIO.new(pdf),
|
|
||||||
filename: "export-#{dossier.id}.pdf",
|
procedure = dossiers.first.procedure
|
||||||
name: 'pdf_export_for_instructeur',
|
tdc_by_id = TypeDeChamp
|
||||||
id: dossier.id,
|
.joins(:revisions)
|
||||||
created_at: dossier.updated_at
|
.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
|
||||||
|
.render(template: 'dossiers/show', formats: [:pdf],
|
||||||
|
assigns: {
|
||||||
|
include_infos_administration: true,
|
||||||
|
dossier: dossier,
|
||||||
|
procedure: procedure,
|
||||||
|
tdc_by_id: tdc_by_id
|
||||||
|
})
|
||||||
|
|
||||||
|
a = FakeAttachment.new(
|
||||||
|
file: StringIO.new(pdf),
|
||||||
|
filename: "export-#{dossier.id}.pdf",
|
||||||
|
name: 'pdf_export_for_instructeur',
|
||||||
|
id: dossier.id,
|
||||||
|
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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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=""
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue