From 4934eb0f2054a0424caa33efedbd3011c1f1f695 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 16 May 2024 12:38:28 +0200 Subject: [PATCH] feat: add fillers behaviour Fix #949 --- e2e/fillers.spec.ts | 25 ++++ lunatic-schema.json | 33 +++++ src/components/FillerLoader/FillerLoader.tsx | 11 ++ src/components/library.ts | 2 + .../shared/HOC/slottableComponent.tsx | 2 + src/components/type.ts | 3 + .../behaviour/fillers/fillers.stories.js | 31 +++++ src/stories/behaviour/fillers/source.json | 77 +++++++++++ src/stories/utils/orchestrator.jsx | 4 + src/type.source.ts | 9 ++ src/use-lunatic/hooks/useFillers.ts | 123 ++++++++++++++++++ src/use-lunatic/type.ts | 5 + src/use-lunatic/use-lunatic.ts | 44 +++++-- 13 files changed, 356 insertions(+), 13 deletions(-) create mode 100644 e2e/fillers.spec.ts create mode 100644 src/components/FillerLoader/FillerLoader.tsx create mode 100644 src/stories/behaviour/fillers/fillers.stories.js create mode 100644 src/stories/behaviour/fillers/source.json create mode 100644 src/use-lunatic/hooks/useFillers.ts diff --git a/e2e/fillers.spec.ts b/e2e/fillers.spec.ts new file mode 100644 index 000000000..11a850794 --- /dev/null +++ b/e2e/fillers.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { + expectPageToHaveText, + gotoNextPage, + gotoPreviousPage, + goToStory, +} from './utils'; + +test.describe('Fillers', () => { + test(`can fill data`, async ({ page }) => { + await goToStory(page, 'behaviour-fillers--default#t100'); + + // First filling + await page.getByLabel('Code postal').fill('34000'); + await gotoNextPage(page); + await expectPageToHaveText(page, 'Chargement'); + await expect(page.getByLabel('Ville')).toHaveValue('Montpellier'); + + // Second filling + await gotoPreviousPage(page); + await page.getByLabel('Code postal').fill('31000'); + await gotoNextPage(page); + await expect(page.getByLabel('Ville')).toHaveValue('Toulouse'); + }); +}); diff --git a/lunatic-schema.json b/lunatic-schema.json index ea57a1eca..5053ce467 100644 --- a/lunatic-schema.json +++ b/lunatic-schema.json @@ -172,6 +172,12 @@ }, "maxPage": { "type": "string" + }, + "fillers": { + "type": "array", + "items": { + "$ref": "#/$defs/FillerDefinition" + } } }, "required": ["components", "variables"], @@ -1349,6 +1355,33 @@ } }, "required": ["name", "fields", "queryParser", "version"] + }, + "FillerDefinition": { + "type": "object", + "properties": { + "endpoint": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"] + }, + "responses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + } + }, + "required": ["responses", "endpoint"] } } } diff --git a/src/components/FillerLoader/FillerLoader.tsx b/src/components/FillerLoader/FillerLoader.tsx new file mode 100644 index 000000000..c87b2acc7 --- /dev/null +++ b/src/components/FillerLoader/FillerLoader.tsx @@ -0,0 +1,11 @@ +import { slottableComponent } from '../shared/HOC/slottableComponent'; + +/** + * Displays a loader while fetching data to fill the form + */ +export const FillerLoader = slottableComponent( + 'FillerLoader', + function FillerLoader() { + return

Chargement des données...

; + } +); diff --git a/src/components/library.ts b/src/components/library.ts index 61ec76c72..f36d55400 100644 --- a/src/components/library.ts +++ b/src/components/library.ts @@ -23,6 +23,7 @@ import { PairwiseLinks } from './PairwiseLinks/PairwiseLinks'; import { CheckboxOne } from './CheckboxOne/CheckboxOne'; import { Suggester } from './Suggester/Suggester'; import { Summary } from './Summary/Summary'; +import { FillerLoader } from './FillerLoader/FillerLoader'; // List of all the "componentType" export const library = { @@ -49,6 +50,7 @@ export const library = { Roundabout: Roundabout, Suggester: Suggester, Summary: Summary, + FillerLoader: FillerLoader, } satisfies { [Property in LunaticComponentType]: ComponentType< LunaticComponentProps diff --git a/src/components/shared/HOC/slottableComponent.tsx b/src/components/shared/HOC/slottableComponent.tsx index 5a29ad22b..a8c1f79bf 100644 --- a/src/components/shared/HOC/slottableComponent.tsx +++ b/src/components/shared/HOC/slottableComponent.tsx @@ -50,6 +50,7 @@ import type { RouterLink } from '../MDLabel/RouterLink'; import type { SummaryResponses, SummaryTitle } from '../../Summary/Summary'; import type { LunaticComponentProps } from '../../type'; import type { MarkdownLink } from '../MDLabel/MarkdownLink'; +import type { FillerLoader } from '../../FillerLoader/FillerLoader'; /** * Contains the type of every customizable component @@ -121,6 +122,7 @@ export type LunaticSlotComponents = { PropsWithChildren >; MarkdownLink: typeof MarkdownLink; + FillerLoader: typeof FillerLoader; }; const empty = {} as Partial | undefined; diff --git a/src/components/type.ts b/src/components/type.ts index 81408cbe2..97d361663 100644 --- a/src/components/type.ts +++ b/src/components/type.ts @@ -269,6 +269,9 @@ export type ComponentPropsByType = { iterations?: VtlExpression; }>; }; + FillerLoader: { + componentType?: 'FillerLoader'; + }; }; export type LunaticComponentType = keyof ComponentPropsByType; diff --git a/src/stories/behaviour/fillers/fillers.stories.js b/src/stories/behaviour/fillers/fillers.stories.js new file mode 100644 index 000000000..e854e2a78 --- /dev/null +++ b/src/stories/behaviour/fillers/fillers.stories.js @@ -0,0 +1,31 @@ +import source from './source.json'; +import Orchestrator from '../../utils/orchestrator.jsx'; + +export default { + title: 'Behaviour/Fillers', + component: Orchestrator, +}; + +export const Default = { + args: { + source: source, + data: {}, + mockFiller: (data) => { + return new Promise((resolve) => { + setTimeout( + () => { + if (data.CODE === '34000') { + return resolve({ CITY: 'Montpellier' }); + } else if (data.CODE === '31000') { + return resolve({ CITY: 'Toulouse' }); + } + return resolve({}); + }, + window.location.hash.startsWith('#t') + ? parseInt(window.location.hash.replace('#t', ''), 10) + : 1500 + ); + }); + }, + }, +}; diff --git a/src/stories/behaviour/fillers/source.json b/src/stories/behaviour/fillers/source.json new file mode 100644 index 000000000..80b4328d4 --- /dev/null +++ b/src/stories/behaviour/fillers/source.json @@ -0,0 +1,77 @@ +{ + "$schema": "../../../../lunatic-schema.json", + "maxPage": "2", + "fillers": [ + { + "endpoint": { + "url": "https://inseefr.github.io/Lunatic/" + }, + "responses": [ + { + "name": "CODE" + } + ] + } + ], + "components": [ + { + "id": "a", + "page": "1", + "componentType": "Input", + "label": { + "type": "TXT", + "value": "Code postal" + }, + "response": { + "name": "CODE" + }, + "declarations": [ + { + "declarationType": "COMMENT", + "position": "DETACHABLE", + "id": "d_a", + "label": { + "value": "Ce code permettra de remplir la ville (34000 ou 31000)", + "type": "TXT" + } + } + ] + }, + { + "id": "a", + "page": "2", + "componentType": "Input", + "label": { + "type": "TXT", + "value": "Ville" + }, + "response": { + "name": "CITY" + } + } + ], + "variables": [ + { + "variableType": "COLLECTED", + "values": { + "COLLECTED": null, + "EDITED": null, + "INPUTTED": null, + "FORCED": null, + "PREVIOUS": null + }, + "name": "CODE" + }, + { + "variableType": "COLLECTED", + "values": { + "COLLECTED": null, + "EDITED": null, + "INPUTTED": null, + "FORCED": null, + "PREVIOUS": null + }, + "name": "CITY" + } + ] +} diff --git a/src/stories/utils/orchestrator.jsx b/src/stories/utils/orchestrator.jsx index c70643746..dc716bc34 100644 --- a/src/stories/utils/orchestrator.jsx +++ b/src/stories/utils/orchestrator.jsx @@ -119,6 +119,7 @@ function OrchestratorForStories({ refusedButton, readOnly = false, disabled = false, + mockFiller = null, ...rest }) { const { maxPage } = source; @@ -154,6 +155,9 @@ function OrchestratorForStories({ withOverview: showOverview, dontKnowButton, refusedButton, + mocks: { + filler: mockFiller, + }, }); const components = getComponents(); diff --git a/src/type.source.ts b/src/type.source.ts index 83c83002d..615d3a3a4 100644 --- a/src/type.source.ts +++ b/src/type.source.ts @@ -317,6 +317,7 @@ export interface LunaticSource { }; }; maxPage?: string; + fillers?: FillerDefinition[]; } export interface VTLExpression { /** @@ -440,3 +441,11 @@ export interface SuggesterDefinition { type: 'soft'; }; } +export interface FillerDefinition { + endpoint: { + url: string; + }; + responses: { + name: string; + }[]; +} diff --git a/src/use-lunatic/hooks/useFillers.ts b/src/use-lunatic/hooks/useFillers.ts new file mode 100644 index 000000000..2a2c0424c --- /dev/null +++ b/src/use-lunatic/hooks/useFillers.ts @@ -0,0 +1,123 @@ +import type { FillerDefinition } from '../../type.source'; +import type { LunaticVariablesStore } from '../commons/variables/lunatic-variables-store'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { LunaticChangesHandler } from '../type'; + +type Args = { + variables: LunaticVariablesStore; + fillers: FillerDefinition[]; + handleChanges: LunaticChangesHandler; + fetchMock: + | null + | ((data: Record) => Promise>); +}; + +/** + * Behaviour that fills variables from a server when some variables are collected + * The filling happens when the user move forward in the form + */ +export function useFillers({ + variables, + fillers, + handleChanges, + fetchMock, +}: Args) { + const watchedVariables = useMemo( + () => buildWatchedVariableMap(fillers), + [fillers] + ); + const activeFillers = useRef(new Set()); // List fillers that should be triggerred in the next navigation + const [isFilling, setFilling] = useState(false); + + // Listen for change on variables to detect if a filler need to be triggered on next page change + useEffect(() => { + const listener = ( + e: CustomEvent<{ + name: string; + }> + ) => { + if (watchedVariables.has(e.detail.name)) { + activeFillers.current.add(watchedVariables.get(e.detail.name)!); + } + }; + variables.on('change', listener); + return () => { + variables.off('change', listener); + }; + }, [variables, watchedVariables]); + + // Trigger fillers + const triggerFillers = useCallback(async () => { + if (activeFillers.current.size === 0) { + return; + } + setFilling(true); + Promise.all( + Array.from(activeFillers.current).map((filler) => { + const values = Object.fromEntries( + filler.responses.map((r) => [r.name, variables.get(r.name)]) + ); + return fetchFillerData(filler, values, fetchMock).then((data) => { + handleChanges( + Object.entries(data).map((d) => ({ + name: d[0], + value: d[1], + })) + ); + }); + }) + ) + .catch((e) => { + console.error(e); + alert(e); + }) + .finally(() => { + setFilling(false); + activeFillers.current.clear(); + }); + }, [activeFillers, variables, handleChanges]); + + return { + triggerFillers, + isFilling, + }; +} + +/** + * Creates a map of FillerDefinition indexed by variable name (improves performance) + */ +function buildWatchedVariableMap( + definitions: FillerDefinition[] +): Map { + const map = new Map(); + for (const definition of definitions) { + for (const response of definition.responses) { + map.set(response.name, definition); + } + } + return map; +} + +/** + * Fetch new data from the server (use mock if it exists) + */ +function fetchFillerData( + filler: FillerDefinition, + data: Record, + mock: + | null + | ((data: Record) => Promise>) +): Promise> { + if (mock) { + return mock(data); + } + + return fetch(filler.endpoint.url, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }).then((res) => res.json()); +} diff --git a/src/use-lunatic/type.ts b/src/use-lunatic/type.ts index 6f02da507..ec342c99b 100644 --- a/src/use-lunatic/type.ts +++ b/src/use-lunatic/type.ts @@ -150,6 +150,11 @@ export type LunaticOptions = { refusedButton?: string; // Enable change tracking to keep a track of what variable changed (allow using getChangedData()) trackChanges?: boolean; + mocks?: { + filler: + | null + | ((data: Record) => Promise>); + }; }; // Type representing the return type of "useLunatic()" diff --git a/src/use-lunatic/use-lunatic.ts b/src/use-lunatic/use-lunatic.ts index 90a539bf9..f7ed06450 100644 --- a/src/use-lunatic/use-lunatic.ts +++ b/src/use-lunatic/use-lunatic.ts @@ -28,6 +28,7 @@ import { getComponentsFromState } from './commons/get-components-from-state'; import { fillComponents } from './commons/fill-components/fill-components'; import { reducer } from './reducer/reducer'; import { mergeDefault } from '../utils/object'; +import { useFillers } from './hooks/useFillers'; const empty = {}; // Keep the same empty object (to avoid problem with useEffect dependencies) const DEFAULT_DATA = empty as LunaticData; @@ -76,6 +77,7 @@ function useLunatic( onChange, trackChanges, preferences, + mocks, } = options; const [state, dispatch] = useReducer( reducer, @@ -117,19 +119,6 @@ function useLunatic( [dispatch] ); - const goNextPage: LunaticState['goNextPage'] = useCallback( - function (payload = {}) { - dispatch(goNextPageAction(payload)); - }, - [dispatch] - ); - - const goToPage: LunaticState['goToPage'] = useCallback( - function (payload) { - dispatch(goToPageAction(payload)); - }, - [dispatch] - ); const handleChanges = useCallback( (responses) => { dispatch(handleChangesAction(responses)); @@ -159,6 +148,28 @@ function useLunatic( const pageTag = getPageTag(state.pager); const { isFirstPage, isLastPage } = isFirstLastPage(state.pager); + const { triggerFillers, isFilling } = useFillers({ + variables: state.variables, + fillers: source.fillers ?? [], + handleChanges, + fetchMock: mocks?.filler ?? null, + }); + + const goNextPage: LunaticState['goNextPage'] = useCallback( + function (payload = {}) { + dispatch(goNextPageAction(payload)); + triggerFillers(); + }, + [dispatch] + ); + + const goToPage: LunaticState['goToPage'] = useCallback( + function (payload) { + dispatch(goToPageAction(payload)); + }, + [dispatch] + ); + const components = fillComponents(getComponentsFromState(state), { handleChanges, preferences, @@ -174,6 +185,13 @@ function useLunatic( only, except, } = {}) => { + if (isFilling) { + return [ + { + componentType: 'FillerLoader', + }, + ]; + } if (only && except) { throw new Error( '"only" and "except" cannot be used together in getComponents()'