diff --git a/.vscode/settings.json b/.vscode/settings.json index b73184061..dd4f3118f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,9 @@ { - "css.customData": ["./.vscode/css-data.json"], + "css.customData": [ + "./.vscode/css-data.json" + ], "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "WillLuke.nextjs.addTypesOnSave": true, "WillLuke.nextjs.hasPrompted": true -} +} \ No newline at end of file diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index d1cc80197..b115eb6da 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -6,6 +6,8 @@ import ProtocolScreen from '~/lib/interviewer/containers/ProtocolScreen'; import { store } from '~/lib/interviewer/store'; import UserBanner from './UserBanner'; import { useSession } from '~/providers/SessionProvider'; +import { useEffect } from 'react'; +import { parseAsInteger, useQueryState } from 'next-usequerystate'; // The job of interview shell is to receive the server-side session and protocol // and create a redux store with that data. @@ -13,6 +15,18 @@ import { useSession } from '~/providers/SessionProvider'; const InterviewShell = ({ serverProtocol, serverSession }) => { const { session } = useSession(); + useEffect(() => { + store.dispatch({ + type: 'SET_SERVER_SESSION', + payload: serverSession, + }); + }, [serverSession]); + + const [stage, setStage] = useQueryState( + 'stage', + parseAsInteger.withDefault(1), + ); + return ( {session && } diff --git a/lib/interviewer/components/Navigation.tsx b/lib/interviewer/components/Navigation.tsx index 6a0bc196a..edb1f9546 100644 --- a/lib/interviewer/components/Navigation.tsx +++ b/lib/interviewer/components/Navigation.tsx @@ -1,38 +1,54 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } 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 { useDispatch, useSelector } from 'react-redux'; -import { type State, getNavigationInfo } from '../selectors/session'; +import { getNavigationInfo } from '../selectors/session'; import { getSkipMap } from '../selectors/skip-logic'; import { parseAsInteger, useQueryState } from 'next-usequerystate'; +import { actionCreators as sessionActions } from '../ducks/modules/session'; +import useReadyForNextStage from '../hooks/useReadyForNextStage'; -type SkipMap = Record; - -const useNavigation = () => { +const useNavigationHelpers = ( + currentStage: number, + setCurrentStage: (stage: number) => void, +) => { const dispatch = useDispatch(); + const skipMap = useSelector(getSkipMap); - const [isReadyForNextStage] = useReadyForNextStage(); - const skipMap: SkipMap = useSelector(getSkipMap); - const { - progress, - isFirstPrompt, - isLastPrompt, - isLastStage, - currentStageIndex, - currentPromptIndex, - } = useSelector((state: State) => getNavigationInfo(state)); + const { isReady: isReadyForNextStage } = useReadyForNextStage(); - const [currentStage, setCurrentStage] = useQueryState( - 'stage', - parseAsInteger.withDefault(1), - ); + const { progress } = useSelector(getNavigationInfo); + + const isCurrentStageValid = useMemo(() => { + return skipMap[currentStage] === false; + }, [currentStage, skipMap]); + + const getPreviousValidStage = useCallback(() => { + return Object.keys(skipMap) + .reverse() + .find( + (stage) => parseInt(stage) < currentStage && skipMap[stage] === false, + ); + }, [currentStage, skipMap]); + + const validateCurrentStage = useCallback(() => { + if (!isCurrentStageValid) { + const previousValidStage = getPreviousValidStage(); + + if (previousValidStage) { + setCurrentStage(parseInt(previousValidStage)); + } + } + }, [isCurrentStageValid, getPreviousValidStage, setCurrentStage]); // Ddetermine if we can navigate to a given stage based on the skip logic - const canNavigateToStage = (stage: number) => { - return skipMap[stage]; - }; + const canNavigateToStage = useCallback( + (stage: number) => { + return skipMap[stage]; + }, + [skipMap], + ); // Move to the next available stage in the interview based on the current stage and skip logic const moveForward = () => { @@ -66,11 +82,12 @@ const useNavigation = () => { return { progress, - isReadyForNextStage: true, + isReadyForNextStage, canMoveForward: true, canMoveBackward: true, moveForward, moveBackward, + validateCurrentStage, }; }; @@ -103,14 +120,25 @@ const NavigationButton = ({ }; const Navigation = () => { + const [currentStage, setCurrentStage] = useQueryState( + 'stage', + parseAsInteger.withDefault(1), + ); + const { + validateCurrentStage, + moveBackward, + moveForward, + canMoveBackward, + canMoveForward, progress, isReadyForNextStage, - canMoveForward, - canMoveBackward, - moveForward, - moveBackward, - } = useNavigation(); + } = useNavigationHelpers(currentStage, setCurrentStage); + + // Check if the current stage is valid for us to be on. + useEffect(() => { + validateCurrentStage(); + }, [validateCurrentStage]); return (
({ - isOpen: !search.collapsed, - isLoading: externalData__isLoading, -}); - -const mapDispatchToProps = { - toggleSearch: searchActions.toggleSearch, - closeSearch: searchActions.closeSearch, -}; - -const withReduxState = connect(mapStateToProps, mapDispatchToProps); - -const initialState = { - hasSearchTerm: false, - searchResults: [], - searchTerm: '', - selectedResults: [], - awaitingResults: false, -}; - -const withSearchState = withStateHandlers( - () => ({ ...initialState }), - { - resetState: () => () => ({ ...initialState }), - setQuery: () => (query) => ({ - searchTerm: query, - hasSearchTerm: query.length !== 0, - searchResults: [], - awaitingResults: true, - }), - setResults: () => (results) => ({ - searchResults: results, - awaitingResults: false, - }), - setSelected: (previousState) => (result) => { - let newResults; - const existingIndex = previousState.selectedResults.indexOf(result); - if (existingIndex > -1) { - newResults = previousState.selectedResults.slice(); - newResults.splice(existingIndex, 1); - } else { - newResults = [...previousState.selectedResults, result]; - } - return { - selectedResults: newResults, - }; - }, - }, -); - -const withSearchHandlers = withHandlers({ - onToggleSearch: ({ toggleSearch }) => () => toggleSearch(), - - onClose: ({ clearResultsOnClose, closeSearch, resetState }) => () => { - if (clearResultsOnClose) { - resetState(); - } - - closeSearch(); - }, - - onCommit: ({ - onComplete, selectedResults, closeSearch, resetState, - }) => () => { - onComplete(selectedResults); - closeSearch(); - resetState(); - }, - - onQueryChange: ({ setQuery, search }) => (e) => { - const query = e.target.value; - - setQuery(query); - - search(query); - }, - - onSelectResult: ({ setSelected }) => (result) => setSelected(result), - - getIsSelected: ({ selectedResults }) => (node) => selectedResults.indexOf(node) > -1, - - getDetails: ({ details, nodeTypeDefinition }) => { - const toDetail = (node, field) => { - const nodeTypeVariables = nodeTypeDefinition.variables; - const labelKey = getParentKeyByNameValue(nodeTypeVariables, field.variable); - return { [field.label]: getEntityAttributes(node)[labelKey] }; - }; - - return (node) => details.map((attr) => toDetail(node, attr)); - }, -}); - -const withDefaultProps = defaultProps({ - details: [], - className: '', - clearResultsOnClose: true, - nodeColor: '', - options: {}, -}); - -/** - * @class Search - * - * @description - * Renders a plaintext node search interface in a semi-modal display, - * with a single text input supporting autocomplete. - * - * Multiple results may be selected by the user, and the final collection - * committed to an `onComplete()` handler. - * - * Note: to ensure that search state is tied to a source data set, set a `key` - * prop that uniquely identifies the source data. - * - * @param {string} getCardTitle The attribute to use for rendering a result - * @param props.details {array} - An array of objects shaped - * `{label: '', variable: ''}` in each search set object. - * @param props.options {object} - * @param props.options.matchProperties {array} - one or more key names to search - * in the dataset. Supports nested properties using dot notation. - * Example: - * data = [{ id: '', attribtues: { name: '' }}]; - * matchProperties = ['attributes.name']; - * @param [props.options.fuzziness=0.5] {number} - - * How inexact search results may be, in the range [0,1]. - * A value of zero requires an exact match. Large search sets may do better - * with smaller values (e.g., 0.2). - * - */ -export default compose( - withDefaultProps, - withExternalData('dataSourceKey', 'externalData'), - withReduxState, - withSearchState, - withSearch, - withSearchHandlers, -)(Search); diff --git a/lib/interviewer/containers/Search/SearchResults.js b/lib/interviewer/containers/Search/SearchResults.js deleted file mode 100644 index 8247b035c..000000000 --- a/lib/interviewer/containers/Search/SearchResults.js +++ /dev/null @@ -1,86 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import Loading from '../../components/Loading'; -import CardList from '../../components/CardList'; - -// This provides a workaround for visibility when a softkeyboard scrolls the viewport -// (for example, on iOS). TODO: better solution once animation is in place. -function styleForSoftKeyboard() { - let style = {}; - const scrollTop = window.pageYOffset || window.scrollY || 0; - if (scrollTop > 0) { - style = { - height: `calc(100% - 320px - ${scrollTop}px)`, - minHeight: '10em', - }; - } - return style; -} - -/** - * @class SearchResults - * @extends Component - * - * @description - * Thin wrapper to render {@link Search} component results in a CardList. - * - * @param props.hasInput {boolean} true if there is user input to the search component - * @param props.results {array} the search results to render. See CardList for formatters. - */ -class SearchResults extends Component { - getResults() { - const { - hasInput, - awaitingResults, - results, - ...rest - } = this.props; - - if (awaitingResults) { - return ; - } - - if (results && results.length) { - return ( - - ); - } - - if (hasInput) { - return (

Nothing matching that search

); - } - - return null; - } - - render() { - const { - hasInput, - } = this.props; - - const style = styleForSoftKeyboard(); - - const classNames = cx( - 'search__results', - { 'search__results--collapsed': !hasInput }, - ); - - return ( -
- {this.getResults()} -
- ); - } -} - -SearchResults.propTypes = { - hasInput: PropTypes.bool.isRequired, - results: PropTypes.array.isRequired, -}; - -export default SearchResults; diff --git a/lib/interviewer/containers/Search/index.js b/lib/interviewer/containers/Search/index.js deleted file mode 100644 index 517d0ee89..000000000 --- a/lib/interviewer/containers/Search/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import Search from './Search'; - -export default Search; diff --git a/lib/interviewer/containers/SlidesForm/SlidesForm.js b/lib/interviewer/containers/SlidesForm/SlidesForm.js index d712cd5f5..45ae8b312 100644 --- a/lib/interviewer/containers/SlidesForm/SlidesForm.js +++ b/lib/interviewer/containers/SlidesForm/SlidesForm.js @@ -56,7 +56,7 @@ const SlidesForm = (props) => { const [activeIndex, setActiveIndex] = useState(0); const [scrollProgress, setScrollProgress] = useState(0); - const [, setIsReadyForNext] = useReadyForNextStage(); + const { updateReady: setIsReadyForNext } = useReadyForNextStage(); const [pendingDirection, setPendingDirection] = useState(null); const [pendingStage, setPendingStage] = useState(-1); diff --git a/lib/interviewer/ducks/modules/search.js b/lib/interviewer/ducks/modules/search.js deleted file mode 100644 index 53d925082..000000000 --- a/lib/interviewer/ducks/modules/search.js +++ /dev/null @@ -1,68 +0,0 @@ -const OPEN_SEARCH = 'OPEN_SEARCH'; -const CLOSE_SEARCH = 'CLOSE_SEARCH'; -const TOGGLE_SEARCH = 'TOGGLE_SEARCH'; - -const initialState = { - collapsed: true, - selectedResults: [], -}; - -export default function reducer(state = initialState, action = {}) { - switch (action.type) { - case TOGGLE_SEARCH: - return { - ...state, - collapsed: !state.collapsed, - selectedResults: [], - }; - case OPEN_SEARCH: - return { - ...state, - collapsed: false, - selectedResults: [], - }; - case CLOSE_SEARCH: - return { - ...state, - collapsed: true, - selectedResults: [], - }; - default: - return state; - } -} - -function openSearch() { - return { - type: OPEN_SEARCH, - }; -} - -function closeSearch() { - return { - type: CLOSE_SEARCH, - }; -} - -function toggleSearch() { - return { - type: TOGGLE_SEARCH, - }; -} - -const actionCreators = { - closeSearch, - openSearch, - toggleSearch, -}; - -const actionTypes = { - CLOSE_SEARCH, - OPEN_SEARCH, - TOGGLE_SEARCH, -}; - -export { - actionCreators, - actionTypes, -}; diff --git a/lib/interviewer/hooks/useReadyForNextStage.js b/lib/interviewer/hooks/useReadyForNextStage.js deleted file mode 100644 index ec6d8d26b..000000000 --- a/lib/interviewer/hooks/useReadyForNextStage.js +++ /dev/null @@ -1,24 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { actionCreators as uiActions } from '../ducks/modules/ui'; -import { get } from '../utils/lodash-replacements'; - -const useReadyForNextStage = () => { - const dispatch = useDispatch(); - - const updateReady = useCallback((isReady) => { - dispatch(uiActions.update({ FORM_IS_READY: isReady })); - }, [dispatch]); - - const isReady = useSelector((state) => get(state, ['ui', 'FORM_IS_READY'], false)); - - useEffect(() => { - updateReady(false); - - return () => updateReady(false); - }, [updateReady]); - - return [isReady, updateReady]; -}; - -export default useReadyForNextStage; diff --git a/lib/interviewer/selectors/network.js b/lib/interviewer/selectors/network.js index db63016b6..47a2f5e92 100644 --- a/lib/interviewer/selectors/network.js +++ b/lib/interviewer/selectors/network.js @@ -9,8 +9,7 @@ import { getStageSubject, getSubjectType } from './prop'; export const getNetwork = createSelector( getActiveSession, - // Todo - this shouldn't have a default value. - (session) => (session && session.network) || { nodes: [], edges: [], ego: {} }, + (session) => session?.network, ); export const getPropStageFilter = (_, props) => props && props.stage && props.stage.filter; diff --git a/lib/interviewer/selectors/session.ts b/lib/interviewer/selectors/session.ts index ad5fb5ee1..5735bd764 100644 --- a/lib/interviewer/selectors/session.ts +++ b/lib/interviewer/selectors/session.ts @@ -2,6 +2,7 @@ import type { Stage, NcNetwork } from '@codaco/shared-consts'; import { createDeepEqualSelector } from './utils'; import { getProtocolStages } from './protocol'; import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '../store'; export type SessionState = Record; @@ -20,19 +21,14 @@ export type Session = { export type SessionsState = Record; -export type State = { - activeSessionId: string; - sessions: SessionsState; -}; - -export const getActiveSessionId = (state: State) => state.activeSessionId; +export const getActiveSessionId = (state: RootState) => state.activeSessionId; -export const getSessions = (state: State) => state.sessions; +export const getSessions = (state: RootState) => state.sessions; export const getActiveSession = createSelector( getActiveSessionId, getSessions, - (activeSessionId, sessions) => sessions[activeSessionId], + (activeSessionId, sessions) => sessions.activeSessionId, ); export const getLastActiveSession = createSelector(getSessions, (sessions) => { diff --git a/lib/interviewer/selectors/skip-logic.js b/lib/interviewer/selectors/skip-logic.js deleted file mode 100644 index 44290d8cb..000000000 --- a/lib/interviewer/selectors/skip-logic.js +++ /dev/null @@ -1,85 +0,0 @@ -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; - -export const getNextIndex = (index) => createSelector( - maxLength, - (max) => rotateIndex(max, index), -); - -const getSkipLogic = (index) => createSelector( - getProtocolStages, - (stages) => stages && stages[index] && stages[index].skipLogic, -); - -/** - * @returns {boolean} true for skip (when query matches), false for show (when query matches) - */ -const isSkipAction = (index) => createSelector( - getSkipLogic(index), - (logic) => logic && logic.action === SkipLogicAction.SKIP, -); - -const formatQueryParameters = (params) => ({ - rules: [], - join: null, - ...params, -}); - -// Hacked together version of isStageSkipped that returns a map of all stages. -// This is more convinient to use with useSelector. -export const getSkipMap = createSelector( - getProtocolStages, - getNetwork, - (stages, network) => stages.reduce((acc, stage, index) => { - const skipLogic = stage.skipLogic; - if (!skipLogic) { - return { - ...acc, - [index]: false, - }; - } - - const skipOnMatch = skipLogic.action === SkipLogicAction.SKIP; - - const queryParameters = formatQueryParameters(skipLogic.filter); - const result = getQuery(queryParameters)(network); - const isSkipped = ((skipOnMatch && result) || (!skipOnMatch && !result)); - - return { - ...acc, - [index]: isSkipped, - }; - }, {}), -); - -export const isStageSkipped = (index) => createSelector( - getSkipLogic(index), - isSkipAction(index), - getNetwork, - (logic, skipOnMatch, network) => { - if (!logic) { return false; } - - // Handle skipLogic with no rules defined differently depending on action. - // skipLogic.action === SHOW <- always show the stage - // skipLogic.action === SKIP <- always skip the stage - // Allows for a quick way to disable a stage by setting SKIP if, and then - // not defining rules. - // Should be changed with https://github.com/complexdatacollective/Architect/issues/517 - if (!logic.filter.rules || !logic.filter.rules.length === 0) { - console.warn('Encountered skip logic with no rules defined at index', index); // eslint-disable-line no-console - return !!skipOnMatch; - } - - const queryParameters = formatQueryParameters(logic.filter); - const result = getQuery(queryParameters)(network); - const isSkipped = ((skipOnMatch && result) || (!skipOnMatch && !result)); - - return isSkipped; - }, -); diff --git a/lib/interviewer/store.ts b/lib/interviewer/store.ts index a54f117ec..b968407e5 100644 --- a/lib/interviewer/store.ts +++ b/lib/interviewer/store.ts @@ -7,9 +7,9 @@ import activeSessionId from '~/lib/interviewer/ducks/modules/activeSessionId'; import sessions from '~/lib/interviewer/ducks/modules/session'; import deviceSettings from '~/lib/interviewer/ducks/modules/deviceSettings'; import dialogs from '~/lib/interviewer/ducks/modules/dialogs'; -import search from '~/lib/interviewer/ducks/modules/search'; import ui from '~/lib/interviewer/ducks/modules/ui'; import installedProtocols from '~/lib/interviewer/ducks/modules/installedProtocols'; +import type { NcNetwork, Protocol } from '@codaco/shared-consts'; export const store = configureStore({ reducer: { @@ -19,11 +19,47 @@ export const store = configureStore({ installedProtocols, deviceSettings, dialogs, - search, ui, }, middleware: [thunk, logger, sound], }); -export type RootState = ReturnType; +export type Session = { + protocolUid: string; + promptIndex: number; + stageIndex: number; + caseId: string; + network: NcNetwork; + startedAt: Date; + updatedAt: Date; + finishedAt: Date; + exportedAt: Date; +}; + +export type SessionsState = Record; + +export type InstalledProtocols = Record; + +export type Dialog = { + id: string; + title: string; + type: string; + confirmLabel?: string; + message: string; +}; + +export type Dialogs = { + dialogs: Dialog[]; +}; + +export type RootState = { + form: Record; + activeSessionId: string | null; + sessions: SessionsState; + installedProtocols: InstalledProtocols; + deviceSettings: Record; + dialogs: Dialogs; + ui: Record; +}; + export type AppDispatch = typeof store.dispatch;