diff --git a/lunatic-schema.json b/lunatic-schema.json index ee55973c52..d8ab6df8e6 100644 --- a/lunatic-schema.json +++ b/lunatic-schema.json @@ -39,7 +39,29 @@ "additionalProperties": { "type": "object", "additionalProperties": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "expression": { + "type": "string" + }, + "shapeFrom": { + "type": "string" + }, + "isAggregatorUsed": { + "type": "boolean" + } + }, + "required": ["expression", "isAggregatorUsed"] + } + } + ] } } }, @@ -364,6 +386,9 @@ }, { "$ref": "#/$defs/ComponentAccordion" + }, + { + "$ref": "#/$defs/ComponentRecapDefinition" } ] }, @@ -1024,6 +1049,39 @@ } ] }, + "ComponentRecapDefinition": { + "type": "object", + "properties": { + "componentType": { + "type": "string", + "const": "Recap" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "pairwise": { + "type": "string" + }, + "value": { + "$ref": "#/$defs/VTLExpression" + } + }, + "required": ["label", "value"] + } + } + }, + "required": ["componentType", "fields"], + "allOf": [ + { + "$ref": "#/$defs/ComponentDefinitionBase" + } + ] + }, "ComponentText": { "type": "object", "properties": { diff --git a/src/components/Recap/Recap.tsx b/src/components/Recap/Recap.tsx new file mode 100644 index 0000000000..6218a38890 --- /dev/null +++ b/src/components/Recap/Recap.tsx @@ -0,0 +1,37 @@ +import { slottableComponent } from '../shared/HOC/slottableComponent'; +import type { LunaticComponentProps } from '../type'; + +/** + * Display a page that list collected data + */ +export const Recap = slottableComponent>( + 'Recap', + function Recap({ label, fields }) { + return ( +
+

{label}

+ +
+ ); + } +); + +function RecapList({ value }: { value: string | string[] }) { + if (Array.isArray(value)) { + return ( + + ); + } + return value; +} diff --git a/src/components/library.ts b/src/components/library.ts index 632f5d5a0b..e736d9a36d 100644 --- a/src/components/library.ts +++ b/src/components/library.ts @@ -24,6 +24,7 @@ import { PairwiseLinks } from './PairwiseLinks/PairwiseLinks'; import { CheckboxOne } from './CheckboxOne/CheckboxOne'; import { Suggester } from './Suggester/Suggester'; import { Summary } from './Summary/Summary'; +import { Recap } from './Recap/Recap'; // List of all the "componentType" export const library = { @@ -51,6 +52,7 @@ export const library = { Suggester: Suggester, Summary: Summary, Accordion: Accordion, + Recap: Recap, } satisfies { [Property in LunaticComponentType]: ComponentType< LunaticComponentProps diff --git a/src/components/shared/HOC/slottableComponent.tsx b/src/components/shared/HOC/slottableComponent.tsx index 74fc11012b..df679d8778 100644 --- a/src/components/shared/HOC/slottableComponent.tsx +++ b/src/components/shared/HOC/slottableComponent.tsx @@ -43,6 +43,7 @@ import type { SummaryResponses, SummaryTitle } from '../../Summary/Summary'; import type { LunaticComponentProps } from '../../type'; import type { MarkdownLink } from '../MDLabel/MarkdownLink'; import type { Accordion } from '../../Accordion/Accordion'; +import type { Recap } from '../../Recap/Recap'; /** * Contain the type of every customizable components. @@ -109,6 +110,7 @@ export type LunaticSlotComponents = { >; MarkdownLink: typeof MarkdownLink; Accordion: typeof Accordion; + Recap: typeof Recap; }; const empty = {} as Partial | undefined; diff --git a/src/components/type.ts b/src/components/type.ts index c4df20ee22..f863b32a26 100644 --- a/src/components/type.ts +++ b/src/components/type.ts @@ -312,6 +312,15 @@ export type ComponentPropsByType = { iterations?: VtlExpression; }>; }; + Recap: LunaticBaseProps & + LunaticExtraProps & { + componentType?: 'Recap'; + label: string; + fields: { + label: string; + value: string; + }[]; + }; }; export type LunaticComponentType = keyof ComponentPropsByType; diff --git a/src/stories/recap/recap.stories.jsx b/src/stories/recap/recap.stories.jsx new file mode 100644 index 0000000000..8caae610c9 --- /dev/null +++ b/src/stories/recap/recap.stories.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Orchestrator from '../utils/orchestrator'; +import source from './source.json'; +import defaultArgTypes from '../utils/default-arg-types'; +import { objectToData } from '../utils/data'; + +const stories = { + title: 'Components/Recap', + component: Orchestrator, + argTypes: defaultArgTypes, +}; + +export default stories; + +const Template = (args) => ; +export const Default = Template.bind({}); +Default.args = { + id: 'recap', + source: source, + data: objectToData({ + PRENOM: ['Maman', 'Papa', 'Fils'], + AGE: [22, 25, 12], + LINKS: [ + [null, '1', '3'], + ['1', null, '3'], + ['2', '2'], + ], + }), + pagination: true, + initialPage: '4', +}; diff --git a/src/stories/recap/source.json b/src/stories/recap/source.json new file mode 100644 index 0000000000..5501d45fca --- /dev/null +++ b/src/stories/recap/source.json @@ -0,0 +1,325 @@ +{ + "$schema": "../../../lunatic-schema.json", + "maxPage": "5", + "components": [ + { + "id": "seq", + "componentType": "Sequence", + "label": { + "value": "\"Description des individus de votre logement\"", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" }, + "page": "1" + }, + { + "id": "loop-prenom", + "componentType": "RosterForLoop", + "label": { "value": "\"Ajouter un individu\"", "type": "VTL|MD" }, + "conditionFilter": { "value": "true", "type": "VTL" }, + "bindingDependencies": ["PRENOM"], + + "lines": { + "min": { "value": "1", "type": "VTL" }, + "max": { "value": "10", "type": "VTL" } + }, + "page": "1", + "components": [ + { + "componentType": "Input", + "label": { "value": "\"Prénom\"", "type": "VTL|MD" }, + "conditionFilter": { "value": "true", "type": "VTL" }, + "maxLength": 30, + "bindingDependencies": ["PRENOM"], + "id": "prenom", + "response": { + "name": "PRENOM" + } + } + ] + }, + { + "id": "age-loop", + "componentType": "Loop", + "paginatedLoop": true, + "iterations": { "value": "count(PRENOM)", "type": "VTL" }, + "page": "2", + "maxPage": "1", + "conditionFilter": { "value": "true", "type": "VTL" }, + "loopDependencies": ["PRENOM"], + "components": [ + { + "id": "age-quest", + "label": { "value": "\"Âge de \" || PRENOM", "type": "VTL|MD" }, + "conditionFilter": { "type": "VTL", "value": "true" }, + "componentType": "InputNumber", + "page": "2.1", + "response": { "name": "AGE" } + } + ] + }, + { + "id": "pairwise-links", + "componentType": "PairwiseLinks", + "conditionFilter": { "value": "true", "type": "VTL" }, + "xAxisIterations": { "value": "count(PRENOM)", "type": "VTL" }, + "yAxisIterations": { "value": "count(PRENOM)", "type": "VTL" }, + "page": "3", + "symLinks": { + "LINKS": { + "1": "1", + "2": "3", + "3": "2", + "4": "4", + "5": "6", + "6": "5", + "7": "8", + "8": "7", + "9": "10", + "10": "9", + "11": "13", + "12": "12", + "13": "11", + "14": null, + "15": "15", + "16": "16", + "17": "17", + "18": "18" + } + }, + "components": [ + { + "componentType": "Dropdown", + "id": "dropdown-1", + "conditionFilter": { "value": "xAxis <> yAxis", "type": "VTL" }, + "label": { + "value": "\"Qui est \" || yAxis || \" pour \" || xAxis || \" ?\"", + "type": "VTL|MD" + }, + "response": { + "name": "LINKS" + }, + "options": [ + { + "value": "1", + "label": { + "value": "\"Son conjoint, sa conjointe\"", + "type": "VTL" + } + }, + { + "value": "2", + "label": { "value": "\"Sa mère, son père\"", "type": "VTL" } + }, + { + "value": "3", + "label": { "value": "\"Sa fille, son fils\"", "type": "VTL" } + }, + { + "value": "4", + "label": { + "value": "\"Sa soeur, son frère (y compris demi et quasi)\"", + "type": "VTL" + } + }, + { + "value": "5", + "label": { + "value": "\"Sa belle-mère, son beau-père (conjoint.e d'un des parents)\"", + "type": "VTL" + } + }, + { + "value": "6", + "label": { + "value": "\"L'enfant du conjoint (belle-fille, beau-fils)\"", + "type": "VTL" + } + }, + { + "value": "7", + "label": { + "value": "\"Sa belle-mère, son beau-père (parent du conjoint)\"", + "type": "VTL" + } + }, + { + "value": "8", + "label": { + "value": "\"Sa belle-fille, son beau-fils (conjoint.e d'un enfant)\"", + "type": "VTL" + } + }, + { + "value": "9", + "label": { + "value": "\"Sa grand-mère, son grand-père\"", + "type": "VTL" + } + }, + { + "value": "10", + "label": { + "value": "\"Sa petite-fille, petit-fils\"", + "type": "VTL" + } + }, + { + "value": "11", + "label": { "value": "\"Sa tante, son oncle\"", "type": "VTL" } + }, + { + "value": "12", + "label": { "value": "\"Sa cousine, son cousin\"", "type": "VTL" } + }, + { + "value": "13", + "label": { "value": "\"Sa nièce, son neveu\"", "type": "VTL" } + }, + { + "value": "14", + "label": { + "value": "\"Un enfant placé en famille d'accueil\"", + "type": "VTL" + } + }, + { + "value": "15", + "label": { + "value": "\"Sa belle-soeur, son beau-frère\"", + "type": "VTL" + } + }, + { + "value": "16", + "label": { "value": "\"Un autre lien familial\"", "type": "VTL" } + }, + { + "value": "17", + "label": { + "value": "\"Un colocataire, sous-locataire\"", + "type": "VTL" + } + }, + { + "value": "18", + "label": { + "value": "\"Autre lien (employé de maison, salarié logé, jeune au pair …)\"", + "type": "VTL" + } + } + ] + } + ] + }, + { + "componentType": "Loop", + "paginatedLoop": false, + "iterations": { "value": "count(PRENOM)", "type": "VTL" }, + "conditionFilter": { "value": "true", "type": "VTL" }, + "label": { + "value": "\"Récapitulatif\"", + "type": "VTL|MD" + }, + "id": "recap1", + "page": "4", + "components": [ + { + "componentType": "Recap", + "label": { + "value": "\"Information sur \" || PRENOM", + "type": "VTL|MD" + }, + "fields": [ + { + "label": "Prénom: ", + "value": { + "value": "PRENOM", + "type": "VTL|MD" + } + }, + { + "label": "Age: ", + "value": { + "value": "AGE", + "type": "VTL|MD" + } + }, + { + "label": "Relations: ", + "pairwise": "LINKS", + "value": { + "value": "PRENOM || \" est \" || LINKS", + "type": "VTL|MD" + } + } + ], + "conditionFilter": { "value": "true", "type": "VTL" }, + "maxLength": 30, + "id": "recap2" + } + ] + }, + { + "componentType": "Sequence", + "label": { + "type": "VTL", + "value": "\"END\"" + }, + "page": "5", + "id": "end" + } + ], + "variables": [ + { + "variableType": "COLLECTED", + "name": "PRENOM", + "values": { + "COLLECTED": [null] + } + }, + { + "variableType": "COLLECTED", + "name": "AGE", + "values": { + "COLLECTED": [null] + } + }, + { + "variableType": "COLLECTED", + "name": "LINKS", + "values": { + "COLLECTED": [[null]] + } + }, + { + "variableType": "COLLECTED", + "name": "OTHER", + "values": { + "COLLECTED": [[null]] + } + }, + { + "variableType": "CALCULATED", + "name": "xAxis", + "expression": { "value": "PRENOM", "type": "VTL" }, + "bindingDependencies": ["PRENOM"], + "shapeFrom": "PRENOM", + "inFilter": "false" + }, + { + "variableType": "CALCULATED", + "name": "yAxis", + "expression": { "value": "PRENOM", "type": "VTL" }, + "bindingDependencies": ["PRENOM"], + "shapeFrom": "PRENOM", + "inFilter": "false" + } + ], + "resizing": { + "PRENOM": { + "sizeForLinksVariables": ["count(PRENOM)", "count(PRENOM)"], + "linksVariables": ["LINKS"] + } + } +} diff --git a/src/stories/utils/data.js b/src/stories/utils/data.js new file mode 100644 index 0000000000..b8bb166f00 --- /dev/null +++ b/src/stories/utils/data.js @@ -0,0 +1,11 @@ +/** + * Generates lunatic shaped data from an object + */ +export function objectToData(obj) { + const items = Object.entries(obj).map(([key, value]) => { + return [key, { COLLECTED: value }]; + }); + return { + COLLECTED: Object.fromEntries(items), + }; +} diff --git a/src/type.source.ts b/src/type.source.ts index d8d02f305d..be5fc856d5 100644 --- a/src/type.source.ts +++ b/src/type.source.ts @@ -33,7 +33,8 @@ export type ComponentDefinition = | ComponentPairWiseLinksDefinition | ComponentSummaryDefinition | ComponentText - | ComponentAccordion; + | ComponentAccordion + | ComponentRecapDefinition; export type ComponentInputDefinition = ComponentDefinitionBaseWithResponse & { componentType: 'Input' | 'Textarea'; maxLength?: number; @@ -233,6 +234,14 @@ export type ComponentSummaryDefinition = ComponentDefinitionBase & { }[]; }[]; }; +export type ComponentRecapDefinition = ComponentDefinitionBase & { + componentType: 'Recap'; + fields: { + label: string; + pairwise?: string; + value: VTLExpression; + }[]; +}; export type Variable = | { variableType: 'EXTERNAL'; @@ -257,7 +266,7 @@ export type VariableValue = VariableScalarValue | unknown[]; export type VariableScalarValue = string | number | null; /** - * Representation of a Lunatic questionnaire. + * Representation of a Lunatic survey unit in the Lunatic Model. */ export type LunaticSource = { label?: VTLExpression; diff --git a/src/use-lunatic/commons/component.ts b/src/use-lunatic/commons/component.ts index 269c28a48a..267e549bff 100644 --- a/src/use-lunatic/commons/component.ts +++ b/src/use-lunatic/commons/component.ts @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import type { LunaticComponentDefinition } from '../type'; +import type { ComponentDefinition } from '../../type.source'; export function hasResponse( component: unknown @@ -45,3 +46,24 @@ export function hasComponentType( typeof component.componentType === 'string' ); } + +/** + * Find a component recursively using a specific condition + */ +export function findComponent( + components: ComponentDefinition[], + cb: (component: ComponentDefinition) => boolean +): ComponentDefinition | null { + for (const component of components) { + if (cb(component)) { + return component; + } + if ('components' in component) { + const child = findComponent(component.components, cb); + if (child) { + return child; + } + } + } + return null; +} diff --git a/src/use-lunatic/commons/fill-components/fill-component-expressions.ts b/src/use-lunatic/commons/fill-components/fill-component-expressions.ts index 88d3490572..924ec8ae8b 100644 --- a/src/use-lunatic/commons/fill-components/fill-component-expressions.ts +++ b/src/use-lunatic/commons/fill-components/fill-component-expressions.ts @@ -84,6 +84,7 @@ type UntranslatedProperties = | 'expressions' | 'sections' | 'body' + | 'fields' | 'item' | 'controls' | 'conditionFilter' diff --git a/src/use-lunatic/commons/fill-components/fill-components.ts b/src/use-lunatic/commons/fill-components/fill-components.ts index 6ae99531a7..46dbea91ae 100644 --- a/src/use-lunatic/commons/fill-components/fill-components.ts +++ b/src/use-lunatic/commons/fill-components/fill-components.ts @@ -27,6 +27,7 @@ type FillComponentArgs = { pager: LunaticReducerState['pager']; variables: LunaticReducerState['variables']; logger: LunaticLogger; + pages: LunaticReducerState['pages']; }; /** diff --git a/src/use-lunatic/props/getComponentTypeProps.ts b/src/use-lunatic/props/getComponentTypeProps.ts index ed4cc65a48..0fc5804b18 100644 --- a/src/use-lunatic/props/getComponentTypeProps.ts +++ b/src/use-lunatic/props/getComponentTypeProps.ts @@ -9,6 +9,7 @@ import { fillComponents, } from '../commons/fill-components/fill-components'; import { times } from '../../utils/array'; +import { getRecapProps } from './getPairwiseProps'; type State = Parameters[1]; @@ -224,6 +225,8 @@ export function getComponentTypeProps( return getTableProps(component, state); case 'Suggester': return getSuggesterProps(component, state); + case 'Recap': + return getRecapProps(component, state); default: return {}; } diff --git a/src/use-lunatic/props/getPairwiseProps.ts b/src/use-lunatic/props/getPairwiseProps.ts new file mode 100644 index 0000000000..9ad1f1e500 --- /dev/null +++ b/src/use-lunatic/props/getPairwiseProps.ts @@ -0,0 +1,89 @@ +import type { DeepTranslateExpression } from '../commons/fill-components/fill-component-expressions'; +import type { LunaticComponentDefinition } from '../type'; +import type { ItemOf } from '../../type.utils'; +import type { fillComponent } from '../commons/fill-components/fill-components'; +import { findComponent } from '../commons/component'; +import type { VTLExpression } from '../../type.source'; + +type State = Parameters[1]; + +/** + * Add specific props for the Recap Component + * - fields expression is transformed + */ +export function getRecapProps( + component: DeepTranslateExpression>, + state: State +) { + const getValue = (field: ItemOf) => { + if (field.pairwise) { + return { + label: field.label, + value: extractPairwiseFrom(field.pairwise, field.value, state), + }; + } + return { + label: field.label, + value: state.executeExpression(field.value, { + iteration: state.pager.iteration, + }), + }; + }; + + return { + fields: component.fields.map(getValue), + }; +} + +/** + * Compute the pairwise data + * + * To handle this logic we first need to find the corresponding dropdown (to get the labels) + * Then, get the value (array of number) and compute labels + */ +function extractPairwiseFrom( + name: string, + expression: VTLExpression, + state: State +): string[] | string { + // Look for the pairwise component linked to the variable + const dropdownComponent = findComponent( + Object.values(state.pages).flatMap((p) => p.components), + (c) => { + return 'response' in c && c.response.name === name; + } + ); + + if (!dropdownComponent || dropdownComponent.componentType !== 'Dropdown') { + return 'Cannot resolve pairwise data'; + } + + const values = state.executeExpression( + { type: 'VTL', value: name }, + { iteration: state.pager.iteration } + ); + + if (!Array.isArray(values)) { + return 'Cannot resolve pairwise data'; + } + + return values + .map((v, k) => { + const option = dropdownComponent.options.find((o) => o.value === v); + if (!option) { + return null; + } + + return state.executeExpression( + { + ...expression, + value: expression.value.replaceAll( + 'LINKS', + `"${state.executeExpression(option.label, { iteration: state.pager.iteration })}"` + ), + }, + { iteration: k } + ); + }) + .filter((v) => v !== null); +}