refactor(types_de_champ_editor): remove old react editor

This commit is contained in:
Paul Chavard 2022-06-01 15:22:53 +02:00 committed by Paul Chavard
parent 9640461464
commit 65bd996f2a
26 changed files with 6 additions and 1629 deletions

View file

@ -30,29 +30,6 @@ module ProcedureHelper
procedure.errors.to_hash(full_messages: true).except(:path)
end
def types_de_champ_data(procedure)
{
isAnnotation: false,
typeDeChampsTypes: TypeDeChamp.type_de_champ_types_for(procedure, current_user),
typeDeChamps: procedure.draft_revision.types_de_champ_public_as_json,
baseUrl: admin_procedure_types_de_champ_path(procedure),
directUploadUrl: rails_direct_uploads_url,
continuerUrl: admin_procedure_path(procedure),
estimatedFillDuration: procedure.draft_revision.estimated_fill_duration
}
end
def types_de_champ_private_data(procedure)
{
isAnnotation: true,
typeDeChampsTypes: TypeDeChamp.type_de_champ_types_for(procedure, current_user),
typeDeChamps: procedure.draft_revision.types_de_champ_private_as_json,
baseUrl: admin_procedure_types_de_champ_path(procedure),
directUploadUrl: rails_direct_uploads_url,
continuerUrl: admin_procedure_path(procedure)
}
end
def procedure_auto_archive_date(procedure)
I18n.l(procedure.auto_archive_on - 1.day, format: '%-d %B %Y')
end

View file

@ -1,43 +0,0 @@
import invariant from 'tiny-invariant';
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;
}
success() {
if (this.isAnnotation) {
this.add('Annotations privées enregistrées.');
} else {
this.add('Formulaire enregistré.');
}
}
error(message: string) {
this.add(message, true);
}
clear() {
this.element.innerHTML = '';
}
add(message: string, isError = false) {
const html = `<div id="flash_message" class="center">
<div class="alert alert-fixed ${
isError ? 'alert-danger' : 'alert-success'
}">
${message}
</div>
</div>`;
this.element.innerHTML = html;
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.clear();
}, 4000);
}
}

View file

@ -1,71 +0,0 @@
import { httpRequest, ResponseError } from '@utils';
import invariant from 'tiny-invariant';
type Operation = {
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.baseUrl = baseUrl;
}
async run() {
if (this.queue.length > 0) {
this.isRunning = true;
const operation = this.queue.shift();
invariant(operation, 'Operation is required');
await this.exec(operation);
this.run();
} else {
this.isRunning = false;
}
}
enqueue(operation: Omit<Operation, 'resolve' | 'reject'>) {
return new Promise((resolve, reject) => {
this.queue.push({ ...operation, resolve, reject });
if (!this.isRunning) {
this.run();
}
});
}
async exec(operation: Operation) {
const { path, method, payload, resolve, reject } = operation;
const url = `${this.baseUrl}${path}`;
try {
const json = payload;
const data = await httpRequest(url, { method, json }).json();
resolve(data);
} catch (e) {
handleError(e as ResponseError, reject);
}
}
}
async function handleError(
{ message, textBody, jsonBody }: ResponseError,
reject: (error: string) => void
) {
if (textBody) {
reject(textBody);
} else if (jsonBody) {
const {
errors: [message]
} = jsonBody as { errors: string[] };
reject(message);
} else {
reject(message);
}
}

View file

@ -1,29 +0,0 @@
import React from 'react';
import type { Handler } from '../types';
export function DescriptionInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLTextAreaElement>;
}) {
if (isVisible) {
return (
<div className="cell">
<label htmlFor={handler.id}>Description</label>
<textarea
id={handler.id}
name={handler.name}
value={handler.value || ''}
onChange={handler.onChange}
rows={3}
cols={40}
className="small-margin small"
/>
</div>
);
}
return null;
}

View file

@ -1,28 +0,0 @@
import React from 'react';
import type { Handler } from '../types';
export function LibelleInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) {
return (
<div className="cell libelle">
<label htmlFor={handler.id}>Libellé</label>
<input
type="text"
id={handler.id}
name={handler.name}
value={handler.value}
onChange={handler.onChange}
className="small-margin small"
/>
</div>
);
}
return null;
}

View file

@ -1,28 +0,0 @@
import React from 'react';
import type { Handler } from '../types';
export function MandatoryInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) {
return (
<div className="cell">
<label htmlFor={handler.id}>Obligatoire</label>
<input
type="checkbox"
id={handler.id}
name={handler.name}
checked={!!handler.value}
onChange={handler.onChange}
className="small-margin small"
/>
</div>
);
}
return null;
}

View file

@ -1,29 +0,0 @@
import React, { MouseEventHandler } from 'react';
import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/solid';
export function MoveButton({
isEnabled,
icon,
title,
onClick
}: {
isEnabled: boolean;
icon: string;
title: string;
onClick: MouseEventHandler<HTMLButtonElement>;
}) {
return (
<button
className="button small move"
disabled={!isEnabled}
title={title}
onClick={onClick}
>
{icon == 'arrow-up' ? (
<ArrowUpIcon className="icon-size" />
) : (
<ArrowDownIcon className="icon-size" />
)}
</button>
);
}

View file

@ -1,308 +0,0 @@
import React, { Dispatch } from 'react';
import { SortableElement, SortableHandle } from 'react-sortable-hoc';
import { useInView } from 'react-intersection-observer';
import { TrashIcon } from '@heroicons/react/outline';
import type { Action, TypeDeChamp, State, Handler } from '../types';
import { DescriptionInput } from './DescriptionInput';
import { LibelleInput } from './LibelleInput';
import { MandatoryInput } from './MandatoryInput';
import { MoveButton } from './MoveButton';
import { TypeDeChampCarteOption } from './TypeDeChampCarteOption';
import { TypeDeChampCarteOptions } from './TypeDeChampCarteOptions';
import { TypeDeChampDropDownOptions } from './TypeDeChampDropDownOptions';
import { TypeDeChampDropDownOther } from './TypeDeChampDropDownOther';
import { TypeDeChampPieceJustificative } from './TypeDeChampPieceJustificative';
import { TypeDeChampRepetitionOptions } from './TypeDeChampRepetitionOptions';
import { TypeDeChampTypesSelect } from './TypeDeChampTypesSelect';
import { TypeDeChampDropDownSecondary } from './TypeDeChampDropDownSecondary';
type TypeDeChampProps = {
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 = [
'drop_down_list',
'multiple_drop_down_list',
'linked_drop_down_list'
].includes(typeDeChamp.type_champ);
const isLinkedDropDown = typeDeChamp.type_champ === 'linked_drop_down_list';
const isSimpleDropDown = typeDeChamp.type_champ === 'drop_down_list';
const isFile = typeDeChamp.type_champ === 'piece_justificative';
const isCarte = typeDeChamp.type_champ === 'carte';
const isExplication = typeDeChamp.type_champ === 'explication';
const isHeaderSection = typeDeChamp.type_champ === 'header_section';
const isTitreIdentite = typeDeChamp.type_champ === 'titre_identite';
const isRepetition = typeDeChamp.type_champ === 'repetition';
const canBeMandatory =
!isHeaderSection && !isExplication && !state.isAnnotation;
const [ref, inView] = useInView({
threshold: 0.6
});
const updateHandlers = createUpdateHandlers(
dispatch,
typeDeChamp,
index,
state.prefix
);
const typeDeChampsTypesForRepetition = state.typeDeChampsTypes.filter(
([, type]) => !EXCLUDE_FROM_REPETITION.includes(type)
);
return (
<div
ref={ref}
data-index={index}
data-in-view={inView ? true : undefined}
data-repetition={isRepetition ? true : undefined}
className={`type-de-champ form flex column justify-start ${
isHeaderSection ? 'type-header-section' : ''
}`}
>
<div
className={`flex justify-start section head ${
!isHeaderSection ? 'hr' : ''
}`}
>
<DragHandle />
<TypeDeChampTypesSelect
handler={updateHandlers.type_champ}
options={state.typeDeChampsTypes}
/>
<div className="flex justify-start delete">
<button
className="button small icon-only danger"
onClick={() => {
if (confirm('Êtes vous sûr de vouloir supprimer ce champ ?'))
dispatch({
type: 'removeTypeDeChamp',
params: { typeDeChamp },
done: (estimatedFillDuration) => {
dispatch({
type: 'refresh',
params: { estimatedFillDuration }
});
}
});
}}
>
<TrashIcon className="icon-size" />
<span className="sr-only">Supprimer</span>
</button>
</div>
</div>
<div
className={`flex justify-start section ${
isDropDown || isFile || isCarte ? 'hr' : ''
}`}
>
<div className="flex column justify-start">
<MoveButton
isEnabled={!isFirstItem}
icon="arrow-up"
title="Déplacer le champ vers le haut"
onClick={() =>
dispatch({
type: 'moveTypeDeChampUp',
params: { typeDeChamp }
})
}
/>
<MoveButton
isEnabled={!isLastItem}
icon="arrow-down"
title="Déplacer le champ vers le bas"
onClick={() =>
dispatch({
type: 'moveTypeDeChampDown',
params: { typeDeChamp }
})
}
/>
</div>
<div className="flex column justify-start">
<LibelleInput handler={updateHandlers.libelle} isVisible={true} />
<MandatoryInput
handler={updateHandlers.mandatory}
isVisible={canBeMandatory}
/>
</div>
<div className="flex justify-start">
<DescriptionInput
isVisible={!isHeaderSection && !isTitreIdentite}
handler={updateHandlers.description}
/>
</div>
</div>
<div className="flex justify-start section shift-left">
<TypeDeChampDropDownOptions
isVisible={isDropDown}
handler={updateHandlers.drop_down_list_value}
/>
<TypeDeChampDropDownSecondary
isVisible={isLinkedDropDown}
libelleHandler={updateHandlers.drop_down_secondary_libelle}
descriptionHandler={updateHandlers.drop_down_secondary_description}
/>
<TypeDeChampDropDownOther
isVisible={isSimpleDropDown}
handler={updateHandlers.drop_down_other}
/>
<TypeDeChampPieceJustificative
isVisible={isFile}
isTitreIdentite={isTitreIdentite}
directUploadUrl={state.directUploadUrl}
filename={typeDeChamp.piece_justificative_template_filename}
handler={updateHandlers.piece_justificative_template}
url={typeDeChamp.piece_justificative_template_url}
/>
<TypeDeChampCarteOptions isVisible={isCarte}>
{Object.entries(OPTIONS_FIELDS).map(([field, label]) => (
<TypeDeChampCarteOption
key={field}
label={label}
handler={updateHandlers[field]}
/>
))}
</TypeDeChampCarteOptions>
<TypeDeChampRepetitionOptions
isVisible={isRepetition}
state={{
...state,
typeDeChampsTypes: typeDeChampsTypesForRepetition,
prefix: `repetition-${index}`,
typeDeChamps: typeDeChamp.types_de_champ || []
}}
typeDeChamp={typeDeChamp}
/>
</div>
</div>
);
}
);
const DragHandle = SortableHandle(() => (
<div
className="handle small icon-only icon move-handle"
title="Déplacer le champ vers le haut ou vers le bas"
/>
));
type HandlerInputElement =
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement;
function createUpdateHandler(
dispatch: Dispatch<Action>,
typeDeChamp: TypeDeChamp,
field: keyof TypeDeChamp,
index: number,
prefix?: string
): Handler<HandlerInputElement> {
return {
id: `${prefix ? `${prefix}-` : ''}champ-${index}-${field}`,
name: field,
value: getValue(typeDeChamp, field),
onChange: ({ target }) =>
dispatch({
type: 'updateTypeDeChamp',
params: {
typeDeChamp,
field,
value: readValue(target)
},
done: (estimatedFillDuration: number) => {
return dispatch({
type: 'refresh',
params: { estimatedFillDuration }
});
}
})
};
}
function createUpdateHandlers(
dispatch: Dispatch<Action>,
typeDeChamp: TypeDeChamp,
index: number,
prefix?: string
) {
return FIELDS.reduce((handlers, field) => {
handlers[field] = createUpdateHandler(
dispatch,
typeDeChamp,
field as keyof TypeDeChamp,
index,
prefix
);
return handlers;
}, {} as Record<string, Handler<HandlerInputElement>>);
}
const OPTIONS_FIELDS = {
'options.cadastres': 'Cadastres',
'options.unesco': 'UNESCO',
'options.arretes_protection': 'Arrêtés de protection',
'options.conservatoire_littoral': 'Conservatoire du Littoral',
'options.reserves_chasse_faune_sauvage':
'Réserves nationales de chasse et de faune sauvage',
'options.reserves_biologiques': 'Réserves biologiques',
'options.reserves_naturelles': 'Réserves naturelles',
'options.natura_2000': 'Natura 2000',
'options.zones_humides': 'Zones humides dimportance internationale',
'options.znieff': 'ZNIEFF'
} as const;
export const FIELDS = [
'description',
'drop_down_list_value',
'drop_down_other',
'libelle',
'mandatory',
'parent_id',
'piece_justificative_template',
'private',
'type_champ',
'drop_down_secondary_libelle',
'drop_down_secondary_description',
...Object.keys(OPTIONS_FIELDS)
] as const;
function getValue(obj: TypeDeChamp, path: string) {
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 = [
'carte',
'dossier_link',
'repetition',
'siret'
];

View file

@ -1,25 +0,0 @@
import React from 'react';
import type { Handler } from '../types';
export function TypeDeChampCarteOption({
label,
handler
}: {
label: string;
handler: Handler<HTMLInputElement>;
}) {
return (
<label htmlFor={handler.id}>
<input
type="checkbox"
id={handler.id}
name={handler.name}
checked={!!handler.value}
onChange={handler.onChange}
className="small-margin small"
/>
{label}
</label>
);
}

View file

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

View file

@ -1,30 +0,0 @@
import React from 'react';
import type { Handler } from '../types';
export function TypeDeChampDropDownOptions({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLTextAreaElement>;
}) {
if (isVisible) {
return (
<div className="cell">
<label htmlFor={handler.id}>Liste déroulante</label>
<textarea
id={handler.id}
name={handler.name}
value={handler.value}
onChange={handler.onChange}
rows={3}
cols={40}
placeholder="Ecrire une valeur par ligne et --valeur-- pour un séparateur."
className="small-margin small"
/>
</div>
);
}
return null;
}

View file

@ -1,30 +0,0 @@
import React from 'react';
import type { Handler } from '../types';
export function TypeDeChampDropDownOther({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) {
return (
<div className="cell">
<label htmlFor={handler.id}>
<input
type="checkbox"
id={handler.id}
name={handler.name}
checked={!!handler.value}
onChange={handler.onChange}
className="small-margin small"
/>
Proposer une option &apos;autre&apos; avec un texte libre
</label>
</div>
);
}
return null;
}

View file

@ -1,40 +0,0 @@
import React from 'react';
import type { Handler } from '../types';
export function TypeDeChampDropDownSecondary({
isVisible,
libelleHandler,
descriptionHandler
}: {
isVisible: boolean;
libelleHandler: Handler<HTMLInputElement>;
descriptionHandler: Handler<HTMLTextAreaElement>;
}) {
if (isVisible) {
return (
<div className="cell">
<label htmlFor={libelleHandler.id}>Libellé secondaire</label>
<input
type="text"
id={libelleHandler.id}
name={libelleHandler.name}
value={libelleHandler.value ?? ''}
onChange={libelleHandler.onChange}
className="small-margin small"
/>
<label htmlFor={descriptionHandler.id}>Description secondaire</label>
<textarea
id={descriptionHandler.id}
name={descriptionHandler.name}
value={descriptionHandler.value ?? ''}
onChange={descriptionHandler.onChange}
rows={3}
cols={40}
className="small-margin small"
/>
</div>
);
}
return null;
}

View file

@ -1,98 +0,0 @@
import React, { ChangeEvent } from 'react';
import Uploader from '../../../shared/activestorage/uploader';
import type { Handler } from '../types';
export function TypeDeChampPieceJustificative({
isVisible,
isTitreIdentite,
url,
filename,
handler,
directUploadUrl
}: {
isVisible: boolean;
isTitreIdentite: boolean;
url?: string;
filename?: string;
handler: Handler<HTMLInputElement>;
directUploadUrl: string;
}) {
if (isVisible) {
const hasFile = !!filename;
return (
<div className="cell">
<label htmlFor={handler.id}>Modèle</label>
<FileInformation isVisible={hasFile} url={url} filename={filename} />
<input
type="file"
id={handler.id}
name={handler.name}
onChange={onFileChange(handler, directUploadUrl)}
className="small-margin small"
/>
</div>
);
}
if (isTitreIdentite) {
return (
<div className="cell">
<p id={`${handler.id}-description`}>
Dans le cadre de la RGPD, le titre d&apos;identité sera supprimé lors
de l&apos;acceptation du dossier
</p>
</div>
);
}
return null;
}
function FileInformation({
isVisible,
url,
filename
}: {
isVisible: boolean;
url?: string;
filename?: string;
}) {
if (isVisible) {
return (
<>
<a href={url} rel="noopener noreferrer" target="_blank">
{filename}
</a>
<br /> Modifier :
</>
);
}
return null;
}
function onFileChange(
handler: Handler<HTMLInputElement>,
directUploadUrl: string
): (event: ChangeEvent<HTMLInputElement>) => void {
return async ({ target }) => {
const file = (target.files ?? [])[0];
if (file) {
const signedId = await uploadFile(target, file, directUploadUrl);
handler.onChange({
target: { value: signedId }
} as ChangeEvent<HTMLInputElement>);
}
};
}
function uploadFile(
input: HTMLInputElement,
file: File,
directUploadUrl: string
) {
const controller = new Uploader(input, file, directUploadUrl);
return controller.start().then((signedId) => {
input.value = '';
return signedId;
});
}

View file

@ -1,61 +0,0 @@
import React, { useReducer } from 'react';
import { PlusIcon } from '@heroicons/react/outline';
import { SortableContainer, addChampLabel } from '../utils';
import { TypeDeChampComponent } from './TypeDeChamp';
import typeDeChampsReducer from '../typeDeChampsReducer';
import type { State, TypeDeChamp } from '../types';
export function TypeDeChampRepetitionOptions({
isVisible,
state: parentState,
typeDeChamp
}: {
isVisible: boolean;
state: State;
typeDeChamp: TypeDeChamp;
}) {
const [state, dispatch] = useReducer(typeDeChampsReducer, parentState);
if (isVisible) {
return (
<div className="repetition flex-grow cell">
<SortableContainer
onSortEnd={(params) =>
dispatch({ type: 'onSortTypeDeChamps', params })
}
useDragHandle
>
{state.typeDeChamps.map((typeDeChamp, index) => (
<TypeDeChampComponent
dispatch={dispatch}
idx={index}
index={index}
isFirstItem={index === 0}
isLastItem={index === state.typeDeChamps.length - 1}
key={`champ-${typeDeChamp.id}`}
state={state}
typeDeChamp={typeDeChamp}
/>
))}
</SortableContainer>
<button
className="button"
onClick={() =>
dispatch({
type: 'addNewRepetitionTypeDeChamp',
params: { typeDeChamp },
done: (estimatedFillDuration: number) =>
dispatch({ type: 'refresh', params: { estimatedFillDuration } })
})
}
>
<PlusIcon className="icon-size" />
&nbsp;&nbsp;
{addChampLabel(state.isAnnotation)}
</button>
</div>
);
}
return null;
}

View file

@ -1,29 +0,0 @@
import React from 'react';
import type { Handler } from '../types';
export function TypeDeChampTypesSelect({
handler,
options
}: {
handler: Handler<HTMLSelectElement>;
options: [label: string, type: string][];
}) {
return (
<div className="cell">
<select
id={handler.id}
name={handler.name}
onChange={handler.onChange}
value={handler.value}
className="small-margin small inline"
>
{options.map(([label, key]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
);
}

View file

@ -1,98 +0,0 @@
import React, { useReducer } from 'react';
import { PlusIcon, ArrowCircleDownIcon } from '@heroicons/react/outline';
import { SortableContainer, addChampLabel } from '../utils';
import { TypeDeChampComponent } from './TypeDeChamp';
import typeDeChampsReducer from '../typeDeChampsReducer';
import type { TypeDeChamp, State } from '../types';
type TypeDeChampsProps = {
state: State;
typeDeChamps: TypeDeChamp[];
};
export function TypeDeChamps({
state: rootState,
typeDeChamps
}: TypeDeChampsProps) {
const [state, dispatch] = useReducer(typeDeChampsReducer, {
...rootState,
typeDeChamps
});
const hasUnsavedChamps = state.typeDeChamps.some(
(tdc) => tdc.id == undefined
);
const formattedEstimatedFillDuration = state.estimatedFillDuration
? Math.max(1, Math.round(state.estimatedFillDuration / 60)) + ' mn'
: '';
return (
<div className="champs-editor">
<SortableContainer
onSortEnd={(params) => dispatch({ type: 'onSortTypeDeChamps', params })}
lockAxis="y"
useDragHandle
>
{state.typeDeChamps.map((typeDeChamp, index) => (
<TypeDeChampComponent
dispatch={dispatch}
idx={index}
index={index}
isFirstItem={index === 0}
isLastItem={index === state.typeDeChamps.length - 1}
key={`champ-${typeDeChamp.id}`}
state={state}
typeDeChamp={typeDeChamp}
/>
))}
</SortableContainer>
{state.typeDeChamps.length === 0 && (
<h2>
<ArrowCircleDownIcon className="icon-size" />
&nbsp;&nbsp;Cliquez sur le bouton «&nbsp;
{addChampLabel(state.isAnnotation)}&nbsp;» pour créer votre premier
champ.
</h2>
)}
<div className="footer">&nbsp;</div>
<div className="buttons">
<button
className="button"
disabled={hasUnsavedChamps}
onClick={() =>
dispatch({
type: 'addNewTypeDeChamp',
done: (estimatedFillDuration: number) => {
dispatch({
type: 'refresh',
params: { estimatedFillDuration }
});
}
})
}
>
<PlusIcon className="icon-size" />
&nbsp;&nbsp;
{addChampLabel(state.isAnnotation)}
</button>
{state.estimatedFillDuration > 0 && (
<span className="fill-duration">
Durée de remplissage estimée&nbsp;:{' '}
<a
href="https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-administrateur#g.-estimation-de-la-duree-de-remplissage"
target="_blank"
rel="noopener noreferrer"
>
{formattedEstimatedFillDuration}
</a>
</span>
)}
<a className="button accepted" href={state.continuerUrl}>
Continuer &gt;
</a>
</div>
</div>
);
}

View file

@ -1,57 +0,0 @@
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][];
estimatedFillDuration: number;
};
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;
estimatedFillDuration?: number;
};
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,
estimatedFillDuration: props.estimatedFillDuration
};
return <TypeDeChamps state={state} typeDeChamps={props.typeDeChamps} />;
}

View file

@ -1,83 +0,0 @@
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);
});
}
export function estimateFillDuration(queue: OperationsQueue): Promise<number> {
return queue
.enqueue({
path: `/estimate_fill_duration`,
method: 'get'
})
.then((data) => {
const responseData = data as EstimatedFillDurationResponseData;
return responseData.estimated_fill_duration;
});
}
type ResponseData = { type_de_champ: Record<string, string> };
type EstimatedFillDurationResponseData = { estimated_fill_duration: number };
function handleResponseData(
typeDeChamp: Partial<TypeDeChamp>,
{ type_de_champ }: ResponseData
) {
for (const field of RESPONSE_FIELDS) {
typeDeChamp[field] = type_de_champ[field];
}
}
const RESPONSE_FIELDS = [
'id',
'piece_justificative_template_filename',
'piece_justificative_template_url'
] as const;

View file

@ -1,360 +0,0 @@
import { debounce } from '@utils';
import {
createTypeDeChampOperation,
destroyTypeDeChampOperation,
moveTypeDeChampOperation,
updateTypeDeChampOperation,
estimateFillDuration
} from './operations';
import type { TypeDeChamp, State, Flash, OperationsQueue } from './types';
type AddNewTypeDeChampAction = {
type: 'addNewTypeDeChamp';
done: (estimatedFillDuration: number) => void;
};
type AddNewRepetitionTypeDeChampAction = {
type: 'addNewRepetitionTypeDeChamp';
params: { typeDeChamp: TypeDeChamp };
done: (estimatedFillDuration: number) => void;
};
type UpdateTypeDeChampAction = {
type: 'updateTypeDeChamp';
params: {
typeDeChamp: TypeDeChamp;
field: keyof TypeDeChamp;
value: string | boolean;
};
done: (estimatedFillDuration: number) => void;
};
type RemoveTypeDeChampAction = {
type: 'removeTypeDeChamp';
params: { typeDeChamp: TypeDeChamp };
done: (estimatedFillDuration: number) => void;
};
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';
params: { estimatedFillDuration: number };
};
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':
return addNewTypeDeChamp(state, state.typeDeChamps, action.done);
case 'addNewRepetitionTypeDeChamp':
return addNewRepetitionTypeDeChamp(
state,
state.typeDeChamps,
action.params,
action.done
);
case 'updateTypeDeChamp':
return updateTypeDeChamp(
state,
state.typeDeChamps,
action.params,
action.done
);
case 'removeTypeDeChamp':
return removeTypeDeChamp(
state,
state.typeDeChamps,
action.params,
action.done
);
case 'moveTypeDeChampUp':
return moveTypeDeChampUp(state, state.typeDeChamps, action.params);
case 'moveTypeDeChampDown':
return moveTypeDeChampDown(state, state.typeDeChamps, action.params);
case 'onSortTypeDeChamps':
return onSortTypeDeChamps(state, state.typeDeChamps, action.params);
case 'refresh':
return {
...state,
typeDeChamps: [...state.typeDeChamps],
estimatedFillDuration: action.params.estimatedFillDuration
};
}
}
function addTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
insertAfter: { index: number; target: HTMLDivElement } | null,
done: (estimatedFillDuration: number) => void
) {
const typeDeChamp = {
...state.defaultTypeDeChampAttributes
};
createTypeDeChampOperation(typeDeChamp, state.queue)
.then(async () => {
if (insertAfter) {
// Move the champ to the correct position server-side
await moveTypeDeChampOperation(
typeDeChamp as TypeDeChamp,
insertAfter.index,
state.queue
);
}
state.flash.success();
const estimatedFillDuration = await estimateFillDuration(state.queue);
done(estimatedFillDuration);
if (insertAfter) {
insertAfter.target.nextElementSibling?.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
})
.catch((message) => state.flash.error(message));
let newTypeDeChamps: TypeDeChamp[] = [
...typeDeChamps,
typeDeChamp as TypeDeChamp
];
if (insertAfter) {
// Move the champ to the correct position client-side
newTypeDeChamps = arrayMove(
newTypeDeChamps,
typeDeChamps.length,
insertAfter.index
);
}
return {
...state,
typeDeChamps: newTypeDeChamps
};
}
function addNewTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
done: (estimatedFillDuration: number) => void
) {
return addTypeDeChamp(state, typeDeChamps, findItemToInsertAfter(), done);
}
function addNewRepetitionTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: AddNewRepetitionTypeDeChampAction['params'],
done: (estimatedFillDuration: number) => void
) {
return addTypeDeChamp(
{
...state,
defaultTypeDeChampAttributes: {
...state.defaultTypeDeChampAttributes,
parent_id: typeDeChamp.id
}
},
typeDeChamps,
null,
done
);
}
function updateTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp, field, value }: UpdateTypeDeChampAction['params'],
done: (estimatedFillDuration: number) => void
) {
if (field == 'type_champ' && !typeDeChamp.drop_down_list_value) {
switch (value) {
case 'linked_drop_down_list':
typeDeChamp.drop_down_list_value =
'--Fromage--\nbleu de sassenage\npicodon\n--Dessert--\néclair\ntarte aux pommes\n';
break;
case 'drop_down_list':
case 'multiple_drop_down_list':
typeDeChamp.drop_down_list_value = 'Premier choix\nDeuxième choix';
}
}
if (field.startsWith('options.')) {
const [, optionsField] = field.split('.');
typeDeChamp.editable_options = typeDeChamp.editable_options || {};
typeDeChamp.editable_options[optionsField] = value as string;
} else {
Object.assign(typeDeChamp, { [field]: value });
}
getUpdateHandler(typeDeChamp, state)(done);
return {
...state,
typeDeChamps: [...typeDeChamps]
};
}
function removeTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: RemoveTypeDeChampAction['params'],
done: (estimatedFillDuration: number) => void
) {
destroyTypeDeChampOperation(typeDeChamp, state.queue)
.then(() => {
state.flash.success();
return estimateFillDuration(state.queue);
})
.then((estimatedFillDuration: number) => {
done(estimatedFillDuration);
})
.catch((message) => state.flash.error(message));
return {
...state,
typeDeChamps: arrayRemove(typeDeChamps, typeDeChamp)
};
}
function moveTypeDeChampUp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: MoveTypeDeChampUpAction['params']
) {
const oldIndex = typeDeChamps.indexOf(typeDeChamp);
const newIndex = oldIndex - 1;
moveTypeDeChampOperation(typeDeChamp, newIndex, state.queue)
.then(() => state.flash.success())
.catch((message) => state.flash.error(message));
return {
...state,
typeDeChamps: arrayMove(typeDeChamps, oldIndex, newIndex)
};
}
function moveTypeDeChampDown(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: MoveTypeDeChampDownAction['params']
) {
const oldIndex = typeDeChamps.indexOf(typeDeChamp);
const newIndex = oldIndex + 1;
moveTypeDeChampOperation(typeDeChamp, newIndex, state.queue)
.then(() => state.flash.success())
.catch((message) => state.flash.error(message));
return {
...state,
typeDeChamps: arrayMove(typeDeChamps, oldIndex, newIndex)
};
}
function onSortTypeDeChamps(
state: State,
typeDeChamps: TypeDeChamp[],
{ oldIndex, newIndex }: OnSortTypeDeChampsAction['params']
) {
moveTypeDeChampOperation(typeDeChamps[oldIndex], newIndex, state.queue)
.then(() => state.flash.success())
.catch((message) => state.flash.error(message));
return {
...state,
typeDeChamps: arrayMove(typeDeChamps, oldIndex, newIndex)
};
}
function arrayRemove<T>(array: T[], item: T) {
array = Array.from(array);
array.splice(array.indexOf(item), 1);
return array;
}
function arrayMove<T>(array: T[], from: number, to: number) {
array = Array.from(array);
array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]);
return array;
}
const updateHandlers = new WeakMap();
function getUpdateHandler(
typeDeChamp: TypeDeChamp,
{ queue, flash }: { queue: OperationsQueue; flash: Flash }
) {
let handler = updateHandlers.get(typeDeChamp);
if (!handler) {
handler = debounce(
(done: (estimatedFillDuration: number) => void) =>
updateTypeDeChampOperation(typeDeChamp, queue)
.then(() => {
flash.success();
return estimateFillDuration(queue);
})
.then((estimatedFillDuration: number) => {
done(estimatedFillDuration);
})
.catch((message) => flash.error(message)),
200
);
updateHandlers.set(typeDeChamp, handler);
}
return handler;
}
function findItemToInsertAfter() {
const target = getLastVisibleTypeDeChamp();
if (target) {
return {
target,
index: parseInt(target.dataset.index ?? '0') + 1
};
} else {
return null;
}
}
function getLastVisibleTypeDeChamp() {
const typeDeChamps =
document.querySelectorAll<HTMLDivElement>('[data-in-view]');
const target = typeDeChamps[typeDeChamps.length - 1];
if (target) {
const parentTarget = target.closest<HTMLDivElement>('[data-repetition]');
if (parentTarget) {
return parentTarget;
}
return target;
}
}

View file

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

View file

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

View file

@ -56,8 +56,7 @@ registerComponents({
ComboRegionsSearch: () => import('../components/ComboRegionsSearch'),
MapEditor: () => import('../components/MapEditor'),
MapReader: () => import('../components/MapReader'),
Trix: () => import('../components/Trix'),
TypesDeChampEditor: () => import('../components/TypesDeChampEditor')
Trix: () => import('../components/Trix')
});
// This is the global application namespace where we expose helpers used from rails views

View file

@ -153,20 +153,6 @@ class ProcedureRevision < ApplicationRecord
end
end
def types_de_champ_public_as_json
types_de_champ = types_de_champ_public.includes(piece_justificative_template_attachment: :blob)
tdcs_as_json = types_de_champ.map(&:as_json_for_editor)
children_types_de_champ_as_json(tdcs_as_json, types_de_champ.filter(&:repetition?))
tdcs_as_json
end
def types_de_champ_private_as_json
types_de_champ = types_de_champ_private.includes(piece_justificative_template_attachment: :blob)
tdcs_as_json = types_de_champ.map(&:as_json_for_editor)
children_types_de_champ_as_json(tdcs_as_json, types_de_champ.filter(&:repetition?))
tdcs_as_json
end
# Estimated duration to fill the form, in seconds.
#
# If the revision is locked (i.e. published), the result is cached (because type de champs can no longer be mutated).

View file

@ -16,6 +16,8 @@
class TypeDeChamp < ApplicationRecord
self.ignored_columns = [:migrated_parent, :revision_id, :parent_id, :order_place]
FEATURE_FLAGS = {}
enum type_champs: {
text: 'text',
textarea: 'textarea',
@ -299,63 +301,6 @@ class TypeDeChamp < ApplicationRecord
options.slice(*TypesDeChamp::CarteTypeDeChamp::LAYERS)
end
FEATURE_FLAGS = {}
def self.type_de_champ_types_for(procedure, user)
has_legacy_number = (procedure.types_de_champ + procedure.types_de_champ_private).any?(&:legacy_number?)
filter_featured_tdc = -> (tdc) do
feature_name = FEATURE_FLAGS[tdc]
feature_name.blank? || Flipper.enabled?(feature_name, user)
end
filter_tdc = -> (tdc) do
case tdc
when TypeDeChamp.type_champs.fetch(:number)
has_legacy_number
when TypeDeChamp.type_champs.fetch(:cnaf)
procedure.cnaf_enabled?
when TypeDeChamp.type_champs.fetch(:dgfip)
procedure.dgfip_enabled?
when TypeDeChamp.type_champs.fetch(:pole_emploi)
procedure.pole_emploi_enabled?
when TypeDeChamp.type_champs.fetch(:mesri)
procedure.mesri_enabled?
else
true
end
end
type_champs
.keys
.filter(&filter_tdc)
.filter(&filter_featured_tdc)
.map { |tdc| [I18n.t("activerecord.attributes.type_de_champ.type_champs.#{tdc}"), tdc] }
.sort_by(&:first)
end
def as_json_for_editor
as_json(
except: [
:created_at,
:options,
:private,
:stable_id,
:type,
:updated_at
],
methods: [
:drop_down_list_value,
:drop_down_other,
:drop_down_secondary_libelle,
:drop_down_secondary_description,
:piece_justificative_template_filename,
:piece_justificative_template_url,
:editable_options
]
)
end
def read_attribute_for_serialization(name)
if name == 'id'
stable_id

View file

@ -52,7 +52,7 @@ shared_examples 'type_de_champ_spec' do
before do
allow(tdc).to receive(:piece_justificative_template).and_return(template_double)
tdc.update_attribute('type_champ', target_type_champ)
tdc.update(type_champ: target_type_champ)
end
context 'when the target type_champ is not pj' do
@ -88,7 +88,7 @@ shared_examples 'type_de_champ_spec' do
let(:tdc) { create(:type_de_champ_repetition, :with_types_de_champ, procedure: procedure) }
before do
tdc.update_attribute('type_champ', target_type_champ)
tdc.update(type_champ: target_type_champ)
end
context 'when the target type_champ is not repetition' do
@ -104,7 +104,7 @@ shared_examples 'type_de_champ_spec' do
let(:tdc) { create(:type_de_champ_drop_down_list) }
before do
tdc.update_attribute('type_champ', target_type_champ)
tdc.update(type_champ: target_type_champ)
end
context 'when the target type_champ is not drop_down_list' do
@ -166,27 +166,6 @@ shared_examples 'type_de_champ_spec' do
end
end
describe '#type_de_champ_types_for' do
let(:procedure) { create(:procedure) }
let(:user) { create(:user) }
context 'when procedure without legacy "number"' do
it 'should have "nombre decimal" instead of "nombre"' do
expect(TypeDeChamp.type_de_champ_types_for(procedure, user).find { |tdc| tdc.last == TypeDeChamp.type_champs.fetch(:number) }).to be_nil
expect(TypeDeChamp.type_de_champ_types_for(procedure, user).find { |tdc| tdc.last == TypeDeChamp.type_champs.fetch(:decimal_number) }).not_to be_nil
end
end
context 'when procedure with legacy "number"' do
let(:procedure) { create(:procedure, :with_number) }
it 'should have "nombre decimal" and "nombre"' do
expect(TypeDeChamp.type_de_champ_types_for(procedure, user).find { |tdc| tdc.last == TypeDeChamp.type_champs.fetch(:number) }).not_to be_nil
expect(TypeDeChamp.type_de_champ_types_for(procedure, user).find { |tdc| tdc.last == TypeDeChamp.type_champs.fetch(:decimal_number) }).not_to be_nil
end
end
end
describe '#drop_down_list_options' do
let(:value) do
<<~EOS