🐛 Fix persistence

* persistSimulation: make it url path specific and make it available to
  all Simulations
** 1 e2e test with salarié & auto-entrepreur
** typescript test
* persistEverything becomes InFranceApp-specific
* mon-entreprise Provider initialStore is now partial
pull/1380/head
Alexandre Hajjar 2020-12-01 19:10:34 +01:00
parent 5eec284b13
commit 512bc74820
10 changed files with 180 additions and 46 deletions

View File

@ -140,18 +140,21 @@ describe('Simulateur salarié', () => {
}
before(() => cy.visit('/simulateurs/salarié'))
it('should save the current simulation', function () {
cy.get(inputSelector).first().type('{selectall}2137')
it('should persist the current simulation (persistSimulation)', function () {
cy.get(inputSelector).first().type('{selectall}42')
cy.contains('Passer').click()
cy.contains('Passer').click()
cy.contains('Passer').click()
cy.wait(1600)
cy.visit('/simulateurs/auto-entrepreneur')
cy.get(inputSelector).first().type('{selectall}007')
cy.contains('Passer').click()
cy.contains('Passer').click()
cy.contains('Passer').click()
cy.wait(1600)
cy.visit('/simulateurs/salarié')
cy.contains('Retrouver ma simulation').click()
cy.get(inputSelector)
.first()
.invoke('val')
.should('match', /2[\s]137/)
cy.get(inputSelector).first().invoke('val').should('match', /42/)
})
it('should not crash when selecting localisation', function () {

View File

@ -22,13 +22,10 @@ import {
} from 'Selectors/simulationSelectors'
import Provider, { ProviderProps } from './Provider'
import {
persistEverything,
retrievePersistedState,
} from './storage/persistEverything'
import {
persistSimulation,
retrievePersistedSimulation,
} from './storage/persistSimulation'
setupInFranceAppPersistence,
retrievePersistedInFranceApp,
} from './storage/persistInFranceApp'
import { setupSimulationPersistence } from './storage/persistSimulation'
import Tracker, { devTracker } from './Tracker'
import './App.css'
import Footer from 'Components/layout/Footer/Footer'
@ -99,12 +96,11 @@ export default function Root({ basename, rules }: RootProps) {
sitePaths={paths}
reduxMiddlewares={middlewares}
onStoreCreated={(store) => {
persistEverything({ except: ['simulation'] })(store)
persistSimulation(store)
setupInFranceAppPersistence(store)
setupSimulationPersistence(store)
}}
initialStore={{
...retrievePersistedState(),
previousSimulation: retrievePersistedSimulation(),
inFranceApp: retrievePersistedInFranceApp(),
}}
>
<EngineProvider value={engine}>

View File

@ -7,6 +7,7 @@ import React, { createContext, useEffect, useMemo } from 'react'
import { I18nextProvider } from 'react-i18next'
import { Provider as ReduxProvider } from 'react-redux'
import { Router } from 'react-router-dom'
import { PreloadedState } from 'redux'
import reducers, { RootState } from 'Reducers/rootReducer'
import { applyMiddleware, compose, createStore, Middleware, Store } from 'redux'
import Tracker from './Tracker'
@ -48,7 +49,7 @@ export type ProviderProps = {
children: React.ReactNode
tracker?: Tracker
sitePaths?: SitePaths
initialStore?: RootState
initialStore?: PreloadedState<RootState>
onStoreCreated?: (store: Store) => void
reduxMiddlewares?: Array<Middleware>
}

View File

@ -2,6 +2,7 @@ import { ThemeColorsProvider } from 'Components/utils/colors'
import { IsEmbeddedContext } from 'Components/utils/embeddedContext'
import Meta from 'Components/utils/Meta'
import useSimulationConfig from 'Components/utils/useSimulationConfig'
import PreviousSimulationBanner from 'Components/PreviousSimulationBanner'
import { default as React, useContext, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { SimulatorData } from './metadata'
@ -38,6 +39,7 @@ export default function SimulateurPage({
<ThemeColorsProvider color={inIframe ? undefined : meta?.color}>
<Component />
{config && <PreviousSimulationBanner />}
{seoExplanations && !inIframe && seoExplanations}
</ThemeColorsProvider>
</>

View File

@ -46,7 +46,6 @@ export default function SalariéSimulation() {
}
/>
<br />
<PreviousSimulationBanner />
{/** L'équipe Code Du Travail Numérique ne souhaite pas référencer
* le simulateur dirigeant de SASU sur son site. */}

View File

@ -164,10 +164,13 @@ function existingCompany(
return state
}
export default combineReducers({
const inFranceAppReducer = combineReducers({
companyLegalStatus,
companyStatusChoice,
companyCreationChecklist,
existingCompany,
hiringChecklist,
})
export default inFranceAppReducer
export type InFranceAppState = ReturnType<typeof inFranceAppReducer>

View File

@ -1,6 +1,7 @@
import { Simulation } from 'Reducers/rootReducer'
import { Action } from 'Actions/actions'
import { RootState } from './rootReducer'
import { retrievePersistedSimulation } from '../storage/persistSimulation'
export const createStateFromPreviousSimulation = (
state: RootState
@ -19,6 +20,11 @@ export const createStateFromPreviousSimulation = (
export default (state: RootState, action: Action): RootState => {
switch (action.type) {
case 'SET_SIMULATION':
return {
...state,
previousSimulation: retrievePersistedSimulation(action.url),
}
case 'LOAD_PREVIOUS_SIMULATION':
return {
...state,

View File

@ -1,31 +1,26 @@
import { Action } from 'Actions/actions'
import { omit } from 'ramda'
import { RootState } from 'Reducers/rootReducer'
import { Store } from 'redux'
import { debounce } from '../utils'
import safeLocalStorage from './safeLocalStorage'
import { InFranceAppState } from '../reducers/inFranceAppReducer'
const VERSION = 5
const VERSION = 6
const LOCAL_STORAGE_KEY = 'app::global-state:v' + VERSION
const LOCAL_STORAGE_KEY = 'mon-entreprise::persisted-infranceapp::v' + VERSION
type OptionsType = {
except?: Array<string>
}
export const persistEverything = (options: OptionsType = {}) => (
store: Store<RootState, Action>
): void => {
export function setupInFranceAppPersistence(store: Store<RootState, Action>) {
const listener = () => {
const state = store.getState()
safeLocalStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify(omit(options.except || [], state))
JSON.stringify(state.inFranceApp)
)
}
store.subscribe(debounce(1000, listener))
}
export function retrievePersistedState(): RootState {
export function retrievePersistedInFranceApp(): InFranceAppState {
const serializedState = safeLocalStorage.getItem(LOCAL_STORAGE_KEY)
return serializedState ? JSON.parse(serializedState) : null
return serializedState ? JSON.parse(serializedState) : undefined
}

View File

@ -5,27 +5,34 @@ import { PreviousSimulation } from 'Selectors/previousSimulationSelectors'
import { debounce } from '../utils'
import safeLocalStorage from './safeLocalStorage'
import { deserialize, serialize } from './serializeSimulation'
import { useLocation } from 'react-router-dom'
const VERSION = 4
const VERSION = 5
const LOCAL_STORAGE_KEY = 'embauche.gouv.fr::persisted-simulation::v' + VERSION
const localStorageKey = (pathname: string) =>
`mon-entreprise::persisted-simulation::v${VERSION}::${pathname}`
export function persistSimulation(store: Store<RootState, Action>) {
export function setupSimulationPersistence(
store: Store<RootState, Action>,
debounceDelay: number = 1000
) {
const listener = () => {
const state = store.getState()
if (!state.simulation?.foldedSteps.length) {
return
}
safeLocalStorage.setItem(LOCAL_STORAGE_KEY, serialize(state))
if (!state.simulation?.url) return
if (!state.simulation?.foldedSteps.length) return
safeLocalStorage.setItem(
localStorageKey(state.simulation.url),
serialize(state)
)
}
store.subscribe(debounce(1000, listener))
store.subscribe(debounce(debounceDelay, listener))
}
export function retrievePersistedSimulation(): SavedSimulation {
const serializedState = safeLocalStorage.getItem(LOCAL_STORAGE_KEY)
export function retrievePersistedSimulation(
simulationUrl: string
): PreviousSimulation | null {
const serializedState = safeLocalStorage.getItem(
localStorageKey(simulationUrl)
)
return serializedState ? deserialize(serializedState) : null
}
export function deletePersistedSimulation(): void {
safeLocalStorage.removeItem(LOCAL_STORAGE_KEY)
}

View File

@ -0,0 +1,122 @@
import { expect } from 'chai'
import * as sinon from 'sinon'
import { createStore } from 'redux'
import { createMemoryHistory } from 'history'
import { DottedName } from 'modele-social'
import reducers, {
Simulation,
SimulationConfig,
} from '../source/reducers/rootReducer'
import { PreviousSimulation } from '../source/selectors/previousSimulationSelectors'
import safeLocalStorage from '../source/storage/safeLocalStorage'
import { setupSimulationPersistence } from '../source/storage/persistSimulation'
import {
loadPreviousSimulation,
updateSituation,
setSimulationConfig,
} from '../source/actions/actions'
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const simulationConfig: SimulationConfig = {
objectifs: [],
'objectifs cachés': [],
situation: {},
'unité par défaut': '€/mois',
}
const initialSimulation: Simulation = {
config: simulationConfig,
url: '/someurl',
hiddenNotifications: [],
situation: {},
initialSituation: {},
targetUnit: '€/mois',
foldedSteps: ['somestep' as DottedName],
unfoldedStep: null,
}
describe('[persistence] When simulation persistence is setup', () => {
const sandbox = sinon.createSandbox()
let spiedSafeLocalStorage
let store
beforeEach(() => {
spiedSafeLocalStorage = sandbox.spy(safeLocalStorage as any)
store = createStore(reducers, {
simulation: initialSimulation,
activeTargetInput: 'sometargetinput',
})
setupSimulationPersistence(store, 0)
})
afterEach(() => {
sandbox.restore()
})
describe('when the state is changed with some data that is persistable', () => {
beforeEach(async () => {
store.dispatch(updateSituation('dotted name' as DottedName, '42'))
await delay(0)
})
it('saves state in localStorage with all fields', () => {
expect(spiedSafeLocalStorage.setItem.calledOnce).to.be.true
expect(spiedSafeLocalStorage.setItem.getCall(0).args[1]).to.eq(
'{"situation":{"dotted name":"42"},"activeTargetInput":"sometargetinput","foldedSteps":["somestep"]}'
)
})
it('saves state in localStorage with a key dependent on the simulation url', () => {
expect(spiedSafeLocalStorage.setItem.calledOnce).to.be.true
expect(spiedSafeLocalStorage.setItem.getCall(0).args[0]).to.contain(
'someurl'
)
})
})
})
describe('[persistence] When simulation config is set', () => {
const serializedPreviousSimulation =
'{"situation":{"dotted name . other":"42"},"activeTargetInput":"someothertargetinput","foldedSteps":["someotherstep"]}'
const sandbox = sinon.createSandbox()
let store
beforeEach(() => {
sandbox
.stub(safeLocalStorage, 'getItem')
.callsFake(() => serializedPreviousSimulation)
store = createStore(reducers)
const history = createMemoryHistory()
history.replace('/someotherurl')
store.dispatch(
setSimulationConfig(simulationConfig, history.location.pathname)
)
})
afterEach(() => {
sandbox.restore()
})
describe('when previous simulation is loaded in state', () => {
beforeEach(() => {
store.dispatch(loadPreviousSimulation())
})
it('loads url in state', () => {
expect(store.getState().simulation.url).to.eq('/someotherurl')
})
it('loads situation in state', () => {
expect(store.getState().simulation.situation).to.deep.eq({
'dotted name . other': '42',
})
})
it('loads activeTargetInput in state', () => {
expect(store.getState().activeTargetInput).to.eq('someothertargetinput')
})
it('loads foldedSteps in state', () => {
expect(store.getState().simulation.foldedSteps).to.deep.eq([
'someotherstep',
])
})
})
})