js: display estimated duration in champ editor

This commit is contained in:
Pierre de La Morinerie 2022-05-25 14:48:32 +00:00
parent 3bd637fc56
commit c738d7d07f
8 changed files with 121 additions and 21 deletions

View file

@ -143,4 +143,19 @@
padding-bottom: 15px; padding-bottom: 15px;
padding-top: 15px; padding-top: 15px;
} }
.fill-duration {
align-self: center;
font-size: 14px;
a {
text-decoration: underline;
text-decoration-style: dotted;
// Remove the icon indicating an external link (for less visual noise)
&[target="_blank"]::after {
display: none;
}
}
}
} }

View file

@ -37,7 +37,8 @@ module ProcedureHelper
typeDeChamps: procedure.draft_revision.types_de_champ_public_as_json, typeDeChamps: procedure.draft_revision.types_de_champ_public_as_json,
baseUrl: admin_procedure_types_de_champ_path(procedure), baseUrl: admin_procedure_types_de_champ_path(procedure),
directUploadUrl: rails_direct_uploads_url, directUploadUrl: rails_direct_uploads_url,
continuerUrl: admin_procedure_path(procedure) continuerUrl: admin_procedure_path(procedure),
estimatedFillDuration: procedure.feature_enabled?(:procedure_estimate_fill_duration) ? procedure.draft_revision.estimated_fill_duration : 0
} }
end end

View file

@ -93,7 +93,13 @@ export const TypeDeChampComponent = SortableElement<TypeDeChampProps>(
if (confirm('Êtes vous sûr de vouloir supprimer ce champ ?')) if (confirm('Êtes vous sûr de vouloir supprimer ce champ ?'))
dispatch({ dispatch({
type: 'removeTypeDeChamp', type: 'removeTypeDeChamp',
params: { typeDeChamp } params: { typeDeChamp },
done: (estimatedFillDuration) => {
dispatch({
type: 'refresh',
params: { estimatedFillDuration }
});
}
}); });
}} }}
> >
@ -223,7 +229,12 @@ function createUpdateHandler(
field, field,
value: readValue(target) value: readValue(target)
}, },
done: () => dispatch({ type: 'refresh' }) done: (estimatedFillDuration: number) => {
return dispatch({
type: 'refresh',
params: { estimatedFillDuration }
});
}
}) })
}; };
} }

View file

@ -45,7 +45,8 @@ export function TypeDeChampRepetitionOptions({
dispatch({ dispatch({
type: 'addNewRepetitionTypeDeChamp', type: 'addNewRepetitionTypeDeChamp',
params: { typeDeChamp }, params: { typeDeChamp },
done: () => dispatch({ type: 'refresh' }) done: (estimatedFillDuration: number) =>
dispatch({ type: 'refresh', params: { estimatedFillDuration } })
}) })
} }
> >

View file

@ -24,6 +24,10 @@ export function TypeDeChamps({
(tdc) => tdc.id == undefined (tdc) => tdc.id == undefined
); );
const formattedEstimatedFillDuration = state.estimatedFillDuration
? Math.max(1, Math.round(state.estimatedFillDuration / 60)) + ' mn'
: '';
return ( return (
<div className="champs-editor"> <div className="champs-editor">
<SortableContainer <SortableContainer
@ -60,7 +64,12 @@ export function TypeDeChamps({
onClick={() => onClick={() =>
dispatch({ dispatch({
type: 'addNewTypeDeChamp', type: 'addNewTypeDeChamp',
done: () => dispatch({ type: 'refresh' }) done: (estimatedFillDuration: number) => {
dispatch({
type: 'refresh',
params: { estimatedFillDuration }
});
}
}) })
} }
> >
@ -68,6 +77,18 @@ export function TypeDeChamps({
&nbsp;&nbsp; &nbsp;&nbsp;
{addChampLabel(state.isAnnotation)} {addChampLabel(state.isAnnotation)}
</button> </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}> <a className="button accepted" href={state.continuerUrl}>
Continuer &gt; Continuer &gt;
</a> </a>

View file

@ -12,6 +12,7 @@ type TypesDeChampEditorProps = {
isAnnotation: boolean; isAnnotation: boolean;
typeDeChamps: TypeDeChamp[]; typeDeChamps: TypeDeChamp[];
typeDeChampsTypes: [label: string, type: string][]; typeDeChampsTypes: [label: string, type: string][];
estimatedFillDuration: number;
}; };
export type State = Omit<TypesDeChampEditorProps, 'baseUrl'> & { export type State = Omit<TypesDeChampEditorProps, 'baseUrl'> & {
@ -27,6 +28,7 @@ export type State = Omit<TypesDeChampEditorProps, 'baseUrl'> & {
| 'mandatory' | 'mandatory'
>; >;
prefix?: string; prefix?: string;
estimatedFillDuration?: number;
}; };
export default function TypesDeChampEditor(props: TypesDeChampEditorProps) { export default function TypesDeChampEditor(props: TypesDeChampEditorProps) {
@ -47,7 +49,8 @@ export default function TypesDeChampEditor(props: TypesDeChampEditorProps) {
typeDeChampsTypes: props.typeDeChampsTypes, typeDeChampsTypes: props.typeDeChampsTypes,
directUploadUrl: props.directUploadUrl, directUploadUrl: props.directUploadUrl,
isAnnotation: props.isAnnotation, isAnnotation: props.isAnnotation,
continuerUrl: props.continuerUrl continuerUrl: props.continuerUrl,
estimatedFillDuration: props.estimatedFillDuration
}; };
return <TypeDeChamps state={state} typeDeChamps={props.typeDeChamps} />; return <TypeDeChamps state={state} typeDeChamps={props.typeDeChamps} />;

View file

@ -3,19 +3,20 @@ import {
createTypeDeChampOperation, createTypeDeChampOperation,
destroyTypeDeChampOperation, destroyTypeDeChampOperation,
moveTypeDeChampOperation, moveTypeDeChampOperation,
updateTypeDeChampOperation updateTypeDeChampOperation,
estimateFillDuration
} from './operations'; } from './operations';
import type { TypeDeChamp, State, Flash, OperationsQueue } from './types'; import type { TypeDeChamp, State, Flash, OperationsQueue } from './types';
type AddNewTypeDeChampAction = { type AddNewTypeDeChampAction = {
type: 'addNewTypeDeChamp'; type: 'addNewTypeDeChamp';
done: () => void; done: (estimatedFillDuration: number) => void;
}; };
type AddNewRepetitionTypeDeChampAction = { type AddNewRepetitionTypeDeChampAction = {
type: 'addNewRepetitionTypeDeChamp'; type: 'addNewRepetitionTypeDeChamp';
params: { typeDeChamp: TypeDeChamp }; params: { typeDeChamp: TypeDeChamp };
done: () => void; done: (estimatedFillDuration: number) => void;
}; };
type UpdateTypeDeChampAction = { type UpdateTypeDeChampAction = {
@ -25,12 +26,13 @@ type UpdateTypeDeChampAction = {
field: keyof TypeDeChamp; field: keyof TypeDeChamp;
value: string | boolean; value: string | boolean;
}; };
done: () => void; done: (estimatedFillDuration: number) => void;
}; };
type RemoveTypeDeChampAction = { type RemoveTypeDeChampAction = {
type: 'removeTypeDeChamp'; type: 'removeTypeDeChamp';
params: { typeDeChamp: TypeDeChamp }; params: { typeDeChamp: TypeDeChamp };
done: (estimatedFillDuration: number) => void;
}; };
type MoveTypeDeChampUpAction = { type MoveTypeDeChampUpAction = {
@ -50,6 +52,7 @@ type OnSortTypeDeChampsAction = {
type RefreshAction = { type RefreshAction = {
type: 'refresh'; type: 'refresh';
params: { estimatedFillDuration: number };
}; };
export type Action = export type Action =
@ -84,7 +87,12 @@ export default function typeDeChampsReducer(
action.done action.done
); );
case 'removeTypeDeChamp': case 'removeTypeDeChamp':
return removeTypeDeChamp(state, state.typeDeChamps, action.params); return removeTypeDeChamp(
state,
state.typeDeChamps,
action.params,
action.done
);
case 'moveTypeDeChampUp': case 'moveTypeDeChampUp':
return moveTypeDeChampUp(state, state.typeDeChamps, action.params); return moveTypeDeChampUp(state, state.typeDeChamps, action.params);
case 'moveTypeDeChampDown': case 'moveTypeDeChampDown':
@ -92,7 +100,11 @@ export default function typeDeChampsReducer(
case 'onSortTypeDeChamps': case 'onSortTypeDeChamps':
return onSortTypeDeChamps(state, state.typeDeChamps, action.params); return onSortTypeDeChamps(state, state.typeDeChamps, action.params);
case 'refresh': case 'refresh':
return { ...state, typeDeChamps: [...state.typeDeChamps] }; return {
...state,
typeDeChamps: [...state.typeDeChamps],
estimatedFillDuration: action.params.estimatedFillDuration
};
} }
} }
@ -100,7 +112,7 @@ function addTypeDeChamp(
state: State, state: State,
typeDeChamps: TypeDeChamp[], typeDeChamps: TypeDeChamp[],
insertAfter: { index: number; target: HTMLDivElement } | null, insertAfter: { index: number; target: HTMLDivElement } | null,
done: () => void done: (estimatedFillDuration: number) => void
) { ) {
const typeDeChamp = { const typeDeChamp = {
...state.defaultTypeDeChampAttributes ...state.defaultTypeDeChampAttributes
@ -117,7 +129,8 @@ function addTypeDeChamp(
); );
} }
state.flash.success(); state.flash.success();
done(); const estimatedFillDuration = await estimateFillDuration(state.queue);
done(estimatedFillDuration);
if (insertAfter) { if (insertAfter) {
insertAfter.target.nextElementSibling?.scrollIntoView({ insertAfter.target.nextElementSibling?.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
@ -150,7 +163,7 @@ function addTypeDeChamp(
function addNewTypeDeChamp( function addNewTypeDeChamp(
state: State, state: State,
typeDeChamps: TypeDeChamp[], typeDeChamps: TypeDeChamp[],
done: () => void done: (estimatedFillDuration: number) => void
) { ) {
return addTypeDeChamp(state, typeDeChamps, findItemToInsertAfter(), done); return addTypeDeChamp(state, typeDeChamps, findItemToInsertAfter(), done);
} }
@ -159,7 +172,7 @@ function addNewRepetitionTypeDeChamp(
state: State, state: State,
typeDeChamps: TypeDeChamp[], typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: AddNewRepetitionTypeDeChampAction['params'], { typeDeChamp }: AddNewRepetitionTypeDeChampAction['params'],
done: () => void done: (estimatedFillDuration: number) => void
) { ) {
return addTypeDeChamp( return addTypeDeChamp(
{ {
@ -179,7 +192,7 @@ function updateTypeDeChamp(
state: State, state: State,
typeDeChamps: TypeDeChamp[], typeDeChamps: TypeDeChamp[],
{ typeDeChamp, field, value }: UpdateTypeDeChampAction['params'], { typeDeChamp, field, value }: UpdateTypeDeChampAction['params'],
done: () => void done: (estimatedFillDuration: number) => void
) { ) {
if (field == 'type_champ' && !typeDeChamp.drop_down_list_value) { if (field == 'type_champ' && !typeDeChamp.drop_down_list_value) {
switch (value) { switch (value) {
@ -212,10 +225,17 @@ function updateTypeDeChamp(
function removeTypeDeChamp( function removeTypeDeChamp(
state: State, state: State,
typeDeChamps: TypeDeChamp[], typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: RemoveTypeDeChampAction['params'] { typeDeChamp }: RemoveTypeDeChampAction['params'],
done: (estimatedFillDuration: number) => void
) { ) {
destroyTypeDeChampOperation(typeDeChamp, state.queue) destroyTypeDeChampOperation(typeDeChamp, state.queue)
.then(() => state.flash.success()) .then(() => {
state.flash.success();
return estimateFillDuration(state.queue);
})
.then((estimatedFillDuration: number) => {
done(estimatedFillDuration);
})
.catch((message) => state.flash.error(message)); .catch((message) => state.flash.error(message));
return { return {
@ -295,11 +315,14 @@ function getUpdateHandler(
let handler = updateHandlers.get(typeDeChamp); let handler = updateHandlers.get(typeDeChamp);
if (!handler) { if (!handler) {
handler = debounce( handler = debounce(
(done: () => void) => (done: (estimatedFillDuration: number) => void) =>
updateTypeDeChampOperation(typeDeChamp, queue) updateTypeDeChampOperation(typeDeChamp, queue)
.then(() => { .then(() => {
flash.success(); flash.success();
done(); return estimateFillDuration(queue);
})
.then((estimatedFillDuration: number) => {
done(estimatedFillDuration);
}) })
.catch((message) => flash.error(message)), .catch((message) => flash.error(message)),
200 200

View file

@ -143,4 +143,29 @@ describe 'As an administrateur I can edit types de champ', js: true do
expect(page).to have_content('Un menu') expect(page).to have_content('Un menu')
end end
context "when the estimated fill duration is enabled" do
before { Flipper.enable(:procedure_estimated_fill_duration) }
after { Flipper.disable(:procedure_estimated_fill_duration) }
scenario "displaying the estimated fill duration" do
# It doesn't display anything when there are no champs
expect(page).not_to have_content('Durée de remplissage estimé')
# It displays the estimate when adding a new champ
add_champ
select('Pièce justificative', from: 'champ-0-type_champ')
expect(page).to have_content('Durée de remplissage estimée : 1 mn')
# It updates the estimate when updating the champ
check 'Obligatoire'
expect(page).to have_content('Durée de remplissage estimée : 3 mn')
# It updates the estimate when removing the champ
page.accept_alert do
click_on 'Supprimer'
end
expect(page).not_to have_content('Durée de remplissage estimée')
end
end
end end