From 1cb271134ffdb0269d74e5c791208ccd7621ac3e Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 5 Dec 2023 16:07:42 +0200 Subject: [PATCH] wip navigation refactor --- lib/interviewer/components/Navigation.tsx | 99 +++++++-- lib/interviewer/containers/LoadParamsRoute.js | 124 ----------- .../containers/Search/withSearch.js | 71 ------- lib/interviewer/containers/Stage.js | 1 - .../selectors/__mocks__/session.js | 4 - .../selectors/__tests__/search.test.js | 42 ---- lib/interviewer/selectors/canvas.js | 14 +- lib/interviewer/selectors/forms.js | 2 +- lib/interviewer/selectors/interface.js | 20 +- lib/interviewer/selectors/name-generator.js | 4 +- lib/interviewer/selectors/network.js | 7 +- lib/interviewer/selectors/protocol.js | 16 +- lib/interviewer/selectors/search.js | 30 --- lib/interviewer/selectors/session.js | 201 ------------------ lib/interviewer/selectors/session.ts | 172 +++++++++++++++ lib/interviewer/selectors/skip-logic.js | 12 +- lib/interviewer/selectors/utils.js | 2 +- lib/interviewer/utils/Validations.js | 2 +- .../utils/__tests__/Validations.test.js | 2 +- package.json | 2 + pnpm-lock.yaml | 14 +- 21 files changed, 301 insertions(+), 540 deletions(-) delete mode 100644 lib/interviewer/containers/LoadParamsRoute.js delete mode 100644 lib/interviewer/containers/Search/withSearch.js delete mode 100644 lib/interviewer/selectors/__mocks__/session.js delete mode 100644 lib/interviewer/selectors/__tests__/search.test.js delete mode 100644 lib/interviewer/selectors/search.js delete mode 100644 lib/interviewer/selectors/session.js create mode 100644 lib/interviewer/selectors/session.ts diff --git a/lib/interviewer/components/Navigation.tsx b/lib/interviewer/components/Navigation.tsx index 98f0b1db..6ae5fd37 100644 --- a/lib/interviewer/components/Navigation.tsx +++ b/lib/interviewer/components/Navigation.tsx @@ -1,10 +1,78 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import ProgressBar from '~/lib/ui/components/ProgressBar'; import useReadyForNextStage from '~/lib/interviewer/hooks/useReadyForNextStage'; import { ChevronDown, ChevronUp, SettingsIcon } from 'lucide-react'; import { cn } from '~/utils/shadcn'; -import { useSelector } from 'react-redux'; -import { getNavigationInfo, getSessionProgress } from '../selectors/session'; +import { useDispatch, useSelector } from 'react-redux'; +import { type State, getNavigationInfo } from '../selectors/session'; +import { getSkipMap } from '../selectors/skip-logic'; +import { parseAsInteger, useQueryState } from 'next-usequerystate'; + +type SkipMap = Record; + +const useNavigation = () => { + const dispatch = useDispatch(); + + const [isReadyForNextStage] = useReadyForNextStage(); + const skipMap: SkipMap = useSelector(getSkipMap); + const { + progress, + isFirstPrompt, + isLastPrompt, + isLastStage, + currentStageIndex, + currentPromptIndex, + } = useSelector((state: State) => getNavigationInfo(state)); + + const [currentStage, setCurrentStage] = useQueryState( + 'stage', + parseAsInteger.withDefault(1), + ); + + // Ddetermine if we can navigate to a given stage based on the skip logic + const canNavigateToStage = (stage: number) => { + return skipMap[stage]; + }; + + // Move to the next available stage in the interview based on the current stage and skip logic + const moveForward = () => { + const nextAvailableStage = Object.keys(skipMap).find( + (stage) => parseInt(stage) > currentStage && skipMap[stage] === false, + ); + + dispatch(sessionActions.updateStage({ stageIndex: nextAvailableStage })); + }; + + // Move to the previous available stage in the interview based on the current stage and skip logic + const moveBackward = () => { + const previousAvailableStage = Object.keys(skipMap) + .reverse() + .find( + (stage) => parseInt(stage) < currentStage && skipMap[stage] === false, + ); + + dispatch( + sessionActions.updateStage({ stageIndex: previousAvailableStage }), + ); + }; + + // When the current stage changes, try to set the session currentStage to the new value using the + // `canNavigateToStage` method. + useEffect(() => { + if (canNavigateToStage(currentStage)) { + dispatch(sessionActions.updateStage({ stageIndex: currentStage })); + } + }, [currentStage, canNavigateToStage, dispatch]); + + return { + progress, + isReadyForNextStage, + canMoveForward, + canMoveBackward, + moveForward, + moveBackward, + }; +}; const NavigationButton = ({ disabled, @@ -35,25 +103,24 @@ const NavigationButton = ({ }; const Navigation = () => { - const [isReadyForNextStage] = useReadyForNextStage(); - const { progress } = useSelector(getNavigationInfo); - - const previousPage = () => {}; - const nextPage = () => {}; - - const hasNextPage = true; - const hasPreviousPage = true; + const { + progress, + isReadyForNextStage, + canMoveForward, + canMoveBackward, + moveForward, + moveBackward, + } = useNavigation(); return (
- +
@@ -65,8 +132,8 @@ const Navigation = () => { 'hover:bg-[var(--nc-primary)]', isReadyForNextStage && 'animate-pulse', )} - onClick={nextPage} - disabled={!hasNextPage} + onClick={moveForward} + disabled={!canMoveForward} > diff --git a/lib/interviewer/containers/LoadParamsRoute.js b/lib/interviewer/containers/LoadParamsRoute.js deleted file mode 100644 index 663ef973..00000000 --- a/lib/interviewer/containers/LoadParamsRoute.js +++ /dev/null @@ -1,124 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { bindActionCreators, compose } from 'redux'; -import { actionCreators as resetActions } from '../ducks/modules/reset'; -import { actionCreators as sessionActions } from '../ducks/modules/session'; -import { actionCreators as sessionActions } from '../ducks/modules/activeSessionId'; - -class LoadParamsRoute extends Component { - // eslint-disable-next-line camelcase - UNSAFE_componentWillMount() { - const { - computedMatch, - setSession, - shouldReset, - resetState, - sessionId, - updatePrompt, - } = this.props; - - setSession(computedMatch.params.sessionId); - - if (shouldReset) { - resetState(); - return; - } - if (sessionId) { - updatePrompt(0); - } - } - - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(nextProps) { - const { - stageIndex, - updatePrompt, - updateStage, - } = this.props; - - if (nextProps.shouldReset) { - nextProps.resetState(); - return; - } - - const { params: nextParams } = nextProps.computedMatch; - - // Reset promptIndex when stage changes. - if ( - nextParams // there are new params - && nextParams.stageIndex // there's a stage index - && nextParams.stageIndex !== stageIndex // the new stage index is different - && nextProps.sessionId // We still have an active session - ) { - updateStage(parseInt(nextParams.stageIndex, 10)); - updatePrompt(0); - } - } - - render() { - const { - backParam, - component: RenderComponent, - shouldReset, - stageIndex, - ...rest - } = this.props; - - const finishedLoading = rest.sessionId; - if (!shouldReset && !finishedLoading) { return null; } - - return ( - - ); - } -} - -LoadParamsRoute.propTypes = { - backParam: PropTypes.string.isRequired, - component: PropTypes.oneOfType([ - PropTypes.object, - PropTypes.func, - ]).isRequired, - computedMatch: PropTypes.object.isRequired, - resetState: PropTypes.func.isRequired, - sessionId: PropTypes.string, - sessionUrl: PropTypes.string, - setSession: PropTypes.func.isRequired, - shouldReset: PropTypes.bool, - stageIndex: PropTypes.number, - updatePrompt: PropTypes.func.isRequired, - updateStage: PropTypes.func.isRequired, -}; - -LoadParamsRoute.defaultProps = { - sessionId: '', - sessionUrl: '/setup', - shouldReset: false, - stageIndex: 0, -}; - -function mapStateToProps(state, ownProps) { - return { - backParam: ownProps.location.search, - sessionId: state.activeSessionId, - stageIndex: state.activeSessionId && state.sessions[state.activeSessionId].stageIndex, - }; -} - -function mapDispatchToProps(dispatch) { - return { - resetState: bindActionCreators(resetActions.resetAppState, dispatch), - updatePrompt: bindActionCreators(sessionActions.updatePrompt, dispatch), - updateStage: bindActionCreators(sessionActions.updateStage, dispatch), - setSession: bindActionCreators(sessionActions.setSession, dispatch), - }; -} - -export default compose( - connect(mapStateToProps, mapDispatchToProps), -)(LoadParamsRoute); diff --git a/lib/interviewer/containers/Search/withSearch.js b/lib/interviewer/containers/Search/withSearch.js deleted file mode 100644 index 6627b932..00000000 --- a/lib/interviewer/containers/Search/withSearch.js +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable react/sort-comp */ - -import { - compose, withHandlers, lifecycle, withPropsOnChange, -} from 'recompose'; -import { connect } from 'react-redux'; -import { debounce } from 'lodash'; -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; -import { LEGACY_makeGetFuse as makeGetFuse } from '../../selectors/search'; - -/** - * Fuse.js: approximate string matching. - * See makeGetFuse() in the search selectors. - */ -const DefaultFuseOpts = { - threshold: 0.5, - minMatchCharLength: 1, - shouldSort: true, - tokenize: true, // Break up query so it can match across different fields -}; - -/** - * The `reselect` selector which provides a Fuse instance for searching. - */ -const FuseSelector = makeGetFuse(DefaultFuseOpts); - -const mapStateToProps = (state, props) => ({ - fuse: FuseSelector(state, props), -}); - -const withReduxState = connect(mapStateToProps); - -const withSearch = withHandlers({ - search: ({ fuse, excludedNodes, setResults }) => (query) => { - if (query.length === 0) { - setResults([]); - return; - } - - // If false, suppress candidate from appearing in search results — - // for example, if the node has already been selected. - // Assumption: - // `excludedNodes` size is small, but search set may be large, - // and so preferable to filter found results dynamically. - const isAllowedResult = (candidate) => excludedNodes.every( - (excluded) => excluded[entityPrimaryKeyProperty] !== candidate[entityPrimaryKeyProperty], - ); - - const searchResults = fuse.search(query); - const results = searchResults.filter(isAllowedResult); - - setResults(results); - }, -}); - -const withLifecycle = lifecycle({ - onComponentWillUnmount: ({ search }) => () => search.cancel(), // cancel debounce when unmounting -}); - -export default compose( - withReduxState, - withSearch, - withPropsOnChange( - ['search'], - ({ search }) => ({ - // TODO: Could use items length to determine debounce (e.g. loading) time - search: debounce(search, 500), // simulate 'deeper' search for better ux? - }), - ), - withLifecycle, -); diff --git a/lib/interviewer/containers/Stage.js b/lib/interviewer/containers/Stage.js index 6fe3a825..50f508e2 100644 --- a/lib/interviewer/containers/Stage.js +++ b/lib/interviewer/containers/Stage.js @@ -41,7 +41,6 @@ const Stage = (props) => { {CurrentInterface && ( () => ({})); diff --git a/lib/interviewer/selectors/__tests__/search.test.js b/lib/interviewer/selectors/__tests__/search.test.js deleted file mode 100644 index cf1a8e11..00000000 --- a/lib/interviewer/selectors/__tests__/search.test.js +++ /dev/null @@ -1,42 +0,0 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - -import * as Search from '../search'; - -const DefaultFuseOpts = { - threshold: 0.5, - minMatchCharLength: 1, - shouldSort: true, -}; - -const externalNode = { - uid: 'person_1', - type: 'person', - name: 'F. Anita', - nickname: 'Annie', - age: 23, -}; - -const mockProps = { - options: {}, - dataSource: 'schoolPupils', -}; - -const mockState = { - externalData: { - schoolPupils: { - nodes: [externalNode, { - name: 'C. Ronaldo', - }], - }, - }, -}; - -describe('search', () => { - describe('memoed selectors', () => { - it('should makeGetFuse', () => { - const selector = Search.LEGACY_makeGetFuse(DefaultFuseOpts); - expect(typeof selector(mockState, mockProps)).toBe('object'); - }); - }); -}); diff --git a/lib/interviewer/selectors/canvas.js b/lib/interviewer/selectors/canvas.js index da4466d6..157743d6 100644 --- a/lib/interviewer/selectors/canvas.js +++ b/lib/interviewer/selectors/canvas.js @@ -9,9 +9,9 @@ import { getNetworkNodes, getNetworkEdges } from './network'; import { createDeepEqualSelector } from './utils'; import createSorter, { processProtocolSortRule } from '../utils/createSorter'; import { getEntityAttributes } from '../ducks/modules/network'; -import { makeGetSessionStageSubject } from './session'; import { get } from '../utils/lodash-replacements'; import { getAllVariableUUIDsByEntity } from './protocol'; +import { getStageSubject } from './prop'; const getLayout = (_, props) => get(props, 'prompt.layout.layoutVariable'); const getSortOptions = (_, props) => get(props, 'prompt.sortOrder', null); @@ -27,7 +27,7 @@ const getDisplayEdges = (_, props) => get(props, 'prompt.edges.display', []); */ export const getNextUnplacedNode = createDeepEqualSelector( getNetworkNodes, - makeGetSessionStageSubject(), + getStageSubject, getLayout, getSortOptions, getAllVariableUUIDsByEntity, @@ -74,7 +74,7 @@ export const getNextUnplacedNode = createDeepEqualSelector( */ export const getPlacedNodes = createDeepEqualSelector( getNetworkNodes, - makeGetSessionStageSubject(), + getStageSubject, getLayout, (nodes, subject, layoutVariable) => { if (nodes && nodes.length === 0) { return []; } @@ -138,15 +138,9 @@ export const getEdges = createDeepEqualSelector( // Selector for stage nodes export const getNodes = createDeepEqualSelector( getNetworkNodes, - makeGetSessionStageSubject(), // This is either a subject object or a collection of subject objects + getStageSubject, (nodes, subject) => { if (!subject) { return nodes; } - - if (isArray(subject)) { - const subjects = subject.map((s) => s.type); - return nodes.filter((node) => subjects.includes(node.type)); - } - return nodes.filter((node) => node.type === subject.type); }, ); diff --git a/lib/interviewer/selectors/forms.js b/lib/interviewer/selectors/forms.js index 61ce3a71..c1f0db7a 100644 --- a/lib/interviewer/selectors/forms.js +++ b/lib/interviewer/selectors/forms.js @@ -1,4 +1,4 @@ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; import { get } from '../utils/lodash-replacements'; import { getProtocolCodebook } from './protocol'; diff --git a/lib/interviewer/selectors/interface.js b/lib/interviewer/selectors/interface.js index a9bed647..53bfb185 100644 --- a/lib/interviewer/selectors/interface.js +++ b/lib/interviewer/selectors/interface.js @@ -1,12 +1,8 @@ -/* eslint-disable import/prefer-default-export */ - -import { createSelector } from 'reselect'; import { filter, includes, intersection } from 'lodash'; -import { assert } from './utils'; import { getProtocolCodebook } from './protocol'; import { getNetwork, getNetworkEdges, getNetworkNodes } from './network'; -import { makeGetSessionStageSubject } from './session'; import { getPromptOtherVariable, getStageSubject, stagePromptIds, getPropPromptId, getPromptVariable } from './prop'; +import { createSelector } from '@reduxjs/toolkit'; // Selectors that are generic between interfaces @@ -16,19 +12,9 @@ These selectors assume the following props: prompt: which contains the protocol config for the prompt */ - -const nodeTypeIsDefined = (codebook, nodeType) => { - if (!codebook) { return false; } - return codebook.node[nodeType]; -}; - -// TODO: Once schema validation is in place, we don't need these asserts. export const getSubjectType = createSelector( - getProtocolCodebook, getStageSubject, - (codebook, subject) => { - assert(subject, 'The "subject" property is not defined for this prompt'); - assert(nodeTypeIsDefined(codebook, subject.type), `Node type "${subject.type}" is not defined in the registry`); + (subject) => { return subject && subject.type; }, ); @@ -82,7 +68,7 @@ export const makeNetworkEdgesForType = () => createSelector( */ export const makeNetworkEntitiesForType = () => createSelector( getNetwork, - makeGetSessionStageSubject(), + getStageSubject, (network, subject) => { if (!subject || !network) { return []; diff --git a/lib/interviewer/selectors/name-generator.js b/lib/interviewer/selectors/name-generator.js index 48c33be3..1e38357d 100644 --- a/lib/interviewer/selectors/name-generator.js +++ b/lib/interviewer/selectors/name-generator.js @@ -1,10 +1,8 @@ -/* eslint-disable import/prefer-default-export */ - -import { createSelector } from 'reselect'; import { has } from 'lodash'; import { getProtocolCodebook } from './protocol'; import { getIds, getStageSubject } from './prop'; import { getSubjectType } from './interface'; +import { createSelector } from '@reduxjs/toolkit'; // Selectors that are specific to the name generator diff --git a/lib/interviewer/selectors/network.js b/lib/interviewer/selectors/network.js index 794bad81..a3773415 100644 --- a/lib/interviewer/selectors/network.js +++ b/lib/interviewer/selectors/network.js @@ -1,11 +1,12 @@ import { findKey, find, get } from 'lodash'; -import { getActiveSession, getStageSubjectType } from './session'; +import { getActiveSession } from './session'; import { createDeepEqualSelector } from './utils'; import { getProtocolCodebook } from './protocol'; import { getEntityAttributes } from '../ducks/modules/network'; import customFilter from '~/lib/network-query/filter'; import { createSelector } from '@reduxjs/toolkit'; import { getStageSubject } from './prop'; +import { getSubjectType } from './interface'; export const getNetwork = createSelector( getActiveSession, @@ -141,7 +142,7 @@ export const makeGetEdgeColor = () => createDeepEqualSelector( export const makeGetNodeAttributeLabel = () => createDeepEqualSelector( getProtocolCodebook, - getStageSubjectType(), + getSubjectType, (_, props) => props.variableId, (codebook, subjectType, variableId) => { const nodeDefinitions = codebook.node; @@ -153,7 +154,7 @@ export const makeGetNodeAttributeLabel = () => createDeepEqualSelector( export const makeGetCategoricalOptions = () => createDeepEqualSelector( (state, props) => getProtocolCodebook(state, props), - getStageSubjectType(), + getSubjectType, (_, props) => props.variableId, (codebook, subjectType, variableId) => { const nodeDefinitions = codebook.node; diff --git a/lib/interviewer/selectors/protocol.js b/lib/interviewer/selectors/protocol.js index 643a98bc..095fab04 100644 --- a/lib/interviewer/selectors/protocol.js +++ b/lib/interviewer/selectors/protocol.js @@ -5,9 +5,10 @@ import { mapValues, omit, } from 'lodash'; -import { createSelector } from 'reselect'; import { entityAttributesProperty } from '@codaco/shared-consts'; import { get } from '../utils/lodash-replacements'; +import { createSelector } from '@reduxjs/toolkit'; +import { getStageSubject } from './prop'; const DefaultFinishStage = { // `id` is used as component key; must be unique from user input @@ -152,3 +153,16 @@ export const getProtocolStages = createSelector( getCurrentSessionProtocol, (protocol) => withFinishStage(protocol.stages), ); + +export const getCodebookVariablesForType = createSelector( + getProtocolCodebook, + getStageSubject, + (codebook, subject) => + codebook && + (subject + ? codebook[subject.entity][subject.type].variables + : codebook.ego.variables), +); + +export const makeGetCodebookVariablesForType = () => + getCodebookVariablesForType; diff --git a/lib/interviewer/selectors/search.js b/lib/interviewer/selectors/search.js deleted file mode 100644 index ddfd6792..00000000 --- a/lib/interviewer/selectors/search.js +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable import/prefer-default-export */ -import FuseLegacy from 'fuse.js-legacy'; -import { createSelector } from 'reselect'; -import { get } from '../utils/lodash-replacements'; - -const getSearchOpts = (_, props) => props.options; -const getSearchData = (_, props) => get(props.externalData, 'nodes', []); - -/** - * The new NameGeneratorRoster interface upgrades the fuse.js version - * to 6.x.x. This would modify the behaviour of the existing legacy - * roster interfaces, so we need to keep this selector for them, which - * uses the fuse.js-legacy npm alias. -*/ -// eslint-disable-next-line camelcase -export const LEGACY_makeGetFuse = (fuseOpts) => createSelector( - getSearchData, - getSearchOpts, - (searchData = [], searchOpts = {}) => { - let threshold = searchOpts.fuzziness; - if (typeof threshold !== 'number') { - threshold = fuseOpts.threshold; - } - return new FuseLegacy(searchData, { - ...fuseOpts, - keys: searchOpts.matchProperties, - threshold, - }); - }, -); diff --git a/lib/interviewer/selectors/session.js b/lib/interviewer/selectors/session.js deleted file mode 100644 index 98d78083..00000000 --- a/lib/interviewer/selectors/session.js +++ /dev/null @@ -1,201 +0,0 @@ -/* eslint-disable no-shadow */ -import { createSelector } from 'reselect'; -import { - clamp, orderBy, values, mapValues, omit, -} from 'lodash'; -import { entityAttributesProperty } from '@codaco/shared-consts'; -import { getAdditionalAttributes, getStageOrPromptSubject } from '../utils/protocol/accessors'; -import { createDeepEqualSelector } from './utils'; -import { getProtocolCodebook, getProtocolStages, getCurrentSessionProtocol } from './protocol'; -import { get } from '../utils/lodash-replacements'; - -export const getActiveSession = (state) => ( - state.activeSessionId && state.sessions[state.activeSessionId] -); - -export const getLastActiveSession = (state) => { - if (Object.keys(state.sessions).length === 0) { - return {}; - } - - const sessionsCollection = values(mapValues(state.sessions, (session, uuid) => ({ - sessionUUID: uuid, - ...session, - }))); - - const lastActive = orderBy(sessionsCollection, ['updatedAt', 'caseId'], ['desc', 'asc'])[0]; - return { - sessionUUID: lastActive.sessionUUID, - [entityAttributesProperty]: { - ...omit(lastActive, 'sessionUUID'), - }, - }; -}; - -export const getStageIndex = createSelector( - getActiveSession, - (session) => (session && session.stageIndex) || 0, -); - -export const getCurrentStage = createSelector( - getProtocolStages, - getStageIndex, - (stages, stageIndex) => stages[stageIndex], -) - -export const getPromptIndex = createSelector( - getActiveSession, - (session) => (session && session.promptIndex) || 0, -); - -export const getCurrentPrompt = createSelector( - getCurrentStage, - getPromptIndex, - (stage, promptIndex) => stage && stage.prompts && stage.prompts[promptIndex], -) - -export const getCaseId = createDeepEqualSelector( - getActiveSession, - (session) => (session && session.caseId), -); - -export const getPrompts = createSelector( - getCurrentStage, - (stage) => stage && stage.prompts, -); - -export const getPromptCount = createSelector( - getPrompts, - (prompts) => prompts && prompts.length, -); - -export const getIsFirstPrompt = createSelector( - getPromptIndex, - (promptIndex) => promptIndex === 0, -); - -export const getIsLastPrompt = createSelector( - getPromptIndex, - getPromptCount, - (promptIndex, promptCount) => promptIndex === promptCount - 1, -); - -export const getIsFirstStage = createSelector( - getStageIndex, - (stageIndex) => stageIndex === 0, -); - -export const getIsLastStage = createSelector( - getStageIndex, - getProtocolStages, - (stageIndex, stages) => stageIndex === stages.length - 1, -); - -export const getStageCount = createSelector( - getProtocolStages, - (stages) => stages.length, -); - - -export const getSessionProgress = createSelector( - getStageIndex, - getStageCount, - getPromptIndex, - getPromptCount, - (stageIndex, stageCount, promptIndex, promptCount) => { - const stageProgress = stageIndex / (stageCount - 1); - const promptProgress = promptCount ? promptIndex / promptCount : 0; - // This can go over 100% when finish screen is not present, - // so it needs to be clamped <- JRM 2023 WTF??? - const percentProgress = clamp( - (stageProgress + (promptProgress / (stageCount - 1))) * 100, - 0, - 100, - ); - - return percentProgress; - }); - - -export const getNavigationInfo = createSelector( - getSessionProgress, - getStageIndex, - getPromptIndex, - getIsFirstPrompt, - getIsLastPrompt, - getIsFirstStage, - getIsLastStage, - (progress, stageIndex, promptIndex, isFirstPrompt, isLastPrompt, isFirstStage, isLastStage) => ({ - progress, - stageIndex, - promptIndex, - isFirstPrompt, - isLastPrompt, - isFirstStage, - isLastStage, - }), -); - -export const anySessionIsActive = createSelector( - getActiveSession, - (session) => !!session, -) - -export const getStageForCurrentSession = createSelector( - getProtocolStages, - getStageIndex, - (stages, stageIndex) => stages[stageIndex], -); - -export const getSessionStageSubject = createSelector( - getStageForCurrentSession, - (stage) => stage.subject, -); - -export const makeGetSessionStageSubject = () => getSessionStageSubject - -export const getStageSubjectType = () => createSelector( - getSessionStageSubject, - (subject) => subject && subject.type, -); - -export const getCodebookVariablesForType = createSelector( - getProtocolCodebook, - getSessionStageSubject, - (codebook, subject) => codebook - && (subject ? codebook[subject.entity][subject.type].variables : codebook.ego.variables), -); - -export const makeGetCodebookVariablesForType = () => getCodebookVariablesForType; - -const getPromptForCurrentSession = createSelector( - getStageForCurrentSession, - getPromptIndex, - (stage, promptIndex) => stage && stage.prompts && stage.prompts[promptIndex], -); - -// @return {Array} An object entry ([key, object]) for the current node type -// from the variable registry -export const getNodeEntryForCurrentPrompt = createSelector( - (state, props) => getProtocolCodebook(state, props), - getPromptForCurrentSession, - getStageForCurrentSession, - (registry, prompt, stage) => { - if (!registry || !registry.node || !prompt || !stage) { - return null; - } - const subject = getStageOrPromptSubject(stage, prompt); - const nodeType = subject && subject.type; - const nodeTypeDefinition = nodeType && registry.node[nodeType]; - if (nodeTypeDefinition) { - return [nodeType, nodeTypeDefinition]; - } - return null; - }, -); - -export const getAdditionalAttributesForCurrentPrompt = createSelector( - getPromptForCurrentSession, - getStageForCurrentSession, - (prompt, stage) => getAdditionalAttributes(stage, prompt), -); diff --git a/lib/interviewer/selectors/session.ts b/lib/interviewer/selectors/session.ts new file mode 100644 index 00000000..ad5fb5ee --- /dev/null +++ b/lib/interviewer/selectors/session.ts @@ -0,0 +1,172 @@ +import type { Stage, NcNetwork } from '@codaco/shared-consts'; +import { createDeepEqualSelector } from './utils'; +import { getProtocolStages } from './protocol'; +import { createSelector } from '@reduxjs/toolkit'; + +export type SessionState = Record; + +export type Session = { + protocolUid: string; + promptIndex: number; + stageIndex: number; + caseId: string; + network: NcNetwork; + startedAt: Date; + updatedAt: Date; + finishedAt: Date; + exportedAt: Date; + stages?: SessionState; +}; + +export type SessionsState = Record; + +export type State = { + activeSessionId: string; + sessions: SessionsState; +}; + +export const getActiveSessionId = (state: State) => state.activeSessionId; + +export const getSessions = (state: State) => state.sessions; + +export const getActiveSession = createSelector( + getActiveSessionId, + getSessions, + (activeSessionId, sessions) => sessions[activeSessionId], +); + +export const getLastActiveSession = createSelector(getSessions, (sessions) => { + const lastActiveSession = Object.keys(sessions).reduce( + (lastSessionId: string | null, sessionId) => { + const session = sessions[sessionId]!; + if ( + !lastSessionId || + (session.updatedAt && + session.updatedAt > sessions[lastSessionId]!.updatedAt) + ) { + return sessionId; + } + return lastSessionId; + }, + null, + ); + + return lastActiveSession; +}); + +export const getStageIndex = createSelector( + getActiveSession, + (session) => session?.stageIndex ?? 0, +); + +export const getCurrentStage = createSelector( + getProtocolStages, + getStageIndex, + (stages: Stage[], stageIndex) => stages[stageIndex], +); + +export const getPromptIndex = createSelector( + getActiveSession, + (session) => session?.promptIndex ?? 0, +); + +export const getCurrentPrompt = createSelector( + getCurrentStage, + getPromptIndex, + (stage, promptIndex) => stage?.prompts?.[promptIndex], +); + +export const getCaseId = createDeepEqualSelector( + getActiveSession, + (session) => session?.caseId, +); + +export const getPrompts = createSelector( + getCurrentStage, + (stage) => stage?.prompts, +); + +export const getPromptCount = createSelector( + getPrompts, + (prompts) => prompts?.length ?? 0, +); + +export const getIsFirstPrompt = createSelector( + getPromptIndex, + (promptIndex) => promptIndex === 0, +); + +export const getIsLastPrompt = createSelector( + getPromptIndex, + getPromptCount, + (promptIndex, promptCount) => promptIndex === promptCount - 1, +); + +export const getIsFirstStage = createSelector( + getStageIndex, + (stageIndex) => stageIndex === 0, +); + +export const getIsLastStage = createSelector( + getStageIndex, + getProtocolStages, + (stageIndex, stages) => stageIndex === stages.length - 1, +); + +export const getStageCount = createSelector( + getProtocolStages, + (stages) => stages.length, +); + +export const getSessionProgress = createSelector( + getStageIndex, + getStageCount, + getPromptIndex, + getPromptCount, + (stageIndex, stageCount, promptIndex, promptCount) => { + const stageProgress = stageIndex / (stageCount - 1); + const promptProgress = promptCount ? promptIndex / promptCount : 0; + const percentProgress = + stageProgress + (promptProgress / (stageCount - 1)) * 100; + + return percentProgress; + }, +); + +export const getNavigationInfo = createSelector( + getSessionProgress, + getStageIndex, + getPromptIndex, + getIsFirstPrompt, + getIsLastPrompt, + getIsFirstStage, + getIsLastStage, + ( + progress, + stageIndex, + promptIndex, + isFirstPrompt, + isLastPrompt, + isFirstStage, + isLastStage, + ) => ({ + progress, + stageIndex, + promptIndex, + isFirstPrompt, + isLastPrompt, + isFirstStage, + isLastStage, + }), +); + +export const anySessionIsActive = createSelector( + getActiveSession, + (session) => !!session, +); + +export const getStageForCurrentSession = createSelector( + getProtocolStages, + getStageIndex, + (stages: Stage[], stageIndex) => stages[stageIndex], +); diff --git a/lib/interviewer/selectors/skip-logic.js b/lib/interviewer/selectors/skip-logic.js index def42218..44290d8c 100644 --- a/lib/interviewer/selectors/skip-logic.js +++ b/lib/interviewer/selectors/skip-logic.js @@ -1,8 +1,8 @@ -import { createSelector } from 'reselect'; import getQuery from '~/lib/network-query/query'; import { getProtocolStages } from './protocol'; import { getNetwork } from './network'; import { SkipLogicAction } from '../protocol-consts'; +import { createSelector } from '@reduxjs/toolkit'; const rotateIndex = (max, nextIndex) => (nextIndex + max) % max; const maxLength = (state) => getProtocolStages(state).length; @@ -41,10 +41,7 @@ export const getSkipMap = createSelector( if (!skipLogic) { return { ...acc, - [index]: { - result: null, - isSkipped: false, - }, + [index]: false, }; } @@ -56,10 +53,7 @@ export const getSkipMap = createSelector( return { ...acc, - [index]: { - result, - isSkipped, - }, + [index]: isSkipped, }; }, {}), ); diff --git a/lib/interviewer/selectors/utils.js b/lib/interviewer/selectors/utils.js index aa9a725e..37b335d7 100644 --- a/lib/interviewer/selectors/utils.js +++ b/lib/interviewer/selectors/utils.js @@ -1,5 +1,5 @@ -import { createSelectorCreator, defaultMemoize } from 'reselect'; import { isEqual } from 'lodash'; +import { createSelectorCreator, defaultMemoize } from 'reselect'; // create a "selector creator" that uses lodash.isEqual instead of === export const createDeepEqualSelector = createSelectorCreator( diff --git a/lib/interviewer/utils/Validations.js b/lib/interviewer/utils/Validations.js index 2c15132f..ff059a44 100644 --- a/lib/interviewer/utils/Validations.js +++ b/lib/interviewer/utils/Validations.js @@ -9,7 +9,7 @@ import { } from 'lodash'; import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { makeNetworkEntitiesForType } from '../selectors/interface'; -import { makeGetCodebookVariablesForType } from '../selectors/session'; +import { makeGetCodebookVariablesForType } from '../selectors/protocol'; // Return an array of values given either a collection, an array, // or a single value diff --git a/lib/interviewer/utils/__tests__/Validations.test.js b/lib/interviewer/utils/__tests__/Validations.test.js index 8a5cf42d..abdae95c 100644 --- a/lib/interviewer/utils/__tests__/Validations.test.js +++ b/lib/interviewer/utils/__tests__/Validations.test.js @@ -16,8 +16,8 @@ import { greaterThanVariable, lessThanVariable, } from '../Validations'; -import { makeGetCodebookVariablesForType } from '../../selectors/session'; import { makeNetworkEntitiesForType } from '../../selectors/interface'; +import { makeGetCodebookVariablesForType } from '../../selectors/protocol'; jest.mock('../../selectors/interface'); jest.mock('../../selectors/session'); diff --git a/package.json b/package.json index abf7d473..1448828d 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark": "^15.0.1", + "reselect": "^4.1.8", "scrollparent": "^2.1.0", "sharp": "^0.32.6", "strip-markdown": "^6.0.0", @@ -123,6 +124,7 @@ "@types/d3-interpolate-path": "^2.0.2", "@types/eslint": "^8.44.6", "@types/jest": "^29.5.6", + "@types/lodash": "^4.14.202", "@types/node": "^20.8.8", "@types/papaparse": "^5.3.10", "@types/react": "^18.2.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67fa6aa0..11ad3fa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ dependencies: remark: specifier: ^15.0.1 version: 15.0.1 + reselect: + specifier: ^4.1.8 + version: 4.1.8 scrollparent: specifier: ^2.1.0 version: 2.1.0 @@ -313,6 +316,9 @@ devDependencies: '@types/jest': specifier: ^29.5.6 version: 29.5.6 + '@types/lodash': + specifier: ^4.14.202 + version: 4.14.202 '@types/node': specifier: ^20.8.8 version: 20.8.8 @@ -4092,7 +4098,7 @@ packages: '@storybook/preview-api': 7.5.1 '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) '@storybook/types': 7.5.1 - '@types/lodash': 4.14.200 + '@types/lodash': 4.14.202 color-convert: 2.0.1 dequal: 2.0.3 lodash: 4.17.21 @@ -5331,11 +5337,11 @@ packages: /@types/lodash-es@4.17.10: resolution: {integrity: sha512-YJP+w/2khSBwbUSFdGsSqmDvmnN3cCKoPOL7Zjle6s30ZtemkkqhjVfFqGwPN7ASil5VyjE2GtyU/yqYY6mC0A==} dependencies: - '@types/lodash': 4.14.200 + '@types/lodash': 4.14.202 dev: false - /@types/lodash@4.14.200: - resolution: {integrity: sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==} + /@types/lodash@4.14.202: + resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} /@types/mdast@4.0.3: resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==}