Skip to content

Commit

Permalink
Track changes (#706)
Browse files Browse the repository at this point in the history
  • Loading branch information
Grafikart authored Oct 5, 2023
1 parent 398a7f2 commit 8f19253
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 20 deletions.
59 changes: 59 additions & 0 deletions src/hooks/use-track-changes.ts
Original file line number Diff line number Diff line change
@@ -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<string>());
// 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,
};
}
27 changes: 24 additions & 3 deletions src/use-lunatic/commons/variables/get-questionnaire-data.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
CALCULATED: {} as Record<string, unknown>,
Expand All @@ -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) {
Expand Down
17 changes: 10 additions & 7 deletions src/use-lunatic/commons/variables/lunatic-variables-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +23,9 @@ type Events = {
iteration?: IterationLevel | undefined;
};
};
export type LunaticVariablesStoreEvent<T extends keyof EventArgs> = {
detail: EventArgs[T];
};

export class LunaticVariablesStore {
private dictionary = new Map<string, LunaticVariable>();
Expand Down Expand Up @@ -84,7 +87,7 @@ export class LunaticVariablesStore {
public set(
name: string,
value: unknown,
args: Pick<Events['change'], 'iteration'> = {}
args: Pick<EventArgs['change'], 'iteration'> = {}
): LunaticVariable {
if (!this.dictionary.has(name)) {
this.dictionary.set(
Expand All @@ -102,7 +105,7 @@ export class LunaticVariablesStore {
...args,
name: name,
value: value,
} satisfies Events['change'],
} satisfies EventArgs['change'],
})
);
}
Expand Down Expand Up @@ -149,19 +152,19 @@ export class LunaticVariablesStore {
/**
* Bind event listeners
*/
public on<T extends keyof Events>(
public on<T extends keyof EventArgs>(
eventName: T,
cb: (e: CustomEvent<Events[T]>) => void
cb: (e: CustomEvent<EventArgs[T]>) => void
): void {
this.eventTarget.addEventListener(eventName, cb as EventListener);
}

/**
* Detach a listener
*/
public off<T extends keyof Events>(
public off<T extends keyof EventArgs>(
eventName: T,
cb: (e: CustomEvent<Events[T]>) => void
cb: (e: CustomEvent<EventArgs[T]>) => void
): void {
this.eventTarget.removeEventListener(eventName, cb as EventListener);
}
Expand Down
11 changes: 6 additions & 5 deletions src/use-lunatic/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ export type LunaticControl = ControlType;

export type VTLBindings = { [variableName: string]: unknown };

export type LunaticData = Partial<
Record<Exclude<VariableType, 'COLLECTED'>, Record<string, unknown>> & {
COLLECTED: Record<string, LunaticCollectedValue>;
}
>;
export type LunaticData = Record<
Exclude<VariableType, 'COLLECTED'>,
Record<string, unknown>
> & {
COLLECTED: Record<string, LunaticCollectedValue>;
};

export type LunaticValues = {
[variableName: string]: unknown;
Expand Down
115 changes: 112 additions & 3 deletions src/use-lunatic/use-lunatic.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -192,4 +192,113 @@ describe('use-lunatic()', () => {
});
});
});

describe('getData()', () => {
let hookRef: { current: ReturnType<typeof useLunatic> };
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<typeof useLunatic> };
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,
},
});
});
});
});
20 changes: 18 additions & 2 deletions src/use-lunatic/use-lunatic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {};
Expand Down Expand Up @@ -56,6 +57,7 @@ function useLunatic(
missingShortcut = DEFAULT_SHORTCUT,
dontKnowButton = DEFAULT_DONT_KNOW,
refusedButton = DEFAULT_REFUSED,
trackChanges = false,
}: {
features?: LunaticState['features'];
preferences?: LunaticState['preferences'];
Expand All @@ -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);
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -259,6 +273,8 @@ function useLunatic(
onChange: handleChange,
overview: buildedOverview,
loopVariables: useLoopVariables(pager, state.pages),
getChangedData,
resetChangedData,
};
}

Expand Down

0 comments on commit 8f19253

Please sign in to comment.