refactor: rassemble la gestion des questions dans Redux

pull/3115/head
Jalil Arfaoui 2024-03-07 15:45:36 +01:00
parent e767348204
commit 54d49accd1
85 changed files with 1345 additions and 991 deletions

View File

@ -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",

View File

@ -63,7 +63,7 @@ export default function Root({
return (
<StrictMode>
<EngineProvider value={engine}>
<Provider basename={basename}>
<Provider engine={engine} basename={basename}>
<Redirections>
<ErrorBoundary fallback={CatchOffline}>
<Router />

View File

@ -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,

View File

@ -43,14 +43,10 @@ function getNotifications(engine: Engine) {
}))
}
export default function Notifications({
engines,
}: {
engines?: Array<Engine<DottedName>>
}) {
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
)

View File

@ -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<SiteName | null>(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 (
<EmbededContextProvider>
<DarkModeProvider>

View File

@ -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() {
<StyledLink
key={dottedName}
$underline={dottedName === currentQuestion}
onPress={() => dispatch(goToQuestion(dottedName))}
onPress={() => dispatch(vaÀLaQuestion(dottedName))}
aria-label={t('{{question}}, aller à la question : {{question}}', {
question: label,
})}

View File

@ -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 = () => {
<span key={year}>
<DesignSystemLink
onPress={() =>
dispatch(updateSituation('date', `01/01/${year}`))
dispatch(enregistreLaRéponse('date', `01/01/${year}`))
}
>
{actualYear === 2024 ? (

View File

@ -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()

View File

@ -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<Engine<DottedName>>
}) {
const { numberCurrentStep, numberSteps } = useSimulationProgress()
@ -47,7 +43,7 @@ export function Questions({
</Notice>
)}
</div>
<Conversation customEndMessages={customEndMessages} engines={engines} />
<Conversation customEndMessages={customEndMessages} />
</QuestionsContainer>
</>
)

View File

@ -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]

View File

@ -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<Engine<DottedName>>
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({
<div className="print-hidden">
<FromTop>{results}</FromTop>
</div>
<Questions
engines={engines}
customEndMessages={customEndMessages}
/>
<Questions customEndMessages={customEndMessages} />
</>
)}
<Spacing md />

View File

@ -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]
)

View File

@ -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<Engine<DottedName>>
}
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<HTMLInputElement | HTMLButtonElement | HTMLLinkElement>(
'input, button, a'
)
?.focus()
}, 5)
}, [])
const goToPrevious = useCallback(() => {
goToPreviousQuestion()
focusFirstElemInForm()
}, [focusFirstElemInForm, goToPreviousQuestion])
const goToNext = useCallback(() => {
goToNextQuestion()
focusFirstElemInForm()
}, [focusFirstElemInForm, goToNextQuestion])
const formRef = React.useRef<HTMLFormElement>(null)
const isDateQuestion =
currentQuestion && engine.getRule(currentQuestion).rawNode.type === 'date'
return (
<>
<div className="print-only">
@ -104,142 +37,17 @@ export default function Conversation({
</div>
<div className="print-hidden">
{currentQuestion ? (
<FromTop>
{Object.keys(situation).length !== 0 && (
<TrackPage name="simulation commencée" />
)}
<form
onSubmit={(e) => {
e.preventDefault()
goToNext()
}}
ref={formRef}
>
<div
style={{
display: 'inline-flex',
alignItems: 'baseline',
}}
>
<H3 id="questionHeader" as="h2">
{evaluateQuestion(engine, engine.getRule(currentQuestion))}
<ExplicableRule light dottedName={currentQuestion} />
</H3>
</div>
<fieldset>
<legend className="sr-only">
{t(
'Répondez à quelques questions additionnelles afin de préciser votre résultat.'
)}
</legend>
<RuleInput
dottedName={currentQuestion}
onChange={onChange}
key={currentQuestion}
onSubmit={goToNext}
aria-labelledby="questionHeader"
hideDefaultValue={isDateQuestion}
/>
</fieldset>
<Grid container spacing={2}>
{previousAnswers.length > 0 && (
<Grid item xs={6} sm="auto">
<Button
color="primary"
light
onPress={goToPrevious}
size="XS"
>
<span aria-hidden></span> <Trans>Précédent</Trans>
</Button>
</Grid>
)}
<Grid item xs={6} sm="auto">
<Button
size="XS"
onPress={goToNext}
light={!currentQuestionIsAnswered ? true : undefined}
aria-label={
currentQuestionIsAnswered
? t('Suivant, passer à la question suivante')
: t('Passer, passer la question sans répondre')
}
>
{currentQuestionIsAnswered ? (
<Trans>Suivant</Trans>
) : (
<Trans>Passer</Trans>
)}{' '}
<span aria-hidden></span>
</Button>
</Grid>
<Grid
item
xs={12}
sm
style={{
justifyContent: 'flex-end',
display: 'flex',
}}
>
<SeeAnswersButton>
{customSituationVisualisation}
</SeeAnswersButton>
</Grid>
</Grid>
<Notifications engines={engines} />
</form>
<QuickLinks />
</FromTop>
<QuestionEnCours
previousAnswers={previousAnswers}
customSituationVisualisation={customSituationVisualisation}
/>
) : (
<>
<div style={{ textAlign: 'center' }}>
{firstRenderDone && <TrackPage name="simulation terminée" />}
<H3 as="h2">
<Emoji emoji="🌟" />{' '}
<Trans i18nKey="simulation-end.title">
Vous avez complété cette simulation
</Trans>
</H3>
<Body>
{customEndMessages || (
<Trans i18nKey="simulation-end.text">
Vous avez maintenant accès à l'estimation la plus précise
possible.
</Trans>
)}
</Body>
{currentSimulatorData?.pathId === 'simulateurs.salarié' && (
<>
<JeDonneMonAvis />
<Spacing md />
</>
)}
<Grid container spacing={2}>
{previousAnswers.length > 0 && (
<Grid item xs={6} sm="auto">
<Button light onPress={goToPrevious} size="XS">
<span aria-hidden></span> <Trans>Précédent</Trans>
</Button>
</Grid>
)}
<Grid
item
xs={6}
sm
style={{
justifyContent: 'flex-end',
display: 'flex',
}}
>
<SeeAnswersButton>
{customSituationVisualisation}
</SeeAnswersButton>
</Grid>
</Grid>
</div>
<Notifications engines={engines} />
</>
<VousAvezComplétéCetteSimulation
firstRenderDone={firstRenderDone}
customEndMessages={customEndMessages}
previousAnswers={previousAnswers}
customSituationVisualisation={customSituationVisualisation}
/>
)}
</div>
</>

View File

@ -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<HTMLFormElement>(null)
const focusFirstElemInForm = useCallback(() => {
setTimeout(() => {
formRef.current
?.querySelector<HTMLInputElement | HTMLButtonElement | HTMLLinkElement>(
'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 (
<FromTop>
{Object.keys(situation).length !== 0 && (
<TrackPage name="simulation commencée" />
)}
<form
onSubmit={(e) => {
e.preventDefault()
handleGoToNext()
}}
ref={formRef}
>
<div
style={{
display: 'inline-flex',
alignItems: 'baseline',
}}
>
<H3 id="questionHeader" as="h2">
{evaluateQuestion(engine, engine.getRule(currentQuestion))}
<ExplicableRule light dottedName={currentQuestion} />
</H3>
</div>
<fieldset>
<legend className="sr-only">
{t(
'Répondez à quelques questions additionnelles afin de préciser votre résultat.'
)}
</legend>
<RuleInput
dottedName={currentQuestion}
onChange={onChange}
key={currentQuestion}
onSubmit={handleGoToNext}
aria-labelledby="questionHeader"
hideDefaultValue={isDateQuestion}
/>
</fieldset>
<Grid container spacing={2}>
{previousAnswers.length > 0 && (
<Grid item xs={6} sm="auto">
<Button
color="primary"
light
onPress={handleGoToPrevious}
size="XS"
>
<span aria-hidden></span> <Trans>Précédent</Trans>
</Button>
</Grid>
)}
<Grid item xs={6} sm="auto">
<Button
size="XS"
onPress={handleGoToNext}
light={!currentQuestionIsAnswered ? true : undefined}
aria-label={
currentQuestionIsAnswered
? t('Suivant, passer à la question suivante')
: t('Passer, passer la question sans répondre')
}
>
{currentQuestionIsAnswered ? (
<Trans>Suivant</Trans>
) : (
<Trans>Passer</Trans>
)}{' '}
<span aria-hidden></span>
</Button>
</Grid>
<Grid
item
xs={12}
sm
style={{
justifyContent: 'flex-end',
display: 'flex',
}}
>
<SeeAnswersButton>{customSituationVisualisation}</SeeAnswersButton>
</Grid>
</Grid>
<Notifications />
</form>
<QuickLinks />
</FromTop>
)
}

View File

@ -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 (
<>
<div style={{ textAlign: 'center' }}>
{firstRenderDone && <TrackPage name="simulation terminée" />}
<H3 as="h2">
<Emoji emoji="🌟" />{' '}
<Trans i18nKey="simulation-end.title">
Vous avez complété cette simulation
</Trans>
</H3>
<Body>
{customEndMessages || (
<Trans i18nKey="simulation-end.text">
Vous avez maintenant accès à l'estimation la plus précise
possible.
</Trans>
)}
</Body>
{currentSimulatorData?.pathId === 'simulateurs.salarié' && (
<>
<JeDonneMonAvis />
<Spacing md />
</>
)}
<Grid container spacing={2}>
{previousAnswers.length > 0 && (
<Grid item xs={6} sm="auto">
<Button light onPress={goToPrevious} size="XS">
<span aria-hidden></span> <Trans>Précédent</Trans>
</Button>
</Grid>
)}
<Grid
item
xs={6}
sm
style={{
justifyContent: 'flex-end',
display: 'flex',
}}
>
<SeeAnswersButton>{customSituationVisualisation}</SeeAnswersButton>
</Grid>
</Grid>
</div>
<Notifications />
</>
)
}

View File

@ -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<Engine<DottedName>>) {
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,

View File

@ -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<DottedName>
}
export const useRawSituation = () => {
const simulatorSituation = useSelector(situationSelector)
const configSituation = useSelector(configSituationSelector)
const companySituation = useSelector(companySituationSelector)
const situation: Partial<Record<DottedName, PublicodesExpression>> = 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<DottedName>) => {
}
}
export function useInversionFail(engines?: Array<Engine<DottedName>>) {
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 =

View File

@ -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'",
}

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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<Contexte>
}
export const isComparateurConfig = (
config: SimulationConfig
): config is ComparateurConfig => 'contextes' in config

View File

@ -0,0 +1,3 @@
import { Situation } from '@/domaine/Situation'
export type Contexte = Situation

View File

@ -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
}>

View File

@ -0,0 +1,6 @@
import { DottedName } from 'modele-social'
import { ASTNode, PublicodesExpression } from 'publicodes'
export type Situation = Partial<
Record<DottedName, PublicodesExpression | ASTNode>
>

View File

@ -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<DottedName> = []
): Array<DottedName> => {
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))
}

View File

@ -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<Record<DottedName, number>> | undefined
export const listeLesVariablesManquantes = (
engine: Engine,
objectifs: ReadonlyArray<DottedName>,
contextes?: NonEmptyReadonlyArray<Situation> | 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<string, number> | undefined = {},
right: Record<string, number> | undefined = {}
): Record<string, number> =>
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 =
<Name extends string>(engine: Engine<Name>) =>
(
missingVariables: Partial<Record<Name, number>>
): Partial<Record<Name, number>> =>
(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<Record<Name, number>>
)

View File

@ -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<SimulationConfig>,
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 }
}

View File

@ -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<Record<DottedName, number>>
// 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<SimulationConfig['questions']> = {},
answeredQuestions: Array<DottedName> = []
): Array<DottedName> {
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<Engine<DottedName>>
): Array<DottedName> {
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<DottedName> =>
useSelector(questionsSuivantesSelector) || []

View File

@ -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]

View File

@ -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
}

View File

@ -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<SimulationConfig>
config?: SimulationConfig
autoloadLastSimulation?: boolean
}) {
const dispatch = useDispatch()

View File

@ -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,
}
}

View File

@ -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 = () => {

View File

@ -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<StatutType> {
}
}
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' }
: {}),
}
}

View File

@ -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}'`
)

View File

@ -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]
)

View File

@ -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]
)

View File

@ -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<DottedName, PublicodesExpression | ASTNode>
>
/**
* 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.
*

View File

@ -1,4 +1,4 @@
import { SimulationConfig } from '../_configs/types'
import { SimulationConfig } from '@/domaine/SimulationConfig'
export const configArtisteAuteur: SimulationConfig = {
objectifs: [

View File

@ -1,4 +1,4 @@
import { SimulationConfig } from '../_configs/types'
import { SimulationConfig } from '@/domaine/SimulationConfig'
export const configAutoEntrepreneur: SimulationConfig = {
'objectifs exclusifs': [

View File

@ -1,4 +1,4 @@
import { SimulationConfig } from '../_configs/types'
import { SimulationConfig } from '@/domaine/SimulationConfig'
export const configChômagePartiel: SimulationConfig = {
objectifs: [

View File

@ -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 = {

View File

@ -0,0 +1,5 @@
import { NamedEngine } from '@/pages/simulateurs/comparaison-statuts/NamedEngine'
export type EngineComparison =
| [NamedEngine, NamedEngine, NamedEngine]
| [NamedEngine, NamedEngine]

View File

@ -0,0 +1,9 @@
import { DottedName } from 'modele-social'
import Engine from 'publicodes'
import { StatutType } from '@/components/StatutTag'
export type NamedEngine = {
engine: Engine<DottedName>
name: StatutType
}

View File

@ -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<DottedName>
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<DottedName>,
Engine<DottedName>,
Engine<DottedName>,
]
const { absoluteSitePaths } = useSitePaths()
return (
<>
<Simulation
engines={engines}
hideDetails
showQuestionsFromBeginning
fullWidth

View File

@ -12,9 +12,9 @@ import { HelpIcon } from '@/design-system/icons'
import { Grid } from '@/design-system/layout'
import { Strong } from '@/design-system/typography'
import { Body } from '@/design-system/typography/paragraphs'
import { EngineComparison } from '@/pages/simulateurs/comparaison-statuts/EngineComparison'
import { getBestOption, OptionType } from '../utils'
import { EngineComparison } from './Comparateur'
import StatusCard from './StatusCard'
export const getGridSizes = (numberOptions: number, total: number) => {

View File

@ -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'

View File

@ -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<boolean | null>(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) {

View File

@ -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,

View File

@ -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'

View File

@ -1,40 +0,0 @@
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useState,
} from 'react'
type CasParticuliersType = {
isAutoEntrepreneurACREEnabled: boolean
setIsAutoEntrepreneurACREEnabled: Dispatch<SetStateAction<boolean>>
}
const CasParticuliersContext = createContext<CasParticuliersType>({
isAutoEntrepreneurACREEnabled: false,
setIsAutoEntrepreneurACREEnabled: () => null,
})
export const CasParticuliersProvider = ({
children,
}: {
children: ReactNode
}) => {
const [isAutoEntrepreneurACREEnabled, setIsAutoEntrepreneurACREEnabled] =
useState(false)
return (
<CasParticuliersContext.Provider
value={{
isAutoEntrepreneurACREEnabled,
setIsAutoEntrepreneurACREEnabled,
}}
>
{children}
</CasParticuliersContext.Provider>
)
}
export const useCasParticuliers = () => useContext(CasParticuliersContext)

View File

@ -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 (
<CasParticuliersProvider>
<ComparateurStatutsUI />
</CasParticuliersProvider>
)
return <ComparateurStatutsUI />
}

View File

@ -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',

View File

@ -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")}
>

View File

@ -1,4 +1,4 @@
import { SimulationConfig } from '../_configs/types'
import { SimulationConfig } from '@/domaine/SimulationConfig'
export const configDividendes: SimulationConfig = {
'objectifs exclusifs': [

View File

@ -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() {
<RuleInput
dottedName={'entreprise . exercice . début'}
onChange={(x) =>
dispatch(updateSituation('entreprise . exercice . début', x))
dispatch(enregistreLaRéponse('entreprise . exercice . début', x))
}
/>{' '}
<RuleInput
dottedName={'entreprise . exercice . fin'}
onChange={(x) =>
dispatch(updateSituation('entreprise . exercice . fin', x))
dispatch(enregistreLaRéponse('entreprise . exercice . fin', x))
}
/>
</ExerciceDateContainer>

View File

@ -1,4 +1,4 @@
import { SimulationConfig } from '../_configs/types'
import { SimulationConfig } from '@/domaine/SimulationConfig'
const ISSimulationConfig: SimulationConfig = {
'unité par défaut': '€/an',

View File

@ -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)
)
}}
/>

View File

@ -1,4 +1,4 @@
import { SimulationConfig } from '../_configs/types'
import { SimulationConfig } from '@/domaine/SimulationConfig'
export const configIndépendant: SimulationConfig = {
'objectifs exclusifs': [

View File

@ -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 = {

View File

@ -1,4 +1,4 @@
import { SimulationConfig } from '../_configs/types'
import { SimulationConfig } from '@/domaine/SimulationConfig'
export const configSalarié: SimulationConfig = {
'objectifs exclusifs': [

View File

@ -1,4 +1,4 @@
import { SimulationConfig } from '../_configs/types'
import { SimulationConfig } from '@/domaine/SimulationConfig'
export const configSASU: SimulationConfig = {
'objectifs exclusifs': [

View File

@ -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<DottedName>) =>
({
type: 'STEP_ACTION',
name: 'fold',
step,
type: 'QUESTIONS_SUIVANTES',
questionsSuivantes,
}) as const
export const setSimulationConfig = (
config: ImmutableType<SimulationConfig>,
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<string, PublicodesExpression> }
) => {
if (value && typeof value === 'object' && 'batchUpdate' in value) {
return batchUpdateSituation(
buildSituationFromObject(
dottedName,
value.batchUpdate as Record<string, PublicodesExpression>
)
)
}
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<string, PublicodesExpression>
) => batchUpdateSituation(buildSituationFromObject(dottedName, value))

View File

@ -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<object, RootState, Dispatch<Action>> =>
(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))

View File

@ -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,

View File

@ -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,
}

View File

@ -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<SimulationConfig>
url: string
hiddenNotifications: Array<string>
situation: Situation
targetUnit: string
foldedSteps: Array<DottedName>
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<PreviousSimulation | null>,
activeTargetInput,

View File

@ -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 na 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 lhistorique', () => {
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 lhistorique 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 na 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'],
})
})
})
})

View File

@ -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<string>
situation: Situation
targetUnit: string
answeredQuestions: Array<DottedName>
questionsSuivantes?: Array<DottedName>
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
}

View File

@ -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'"
)

View File

@ -0,0 +1,8 @@
import { createSelector } from 'reselect'
import { simulationSelector } from '@/store/selectors/simulation.selector'
export const configSelector = createSelector(
[simulationSelector],
(simulation) => simulation?.config ?? {}
)

View File

@ -0,0 +1,8 @@
import { createSelector } from 'reselect'
import { simulationSelector } from '@/store/selectors/simulation.selector'
export const currentQuestionSelector = createSelector(
[simulationSelector],
(simulation) => simulation?.currentQuestion ?? null
)

View File

@ -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,
}
}

View File

@ -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)
)

View File

@ -0,0 +1,8 @@
import { createSelector } from 'reselect'
import { simulationSelector } from '@/store/selectors/simulation.selector'
export const questionsSuivantesSelector = createSelector(
[simulationSelector],
(simulation) => simulation?.questionsSuivantes || []
)

View File

@ -0,0 +1,3 @@
import { RootState } from '@/store/reducers/rootReducer'
export const simulationSelector = (state: RootState) => state.simulation

View File

@ -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<Engine<DottedName>>
}): Partial<Record<DottedName, number>> => {
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<Situation>
)
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<Name extends string>(
missingVariables: Partial<Record<Name, number>>,
engine: Engine<Name>
): Partial<Record<Name, number>> {
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<Record<Name, number>>
)
}
const mergeMissing = (
left: Record<string, number> | undefined = {},
right: Record<string, number> | undefined = {}
): Record<string, number> =>
Object.fromEntries(
[...Object.keys(left), ...Object.keys(right)].map((key) => [
key,
(left[key] ?? 0) + (right[key] ?? 0),
])
)

View File

@ -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
}

View File

@ -0,0 +1,6 @@
type Not<B extends boolean> = B extends true ? false : true
export const complement =
<A extends unknown[], B extends boolean>(f: (...args: A) => B) =>
(...args: A) =>
!f(...args) as Not<B>

View File

@ -120,6 +120,17 @@ export function omit<T extends object, K extends keyof T>(
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<T>(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

View File

@ -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')
})
})

View File

@ -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', () => {

View File

@ -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<string, Simulation['situation'][]>

View File

@ -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