From 8f192531d89e6b169eaa8b7f1a3b0cbd63258493 Mon Sep 17 00:00:00 2001 From: Jonathan <395137+Grafikart@users.noreply.github.com> Date: Thu, 5 Oct 2023 16:51:50 +0200 Subject: [PATCH] Track changes (#706) --- src/hooks/use-track-changes.ts | 59 +++++++++ .../variables/get-questionnaire-data.ts | 27 +++- .../variables/lunatic-variables-store.ts | 17 +-- src/use-lunatic/type.ts | 11 +- src/use-lunatic/use-lunatic.test.ts | 115 +++++++++++++++++- src/use-lunatic/use-lunatic.ts | 20 ++- 6 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 src/hooks/use-track-changes.ts diff --git a/src/hooks/use-track-changes.ts b/src/hooks/use-track-changes.ts new file mode 100644 index 000000000..67a887c62 --- /dev/null +++ b/src/hooks/use-track-changes.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useRefSync } from './use-ref-sync'; +import type { + LunaticVariablesStore, + LunaticVariablesStoreEvent, +} from '../use-lunatic/commons/variables/lunatic-variables-store'; +import type { LunaticData } from '../use-lunatic/type'; + +/** + * Allow tracking changed while interacting with Lunatic forms + */ +export function useTrackChanges( + enabled: boolean, + store: LunaticVariablesStore, + getData: (names: string[]) => LunaticData +) { + // Saves the list of changed variable + const changedVariables = useRef(new Set()); + // Use ref to avoid dependencies in useCallback + const enabledRef = useRefSync(enabled); + const getDataRef = useRefSync(getData); + + useEffect(() => { + if (!enabled || !store) { + return; + } + const handleChange = (e: LunaticVariablesStoreEvent<'change'>) => { + changedVariables.current.add(e.detail.name); + }; + store.on('change', handleChange); + return () => store.off('change', handleChange); + }, [enabled, store]); + + // Reset list of changed variables + const resetChangedData = useCallback(() => { + changedVariables.current.clear(); + }, [changedVariables]); + + const getChangedData = useCallback( + (reset: boolean = false) => { + if (!enabledRef.current) { + throw new Error( + 'getChangedData() cannot be used without enabling tracking mode, add "trackChanges: true" to useLunatic options' + ); + } + const data = getDataRef.current(Array.from(changedVariables.current)); + if (reset) { + resetChangedData(); + } + return data; + }, + [enabledRef, getDataRef, resetChangedData] + ); + + return { + getChangedData, + resetChangedData, + }; +} diff --git a/src/use-lunatic/commons/variables/get-questionnaire-data.ts b/src/use-lunatic/commons/variables/get-questionnaire-data.ts index c87519aee..23cdd2375 100644 --- a/src/use-lunatic/commons/variables/get-questionnaire-data.ts +++ b/src/use-lunatic/commons/variables/get-questionnaire-data.ts @@ -1,11 +1,13 @@ import type { LunaticVariablesStore } from './lunatic-variables-store'; import type { LunaticSource } from '../../type-source'; +import type { LunaticData } from '../../type'; export function getQuestionnaireData( store: LunaticVariablesStore, variables: LunaticSource['variables'], - withCalculated: boolean = false -) { + withCalculated: boolean = false, + variableNames?: string[] +): LunaticData { const result = { EXTERNAL: {} as Record, CALCULATED: {} as Record, @@ -22,7 +24,26 @@ export function getQuestionnaireData( }; if (!variables) { - return {}; + return result; + } + + // Only return requested variables + if (variableNames) { + return { + ...result, + COLLECTED: Object.fromEntries( + variableNames.map((name) => [ + name, + { + EDITED: null, + FORCED: null, + INPUTED: null, + PREVIOUS: null, + COLLECTED: store.get(name), + }, + ]) + ), + }; } for (const variable of variables) { diff --git a/src/use-lunatic/commons/variables/lunatic-variables-store.ts b/src/use-lunatic/commons/variables/lunatic-variables-store.ts index fb63d2d7c..922c0a946 100644 --- a/src/use-lunatic/commons/variables/lunatic-variables-store.ts +++ b/src/use-lunatic/commons/variables/lunatic-variables-store.ts @@ -13,7 +13,7 @@ import { isNumber } from '../../../utils/number'; let interpretCount = 0; type IterationLevel = number[]; -type Events = { +type EventArgs = { change: { // Name of the changed variable name: string; @@ -23,6 +23,9 @@ type Events = { iteration?: IterationLevel | undefined; }; }; +export type LunaticVariablesStoreEvent = { + detail: EventArgs[T]; +}; export class LunaticVariablesStore { private dictionary = new Map(); @@ -84,7 +87,7 @@ export class LunaticVariablesStore { public set( name: string, value: unknown, - args: Pick = {} + args: Pick = {} ): LunaticVariable { if (!this.dictionary.has(name)) { this.dictionary.set( @@ -102,7 +105,7 @@ export class LunaticVariablesStore { ...args, name: name, value: value, - } satisfies Events['change'], + } satisfies EventArgs['change'], }) ); } @@ -149,9 +152,9 @@ export class LunaticVariablesStore { /** * Bind event listeners */ - public on( + public on( eventName: T, - cb: (e: CustomEvent) => void + cb: (e: CustomEvent) => void ): void { this.eventTarget.addEventListener(eventName, cb as EventListener); } @@ -159,9 +162,9 @@ export class LunaticVariablesStore { /** * Detach a listener */ - public off( + public off( eventName: T, - cb: (e: CustomEvent) => void + cb: (e: CustomEvent) => void ): void { this.eventTarget.removeEventListener(eventName, cb as EventListener); } diff --git a/src/use-lunatic/type.ts b/src/use-lunatic/type.ts index a96695ac7..58a94313a 100644 --- a/src/use-lunatic/type.ts +++ b/src/use-lunatic/type.ts @@ -15,11 +15,12 @@ export type LunaticControl = ControlType; export type VTLBindings = { [variableName: string]: unknown }; -export type LunaticData = Partial< - Record, Record> & { - COLLECTED: Record; - } ->; +export type LunaticData = Record< + Exclude, + Record +> & { + COLLECTED: Record; +}; export type LunaticValues = { [variableName: string]: unknown; diff --git a/src/use-lunatic/use-lunatic.test.ts b/src/use-lunatic/use-lunatic.test.ts index b6af9bedc..4f35d0d22 100644 --- a/src/use-lunatic/use-lunatic.test.ts +++ b/src/use-lunatic/use-lunatic.test.ts @@ -1,10 +1,10 @@ import { act, renderHook } from '@testing-library/react-hooks'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, beforeEach } from 'vitest'; import useLunatic from './use-lunatic'; import sourceWithoutHierarchy from '../stories/overview/source.json'; import sourceLogement from '../stories/questionnaires/logement/source.json'; -import sourceSimpsons from '../stories/questionnaires/simpsons/source.json'; +import sourceSimpsons from '../stories/questionnaires2023/simpsons/source.json'; import sourceComponentSet from '../stories/component-set/source.json'; import type { LunaticData } from './type'; import { type FilledLunaticComponentProps } from './commons/fill-components/fill-components'; @@ -40,7 +40,7 @@ describe('use-lunatic()', () => { const { result } = renderHook(() => useLunatic(...defaultParams)); expect(result.current.pager.page).toBe('1'); expect(result.current.pager.lastReachedPage).toBe('1'); - expect(result.current.pager.maxPage).toBe('39'); + expect(result.current.pager.maxPage).toBe('41'); }); it('should go to the next page correcly', () => { const { result } = renderHook(() => useLunatic(...defaultParams)); @@ -192,4 +192,113 @@ describe('use-lunatic()', () => { }); }); }); + + describe('getData()', () => { + let hookRef: { current: ReturnType }; + beforeEach(() => { + const { result } = renderHook(() => + useLunatic(sourceSimpsons as any, undefined, {}) + ); + act(() => { + result.current.onChange({ name: 'COMMENT' }, 'Mon commentaire'); + result.current.onChange({ name: 'READY' }, true); + }); + hookRef = result; + }); + it('should return every value', () => { + const data = hookRef.current.getData(false); + expect(data).toMatchObject({ + COLLECTED: { + COMMENT: { + COLLECTED: 'Mon commentaire', + }, + READY: { + COLLECTED: true, + }, + }, + }); + expect(Object.keys(data.COLLECTED)).toHaveLength(109); + expect(Object.keys(data.CALCULATED)).toHaveLength(0); + }); + it('should return calculated values', () => { + const data = hookRef.current.getData(true); + expect(Object.keys(data.COLLECTED)).toHaveLength(109); + expect(Object.keys(data.CALCULATED)).toHaveLength(33); + }); + it('should only return requested variables', () => { + const data = hookRef.current.getData(false, ['COMMENT']); + expect(data).toMatchObject({ + COLLECTED: { + COMMENT: { + COLLECTED: 'Mon commentaire', + }, + }, + }); + expect(Object.keys(data.COLLECTED)).toHaveLength(1); + }); + }); + + describe('getChangedData()', () => { + let hookRef: { current: ReturnType }; + beforeEach(() => { + const { result } = renderHook(() => + useLunatic(sourceSimpsons as any, undefined, { trackChanges: true }) + ); + hookRef = result; + }); + it('should return every value', () => { + const data = hookRef.current.getChangedData(); + expect(data.COLLECTED).toEqual({}); + }); + it('should return changes since the last update', () => { + act(() => { + hookRef.current.onChange({ name: 'COMMENT' }, 'Mon commentaire'); + hookRef.current.onChange({ name: 'READY' }, true); + }); + expect(hookRef.current.getChangedData()).toMatchObject({ + COLLECTED: { + COMMENT: { + COLLECTED: 'Mon commentaire', + }, + READY: { + COLLECTED: true, + }, + }, + }); + }); + it('should reset changes with true parameter', () => { + act(() => { + hookRef.current.onChange({ name: 'COMMENT' }, 'Mon commentaire'); + hookRef.current.onChange({ name: 'READY' }, true); + }); + const data = hookRef.current.getChangedData(true); + expect(data).toMatchObject({ + COLLECTED: { + COMMENT: { + COLLECTED: 'Mon commentaire', + }, + READY: { + COLLECTED: true, + }, + }, + }); + expect(hookRef.current.getChangedData().COLLECTED).toEqual({}); + }); + it('should reset changes with resetChanges()', () => { + act(() => { + hookRef.current.onChange({ name: 'COMMENT' }, 'Mon commentaire'); + hookRef.current.onChange({ name: 'READY' }, true); + }); + hookRef.current.resetChangedData(); + expect(hookRef.current.getChangedData().COLLECTED).toEqual({}); + act(() => { + hookRef.current.onChange({ name: 'READY' }, false); + }); + expect(hookRef.current.getChangedData().COLLECTED).toMatchObject({ + READY: { + COLLECTED: false, + }, + }); + }); + }); }); diff --git a/src/use-lunatic/use-lunatic.ts b/src/use-lunatic/use-lunatic.ts index 4a71f67b4..696c7b467 100644 --- a/src/use-lunatic/use-lunatic.ts +++ b/src/use-lunatic/use-lunatic.ts @@ -21,6 +21,7 @@ import { useLoopVariables } from './hooks/use-loop-variables'; import reducer from './reducer'; import { useSuggesters } from './use-suggesters'; import { getQuestionnaireData } from './commons/variables/get-questionnaire-data'; +import { useTrackChanges } from '../hooks/use-track-changes'; const empty = {}; // Keep the same empty object (to avoid problem with useEffect dependencies) const emptyFn = () => {}; @@ -56,6 +57,7 @@ function useLunatic( missingShortcut = DEFAULT_SHORTCUT, dontKnowButton = DEFAULT_DONT_KNOW, refusedButton = DEFAULT_REFUSED, + trackChanges = false, }: { features?: LunaticState['features']; preferences?: LunaticState['preferences']; @@ -75,6 +77,8 @@ function useLunatic( missingShortcut?: { dontKnow: string; refused: string }; dontKnowButton?: string; refusedButton?: string; + // Enable change tracking to keep a track of what variable changed (allow using getChangedData()) + trackChanges?: boolean; } ) { const [state, dispatch] = useReducer(reducer, INITIAL_STATE); @@ -179,14 +183,24 @@ function useLunatic( [dispatch, onChange] ); - const getData = (withRefreshedCalculated: boolean) => { + const getData = ( + withRefreshedCalculated: boolean, + variableNames?: string[] + ) => { return getQuestionnaireData( state.variables, source.variables, - withRefreshedCalculated + withRefreshedCalculated, + variableNames ); }; + const { resetChangedData, getChangedData } = useTrackChanges( + trackChanges, + state.variables, + (variableNames?: string[]) => getData(false, variableNames) + ); + const buildedOverview = useMemo( () => overviewWithChildren(overview), [overview] @@ -259,6 +273,8 @@ function useLunatic( onChange: handleChange, overview: buildedOverview, loopVariables: useLoopVariables(pager, state.pages), + getChangedData, + resetChangedData, }; }