From 54d49accd1c5cfbd923d63aad61e1e5ab760c4bd Mon Sep 17 00:00:00 2001 From: Jalil Arfaoui Date: Thu, 7 Mar 2024 15:45:36 +0100 Subject: [PATCH] refactor: rassemble la gestion des questions dans Redux --- site/package.json | 3 + site/source/components/App.tsx | 2 +- .../components/EngineDocumentationRoutes.tsx | 2 +- site/source/components/Notifications.tsx | 8 +- site/source/components/Provider.tsx | 7 +- site/source/components/QuickLinks.tsx | 10 +- .../components/SelectSimulationYear.tsx | 4 +- .../components/SimulateurOrAssistantPage.tsx | 3 +- .../components/Simulation/Questions.tsx | 8 +- .../components/Simulation/SimulationGoal.tsx | 4 +- site/source/components/Simulation/index.tsx | 9 +- .../components/conversation/AnswerList.tsx | 5 +- .../components/conversation/Conversation.tsx | 224 ++---------------- .../conversation/QuestionEnCours.tsx | 163 +++++++++++++ .../VousAvezComplétéCetteSimulation.tsx | 83 +++++++ .../conversation/useNavigateQuestions.ts | 49 +--- .../source/components/utils/EngineContext.tsx | 27 +-- site/source/domain/AssimiléSalariéContexte.ts | 7 + .../source/domain/AutoentrepreneurContexte.ts | 6 + site/source/domain/IndépendantContexte.ts | 7 + site/source/domaine/ComparateurConfig.ts | 12 + site/source/domaine/Contexte.ts | 3 + site/source/domaine/SimulationConfig.ts | 51 ++++ site/source/domaine/Situation.ts | 6 + .../engine/détermineLesProchainesQuestions.ts | 69 ++++++ .../engine/listeLesVariablesManquantes.ts | 75 ++++++ site/source/domaine/updateSituation.ts | 37 +++ site/source/hooks/useNextQuestion.tsx | 104 +------- site/source/hooks/useQuestionList.ts | 10 +- site/source/hooks/useShouldFocusField.ts | 20 -- site/source/hooks/useSimulationConfig.ts | 5 +- site/source/hooks/useSimulationProgress.tsx | 20 ++ .../assistants/choix-du-statut/commune.tsx | 15 +- .../choix-du-statut/comparateur.tsx | 14 +- .../choix-du-statut/recherche-activité.tsx | 4 +- .../pages/assistants/components/Fields.tsx | 4 +- .../index.tsx | 4 +- site/source/pages/domaine/SimulationConfig.ts | 0 .../pages/simulateurs/_configs/types.ts | 54 +---- .../artiste-auteur/simulationConfig.ts | 2 +- .../auto-entrepreneur/simulationConfig.ts | 2 +- .../chômage-partiel/simulationConfig.ts | 2 +- .../simulateurs/cipav/simulationConfig.ts | 3 +- .../comparaison-statuts/EngineComparison.tsx | 5 + .../comparaison-statuts/NamedEngine.tsx | 9 + .../components/Comparateur.tsx | 20 +- .../components/DetailsRowCards.tsx | 2 +- .../components/Détails.tsx | 2 +- .../components/ModifierOptions.tsx | 29 +-- .../components/RevenuTable.tsx | 3 +- .../components/StatutChoice.tsx | 2 +- .../contexts/CasParticuliers.tsx | 40 ---- .../simulateurs/comparaison-statuts/index.tsx | 41 +--- .../comparaison-statuts/simulationConfig.ts | 12 +- .../simulateurs/dividendes/Dividendes.tsx | 4 +- .../dividendes/simulationConfig.ts | 2 +- .../pages/simulateurs/impot-societe/index.tsx | 9 +- .../impot-societe/simulationConfig.ts | 2 +- .../simulateurs/indépendant/Indépendant.tsx | 4 +- .../indépendant/simulationConfig.ts | 2 +- .../profession-libérale/simulationConfig.ts | 3 +- .../simulateurs/salarié/simulationConfig.ts | 2 +- .../simulateurs/sasu/simulationConfig.ts | 2 +- site/source/store/actions/actions.ts | 73 +++--- .../prendLaProchaineQuestion.middleware.ts | 49 ++++ .../store/reducers/companySituationReducer.ts | 2 +- .../reducers/previousSimulationRootReducer.ts | 4 +- site/source/store/reducers/rootReducer.ts | 152 +----------- .../store/reducers/simulation.reducer.spec.ts | 215 +++++++++++++++++ .../store/reducers/simulation.reducer.ts | 191 +++++++++++++++ .../store/selectors/acreActivé.selector.ts | 9 + .../source/store/selectors/config.selector.ts | 8 + .../selectors/currentQuestion.selector.ts | 8 + .../selectors/previousSimulationSelectors.ts | 5 +- .../questionEnCoursRépondue.selector.ts | 12 + .../selectors/questionsSuivantes.selector.ts | 8 + .../store/selectors/simulation.selector.ts | 3 + .../store/selectors/simulationSelectors.ts | 94 +++----- site/source/store/store.ts | 28 ++- site/source/utils/complement.ts | 6 + site/source/utils/index.ts | 11 + site/test/conversation.test.ts | 75 ------ site/test/persistence.test.ts | 15 +- site/test/regressions/utils.ts | 2 +- yarn.lock | 24 ++ 85 files changed, 1345 insertions(+), 991 deletions(-) create mode 100644 site/source/components/conversation/QuestionEnCours.tsx create mode 100644 site/source/components/conversation/VousAvezComplétéCetteSimulation.tsx create mode 100644 site/source/domain/AssimiléSalariéContexte.ts create mode 100644 site/source/domain/AutoentrepreneurContexte.ts create mode 100644 site/source/domain/IndépendantContexte.ts create mode 100644 site/source/domaine/ComparateurConfig.ts create mode 100644 site/source/domaine/Contexte.ts create mode 100644 site/source/domaine/SimulationConfig.ts create mode 100644 site/source/domaine/Situation.ts create mode 100644 site/source/domaine/engine/détermineLesProchainesQuestions.ts create mode 100644 site/source/domaine/engine/listeLesVariablesManquantes.ts create mode 100644 site/source/domaine/updateSituation.ts delete mode 100644 site/source/hooks/useShouldFocusField.ts create mode 100644 site/source/hooks/useSimulationProgress.tsx create mode 100644 site/source/pages/domaine/SimulationConfig.ts create mode 100644 site/source/pages/simulateurs/comparaison-statuts/EngineComparison.tsx create mode 100644 site/source/pages/simulateurs/comparaison-statuts/NamedEngine.tsx delete mode 100644 site/source/pages/simulateurs/comparaison-statuts/contexts/CasParticuliers.tsx create mode 100644 site/source/store/middlewares/prendLaProchaineQuestion.middleware.ts create mode 100644 site/source/store/reducers/simulation.reducer.spec.ts create mode 100644 site/source/store/reducers/simulation.reducer.ts create mode 100644 site/source/store/selectors/acreActivé.selector.ts create mode 100644 site/source/store/selectors/config.selector.ts create mode 100644 site/source/store/selectors/currentQuestion.selector.ts create mode 100644 site/source/store/selectors/questionEnCoursRépondue.selector.ts create mode 100644 site/source/store/selectors/questionsSuivantes.selector.ts create mode 100644 site/source/store/selectors/simulation.selector.ts create mode 100644 site/source/utils/complement.ts delete mode 100644 site/test/conversation.test.ts diff --git a/site/package.json b/site/package.json index 6df8adbd8..fee8f6bee 100644 --- a/site/package.json +++ b/site/package.json @@ -61,8 +61,11 @@ "@react-pdf/renderer": "^3.1.12", "@sentry/integrations": "^7.70.0", "@sentry/react": "^7.70.0", + "@types/deep-eql": "^4.0.2", "algoliasearch": "^4.20.0", "date-fns": "^3.6.0", + "deep-eql": "^5.0.1", + "effect": "^3.0.0", "exoneration-covid": "workspace:^", "focus-trap-react": "^10.2.1", "fuse.js": "^6.6.2", diff --git a/site/source/components/App.tsx b/site/source/components/App.tsx index 68f99240f..06c321fc6 100644 --- a/site/source/components/App.tsx +++ b/site/source/components/App.tsx @@ -63,7 +63,7 @@ export default function Root({ return ( - + diff --git a/site/source/components/EngineDocumentationRoutes.tsx b/site/source/components/EngineDocumentationRoutes.tsx index 356389b59..5a647676a 100644 --- a/site/source/components/EngineDocumentationRoutes.tsx +++ b/site/source/components/EngineDocumentationRoutes.tsx @@ -2,7 +2,7 @@ import { Route, Routes, useNavigate } from 'react-router-dom' import Popover from '@/design-system/popover/Popover' import Documentation from '@/pages/Documentation' -import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/components/Comparateur' +import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison' export function EngineDocumentationRoutes({ namedEngines, diff --git a/site/source/components/Notifications.tsx b/site/source/components/Notifications.tsx index 4cdeb05e5..b3bf17664 100644 --- a/site/source/components/Notifications.tsx +++ b/site/source/components/Notifications.tsx @@ -43,14 +43,10 @@ function getNotifications(engine: Engine) { })) } -export default function Notifications({ - engines, -}: { - engines?: Array> -}) { +export default function Notifications() { const { t } = useTranslation() const engine = useEngine() - const inversionFail = useInversionFail(engines) + const inversionFail = useInversionFail() const hiddenNotifications = useSelector( (state: RootState) => state.simulation?.hiddenNotifications ) diff --git a/site/source/components/Provider.tsx b/site/source/components/Provider.tsx index 9d46ab02d..d1babd6de 100644 --- a/site/source/components/Provider.tsx +++ b/site/source/components/Provider.tsx @@ -1,6 +1,7 @@ import { OverlayProvider } from '@react-aria/overlays' import { ErrorBoundary } from '@sentry/react' import i18next from 'i18next' +import Engine from 'publicodes' import { createContext, ReactNode } from 'react' import { HelmetProvider } from 'react-helmet-async' import { I18nextProvider } from 'react-i18next' @@ -13,7 +14,7 @@ import DesignSystemThemeProvider from '@/design-system/root' import { EmbededContextProvider } from '@/hooks/useIsEmbedded' import * as safeLocalStorage from '../storage/safeLocalStorage' -import { store } from '../store/store' +import { makeStore } from '../store/store' import { TrackingContext } from './ATInternetTracking' import { createTracker } from './ATInternetTracking/Tracker' import { ErrorFallback } from './ErrorPage' @@ -29,12 +30,16 @@ export const SiteNameContext = createContext(null) export type ProviderProps = { basename: SiteName children: ReactNode + engine: Engine } export default function Provider({ basename, children, + engine, }: ProviderProps): JSX.Element { + const store = makeStore(engine) + return ( diff --git a/site/source/components/QuickLinks.tsx b/site/source/components/QuickLinks.tsx index e9280d06c..1e50c9715 100644 --- a/site/source/components/QuickLinks.tsx +++ b/site/source/components/QuickLinks.tsx @@ -6,12 +6,10 @@ import { Spacing } from '@/design-system/layout' import { Link } from '@/design-system/typography/link' import { SmallBody } from '@/design-system/typography/paragraphs' import { useNextQuestions } from '@/hooks/useNextQuestion' -import { goToQuestion } from '@/store/actions/actions' +import { vaÀLaQuestion } from '@/store/actions/actions' import { RootState } from '@/store/reducers/rootReducer' -import { - answeredQuestionsSelector, - currentQuestionSelector, -} from '@/store/selectors/simulationSelectors' +import { currentQuestionSelector } from '@/store/selectors/currentQuestion.selector' +import { answeredQuestionsSelector } from '@/store/selectors/simulationSelectors' export default function QuickLinks() { const currentQuestion = useSelector(currentQuestionSelector) @@ -43,7 +41,7 @@ export default function QuickLinks() { dispatch(goToQuestion(dottedName))} + onPress={() => dispatch(vaÀLaQuestion(dottedName))} aria-label={t('{{question}}, aller à la question : {{question}}', { question: label, })} diff --git a/site/source/components/SelectSimulationYear.tsx b/site/source/components/SelectSimulationYear.tsx index 91f3fcbda..56a2f6518 100644 --- a/site/source/components/SelectSimulationYear.tsx +++ b/site/source/components/SelectSimulationYear.tsx @@ -6,7 +6,7 @@ import { styled } from 'styled-components' import Banner from '@/components/Banner' import { EngineContext } from '@/components/utils/EngineContext' import { Link as DesignSystemLink } from '@/design-system/typography/link' -import { updateSituation } from '@/store/actions/actions' +import { enregistreLaRéponse } from '@/store/actions/actions' const Bold = styled.span<{ $bold: boolean }>` ${({ $bold }) => ($bold ? 'font-weight: bold;' : '')} @@ -36,7 +36,7 @@ export const SelectSimulationYear = () => { - dispatch(updateSituation('date', `01/01/${year}`)) + dispatch(enregistreLaRéponse('date', `01/01/${year}`)) } > {actualYear === 2024 ? ( diff --git a/site/source/components/SimulateurOrAssistantPage.tsx b/site/source/components/SimulateurOrAssistantPage.tsx index 7f46ce0fd..1204bedda 100644 --- a/site/source/components/SimulateurOrAssistantPage.tsx +++ b/site/source/components/SimulateurOrAssistantPage.tsx @@ -15,6 +15,7 @@ import { } from '@/hooks/useCurrentSimulatorData' import { useIsEmbedded } from '@/hooks/useIsEmbedded' import useSimulationConfig from '@/hooks/useSimulationConfig' +import { Simulation } from '@/store/reducers/simulation.reducer' import { situationSelector } from '@/store/selectors/simulationSelectors' import { Merge } from '@/types/utils' @@ -51,7 +52,7 @@ export default function SimulateurOrAssistantPage() { const inIframe = useIsEmbedded() useSimulationConfig({ key: path, - config: simulation, + config: simulation as Simulation, autoloadLastSimulation, }) useSearchParamsSimulationSharing() diff --git a/site/source/components/Simulation/Questions.tsx b/site/source/components/Simulation/Questions.tsx index cf2d8c96b..744be3adf 100644 --- a/site/source/components/Simulation/Questions.tsx +++ b/site/source/components/Simulation/Questions.tsx @@ -1,5 +1,3 @@ -import { DottedName } from 'modele-social' -import Engine from 'publicodes' import { Trans } from 'react-i18next' import { styled } from 'styled-components' @@ -8,7 +6,7 @@ import Conversation, { } from '@/components/conversation/Conversation' import Progress from '@/components/ui/Progress' import { Body } from '@/design-system/typography/paragraphs' -import { useSimulationProgress } from '@/hooks/useNextQuestion' +import { useSimulationProgress } from '@/hooks/useSimulationProgress' const QuestionsContainer = styled.div` padding: ${({ theme }) => ` ${theme.spacings.xs} ${theme.spacings.lg}`}; @@ -27,10 +25,8 @@ const Notice = styled(Body)` export function Questions({ customEndMessages, - engines, }: { customEndMessages?: ConversationProps['customEndMessages'] - engines?: Array> }) { const { numberCurrentStep, numberSteps } = useSimulationProgress() @@ -47,7 +43,7 @@ export function Questions({ )} - + ) diff --git a/site/source/components/Simulation/SimulationGoal.tsx b/site/source/components/Simulation/SimulationGoal.tsx index b1a552449..1b87226b5 100644 --- a/site/source/components/Simulation/SimulationGoal.tsx +++ b/site/source/components/Simulation/SimulationGoal.tsx @@ -8,7 +8,7 @@ import { ForceThemeProvider } from '@/components/utils/DarkModeContext' import { Grid } from '@/design-system/layout' import { Strong } from '@/design-system/typography' import { Body, SmallBody } from '@/design-system/typography/paragraphs' -import { updateSituation } from '@/store/actions/actions' +import { enregistreLaRéponse } from '@/store/actions/actions' import { targetUnitSelector } from '@/store/selectors/simulationSelectors' import { ExplicableRule } from '../conversation/Explicable' @@ -60,7 +60,7 @@ export function SimulationGoal({ const [isFocused, setFocused] = useState(false) const onChange = useCallback( (x?: PublicodesExpression) => { - dispatch(updateSituation(dottedName, x)) + dispatch(enregistreLaRéponse(dottedName, x)) onUpdateSituation?.(dottedName, x) }, [dispatch, onUpdateSituation, dottedName] diff --git a/site/source/components/Simulation/index.tsx b/site/source/components/Simulation/index.tsx index be034c954..0fa9b9b4a 100644 --- a/site/source/components/Simulation/index.tsx +++ b/site/source/components/Simulation/index.tsx @@ -1,5 +1,3 @@ -import { DottedName } from 'modele-social' -import Engine from 'publicodes' import React from 'react' import { Trans, useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -31,7 +29,6 @@ export { SimulationGoals } from './SimulationGoals' type SimulationProps = { explanations?: React.ReactNode - engines?: Array> results?: React.ReactNode children?: React.ReactNode afterQuestionsSlot?: React.ReactNode @@ -59,7 +56,6 @@ export default function Simulation({ afterQuestionsSlot, customEndMessages, showQuestionsFromBeginning, - engines, hideDetails = false, fullWidth, id, @@ -85,10 +81,7 @@ export default function Simulation({
{results}
- + )} diff --git a/site/source/components/conversation/AnswerList.tsx b/site/source/components/conversation/AnswerList.tsx index 9339daea0..a91025fe4 100644 --- a/site/source/components/conversation/AnswerList.tsx +++ b/site/source/components/conversation/AnswerList.tsx @@ -17,7 +17,7 @@ import { Link } from '@/design-system/typography/link' import { Body, Intro } from '@/design-system/typography/paragraphs' import { useCurrentSimulatorData } from '@/hooks/useCurrentSimulatorData' import { useNextQuestions } from '@/hooks/useNextQuestion' -import { answerQuestion, resetSimulation } from '@/store/actions/actions' +import { enregistreLaRéponse, resetSimulation } from '@/store/actions/actions' import { resetCompany } from '@/store/actions/companyActions' import { isCompanyDottedName } from '@/store/reducers/companySituationReducer' import { @@ -284,7 +284,8 @@ function AnswerElement(rule: RuleNode) { const handleChange = useCallback( (value: PublicodesExpression | undefined) => { - questionDottedName && dispatch(answerQuestion(questionDottedName, value)) + questionDottedName && + dispatch(enregistreLaRéponse(questionDottedName, value)) }, [dispatch, questionDottedName] ) diff --git a/site/source/components/conversation/Conversation.tsx b/site/source/components/conversation/Conversation.tsx index 565678670..c9f8dec6d 100644 --- a/site/source/components/conversation/Conversation.tsx +++ b/site/source/components/conversation/Conversation.tsx @@ -1,96 +1,29 @@ -import { DottedName } from 'modele-social' -import Engine, { PublicodesExpression } from 'publicodes' -import React, { useCallback, useEffect, useState } from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { useDispatch, useSelector } from 'react-redux' +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' -import RuleInput from '@/components/conversation/RuleInput' -import Notifications from '@/components/Notifications' -import QuickLinks from '@/components/QuickLinks' -import { useEngine } from '@/components/utils/EngineContext' -import { Button } from '@/design-system/buttons' -import { Emoji } from '@/design-system/emoji' -import { Grid, Spacing } from '@/design-system/layout' -import { H3 } from '@/design-system/typography/heading' -import { Body } from '@/design-system/typography/paragraphs' -import { useCurrentSimulatorData } from '@/hooks/useCurrentSimulatorData' -import { answerQuestion } from '@/store/actions/actions' -import { - answeredQuestionsSelector, - situationSelector, -} from '@/store/selectors/simulationSelectors' -import { evaluateQuestion } from '@/utils' +import { QuestionEnCours } from '@/components/conversation/QuestionEnCours' +import { VousAvezComplétéCetteSimulation } from '@/components/conversation/VousAvezComplétéCetteSimulation' +import { answeredQuestionsSelector } from '@/store/selectors/simulationSelectors' -import { TrackPage } from '../ATInternetTracking' -import { JeDonneMonAvis } from '../JeDonneMonAvis' -import { FromTop } from '../ui/animate' import AnswerList from './AnswerList' -import { ExplicableRule } from './Explicable' -import SeeAnswersButton from './SeeAnswersButton' import { useNavigateQuestions } from './useNavigateQuestions' export type ConversationProps = { customEndMessages?: React.ReactNode customSituationVisualisation?: React.ReactNode - engines?: Array> } export default function Conversation({ customEndMessages, customSituationVisualisation, - engines, }: ConversationProps) { - const { currentSimulatorData } = useCurrentSimulatorData() - const dispatch = useDispatch() - const engine = useEngine() - - const situation = useSelector(situationSelector) - const previousAnswers = useSelector(answeredQuestionsSelector) - const { t } = useTranslation() - - const { - currentQuestion, - currentQuestionIsAnswered, - goToPrevious: goToPreviousQuestion, - goToNext: goToNextQuestion, - } = useNavigateQuestions(engines) - - const onChange = ( - value: PublicodesExpression | undefined, - dottedName: DottedName - ) => { - dispatch(answerQuestion(dottedName, value)) - } + const { currentQuestion } = useNavigateQuestions() const [firstRenderDone, setFirstRenderDone] = useState(false) useEffect(() => setFirstRenderDone(true), []) - const focusFirstElemInForm = useCallback(() => { - setTimeout(() => { - formRef.current - ?.querySelector( - 'input, button, a' - ) - ?.focus() - }, 5) - }, []) - - const goToPrevious = useCallback(() => { - goToPreviousQuestion() - focusFirstElemInForm() - }, [focusFirstElemInForm, goToPreviousQuestion]) - - const goToNext = useCallback(() => { - goToNextQuestion() - focusFirstElemInForm() - }, [focusFirstElemInForm, goToNextQuestion]) - - const formRef = React.useRef(null) - const isDateQuestion = - currentQuestion && engine.getRule(currentQuestion).rawNode.type === 'date' - return ( <>
@@ -104,142 +37,17 @@ export default function Conversation({
{currentQuestion ? ( - - {Object.keys(situation).length !== 0 && ( - - )} -
{ - e.preventDefault() - goToNext() - }} - ref={formRef} - > -
-

- {evaluateQuestion(engine, engine.getRule(currentQuestion))} - -

-
-
- - {t( - 'Répondez à quelques questions additionnelles afin de préciser votre résultat.' - )} - - -
- - {previousAnswers.length > 0 && ( - - - - )} - - - - - - {customSituationVisualisation} - - - - - - -
+ ) : ( - <> -
- {firstRenderDone && } -

- {' '} - - Vous avez complété cette simulation - -

- - {customEndMessages || ( - - Vous avez maintenant accès à l'estimation la plus précise - possible. - - )} - - {currentSimulatorData?.pathId === 'simulateurs.salarié' && ( - <> - - - - )} - - {previousAnswers.length > 0 && ( - - - - )} - - - {customSituationVisualisation} - - - -
- - + )}
diff --git a/site/source/components/conversation/QuestionEnCours.tsx b/site/source/components/conversation/QuestionEnCours.tsx new file mode 100644 index 000000000..a8ce81fb5 --- /dev/null +++ b/site/source/components/conversation/QuestionEnCours.tsx @@ -0,0 +1,163 @@ +import { DottedName } from 'modele-social' +import Engine, { PublicodesExpression } from 'publicodes' +import React, { useCallback } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' + +import { TrackPage } from '@/components/ATInternetTracking' +import { ExplicableRule } from '@/components/conversation/Explicable' +import RuleInput from '@/components/conversation/RuleInput' +import SeeAnswersButton from '@/components/conversation/SeeAnswersButton' +import { useNavigateQuestions } from '@/components/conversation/useNavigateQuestions' +import Notifications from '@/components/Notifications' +import QuickLinks from '@/components/QuickLinks' +import { FromTop } from '@/components/ui/animate' +import { useEngine } from '@/components/utils/EngineContext' +import { Button } from '@/design-system/buttons' +import { Grid } from '@/design-system/layout' +import { H3 } from '@/design-system/typography/heading' +import { enregistreLaRéponse } from '@/store/actions/actions' +import { situationSelector } from '@/store/selectors/simulationSelectors' +import { evaluateQuestion } from '@/utils' + +interface Props { + previousAnswers: DottedName[] + customSituationVisualisation?: React.ReactNode +} + +export function QuestionEnCours({ + previousAnswers, + customSituationVisualisation, +}: Props) { + const dispatch = useDispatch() + const { t } = useTranslation() + + const engine = useEngine() + + const situation = useSelector(situationSelector) + + const { currentQuestion, currentQuestionIsAnswered, goToPrevious, goToNext } = + useNavigateQuestions() + + const formRef = React.useRef(null) + + const focusFirstElemInForm = useCallback(() => { + setTimeout(() => { + formRef.current + ?.querySelector( + 'input, button, a' + ) + ?.focus() + }, 5) + }, []) + + const handleGoToPrevious = useCallback(() => { + goToPrevious() + focusFirstElemInForm() + }, [focusFirstElemInForm, goToPrevious]) + + const handleGoToNext = useCallback(() => { + goToNext() + focusFirstElemInForm() + }, [focusFirstElemInForm, goToNext]) + + if (!currentQuestion) return null + + const onChange = ( + value: PublicodesExpression | undefined, + dottedName: DottedName + ) => { + dispatch(enregistreLaRéponse(dottedName, value)) + } + + const isDateQuestion = + currentQuestion && engine.getRule(currentQuestion).rawNode.type === 'date' + + return ( + + {Object.keys(situation).length !== 0 && ( + + )} +
{ + e.preventDefault() + handleGoToNext() + }} + ref={formRef} + > +
+

+ {evaluateQuestion(engine, engine.getRule(currentQuestion))} + +

+
+
+ + {t( + 'Répondez à quelques questions additionnelles afin de préciser votre résultat.' + )} + + +
+ + {previousAnswers.length > 0 && ( + + + + )} + + + + + {customSituationVisualisation} + + + + + +
+ ) +} diff --git a/site/source/components/conversation/VousAvezComplétéCetteSimulation.tsx b/site/source/components/conversation/VousAvezComplétéCetteSimulation.tsx new file mode 100644 index 000000000..3fc5d4080 --- /dev/null +++ b/site/source/components/conversation/VousAvezComplétéCetteSimulation.tsx @@ -0,0 +1,83 @@ +import { DottedName } from 'modele-social' +import Engine from 'publicodes' +import React from 'react' +import { Trans } from 'react-i18next' + +import { TrackPage } from '@/components/ATInternetTracking' +import SeeAnswersButton from '@/components/conversation/SeeAnswersButton' +import { useNavigateQuestions } from '@/components/conversation/useNavigateQuestions' +import { JeDonneMonAvis } from '@/components/JeDonneMonAvis' +import Notifications from '@/components/Notifications' +import { Button } from '@/design-system/buttons' +import { Emoji } from '@/design-system/emoji' +import { Grid, Spacing } from '@/design-system/layout' +import { H3 } from '@/design-system/typography/heading' +import { Body } from '@/design-system/typography/paragraphs' +import { useCurrentSimulatorData } from '@/hooks/useCurrentSimulatorData' + +interface Props { + firstRenderDone: boolean + customEndMessages?: React.ReactNode + previousAnswers: DottedName[] + customSituationVisualisation?: React.ReactNode +} + +export function VousAvezComplétéCetteSimulation({ + firstRenderDone, + customEndMessages, + previousAnswers, + customSituationVisualisation, +}: Props) { + const { currentSimulatorData } = useCurrentSimulatorData() + + const { goToPrevious } = useNavigateQuestions() + + return ( + <> +
+ {firstRenderDone && } +

+ {' '} + + Vous avez complété cette simulation + +

+ + {customEndMessages || ( + + Vous avez maintenant accès à l'estimation la plus précise + possible. + + )} + + {currentSimulatorData?.pathId === 'simulateurs.salarié' && ( + <> + + + + )} + + {previousAnswers.length > 0 && ( + + + + )} + + {customSituationVisualisation} + + +
+ + + ) +} diff --git a/site/source/components/conversation/useNavigateQuestions.ts b/site/source/components/conversation/useNavigateQuestions.ts index 9642eaacc..56bba094f 100644 --- a/site/source/components/conversation/useNavigateQuestions.ts +++ b/site/source/components/conversation/useNavigateQuestions.ts @@ -1,58 +1,27 @@ -import { DottedName } from 'modele-social' -import Engine from 'publicodes' -import { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { useEngine } from '@/components/utils/EngineContext' -import { useNextQuestions } from '@/hooks/useNextQuestion' import { - goToQuestion, - stepAction, - updateShouldFocusField, + retourneÀLaQuestionPrécédente, + vaÀLaQuestionSuivante, } from '@/store/actions/actions' -import { - answeredQuestionsSelector, - currentQuestionSelector, - urlSelector, - useMissingVariables, -} from '@/store/selectors/simulationSelectors' +import { currentQuestionSelector } from '@/store/selectors/currentQuestion.selector' +import { questionEnCoursRépondueSelector } from '@/store/selectors/questionEnCoursRépondue.selector' -export function useNavigateQuestions(engines?: Array>) { +export function useNavigateQuestions() { const dispatch = useDispatch() - const engine = useEngine() - const nextQuestion = useNextQuestions(engines)[0] const currentQuestion = useSelector(currentQuestionSelector) - const url = useSelector(urlSelector) - const missingVariables = useMissingVariables({ engines: engines ?? [engine] }) - const currentQuestionIsAnswered = - currentQuestion && !(currentQuestion in missingVariables) - - const previousAnswers = useSelector(answeredQuestionsSelector) + const currentQuestionIsAnswered = useSelector(questionEnCoursRépondueSelector) const goToPrevious = () => { - dispatch(updateShouldFocusField(true)) - dispatch(goToQuestion(previousAnswers.slice(-1)[0])) + dispatch(retourneÀLaQuestionPrécédente()) } const goToNext = () => { - dispatch(updateShouldFocusField(true)) - if (currentQuestion) { - dispatch(stepAction(currentQuestion)) - } + dispatch(vaÀLaQuestionSuivante()) } - useEffect(() => { - if (!currentQuestion && nextQuestion) { - dispatch(goToQuestion(nextQuestion)) - } - }, [nextQuestion, currentQuestion]) - - useEffect(() => { - dispatch(goToQuestion(nextQuestion)) - }, [url]) - return { - currentQuestion: currentQuestion ?? nextQuestion, + currentQuestion, currentQuestionIsAnswered, goToPrevious, goToNext, diff --git a/site/source/components/utils/EngineContext.tsx b/site/source/components/utils/EngineContext.tsx index 9fb5aca5b..3b5e679cb 100644 --- a/site/source/components/utils/EngineContext.tsx +++ b/site/source/components/utils/EngineContext.tsx @@ -6,7 +6,7 @@ import Engine, { Rule, RuleNode, } from 'publicodes' -import { createContext, useContext, useMemo } from 'react' +import { createContext, useContext } from 'react' import { useDispatch, useSelector } from 'react-redux' import { deleteFromSituation } from '@/store/actions/actions' @@ -14,6 +14,7 @@ import { companySituationSelector, configObjectifsSelector, configSituationSelector, + rawSituationSelector, situationSelector, } from '@/store/selectors/simulationSelectors' import { omit } from '@/utils' @@ -79,22 +80,7 @@ export function useEngine() { return useContext(EngineContext) as Engine } -export const useRawSituation = () => { - const simulatorSituation = useSelector(situationSelector) - const configSituation = useSelector(configSituationSelector) - const companySituation = useSelector(companySituationSelector) - - const situation: Partial> = useMemo( - () => ({ - ...companySituation, - ...configSituation, - ...simulatorSituation, - }), - [configSituation, simulatorSituation, companySituation] - ) - - return situation -} +export const useRawSituation = () => useSelector(rawSituationSelector) /** * Try to set situation and delete all rules with syntax/evaluation error @@ -194,11 +180,10 @@ export const useSetupSafeSituation = (engine: Engine) => { } } -export function useInversionFail(engines?: Array>) { +export function useInversionFail() { const engine = useEngine() - const enginesToUse = engines ?? [engine] - const objectifs = useSelector(configObjectifsSelector).flatMap((objectif) => - enginesToUse.map((e) => e.evaluate(objectif).nodeValue) + const objectifs = useSelector(configObjectifsSelector).map( + (objectif) => engine.evaluate(objectif).nodeValue ) const inversionFail = diff --git a/site/source/domain/AssimiléSalariéContexte.ts b/site/source/domain/AssimiléSalariéContexte.ts new file mode 100644 index 000000000..0da5360cf --- /dev/null +++ b/site/source/domain/AssimiléSalariéContexte.ts @@ -0,0 +1,7 @@ +import { Contexte } from '@/domaine/Contexte' + +export const AssimiléSalariéContexte: Contexte = { + 'entreprise . imposition': "'IS'", + 'entreprise . catégorie juridique': "'SAS'", + 'entreprise . associés': "'unique'", +} diff --git a/site/source/domain/AutoentrepreneurContexte.ts b/site/source/domain/AutoentrepreneurContexte.ts new file mode 100644 index 000000000..ad35a4072 --- /dev/null +++ b/site/source/domain/AutoentrepreneurContexte.ts @@ -0,0 +1,6 @@ +import { Contexte } from '@/domaine/Contexte' + +export const AutoentrepreneurContexte: Contexte = { + 'entreprise . catégorie juridique': "'EI'", + 'entreprise . catégorie juridique . EI . auto-entrepreneur': 'oui', +} diff --git a/site/source/domain/IndépendantContexte.ts b/site/source/domain/IndépendantContexte.ts new file mode 100644 index 000000000..f1eee5434 --- /dev/null +++ b/site/source/domain/IndépendantContexte.ts @@ -0,0 +1,7 @@ +import { Contexte } from '@/domaine/Contexte' + +export const IndépendantContexte: Contexte = { + 'entreprise . imposition': "'IS'", + 'entreprise . catégorie juridique': "'EI'", + 'entreprise . catégorie juridique . EI . auto-entrepreneur': 'non', +} diff --git a/site/source/domaine/ComparateurConfig.ts b/site/source/domaine/ComparateurConfig.ts new file mode 100644 index 000000000..9dd3556f7 --- /dev/null +++ b/site/source/domaine/ComparateurConfig.ts @@ -0,0 +1,12 @@ +import { NonEmptyReadonlyArray } from 'effect/Array' + +import { Contexte } from '@/domaine/Contexte' +import { SimulationConfig } from '@/domaine/SimulationConfig' + +export interface ComparateurConfig extends SimulationConfig { + contextes: NonEmptyReadonlyArray +} + +export const isComparateurConfig = ( + config: SimulationConfig +): config is ComparateurConfig => 'contextes' in config diff --git a/site/source/domaine/Contexte.ts b/site/source/domaine/Contexte.ts new file mode 100644 index 000000000..4304269df --- /dev/null +++ b/site/source/domaine/Contexte.ts @@ -0,0 +1,3 @@ +import { Situation } from '@/domaine/Situation' + +export type Contexte = Situation diff --git a/site/source/domaine/SimulationConfig.ts b/site/source/domaine/SimulationConfig.ts new file mode 100644 index 000000000..d7ebafa64 --- /dev/null +++ b/site/source/domaine/SimulationConfig.ts @@ -0,0 +1,51 @@ +import { DottedName } from 'modele-social' + +import { Situation } from './Situation' + +export type SimulationConfig = Partial<{ + /** + * Objectifs exclusifs de la simulation : si une règle change dans la situation + * et qu'elle est dans `objectifs exclusifs`, alors toute les autres règles + * dans `objectifs exclusifs` seront supprimées de la situation + */ + 'objectifs exclusifs': DottedName[] + + /** + * Objectifs de la simulation + */ + objectifs?: DottedName[] + + /** + * La situation de base du simulateur + */ + situation: Situation + + questions: { + /** + * Question non prioritaires, elles aparraitront en fin de simulation + */ + 'non prioritaires'?: DottedName[] + + /** + * Whitelist des questions qui sont affiché à l'utilisateur. + * Cela peut également servir pour prioriser des questions + * en mettant une string vide comme dernier élément + */ + liste?: (DottedName | '')[] + + /** + * Questions qui ne sont pas affiché à l'utilisateur + */ + 'liste noire'?: DottedName[] + + /** + * Questions "raccourcis" sélectionnables en bas du simulateur + */ + "à l'affiche"?: { + label: string + dottedName: DottedName + }[] + } + + 'unité par défaut'?: string +}> diff --git a/site/source/domaine/Situation.ts b/site/source/domaine/Situation.ts new file mode 100644 index 000000000..36e1b2afd --- /dev/null +++ b/site/source/domaine/Situation.ts @@ -0,0 +1,6 @@ +import { DottedName } from 'modele-social' +import { ASTNode, PublicodesExpression } from 'publicodes' + +export type Situation = Partial< + Record +> diff --git a/site/source/domaine/engine/détermineLesProchainesQuestions.ts b/site/source/domaine/engine/détermineLesProchainesQuestions.ts new file mode 100644 index 000000000..f6fb8529d --- /dev/null +++ b/site/source/domaine/engine/détermineLesProchainesQuestions.ts @@ -0,0 +1,69 @@ +import { Order, pipe } from 'effect' +import { filter, map, sort } from 'effect/Array' +import { DottedName } from 'modele-social' +import Engine from 'publicodes' + +import { ComparateurConfig } from '@/domaine/ComparateurConfig' +import { listeLesVariablesManquantes } from '@/domaine/engine/listeLesVariablesManquantes' +import { SimulationConfig } from '@/domaine/SimulationConfig' + +export const détermineLesProchainesQuestions = ( + engine: Engine, + config: SimulationConfig | ComparateurConfig, + answeredQuestions: Array = [] +): Array => { + const variablesManquantes = listeLesVariablesManquantes( + engine, + config.objectifs || [], + 'contextes' in config ? config.contextes : undefined + ) + + const { + liste = [], + 'liste noire': listeNoire = [], + 'non prioritaires': nonPrioritaires = [], + } = config.questions || {} + + const score = (question: string) => { + const indexList = liste.findIndex((name) => question.startsWith(name)) + 1 + const indexNonPrioritaire = + nonPrioritaires.findIndex((name) => question.startsWith(name)) + 1 + const différenceCoeff = questionDifference( + question, + answeredQuestions.slice(-1)[0] + ) + + return indexList + indexNonPrioritaire + différenceCoeff + } + + const nextSteps = pipe( + variablesManquantes, + Object.entries, + sort(([, a], [, b]) => Order.number(b, a)), + map(([name]) => name as DottedName), + filter((name) => !answeredQuestions.includes(name)), + filter( + (step) => + (!liste.length || liste.some((name) => step.startsWith(name))) && + (!listeNoire.length || !listeNoire.some((name) => step === name)) + ), + sort((a: DottedName, b: DottedName) => Order.number(score(a), score(b))), + filter( + (question) => engine.getRule(question).rawNode.question !== undefined + ) + ) + + return nextSteps +} + +// Max : 1 +// Min -> 0 +const questionDifference = (ruleA = '', ruleB = '') => { + if (ruleA === ruleB) { + return 0 + } + const partsA = ruleA.split(' . ') + const partsB = ruleB.split(' . ') + + return 1 / (1 + partsA.findIndex((val, i) => partsB?.[i] !== val)) +} diff --git a/site/source/domaine/engine/listeLesVariablesManquantes.ts b/site/source/domaine/engine/listeLesVariablesManquantes.ts new file mode 100644 index 000000000..7960913d9 --- /dev/null +++ b/site/source/domaine/engine/listeLesVariablesManquantes.ts @@ -0,0 +1,75 @@ +import { pipe } from 'effect' +import { flatMap, NonEmptyReadonlyArray, reduce } from 'effect/Array' +import { DottedName } from 'modele-social' +import Engine, { utils } from 'publicodes' + +import { Contexte } from '@/domaine/Contexte' +import { Situation } from '@/domaine/Situation' + +export const evalueDansLeContexte = + (engine: Engine, contexte: Contexte) => (expression: DottedName) => + engine.evaluate({ + value: expression, + contexte, + }) + +export type MissingVariables = Partial> | undefined + +export const listeLesVariablesManquantes = ( + engine: Engine, + objectifs: ReadonlyArray, + contextes?: NonEmptyReadonlyArray | undefined +) => { + console.log('engine', engine) + + return pipe( + objectifs, + flatMap((objectif) => + contextes + ? contextes.map( + (contexte) => + evalueDansLeContexte(engine, contexte)(objectif) + .missingVariables ?? {} + ) + : [engine.evaluate(objectif).missingVariables ?? {}] + ), + reduce({}, mergeMissing), + treatAPIMissingVariables(engine) + ) +} + +const mergeMissing = ( + left: Record | undefined = {}, + right: Record | undefined = {} +): Record => + Object.fromEntries( + [...Object.keys(left), ...Object.keys(right)].map((key) => [ + key, + (left[key] ?? 0) + (right[key] ?? 0), + ]) + ) + +/** + * Merge objectifs missings that depends on the same input field. + * + * For instance, the commune field (API) will fill `commune . nom` `commune . taux versement transport`, `commune . département`, etc. + */ +const treatAPIMissingVariables = + (engine: Engine) => + ( + missingVariables: Partial> + ): Partial> => + (Object.entries(missingVariables) as Array<[Name, number]>).reduce( + (missings, [name, value]: [Name, number]) => { + const parentName = utils.ruleParent(name) as Name + if (parentName && engine.getRule(parentName).rawNode.API) { + missings[parentName] = (missings[parentName] ?? 0) + value + + return missings + } + missings[name] = value + + return missings + }, + {} as Partial> + ) diff --git a/site/source/domaine/updateSituation.ts b/site/source/domaine/updateSituation.ts new file mode 100644 index 000000000..068b58d3e --- /dev/null +++ b/site/source/domaine/updateSituation.ts @@ -0,0 +1,37 @@ +import { DottedName } from 'modele-social' +import { PublicodesExpression } from 'publicodes' + +import { ImmutableType } from '@/types/utils' +import { objectTransform, omit } from '@/utils' + +import { SimulationConfig } from './SimulationConfig' +import { Situation } from './Situation' + +export function updateSituation( + config: ImmutableType, + currentSituation: Situation, + dottedName: DottedName, + value: PublicodesExpression | undefined +): Situation { + if (value === undefined) { + return omit(currentSituation, dottedName) + } + + const objectifsExclusifs = config['objectifs exclusifs'] ?? [] + + if (!objectifsExclusifs.includes(dottedName)) { + return { ...currentSituation, [dottedName]: value } + } + + const objectifsToReset = objectifsExclusifs.filter( + (name) => name !== dottedName + ) + + const clearedSituation = objectTransform(currentSituation, (entries) => + entries.filter( + ([dottedName]) => !objectifsToReset.includes(dottedName as DottedName) + ) + ) + + return { ...clearedSituation, [dottedName]: value } +} diff --git a/site/source/hooks/useNextQuestion.tsx b/site/source/hooks/useNextQuestion.tsx index 163687fa2..e40297d46 100644 --- a/site/source/hooks/useNextQuestion.tsx +++ b/site/source/hooks/useNextQuestion.tsx @@ -1,105 +1,7 @@ import { DottedName } from 'modele-social' -import Engine from 'publicodes' -import { useMemo } from 'react' import { useSelector } from 'react-redux' -import { SimulationConfig } from '@/store/reducers/rootReducer' -import { - answeredQuestionsSelector, - configSelector, - useMissingVariables, -} from '@/store/selectors/simulationSelectors' -import { ImmutableType } from '@/types/utils' +import { questionsSuivantesSelector } from '@/store/selectors/questionsSuivantes.selector' -import { useEngine } from '../components/utils/EngineContext' - -type MissingVariables = Partial> - -// Max : 1 -// Min -> 0 -const questionDifference = (ruleA = '', ruleB = '') => { - if (ruleA === ruleB) { - return 0 - } - const partsA = ruleA.split(' . ') - const partsB = ruleB.split(' . ') - - return 1 / (1 + partsA.findIndex((val, i) => partsB?.[i] !== val)) -} - -export function getNextQuestions( - missingVariables: MissingVariables, - questionConfig: ImmutableType = {}, - answeredQuestions: Array = [] -): Array { - const { - 'non prioritaires': notPriority = [], - liste: whitelist = [], - 'liste noire': blacklist = [], - } = questionConfig - - const nextSteps = Object.entries(missingVariables) - .sort(([, a], [, b]) => b - a) - .map(([a]) => a as DottedName) - .filter((name) => !answeredQuestions.includes(name)) - .filter( - (step) => - (!whitelist.length || - whitelist.some((name) => step.startsWith(name))) && - (!blacklist.length || !blacklist.some((name) => step === name)) - ) - - const score = (question: string) => { - const indexList = - whitelist.findIndex((name) => question.startsWith(name)) + 1 - const indexNotPriority = - notPriority.findIndex((name) => question.startsWith(name)) + 1 - const differenceCoeff = questionDifference( - question, - answeredQuestions.slice(-1)[0] - ) - - return indexList + indexNotPriority + differenceCoeff - } - - // The higher the score, the less important the question - return nextSteps.sort((a, b) => score(a) - score(b)) -} - -export const useNextQuestions = function ( - engines?: Array> -): Array { - const answeredQuestions = useSelector(answeredQuestionsSelector) - const config = useSelector(configSelector) - const engine = useEngine() - const missingVariables = useMissingVariables({ engines: engines ?? [engine] }) - const nextQuestions = useMemo(() => { - const next = getNextQuestions( - missingVariables, - config.questions ?? {}, - answeredQuestions - ) - - return next.filter( - (question) => engine.getRule(question).rawNode.question !== undefined - ) - }, [missingVariables, config.questions, answeredQuestions, engine]) - - return nextQuestions -} - -export function useSimulationProgress(): { - progressRatio: number - numberCurrentStep: number - numberSteps: number -} { - const numberQuestionAnswered = useSelector(answeredQuestionsSelector).length - const numberQuestionLeft = useNextQuestions().length - - return { - progressRatio: - numberQuestionAnswered / (numberQuestionAnswered + numberQuestionLeft), - numberCurrentStep: numberQuestionAnswered, - numberSteps: numberQuestionAnswered + numberQuestionLeft, - } -} +export const useNextQuestions = (): Array => + useSelector(questionsSuivantesSelector) || [] diff --git a/site/source/hooks/useQuestionList.ts b/site/source/hooks/useQuestionList.ts index c2a7c50e2..614d91632 100644 --- a/site/source/hooks/useQuestionList.ts +++ b/site/source/hooks/useQuestionList.ts @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux' import { useEngine } from '@/components/utils/EngineContext' import { useNextQuestions } from '@/hooks/useNextQuestion' -import { stepAction, updateSituation } from '@/store/actions/actions' +import { enregistreLaRéponse } from '@/store/actions/actions' import { answeredQuestionsSelector } from '@/store/selectors/simulationSelectors' export function useQuestionList(): [ @@ -25,10 +25,10 @@ export function useQuestionList(): [ const onQuestionAnswered = (dottedName: DottedName) => (value?: PublicodesExpression) => { - if (!answeredQuestions.includes(dottedName)) { - dispatch(stepAction(dottedName)) - } - dispatch(updateSituation(dottedName, value)) + // if (!answeredQuestions.includes(dottedName)) { + // dispatch(vaÀLaQuestionSuivante()) + // } + dispatch(enregistreLaRéponse(dottedName, value)) } return [questions, onQuestionAnswered] diff --git a/site/source/hooks/useShouldFocusField.ts b/site/source/hooks/useShouldFocusField.ts deleted file mode 100644 index 7f8e3b925..000000000 --- a/site/source/hooks/useShouldFocusField.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect } from 'react' -import { useDispatch, useSelector } from 'react-redux' - -import { updateShouldFocusField } from '@/store/actions/actions' -import { shouldFocusFieldSelector } from '@/store/selectors/simulationSelectors' - -// TODO: remove this hook, is not used anymore -export const useShouldFocusField = () => { - const dispatch = useDispatch() - const shouldFocusField = useSelector(shouldFocusFieldSelector) - - useEffect(() => { - setTimeout(() => { - dispatch(updateShouldFocusField(false)) - }, 0) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return shouldFocusField -} diff --git a/site/source/hooks/useSimulationConfig.ts b/site/source/hooks/useSimulationConfig.ts index 904bee072..168f61cc7 100644 --- a/site/source/hooks/useSimulationConfig.ts +++ b/site/source/hooks/useSimulationConfig.ts @@ -10,8 +10,7 @@ import { setSimulationConfig, } from '@/store/actions/actions' import { SimulationConfig } from '@/store/reducers/rootReducer' -import { configSelector } from '@/store/selectors/simulationSelectors' -import { ImmutableType } from '@/types/utils' +import { configSelector } from '@/store/selectors/config.selector' export default function useSimulationConfig({ key, @@ -19,7 +18,7 @@ export default function useSimulationConfig({ autoloadLastSimulation = false, }: { key: string - config?: ImmutableType + config?: SimulationConfig autoloadLastSimulation?: boolean }) { const dispatch = useDispatch() diff --git a/site/source/hooks/useSimulationProgress.tsx b/site/source/hooks/useSimulationProgress.tsx new file mode 100644 index 000000000..65a934248 --- /dev/null +++ b/site/source/hooks/useSimulationProgress.tsx @@ -0,0 +1,20 @@ +import { useSelector } from 'react-redux' + +import { useNextQuestions } from '@/hooks/useNextQuestion' +import { answeredQuestionsSelector } from '@/store/selectors/simulationSelectors' + +export function useSimulationProgress(): { + progressRatio: number + numberCurrentStep: number + numberSteps: number +} { + const numberQuestionAnswered = useSelector(answeredQuestionsSelector).length + const numberQuestionLeft = useNextQuestions().length + + return { + progressRatio: + numberQuestionAnswered / (numberQuestionAnswered + numberQuestionLeft), + numberCurrentStep: numberQuestionAnswered, + numberSteps: numberQuestionAnswered + numberQuestionLeft, + } +} diff --git a/site/source/pages/assistants/choix-du-statut/commune.tsx b/site/source/pages/assistants/choix-du-statut/commune.tsx index 81467fe63..b094ac1b1 100644 --- a/site/source/pages/assistants/choix-du-statut/commune.tsx +++ b/site/source/pages/assistants/choix-du-statut/commune.tsx @@ -8,7 +8,10 @@ import { useEngine } from '@/components/utils/EngineContext' import { usePersistingState } from '@/components/utils/persistState' import { HelpButtonWithPopover } from '@/design-system/buttons' import { Body } from '@/design-system/typography/paragraphs' -import { answerQuestion, batchUpdateSituation } from '@/store/actions/actions' +import { + answerBatchQuestion, + batchUpdateSituation, +} from '@/store/actions/actions' import Layout from './_components/Layout' import Navigation from './_components/Navigation' @@ -68,18 +71,12 @@ function useCommuneSelection(): [ const handleChange = (commune: CommuneType) => { setState({ commune }) - dispatch( - answerQuestion('établissement . commune', { batchUpdate: commune }) - ) + dispatch(answerBatchQuestion('établissement . commune', commune)) } useEffect(() => { state.commune && - dispatch( - answerQuestion('établissement . commune', { - batchUpdate: state.commune, - }) - ) + dispatch(answerBatchQuestion('établissement . commune', state.commune)) }, []) const reset = () => { diff --git a/site/source/pages/assistants/choix-du-statut/comparateur.tsx b/site/source/pages/assistants/choix-du-statut/comparateur.tsx index 5e8680074..d1d2dc755 100644 --- a/site/source/pages/assistants/choix-du-statut/comparateur.tsx +++ b/site/source/pages/assistants/choix-du-statut/comparateur.tsx @@ -9,12 +9,11 @@ import { Button } from '@/design-system/buttons' import { Container, Grid, Spacing } from '@/design-system/layout' import { Strong } from '@/design-system/typography' import { Intro } from '@/design-system/typography/paragraphs' -import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/components/Comparateur' import Détails from '@/pages/simulateurs/comparaison-statuts/components/Détails' import ModifierOptions from '@/pages/simulateurs/comparaison-statuts/components/ModifierOptions' import RevenuEstimé from '@/pages/simulateurs/comparaison-statuts/components/RevenuEstimé' import StatutChoice from '@/pages/simulateurs/comparaison-statuts/components/StatutChoice' -import { useCasParticuliers } from '@/pages/simulateurs/comparaison-statuts/contexts/CasParticuliers' +import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison' import { useSitePaths } from '@/sitePaths' import { Situation } from '@/store/reducers/rootReducer' @@ -83,7 +82,6 @@ export default function Comparateur() { * @param statut */ function useStatutComparaison(): EngineComparison { - const { isAutoEntrepreneurACREEnabled } = useCasParticuliers() const possibleStatuts = usePossibleStatuts() const situation = useRawSituation() const engine = useEngine() @@ -100,7 +98,7 @@ function useStatutComparaison(): EngineComparison { for (const { name, engine } of namedEngines) { engine.setSituation({ ...situation, - ...getSituationFromStatut(name, isAutoEntrepreneurACREEnabled), + ...getSituationFromStatut(name), }) } @@ -129,10 +127,7 @@ function usePossibleStatuts(): Array { } } -function getSituationFromStatut( - statut: StatutType, - AEAcre: boolean -): Situation { +function getSituationFromStatut(statut: StatutType): Situation { return { 'entreprise . catégorie juridique . remplacements': 'oui', 'entreprise . catégorie juridique': @@ -152,8 +147,5 @@ function getSituationFromStatut( 'entreprise . associés': ['SARL', 'SAS', 'SELAS', 'SELARL'].includes(statut) ? "'multiple'" : "'unique'", - ...(statut === 'AE' - ? { 'dirigeant . exonérations . ACRE': AEAcre ? 'oui' : 'non' } - : {}), } } diff --git a/site/source/pages/assistants/choix-du-statut/recherche-activité.tsx b/site/source/pages/assistants/choix-du-statut/recherche-activité.tsx index bba9176e0..0249cd1bc 100644 --- a/site/source/pages/assistants/choix-du-statut/recherche-activité.tsx +++ b/site/source/pages/assistants/choix-du-statut/recherche-activité.tsx @@ -8,7 +8,7 @@ import { H5 } from '@/design-system/typography/heading' import { Link } from '@/design-system/typography/link' import { Body } from '@/design-system/typography/paragraphs' import { useIsEmbedded } from '@/hooks/useIsEmbedded' -import { resetSimulation, updateSituation } from '@/store/actions/actions' +import { enregistreLaRéponse, resetSimulation } from '@/store/actions/actions' import SearchCodeAPE from '../recherche-code-ape/SearchCodeAPE' import Layout from './_components/Layout' @@ -32,7 +32,7 @@ export default function RechercheActivité() { currentStepIsComplete={!!codeApe} onNextStep={() => { dispatch( - updateSituation( + enregistreLaRéponse( 'entreprise . activités . principale . code APE', `'${codeApe}'` ) diff --git a/site/source/pages/assistants/components/Fields.tsx b/site/source/pages/assistants/components/Fields.tsx index cc1d5e46e..8dee35d3f 100644 --- a/site/source/pages/assistants/components/Fields.tsx +++ b/site/source/pages/assistants/components/Fields.tsx @@ -14,7 +14,7 @@ import { Spacing } from '@/design-system/layout' import { H3 } from '@/design-system/typography/heading' import { Intro, SmallBody } from '@/design-system/typography/paragraphs' import { useNextQuestions } from '@/hooks/useNextQuestion' -import { updateSituation } from '@/store/actions/actions' +import { enregistreLaRéponse } from '@/store/actions/actions' import { situationSelector, targetUnitSelector, @@ -90,7 +90,7 @@ export function SimpleField(props: SimpleFieldProps) { const meta = getMeta<{ requis?: 'oui' | 'non' }>(rule.rawNode, {}) const dispatchValue = useCallback( (value: PublicodesExpression | undefined, dottedName: DottedName) => { - dispatch(updateSituation(dottedName, value)) + dispatch(enregistreLaRéponse(dottedName, value)) }, [dispatch] ) diff --git a/site/source/pages/assistants/declaration-charges-sociales-independant/index.tsx b/site/source/pages/assistants/declaration-charges-sociales-independant/index.tsx index 14e22f56c..e2a01ae3a 100644 --- a/site/source/pages/assistants/declaration-charges-sociales-independant/index.tsx +++ b/site/source/pages/assistants/declaration-charges-sociales-independant/index.tsx @@ -19,7 +19,7 @@ import { Li, Ul } from '@/design-system/typography/list' import { Body, Intro, SmallBody } from '@/design-system/typography/paragraphs' import useSimulationConfig from '@/hooks/useSimulationConfig' import { useSitePaths } from '@/sitePaths' -import { updateSituation } from '@/store/actions/actions' +import { enregistreLaRéponse } from '@/store/actions/actions' import { SimulationConfig } from '@/store/reducers/rootReducer' import { situationSelector } from '@/store/selectors/simulationSelectors' @@ -195,7 +195,7 @@ function ImpositionSection() { const setSituation = useCallback( (value: PublicodesExpression | undefined, dottedName: DottedName) => { - dispatch(updateSituation(dottedName, value)) + dispatch(enregistreLaRéponse(dottedName, value)) }, [dispatch] ) diff --git a/site/source/pages/domaine/SimulationConfig.ts b/site/source/pages/domaine/SimulationConfig.ts new file mode 100644 index 000000000..e69de29bb diff --git a/site/source/pages/simulateurs/_configs/types.ts b/site/source/pages/simulateurs/_configs/types.ts index 75ba44d05..f41896af1 100644 --- a/site/source/pages/simulateurs/_configs/types.ts +++ b/site/source/pages/simulateurs/_configs/types.ts @@ -1,13 +1,8 @@ import type { TFunction } from 'i18next' -import { DottedName } from 'modele-social' -import { ASTNode, PublicodesExpression } from 'publicodes' +import { SimulationConfig } from '@/domaine/SimulationConfig' import { AbsoluteSitePaths } from '@/sitePaths' -export type Situation = Partial< - Record -> - /** * Configuration d'une page de simulateur ou d'assistant */ @@ -94,53 +89,6 @@ export interface PageConfig { seoExplanations?: () => JSX.Element } -export type SimulationConfig = Partial<{ - /** - * Objectifs exclusifs de la simulation : si une règle change dans la situation - * et qu'elle est dans `objectifs exclusifs`, alors toute les autres règles - * dans `objectifs exclusifs` seront supprimées de la situation - */ - 'objectifs exclusifs': DottedName[] - - /** - * Objectifs de la simulation - */ - objectifs?: DottedName[] - - /** - * La situation de base du simulateur - */ - situation: Situation - - questions: { - /** - * Question non prioritaires, elles aparraitront en fin de simulation - */ - 'non prioritaires'?: DottedName[] - - /** - * Whitelist des questions qui sont affiché à l'utilisateur. - * Cela peut également servir pour prioriser des questions - * en mettant une string vide comme dernier élément - */ - liste?: (DottedName | '')[] - - /** - * Questions qui ne sont pas affiché à l'utilisateur - */ - 'liste noire'?: DottedName[] - - /** - * Questions "raccourcis" sélectionnables en bas du simulateur - */ - "à l'affiche"?: { - label: string - dottedName: DottedName - }[] - } - - 'unité par défaut'?: string -}> /** * Les informations liées au tracking, utilisées pour les statistiques. * diff --git a/site/source/pages/simulateurs/artiste-auteur/simulationConfig.ts b/site/source/pages/simulateurs/artiste-auteur/simulationConfig.ts index 7e8da5af8..b5918f86c 100644 --- a/site/source/pages/simulateurs/artiste-auteur/simulationConfig.ts +++ b/site/source/pages/simulateurs/artiste-auteur/simulationConfig.ts @@ -1,4 +1,4 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' export const configArtisteAuteur: SimulationConfig = { objectifs: [ diff --git a/site/source/pages/simulateurs/auto-entrepreneur/simulationConfig.ts b/site/source/pages/simulateurs/auto-entrepreneur/simulationConfig.ts index 64177102c..42cd079e3 100644 --- a/site/source/pages/simulateurs/auto-entrepreneur/simulationConfig.ts +++ b/site/source/pages/simulateurs/auto-entrepreneur/simulationConfig.ts @@ -1,4 +1,4 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' export const configAutoEntrepreneur: SimulationConfig = { 'objectifs exclusifs': [ diff --git a/site/source/pages/simulateurs/chômage-partiel/simulationConfig.ts b/site/source/pages/simulateurs/chômage-partiel/simulationConfig.ts index 95e03e9e6..18a2b584f 100644 --- a/site/source/pages/simulateurs/chômage-partiel/simulationConfig.ts +++ b/site/source/pages/simulateurs/chômage-partiel/simulationConfig.ts @@ -1,4 +1,4 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' export const configChômagePartiel: SimulationConfig = { objectifs: [ diff --git a/site/source/pages/simulateurs/cipav/simulationConfig.ts b/site/source/pages/simulateurs/cipav/simulationConfig.ts index 6cf18f08d..a63f9c548 100644 --- a/site/source/pages/simulateurs/cipav/simulationConfig.ts +++ b/site/source/pages/simulateurs/cipav/simulationConfig.ts @@ -1,4 +1,5 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' + import { configProfessionLibérale } from '../profession-libérale/simulationConfig' const cipavSimulationConfig: SimulationConfig = { diff --git a/site/source/pages/simulateurs/comparaison-statuts/EngineComparison.tsx b/site/source/pages/simulateurs/comparaison-statuts/EngineComparison.tsx new file mode 100644 index 000000000..a8a2f9279 --- /dev/null +++ b/site/source/pages/simulateurs/comparaison-statuts/EngineComparison.tsx @@ -0,0 +1,5 @@ +import { NamedEngine } from '@/pages/simulateurs/comparaison-statuts/NamedEngine' + +export type EngineComparison = + | [NamedEngine, NamedEngine, NamedEngine] + | [NamedEngine, NamedEngine] diff --git a/site/source/pages/simulateurs/comparaison-statuts/NamedEngine.tsx b/site/source/pages/simulateurs/comparaison-statuts/NamedEngine.tsx new file mode 100644 index 000000000..33742d6b4 --- /dev/null +++ b/site/source/pages/simulateurs/comparaison-statuts/NamedEngine.tsx @@ -0,0 +1,9 @@ +import { DottedName } from 'modele-social' +import Engine from 'publicodes' + +import { StatutType } from '@/components/StatutTag' + +export type NamedEngine = { + engine: Engine + name: StatutType +} diff --git a/site/source/pages/simulateurs/comparaison-statuts/components/Comparateur.tsx b/site/source/pages/simulateurs/comparaison-statuts/components/Comparateur.tsx index 9dcc0b5b7..fdb5166ef 100644 --- a/site/source/pages/simulateurs/comparaison-statuts/components/Comparateur.tsx +++ b/site/source/pages/simulateurs/comparaison-statuts/components/Comparateur.tsx @@ -1,5 +1,3 @@ -import { DottedName } from 'modele-social' -import Engine from 'publicodes' import { Trans, useTranslation } from 'react-i18next' import { EngineDocumentationRoutes } from '@/components/EngineDocumentationRoutes' @@ -9,42 +7,26 @@ import Simulation, { SimulationGoal, SimulationGoals, } from '@/components/Simulation' -import { StatutType } from '@/components/StatutTag' import { Message } from '@/design-system' import { Container, Spacing } from '@/design-system/layout' import { H4 } from '@/design-system/typography/heading' import { Link } from '@/design-system/typography/link' import { Body } from '@/design-system/typography/paragraphs' +import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison' import { useSitePaths } from '@/sitePaths' import Détails from './Détails' import ModifierOptions from './ModifierOptions' import StatutChoice from './StatutChoice' -type NamedEngine = { - engine: Engine - name: StatutType -} - -export type EngineComparison = - | [NamedEngine, NamedEngine, NamedEngine] - | [NamedEngine, NamedEngine] - function Comparateur({ namedEngines }: { namedEngines: EngineComparison }) { const { t } = useTranslation() - const engines = namedEngines.map(({ engine }) => engine) as [ - Engine, - Engine, - Engine, - ] - const { absoluteSitePaths } = useSitePaths() return ( <> { diff --git a/site/source/pages/simulateurs/comparaison-statuts/components/Détails.tsx b/site/source/pages/simulateurs/comparaison-statuts/components/Détails.tsx index 996a8e6da..3c0b544f5 100644 --- a/site/source/pages/simulateurs/comparaison-statuts/components/Détails.tsx +++ b/site/source/pages/simulateurs/comparaison-statuts/components/Détails.tsx @@ -14,8 +14,8 @@ import { H2, H4 } from '@/design-system/typography/heading' import { StyledLink } from '@/design-system/typography/link' import { Li, Ul } from '@/design-system/typography/list' import { Body } from '@/design-system/typography/paragraphs' +import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison' -import { EngineComparison } from './Comparateur' import DetailsRowCards from './DetailsRowCards' import ItemTitle from './ItemTitle' import RevenuTable from './RevenuTable' diff --git a/site/source/pages/simulateurs/comparaison-statuts/components/ModifierOptions.tsx b/site/source/pages/simulateurs/comparaison-statuts/components/ModifierOptions.tsx index c1f1f1a91..5d3d56d23 100644 --- a/site/source/pages/simulateurs/comparaison-statuts/components/ModifierOptions.tsx +++ b/site/source/pages/simulateurs/comparaison-statuts/components/ModifierOptions.tsx @@ -1,7 +1,7 @@ import { PublicodesExpression } from 'publicodes' import { useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { styled } from 'styled-components' import { SwitchInput } from '@/components/conversation/ChoicesInput' @@ -16,10 +16,9 @@ import { Strong } from '@/design-system/typography' import { H2, H3, H5 } from '@/design-system/typography/heading' import { Link, StyledLink } from '@/design-system/typography/link' import { Body } from '@/design-system/typography/paragraphs' -import { answerQuestion } from '@/store/actions/actions' - -import { useCasParticuliers } from '../contexts/CasParticuliers' -import { EngineComparison } from './Comparateur' +import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison' +import { enregistreLaRéponse, setACRE } from '@/store/actions/actions' +import { acreActivéSelector } from '@/store/selectors/acreActivé.selector' const DOTTEDNAME_SOCIETE_IMPOT = 'entreprise . imposition' const DOTTEDNAME_SOCIETE_VERSEMENT_LIBERATOIRE = @@ -55,15 +54,16 @@ const ModifierOptions = ({ defaultValueVersementLiberatoire ) const [acreValue, setAcreValue] = useState(defaultValueACRE) - const { isAutoEntrepreneurACREEnabled, setIsAutoEntrepreneurACREEnabled } = - useCasParticuliers() + + const isAutoEntrepreneurACREEnabled = useSelector(acreActivéSelector) + const dispatch = useDispatch() + const setIsAutoEntrepreneurACREEnabled = (activé: boolean) => + dispatch(setACRE(activé)) const [AEAcreValue, setAEAcreValue] = useState(null) const { t } = useTranslation() - const dispatch = useDispatch() - const onCancel = useCallback(() => { setAcreValue(null) setVersementLiberatoireValue(null) @@ -82,19 +82,14 @@ const ModifierOptions = ({ )} confirmLabel="Enregistrer les options" onConfirm={() => { - dispatch( - answerQuestion( - DOTTEDNAME_SOCIETE_IMPOT, - impotValue as PublicodesExpression - ) - ) + dispatch(enregistreLaRéponse(DOTTEDNAME_SOCIETE_IMPOT, impotValue)) const versementLibératoireValuePassed = versementLiberatoireValue === null ? defaultValueVersementLiberatoire : versementLiberatoireValue dispatch( - answerQuestion( + enregistreLaRéponse( DOTTEDNAME_SOCIETE_VERSEMENT_LIBERATOIRE, versementLibératoireValuePassed ? 'oui' : 'non' ) @@ -103,7 +98,7 @@ const ModifierOptions = ({ const acreValuePassed = acreValue === null ? defaultValueACRE : acreValue dispatch( - answerQuestion(DOTTEDNAME_ACRE, acreValuePassed ? 'oui' : 'non') + enregistreLaRéponse(DOTTEDNAME_ACRE, acreValuePassed ? 'oui' : 'non') ) if (!acreValuePassed) { diff --git a/site/source/pages/simulateurs/comparaison-statuts/components/RevenuTable.tsx b/site/source/pages/simulateurs/comparaison-statuts/components/RevenuTable.tsx index 38c36ac2a..d4bd1f759 100644 --- a/site/source/pages/simulateurs/comparaison-statuts/components/RevenuTable.tsx +++ b/site/source/pages/simulateurs/comparaison-statuts/components/RevenuTable.tsx @@ -5,8 +5,7 @@ import Value from '@/components/EngineValue/Value' import { StatutTag } from '@/components/StatutTag' import { Tag } from '@/design-system/tag' import { Strong } from '@/design-system/typography' - -import { EngineComparison } from './Comparateur' +import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison' export default function RevenuTable({ namedEngines, diff --git a/site/source/pages/simulateurs/comparaison-statuts/components/StatutChoice.tsx b/site/source/pages/simulateurs/comparaison-statuts/components/StatutChoice.tsx index 28f2bf876..f38abcdd9 100644 --- a/site/source/pages/simulateurs/comparaison-statuts/components/StatutChoice.tsx +++ b/site/source/pages/simulateurs/comparaison-statuts/components/StatutChoice.tsx @@ -9,9 +9,9 @@ import { Grid, Spacing } from '@/design-system/layout' import { Strong } from '@/design-system/typography' import { H4 } from '@/design-system/typography/heading' import { Li, Ul } from '@/design-system/typography/list' +import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison' import { useSitePaths } from '@/sitePaths' -import { EngineComparison } from './Comparateur' import { getGridSizes } from './DetailsRowCards' import StatusCard from './StatusCard' diff --git a/site/source/pages/simulateurs/comparaison-statuts/contexts/CasParticuliers.tsx b/site/source/pages/simulateurs/comparaison-statuts/contexts/CasParticuliers.tsx deleted file mode 100644 index 052f94606..000000000 --- a/site/source/pages/simulateurs/comparaison-statuts/contexts/CasParticuliers.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { - createContext, - Dispatch, - ReactNode, - SetStateAction, - useContext, - useState, -} from 'react' - -type CasParticuliersType = { - isAutoEntrepreneurACREEnabled: boolean - setIsAutoEntrepreneurACREEnabled: Dispatch> -} - -const CasParticuliersContext = createContext({ - isAutoEntrepreneurACREEnabled: false, - setIsAutoEntrepreneurACREEnabled: () => null, -}) - -export const CasParticuliersProvider = ({ - children, -}: { - children: ReactNode -}) => { - const [isAutoEntrepreneurACREEnabled, setIsAutoEntrepreneurACREEnabled] = - useState(false) - - return ( - - {children} - - ) -} - -export const useCasParticuliers = () => useContext(CasParticuliersContext) diff --git a/site/source/pages/simulateurs/comparaison-statuts/index.tsx b/site/source/pages/simulateurs/comparaison-statuts/index.tsx index 0a14d71c8..27f89728c 100644 --- a/site/source/pages/simulateurs/comparaison-statuts/index.tsx +++ b/site/source/pages/simulateurs/comparaison-statuts/index.tsx @@ -7,56 +7,43 @@ import { Emoji } from '@/design-system/emoji' import { Strong } from '@/design-system/typography' import { Link } from '@/design-system/typography/link' import { Body, Intro } from '@/design-system/typography/paragraphs' +import { AssimiléSalariéContexte } from '@/domain/AssimiléSalariéContexte' +import { AutoentrepreneurContexte } from '@/domain/AutoentrepreneurContexte' +import { IndépendantContexte } from '@/domain/IndépendantContexte' import { useSitePaths } from '@/sitePaths' -import Comparateur, { EngineComparison } from './components/Comparateur' -import { - CasParticuliersProvider, - useCasParticuliers, -} from './contexts/CasParticuliers' +import Comparateur from './components/Comparateur' +import { EngineComparison } from './EngineComparison' function ComparateurStatutsUI() { const engine = useEngine() const situation = useRawSituation() const { absoluteSitePaths } = useSitePaths() - const { isAutoEntrepreneurACREEnabled } = useCasParticuliers() const assimiléEngine = useMemo( () => engine.shallowCopy().setSituation({ ...situation, - 'entreprise . imposition': "'IS'", - 'entreprise . catégorie juridique': "'SAS'", - 'entreprise . associés': "'unique'", + ...AssimiléSalariéContexte, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [situation] + [situation, engine] ) const autoEntrepreneurEngine = useMemo( () => engine.shallowCopy().setSituation({ ...situation, - 'entreprise . catégorie juridique': "'EI'", - 'entreprise . catégorie juridique . EI . auto-entrepreneur': 'oui', - ...(isAutoEntrepreneurACREEnabled - ? { 'dirigeant . exonérations . ACRE': 'oui' } - : { 'dirigeant . exonérations . ACRE': 'non' }), + ...AutoentrepreneurContexte, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [situation, isAutoEntrepreneurACREEnabled] + [situation, engine] ) const indépendantEngine = useMemo( () => engine.shallowCopy().setSituation({ ...situation, - 'entreprise . imposition': - situation['entreprise . imposition'] ?? "'IS'", - 'entreprise . catégorie juridique': "'EI'", - 'entreprise . catégorie juridique . EI . auto-entrepreneur': 'non', + ...IndépendantContexte, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [situation] + [situation, engine] ) const engines = [ @@ -97,9 +84,5 @@ function ComparateurStatutsUI() { } export default function ComparateurStatuts() { - return ( - - - - ) + return } diff --git a/site/source/pages/simulateurs/comparaison-statuts/simulationConfig.ts b/site/source/pages/simulateurs/comparaison-statuts/simulationConfig.ts index e297b8610..aa266be4c 100644 --- a/site/source/pages/simulateurs/comparaison-statuts/simulationConfig.ts +++ b/site/source/pages/simulateurs/comparaison-statuts/simulationConfig.ts @@ -1,6 +1,14 @@ -import { SimulationConfig } from '../_configs/types' +import { AssimiléSalariéContexte } from '@/domain/AssimiléSalariéContexte' +import { AutoentrepreneurContexte } from '@/domain/AutoentrepreneurContexte' +import { IndépendantContexte } from '@/domain/IndépendantContexte' +import { ComparateurConfig } from '@/domaine/ComparateurConfig' -export const configComparateurStatuts: SimulationConfig = { +export const configComparateurStatuts: ComparateurConfig = { + contextes: [ + AssimiléSalariéContexte, + AutoentrepreneurContexte, + IndépendantContexte, + ], 'objectifs exclusifs': [], objectifs: [ 'dirigeant . rémunération . net', diff --git a/site/source/pages/simulateurs/dividendes/Dividendes.tsx b/site/source/pages/simulateurs/dividendes/Dividendes.tsx index 283f05e5e..9d1893989 100644 --- a/site/source/pages/simulateurs/dividendes/Dividendes.tsx +++ b/site/source/pages/simulateurs/dividendes/Dividendes.tsx @@ -16,7 +16,7 @@ import { useEngine } from '@/components/utils/EngineContext' import { Radio, ToggleGroup } from '@/design-system/field' import { H2 } from '@/design-system/typography/heading' import { Body } from '@/design-system/typography/paragraphs' -import { updateSituation } from '@/store/actions/actions' +import { enregistreLaRéponse } from '@/store/actions/actions' export default function DividendesSimulation() { return ( @@ -63,7 +63,7 @@ function OptionBarèmeSwitch() { value={currentOptionPFU} onChange={(value) => { setCurrentOptionPFU(value) - dispatch(updateSituation(dottedName, `'${value}'`)) + dispatch(enregistreLaRéponse(dottedName, `'${value}'`)) }} aria-label={t("Régime d'imposition")} > diff --git a/site/source/pages/simulateurs/dividendes/simulationConfig.ts b/site/source/pages/simulateurs/dividendes/simulationConfig.ts index ce992ce15..4f0cd3b97 100644 --- a/site/source/pages/simulateurs/dividendes/simulationConfig.ts +++ b/site/source/pages/simulateurs/dividendes/simulationConfig.ts @@ -1,4 +1,4 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' export const configDividendes: SimulationConfig = { 'objectifs exclusifs': [ diff --git a/site/source/pages/simulateurs/impot-societe/index.tsx b/site/source/pages/simulateurs/impot-societe/index.tsx index 1e3bf879d..7fae532bd 100644 --- a/site/source/pages/simulateurs/impot-societe/index.tsx +++ b/site/source/pages/simulateurs/impot-societe/index.tsx @@ -18,7 +18,10 @@ import Warning from '@/components/ui/WarningBlock' import { H2 } from '@/design-system/typography/heading' import { Link } from '@/design-system/typography/link' import { Body, Intro } from '@/design-system/typography/paragraphs' -import { batchUpdateSituation, updateSituation } from '@/store/actions/actions' +import { + batchUpdateSituation, + enregistreLaRéponse, +} from '@/store/actions/actions' import { situationSelector } from '@/store/selectors/simulationSelectors' export default function ISSimulation() { @@ -105,13 +108,13 @@ function ExerciceDate() { - dispatch(updateSituation('entreprise . exercice . début', x)) + dispatch(enregistreLaRéponse('entreprise . exercice . début', x)) } />{' '} - dispatch(updateSituation('entreprise . exercice . fin', x)) + dispatch(enregistreLaRéponse('entreprise . exercice . fin', x)) } /> diff --git a/site/source/pages/simulateurs/impot-societe/simulationConfig.ts b/site/source/pages/simulateurs/impot-societe/simulationConfig.ts index 791ebcd1b..45311f60f 100644 --- a/site/source/pages/simulateurs/impot-societe/simulationConfig.ts +++ b/site/source/pages/simulateurs/impot-societe/simulationConfig.ts @@ -1,4 +1,4 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' const ISSimulationConfig: SimulationConfig = { 'unité par défaut': '€/an', diff --git a/site/source/pages/simulateurs/indépendant/Indépendant.tsx b/site/source/pages/simulateurs/indépendant/Indépendant.tsx index b426971fc..1111c03fd 100644 --- a/site/source/pages/simulateurs/indépendant/Indépendant.tsx +++ b/site/source/pages/simulateurs/indépendant/Indépendant.tsx @@ -17,7 +17,7 @@ import { Message } from '@/design-system' import { Emoji } from '@/design-system/emoji' import { H2 } from '@/design-system/typography/heading' import { Body } from '@/design-system/typography/paragraphs' -import { updateSituation } from '@/store/actions/actions' +import { enregistreLaRéponse } from '@/store/actions/actions' export function IndépendantPLSimulation() { return ( @@ -135,7 +135,7 @@ export default function IndépendantSimulation() { dottedName="entreprise . imposition" onChange={(imposition) => { dispatch( - updateSituation('entreprise . imposition', imposition) + enregistreLaRéponse('entreprise . imposition', imposition) ) }} /> diff --git a/site/source/pages/simulateurs/indépendant/simulationConfig.ts b/site/source/pages/simulateurs/indépendant/simulationConfig.ts index 102833529..4d75dc6ca 100644 --- a/site/source/pages/simulateurs/indépendant/simulationConfig.ts +++ b/site/source/pages/simulateurs/indépendant/simulationConfig.ts @@ -1,4 +1,4 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' export const configIndépendant: SimulationConfig = { 'objectifs exclusifs': [ diff --git a/site/source/pages/simulateurs/profession-libérale/simulationConfig.ts b/site/source/pages/simulateurs/profession-libérale/simulationConfig.ts index 8dd47c97a..268c9841f 100644 --- a/site/source/pages/simulateurs/profession-libérale/simulationConfig.ts +++ b/site/source/pages/simulateurs/profession-libérale/simulationConfig.ts @@ -1,4 +1,5 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' + import { configIndépendant } from '../indépendant/simulationConfig' export const configProfessionLibérale: SimulationConfig = { diff --git a/site/source/pages/simulateurs/salarié/simulationConfig.ts b/site/source/pages/simulateurs/salarié/simulationConfig.ts index c4a7cb22d..11893495e 100644 --- a/site/source/pages/simulateurs/salarié/simulationConfig.ts +++ b/site/source/pages/simulateurs/salarié/simulationConfig.ts @@ -1,4 +1,4 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' export const configSalarié: SimulationConfig = { 'objectifs exclusifs': [ diff --git a/site/source/pages/simulateurs/sasu/simulationConfig.ts b/site/source/pages/simulateurs/sasu/simulationConfig.ts index 47781cd37..461ca7dda 100644 --- a/site/source/pages/simulateurs/sasu/simulationConfig.ts +++ b/site/source/pages/simulateurs/sasu/simulationConfig.ts @@ -1,4 +1,4 @@ -import { SimulationConfig } from '../_configs/types' +import { SimulationConfig } from '@/domaine/SimulationConfig' export const configSASU: SimulationConfig = { 'objectifs exclusifs': [ diff --git a/site/source/store/actions/actions.ts b/site/source/store/actions/actions.ts index 4a91b218d..a1496e7c2 100644 --- a/site/source/store/actions/actions.ts +++ b/site/source/store/actions/actions.ts @@ -2,7 +2,6 @@ import { DottedName } from 'modele-social' import Engine, { PublicodesExpression } from 'publicodes' import { SimulationConfig } from '@/store/reducers/rootReducer' -import { ImmutableType } from '@/types/utils' import { buildSituationFromObject } from '@/utils' import { CompanyActions } from './companyActions' @@ -11,18 +10,19 @@ import { HiringChecklistAction } from './hiringChecklistAction' export type Action = | ReturnType< | typeof explainVariable - | typeof goToQuestion + | typeof vaÀLaQuestion | typeof hideNotification | typeof loadPreviousSimulation | typeof resetSimulation | typeof setActiveTarget | typeof setSimulationConfig - | typeof stepAction - | typeof updateSituation + | typeof retourneÀLaQuestionPrécédente + | typeof vaÀLaQuestionSuivante + | typeof enregistreLaRéponse | typeof deleteFromSituation | typeof updateUnit | typeof batchUpdateSituation - | typeof updateShouldFocusField + | typeof questionsSuivantes > | CompanyActions | HiringChecklistAction @@ -32,24 +32,19 @@ export const resetSimulation = () => type: 'RESET_SIMULATION', }) as const -export const goToQuestion = (question: DottedName) => +export const vaÀLaQuestion = (question: DottedName) => ({ - type: 'STEP_ACTION', - name: 'unfold', - step: question, + type: 'VA_À_LA_QUESTION', + question, }) as const -export const stepAction = (step: DottedName) => +export const questionsSuivantes = (questionsSuivantes: Array) => ({ - type: 'STEP_ACTION', - name: 'fold', - step, + type: 'QUESTIONS_SUIVANTES', + questionsSuivantes, }) as const -export const setSimulationConfig = ( - config: ImmutableType, - url: string -) => +export const setSimulationConfig = (config: SimulationConfig, url: string) => ({ type: 'SET_SIMULATION', url, @@ -62,11 +57,14 @@ export const setActiveTarget = (targetName: DottedName) => name: targetName, }) as const -export const updateSituation = (fieldName: DottedName, value: unknown) => +export const enregistreLaRéponse = ( + fieldName: DottedName, + value: PublicodesExpression | undefined +) => value === undefined ? deleteFromSituation(fieldName) : ({ - type: 'UPDATE_SITUATION', + type: 'ENREGISTRE_LA_RÉPONSE', fieldName, value, } as const) @@ -107,30 +105,23 @@ export const explainVariable = (variableName: DottedName | null = null) => variableName, }) as const -export const updateShouldFocusField = (shouldFocusField: boolean) => +export const retourneÀLaQuestionPrécédente = () => ({ - type: 'UPDATE_SHOULD_FOCUS_FIELD', - shouldFocusField, + type: 'RETOURNE_À_LA_QUESTION_PRÉCÉDENTE', }) as const -export const answerQuestion = ( - dottedName: DottedName, - value: - | PublicodesExpression - | undefined - | { batchUpdate: Record } -) => { - if (value && typeof value === 'object' && 'batchUpdate' in value) { - return batchUpdateSituation( - buildSituationFromObject( - dottedName, - value.batchUpdate as Record - ) - ) - } +export const vaÀLaQuestionSuivante = () => + ({ + type: 'VA_À_LA_QUESTION_SUIVANTE', + }) as const - return (value == null ? deleteFromSituation : updateSituation)( - dottedName, - value +export const setACRE = (activé: boolean) => + enregistreLaRéponse( + 'dirigeant . exonérations . ACRE', + activé ? "'oui'" : "'non'" ) -} + +export const answerBatchQuestion = ( + dottedName: DottedName, + value: Record +) => batchUpdateSituation(buildSituationFromObject(dottedName, value)) diff --git a/site/source/store/middlewares/prendLaProchaineQuestion.middleware.ts b/site/source/store/middlewares/prendLaProchaineQuestion.middleware.ts new file mode 100644 index 000000000..3d91f7ff9 --- /dev/null +++ b/site/source/store/middlewares/prendLaProchaineQuestion.middleware.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import * as Array from 'effect/Array' +import * as String from 'effect/String' +import Engine from 'publicodes' +import { Dispatch, Middleware } from 'redux' + +import { détermineLesProchainesQuestions } from '@/domaine/engine/détermineLesProchainesQuestions' +import { SimulationConfig } from '@/domaine/SimulationConfig' +import { Action, questionsSuivantes } from '@/store/actions/actions' +import { RootState } from '@/store/reducers/rootReducer' +import { rawSituationSelector } from '@/store/selectors/simulationSelectors' +import { complement } from '@/utils/complement' + +export const prendLaProchaineQuestionMiddleware = + (engine: Engine): Middleware> => + (store) => + (next) => + (action) => { + const result = next(action) + + const newState = store.getState() + + const config = newState.simulation?.config + const situation = rawSituationSelector(newState) + const questionsRépondues = newState.simulation?.answeredQuestions + const questionsSuivantesActuelles = + newState.simulation?.questionsSuivantes || [] + + if (situation && config) { + engine.setSituation(situation) + + const prochainesQuestions = détermineLesProchainesQuestions( + engine, + config as SimulationConfig, + questionsRépondues + ) + + if ( + arraysAreDifferent(prochainesQuestions, questionsSuivantesActuelles) + ) { + store.dispatch(questionsSuivantes(prochainesQuestions)) + } + } + + return result + } + +const arraysAreDifferent = complement(Array.getEquivalence(String.Equivalence)) diff --git a/site/source/store/reducers/companySituationReducer.ts b/site/source/store/reducers/companySituationReducer.ts index d45f10279..d359613e1 100644 --- a/site/source/store/reducers/companySituationReducer.ts +++ b/site/source/store/reducers/companySituationReducer.ts @@ -39,7 +39,7 @@ export function isCompanyDottedName(dottedName: DottedName) { export function companySituation(state: Situation = {}, action: Action) { switch (action.type) { - case 'UPDATE_SITUATION': + case 'ENREGISTRE_LA_RÉPONSE': if (isCompanyDottedName(action.fieldName)) { return { ...state, diff --git a/site/source/store/reducers/previousSimulationRootReducer.ts b/site/source/store/reducers/previousSimulationRootReducer.ts index 53ba3f73c..863c97ac3 100644 --- a/site/source/store/reducers/previousSimulationRootReducer.ts +++ b/site/source/store/reducers/previousSimulationRootReducer.ts @@ -1,6 +1,6 @@ import { retrievePersistedSimulation } from '@/storage/persistSimulation' import { Action } from '@/store/actions/actions' -import { Simulation } from '@/store/reducers/rootReducer' +import { Simulation } from '@/store/reducers/simulation.reducer' import { RootState } from './rootReducer' @@ -13,7 +13,7 @@ export const createStateFromPreviousSimulation = ( simulation: { ...state.simulation, situation: state.previousSimulation.situation || {}, - foldedSteps: state.previousSimulation.foldedSteps, + answeredQuestions: state.previousSimulation.foldedSteps, } as Simulation, previousSimulation: null, } diff --git a/site/source/store/reducers/rootReducer.ts b/site/source/store/reducers/rootReducer.ts index d51e6945a..c88b2dbb6 100644 --- a/site/source/store/reducers/rootReducer.ts +++ b/site/source/store/reducers/rootReducer.ts @@ -2,11 +2,14 @@ import { DottedName } from 'modele-social' import reduceReducers from 'reduce-reducers' import { combineReducers, Reducer } from 'redux' -import { SimulationConfig, Situation } from '@/pages/simulateurs/_configs/types' -import { Action, updateSituation } from '@/store/actions/actions' +import { SimulationConfig } from '@/domaine/SimulationConfig' +import { Situation } from '@/domaine/Situation' +import { + Action, + enregistreLaRéponse as updateSituationAction, +} from '@/store/actions/actions' +import { simulationReducer } from '@/store/reducers/simulation.reducer' import { PreviousSimulation } from '@/store/selectors/previousSimulationSelectors' -import { ImmutableType } from '@/types/utils' -import { objectTransform, omit } from '@/utils' import choixStatutJuridique from './choixStatutJuridiqueReducer' import { companySituation } from './companySituationReducer' @@ -14,20 +17,6 @@ import previousSimulationRootReducer from './previousSimulationRootReducer' export type { SimulationConfig, Situation } -function explainedVariable( - state: DottedName | null = null, - action: Action -): DottedName | null { - switch (action.type) { - case 'EXPLAIN_VARIABLE': - return action.variableName - case 'STEP_ACTION': - return null - default: - return state - } -} - function activeTargetInput(state: DottedName | null = null, action: Action) { switch (action.type) { case 'SET_ACTIVE_TARGET_INPUT': @@ -39,128 +28,6 @@ function activeTargetInput(state: DottedName | null = null, action: Action) { } } -export type Simulation = { - config: ImmutableType - url: string - hiddenNotifications: Array - situation: Situation - targetUnit: string - foldedSteps: Array - unfoldedStep?: DottedName | null - shouldFocusField: boolean -} - -function simulation( - state: Simulation | null = null, - action: Action -): Simulation | null { - if (action.type === 'SET_SIMULATION') { - const { config, url } = action - - return { - config, - url, - hiddenNotifications: [], - situation: {}, - targetUnit: config['unité par défaut'] || '€/mois', - foldedSteps: [], - unfoldedStep: null, - shouldFocusField: false, - } - } - - if (state === null) { - return state - } - - switch (action.type) { - case 'HIDE_NOTIFICATION': - return { - ...state, - hiddenNotifications: [...state.hiddenNotifications, action.id], - } - case 'RESET_SIMULATION': - return { - ...state, - hiddenNotifications: [], - situation: {}, - foldedSteps: [], - unfoldedStep: null, - } - - case 'UPDATE_SITUATION': { - const situation = state.situation - const { fieldName: dottedName, value } = action - - if (value === undefined) { - return { ...state, situation: omit(situation, dottedName) } - } - - const objectifsExclusifs = state.config['objectifs exclusifs'] ?? [] - - if (objectifsExclusifs.includes(dottedName)) { - const objectifsToReset = objectifsExclusifs.filter( - (name) => name !== dottedName - ) - - const newSituation = objectTransform(situation, (entries) => - entries.filter( - ([dottedName]) => - !objectifsToReset.includes(dottedName as DottedName) - ) - ) - - return { ...state, situation: { ...newSituation, [dottedName]: value } } - } - - return { ...state, situation: { ...situation, [dottedName]: value } } - } - case 'DELETE_FROM_SITUATION': { - const newState = { - ...state, - situation: omit( - state.situation, - action.fieldName - ) as Simulation['situation'], - } - - return newState - } - case 'STEP_ACTION': { - const { name, step } = action - if (name === 'fold') { - return { - ...state, - foldedSteps: [...state.foldedSteps, step], - unfoldedStep: null, - } - } - if (name === 'unfold') { - return { - ...state, - foldedSteps: state.foldedSteps.filter((name) => name !== step), - unfoldedStep: step, - } - } - - return state - } - case 'UPDATE_TARGET_UNIT': - return { - ...state, - targetUnit: action.targetUnit, - } - - case 'UPDATE_SHOULD_FOCUS_FIELD': - return { - ...state, - shouldFocusField: action.shouldFocusField, - } - } - - return state -} - function batchUpdateSituationReducer(state: RootState, action: Action) { if (action.type !== 'BATCH_UPDATE_SITUATION') { return state @@ -170,15 +37,14 @@ function batchUpdateSituationReducer(state: RootState, action: Action) { (newState, [fieldName, value]) => mainReducer( newState ?? undefined, - updateSituation(fieldName as DottedName, value) + updateSituationAction(fieldName as DottedName, value) ), state ) } const mainReducer = combineReducers({ - explainedVariable, - simulation, + simulation: simulationReducer, companySituation, previousSimulation: ((p) => p ?? null) as Reducer, activeTargetInput, diff --git a/site/source/store/reducers/simulation.reducer.spec.ts b/site/source/store/reducers/simulation.reducer.spec.ts new file mode 100644 index 000000000..4046194a4 --- /dev/null +++ b/site/source/store/reducers/simulation.reducer.spec.ts @@ -0,0 +1,215 @@ +import { DottedName } from 'modele-social' +import { describe, expect, it } from 'vitest' + +import { + Simulation, + simulationReducer, +} from '@/store/reducers/simulation.reducer' + +import { + deleteFromSituation, + enregistreLaRéponse, + retourneÀLaQuestionPrécédente, + vaÀLaQuestion, + vaÀLaQuestionSuivante, +} from '../actions/actions' + +const previousQuestionAction = retourneÀLaQuestionPrécédente() +const nextQuestionAction = vaÀLaQuestionSuivante() + +describe('simulationReducer', () => { + describe('RETOURNE_À_LA_QUESTION_PRÉCÉDENTE', () => { + it('fonctionne quand la question en cours est la dernière posée', () => { + const state = { + currentQuestion: 'b', + answeredQuestions: ['a', 'b'], + } + expect( + simulationReducer(state as Simulation, previousQuestionAction) + ).toEqual({ + currentQuestion: 'a', + answeredQuestions: ['a', 'b'], + }) + }) + it('fonctionne quand la question en cours n’a pas été répondue', () => { + const state = { + currentQuestion: 'c', + answeredQuestions: ['a', 'b'], + } + expect( + simulationReducer(state as Simulation, previousQuestionAction) + ).toEqual({ + currentQuestion: 'b', + answeredQuestions: ['a', 'b'], + }) + }) + + it('fonctionne quand on est au milieu de l’historique', () => { + const state = { + currentQuestion: 'b', + answeredQuestions: ['a', 'b', 'c'], + } + expect( + simulationReducer( + state as unknown as Simulation, + previousQuestionAction + ) + ).toEqual({ + currentQuestion: 'a', + answeredQuestions: ['a', 'b', 'c'], + }) + }) + + it('fonctionne quand on a déjà répondu à toutes les questions', () => { + const state = { + currentQuestion: null, + answeredQuestions: ['a', 'b', 'c'], + } + expect( + simulationReducer( + state as unknown as Simulation, + previousQuestionAction + ) + ).toEqual({ + currentQuestion: 'c', + answeredQuestions: ['a', 'b', 'c'], + }) + }) + + it('ne fait rien si on est sur la première question', () => { + const state = { + currentQuestion: 'a', + answeredQuestions: [], + } + expect( + simulationReducer( + state as unknown as Simulation, + previousQuestionAction + ) + ).toEqual(state) + }) + + it('ne fait rien quand on est revenu à la première question', () => { + const state = { + currentQuestion: 'a', + answeredQuestions: ['a', 'b'], + } + expect( + simulationReducer( + state as unknown as Simulation, + previousQuestionAction + ) + ).toEqual(state) + }) + }) + describe('VA_À_LA_QUESTION_SUIVANTE', () => { + it('va à la prochaine question déjà répondue de l’historique le cas échéant', () => { + const state = { + currentQuestion: 'b', + answeredQuestions: ['a', 'b', 'c'], + } + expect( + simulationReducer(state as unknown as Simulation, nextQuestionAction) + ).toEqual({ + currentQuestion: 'c', + answeredQuestions: ['a', 'b', 'c'], + }) + }) + it('demande une nouvelle question si par défaut', () => { + const state = { + currentQuestion: 'b', + answeredQuestions: ['a', 'b'], + } + expect( + simulationReducer(state as unknown as Simulation, nextQuestionAction) + ).toEqual({ + currentQuestion: null, + answeredQuestions: ['a', 'b'], + }) + }) + it('ne fait rien si la question actuelle n’a pas été répondue', () => { + const state = { + currentQuestion: 'c', + answeredQuestions: ['a', 'b'], + } + expect( + simulationReducer(state as unknown as Simulation, nextQuestionAction) + ).toEqual(state) + }) + }) + describe('VA_À_LA_QUESTION', () => { + it('va à la question demandée', () => { + const state = { + currentQuestion: 'b', + answeredQuestions: ['a', 'b'], + } + expect( + simulationReducer( + state as unknown as Simulation, + vaÀLaQuestion('c' as DottedName) + ) + ).toEqual({ + currentQuestion: 'c', + answeredQuestions: ['a', 'b'], + }) + }) + }) + describe('ENREGISTRE_LA_RÉPONSE', () => { + it('marque la question répondue si une réponse est fournie', () => { + const state = { + answeredQuestions: ['a', 'b'], + config: { + 'objectifs exclusifs': [], + }, + } + expect( + simulationReducer( + state as unknown as Simulation, + enregistreLaRéponse('c' as DottedName, 42) + ) + ).toMatchObject({ + answeredQuestions: ['a', 'b', 'c'], + }) + }) + }) + describe('DELETE_FROM_SITUATION', () => { + it('supprime la question des questions répondues si réponse effacée', () => { + const state = { + answeredQuestions: ['a', 'b', 'c'], + config: { + 'objectifs exclusifs': [], + }, + situation: { + c: 42, + }, + } + expect( + simulationReducer( + state as unknown as Simulation, + deleteFromSituation('c' as DottedName) + ) + ).toMatchObject({ + answeredQuestions: ['a', 'b'], + }) + }) + it('ne change rien sinon', () => { + const state = { + answeredQuestions: ['a', 'b', 'c'], + config: { + 'objectifs exclusifs': [], + }, + situation: { + c: 42, + }, + } + expect( + simulationReducer( + state as unknown as Simulation, + deleteFromSituation('d' as DottedName) + ) + ).toMatchObject({ + answeredQuestions: ['a', 'b', 'c'], + }) + }) + }) +}) diff --git a/site/source/store/reducers/simulation.reducer.ts b/site/source/store/reducers/simulation.reducer.ts new file mode 100644 index 000000000..cf4075555 --- /dev/null +++ b/site/source/store/reducers/simulation.reducer.ts @@ -0,0 +1,191 @@ +import { DottedName } from 'modele-social' + +import { SimulationConfig } from '@/domaine/SimulationConfig' +import { Situation } from '@/domaine/Situation' +import { updateSituation } from '@/domaine/updateSituation' +import { Action } from '@/store/actions/actions' +import { omit, reject } from '@/utils' + +export type Simulation = { + config: SimulationConfig + url: string + hiddenNotifications: Array + situation: Situation + targetUnit: string + answeredQuestions: Array + questionsSuivantes?: Array + currentQuestion?: DottedName | null +} + +export function simulationReducer( + state: Simulation | null = null, + action: Action +): Simulation | null { + if (action.type === 'SET_SIMULATION') { + const { config, url } = action + + return { + config, + url, + hiddenNotifications: [], + situation: {}, + targetUnit: config['unité par défaut'] || '€/mois', + answeredQuestions: [], + currentQuestion: null, + } + } + + if (state === null) { + return state + } + + switch (action.type) { + case 'HIDE_NOTIFICATION': + return { + ...state, + hiddenNotifications: [...state.hiddenNotifications, action.id], + } + case 'RESET_SIMULATION': + return { + ...state, + hiddenNotifications: [], + situation: {}, + answeredQuestions: [], + currentQuestion: null, + } + + case 'ENREGISTRE_LA_RÉPONSE': { + const answeredQuestions = state.answeredQuestions.includes( + action.fieldName + ) + ? state.answeredQuestions + : [...state.answeredQuestions, action.fieldName] + + return { + ...state, + answeredQuestions, + situation: updateSituation( + state.config, + state.situation, + action.fieldName, + action.value + ), + } + } + + case 'DELETE_FROM_SITUATION': { + const newState = { + ...state, + answeredQuestions: reject( + state.answeredQuestions, + (q) => q === action.fieldName + ), + situation: omit( + state.situation, + action.fieldName + ) as Simulation['situation'], + } + + return newState + } + case 'RETOURNE_À_LA_QUESTION_PRÉCÉDENTE': { + if (state.answeredQuestions.length === 0) { + return state + } + + const currentIndex = state.currentQuestion + ? state.answeredQuestions.indexOf(state.currentQuestion) + : -1 + + if (currentIndex === -1) { + return { + ...state, + currentQuestion: state.answeredQuestions.at(-1), + } + } + + const previousQuestion = state.answeredQuestions[currentIndex - 1] + if (previousQuestion === undefined) { + return state + } + + return { + ...state, + currentQuestion: previousQuestion, + } + } + + case 'VA_À_LA_QUESTION_SUIVANTE': { + const currentIndex = state.currentQuestion + ? state.answeredQuestions.indexOf(state.currentQuestion) + : -1 + + // La question en cours n'est pas répondue + if (currentIndex === -1) { + console.log( + 'Passer à la question suivante ne devrait pas être autorisé' + ) + + return state + } + + // On était sur la dernière question posée, on en prend une nouvelle + if (currentIndex === state.answeredQuestions.length - 1) { + const questionEnCours = state.questionsSuivantes?.length + ? state.questionsSuivantes[0] + : null + + return { + ...state, + currentQuestion: questionEnCours, + } + } + + // Sinon, on navigue simplement à la questions déjà répondue suivante + return { + ...state, + currentQuestion: state.answeredQuestions[currentIndex + 1], + } + } + + case 'VA_À_LA_QUESTION': { + return { + ...state, + currentQuestion: action.question, + } + } + + case 'QUESTIONS_SUIVANTES': { + const currentQuestion = state.currentQuestion + const pasDeQuestionEnCours = !currentQuestion + const questionEnCoursNEstPasÀRépondre = currentQuestion + ? !action.questionsSuivantes.includes(currentQuestion) + : false + const questionEnCoursNEstPasRépondue = currentQuestion + ? !state.answeredQuestions?.includes(currentQuestion) + : false + const questionEnCoursPlusNécessaire = + questionEnCoursNEstPasÀRépondre && questionEnCoursNEstPasRépondue + + const nouvelleQuestionEnCours = + action.questionsSuivantes.length && + (pasDeQuestionEnCours || questionEnCoursPlusNécessaire) + ? action.questionsSuivantes[0] + : currentQuestion + + return { + ...state, + questionsSuivantes: action.questionsSuivantes, + currentQuestion: nouvelleQuestionEnCours, + } + } + + case 'UPDATE_TARGET_UNIT': + return { + ...state, + targetUnit: action.targetUnit, + } + } + + return state +} diff --git a/site/source/store/selectors/acreActivé.selector.ts b/site/source/store/selectors/acreActivé.selector.ts new file mode 100644 index 000000000..92a1d365f --- /dev/null +++ b/site/source/store/selectors/acreActivé.selector.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect' + +import { situationSelector } from '@/store/selectors/simulationSelectors' + +export const acreActivéSelector = createSelector( + [situationSelector], + (situation) => + (situation['dirigeant . exonérations . ACRE'] || "'non'") === "'oui'" +) diff --git a/site/source/store/selectors/config.selector.ts b/site/source/store/selectors/config.selector.ts new file mode 100644 index 000000000..ad0f4223e --- /dev/null +++ b/site/source/store/selectors/config.selector.ts @@ -0,0 +1,8 @@ +import { createSelector } from 'reselect' + +import { simulationSelector } from '@/store/selectors/simulation.selector' + +export const configSelector = createSelector( + [simulationSelector], + (simulation) => simulation?.config ?? {} +) diff --git a/site/source/store/selectors/currentQuestion.selector.ts b/site/source/store/selectors/currentQuestion.selector.ts new file mode 100644 index 000000000..ce70d4b79 --- /dev/null +++ b/site/source/store/selectors/currentQuestion.selector.ts @@ -0,0 +1,8 @@ +import { createSelector } from 'reselect' + +import { simulationSelector } from '@/store/selectors/simulation.selector' + +export const currentQuestionSelector = createSelector( + [simulationSelector], + (simulation) => simulation?.currentQuestion ?? null +) diff --git a/site/source/store/selectors/previousSimulationSelectors.ts b/site/source/store/selectors/previousSimulationSelectors.ts index ea6f737de..1c5eef03f 100644 --- a/site/source/store/selectors/previousSimulationSelectors.ts +++ b/site/source/store/selectors/previousSimulationSelectors.ts @@ -1,6 +1,7 @@ import { DottedName } from 'modele-social' -import { RootState, Simulation } from '@/store/reducers/rootReducer' +import { RootState } from '@/store/reducers/rootReducer' +import { Simulation } from '@/store/reducers/simulation.reducer' export type PreviousSimulation = { situation: Simulation['situation'] @@ -14,6 +15,6 @@ export const currentSimulationSelector = ( return { situation: state.simulation?.situation ?? {}, activeTargetInput: state.activeTargetInput, - foldedSteps: state.simulation?.foldedSteps, + foldedSteps: state.simulation?.answeredQuestions, } } diff --git a/site/source/store/selectors/questionEnCoursRépondue.selector.ts b/site/source/store/selectors/questionEnCoursRépondue.selector.ts new file mode 100644 index 000000000..bdf9a334c --- /dev/null +++ b/site/source/store/selectors/questionEnCoursRépondue.selector.ts @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect' + +import { currentQuestionSelector } from '@/store/selectors/currentQuestion.selector' +import { questionsSuivantesSelector } from '@/store/selectors/questionsSuivantes.selector' + +export const questionEnCoursRépondueSelector = createSelector( + [currentQuestionSelector, questionsSuivantesSelector], + (questionEnCours, questionsSuivantes) => + !!questionEnCours && + !!questionsSuivantes && + !questionsSuivantes.includes(questionEnCours) +) diff --git a/site/source/store/selectors/questionsSuivantes.selector.ts b/site/source/store/selectors/questionsSuivantes.selector.ts new file mode 100644 index 000000000..7bba8b125 --- /dev/null +++ b/site/source/store/selectors/questionsSuivantes.selector.ts @@ -0,0 +1,8 @@ +import { createSelector } from 'reselect' + +import { simulationSelector } from '@/store/selectors/simulation.selector' + +export const questionsSuivantesSelector = createSelector( + [simulationSelector], + (simulation) => simulation?.questionsSuivantes || [] +) diff --git a/site/source/store/selectors/simulation.selector.ts b/site/source/store/selectors/simulation.selector.ts new file mode 100644 index 000000000..7eb95037f --- /dev/null +++ b/site/source/store/selectors/simulation.selector.ts @@ -0,0 +1,3 @@ +import { RootState } from '@/store/reducers/rootReducer' + +export const simulationSelector = (state: RootState) => state.simulation diff --git a/site/source/store/selectors/simulationSelectors.ts b/site/source/store/selectors/simulationSelectors.ts index b57d5b8cc..60676cb6c 100644 --- a/site/source/store/selectors/simulationSelectors.ts +++ b/site/source/store/selectors/simulationSelectors.ts @@ -1,13 +1,9 @@ -import { DottedName } from 'modele-social' -import Engine, { utils } from 'publicodes' -import { useSelector } from 'react-redux' import { createSelector } from 'reselect' -import { useEngine } from '@/components/utils/EngineContext' +import { isComparateurConfig } from '@/domaine/ComparateurConfig' import { RootState, Situation } from '@/store/reducers/rootReducer' - -export const configSelector = (state: RootState) => - state.simulation?.config ?? {} +import { configSelector } from '@/store/selectors/config.selector' +import { NonEmptyArray } from 'effect/Array' export const configObjectifsSelector = createSelector( [ @@ -22,31 +18,40 @@ export const configObjectifsSelector = createSelector( const emptySituation: Situation = {} -export const useMissingVariables = ({ - engines, -}: { - engines: Array> -}): Partial> => { - const objectifs = useSelector(configObjectifsSelector) - - return treatAPIMissingVariables( - objectifs - .flatMap((objectif) => - engines.map((e) => e.evaluate(objectif).missingVariables ?? {}) - ) - .reduce(mergeMissing, {}), - useEngine() - ) -} export const situationSelector = (state: RootState) => state.simulation?.situation ?? emptySituation export const configSituationSelector = (state: RootState) => configSelector(state).situation ?? emptySituation +export const configContextesSelector = createSelector( + [configSelector], + (config) => (isComparateurConfig(config) ? config.contextes : undefined) +) + export const companySituationSelector = (state: RootState) => state.companySituation +export const rawSituationSelector = createSelector( + [situationSelector, configSituationSelector, companySituationSelector], + (simulatorSituation, configSituation, companySituation) => ({ + ...companySituation, + ...configSituation, + ...simulatorSituation, + }) +) + +export const rawSituationsSelonContextesSelector = createSelector( + [rawSituationSelector, configContextesSelector], + (rawSituation, contextes) => + (contextes + ? contextes.map((contexte) => ({ + ...rawSituation, + ...contexte, + })) + : [rawSituation]) as NonEmptyArray +) + export const firstStepCompletedSelector = (state: RootState) => { const situation = situationSelector(state) @@ -61,48 +66,7 @@ export const firstStepCompletedSelector = (state: RootState) => { export const targetUnitSelector = (state: RootState) => state.simulation?.targetUnit ?? '€/mois' -export const currentQuestionSelector = (state: RootState) => - state.simulation?.unfoldedStep ?? null - export const answeredQuestionsSelector = (state: RootState) => - state.simulation?.foldedSteps ?? [] - -export const shouldFocusFieldSelector = (state: RootState) => - state.simulation?.shouldFocusField ?? false + state.simulation?.answeredQuestions ?? [] export const urlSelector = (state: RootState) => state.simulation?.url - -/** - * Merge objectifs missings that depends on the same input field. - * - * For instance, the commune field (API) will fill `commune . nom` `commune . taux versement transport`, `commune . département`, etc. - */ -function treatAPIMissingVariables( - missingVariables: Partial>, - engine: Engine -): Partial> { - return (Object.entries(missingVariables) as Array<[Name, number]>).reduce( - (missings, [name, value]: [Name, number]) => { - const parentName = utils.ruleParent(name) as Name - if (parentName && engine.getRule(parentName).rawNode.API) { - missings[parentName] = (missings[parentName] ?? 0) + value - - return missings - } - missings[name] = value - - return missings - }, - {} as Partial> - ) -} -const mergeMissing = ( - left: Record | undefined = {}, - right: Record | undefined = {} -): Record => - Object.fromEntries( - [...Object.keys(left), ...Object.keys(right)].map((key) => [ - key, - (left[key] ?? 0) + (right[key] ?? 0), - ]) - ) diff --git a/site/source/store/store.ts b/site/source/store/store.ts index 4246c009e..c1986ab47 100644 --- a/site/source/store/store.ts +++ b/site/source/store/store.ts @@ -1,18 +1,19 @@ import { composeWithDevToolsDevelopmentOnly } from '@redux-devtools/extension' import { createReduxEnhancer } from '@sentry/react' +import Engine from 'publicodes' import { applyMiddleware, createStore, StoreEnhancer } from 'redux' -import reducers from '@/store/reducers/rootReducer' - import { retrievePersistedChoixStatutJuridique, setupChoixStatutJuridiquePersistence, -} from '../storage/persistChoixStatutJuridique' +} from '@/storage/persistChoixStatutJuridique' import { retrievePersistedCompanySituation, setupCompanySituationPersistence, -} from '../storage/persistCompanySituation' -import { setupSimulationPersistence } from '../storage/persistSimulation' +} from '@/storage/persistCompanySituation' +import { setupSimulationPersistence } from '@/storage/persistSimulation' +import { prendLaProchaineQuestionMiddleware } from '@/store/middlewares/prendLaProchaineQuestion.middleware' +import reducers from '@/store/reducers/rootReducer' const initialStore = { choixStatutJuridique: retrievePersistedChoixStatutJuridique(), @@ -25,10 +26,17 @@ const composeEnhancers = composeWithDevToolsDevelopmentOnly( const sentryReduxEnhancer = createReduxEnhancer({}) as StoreEnhancer -const storeEnhancer = composeEnhancers(applyMiddleware(), sentryReduxEnhancer) +export const makeStore = (engine: Engine) => { + const storeEnhancer = composeEnhancers( + applyMiddleware(prendLaProchaineQuestionMiddleware(engine)), + sentryReduxEnhancer + ) -export const store = createStore(reducers, initialStore, storeEnhancer) + const store = createStore(reducers, initialStore, storeEnhancer) -setupChoixStatutJuridiquePersistence(store) -setupCompanySituationPersistence(store) -setupSimulationPersistence(store) + setupChoixStatutJuridiquePersistence(store) + setupCompanySituationPersistence(store) + setupSimulationPersistence(store) + + return store +} diff --git a/site/source/utils/complement.ts b/site/source/utils/complement.ts new file mode 100644 index 000000000..7e6319cc4 --- /dev/null +++ b/site/source/utils/complement.ts @@ -0,0 +1,6 @@ +type Not = B extends true ? false : true + +export const complement = + (f: (...args: A) => B) => + (...args: A) => + !f(...args) as Not diff --git a/site/source/utils/index.ts b/site/source/utils/index.ts index b6f552df9..09d45523a 100644 --- a/site/source/utils/index.ts +++ b/site/source/utils/index.ts @@ -120,6 +120,17 @@ export function omit( return ret } +/** + * Filters out elements from the given array that satisfy the provided predicate. + * + * @param {Array} array - The array to filter. + * @param {Function} predicate - The function used to test each element. + * @returns {Array} - An array containing the elements that do not satisfy the predicate. + */ +export function reject(array: T[], predicate: (value: T) => boolean): T[] { + return array.filter((value) => !predicate(value)) +} + /** * Transforms an object into entries which is then passed to the transform function to be * modified as desired with map, filter, etc., then transformed back into an object diff --git a/site/test/conversation.test.ts b/site/test/conversation.test.ts deleted file mode 100644 index 323f27cf0..000000000 --- a/site/test/conversation.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import rules from 'modele-social' -import Engine from 'publicodes' -import { describe, expect, it } from 'vitest' - -import { getNextQuestions } from '../source/hooks/useNextQuestion' - -describe('conversation', function () { - it('should start with the first missing variable', function () { - const missingVariables = new Engine({ - // TODO - this won't work without the indirection, figure out why - top: 'oui', - 'top . startHere': { formule: { somme: ['a', 'b'] } }, - 'top . a': { question: '?', titre: 'a', unité: '€' }, - 'top . b': { question: '?', titre: 'b', unité: '€' }, - }).evaluate('top . startHere').missingVariables - expect(getNextQuestions(missingVariables)[0]).toBe('top . a') - }) - it('should first ask for questions without defaults, then those with defaults', function () { - const engine = new Engine({ - net: { formule: 'brut - cotisation' }, - brut: { - question: 'Quel est le salaire brut ?', - unité: '€/an', - }, - cotisation: { - formule: { - produit: [ - 'brut', - { - variations: [ - { - si: 'cadre', - alors: '77%', - }, - { - sinon: '80%', - }, - ], - }, - ], - }, - }, - cadre: { - question: 'Est-ce un cadre ?', - }, - }) - - expect(getNextQuestions(engine.evaluate('net').missingVariables)[0]).toBe( - 'brut' - ) - - engine.setSituation({ - brut: 2300, - }) - - expect(getNextQuestions(engine.evaluate('net').missingVariables)[0]).toBe( - 'cadre' - ) - }) - - it('should ask "motif CDD" if "CDD" applies', function () { - const result = Object.keys( - new Engine(rules) - .setSituation({ - salarié: 'oui', - 'salarié . contrat . CDD': 'oui', - 'salarié . contrat . salaire brut': '2300', - }) - .evaluate('salarié . rémunération . net . à payer avant impôt') - .missingVariables - ) - - expect(result).toContain('salarié . contrat . CDD . motif') - }) -}) diff --git a/site/test/persistence.test.ts b/site/test/persistence.test.ts index 69f00b55f..e25f6cc57 100644 --- a/site/test/persistence.test.ts +++ b/site/test/persistence.test.ts @@ -6,14 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { setupSimulationPersistence } from '@/storage/persistSimulation' import * as safeLocalStorage from '@/storage/safeLocalStorage' import { + enregistreLaRéponse, loadPreviousSimulation, setSimulationConfig, - updateSituation, } from '@/store/actions/actions' -import reducers, { - Simulation, - SimulationConfig, -} from '@/store/reducers/rootReducer' +import reducers, { SimulationConfig } from '@/store/reducers/rootReducer' +import { Simulation } from '@/store/reducers/simulation.reducer' function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -31,9 +29,8 @@ const initialSimulation: Simulation = { hiddenNotifications: [], situation: {}, targetUnit: '€/mois', - foldedSteps: ['somestep' as DottedName], - unfoldedStep: null, - shouldFocusField: false, + answeredQuestions: ['somestep' as DottedName], + currentQuestion: null, } describe('[persistence] When simulation persistence is setup', () => { @@ -51,7 +48,7 @@ describe('[persistence] When simulation persistence is setup', () => { describe('when the state is changed with some data that is persistable', () => { beforeEach(async () => { - store.dispatch(updateSituation('dotted name' as DottedName, '42')) + store.dispatch(enregistreLaRéponse('dotted name' as DottedName, '42')) await delay(0) }) it('saves state in localStorage with all fields', () => { diff --git a/site/test/regressions/utils.ts b/site/test/regressions/utils.ts index cc83a2dec..ecece2192 100644 --- a/site/test/regressions/utils.ts +++ b/site/test/regressions/utils.ts @@ -9,7 +9,7 @@ import { EvaluatedNode, Evaluation } from 'publicodes' import { expect } from 'vitest' import { engineFactory } from '@/components/utils/EngineContext' -import { Simulation } from '@/store/reducers/rootReducer' +import { Simulation } from '@/store/reducers/simulation.reducer' type SituationsSpecs = Record diff --git a/yarn.lock b/yarn.lock index c3ad9f4ac..7a70e1deb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12234,6 +12234,13 @@ __metadata: languageName: node linkType: hard +"@types/deep-eql@npm:^4.0.2": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 249a27b0bb22f6aa28461db56afa21ec044fa0e303221a62dff81831b20c8530502175f1a49060f7099e7be06181078548ac47c668de79ff9880241968d43d0c + languageName: node + linkType: hard + "@types/detect-port@npm:^1.3.0": version: 1.3.2 resolution: "@types/detect-port@npm:1.3.2" @@ -16998,6 +17005,13 @@ __metadata: languageName: node linkType: hard +"deep-eql@npm:^5.0.1": + version: 5.0.1 + resolution: "deep-eql@npm:5.0.1" + checksum: 8009e8a8bf3e0f591a122e7788e304a2bed1299b7774f039be96f9ef35c00fb254292fb1568952651aea0c1d1eb23d0bca484bbdd2cf4fcee685c6f2c43670f3 + languageName: node + linkType: hard + "deep-equal@npm:^2.0.5": version: 2.2.0 resolution: "deep-equal@npm:2.2.0" @@ -17575,6 +17589,13 @@ __metadata: languageName: node linkType: hard +"effect@npm:^3.0.0": + version: 3.0.1 + resolution: "effect@npm:3.0.1" + checksum: e2284b2789fc73095653ff1a44628cf57a4dd11de61ed458c16807f83132caa91abf3a6f8238a6021efdbd3c8805d696c859d5daecbe7da092fab0d58aaa6ab8 + languageName: node + linkType: hard + "ejs@npm:^3.1.6": version: 3.1.8 resolution: "ejs@npm:3.1.8" @@ -29212,6 +29233,7 @@ __metadata: "@storybook/testing-library": ^0.1.0 "@testing-library/jest-dom": ^6.4.2 "@testing-library/react": ^14.2.1 + "@types/deep-eql": ^4.0.2 "@types/history": ^5.0.0 "@types/react": ^18.2.22 "@types/react-dom": ^18.2.7 @@ -29227,7 +29249,9 @@ __metadata: cypress-plugin-tab: ^1.0.5 cypress-wait-until: ^1.7.2 date-fns: ^3.6.0 + deep-eql: ^5.0.1 dotenv: ^16.3.1 + effect: ^3.0.0 eslint-plugin-testing-library: ^6.2.0 exoneration-covid: "workspace:^" focus-trap-react: ^10.2.1