Partage de la situation via URL (#1241)
* ✨ Simplifie la lecture de l’action SET_SIMULATION - “return early” * ✨ Make automatic translation more fail-safe * 🎨 Fix visuals for Overlay component * ✨ Make Banner component more versatile * Share simulation banner * Ajout des identifiants courts pour les objectifs * Dé/sérialisation search params <-> situation & targetUnit, basée sur une logique générique (typeof) * Suppression dans l'URL des search params correspondant à des noms de règles ou identifiant courts * Banner de partage, avec modale ou Navigator.share si disponible. Co-authored-by: Alexandre Hajjar <alexandre.hajjar@gmail.com> * URL with state: remove targetUnit * serializeEvaluation for url sharing * serializeEvaluation for number, boolean, string * use this serialization in url search params * for now, no support for Objects (like localisation) Co-authored-by: Johan Girod <dev@johangirod.com> * 🖋️ Quelques légères modifications de nom pour les identifiants courts Co-authored-by: Paul Chavard <github@paul.chavard.net> Co-authored-by: Johan Girod <dev@johangirod.com> close #552pull/1230/head
parent
1ae2529a7a
commit
40fbb99026
|
@ -126,6 +126,7 @@ dirigeant . auto-entrepreneur . plafond:
|
|||
|
||||
dirigeant . auto-entrepreneur . net de cotisations:
|
||||
titre: Revenu net de cotisations
|
||||
identifiant court: auto-entrepreneur-net
|
||||
résumé: Avant impôt
|
||||
question: Quel revenu avant impôt voulez-vous toucher ?
|
||||
description: Il s'agit du revenu net de cotisations et de charges, avant le paiement de l'impôt sur le revenu.
|
||||
|
@ -383,6 +384,7 @@ dirigeant . auto-entrepreneur . impôt . revenu abattu:
|
|||
|
||||
dirigeant . auto-entrepreneur . net après impôt:
|
||||
titre: revenu net après impôt
|
||||
identifiant court: auto-entrepreneur-net-apres-impot
|
||||
résumé: Avant déduction des dépenses liées à l'activité
|
||||
unité: €/an
|
||||
question: Quel est le revenu net après impôt souhaité ?
|
||||
|
@ -413,6 +415,7 @@ dirigeant . rémunération totale:
|
|||
question: Quel montant pensez-vous dégager pour votre rémunération ?
|
||||
résumé: Dépensé par l'entreprise
|
||||
unité: €/an
|
||||
identifiant court: dirigeant-total
|
||||
description: C'est ce que l'entreprise dépense en tout pour la rémunération du dirigeant.
|
||||
Cette rémunération "super-brute" inclut toutes les cotisations sociales à payer.
|
||||
|
||||
|
@ -547,6 +550,7 @@ dirigeant . indépendant . cotisations et contributions . aide indépendant covi
|
|||
Sécu-indépendant: https://www.secu-independants.fr/cpsti/actualites/actualites-nationales/covid-dispositifs-de-reduction-des-cotisations/
|
||||
|
||||
dirigeant . indépendant . revenu net de cotisations:
|
||||
identifiant court: independant-net
|
||||
synonymes:
|
||||
- résultat comptable
|
||||
formule:
|
||||
|
|
|
@ -57,8 +57,10 @@ entreprise . chiffre d'affaires:
|
|||
résumé: Montant total des recettes brutes (hors taxe)
|
||||
unité: €/an
|
||||
formule: dirigeant . rémunération totale + charges
|
||||
identifiant court: ca
|
||||
|
||||
entreprise . chiffre d'affaires minimum:
|
||||
identifiant court: entreprise-ca-min
|
||||
description: Le montant minimum des ventes (H.T) à réaliser pour atteindre le seuil de rentabilité.
|
||||
question: Quel est votre chiffre d'affaires minimum envisagé ?
|
||||
unité: €/an
|
||||
|
@ -138,6 +140,7 @@ entreprise . charges:
|
|||
- charges d'exploitation
|
||||
- charges de fonctionnement
|
||||
titre: charges de fonctionnement
|
||||
identifiant court: charges
|
||||
résumé: Toutes les dépenses nécessaires à l'entreprise
|
||||
question: Quelles sont les charges de l'entreprise ?
|
||||
description: |
|
||||
|
@ -204,10 +207,10 @@ entreprise . ACRE:
|
|||
par défaut: ACRE par défaut
|
||||
note: Les auto-entreprises crées entre le 1er janvier et le 31 décembre 2019 bénéficient d'un dispositif plus favorable, actif pendant 3 années.
|
||||
|
||||
entreprise . ACRE par défaut:
|
||||
entreprise . ACRE par défaut:
|
||||
formule:
|
||||
variations:
|
||||
- si:
|
||||
- si:
|
||||
toutes ces conditions:
|
||||
- dirigeant . auto-entrepreneur
|
||||
- une de ces conditions:
|
||||
|
|
|
@ -274,6 +274,7 @@ revenu net après impôt:
|
|||
unité: €/an
|
||||
résumé: Disponible sur votre compte en banque
|
||||
question: Quel revenu voulez-vous toucher ?
|
||||
identifiant court: net-apres-impot
|
||||
description: |
|
||||
Il s'agit du revenu net de charges, cotisations et d'impôts.
|
||||
Autrement dit, c'est ce que vous gagnez à la fin sur votre compte en banque.
|
||||
|
|
|
@ -1200,6 +1200,7 @@ contrat salarié . cotisations . assiette minimale:
|
|||
|
||||
contrat salarié . rémunération . brut de base:
|
||||
titre: Salaire brut
|
||||
identifiant court: salaire-brut
|
||||
résumé: Brut de référence (sans les primes, indemnités ni majorations)
|
||||
type: salaire
|
||||
question: Quel est votre salaire brut ?
|
||||
|
@ -1610,8 +1611,8 @@ contrat salarié . plafond sécurité sociale . renonciation proratisation:
|
|||
D'un commun accord, l'employeur et l'employé peuvent renoncer à la réduction
|
||||
du plafond de la sécurité sociale (applicable pour les salariés à temps
|
||||
partiel), notamment afin d'augmenter le montant des cotisations vieillesse.
|
||||
formule: non
|
||||
# TODO : ajouter une question non prioritaire
|
||||
# TODO : Réactiver la règle (peut être ajouter des références et la déplacer dans l'espace de nom temps de travail)
|
||||
valeur: non
|
||||
applicable si: temps de travail . quotité de travail < 100%
|
||||
remplace:
|
||||
- règle: plafond sécurité sociale
|
||||
|
@ -1755,6 +1756,7 @@ contrat salarié . prime d'impatriation:
|
|||
|
||||
contrat salarié . rémunération . net:
|
||||
titre: Salaire net
|
||||
identifiant court: salaire-net
|
||||
unité: €/mois
|
||||
type: salaire
|
||||
question: Quel est votre salaire net ?
|
||||
|
@ -1779,6 +1781,7 @@ contrat salarié . rémunération . net:
|
|||
|
||||
contrat salarié . rémunération . net après impôt:
|
||||
titre: Salaire net après impôt
|
||||
identifiant court: salaire-net-apres-impot
|
||||
résumé: Versé sur le compte bancaire
|
||||
question: Quel est le revenu net du salarié après impôt ?
|
||||
type: salaire
|
||||
|
@ -1798,6 +1801,7 @@ contrat salarié . rémunération . net après impôt:
|
|||
|
||||
contrat salarié . prix du travail:
|
||||
titre: Coût total
|
||||
identifiant court: cout-embauche
|
||||
résumé: Dépensé par l'entreprise
|
||||
question: Quel est le coût total de cette embauche ?
|
||||
description: |
|
||||
|
|
|
@ -100,6 +100,40 @@ describe('Simulateur auto-entrepreneur', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Simulateur salarié mode partagé', () => {
|
||||
const brutInputSelector =
|
||||
'input.currencyInput__input[name="contrat salarié . rémunération . brut de base"]'
|
||||
const simulatorUrl = '/simulateurs/salaire-brut-net'
|
||||
const searchParams = new URLSearchParams({
|
||||
'contrat salarié': "'CDD'",
|
||||
'salaire-brut': '1539€/mois',
|
||||
})
|
||||
|
||||
const urlWithState = `${simulatorUrl}?${searchParams.toString()}`
|
||||
if (!fr) {
|
||||
return
|
||||
}
|
||||
it('should set input value from URL', function () {
|
||||
cy.visit(urlWithState)
|
||||
cy.wait(800)
|
||||
cy.get(brutInputSelector).first().invoke('val').should('be', '1 539')
|
||||
|
||||
cy.get('button.ui__.small.simple.button').first().click()
|
||||
cy.get('span.answerContent').first().contains('CDD')
|
||||
})
|
||||
it('should set URL from input value', function () {
|
||||
cy.visit(simulatorUrl)
|
||||
cy.get(brutInputSelector).first().type('{selectall}1539')
|
||||
cy.wait(1000)
|
||||
cy.get('.step').find('input[value="\'CDD\'"]').click({ force: true })
|
||||
cy.wait(1000)
|
||||
cy.get('button.shareButton').click()
|
||||
cy.get('.overlayContent textarea')
|
||||
.invoke('val')
|
||||
.should('eq', Cypress.config().baseUrl + urlWithState)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Simulateur salarié', () => {
|
||||
if (!fr) {
|
||||
return
|
||||
|
|
|
@ -107,7 +107,6 @@ const getUiMissingTranslations = () => {
|
|||
}
|
||||
|
||||
const fetchTranslation = async (text) => {
|
||||
console.log(`Fetch translation for:\n\t${text}`)
|
||||
const response = await fetch(
|
||||
`https://api.deepl.com/v2/translate?${querystring.stringify({
|
||||
text,
|
||||
|
@ -117,8 +116,14 @@ const fetchTranslation = async (text) => {
|
|||
target_lang: 'EN',
|
||||
})}`
|
||||
)
|
||||
const { translations } = await response.json()
|
||||
return translations[0].text
|
||||
try {
|
||||
const { translations } = await response.json()
|
||||
console.log(`✅ Deepl translation succeeded for:\n\t${text}\n`)
|
||||
return translations[0].text
|
||||
} catch (e) {
|
||||
console.warn(`❌ Deepl translation failed for:\n\t${text}\n`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
module.exports = {
|
||||
fetchTranslation,
|
||||
|
|
|
@ -8,16 +8,19 @@ import './Banner.css'
|
|||
type BannerProps = {
|
||||
children: React.ReactNode
|
||||
hidden?: boolean
|
||||
hideAfterFirstStep?: boolean
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export default function Banner({
|
||||
children,
|
||||
hidden: hiddenProp = false,
|
||||
hideAfterFirstStep = true,
|
||||
icon,
|
||||
}: BannerProps) {
|
||||
const hiddenState = useSelector(firstStepCompletedSelector)
|
||||
const hidden = hiddenProp || hiddenState
|
||||
|
||||
const hidden = hiddenProp || (hideAfterFirstStep && hiddenState)
|
||||
return !hidden ? (
|
||||
<Animate.fadeIn>
|
||||
<div className="ui__ banner">
|
||||
|
|
|
@ -108,20 +108,37 @@ const StyledOverlayWrapper = styled.div<{ offsetTop: number | null }>`
|
|||
border-left: 0.5rem solid white;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
color: var(--lighterTextColor);
|
||||
padding: 0 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ui__.card[aria-modal='true'] {
|
||||
padding-bottom: 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.overlayContent {
|
||||
width: 100%;
|
||||
}
|
||||
.overlayCloseButton {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
line-height: 1rem;
|
||||
padding: 1.2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
font-size: 3rem;
|
||||
background: var(--lighterColor);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.overlayCloseButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
right: 0;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Overlay from './Overlay'
|
||||
import Banner from './Banner'
|
||||
import { LinkButton } from 'Components/ui/Button'
|
||||
|
||||
export default function ShareSimulationBanner({
|
||||
getShareSearchParams,
|
||||
}: {
|
||||
getShareSearchParams: () => URLSearchParams
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const getUrl = () =>
|
||||
[
|
||||
window.location.origin,
|
||||
window.location.pathname,
|
||||
'?',
|
||||
getShareSearchParams().toString(),
|
||||
].join('')
|
||||
|
||||
const handleClose = () => {
|
||||
setOpened(false)
|
||||
}
|
||||
const onClick = () => {
|
||||
if (window.navigator.share) {
|
||||
window.navigator.share({
|
||||
title: document.title,
|
||||
text: t(
|
||||
'shareSimulation.navigatorShare',
|
||||
'Ma simulation Mon Entreprise'
|
||||
),
|
||||
url: getUrl(),
|
||||
})
|
||||
} else {
|
||||
setOpened(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner hidden={false} hideAfterFirstStep={false} icon="📤">
|
||||
<Trans i18nKey="shareSimulation.banner">
|
||||
Vous pouvez partager votre simulation :{' '}
|
||||
<LinkButton onClick={onClick} className="shareButton">
|
||||
Partager le lien
|
||||
</LinkButton>
|
||||
</Trans>
|
||||
{opened && (
|
||||
<Overlay onClose={handleClose}>
|
||||
<ShareSimulationPopup handleClose={handleClose} getUrl={getUrl} />
|
||||
</Overlay>
|
||||
)}
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareSimulationPopup({
|
||||
handleClose,
|
||||
getUrl,
|
||||
}: {
|
||||
handleClose: () => void
|
||||
getUrl: () => string
|
||||
}) {
|
||||
const textAreaRef: React.RefObject<HTMLTextAreaElement> = React.createRef()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const node = textAreaRef.current
|
||||
if (node) {
|
||||
node.select()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>{t('shareSimulation.modal.title', 'Votre lien de partage')}</h2>
|
||||
<textarea
|
||||
className="ui__ "
|
||||
ref={textAreaRef}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{getUrl()}
|
||||
</textarea>
|
||||
{navigator.clipboard ? (
|
||||
<button
|
||||
className="ui__ small simple button "
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getUrl())
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
📋 {t('shareSimulation.modal.button', 'Copier le lien')}
|
||||
</button>
|
||||
) : (
|
||||
<p>
|
||||
{t(
|
||||
'shareSimulation.modal.helpText',
|
||||
'Le lien est déjà sélectionné, vous pouvez faire "copier".'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -14,6 +14,8 @@ import { Trans } from 'react-i18next'
|
|||
import { useSelector } from 'react-redux'
|
||||
import { firstStepCompletedSelector } from 'Selectors/simulationSelectors'
|
||||
import LinkToForm from './Feedback/LinkToForm'
|
||||
import useSearchParamsSimulationSharing from 'Components/utils/useSearchParamsSimulationSharing'
|
||||
import ShareSimulationBanner from 'Components/ShareSimulationBanner'
|
||||
|
||||
type SimulationProps = {
|
||||
explanations?: React.ReactNode
|
||||
|
@ -31,6 +33,8 @@ export default function Simulation({
|
|||
showPeriodSwitch,
|
||||
}: SimulationProps) {
|
||||
const firstStepCompleted = useSelector(firstStepCompletedSelector)
|
||||
const getShareSearchParams = useSearchParamsSimulationSharing()
|
||||
|
||||
return (
|
||||
<>
|
||||
<TargetSelection showPeriodSwitch={showPeriodSwitch} />
|
||||
|
@ -38,6 +42,7 @@ export default function Simulation({
|
|||
{firstStepCompleted && (
|
||||
<Animate.fromTop>
|
||||
{results}
|
||||
<ShareSimulationBanner getShareSearchParams={getShareSearchParams} />
|
||||
<Questions customEndMessages={customEndMessages} />
|
||||
<br />
|
||||
{showLinkToForm && <LinkToForm />}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
// backported from react-router 6
|
||||
// https://github.com/ReactTraining/react-router/blob/a97dbdb7297474ff0114411e363db2c8fb417e55/packages/react-router-dom/index.tsx#L383
|
||||
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
|
||||
export type ParamKeyValuePair = [string, string]
|
||||
export type URLSearchParamsInit =
|
||||
| string
|
||||
| ParamKeyValuePair[]
|
||||
| Record<string, string | string[]>
|
||||
| URLSearchParams
|
||||
|
||||
export function useSearchParams(defaultInit?: URLSearchParamsInit) {
|
||||
const defaultSearchParamsRef = useRef(createSearchParams(defaultInit))
|
||||
|
||||
const location = useLocation()
|
||||
const searchParams = useMemo(() => {
|
||||
const searchParams = createSearchParams(location.search)
|
||||
|
||||
for (const key of defaultSearchParamsRef.current.keys()) {
|
||||
if (!searchParams.has(key)) {
|
||||
defaultSearchParamsRef.current.getAll(key).forEach((value) => {
|
||||
searchParams.append(key, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return searchParams
|
||||
}, [location.search])
|
||||
|
||||
const history = useHistory()
|
||||
const setSearchParams = useCallback(
|
||||
(
|
||||
nextInit: URLSearchParamsInit,
|
||||
navigateOptions?: { replace?: boolean }
|
||||
) => {
|
||||
if (navigateOptions?.replace) {
|
||||
history.replace('?' + createSearchParams(nextInit))
|
||||
} else {
|
||||
history.push('?' + createSearchParams(nextInit))
|
||||
}
|
||||
},
|
||||
[history]
|
||||
)
|
||||
|
||||
return [searchParams, setSearchParams] as const
|
||||
}
|
||||
|
||||
export function createSearchParams(
|
||||
init: URLSearchParamsInit = ''
|
||||
): URLSearchParams {
|
||||
return new URLSearchParams(
|
||||
typeof init === 'string' ||
|
||||
Array.isArray(init) ||
|
||||
init instanceof URLSearchParams
|
||||
? init
|
||||
: Object.keys(init).reduce((memo, key) => {
|
||||
const value = init[key]
|
||||
return memo.concat(
|
||||
Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]]
|
||||
)
|
||||
}, [] as ParamKeyValuePair[])
|
||||
)
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { SimulationConfig, Situation } from 'Reducers/rootReducer'
|
||||
import { useSearchParams } from 'Components/utils/useSearchParams'
|
||||
import { useEngine } from 'Components/utils/EngineContext'
|
||||
import {
|
||||
configSelector,
|
||||
situationSelector,
|
||||
} from 'Selectors/simulationSelectors'
|
||||
import Engine, { ParsedRules, serializeEvaluation } from 'publicodes'
|
||||
import { DottedName } from 'modele-social'
|
||||
import { updateSituation, setActiveTarget } from 'Actions/actions'
|
||||
|
||||
type Objectifs = (string | { objectifs: string[] })[]
|
||||
type ShortName = string
|
||||
type ParamName = DottedName | ShortName
|
||||
|
||||
export default function useSearchParamsSimulationSharing() {
|
||||
const [urlSituationIsExtracted, setUrlSituationIsExtracted] = useState(false)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const config = useSelector(configSelector)
|
||||
const situation = useSelector(situationSelector)
|
||||
const engine = useEngine()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const dottedNameParamName = useMemo(
|
||||
() => getRulesParamNames(engine.getParsedRules()),
|
||||
[engine]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const hasConfig = Object.keys(config).length > 0
|
||||
if (!hasConfig) return
|
||||
|
||||
// On load:
|
||||
if (!urlSituationIsExtracted) {
|
||||
const objectifs = objectifsOfConfig(config)
|
||||
const newSituation = getSituationFromSearchParams(
|
||||
searchParams,
|
||||
dottedNameParamName
|
||||
)
|
||||
|
||||
Object.entries(newSituation).forEach(([dottedName, value]) => {
|
||||
dispatch(updateSituation(dottedName as DottedName, value))
|
||||
})
|
||||
const newActiveTarget = Object.keys(newSituation).filter((dottedName) =>
|
||||
objectifs.includes(dottedName)
|
||||
)[0]
|
||||
if (newActiveTarget) {
|
||||
dispatch(setActiveTarget(newActiveTarget as DottedName))
|
||||
}
|
||||
|
||||
cleanSearchParams(
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
dottedNameParamName,
|
||||
Object.keys(newSituation) as DottedName[]
|
||||
)
|
||||
|
||||
setUrlSituationIsExtracted(true)
|
||||
return
|
||||
}
|
||||
}, [
|
||||
dispatch,
|
||||
dottedNameParamName,
|
||||
config,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
situation,
|
||||
urlSituationIsExtracted,
|
||||
])
|
||||
|
||||
return () =>
|
||||
getSearchParamsFromSituation(engine, situation, dottedNameParamName)
|
||||
}
|
||||
|
||||
const objectifsOfConfig = (config: Partial<SimulationConfig>) =>
|
||||
(config.objectifs as Objectifs).flatMap((objectifOrSection) => {
|
||||
if (typeof objectifOrSection === 'string') {
|
||||
return [objectifOrSection]
|
||||
}
|
||||
return objectifOrSection.objectifs
|
||||
})
|
||||
|
||||
export const cleanSearchParams = (
|
||||
searchParams: ReturnType<typeof useSearchParams>[0],
|
||||
setSearchParams: ReturnType<typeof useSearchParams>[1],
|
||||
dottedNameParamName: [DottedName, ParamName][],
|
||||
dottedNames: DottedName[]
|
||||
) => {
|
||||
const dottedNameParamNameMapping = Object.fromEntries(dottedNameParamName)
|
||||
dottedNames.forEach((dottedName) =>
|
||||
searchParams.delete(dottedNameParamNameMapping[dottedName])
|
||||
)
|
||||
setSearchParams(searchParams.toString())
|
||||
}
|
||||
|
||||
export const getRulesParamNames = (
|
||||
parsedRules: ParsedRules<DottedName>
|
||||
): [DottedName, ParamName][] =>
|
||||
(Object.entries(parsedRules) as [
|
||||
DottedName,
|
||||
{ rawNode: { 'identifiant court'?: ShortName } }
|
||||
][]).map(([dottedName, ruleNode]) => [
|
||||
dottedName,
|
||||
ruleNode.rawNode['identifiant court'] || dottedName,
|
||||
])
|
||||
|
||||
export function getSearchParamsFromSituation(
|
||||
engine: Engine,
|
||||
situation: Situation,
|
||||
dottedNameParamName: [DottedName, ParamName][]
|
||||
): URLSearchParams {
|
||||
const searchParams = new URLSearchParams()
|
||||
const dottedNameParamNameMapping = Object.fromEntries(dottedNameParamName)
|
||||
;(Object.entries(situation) as [DottedName, any][]).forEach(
|
||||
([dottedName, value]) => {
|
||||
const paramName = dottedNameParamNameMapping[dottedName]
|
||||
const serializedValue = serializeEvaluation(engine.evaluate(value))
|
||||
if (typeof serializedValue !== 'undefined')
|
||||
searchParams.set(paramName, serializedValue)
|
||||
}
|
||||
)
|
||||
searchParams.sort()
|
||||
return searchParams
|
||||
}
|
||||
|
||||
export function getSituationFromSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
dottedNameParamName: [DottedName, ParamName][]
|
||||
) {
|
||||
const situation = {} as Situation
|
||||
|
||||
const paramNameDottedName = dottedNameParamName.reduce(
|
||||
(dottedNameBySearchParamName, [dottedName, paramName]) => ({
|
||||
...dottedNameBySearchParamName,
|
||||
[paramName]: dottedName,
|
||||
}),
|
||||
{} as Record<ParamName, DottedName>
|
||||
)
|
||||
|
||||
searchParams.forEach((value, paramName) => {
|
||||
if (Object.prototype.hasOwnProperty.call(paramNameDottedName, paramName)) {
|
||||
situation[paramNameDottedName[paramName]] = value
|
||||
}
|
||||
})
|
||||
|
||||
return situation
|
||||
}
|
|
@ -2580,6 +2580,8 @@ contrat salarié . prix du travail:
|
|||
résumé.fr: Dépensé par l'entreprise
|
||||
titre.en: labor cost
|
||||
titre.fr: Coût total
|
||||
identifiant court.en: employee-laborcost
|
||||
identifiant court.fr: salarie-coutembauche
|
||||
contrat salarié . profession spécifique:
|
||||
question.en: '[automatic] Does the employee work in one of the following professions?'
|
||||
question.fr: Le salarié exerce t-il l'une des professions suivantes ?
|
||||
|
@ -3044,6 +3046,8 @@ contrat salarié . rémunération . brut de base:
|
|||
suggestions.salaire médian.fr: salaire médian
|
||||
titre.en: Gross salary
|
||||
titre.fr: Salaire brut
|
||||
identifiant court.en: employee-brut
|
||||
identifiant court.fr: salarie-brut
|
||||
contrat salarié . rémunération . brut de base . équivalent temps plein:
|
||||
question.en: What is the full-time equivalent salary?
|
||||
question.fr: Quel est le salaire en équivalent temps plein ?
|
||||
|
@ -3101,6 +3105,8 @@ contrat salarié . rémunération . net:
|
|||
résumé.fr: Salaire net avant impôt
|
||||
titre.en: Net salary
|
||||
titre.fr: Salaire net
|
||||
identifiant court.en: employee-net
|
||||
identifiant court.fr: salarie-net
|
||||
contrat salarié . rémunération . net après impôt:
|
||||
description.en: >-
|
||||
Net salary after deduction of the **neutral** income tax (also called
|
||||
|
@ -3126,6 +3132,8 @@ contrat salarié . rémunération . net après impôt:
|
|||
résumé.fr: Versé sur le compte bancaire
|
||||
titre.en: Net salary after income tax
|
||||
titre.fr: Salaire net après impôt
|
||||
identifiant court.en: employee-netaftertax
|
||||
identifiant court.fr: salarie-netapresimpot
|
||||
contrat salarié . rémunération . net avec revenus de remplacement:
|
||||
titre.en: '[automatic] net with replacement income'
|
||||
titre.fr: net avec revenus de remplacement
|
||||
|
@ -3284,6 +3292,8 @@ contrat salarié . rémunération . total:
|
|||
résumé.fr: Dépensé par l'entreprise
|
||||
titre.en: Total salary
|
||||
titre.fr: Total chargé
|
||||
identifiant court.fr: salarie-total
|
||||
identifiant court.en: employee-total
|
||||
contrat salarié . salarié majeur:
|
||||
question.en: Is the employee over 18 (age of majority)?
|
||||
question.fr: Le salarié est-il majeur ?
|
||||
|
@ -3979,6 +3989,8 @@ dirigeant . auto-entrepreneur . net après impôt:
|
|||
résumé.fr: Avant déduction des dépenses liées à l'activité
|
||||
titre.en: net income after tax
|
||||
titre.fr: revenu net après impôt
|
||||
identifiant court.en: auto-entrepreneur-netaftertax
|
||||
identifiant court.fr: auto-entrepreneur-netapresimpot
|
||||
dirigeant . auto-entrepreneur . net de cotisations:
|
||||
description.en:
|
||||
This is the income net of contributions and expenses, before the
|
||||
|
@ -3991,6 +4003,8 @@ dirigeant . auto-entrepreneur . net de cotisations:
|
|||
résumé.fr: Avant impôt
|
||||
titre.en: Net contribution income
|
||||
titre.fr: Revenu net de cotisations
|
||||
identifiant court.en: auto-entrepreneur-net
|
||||
identifiant court.fr: auto-entrepreneur-net
|
||||
dirigeant . auto-entrepreneur . notification calcul ACRE annuel:
|
||||
description.en: >
|
||||
[automatic] The ACRE rate used is the one corresponding to the current
|
||||
|
@ -5762,6 +5776,8 @@ dirigeant . indépendant . revenu net de cotisations:
|
|||
résumé.fr: Avant déduction de l'impôt sur le revenu
|
||||
titre.en: net contribution income
|
||||
titre.fr: revenu net de cotisations
|
||||
identifiant court.en: independant-net
|
||||
identifiant court.fr: independant-net
|
||||
dirigeant . indépendant . revenu professionnel:
|
||||
description.en: >
|
||||
This is the income net of deductible contributions of the self-employed
|
||||
|
@ -5842,6 +5858,8 @@ dirigeant . rémunération totale:
|
|||
résumé.fr: Dépensé par l'entreprise
|
||||
titre.en: Director total income
|
||||
titre.fr: rémunération totale
|
||||
identifiant court.en: director-total
|
||||
identifiant court.fr: dirigeant-total
|
||||
entreprise:
|
||||
description.en: The contract binds a company and an employee
|
||||
description.fr: |
|
||||
|
@ -6146,6 +6164,8 @@ entreprise . charges:
|
|||
résumé.fr: Toutes les dépenses nécessaires à l'entreprise
|
||||
titre.en: expenses
|
||||
titre.fr: charges de fonctionnement
|
||||
identifiant court.en: company-expenses
|
||||
identifiant court.fr: entreprise-charges
|
||||
entreprise . charges dont rémunération dirigeant:
|
||||
titre.en: expenses of which executive compensation
|
||||
titre.fr: charges dont rémunération dirigeant
|
||||
|
@ -6156,6 +6176,8 @@ entreprise . chiffre d'affaires:
|
|||
résumé.fr: Montant total des recettes brutes (hors taxe)
|
||||
titre.en: '[automatic] revenues'
|
||||
titre.fr: chiffre d'affaires
|
||||
identifiant court.en: company-turnover
|
||||
identifiant court.fr: entreprise-ca
|
||||
entreprise . chiffre d'affaires de société:
|
||||
titre.en: company turnover
|
||||
titre.fr: chiffre d'affaires de société
|
||||
|
@ -6169,6 +6191,8 @@ entreprise . chiffre d'affaires minimum:
|
|||
question.fr: Quel est votre chiffre d'affaires minimum envisagé ?
|
||||
titre.en: Minimum turnover
|
||||
titre.fr: chiffre d'affaires minimum
|
||||
identifiant court.en: company-turnover-min
|
||||
identifiant court.fr: entreprise-ca-min
|
||||
entreprise . date de création:
|
||||
description.en: >
|
||||
[automatic] The activity start date (or creation date) is set at the time of
|
||||
|
@ -7240,6 +7264,8 @@ revenu net après impôt:
|
|||
résumé.fr: Disponible sur votre compte en banque
|
||||
titre.en: Net income after tax
|
||||
titre.fr: revenu net après impôt
|
||||
identifiant court.en: netaftertax
|
||||
identifiant court.fr: netapresimpot
|
||||
revenus net de cotisations:
|
||||
description.en: |
|
||||
l'impôt sur le revenu.
|
||||
|
|
|
@ -38,6 +38,7 @@ export const attributesToTranslate = [
|
|||
'résumé',
|
||||
'suggestions',
|
||||
'note',
|
||||
'identifiant court',
|
||||
]
|
||||
|
||||
const translateProp = (lang: string, translation: Translation) => (
|
||||
|
|
|
@ -1313,6 +1313,14 @@ selectionRégime:
|
|||
titre: Before starting...
|
||||
urssaf: The figures are indicative and do not replace the actual accounts of the
|
||||
Urssaf, impots.gouv.fr, etc
|
||||
shareSimulation:
|
||||
banner: "You can share your simulation: <2 onClick={onClick}>Share
|
||||
link</2>"
|
||||
modal:
|
||||
button: Copy link
|
||||
helpText: The link is already selected, you can just hit "copy".
|
||||
title: Your sharing link
|
||||
navigatorShare: My company simulation
|
||||
simulateurs:
|
||||
explanation:
|
||||
CNAPL: It recovers contributions related to your retirement and disability/death
|
||||
|
|
|
@ -60,7 +60,7 @@ export type SimulationConfig = {
|
|||
'unité par défaut': string
|
||||
}
|
||||
|
||||
type Situation = Partial<Record<DottedName, any>>
|
||||
export type Situation = Partial<Record<DottedName, any>>
|
||||
export type Simulation = {
|
||||
config: SimulationConfig
|
||||
url: string
|
||||
|
@ -91,13 +91,13 @@ function simulation(
|
|||
existingCompany: Company
|
||||
): Simulation | null {
|
||||
if (action.type === 'SET_SIMULATION') {
|
||||
if (state && state.config === action.config) {
|
||||
return state
|
||||
}
|
||||
const companySituation = action.useCompanyDetails
|
||||
? getCompanySituation(existingCompany)
|
||||
: {}
|
||||
const { config, url } = action
|
||||
if (state && state.config === config) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
config,
|
||||
url,
|
||||
|
|
|
@ -4,6 +4,7 @@ import { DottedName } from 'modele-social'
|
|||
// Note: it is currently not possible to define SavedSimulation as the return
|
||||
// type of the currentSimulationSelector function because the type would then
|
||||
// circulary reference itself.
|
||||
// TODO: recursive type references should work now: https://github.com/microsoft/TypeScript/pull/33050
|
||||
export type SavedSimulation = {
|
||||
situation: Simulation['situation']
|
||||
activeTargetInput: RootState['activeTargetInput']
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
import { expect } from 'chai'
|
||||
var sinon = require('sinon')
|
||||
import Engine, { parsePublicodes } from 'publicodes'
|
||||
import rules from 'modele-social'
|
||||
import {
|
||||
getSearchParamsFromSituation,
|
||||
getSituationFromSearchParams,
|
||||
getRulesParamNames,
|
||||
cleanSearchParams,
|
||||
} from '../source/components/utils/useSearchParamsSimulationSharing'
|
||||
|
||||
describe('identifiant court', () => {
|
||||
const questions = Object.entries(parsePublicodes(rules))
|
||||
.filter(([, ruleNode]) => ruleNode.rawNode['identifiant court'])
|
||||
.map(([dottedName, ruleNode]) => [
|
||||
dottedName,
|
||||
ruleNode.rawNode['identifiant court'],
|
||||
])
|
||||
|
||||
it('should be unique amongst rules', () => {
|
||||
expect(questions.length).to.greaterThan(0)
|
||||
expect(questions.length).to.eq(
|
||||
new Set(questions.map(([, name]) => name)).size
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSearchParamsSimulationSharing', () => {
|
||||
const someRules = parsePublicodes(`
|
||||
rule with:
|
||||
identifiant court: panta
|
||||
formule: 0
|
||||
rule without:
|
||||
formule: 0
|
||||
`)
|
||||
const engine = new Engine(someRules)
|
||||
const dottedNameParamName = getRulesParamNames(engine.getParsedRules())
|
||||
|
||||
describe('getSearchParamsFromSituation', () => {
|
||||
it('builds search params with and without identifiant court', () => {
|
||||
expect(
|
||||
getSearchParamsFromSituation(
|
||||
engine,
|
||||
{ 'rule with': '2000€/mois', 'rule without': '1000€/mois' },
|
||||
dottedNameParamName
|
||||
).toString()
|
||||
).to.equal(
|
||||
new URLSearchParams(
|
||||
'panta=2000€/mois&rule without=1000€/mois'
|
||||
).toString()
|
||||
)
|
||||
})
|
||||
it.skip('builds search params with object', () => {
|
||||
expect(
|
||||
getSearchParamsFromSituation(
|
||||
engine,
|
||||
{ 'rule without': { 1: 2, 3: { 4: '5' } } },
|
||||
dottedNameParamName
|
||||
).toString()
|
||||
).to.equal(
|
||||
new URLSearchParams('rule without={"1":2,"3":{"4":"5"}}').toString()
|
||||
)
|
||||
})
|
||||
it('handles empty situation with proper defaults', () => {
|
||||
expect(
|
||||
getSearchParamsFromSituation(engine, {}, dottedNameParamName).toString()
|
||||
).to.equal('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSituationFromSearchParams', () => {
|
||||
it('reads search params with and without identifiant court', () => {
|
||||
expect(
|
||||
getSituationFromSearchParams(
|
||||
new URLSearchParams('panta=2000€/mois&rule without=1000€/mois'),
|
||||
dottedNameParamName
|
||||
)
|
||||
).to.deep.equal({
|
||||
'rule with': '2000€/mois',
|
||||
'rule without': '1000€/mois',
|
||||
})
|
||||
})
|
||||
it('handles empty search params with proper defaults', () => {
|
||||
expect(
|
||||
getSituationFromSearchParams(
|
||||
new URLSearchParams(''),
|
||||
dottedNameParamName
|
||||
)
|
||||
).to.deep.equal({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSearchParamsSimulationSharing hook', () => {
|
||||
const someRules = parsePublicodes(`
|
||||
rule with:
|
||||
identifiant court: panta
|
||||
formule: 0
|
||||
rule without:
|
||||
formule: 0
|
||||
`)
|
||||
|
||||
const dottedNameParamName = getRulesParamNames(
|
||||
new Engine(someRules).getParsedRules()
|
||||
)
|
||||
let setSearchParams
|
||||
|
||||
beforeEach(() => {
|
||||
setSearchParams = sinon.spy(() => {})
|
||||
})
|
||||
it('removes searchParams that are in situation', () => {
|
||||
const searchParams = new URLSearchParams('panta=123&rule without=333')
|
||||
const newSituation = getSituationFromSearchParams(
|
||||
searchParams,
|
||||
dottedNameParamName
|
||||
)
|
||||
cleanSearchParams(
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
dottedNameParamName,
|
||||
Object.keys(newSituation)
|
||||
)
|
||||
expect(setSearchParams.calledWith('')).to.be.true
|
||||
})
|
||||
it("doesn't remove other search params", () => {
|
||||
const searchParams = new URLSearchParams(
|
||||
'rule without=123&utm_campaign=marketing'
|
||||
)
|
||||
const newSituation = getSituationFromSearchParams(
|
||||
searchParams,
|
||||
dottedNameParamName
|
||||
)
|
||||
cleanSearchParams(
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
dottedNameParamName,
|
||||
Object.keys(newSituation)
|
||||
)
|
||||
expect(setSearchParams.calledWith('utm_campaign=marketing')).to.be.true
|
||||
})
|
||||
})
|
|
@ -8,7 +8,7 @@
|
|||
@{%
|
||||
const {
|
||||
string, date, variable, temporalNumericValue, binaryOperation,
|
||||
unaryOperation, boolean, number, numberWithUnit
|
||||
unaryOperation, boolean, number, numberWithUnit, JSONObject
|
||||
} = require('./grammarFunctions')
|
||||
|
||||
const moo = require("moo");
|
||||
|
@ -23,6 +23,7 @@ const periodWord = `\\| ${word}(?:[\\s]${word})*`
|
|||
|
||||
const numberRegExp = '-?(?:[1-9][0-9]+|[0-9])(?:\\.[0-9]+)?';
|
||||
const lexer = moo.compile({
|
||||
|
||||
'(': '(',
|
||||
')': ')',
|
||||
'[': '[',
|
||||
|
@ -35,6 +36,7 @@ const lexer = moo.compile({
|
|||
words: new RegExp(words),
|
||||
number: new RegExp(numberRegExp),
|
||||
string: /'.*'/,
|
||||
JSONObject: /{.*}/,
|
||||
additionSubstraction: /[\+-]/,
|
||||
multiplicationDivision: ['*','/'],
|
||||
dot: ' . ',
|
||||
|
@ -54,6 +56,7 @@ main ->
|
|||
| NumericValue {% id %}
|
||||
| Date {% id %}
|
||||
| NonNumericTerminal {% id %}
|
||||
| JSONObject {% id %}
|
||||
|
||||
NumericValue ->
|
||||
AdditionSubstraction {% id %}
|
||||
|
@ -116,3 +119,5 @@ number ->
|
|||
| %number (%space):? Unit {% numberWithUnit %}
|
||||
|
||||
string -> %string {% string %}
|
||||
|
||||
JSONObject -> %JSONObject {% JSONObject %}
|
|
@ -34,6 +34,10 @@ export let variable = ([firstFragment, nextFragment], _, reject) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const JSONObject = ([{ value }]) => {
|
||||
console.log(value)
|
||||
// TODO
|
||||
}
|
||||
export let number = ([{ value }]) => ({
|
||||
constant: {
|
||||
type: 'number',
|
||||
|
|
|
@ -47,6 +47,7 @@ export { simplifyNodeUnit } from './nodeUnits'
|
|||
export { serializeUnit } from './units'
|
||||
export { parsePublicodes, utils }
|
||||
export { Rule, RuleNode, ASTNode, EvaluatedNode }
|
||||
export { default as serializeEvaluation } from './serializeEvaluation'
|
||||
|
||||
type PublicodesExpression = string | Record<string, unknown> | number
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ export type Rule = {
|
|||
suggestions?: Record<string, string | number | Record<string, unknown>>
|
||||
références?: { [source: string]: string }
|
||||
API?: string
|
||||
'identifiant court'?: string
|
||||
}
|
||||
|
||||
type Remplace =
|
||||
|
@ -58,6 +59,7 @@ export type RuleNode = {
|
|||
valeur: ASTNode
|
||||
}
|
||||
suggestions: Record<string, ASTNode>
|
||||
'identifiant court'?: string
|
||||
}
|
||||
|
||||
export default function parseRule(
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { EvaluatedNode } from './index'
|
||||
import { serializeUnit } from './units'
|
||||
export default function serializeEvaluation(
|
||||
node: EvaluatedNode
|
||||
): string | undefined {
|
||||
if (typeof node.nodeValue === 'number') {
|
||||
const serializedUnit = serializeUnit(node.unit)
|
||||
return (
|
||||
'' +
|
||||
node.nodeValue +
|
||||
(serializedUnit ? serializedUnit.replace(/\s/g, '') : '')
|
||||
)
|
||||
} else if (typeof node.nodeValue === 'boolean') {
|
||||
return node.nodeValue ? 'oui' : 'non'
|
||||
} else if (typeof node.nodeValue === 'string') {
|
||||
return `'${node.nodeValue}'`
|
||||
}
|
||||
}
|
|
@ -33,7 +33,7 @@ export const serializeUnit = (
|
|||
rawUnit: Unit | undefined | string,
|
||||
count: number = plural,
|
||||
formatUnit: formatUnit = (x) => x
|
||||
) => {
|
||||
): string | undefined => {
|
||||
if (rawUnit === null || typeof rawUnit !== 'object') {
|
||||
return typeof rawUnit === 'string' ? formatUnit(rawUnit, count) : rawUnit
|
||||
}
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import Engine from '../source/index'
|
||||
import serializeEvaluation from '../source/serializeEvaluation'
|
||||
import { expect } from 'chai'
|
||||
|
||||
describe('serializeEvaluation', () => {
|
||||
it('should serialize a number', () => {
|
||||
const engine = new Engine()
|
||||
const expression = '2300'
|
||||
const evaluation = engine.evaluate(expression)
|
||||
|
||||
expect(serializeEvaluation(evaluation)).to.eq(expression)
|
||||
})
|
||||
|
||||
it('should serialize a boolean', () => {
|
||||
const engine = new Engine()
|
||||
const expression = 'oui'
|
||||
const evaluation = engine.evaluate(expression)
|
||||
|
||||
expect(serializeEvaluation(evaluation)).to.eq(expression)
|
||||
})
|
||||
|
||||
it('should serialize a number with unit', () => {
|
||||
const engine = new Engine()
|
||||
const expression = '457€/mois'
|
||||
const evaluation = engine.evaluate(expression)
|
||||
|
||||
expect(serializeEvaluation(evaluation)).to.eq(expression)
|
||||
})
|
||||
|
||||
it('should serialize a string', () => {
|
||||
const engine = new Engine()
|
||||
const expression = "'CDI'"
|
||||
const evaluation = engine.evaluate(expression)
|
||||
|
||||
expect(serializeEvaluation(evaluation)).to.eq(expression)
|
||||
})
|
||||
|
||||
it.skip('should serialize an object', () => {
|
||||
const engine = new Engine()
|
||||
const expression = '{ a: 45, b: {a: 15}}'
|
||||
const evaluation = engine.evaluate(expression)
|
||||
|
||||
expect(serializeEvaluation(evaluation)).to.eq(expression)
|
||||
})
|
||||
})
|
|
@ -109,20 +109,37 @@ const StyledOverlayWrapper = styled.div<{ offsetTop: number | null }>`
|
|||
border-left: 0.5rem solid white;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
color: var(--lighterTextColor);
|
||||
padding: 0 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ui__.card[aria-modal='true'] {
|
||||
padding-bottom: 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.overlayContent {
|
||||
width: 100%;
|
||||
}
|
||||
.overlayCloseButton {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
line-height: 1rem;
|
||||
padding: 1.2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
font-size: 3rem;
|
||||
background: var(--lighterColor);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.overlayCloseButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
right: 0;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue