Merge pull request #7409 from betagouv/estimated-completion-time-admin

Administrateur : affichage de la durée estimée de la démarche dans l'éditeur de champs (derrière un feature-flag)
This commit is contained in:
Pierre de La Morinerie 2022-05-26 11:41:21 +02:00 committed by GitHub
commit d21896b1e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 157 additions and 31 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

@ -1,6 +1,6 @@
module Administrateurs module Administrateurs
class TypesDeChampController < AdministrateurController class TypesDeChampController < AdministrateurController
before_action :retrieve_procedure, only: [:create, :update, :move, :destroy] before_action :retrieve_procedure, only: [:create, :update, :move, :estimate_fill_duration, :destroy]
before_action :procedure_revisable?, only: [:create, :update, :move, :destroy] before_action :procedure_revisable?, only: [:create, :update, :move, :destroy]
def create def create
@ -31,6 +31,15 @@ module Administrateurs
head :no_content head :no_content
end end
def estimate_fill_duration
estimate = if @procedure.feature_enabled?(:procedure_estimated_fill_duration)
@procedure.draft_revision.estimated_fill_duration
else
0
end
render json: { estimated_fill_duration: estimate }
end
def destroy def destroy
@procedure.draft_revision.remove_type_de_champ(params[:id]) @procedure.draft_revision.remove_type_de_champ(params[:id])
reset_procedure reset_procedure

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

@ -4,7 +4,7 @@ import invariant from 'tiny-invariant';
type Operation = { type Operation = {
path: string; path: string;
method: string; method: string;
payload: unknown; payload?: unknown;
resolve: (value: unknown) => void; resolve: (value: unknown) => void;
reject: () => void; reject: () => void;
}; };
@ -45,7 +45,8 @@ export class OperationsQueue {
const url = `${this.baseUrl}${path}`; const url = `${this.baseUrl}${path}`;
try { try {
const data = await httpRequest(url, { method, json: payload }).json(); const json = payload;
const data = await httpRequest(url, { method, json }).json();
resolve(data); resolve(data);
} catch (e) { } catch (e) {
handleError(e as ResponseError, reject); handleError(e as ResponseError, reject);

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

@ -52,8 +52,20 @@ export function updateTypeDeChampOperation(
handleResponseData(typeDeChamp, data as ResponseData); 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 ResponseData = { type_de_champ: Record<string, string> };
type EstimatedFillDurationResponseData = { estimated_fill_duration: number };
function handleResponseData( function handleResponseData(
typeDeChamp: Partial<TypeDeChamp>, typeDeChamp: Partial<TypeDeChamp>,

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

@ -461,6 +461,9 @@ Rails.application.routes.draw do
resources :experts, controller: 'experts_procedures', only: [:index, :create, :update, :destroy] resources :experts, controller: 'experts_procedures', only: [:index, :create, :update, :destroy]
resources :types_de_champ, only: [:create, :update, :destroy] do resources :types_de_champ, only: [:create, :update, :destroy] do
collection do
get :estimate_fill_duration
end
member do member do
patch :move patch :move
end end

View file

@ -7,7 +7,7 @@ describe 'As an administrateur I can edit types de champ', js: true do
visit champs_admin_procedure_path(procedure) visit champs_admin_procedure_path(procedure)
end end
it "Add a new champ" do scenario "adding a new champ" do
add_champ add_champ
fill_in 'champ-0-libelle', with: 'libellé de champ' fill_in 'champ-0-libelle', with: 'libellé de champ'
@ -15,7 +15,7 @@ describe 'As an administrateur I can edit types de champ', js: true do
expect(page).to have_content('Formulaire enregistré') expect(page).to have_content('Formulaire enregistré')
end end
it "Add multiple champs" do scenario "adding multiple champs" do
# Champs are created when clicking the 'Add field' button # Champs are created when clicking the 'Add field' button
add_champs(count: 3) add_champs(count: 3)
@ -47,12 +47,13 @@ describe 'As an administrateur I can edit types de champ', js: true do
expect(page).to have_content('Supprimer', count: 2) expect(page).to have_content('Supprimer', count: 2)
end end
it "Remove champs" do scenario "removing champs" do
add_champ(remove_flash_message: true) add_champ(remove_flash_message: true)
fill_in 'champ-0-libelle', with: 'libellé de champ' fill_in 'champ-0-libelle', with: 'libellé de champ'
blur blur
expect(page).to have_content('Formulaire enregistré') expect(page).to have_content('Formulaire enregistré')
page.refresh page.refresh
page.accept_alert do page.accept_alert do
@ -65,7 +66,7 @@ describe 'As an administrateur I can edit types de champ', js: true do
expect(page).to have_content('Supprimer', count: 0) expect(page).to have_content('Supprimer', count: 0)
end end
it "Only add valid champs" do scenario "adding an invalid champ" do
add_champ(remove_flash_message: true) add_champ(remove_flash_message: true)
fill_in 'champ-0-libelle', with: '' fill_in 'champ-0-libelle', with: ''
@ -78,7 +79,7 @@ describe 'As an administrateur I can edit types de champ', js: true do
expect(page).to have_content('Formulaire enregistré') expect(page).to have_content('Formulaire enregistré')
end end
it "Add repetition champ" do scenario "adding a repetition champ" do
add_champ(remove_flash_message: true) add_champ(remove_flash_message: true)
select('Bloc répétable', from: 'champ-0-type_champ') select('Bloc répétable', from: 'champ-0-type_champ')
@ -109,7 +110,7 @@ describe 'As an administrateur I can edit types de champ', js: true do
expect(page).to have_content('Supprimer', count: 3) expect(page).to have_content('Supprimer', count: 3)
end end
it "Add carte champ" do scenario "adding a carte champ" do
add_champ add_champ
select('Carte', from: 'champ-0-type_champ') select('Carte', from: 'champ-0-type_champ')
@ -128,7 +129,7 @@ describe 'As an administrateur I can edit types de champ', js: true do
end end
end end
it "Add dropdown champ" do scenario "adding a dropdown champ" do
add_champ add_champ
select('Choix parmi une liste', from: 'champ-0-type_champ') select('Choix parmi une liste', from: 'champ-0-type_champ')
@ -142,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