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-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
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]
def create
@ -31,6 +31,15 @@ module Administrateurs
head :no_content
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
@procedure.draft_revision.remove_type_de_champ(params[:id])
reset_procedure

View file

@ -37,7 +37,8 @@ module ProcedureHelper
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)
continuerUrl: admin_procedure_path(procedure),
estimatedFillDuration: procedure.feature_enabled?(:procedure_estimate_fill_duration) ? procedure.draft_revision.estimated_fill_duration : 0
}
end

View file

@ -4,7 +4,7 @@ import invariant from 'tiny-invariant';
type Operation = {
path: string;
method: string;
payload: unknown;
payload?: unknown;
resolve: (value: unknown) => void;
reject: () => void;
};
@ -45,7 +45,8 @@ export class OperationsQueue {
const url = `${this.baseUrl}${path}`;
try {
const data = await httpRequest(url, { method, json: payload }).json();
const json = payload;
const data = await httpRequest(url, { method, json }).json();
resolve(data);
} catch (e) {
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 ?'))
dispatch({
type: 'removeTypeDeChamp',
params: { typeDeChamp }
params: { typeDeChamp },
done: (estimatedFillDuration) => {
dispatch({
type: 'refresh',
params: { estimatedFillDuration }
});
}
});
}}
>
@ -223,7 +229,12 @@ function createUpdateHandler(
field,
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({
type: 'addNewRepetitionTypeDeChamp',
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
);
const formattedEstimatedFillDuration = state.estimatedFillDuration
? Math.max(1, Math.round(state.estimatedFillDuration / 60)) + ' mn'
: '';
return (
<div className="champs-editor">
<SortableContainer
@ -60,7 +64,12 @@ export function TypeDeChamps({
onClick={() =>
dispatch({
type: 'addNewTypeDeChamp',
done: () => dispatch({ type: 'refresh' })
done: (estimatedFillDuration: number) => {
dispatch({
type: 'refresh',
params: { estimatedFillDuration }
});
}
})
}
>
@ -68,6 +77,18 @@ export function TypeDeChamps({
&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>

View file

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

View file

@ -52,8 +52,20 @@ export function updateTypeDeChampOperation(
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>,

View file

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

View file

@ -461,6 +461,9 @@ Rails.application.routes.draw do
resources :experts, controller: 'experts_procedures', only: [:index, :create, :update, :destroy]
resources :types_de_champ, only: [:create, :update, :destroy] do
collection do
get :estimate_fill_duration
end
member do
patch :move
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)
end
it "Add a new champ" do
scenario "adding a new champ" do
add_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é')
end
it "Add multiple champs" do
scenario "adding multiple champs" do
# Champs are created when clicking the 'Add field' button
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)
end
it "Remove champs" do
scenario "removing champs" do
add_champ(remove_flash_message: true)
fill_in 'champ-0-libelle', with: 'libellé de champ'
blur
expect(page).to have_content('Formulaire enregistré')
page.refresh
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)
end
it "Only add valid champs" do
scenario "adding an invalid champ" do
add_champ(remove_flash_message: true)
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é')
end
it "Add repetition champ" do
scenario "adding a repetition champ" do
add_champ(remove_flash_message: true)
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)
end
it "Add carte champ" do
scenario "adding a carte champ" do
add_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
it "Add dropdown champ" do
scenario "adding a dropdown champ" do
add_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')
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