diff --git a/package.json b/package.json index ea1247195..283beb0db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inseefr/lunatic", - "version": "3.4.10", + "version": "3.5.0-rc.3", "description": "Library of questionnaire components", "repository": { "type": "git", diff --git a/src/index.ts b/src/index.ts index 2ed08d5b0..11c9cbea3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { Button } from './components/shared/Button/Button'; export { LunaticComponents } from './components/LunaticComponents'; export { useLunatic } from './use-lunatic/use-lunatic'; +export { getArticulation } from './utils/getArticulation'; export type { LunaticComponentDefinition, diff --git a/src/stories/behaviour/articulation/articulation.stories.tsx b/src/stories/behaviour/articulation/articulation.stories.tsx new file mode 100644 index 000000000..75d1dcb11 --- /dev/null +++ b/src/stories/behaviour/articulation/articulation.stories.tsx @@ -0,0 +1,108 @@ +import Orchestrator from '../../utils/orchestrator'; +import source from './roundabout.json'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useMemo, useState } from 'react'; +import { getArticulation } from '../../../utils/getArticulation'; + +type Source = Parameters[0]; +type Data = Parameters[1]; + +type Props = { + source: Source; + data: Data; +}; + +function StoryComponent({ source, data }: Props) { + const [page, setPage] = useState(null as null | string); + const gotoNav = () => setPage(null); + const { items } = useMemo( + () => getArticulation(source, data), + [source, data] + ); + + if (page) { + return ( +
+ {page} + + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/*/ @ts-ignore */} + +
+ ); + } + + const progressLabel = (n: number) => { + if (n === -1) { + return 'Commencer'; + } + if (n === 0) { + return 'Continuer'; + } + return 'Complété'; + }; + + return ( +
+

Articulation

+ + + + {items[0].cells.map((cell, k) => ( + + ))} + + + + + {items.map((item, k) => ( + + {item.cells.map((cell, kk) => ( + + ))} + + + ))} + +
+ {cell.label} + Actions
+ {cell.value} + + +
+
+ ); +} + +const meta: Meta = { + title: 'Behaviour/Articulation', + component: StoryComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + source: source as Source, + data: {}, + }, +}; diff --git a/src/stories/behaviour/articulation/roundabout.json b/src/stories/behaviour/articulation/roundabout.json new file mode 100644 index 000000000..e34a50895 --- /dev/null +++ b/src/stories/behaviour/articulation/roundabout.json @@ -0,0 +1,348 @@ +{ + "$schema": "../../../../lunatic-schema.json", + "maxPage": "4", + "articulation": { + "source": "roundabout", + "items": [ + { + "label": "Prénom", + "value": "PRENOMS" + }, + { + "label": "Sexe", + "value": "if SEXE = \"H\" then \"Homme\" else \"Femme\"" + }, + { + "label": "Age", + "value": "cast(AGE, string) || \" ans\"" + } + ] + }, + "components": [ + { + "id": "how", + "componentType": "InputNumber", + "mandatory": false, + "page": "1", + "min": 1, + "max": 10, + "decimals": 0, + "label": { + "value": "\"Combien de personnes vivent habituellement à votre adresse ?\"", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" }, + "response": { "name": "NB_HAB" } + }, + { + "id": "loop", + "componentType": "Loop", + "page": "2", + "depth": 1, + "paginatedLoop": false, + "conditionFilter": { "value": "true", "type": "VTL" }, + "loopDependencies": ["NHAB"], + "lines": { + "min": { "value": "NB_HAB", "type": "VTL" }, + "max": { "value": "NB_HAB", "type": "VTL" } + }, + "components": [ + { + "id": "prenom", + "componentType": "Input", + "mandatory": false, + "maxLength": 20, + "label": { + "value": "\"Prénom\"))", + "type": "VTL|MD" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "response": { "name": "PRENOMS" } + }, + { + "id": "sexe", + "componentType": "CheckboxOne", + "mandatory": false, + "maxLength": 20, + "label": { + "value": "\"Sexe\"", + "type": "VTL|MD" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "options": [ + { + "value": "H", + "label": { "value": "\"Homme\"", "type": "VTL|MD" } + }, + + { + "value": "F", + "label": { "value": "\"Femme\"", "type": "VTL|MD" } + } + ], + "response": { "name": "SEXE" } + }, + { + "id": "age", + "componentType": "InputNumber", + "maxLength": 3, + "label": { + "value": "\"Age\"", + "type": "VTL|MD" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "response": { "name": "AGE" } + } + ] + }, + { + "id": "roundabout", + "componentType": "Roundabout", + "page": "3", + "conditionFilter": { "value": "true", "type": "VTL" }, + "iterations": { "value": "NB_HAB", "type": "VTL" }, + "label": { "value": "\"Libellé du rondpoint\"", "type": "VTL" }, + "locked": true, + "progressVariable": "PROGRESS", + "item": { + "label": { + "value": "\"Questions de \" || PRENOMS", + "type": "VTL" + }, + "description": { + "value": "if AGE > 18 then \"Aller aux question destinées à \" || PRENOMS else PRENOMS || \" n'est pas majeur, il/elle n'a pas à répondre aux questions\"", + "type": "VTL" + }, + "disabled": { + "value": "AGE < 18", + "type": "VTL" + } + }, + "controls": [], + "components": [ + { + "id": "radio", + "componentType": "Radio", + "mandatory": false, + "page": "3.1", + "label": { + "value": "\"Connaissez-vous le recensement de la population ?\"", + "type": "VTL|MD" + }, + + "conditionFilter": { "value": "true", "type": "VTL" }, + + "options": [ + { "value": "1", "label": { "value": "\"oui\"", "type": "VTL|MD" } }, + + { "value": "2", "label": { "value": "\"non\"", "type": "VTL|MD" } } + ], + "response": { "name": "KNOWREC" } + }, + { + "id": "jsygk7m7", + "componentType": "Subsequence", + "page": "3.2", + "label": { + "value": "\"Deuxième page de questions pour \"|| PRENOMS", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" } + }, + { + "id": "sexe", + "componentType": "Radio", + "page": "3.2", + "label": { + "value": "\"Sexe\"", + "type": "VTL" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "options": [ + { + "value": "1", + "label": { "value": "\"Homme\"", "type": "VTL|MD" } + }, + { + "value": "2", + "label": { "value": "\"Femme\"", "type": "VTL|MD" } + } + ], + "response": { "name": "SEXE" } + }, + { + "id": "jsygk7m7", + "componentType": "Subsequence", + "page": "3.3", + "label": { + "value": "\"Troisième page de questions \" || PRENOMS", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" } + }, + { + "id": "kmno1n7m", + "componentType": "Input", + "maxLength": 30, + "page": "3.3", + "label": { + "value": "\"Dites quelque chose.\"))", + "type": "VTL|MD" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "response": { "name": "SOMETHING" } + } + ] + }, + { + "id": "seq", + "componentType": "Sequence", + "label": { + "value": "\"Merci !\"", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" }, + "page": "4" + } + ], + "variables": [ + { + "variableType": "COLLECTED", + "name": "NB_HAB", + "values": { + "PREVIOUS": null, + "COLLECTED": 2, + "FORCED": null, + "EDITED": null, + "INPUTTED": null + } + }, + { + "variableType": "COLLECTED", + "name": "SOMETHING", + "values": { + "PREVIOUS": [], + "COLLECTED": [], + "FORCED": [], + "EDITED": [], + "INPUTTED": [] + } + }, + { + "variableType": "COLLECTED", + "name": "SEXE", + "values": { + "PREVIOUS": null, + "COLLECTED": ["H", "F"], + "FORCED": null, + "EDITED": null, + "INPUTTED": null + } + }, + { + "variableType": "COLLECTED", + "name": "AGE", + "values": { + "PREVIOUS": null, + "COLLECTED": [24, 24], + "FORCED": null, + "EDITED": null, + "INPUTTED": null + } + }, + { + "variableType": "COLLECTED", + "name": "SEXE", + "values": { + "PREVIOUS": [], + "COLLECTED": [], + "FORCED": [], + "EDITED": [], + "INPUTTED": [] + } + }, + { + "variableType": "COLLECTED", + "name": "PRENOMS", + "values": { + "PREVIOUS": null, + "COLLECTED": ["Fanny", "Ines"], + "FORCED": null, + "EDITED": null, + "INPUTTED": null + } + }, + { + "variableType": "COLLECTED", + "name": "KNOWREC", + "values": { + "PREVIOUS": [], + "COLLECTED": [], + "FORCED": [], + "EDITED": [], + "INPUTTED": [] + } + }, + { + "variableType": "COLLECTED", + "name": "PROGRESS", + "values": { + "PREVIOUS": [], + "COLLECTED": [0, -1], + "FORCED": [], + "EDITED": [], + "INPUTTED": [] + } + }, + { + "variableType": "CALCULATED", + "name": "PRENOMREF", + "expression": { "value": "first_value(PRENOMS over())", "type": "VTL" }, + "bindingDependencies": ["PRENOMS"], + "inFilter": "true" + }, + { + "variableType": "CALCULATED", + "name": "COMPLETE", + "expression": { + "value": "not(isnull(KNOWREC)) and not(isnull(SEXE)) and not(isnull(SOMETHING))", + "type": "VTL" + }, + "bindingDependencies": ["KNOWREC", "SEXE", "SOMETHING"], + "shapeFrom": "PRENOMS", + "inFilter": "true" + }, + { + "variableType": "CALCULATED", + "name": "PARTIAL", + "expression": { + "value": "not(isnull(KNOWREC)) or not(isnull(SEXE)) or not(isnull(SOMETHING))", + "type": "VTL" + }, + "bindingDependencies": ["KNOWREC", "SEXE", "SOMETHING"], + "shapeFrom": "PRENOMS", + "inFilter": "true" + } + ], + "resizing": { + "NB_HAB": { + "size": "NB_HAB", + "variables": ["PRENOMS", "AGE", "SEXE", "SOMETHING", "DATNAIS"] + } + } +} diff --git a/src/stories/behaviour/performance/performance.stories.jsx b/src/stories/behaviour/performance/performance.stories.jsx index c5c15f88f..065bf88b1 100644 --- a/src/stories/behaviour/performance/performance.stories.jsx +++ b/src/stories/behaviour/performance/performance.stories.jsx @@ -1,8 +1,8 @@ import React from 'react'; -import Orchestrator from '../../utils/orchestrator'; import source from './source.json'; import { generateData } from '../../../tests/utils/lunatic'; import { times } from '../../../utils/array'; +import Orchestrator from '../../utils/orchestrator'; const stories = { title: 'Behaviour/Performance', diff --git a/src/stories/utils/SchemaValidator.jsx b/src/stories/utils/SchemaValidator.tsx similarity index 93% rename from src/stories/utils/SchemaValidator.jsx rename to src/stories/utils/SchemaValidator.tsx index cb51bd4f6..82a1b872e 100644 --- a/src/stories/utils/SchemaValidator.jsx +++ b/src/stories/utils/SchemaValidator.tsx @@ -2,7 +2,7 @@ import Ajv from 'ajv/dist/2020.js'; import { useMemo } from 'react'; import LunaticSchema from '../../../lunatic-schema.json'; -export function SchemaValidator({ source }) { +export function SchemaValidator({ source }: { source: any }) { const errors = useMemo(() => { const ajv = new Ajv({ removeAdditional: true, diff --git a/src/stories/utils/orchestrator.jsx b/src/stories/utils/orchestrator.tsx similarity index 91% rename from src/stories/utils/orchestrator.jsx rename to src/stories/utils/orchestrator.tsx index 92df20fd4..7bee6b72d 100644 --- a/src/stories/utils/orchestrator.jsx +++ b/src/stories/utils/orchestrator.tsx @@ -1,3 +1,5 @@ +/* eslint-disable */ +// @ts-nocheck import './custom-lunatic.scss'; import './orchestrator.scss'; @@ -7,19 +9,19 @@ import { LunaticComponents, ModalControls, useLunatic, -} from '../..'; -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +} from '../../index.js'; +import { memo, useCallback, useEffect, useState } from 'react'; -import { Logger } from '../../utils/logger'; -import { Overview } from './overview'; +import { Logger } from '../../utils/logger.js'; +import { Overview } from './overview.js'; import { SchemaValidator } from './SchemaValidator.jsx'; const Input = components.Input; -function DevOptions({ goToPage, getData }) { +function DevOptions({ goToPage, getData }: { goToPage: any; getData: any }) { const [toPage, setToPage] = useState(1); - function handleChange(_, value) { + function handleChange(_, value: any) { setToPage(value); } @@ -33,6 +35,8 @@ function DevOptions({ goToPage, getData }) { = {}; // Starting data for the form (merged with data.json or injected data) const initialValues: Record = {}; diff --git a/src/use-lunatic/reducer/reducerInitializer.tsx b/src/use-lunatic/reducer/reducerInitializer.tsx index 13c9cca18..773eeb364 100644 --- a/src/use-lunatic/reducer/reducerInitializer.tsx +++ b/src/use-lunatic/reducer/reducerInitializer.tsx @@ -38,6 +38,8 @@ const baseState = { options: { disableFilters: false }, } satisfies LunaticReducerState; +const onChange = { current: () => {} }; + export function reducerInitializer({ source, data, @@ -47,8 +49,8 @@ export function reducerInitializer({ withOverview = false, disableFilters = false, getReferentiel, - onVariableChange, - logger, + onVariableChange = onChange, + logger = console.error, }: { source: LunaticSource; data: LunaticData; @@ -58,8 +60,8 @@ export function reducerInitializer({ withOverview?: LunaticOptions['withOverview']; disableFilters?: LunaticOptions['disableFilters']; getReferentiel?: LunaticOptions['getReferentiel']; - onVariableChange: RefObject; - logger: LunaticLogger; + onVariableChange?: RefObject; + logger?: LunaticLogger; }): LunaticReducerState { const variables = LunaticVariablesStore.makeFromSource( source, @@ -110,11 +112,13 @@ export function reducerInitializer({ } return result as any; } catch (e) { - // If there is an error interpreting a variable, return the raw expression - logger({ - type: 'ERROR', - error: e as Error, - }); + if (logger) { + // If there is an error interpreting a variable, return the raw expression + logger({ + type: 'ERROR', + error: e as Error, + }); + } return expressionString; } }; @@ -131,9 +135,9 @@ export function reducerInitializer({ const pager = { page: initialPager?.page ?? 1, maxPage: source.maxPage ? parseInt(source.maxPage, 10) : 1, - subPage: undefined, + subPage: initialPager?.subPage, nbSubPages: undefined, - iteration: undefined, + iteration: initialPager?.iteration, nbIterations: undefined, lastReachedPage: lastReachedPage ?? initialPage, }; @@ -143,7 +147,7 @@ export function reducerInitializer({ pager, previousPager: pager, pages, - isInLoop: false, + isInLoop: pager.subPage !== undefined, updatedAt: Date.now(), overview: withOverview ? buildOverview(source) : [], updateBindings, @@ -161,22 +165,29 @@ function fillPagerForLoop(state: LunaticReducerState): LunaticReducerState { return state; } const { isLoop, subPages, iterations, loopDependencies } = pages[pager.page]; - if (!isLoop) { - return state; + + if ( + // For loop, jump at the first page + isLoop || + // For roundabout, jump at the desired iteration / subpage (only if defined) + (pager?.iteration !== undefined && subPages) + ) { + return { + ...state, + isInLoop: true, + pager: { + ...pager, + subPage: pager?.subPage ?? 1, + nbSubPages: (subPages ?? []).length, + iteration: pager?.iteration ?? 0, + nbIterations: forceInt( + state.executeExpression(iterations, { + deps: loopDependencies, + }) + ), + }, + }; } - return { - ...state, - isInLoop: true, - pager: { - ...pager, - subPage: pager?.subPage ?? 0, - nbSubPages: (subPages ?? []).length, - iteration: pager?.iteration ?? 0, - nbIterations: forceInt( - state.executeExpression(iterations, { - deps: loopDependencies, - }) - ), - }, - }; + + return state; } diff --git a/src/utils/getArticulation.ts b/src/utils/getArticulation.ts new file mode 100644 index 000000000..dbefba3cb --- /dev/null +++ b/src/utils/getArticulation.ts @@ -0,0 +1,117 @@ +import type { + ComponentDefinition, + ComponentRoundaboutDefinition, + LunaticSource, +} from '../type.source'; +import type { LunaticData, PageTag } from '../use-lunatic/type'; +import { reducerInitializer } from '../use-lunatic/reducer/reducerInitializer'; +import { type ReactNode } from 'react'; +import { times } from './array'; +import { forceInt } from './number'; + +type ArticulationItem = { + label: string; + value: string; +}; + +type Articulation = { + source: string; + items: ArticulationItem[]; +}; + +type Item = { + cells: { + label: string; + value: ReactNode; + }[]; + progress: number; // -1: not completed, 0: started, 1: finished + page: PageTag; +}; + +/** + * Retrieve the articulation state + * + * ## Why this hook + * + * The goal of this hook is to provide insights about a roundabout using extra information inserted in the JSON source + * provided to Lunatic. + * + * For instance + * + * ``` + * "articulation": { + * "source": "roundabout", + * "items": [ + * { + * "label": "Prénom", + * "value": "PRENOMS" + * }, + * { + * "label": "Sexe", + * "value": "if SEXE = \"H\" then \"Homme\" else \"Femme\"" + * }, + * { + * "label": "Age", + * "value": "cast(AGE, string) || \" ans\"" + * } + * ] + * }, + * ``` + * + * - source is the ID of the roundabout component + * - items define the field to extract from the roundabout data + */ +export function getArticulation( + source: LunaticSource & { articulation: Articulation }, + data: LunaticData +): { items: Item[] } { + const roundabout = findComponentById( + source.components, + source.articulation.source + ); + const { variables } = reducerInitializer({ source, data }); + const iterations = forceInt( + variables.run(roundabout?.iterations.value ?? '0') + ); + + const rows = times(iterations, (k) => + source.articulation.items.map((item) => ({ + label: item.label, + value: variables.run(item.value, { iteration: [k] }) as ReactNode, + })) + ); + + if (!roundabout) { + return { + items: [], + }; + } + + return { + items: rows.map((row, k) => ({ + cells: row, + progress: forceInt(variables.get(roundabout.progressVariable, [k]) ?? -1), + page: (roundabout.page + ? `${roundabout.page}.1#${k + 1}` + : '1') as PageTag, + })), + }; +} + +function findComponentById( + components: ComponentDefinition[], + id: string +): (ComponentRoundaboutDefinition & { page?: string }) | null { + for (const c of components) { + if ('id' in c && c.id === id && c.componentType === 'Roundabout') { + return c; + } + if ('components' in c) { + const child = findComponentById(c.components, id); + if (child) { + return child; + } + } + } + return null; +}