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 #552
pull/1230/head
Alexandre Hajjar 2021-01-07 17:08:19 +00:00 committed by GitHub
parent 1ae2529a7a
commit 40fbb99026
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 684 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,7 @@ export const attributesToTranslate = [
'résumé',
'suggestions',
'note',
'identifiant court',
]
const translateProp = (lang: string, translation: Translation) => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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