From 40fbb99026c6b27068e1ecf6d5a19c8f835c68f9 Mon Sep 17 00:00:00 2001 From: Alexandre Hajjar Date: Thu, 7 Jan 2021 17:08:19 +0000 Subject: [PATCH] Partage de la situation via URL (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 * 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 * :fountain_pen: Quelques légères modifications de nom pour les identifiants courts Co-authored-by: Paul Chavard Co-authored-by: Johan Girod close #552 --- modele-social/règles/dirigeant.yaml | 4 + .../règles/entreprise-établissement.yaml | 7 +- modele-social/règles/impôt.yaml | 1 + modele-social/règles/salarié.yaml | 8 +- .../integration/mon-entreprise/simulateurs.js | 34 ++++ mon-entreprise/scripts/i18n/utils.js | 11 +- mon-entreprise/source/components/Banner.tsx | 5 +- mon-entreprise/source/components/Overlay.tsx | 19 ++- .../components/ShareSimulationBanner.tsx | 109 +++++++++++++ .../source/components/Simulation.tsx | 5 + .../components/utils/useSearchParams.ts | 65 ++++++++ .../utils/useSearchParamsSimulationSharing.ts | 149 ++++++++++++++++++ mon-entreprise/source/locales/rules-en.yaml | 26 +++ .../source/locales/translateRules.ts | 1 + mon-entreprise/source/locales/ui-en.yaml | 8 + mon-entreprise/source/reducers/rootReducer.ts | 8 +- .../source/selectors/storageSelectors.ts | 1 + .../useSearchParamsSimulationSharing.test.js | 141 +++++++++++++++++ publicodes/core/source/grammar.ne | 7 +- publicodes/core/source/grammarFunctions.js | 4 + publicodes/core/source/index.ts | 1 + publicodes/core/source/rule.ts | 2 + publicodes/core/source/serializeEvaluation.ts | 18 +++ publicodes/core/source/units.ts | 2 +- publicodes/test/serializeEvaluation.test.js | 45 ++++++ publicodes/ui-react/source/Overlay.tsx | 19 ++- 26 files changed, 684 insertions(+), 16 deletions(-) create mode 100644 mon-entreprise/source/components/ShareSimulationBanner.tsx create mode 100644 mon-entreprise/source/components/utils/useSearchParams.ts create mode 100644 mon-entreprise/source/components/utils/useSearchParamsSimulationSharing.ts create mode 100644 mon-entreprise/test/useSearchParamsSimulationSharing.test.js create mode 100644 publicodes/core/source/serializeEvaluation.ts create mode 100644 publicodes/test/serializeEvaluation.test.js diff --git a/modele-social/règles/dirigeant.yaml b/modele-social/règles/dirigeant.yaml index 95f5a8383..b1f8b98bf 100644 --- a/modele-social/règles/dirigeant.yaml +++ b/modele-social/règles/dirigeant.yaml @@ -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: diff --git a/modele-social/règles/entreprise-établissement.yaml b/modele-social/règles/entreprise-établissement.yaml index d6ea16ab4..e76184cd1 100644 --- a/modele-social/règles/entreprise-établissement.yaml +++ b/modele-social/règles/entreprise-établissement.yaml @@ -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: diff --git a/modele-social/règles/impôt.yaml b/modele-social/règles/impôt.yaml index e6b2e07e0..247cfe5c4 100644 --- a/modele-social/règles/impôt.yaml +++ b/modele-social/règles/impôt.yaml @@ -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. diff --git a/modele-social/règles/salarié.yaml b/modele-social/règles/salarié.yaml index b633754ee..5567cdbc3 100644 --- a/modele-social/règles/salarié.yaml +++ b/modele-social/règles/salarié.yaml @@ -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: | diff --git a/mon-entreprise/cypress/integration/mon-entreprise/simulateurs.js b/mon-entreprise/cypress/integration/mon-entreprise/simulateurs.js index af06dcb61..3a171d1eb 100644 --- a/mon-entreprise/cypress/integration/mon-entreprise/simulateurs.js +++ b/mon-entreprise/cypress/integration/mon-entreprise/simulateurs.js @@ -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 diff --git a/mon-entreprise/scripts/i18n/utils.js b/mon-entreprise/scripts/i18n/utils.js index ee3aea7ca..bd7cbca91 100644 --- a/mon-entreprise/scripts/i18n/utils.js +++ b/mon-entreprise/scripts/i18n/utils.js @@ -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, diff --git a/mon-entreprise/source/components/Banner.tsx b/mon-entreprise/source/components/Banner.tsx index a4179b306..aaa875770 100644 --- a/mon-entreprise/source/components/Banner.tsx +++ b/mon-entreprise/source/components/Banner.tsx @@ -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 ? (
diff --git a/mon-entreprise/source/components/Overlay.tsx b/mon-entreprise/source/components/Overlay.tsx index 54d217358..12b735f9d 100644 --- a/mon-entreprise/source/components/Overlay.tsx +++ b/mon-entreprise/source/components/Overlay.tsx @@ -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; } diff --git a/mon-entreprise/source/components/ShareSimulationBanner.tsx b/mon-entreprise/source/components/ShareSimulationBanner.tsx new file mode 100644 index 000000000..927fac65e --- /dev/null +++ b/mon-entreprise/source/components/ShareSimulationBanner.tsx @@ -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 ( + + ) +} + +function ShareSimulationPopup({ + handleClose, + getUrl, +}: { + handleClose: () => void + getUrl: () => string +}) { + const textAreaRef: React.RefObject = React.createRef() + const { t } = useTranslation() + + useEffect(() => { + const node = textAreaRef.current + if (node) { + node.select() + } + }) + + return ( + <> +

{t('shareSimulation.modal.title', 'Votre lien de partage')}

+ + {navigator.clipboard ? ( + + ) : ( +

+ {t( + 'shareSimulation.modal.helpText', + 'Le lien est déjà sélectionné, vous pouvez faire "copier".' + )} +

+ )} + + ) +} diff --git a/mon-entreprise/source/components/Simulation.tsx b/mon-entreprise/source/components/Simulation.tsx index 3fe5139a1..a3ebe60dd 100644 --- a/mon-entreprise/source/components/Simulation.tsx +++ b/mon-entreprise/source/components/Simulation.tsx @@ -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 ( <> @@ -38,6 +42,7 @@ export default function Simulation({ {firstStepCompleted && ( {results} +
{showLinkToForm && } diff --git a/mon-entreprise/source/components/utils/useSearchParams.ts b/mon-entreprise/source/components/utils/useSearchParams.ts new file mode 100644 index 000000000..2525747d7 --- /dev/null +++ b/mon-entreprise/source/components/utils/useSearchParams.ts @@ -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 + | 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[]) + ) +} diff --git a/mon-entreprise/source/components/utils/useSearchParamsSimulationSharing.ts b/mon-entreprise/source/components/utils/useSearchParamsSimulationSharing.ts new file mode 100644 index 000000000..760d322e4 --- /dev/null +++ b/mon-entreprise/source/components/utils/useSearchParamsSimulationSharing.ts @@ -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) => + (config.objectifs as Objectifs).flatMap((objectifOrSection) => { + if (typeof objectifOrSection === 'string') { + return [objectifOrSection] + } + return objectifOrSection.objectifs + }) + +export const cleanSearchParams = ( + searchParams: ReturnType[0], + setSearchParams: ReturnType[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, 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 + ) + + searchParams.forEach((value, paramName) => { + if (Object.prototype.hasOwnProperty.call(paramNameDottedName, paramName)) { + situation[paramNameDottedName[paramName]] = value + } + }) + + return situation +} diff --git a/mon-entreprise/source/locales/rules-en.yaml b/mon-entreprise/source/locales/rules-en.yaml index 541e320b2..834ccb891 100644 --- a/mon-entreprise/source/locales/rules-en.yaml +++ b/mon-entreprise/source/locales/rules-en.yaml @@ -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. diff --git a/mon-entreprise/source/locales/translateRules.ts b/mon-entreprise/source/locales/translateRules.ts index 7ccdd9404..ea5b354c1 100644 --- a/mon-entreprise/source/locales/translateRules.ts +++ b/mon-entreprise/source/locales/translateRules.ts @@ -38,6 +38,7 @@ export const attributesToTranslate = [ 'résumé', 'suggestions', 'note', + 'identifiant court', ] const translateProp = (lang: string, translation: Translation) => ( diff --git a/mon-entreprise/source/locales/ui-en.yaml b/mon-entreprise/source/locales/ui-en.yaml index 57b3b1aa6..7412e731d 100644 --- a/mon-entreprise/source/locales/ui-en.yaml +++ b/mon-entreprise/source/locales/ui-en.yaml @@ -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" + 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 diff --git a/mon-entreprise/source/reducers/rootReducer.ts b/mon-entreprise/source/reducers/rootReducer.ts index 94a06bbc6..950a90f2c 100644 --- a/mon-entreprise/source/reducers/rootReducer.ts +++ b/mon-entreprise/source/reducers/rootReducer.ts @@ -60,7 +60,7 @@ export type SimulationConfig = { 'unité par défaut': string } -type Situation = Partial> +export type Situation = Partial> 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, diff --git a/mon-entreprise/source/selectors/storageSelectors.ts b/mon-entreprise/source/selectors/storageSelectors.ts index 6c912c653..fd5b47fa8 100644 --- a/mon-entreprise/source/selectors/storageSelectors.ts +++ b/mon-entreprise/source/selectors/storageSelectors.ts @@ -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'] diff --git a/mon-entreprise/test/useSearchParamsSimulationSharing.test.js b/mon-entreprise/test/useSearchParamsSimulationSharing.test.js new file mode 100644 index 000000000..02ee5e9bf --- /dev/null +++ b/mon-entreprise/test/useSearchParamsSimulationSharing.test.js @@ -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 + }) +}) diff --git a/publicodes/core/source/grammar.ne b/publicodes/core/source/grammar.ne index e585fe9aa..93c12ff1f 100644 --- a/publicodes/core/source/grammar.ne +++ b/publicodes/core/source/grammar.ne @@ -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 %} \ No newline at end of file diff --git a/publicodes/core/source/grammarFunctions.js b/publicodes/core/source/grammarFunctions.js index 965389869..70d7c8722 100644 --- a/publicodes/core/source/grammarFunctions.js +++ b/publicodes/core/source/grammarFunctions.js @@ -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', diff --git a/publicodes/core/source/index.ts b/publicodes/core/source/index.ts index 373292624..d9516de1f 100644 --- a/publicodes/core/source/index.ts +++ b/publicodes/core/source/index.ts @@ -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 | number diff --git a/publicodes/core/source/rule.ts b/publicodes/core/source/rule.ts index a80fb4506..d4493aec1 100644 --- a/publicodes/core/source/rule.ts +++ b/publicodes/core/source/rule.ts @@ -34,6 +34,7 @@ export type Rule = { suggestions?: Record> références?: { [source: string]: string } API?: string + 'identifiant court'?: string } type Remplace = @@ -58,6 +59,7 @@ export type RuleNode = { valeur: ASTNode } suggestions: Record + 'identifiant court'?: string } export default function parseRule( diff --git a/publicodes/core/source/serializeEvaluation.ts b/publicodes/core/source/serializeEvaluation.ts new file mode 100644 index 000000000..7f1f8d096 --- /dev/null +++ b/publicodes/core/source/serializeEvaluation.ts @@ -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}'` + } +} diff --git a/publicodes/core/source/units.ts b/publicodes/core/source/units.ts index 5989a9f53..caf6ccf09 100644 --- a/publicodes/core/source/units.ts +++ b/publicodes/core/source/units.ts @@ -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 } diff --git a/publicodes/test/serializeEvaluation.test.js b/publicodes/test/serializeEvaluation.test.js new file mode 100644 index 000000000..62087bc58 --- /dev/null +++ b/publicodes/test/serializeEvaluation.test.js @@ -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) + }) +}) diff --git a/publicodes/ui-react/source/Overlay.tsx b/publicodes/ui-react/source/Overlay.tsx index 9bf945d4c..7832697b9 100644 --- a/publicodes/ui-react/source/Overlay.tsx +++ b/publicodes/ui-react/source/Overlay.tsx @@ -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; }