diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index 4ff6e31b..15a63ee9 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -11,7 +11,6 @@ import { store } from '~/lib/interviewer/store'; import { api } from '~/trpc/client'; import { useRouter } from 'next/navigation'; import ServerSync from './ServerSync'; -import { parseAsInteger, useQueryState } from 'nuqs'; import { useEffect, useState } from 'react'; import { Spinner } from '~/lib/ui/components'; @@ -22,11 +21,6 @@ const InterviewShell = ({ interviewID }: { interviewID: string }) => { const router = useRouter(); const [initialized, setInitialized] = useState(false); - const [currentStage, setCurrentStage] = useQueryState( - 'stage', - parseAsInteger, - ); - const { isLoading, data: serverData } = api.interview.get.byId.useQuery( { id: interviewID }, { @@ -49,17 +43,6 @@ const InterviewShell = ({ interviewID }: { interviewID: string }) => { const { protocol, ...serverSession } = serverData; - // If we have a current stage in the URL bar, and it is different from the - // server session, set the server session to the current stage. - // - // If we don't have a current stage in the URL bar, set it to the server - // session, and set the URL bar to the server session. - if (currentStage === null) { - void setCurrentStage(serverSession.currentStep); - } else if (currentStage !== serverSession.currentStep) { - serverSession.currentStep = currentStage; - } - // If there's no current stage in the URL bar, set it. store.dispatch({ type: SET_SERVER_SESSION, @@ -70,14 +53,7 @@ const InterviewShell = ({ interviewID }: { interviewID: string }) => { }); setInitialized(true); - }, [ - serverData, - currentStage, - setCurrentStage, - router, - initialized, - setInitialized, - ]); + }, [serverData, router, initialized, setInitialized]); if (isLoading) { return ( diff --git a/app/(interview)/interview/_components/ServerSync.tsx b/app/(interview)/interview/_components/ServerSync.tsx index 6f210b3d..b5921ab5 100644 --- a/app/(interview)/interview/_components/ServerSync.tsx +++ b/app/(interview)/interview/_components/ServerSync.tsx @@ -30,7 +30,7 @@ const ServerSync = ({ // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedSessionSync = useCallback( debounce(syncSessionWithServer, 2000, { - leading: false, + leading: true, trailing: true, maxWait: 10000, }), @@ -55,6 +55,7 @@ const ServerSync = ({ id: interviewId, network: currentSession.network, currentStep: currentSession.currentStep ?? 0, + stageMetadata: currentSession.stageMetadata, // Temporary storage used by tiestrengthcensus/dyadcensus to store negative responses }); }, [ currentSession, diff --git a/hooks/useNetwork.ts b/hooks/useNetwork.ts deleted file mode 100644 index e4913495..00000000 --- a/hooks/useNetwork.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useReducer } from 'react'; -import type { NcEdge, NcNetwork, NcNode } from '@codaco/shared-consts'; - -const initialState: NcNetwork = { - nodes: [], - edges: [], -}; - -type NetworkActionBase = { - type: - | 'ADD_NODE' - | 'ADD_EDGE' - | 'UPDATE_NODE' - | 'UPDATE_EDGE' - | 'DELETE_NODE' - | 'DELETE_EDGE'; - payload: unknown; -}; - -type ActionAddNode = NetworkActionBase & { - type: 'ADD_NODE'; - payload: NcNode; -}; - -type ActionAddEdge = NetworkActionBase & { - type: 'ADD_EDGE'; - payload: NcEdge; -}; - -type ActionUpdateNode = NetworkActionBase & { - type: 'UPDATE_NODE'; - payload: NcNode; -}; - -type ActionUpdateEdge = NetworkActionBase & { - type: 'UPDATE_EDGE'; - payload: NcEdge; -}; - -type ActionDeleteNode = NetworkActionBase & { - type: 'DELETE_NODE'; - payload: string; -}; - -type ActionDeleteEdge = NetworkActionBase & { - type: 'DELETE_EDGE'; - payload: string; -}; - -type NetworkAction = - | ActionAddNode - | ActionAddEdge - | ActionUpdateNode - | ActionUpdateEdge - | ActionDeleteNode - | ActionDeleteEdge; - -function reducer(state: NcNetwork, action: NetworkAction): NcNetwork { - switch (action.type) { - case 'ADD_NODE': - return { - ...state, - nodes: [...state.nodes, action.payload], - }; - case 'ADD_EDGE': - return { - ...state, - edges: [...state.edges, action.payload], - }; - case 'UPDATE_NODE': - return { - ...state, - nodes: state.nodes.map((node) => - node._uid === action.payload._uid ? action.payload : node, - ), - }; - case 'UPDATE_EDGE': - return { - ...state, - edges: state.edges.map((edge) => - edge._uid === action.payload._uid ? action.payload : edge, - ), - }; - case 'DELETE_NODE': - return { - ...state, - nodes: state.nodes.filter((node) => node._uid !== action.payload), - }; - case 'DELETE_EDGE': - return { - ...state, - edges: state.edges.filter((edge) => edge._uid !== action.payload), - }; - default: - return state; - } -} - -const useNetwork = (initialNetwork = initialState) => { - const [network, dispatch] = useReducer(reducer, initialNetwork); - - const networkHandlers = { - addNode: (node: NcNode) => { - dispatch({ type: 'ADD_NODE', payload: node }); - }, - - addEdge: (edge: NcEdge) => { - dispatch({ type: 'ADD_EDGE', payload: edge }); - }, - - updateNode: (node: NcNode) => { - dispatch({ type: 'UPDATE_NODE', payload: node }); - }, - - updateEdge: (edge: NcEdge) => { - dispatch({ type: 'UPDATE_EDGE', payload: edge }); - }, - - deleteNode: (nodeId: string) => { - dispatch({ type: 'DELETE_NODE', payload: nodeId }); - }, - - deleteEdge: (edgeId: string) => { - dispatch({ type: 'DELETE_EDGE', payload: edgeId }); - }, - }; - - return { network, networkHandlers }; -}; - -export default useNetwork; diff --git a/hooks/useWhatChanged.tsx b/hooks/useWhatChanged.tsx new file mode 100644 index 00000000..992cf43a --- /dev/null +++ b/hooks/useWhatChanged.tsx @@ -0,0 +1,32 @@ +/* eslint-disable no-console */ +import { useEffect, useRef } from 'react'; + +export default function useWhatChanged(props: Record) { + // cache the last set of props + const prev = useRef(props); + + useEffect(() => { + // check each prop to see if it has changed + const changed = Object.entries(props).reduce( + (a, [key, prop]: [string, unknown]) => { + if (prev.current[key] === prop) return a; + return { + ...a, + [key]: { + prev: prev.current[key], + next: prop, + }, + }; + }, + {} as Record, + ); + + if (Object.keys(changed).length > 0) { + console.group('Props That Changed'); + console.log(changed); + console.groupEnd(); + } + + prev.current = props; + }, [props]); +} diff --git a/lib/interviewer/behaviours/withPrompt.js b/lib/interviewer/behaviours/withPrompt.js index 94cfe27a..d7d85559 100644 --- a/lib/interviewer/behaviours/withPrompt.js +++ b/lib/interviewer/behaviours/withPrompt.js @@ -2,8 +2,6 @@ import { useDispatch, useSelector } from 'react-redux'; import { actionCreators as sessionActions } from '../ducks/modules/session'; import { getAllVariableUUIDsByEntity } from '../selectors/protocol'; import { - getIsFirstPrompt, - getIsLastPrompt, getPromptIndex, getPrompts, } from '../selectors/session'; @@ -35,6 +33,43 @@ const processSortRules = (prompts = [], codebookVariables) => { }); }; +/** + * @typedef {Object} Prompt + * @property {string} id + * @property {string} text + * @property {string} [variable] + * @property {string} [createEdge] + * @property {string} [edgeVariable] + * + * @typedef {Array} Prompts + * + * @typedef {Object} PromptState + * @property {number} promptIndex + * @property {Prompt} prompt + * @property {Prompts} prompts + * @property {Function} promptForward + * @property {Function} promptBackward + * @property {Function} setPrompt + * @property {boolean} isLastPrompt + * @property {boolean} isFirstPrompt + * @property {Function} updatePrompt + * + * @returns {PromptState} + * @private + * + * @example + * const { + * promptIndex, + * prompt, + * prompts, + * promptForward, + * promptBackward, + * setPrompt, + * isLastPrompt, + * isFirstPrompt, + * updatePrompt, + * } = usePrompts(); + */ export const usePrompts = () => { const dispatch = useDispatch(); const updatePrompt = (promptIndex) => @@ -45,9 +80,9 @@ export const usePrompts = () => { const processedPrompts = processSortRules(prompts, codebookVariables); - const isFirstPrompt = useSelector(getIsFirstPrompt); - const isLastPrompt = useSelector(getIsLastPrompt); const promptIndex = useSelector(getPromptIndex); + const isFirstPrompt = prompts.length === 0; + const isLastPrompt = promptIndex === prompts.length - 1; const promptForward = () => { updatePrompt((promptIndex + 1) % processedPrompts.length); @@ -59,6 +94,10 @@ export const usePrompts = () => { ); }; + const setPrompt = (index) => { + updatePrompt(index); + }; + const currentPrompt = () => { return processedPrompts[promptIndex] ?? { id: null }; }; @@ -69,6 +108,7 @@ export const usePrompts = () => { prompts: processedPrompts, promptForward, promptBackward, + setPrompt, isLastPrompt, isFirstPrompt, updatePrompt, diff --git a/lib/interviewer/components/MultiNodeBucket.js b/lib/interviewer/components/MultiNodeBucket.js index ad3610de..f89ab253 100644 --- a/lib/interviewer/components/MultiNodeBucket.js +++ b/lib/interviewer/components/MultiNodeBucket.js @@ -7,6 +7,7 @@ import { DragSource } from '../behaviours/DragAndDrop'; import createSorter from '../utils/createSorter'; import { NodeTransition } from './NodeList'; import { AnimatePresence, motion } from 'framer-motion'; +import useReadyForNextStage from '../hooks/useReadyForNextStage'; const EnhancedNode = DragSource(Node); @@ -28,9 +29,21 @@ const MultiNodeBucket = (props) => { setSortedNodes(sorted); }, [nodes, sortOrder, listId]); + // Set the ready to advance state when there are no items left in the bucket + const { updateReady } = useReadyForNextStage(); + + useEffect(() => { + updateReady(sortedNodes.length === 0); + + return () => { + updateReady(false); + } + }, [sortedNodes.length, updateReady]); + return ( + {sortedNodes.length === 0 && (
No items to place
)} {sortedNodes.slice(0, 3).map((node, index) => ( void; moveForward: () => void; - canMoveForward: boolean; - canMoveBackward: boolean; - progress: number; - isReadyForNextStage: boolean; + pulseNext: boolean; + disabled: boolean; }; const Navigation = ({ moveBackward, moveForward, - canMoveForward, - canMoveBackward, - progress, - isReadyForNextStage, + pulseNext, + disabled, }: NavigationProps) => { + const { progress, canMoveForward, canMoveBackward } = + useSelector(getNavigationInfo); + return (
- {/* */} - +
@@ -63,10 +66,10 @@ const Navigation = ({ className={cn( 'bg-[var(--nc-light-background)]', 'hover:bg-[var(--nc-primary)]', - isReadyForNextStage && 'animate-pulse', + pulseNext && 'animate-pulse bg-success', )} onClick={moveForward} - disabled={!canMoveForward} + disabled={disabled || !canMoveForward} > diff --git a/lib/interviewer/components/NodeBin.js b/lib/interviewer/components/NodeBin.js index 21b569a8..08ecbb7c 100644 --- a/lib/interviewer/components/NodeBin.js +++ b/lib/interviewer/components/NodeBin.js @@ -3,7 +3,7 @@ import { compose, withProps } from 'recompose'; import PropTypes from 'prop-types'; import cx from 'classnames'; import { DropTarget, MonitorDropTarget } from '../behaviours/DragAndDrop'; -import usePortal from 'react-useportal' +import { createPortal } from 'react-dom'; /** * Renders a droppable NodeBin which accepts `EXISTING_NODE`. @@ -12,16 +12,13 @@ const NodeBin = ({ willAccept = false, isOver = false, }) => { - const { Portal } = usePortal(); - - const classNames = cx( 'node-bin', { 'node-bin--active': willAccept }, { 'node-bin--hover': willAccept && isOver }, ); - return
; + return createPortal(
, document.body); }; NodeBin.propTypes = { diff --git a/lib/interviewer/components/SettingsMenu.tsx b/lib/interviewer/components/SettingsMenu.tsx deleted file mode 100644 index ccd4f502..00000000 --- a/lib/interviewer/components/SettingsMenu.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { SettingsIcon } from 'lucide-react'; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, -} from '~/components/ui/sheet'; - -import { - actionCreators as deviceSettingsActions, - type DeviceSettings, -} from '~/lib/interviewer/ducks/modules/deviceSettings'; -import { Switch as SwitchUI } from '~/components/ui/switch'; -import { useDispatch, useSelector } from 'react-redux'; - -type SettingsMenuState = { - deviceSettings: Pick; -}; - -export const SettingsMenu = () => { - const dispatch = useDispatch(); - const enableExperimentalSounds: boolean = useSelector( - (state: SettingsMenuState) => state.deviceSettings.enableExperimentalSounds, - ); - - const toggleExperimentalSounds = () => { - dispatch(deviceSettingsActions.toggleSetting('enableExperimentalSounds')); - }; - - return ( - - - - - - - - Interview Settings - - -
-
-

- Use experimental interaction sounds? -

-

- This feature adds interaction sounds to common actions in the - app, which may improve the interview experience. These sounds - were developed by our summer intern, Anika Wilsnack. -

-
- -
-
-
-
-
- ); -}; diff --git a/lib/interviewer/containers/Interfaces/CategoricalBin.js b/lib/interviewer/containers/Interfaces/CategoricalBin.js index f3082843..dda07b97 100644 --- a/lib/interviewer/containers/Interfaces/CategoricalBin.js +++ b/lib/interviewer/containers/Interfaces/CategoricalBin.js @@ -1,6 +1,5 @@ import React from 'react'; import { compose } from 'redux'; -import { connect } from 'react-redux'; import { withStateHandlers } from 'recompose'; import PropTypes from 'prop-types'; import { entityAttributesProperty } from '@codaco/shared-consts'; diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js b/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js index bfa6cf88..70b9fe83 100644 --- a/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js +++ b/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; import BooleanOption from '~/lib/ui/components/Boolean/BooleanOption'; @@ -9,11 +9,10 @@ import { usePrompts } from '../../../behaviours/withPrompt'; import { getEdgeColor, getNetworkEdges } from '../../../selectors/network'; import { getPairs, getNodePair } from './helpers'; import useSteps from './useSteps'; -import useNetworkEdgeState from './useEdgeState'; +import useEdgeState from './useEdgeState'; import useAutoAdvance from './useAutoAdvance'; import Pair from './Pair'; import { get } from '../../../utils/lodash-replacements'; -import { useNavigationHelpers } from '~/lib/interviewer/hooks/useNavigationHelpers'; import { getNetworkNodesForType } from '~/lib/interviewer/selectors/interface'; import usePropSelector from '~/lib/interviewer/hooks/usePropSelector'; @@ -45,131 +44,111 @@ const introVariants = { * Dyad Census Interface */ const DyadCensus = (props) => { - const { registerBeforeNext, stage } = props; + const { registerBeforeNext, stage, getNavigationHelpers } = props; + + const { + moveForward, + } = getNavigationHelpers(); const [isIntroduction, setIsIntroduction] = useState(true); const [isForwards, setForwards] = useState(true); const [isValid, setIsValid] = useState(true); - const { moveBackward, moveForward, currentStage } = useNavigationHelpers(); - - const { prompt, promptIndex } = usePrompts(); + const { + prompt: { + createEdge, + }, + promptIndex, + prompts, + } = usePrompts(); const nodes = usePropSelector(getNetworkNodesForType, props); const edges = usePropSelector(getNetworkEdges, props); const edgeColor = usePropSelector(getEdgeColor, { - type: prompt?.createEdge, + type: createEdge, }); - const pairs = getPairs(nodes); + const pairs = getPairs(nodes); // Number of pairs times number of prompts e.g. `[3, 3, 3]` - const steps = Array(stage.prompts.length).fill(get(pairs, 'length', 0)); + const steps = Array(prompts.length).fill(get(pairs, 'length', 0)); + const [stepsState, nextStep, previousStep] = useSteps(steps); const pair = get(pairs, stepsState.substep, null); + const [fromNode, toNode] = getNodePair(nodes, pair); - const [hasEdge, setEdge, isTouched, isChanged] = useNetworkEdgeState( - edges, - prompt?.createEdge, // Type of edge to create + const { hasEdge, setEdge, isTouched, isChanged } = useEdgeState( pair, - prompt.id, - currentStage, - [stepsState.step], + edges, + `${stepsState.step}_${stepsState.substep}`, ); - const next = useCallback(() => { - setForwards(true); - setIsValid(true); + registerBeforeNext(async (direction) => { + if (direction === 'forwards') { + setForwards(true); + setIsValid(true); - if (isIntroduction) { - // If there are no steps, clicking next should advance the stage - if (stepsState.totalSteps === 0) { - moveForward({ forceChangeStage: true }); - return; - } + if (isIntroduction) { + // If there are no steps, clicking next should move to the next stage + if (stepsState.totalSteps === 0) { + return 'FORCE'; + } - setIsIntroduction(false); - return; - } + setIsIntroduction(false); + return false; + } - // check value has been set - if (hasEdge === null) { - setIsValid(false); - return; - } + // check value has been set + if (hasEdge === null) { + setIsValid(false); + return false; // don't move to next stage + } - if (stepsState.isStageEnd) { - moveForward(); - } + if (stepsState.isStepEnd || stepsState.isEnd) { + // IMPORTANT! `nextStep()` needs to be called still, so that the useSteps + // state reflects the change in substep! Alternatively it could be + // refactored to use the prompt index in place of the step. + nextStep(); + return true; // Advance the prompt or the stage as appropriate + } - if (stepsState.isEnd) { - return; + nextStep(); + return false; } - nextStep(); - }, [ - hasEdge, - isIntroduction, - moveForward, - nextStep, - stepsState.isEnd, - stepsState.isStageEnd, - stepsState.totalSteps, - ]); - - const back = useCallback(() => { - setForwards(false); - setIsValid(true); + if (direction === 'backwards') { + setForwards(false); + setIsValid(true); - if (stepsState.isStart && !isIntroduction) { - setIsIntroduction(true); - return; - } - - if (stepsState.isStageStart) { - if (stepsState.totalSteps === 0) { - moveBackward({ forceChangeStage: true }); - return; + if (isIntroduction) { + return true; } - moveBackward(); - } - if (stepsState.isStart) { - return; - } - - previousStep(); - }, [ - isIntroduction, - moveBackward, - previousStep, - stepsState.isStageStart, - stepsState.isStart, - stepsState.totalSteps, - ]); + if (stepsState.isStart) { + setIsIntroduction(true); // Go back to the introduction + return false; + } - const beforeNext = - (direction) => { - if (direction === 'backwards') { - back(); - } else { - next(); + if (stepsState.isStepStart) { + // IMPORTANT! `previousStep()` needs to be called still, so that the useSteps + // state reflects the change in substep! Alternatively it could be + // refactored to use the prompt index in place of the step. + previousStep(); + return true; // Go back to the previous prompt } + previousStep(); return false; - }; - - registerBeforeNext(beforeNext); + } + }); - useAutoAdvance(next, isTouched, isChanged); + useAutoAdvance(moveForward, isTouched, isChanged); const handleChange = (nextValue) => () => { // 'debounce' clicks, one click (isTouched) should start auto-advance // so ignore further clicks - if (isTouched) { - return; - } + if (isTouched) { return; } setEdge(nextValue); }; @@ -218,7 +197,7 @@ const DyadCensus = (props) => {
{ const timer = useRef(); const next = useRef(); - const delay = getCSSVariableAsNumber('--animation-duration-standard-ms'); next.current = _next; // Auto advance useEffect(() => { if (isTouched) { - if (timer.current) { - clearTimeout(timer.current); - } + if (timer.current) { clearTimeout(timer.current); } if (isChanged) { + const delay = getCSSVariableAsNumber('--animation-duration-standard-ms'); timer.current = setTimeout(next.current, delay); } else { next.current(); @@ -32,12 +30,10 @@ const useAutoAdvance = (_next, isTouched, isChanged) => { } return () => { - if (!timer.current) { - return () => undefined; - } + if (!timer.current) { return; } return clearTimeout(timer.current); }; - }, [isTouched, delay, isChanged]); + }, [isTouched, isChanged]); }; -export default useAutoAdvance; +export default useAutoAdvance; \ No newline at end of file diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.js b/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.js deleted file mode 100644 index 60c59227..00000000 --- a/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.js +++ /dev/null @@ -1,153 +0,0 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; -import { useState, useEffect } from 'react'; -import { actionCreators as sessionActions } from '../../../ducks/modules/session'; -import { useDispatch } from 'react-redux'; - -export const getEdgeInNetwork = (edges, pair, edgeType) => { - if (!pair) { - return null; - } - const [a, b] = pair; - - const edge = edges.find( - ({ from, to, type }) => - type === edgeType && - ((from === a && to === b) || (to === a && from === b)), - ); - - if (!edge) { - return null; - } - - return edge; -}; - -export const matchEntry = - (prompt, pair) => - ([p, a, b]) => - (p === prompt && a === pair[0] && b === pair[1]) || - (p === prompt && b === pair[0] && a === pair[1]); - -export const getIsPreviouslyAnsweredNo = (state, prompt, pair) => { - if (!Array.isArray(state) || pair.length !== 2) { - return false; - } - - const answer = state.find(matchEntry(prompt, pair)); - - if (answer && answer[3] === false) { - return true; - } - - return false; -}; - -export const stageStateReducer = (state = [], { pair, prompt, value }) => { - // Remove existing entry, if it exists, and add new one on the end - if (!Array.isArray(state) || pair.length !== 2) { - return false; - } - const newState = [ - ...state.filter((item) => !matchEntry(prompt, pair)(item)), - [prompt, ...pair, value], - ]; - - return newState; -}; - -/** - * Manages a virtual edge state between the current pair, - * taking into account where we are in the stage, and the - * actual state of the edge in the network. - * - * The latest Redux version would allow the removal of - * dispatch, and the passed in state (edges, edgeType, stageState). - * - * @param {array} edges - List of all the edges relevant to this stage - * @param {string} edgeType - Type of edge relevant to this prompt - * @param {array} pair - Pair of node ids in format `[a, b]` - * @param {boolean} stageState - Tracked choices in redux state - * @param {array} deps - If these deps are changed, reset - */ -const useEdgeState = ( - edges, - edgeType, - pair, - promptIndex, - stageState, - deps, -) => { - const dispatch = useDispatch(); - - const [edgeState, setEdgeState] = useState( - getEdgeInNetwork(edges, pair, edgeType), - ); - - const [isTouched, setIsTouched] = useState(false); - const [isChanged, setIsChanged] = useState(false); - - const getHasEdge = () => { - if (!pair) { - return null; - } - - // Either we set a value for this or it already has an edge - if (edgeState !== null) { - return !!edgeState; - } - - // Check if this pair was marked as no before - if (getIsPreviouslyAnsweredNo(stageState, promptIndex, pair)) { - return false; - } - - // Otherwise consider this blank - return null; - }; - - const setEdge = (value = true) => { - if (!pair) { - return; - } - - const existingEdge = getEdgeInNetwork(edges, pair, edgeType); - - setEdgeState(value); - setIsChanged(getHasEdge() !== value); - setIsTouched(true); - - const addEdge = value && !existingEdge; - const removeEdge = !value && existingEdge; - - const newStageState = stageStateReducer(stageState, { - pair, - prompt: promptIndex, - value, - }); - - if (addEdge) { - dispatch( - sessionActions.addEdge({ from: pair[0], to: pair[1], type: edgeType }), - ); - } else if (removeEdge) { - dispatch( - sessionActions.removeEdge(existingEdge[entityPrimaryKeyProperty]), - ); - } - - dispatch(sessionActions.updateStageState(newStageState)); - }; - - // we're only going to reset manually (when deps change), because - // we are internally keeping track of the edge state. - useEffect(() => { - setEdgeState(getEdgeInNetwork(edges, pair, edgeType)); - setIsTouched(false); - setIsChanged(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); - - return [getHasEdge(), setEdge, isTouched, isChanged]; -}; - -export default useEdgeState; diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.ts b/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.ts new file mode 100644 index 00000000..87cbb617 --- /dev/null +++ b/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.ts @@ -0,0 +1,190 @@ +import { + entityAttributesProperty, + entityPrimaryKeyProperty, + type NcEdge, +} from '@codaco/shared-consts'; +import { useDispatch, useSelector } from 'react-redux'; +import { usePrompts } from '~/lib/interviewer/behaviours/withPrompt'; +import { edgeExists } from '~/lib/interviewer/ducks/modules/network'; +import { getStageMetadata } from '~/lib/interviewer/selectors/session'; +import { actionCreators as sessionActions } from '../../../ducks/modules/session'; +import type { + StageMetadata, + StageMetadataEntry, +} from '~/lib/interviewer/store'; +import { type AnyAction } from '@reduxjs/toolkit'; +import { useEffect, useState } from 'react'; + +export const matchEntry = + (promptIndex: number, pair: Pair) => + ([p, a, b]: StageMetadataEntry) => + (p === promptIndex && a === pair[0] && b === pair[1]) || + (p === promptIndex && b === pair[0] && a === pair[1]); + +const getStageMetadataResponse = ( + state: StageMetadata | undefined, + promptIndex: number, + pair: Pair, +) => { + // If the state is not an array or the pair is not a pair, return false + if (!Array.isArray(state) || pair.length !== 2) { + return null; + } + + const answer = state.find(matchEntry(promptIndex, pair)); + return answer ? answer[3] : null; +}; + +type Pair = [string, string]; + +// Hack to work around edge type not being correct. +type NcEdgeWithId = NcEdge & { [entityPrimaryKeyProperty]: string }; + +export default function useEdgeState( + pair: Pair | null, + edges: NcEdgeWithId[], + deps: string, +) { + const dispatch = useDispatch(); + const stageMetadata = useSelector(getStageMetadata); + const [isTouched, setIsTouched] = useState(false); + const [isChanged, setIsChanged] = useState(false); + const { prompt, promptIndex } = usePrompts(); + const edgeType = prompt.createEdge!; + const edgeVariable = prompt.edgeVariable; + + const existingEdgeId = + (pair && edgeExists(edges, pair[0], pair[1], edgeType)) ?? false; + const getEdgeVariableValue = () => { + if (!edgeVariable) { + return undefined; + } + + const edge = edges.find( + (e) => e[entityPrimaryKeyProperty] === existingEdgeId, + ); + return edge?.[entityAttributesProperty]?.[edgeVariable] ?? undefined; + }; + + // TODO: update this to handle edge variables for TieStrengthCensus + const hasEdge = () => { + if (!pair) { + return false; + } + + const edgeExistsInMetadata = getStageMetadataResponse( + stageMetadata, + promptIndex, + pair, + ); + + // If the edge exists in the network, return true + if (existingEdgeId) { + return true; + } + + if (edgeExistsInMetadata === null) { + return null; + } + + return false; + }; + + // If value is boolean we are creating or deleting the edge. + // If value is string, we are creating an edge with a variable value, + // or updating the edge variable value. + const setEdge = (value: boolean | string | number) => { + setIsChanged(hasEdge() !== value); + setIsTouched(true); + + if (!pair) { + return; + } + + if (value === true) { + dispatch( + sessionActions.addEdge({ + from: pair[0], + to: pair[1], + type: edgeType, + }) as unknown as AnyAction, + ); + + const newStageMetadata = [ + ...(stageMetadata?.filter( + (item) => !matchEntry(promptIndex, pair)(item), + ) ?? []), + ]; + + dispatch( + sessionActions.updateStageMetadata( + newStageMetadata, + ) as unknown as AnyAction, + ); + } + + if (value === false) { + if (existingEdgeId) { + dispatch( + sessionActions.removeEdge(existingEdgeId) as unknown as AnyAction, + ); + } + + // Construct new stage metadata from scratch + // if (!stageMetadata) {} + + const newStageMetadata = [ + ...(stageMetadata?.filter( + (item) => !matchEntry(promptIndex, pair)(item), + ) ?? []), + [promptIndex, ...pair, value], + ]; + + dispatch( + sessionActions.updateStageMetadata( + newStageMetadata, + ) as unknown as AnyAction, + ); + } + + if (typeof value === 'string' || typeof value === 'number') { + if (existingEdgeId) { + dispatch( + sessionActions.updateEdge( + existingEdgeId, + {}, + { + [edgeVariable!]: value, + }, + ) as unknown as AnyAction, + ); + } else { + dispatch( + sessionActions.addEdge( + { + from: pair[0], + to: pair[1], + type: edgeType, + }, + { + [edgeVariable!]: value, + }, + ) as unknown as AnyAction, + ); + } + } + }; + + useEffect(() => { + setIsTouched(false); + setIsChanged(false); + }, [deps]); + + return { + hasEdge: hasEdge(), // If an edge exists. null if not yet determined. + edgeVariableValue: getEdgeVariableValue(), // The value of the edge variable + setEdge, // Set the edge to true or false. Triggers redux actions to create or delete edges. + isTouched, + isChanged, + }; +} diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/useSteps.js b/lib/interviewer/containers/Interfaces/DyadCensus/useSteps.js deleted file mode 100644 index c0abcdf6..00000000 --- a/lib/interviewer/containers/Interfaces/DyadCensus/useSteps.js +++ /dev/null @@ -1,110 +0,0 @@ -import { useState } from 'react'; - -/* given a map of steps, where are we given a specific 'total' step number */ -const getSubStep = (steps, nextStep) => { - const [r] = steps.reduce( - ([result, target], step, index) => { - if (step > target && result === null) { - return [{ step: target, stage: index }]; - } - - if (step <= target) { - return [result, target - step]; - } - - return [result, target]; - }, - [null, nextStep], - ); - - return r; -}; - -// state reducer for steps state -const stateReducer = - ({ step, substep, stage, direction }) => - (state) => { - const progress = step > state.progress ? step : state.progress; - - return { - ...state, - step, - progress, - substep, - stage, - direction, - isCompletedStep: progress > step, - isStageStart: substep === 0, - isStageEnd: substep >= state.steps[stage] - 1, - isStart: step === 0, - isEnd: step >= state.totalSteps - 1, - }; - }; - -/** - * Models 'substeps' in prompts, which allows us to keep track - * of overall progress, and where were are at within each - * prompt. - * - * @param {array} steps - map of steps per prompt, e.g. [3, 2, 1] - */ -const useSteps = (steps = []) => { - const totalSteps = steps.reduce((count, step) => count + step, 0); - - const initialValues = { - steps, - totalSteps, - progress: null, - }; - - const [state, setState] = useState( - stateReducer({ - step: 0, - substep: 0, - stage: 0, - direction: 'forward', - })(initialValues), - ); - - const next = () => { - const nextStep = state.step + 1; - - if (nextStep >= totalSteps) { - return; - } - - const substep = getSubStep(steps, nextStep); - - setState( - stateReducer({ - step: nextStep, - substep: substep.step, - stage: substep.stage, - direction: 'forward', - }), - ); - }; - - const previous = () => { - const nextStep = state.step - 1; - - if (nextStep < 0) { - return; - } - - const substep = getSubStep(steps, nextStep); - - setState( - stateReducer({ - step: nextStep, - substep: substep.step, - stage: substep.stage, - direction: 'backward', - }), - ); - }; - - return [state, next, previous]; -}; - -export default useSteps; diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/useSteps.ts b/lib/interviewer/containers/Interfaces/DyadCensus/useSteps.ts new file mode 100644 index 00000000..d5e4bb72 --- /dev/null +++ b/lib/interviewer/containers/Interfaces/DyadCensus/useSteps.ts @@ -0,0 +1,50 @@ +import { useState } from 'react'; + +type Steps = number[]; + +export default function useSteps(steps: Steps) { + const [currentStep, setCurrentStep] = useState(0); + const [currentSubStep, setCurrentSubStep] = useState(0); + + // Total = number of steps * number of substeps + const totalSteps = steps.reduce((acc, val) => acc + val, 0); + + const next = () => { + if (currentSubStep < steps[currentStep]! - 1) { + setCurrentSubStep((prev) => prev + 1); + } else if (currentStep < steps.length - 1) { + setCurrentStep((prev) => prev + 1); + setCurrentSubStep(0); + } + }; + + const back = () => { + if (currentSubStep > 0) { + setCurrentSubStep((prev) => prev - 1); + } else if (currentStep > 0) { + setCurrentStep((prev) => prev - 1); + setCurrentSubStep(steps[currentStep - 1]! - 1); + } + }; + + const isStepEnd = currentSubStep === steps[currentStep]! - 1; + const isEnd = currentStep === steps.length - 1 && isStepEnd; + const isStepStart = currentSubStep === 0; + const isStart = currentStep === 0 && isStepStart; + + const value = [ + { + totalSteps, + step: currentStep, + substep: currentSubStep, + isStepEnd, // we are on the last substep of the current step + isEnd, // we are on the last step and the last substep + isStepStart, // we are on the first substep of the current step + isStart, // we are on the first step and the first substep + }, + next, // move to the next step or substep + back, // move to the previous step or substep + ]; + + return value; +} diff --git a/lib/interviewer/containers/Interfaces/NameGenerator.js b/lib/interviewer/containers/Interfaces/NameGenerator.js index a9048cd5..65eb9c48 100644 --- a/lib/interviewer/containers/Interfaces/NameGenerator.js +++ b/lib/interviewer/containers/Interfaces/NameGenerator.js @@ -34,6 +34,18 @@ import { getNodeColor, getNodeTypeLabel } from '../../selectors/network'; import usePropSelector from '../../hooks/usePropSelector'; import { getAdditionalAttributesSelector } from '../../selectors/prop'; +export const nameGeneratorHandleBeforeLeaving = (isLastPrompt, stageNodeCount, minNodes, setShowMinWarning) => (direction) => { + if ( + (isLastPrompt && direction === 'forwards') && + stageNodeCount < minNodes + ) { + setShowMinWarning(true); + return false; + } + + return true; +}; + const NameGenerator = (props) => { const { registerBeforeNext, stage } = props; @@ -41,7 +53,7 @@ const NameGenerator = (props) => { const interfaceRef = useRef(null); - const { prompt, isFirstPrompt, isLastPrompt, promptIndex } = usePrompts(); + const { prompt, isLastPrompt, promptIndex } = usePrompts(); const [selectedNode, setSelectedNode] = useState(null); const [showMinWarning, setShowMinWarning] = useState(false); @@ -78,26 +90,7 @@ const NameGenerator = (props) => { } }, [stageNodeCount, minNodes]); - // Prevent leaving the stage if the minimum number of nodes has not been met - const handleBeforeLeaving = (direction) => { - const isLeavingStage = - (isFirstPrompt && direction === 'backwards') || (isLastPrompt && direction === 'forwards'); - - // Implementation quirk that destination is only provided when navigation - // is triggered by Stages Menu. Use this to skip message if user has - // navigated directly using stages menu. - if ( - isLeavingStage && - stageNodeCount < minNodes - ) { - setShowMinWarning(true); - return false; - } - - return true; - }; - - registerBeforeNext(handleBeforeLeaving); + registerBeforeNext(nameGeneratorHandleBeforeLeaving(isLastPrompt, stageNodeCount, minNodes, setShowMinWarning)); /** * Drop node handler @@ -156,6 +149,11 @@ const NameGenerator = (props) => { />
+ meta.itemType === 'EXISTING_NODE'} + dropHandler={(meta) => removeNode(meta[entityPrimaryKeyProperty])} + id="NODE_BIN" + /> { createPortal( , @@ -196,11 +194,6 @@ const NameGenerator = (props) => { onShowForm={() => setShowMinWarning(false)} /> )} - meta.itemType === 'EXISTING_NODE'} - dropHandler={(meta) => removeNode(meta[entityPrimaryKeyProperty])} - id="NODE_BIN" - />
); }; diff --git a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/NameGeneratorRoster.js b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/NameGeneratorRoster.js index d9a5c992..0271f304 100644 --- a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/NameGeneratorRoster.js +++ b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/NameGeneratorRoster.js @@ -8,7 +8,7 @@ import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import cx from 'classnames'; import { motion, AnimatePresence } from 'framer-motion'; -import { isEmpty, isUndefined } from 'lodash'; +import { isEmpty } from 'lodash'; import { DataCard } from '~/lib/ui/components/Cards'; import { entityAttributesProperty, @@ -45,6 +45,7 @@ import { } from '../utils/StageLevelValidation'; import { get } from '../../../utils/lodash-replacements'; import { getAdditionalAttributesSelector } from '~/lib/interviewer/selectors/prop'; +import { nameGeneratorHandleBeforeLeaving } from '../NameGenerator'; const countColumns = (width) => (width < 140 ? 1 : Math.floor(width / 450)); @@ -75,7 +76,7 @@ const NameGeneratorRoster = (props) => { registerBeforeNext, } = props; - const { promptIndex, isFirstPrompt, isLastPrompt } = usePrompts(); + const { promptIndex, isLastPrompt } = usePrompts(); const interfaceRef = useRef(null); @@ -107,28 +108,7 @@ const NameGeneratorRoster = (props) => { const [showMinWarning, setShowMinWarning] = useState(false); - const handleBeforeLeaving = - (direction, destination) => { - const isLeavingStage = - (isFirstPrompt && direction === -1) || - (isLastPrompt && direction === 1); - - // Implementation quirk that destination is only provided when navigation - // is triggered by Stages Menu. Use this to skip message if user has - // navigated directly using stages menu. - if ( - isUndefined(destination) && - isLeavingStage && - stageNodeCount < minNodes - ) { - setShowMinWarning(true); - return false; - } - - return true; - }; - - registerBeforeNext(handleBeforeLeaving); + registerBeforeNext(nameGeneratorHandleBeforeLeaving(isLastPrompt, stageNodeCount, minNodes, setShowMinWarning)); useEffect(() => { setShowMinWarning(false); diff --git a/lib/interviewer/containers/Interfaces/TieStrengthCensus/TieStrengthCensus.js b/lib/interviewer/containers/Interfaces/TieStrengthCensus/TieStrengthCensus.js index 43aea979..796fcecd 100644 --- a/lib/interviewer/containers/Interfaces/TieStrengthCensus/TieStrengthCensus.js +++ b/lib/interviewer/containers/Interfaces/TieStrengthCensus/TieStrengthCensus.js @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; import BooleanOption from '~/lib/ui/components/Boolean/BooleanOption'; @@ -6,16 +6,16 @@ import { AnimatePresence, motion } from 'framer-motion'; import { Markdown } from '~/lib/ui/components/Fields'; import Prompts from '../../../components/Prompts'; import { usePrompts } from '../../../behaviours/withPrompt'; -import { getNetworkNodesForType, getVariableOptions } from '../../../selectors/interface'; +import { getNetworkNodesForType } from '../../../selectors/interface'; import { getEdgeColor, getNetworkEdges as getEdges } from '../../../selectors/network'; import { getPairs, getNodePair } from './helpers'; -import useSteps from './useSteps'; -import useNetworkEdgeState from './useEdgeState'; -import useAutoAdvance from './useAutoAdvance'; import Pair from './Pair'; import { get } from '../../../utils/lodash-replacements'; -import { useNavigationHelpers } from '~/lib/interviewer/hooks/useNavigationHelpers'; import usePropSelector from '~/lib/interviewer/hooks/usePropSelector'; +import useAutoAdvance from '../DyadCensus/useAutoAdvance'; +import useSteps from '../DyadCensus/useSteps'; +import useEdgeState from '../DyadCensus/useEdgeState'; +import { getProtocolCodebook } from '~/lib/interviewer/selectors/protocol'; const fadeVariants = { show: { opacity: 1, transition: { duration: 0.5 } }, @@ -48,160 +48,116 @@ const TieStrengthCensus = (props) => { const { registerBeforeNext, stage, + getNavigationHelpers, } = props; + const { + moveForward, + } = getNavigationHelpers(); + const [isIntroduction, setIsIntroduction] = useState(true); const [isForwards, setForwards] = useState(true); const [isValid, setIsValid] = useState(true); - // Number of pairs times number of prompts e.g. `[3, 3, 3]` - const steps = Array(stage.prompts.length).fill(get(pairs, 'length', 0)); - const [stepsState, nextStep, previousStep] = useSteps(steps); - const { promptIndex, - prompt, + prompt: { + createEdge, + edgeVariable, + negativeLabel + }, + prompts } = usePrompts(); const nodes = usePropSelector(getNetworkNodesForType, props); const edges = usePropSelector(getEdges, props); const edgeColor = usePropSelector(getEdgeColor, { - type: prompt?.createEdge + type: createEdge }) - const edgeVariableOptions = usePropSelector(getVariableOptions, { - subject: { - entity: 'edge', - type: prompt?.createEdge, - }, - variable: prompt?.edgeVariable, - }); - + const codebook = usePropSelector(getProtocolCodebook, props); + const edgeVariableOptions = get(codebook, ['edge', createEdge, 'variables', edgeVariable, 'options'], []); const pairs = getPairs(nodes); - + // Number of pairs times number of prompts e.g. `[3, 3, 3]` + const steps = Array(prompts.length).fill(get(pairs, 'length', 0)); + const [stepsState, nextStep, previousStep] = useSteps(steps); const pair = get(pairs, stepsState.substep, null); const [fromNode, toNode] = getNodePair(nodes, pair); - const { - moveBackward, - moveForward, - currentStep, - } = useNavigationHelpers(); - - - - const { - createEdge, // Edge type to create - edgeVariable, // Edge variable to set value of - negativeLabel, // Label for the "reject" option - } = prompt; - // hasEdge: // - false: user denied // - null: not yet decided // - true: edge exists - const [hasEdge, edgeVariableValue, setEdge, isTouched, isChanged] = - useNetworkEdgeState( - edges, - createEdge, // Type of edge to create - edgeVariable, // Edge ordinal variable + const { hasEdge, edgeVariableValue, setEdge, isTouched, isChanged } = + useEdgeState( pair, - prompt.id, - currentStep, - [stepsState.step], + edges, + `${stepsState.step}_${stepsState.substep}`, ); - const next = useCallback(() => { - setForwards(true); - setIsValid(true); + registerBeforeNext(async (direction) => { + if (direction === 'forwards') { + setForwards(true); + setIsValid(true); - if (isIntroduction) { - // If there are no steps, clicking next should advance the stage - if (stepsState.totalSteps === 0) { - moveForward({ forceChangeStage: true }); - return; - } + if (isIntroduction) { + // If there are no steps, clicking next should move to the next stage + if (stepsState.totalSteps === 0) { + return 'FORCE'; + } - setIsIntroduction(false); - return; - } + setIsIntroduction(false); + return false; + } - // check that the edgeVariable has a value - // hasEdge is false when user has declined, but null when it doesn't exist yet - // edgeVariableValue is null when edge doesn't exist, or variable isn't set - if (hasEdge === null && edgeVariableValue === null) { - setIsValid(false); - return; - } + // check value has been set + if (hasEdge === null) { + setIsValid(false); + return false; // don't move to next stage + } - if (stepsState.isStageEnd) { - moveForward(); - } + if (stepsState.isStepEnd || stepsState.isEnd) { + // IMPORTANT! `nextStep()` needs to be called still, so that the useSteps + // state reflects the change in substep! Alternatively it could be + // refactored to use the prompt index in place of the step. + nextStep(); + return true; // Advance the prompt or the stage as appropriate + } - if (stepsState.isEnd) { - return; + nextStep(); + return false; } - nextStep(); - }, [ - edgeVariableValue, - hasEdge, - isIntroduction, - moveForward, - nextStep, - stepsState.isEnd, - stepsState.isStageEnd, - stepsState.totalSteps, - ]); - - const back = useCallback(() => { - setForwards(false); - setIsValid(true); + if (direction === 'backwards') { + setForwards(false); + setIsValid(true); - if (stepsState.isStart && !isIntroduction) { - setIsIntroduction(true); - return; - } - - if (stepsState.isStageStart) { - if (stepsState.totalSteps === 0) { - moveBackward({ forceChangeStage: true }); - return; + if (isIntroduction) { + return true; } - moveBackward(); - } - if (stepsState.isStart) { - return; - } - - previousStep(); - }, [ - isIntroduction, - moveBackward, - previousStep, - stepsState.isStageStart, - stepsState.isStart, - stepsState.totalSteps, - ]); + if (stepsState.isStart) { + setIsIntroduction(true); // Go back to the introduction + return false; + } - const beforeNext = - (direction) => { - if (direction === 'backwards') { - back(); - } else { - next(); + if (stepsState.isStepStart) { + // IMPORTANT! `previousStep()` needs to be called still, so that the useSteps + // state reflects the change in substep! Alternatively it could be + // refactored to use the prompt index in place of the step. + previousStep(); + return true; // Go back to the previous prompt } + previousStep(); return false; - }; - - registerBeforeNext(beforeNext); + } + }); - useAutoAdvance(next, isTouched, isChanged); + useAutoAdvance(moveForward, isTouched, isChanged); const handleChange = (nextValue) => () => { // 'debounce' clicks, one click (isTouched) should start auto-advance @@ -257,7 +213,7 @@ const TieStrengthCensus = (props) => {
{ - const timer = useRef(); - const next = useRef(); - const delay = getCSSVariableAsNumber('--animation-duration-standard-ms'); - - next.current = _next; - - // Auto advance - useEffect(() => { - if (isTouched) { - if (timer.current) { - clearTimeout(timer.current); - } - - if (isChanged) { - timer.current = setTimeout(next.current, delay); - } else { - next.current(); - } - } - - return () => { - if (!timer.current) { - return () => undefined; - } - return clearTimeout(timer.current); - }; - }, [isTouched, delay, isChanged]); -}; - -export default useAutoAdvance; diff --git a/lib/interviewer/containers/Interfaces/TieStrengthCensus/useEdgeState.js b/lib/interviewer/containers/Interfaces/TieStrengthCensus/useEdgeState.js deleted file mode 100644 index 704c50a7..00000000 --- a/lib/interviewer/containers/Interfaces/TieStrengthCensus/useEdgeState.js +++ /dev/null @@ -1,220 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; -import { actionCreators as sessionActions } from '../../../ducks/modules/session'; -import { get } from '../../../utils/lodash-replacements'; -import { useDispatch } from 'react-redux'; - -export const getEdgeInNetwork = (edges, pair, edgeType) => { - if (!pair) { - return null; - } - const [a, b] = pair; - - const edge = edges.find( - ({ from, to, type }) => - type === edgeType && - ((from === a && to === b) || (to === a && from === b)), - ); - - if (!edge) { - return null; - } - - return edge; -}; - -const edgeExistsInNetwork = (edges, pair, edgeType) => - !!getEdgeInNetwork(edges, pair, edgeType); - -export const matchEntry = - (prompt, pair) => - ([p, a, b]) => - (p === prompt && a === pair[0] && b === pair[1]) || - (p === prompt && b === pair[0] && a === pair[1]); - -export const getIsPreviouslyAnsweredNo = (state, prompt, pair) => { - if (!Array.isArray(state) || pair.length !== 2) { - return false; - } - - const answer = state.find(matchEntry(prompt, pair)); - - if (answer && answer[3] === false) { - return true; - } - - return false; -}; - -export const stageStateReducer = (state = [], { pair, prompt, value }) => { - // Remove existing entry, if it exists, and add new one on the end - if (!Array.isArray(state) || pair.length !== 2) { - return false; - } - const newState = [ - ...state.filter((item) => !matchEntry(prompt, pair)(item)), - [prompt, ...pair, value], - ]; - - return newState; -}; - -/** - * Manages a virtual edge state between the current pair, - * taking into account where we are in the stage, and the - * actual state of the edge in the network. - * - * The latest Redux version would allow the removal of - * dispatch, and the passed in state (edges, edgeType, stageState). - * - * @param {array} edges - List of all the edges relevant to this stage - * @param {string} edgeType - Type of edge relevant to this prompt - * @param {array} pair - Pair of node ids in format `[a, b]` - * @param {boolean} stageState - Tracked choices in redux state - * @param {array} deps - If these deps are changed, reset - */ -const useEdgeState = ( - edges, - edgeType, - edgeVariable, - pair, - promptIndex, - stageState, - deps, -) => { - const dispatch = useDispatch(); - - // Internal state for if edge exists. True or False - const [edgeState, setEdgeState] = useState( - edgeExistsInNetwork(edges, pair, edgeType), - ); - - // Internal state for edge variable value. `value` or null, - const [edgeValueState, setEdgeValueState] = useState( - get( - getEdgeInNetwork(edges, pair, edgeType), - [entityAttributesProperty, edgeVariable], - null, - ), - ); - - const [isTouched, setIsTouched] = useState(false); - const [isChanged, setIsChanged] = useState(false); - - // Translates edgeState into true/false/null using stageState - // True: edge exists - // False: user declined edge (based on stageState) - // Null: user hasn't decided - const getHasEdge = () => { - if (!pair) { - return null; - } - - // Check if this pair was marked as no before - if (getIsPreviouslyAnsweredNo(stageState, promptIndex, pair)) { - return false; - } - - // If edgeState is false, edge doesn't exist. - return edgeState === false ? null : edgeState; - }; - - // Return current edgeValue - const getEdgeValue = () => { - if (!pair) { - return null; - } - - return edgeValueState; - }; - - // Update edge value - // False for user denying edge - // any value for setting edge variable value - const setEdge = (value) => { - if (!pair) { - return; - } - // Determine what we need to do: - - // If truthy value and edge exists, we are changing an edge - const changeEdge = - value !== false && - edgeExistsInNetwork(edges, pair, edgeType) && - value !== - get(getEdgeInNetwork(edges, pair, edgeType), [ - entityAttributesProperty, - edgeVariable, - ]); - - // If truthy value but no existing edge, adding an edge - const addEdge = - value !== false && !edgeExistsInNetwork(edges, pair, edgeType); - - // If value is false and edge exists, removing an edge - const removeEdge = - value === false && edgeExistsInNetwork(edges, pair, edgeType); - - const existingEdgeID = get( - getEdgeInNetwork(edges, pair, edgeType), - entityPrimaryKeyProperty, - ); - - if (changeEdge) { - dispatch( - sessionActions.updateEdge( - existingEdgeID, - {}, - { [edgeVariable]: value }, - ), - ); - } else if (addEdge) { - dispatch( - sessionActions.addEdge( - { from: pair[0], to: pair[1], type: edgeType }, - { [edgeVariable]: value }, - ), - ); - } else if (removeEdge) { - dispatch(sessionActions.removeEdge(existingEdgeID)); - } - - // We set our internal state to update the return value of the hook - // The interface will update before transitioning to the next step - setEdgeState(value !== false); // Ensure edgeState is true or false - setEdgeValueState(value); // Store the actual value in the internal edgeValue state - setIsChanged(getHasEdge() !== value); // Set changed if we have a new value - setIsTouched(true); - - // Update our private stage state - const newStageState = stageStateReducer(stageState, { - pair, - prompt: promptIndex, - value, - }); - dispatch(sessionActions.updateStageState(newStageState)); - }; - - // we're only going to reset manually (when deps change), because - // we are internally keeping track of the edge state. - useEffect(() => { - setEdgeState(edgeExistsInNetwork(edges, pair, edgeType)); - setEdgeValueState( - get( - getEdgeInNetwork(edges, pair, edgeType), - [entityAttributesProperty, edgeVariable], - null, - ), - ); - setIsTouched(false); - setIsChanged(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); - - return [getHasEdge(), getEdgeValue(), setEdge, isTouched, isChanged]; -}; - -export default useEdgeState; diff --git a/lib/interviewer/containers/Interfaces/TieStrengthCensus/useSteps.js b/lib/interviewer/containers/Interfaces/TieStrengthCensus/useSteps.js deleted file mode 100644 index 3e03c0d9..00000000 --- a/lib/interviewer/containers/Interfaces/TieStrengthCensus/useSteps.js +++ /dev/null @@ -1,108 +0,0 @@ -import { useState } from 'react'; - -/* given a map of steps, where are we given a specific 'total' step number */ -const getSubStep = (steps, nextStep) => { - const [r] = steps.reduce(([result, target], step, index) => { - if (step > target && result === null) { - return [{ step: target, stage: index }]; - } - - if (step <= target) { - return [result, target - step]; - } - - return [result, target]; - }, [null, nextStep]); - - return r; -}; - -// state reducer for steps state -const stateReducer = ({ - step, - substep, - stage, - direction, -}) => (state) => { - const progress = step > state.progress ? step : state.progress; - - return ({ - ...state, - step, - progress, - substep, - stage, - direction, - isCompletedStep: progress > step, - isStageStart: substep === 0, - isStageEnd: substep >= state.steps[stage] - 1, - isStart: step === 0, - isEnd: step >= state.totalSteps - 1, - }); -}; - -/** - * Models 'substeps' in prompts, which allows us to keep track - * of overall progress, and where were are at within each - * prompt. - * - * @param {array} steps - map of steps per prompt, e.g. [3, 2, 1] - */ -const useSteps = ( - steps = [], -) => { - const totalSteps = steps.reduce((count, step) => count + step, 0); - - const initialValues = { - steps, - totalSteps, - progress: null, - }; - - const [state, setState] = useState( - stateReducer({ - step: 0, - substep: 0, - stage: 0, - direction: 'forward', - })(initialValues), - ); - - const next = () => { - const nextStep = state.step + 1; - - if (nextStep >= totalSteps) { - return; - } - - const substep = getSubStep(steps, nextStep); - - setState(stateReducer({ - step: nextStep, - substep: substep.step, - stage: substep.stage, - direction: 'forward', - })); - }; - - const previous = () => { - const nextStep = state.step - 1; - - if (nextStep < 0) { - return; - } - - const substep = getSubStep(steps, nextStep); - - setState(stateReducer({ - step: nextStep, - substep: substep.step, - stage: substep.stage, - direction: 'backward', - })); - }; - - return [state, next, previous]; -}; - -export default useSteps; diff --git a/lib/interviewer/containers/Interfaces/utils/StageLevelValidation.js b/lib/interviewer/containers/Interfaces/utils/StageLevelValidation.js index 9374dcae..582a09ff 100644 --- a/lib/interviewer/containers/Interfaces/utils/StageLevelValidation.js +++ b/lib/interviewer/containers/Interfaces/utils/StageLevelValidation.js @@ -4,6 +4,7 @@ import Icon from '~/lib/ui/components/Icon'; import { v4 as uuid } from 'uuid'; import { defaultTo } from 'lodash'; import { FIRST_LOAD_UI_ELEMENT_DELAY } from './constants'; +import useReadyForNextStage from '~/lib/interviewer/hooks/useReadyForNextStage'; /** * Simple wrapper to add self-dismissing behaviour to a component @@ -162,32 +163,44 @@ export const MinNodesNotMet = SelfDismissingNote(({ minNodes }) => ( )); -export const MaxNodesMet = SelfDismissingNote(() => ( - +export const MaxNodesMet = SelfDismissingNote(() => { + const { updateReady } = useReadyForNextStage(); + + useEffect(() => { + updateReady(true); + + return () => { + updateReady(false); + } + }, [updateReady]); + + return ( -
- -
- -

- You have added the maximum number of items for this screen. Click the - down arrow to continue. -

+ +
+ +
+ +

+ You have added the maximum number of items for this screen. Click the + down arrow to continue. +

+
-
-)); + ); +}); export const minNodesWithDefault = (stageValue) => defaultTo(stageValue, 0); export const maxNodesWithDefault = (stageValue) => diff --git a/lib/interviewer/containers/NodeForm.js b/lib/interviewer/containers/NodeForm.js index 74d153fb..0ac87039 100644 --- a/lib/interviewer/containers/NodeForm.js +++ b/lib/interviewer/containers/NodeForm.js @@ -101,22 +101,17 @@ const NodeForm = (props) => { onClose(); }, [onClose]); + const variants = { + initial: { opacity: 0, y: '100%' }, + animate: { opacity: 1, y: '0rem', transition: { delay: FIRST_LOAD_UI_ELEMENT_DELAY } }, + }; + return ( <> { - const currentStage = useSelector(getCurrentStage); +export type BeforeNextFunction = ( + direction: directions, +) => Promise; + +const animationOptions: ValueAnimationTransition = { + type: 'spring', + damping: 20, + stiffness: 150, + mass: 1, +}; + +const variants = { + initial: ({ + current, + previous, + }: { + current: number; + previous: number | undefined; + }) => { + if (!previous) { + return { opacity: 0, y: 0 }; + } + + return current > previous ? { y: '100vh' } : { y: '-100vh' }; + }, + animate: { + opacity: 1, + y: 0, + transition: { when: 'beforeChildren', ...animationOptions }, + }, +}; + +export default function ProtocolScreen() { + const [scope, animate] = useAnimate(); + const dispatch = useDispatch(); + + // State const session = useAtomValue(sessionAtom); + const [forceNavigationDisabled, setForceNavigationDisabled] = useState(false); + + // Selectors + const stage = useSelector(getCurrentStage); + const { isReady: isReadyForNextStage } = useReadyForNextStage(); + const { currentStep, isLastPrompt, isFirstPrompt, promptIndex } = + useSelector(getNavigationInfo); + const prevCurrentStep = usePrevious(currentStep); + const { nextValidStageIndex, previousValidStageIndex } = + useSelector(getNavigableStages); + + // Refs + const beforeNextFunction = useRef(null); + + const registerBeforeNext = useCallback((fn: BeforeNextFunction | null) => { + beforeNextFunction.current = fn; + }, []); + + /** + * Before navigation is allowed, we check if there is a beforeNextFunction + * and if it exists we evaluate it and use the return value to determine if + * navigation continues. This allows stages to 'hijack' the navigation + * process and prevent navigation if necessary. + */ + const canNavigate = async (direction: directions) => { + if (!beforeNextFunction.current) { + return true; + } - const { - moveBackward, - canMoveForward, - moveForward, - canMoveBackward, - progress, + const beforeNextResult = await beforeNextFunction.current(direction); + + // Throw an error if beforeNextFunction returns an invalid value + if ( + beforeNextResult !== true && + beforeNextResult !== false && + beforeNextResult !== 'FORCE' + ) { + throw new Error( + `beforeNextFunction must return a boolean or the string 'FORCE'`, + ); + } + + return beforeNextResult; + }; + + const moveForward = useCallback(async () => { + const stageAllowsNavigation = await canNavigate('forwards'); + + if (!stageAllowsNavigation) { + return; + } + + // Advance the prompt if we're not at the last one. + if (stageAllowsNavigation !== 'FORCE' && !isLastPrompt) { + dispatch( + sessionActions.updatePrompt(promptIndex + 1) as unknown as AnyAction, + ); + return; + } + + // from this point on we are definitely navigating, so set up the animation + setForceNavigationDisabled(true); + await animate(scope.current, { y: '-100vh' }, animationOptions); + // If the result is true or 'FORCE' we can reset the function here: + registerBeforeNext(null); + dispatch( + sessionActions.updateStage(nextValidStageIndex) as unknown as AnyAction, + ); + setForceNavigationDisabled(false); + }, [ + animate, + dispatch, + isLastPrompt, + nextValidStageIndex, + promptIndex, registerBeforeNext, - isReadyForNextStage, - } = useNavigationHelpers(); + scope, + ]); + + const moveBackward = useCallback(async () => { + const stageAllowsNavigation = await canNavigate('backwards'); - // If current stage is null, we are waiting for the stage to be set - if (!currentStage) { - return
Waiting for stage to be set...
; - } + if (!stageAllowsNavigation) { + return; + } - // TODO: If it is undefined, we have landed on an invalid stage. This should have been caught higher up the tree. + // Advance the prompt if we're not at the last one. + if (stageAllowsNavigation !== 'FORCE' && !isFirstPrompt) { + dispatch( + sessionActions.updatePrompt(promptIndex - 1) as unknown as AnyAction, + ); + return; + } + + // from this point on we are definitely navigating, so set up the animation + setForceNavigationDisabled(true); + await animate(scope.current, { y: '100vh' }, animationOptions); + registerBeforeNext(null); + dispatch( + sessionActions.updateStage( + previousValidStageIndex, + ) as unknown as AnyAction, + ); + setForceNavigationDisabled(false); + }, [ + animate, + dispatch, + isFirstPrompt, + previousValidStageIndex, + promptIndex, + registerBeforeNext, + scope, + ]); + + const getNavigationHelpers = useCallback( + () => ({ + moveForward, + moveBackward, + }), + [moveForward, moveBackward], + ); return ( <> {session && } - - {currentStage && ( - - )} - {!currentStage && ( - - Other loading? - - )} - + + + ); -}; - -export default ProtocolScreen; +} diff --git a/lib/interviewer/containers/QuickNodeForm.js b/lib/interviewer/containers/QuickNodeForm.js index fcd231f1..b2366966 100644 --- a/lib/interviewer/containers/QuickNodeForm.js +++ b/lib/interviewer/containers/QuickNodeForm.js @@ -8,21 +8,20 @@ import { FIRST_LOAD_UI_ELEMENT_DELAY } from './Interfaces/utils/constants'; import { actionCreators as sessionActions } from '../ducks/modules/session'; const containerVariants = { - normal: { - width: 'var(--closed-width)', + animate: (wide) => wide ? ({ + width: 'var(--open-width)', y: '0rem', transition: { - delay: FIRST_LOAD_UI_ELEMENT_DELAY, + duration: 0, }, - }, - wide: { - width: 'var(--open-width)', + }) : ({ + width: 'var(--closed-width)', y: '0rem', transition: { - duration: 0, + delay: FIRST_LOAD_UI_ELEMENT_DELAY, }, - }, - hide: { + }), + initial: { y: '100%', }, }; @@ -127,8 +126,7 @@ const QuickAddForm = ({ {showForm && ( diff --git a/lib/interviewer/containers/SlidesForm/SlidesForm.js b/lib/interviewer/containers/SlidesForm/SlidesForm.js index 0de78440..ce52e7a7 100644 --- a/lib/interviewer/containers/SlidesForm/SlidesForm.js +++ b/lib/interviewer/containers/SlidesForm/SlidesForm.js @@ -36,6 +36,7 @@ const SlidesForm = (props) => { const { form, stage, + getNavigationHelpers, items = [], slideForm: SlideForm, parentClass = '', @@ -46,6 +47,8 @@ const SlidesForm = (props) => { updateItem, } = props; + const { moveForward } = getNavigationHelpers(); + const dispatch = useDispatch(); const openDialog = useCallback( (dialog) => dispatch(dialogActions.openDialog(dialog)), @@ -233,7 +236,7 @@ const SlidesForm = (props) => { // enter key should always move forward, and needs to process using beforeNext const handleEnterSubmit = (e) => { - beforeNext('forwards'); + moveForward(); e.preventDefault(); }; diff --git a/lib/interviewer/containers/Stage.tsx b/lib/interviewer/containers/Stage.tsx index bb7dee5f..89b4f18e 100644 --- a/lib/interviewer/containers/Stage.tsx +++ b/lib/interviewer/containers/Stage.tsx @@ -1,66 +1,44 @@ import getInterface from './Interfaces'; import StageErrorBoundary from '../components/StageErrorBoundary'; -import { motion } from 'framer-motion'; -import type { directions } from '../hooks/useNavigationHelpers'; -import { type ElementType } from 'react'; -import { useNavigationHelpers } from '../hooks/useNavigationHelpers'; +import { type ElementType, memo } from 'react'; +import { type BeforeNextFunction } from './ProtocolScreen'; type StageProps = { stage: { id: string; type: string; }; - registerBeforeNext: (fn: (direction: directions) => Promise) => void; + registerBeforeNext: (fn: BeforeNextFunction | null) => void; + getNavigationHelpers: () => { + moveForward: () => void; + moveBackward: () => void; + }; }; -const Stage = (props: StageProps) => { - const { stage, registerBeforeNext } = props; +function Stage(props: StageProps) { + const { stage, registerBeforeNext, getNavigationHelpers } = props; + const CurrentInterface = getInterface( stage.type, ) as unknown as ElementType; - const { setForceNavigationDisabled } = useNavigationHelpers(); - - const handleAnimationStart = () => { - setForceNavigationDisabled(true); - }; - const handleAnimationComplete = () => { - setForceNavigationDisabled(false); - }; - return ( - {CurrentInterface && ( )} - +
); -}; +} -export default Stage; +export default memo(Stage); diff --git a/lib/interviewer/ducks/modules/network.js b/lib/interviewer/ducks/modules/network.js index 80e016bb..ff9cda6b 100644 --- a/lib/interviewer/ducks/modules/network.js +++ b/lib/interviewer/ducks/modules/network.js @@ -59,7 +59,21 @@ function flipEdge(edge) { return { from: edge.to, to: edge.from, type: edge.type }; } -function edgeExists(edges, from, to, type) { +/** + * Check if an edge exists in the network + * @param {Array} edges - Array of edges + * @param {string} from - UID of the source node + * @param {string} to - UID of the target node + * @param {string} type - Type of edge + * @returns {string|boolean} - Returns the UID of the edge if it exists, otherwise false + * @example + * const edgeExists = edgeExists(edges, 'a', 'b', 'friend'); + * if (edgeExists) { + * console.log('Edge exists:', edgeExists); + * } + * +*/ +export function edgeExists(edges, from, to, type) { const forwardsEdge = find(edges, { from, to, type }); const reverseEdge = find(edges, flipEdge({ from, to, type })); diff --git a/lib/interviewer/ducks/modules/session.js b/lib/interviewer/ducks/modules/session.js index 96070848..0cb8d7fa 100644 --- a/lib/interviewer/ducks/modules/session.js +++ b/lib/interviewer/ducks/modules/session.js @@ -12,7 +12,7 @@ const LOAD_SESSION = 'LOAD_SESSION'; const UPDATE_PROMPT = 'UPDATE_PROMPT'; const UPDATE_STAGE = 'UPDATE_STAGE'; const UPDATE_CASE_ID = 'UPDATE_CASE_ID'; -const UPDATE_STAGE_STATE = 'UPDATE_STAGE_STATE'; +const UPDATE_STAGE_METADATA = 'UPDATE_STAGE_METADATA'; const REMOVE_SESSION = 'REMOVE_SESSION'; const initialState = {}; @@ -40,6 +40,7 @@ const getReducer = (network) => (state = initialState, action = {}) => { protocolUID, ...action.payload.session, network: action.payload.session.network ?? network(state.network, action), + stageMetadata: action.payload.session.stageMetadata ?? {}, }, } } @@ -140,15 +141,15 @@ const getReducer = (network) => (state = initialState, action = {}) => { }), }; } - case UPDATE_STAGE_STATE: { + case UPDATE_STAGE_METADATA: { if (!sessionExists(action.sessionId, state)) { return state; } const session = state[action.sessionId]; return { ...state, [action.sessionId]: withTimestamp({ ...session, - stages: { - ...session.stages, + stageMetadata: { + ...session.stageMetadata, [action.currentStep]: action.state, }, }), @@ -448,12 +449,12 @@ const withSessionId = (action) => (dispatch, getState) => { }); }; -const updateStageState = (state) => (dispatch, getState) => { +const updateStageMetadata = (state) => (dispatch, getState) => { const { activeSessionId, sessions } = getState(); const { currentStep } = sessions[activeSessionId]; dispatch(withSessionId({ - type: UPDATE_STAGE_STATE, + type: UPDATE_STAGE_METADATA, currentStep, state, })); @@ -494,7 +495,7 @@ const actionCreators = { updatePrompt, updateStage, updateCaseId, - updateStageState, + updateStageMetadata, removeSession, setSessionFinished, setSessionExported, @@ -508,7 +509,7 @@ const actionTypes = { UPDATE_PROMPT, UPDATE_STAGE, UPDATE_CASE_ID, - UPDATE_STAGE_STATE, + UPDATE_STAGE_METADATA, REMOVE_SESSION, }; diff --git a/lib/interviewer/ducks/modules/setServerSession.ts b/lib/interviewer/ducks/modules/setServerSession.ts index 2eaa31c0..14b5a1c7 100644 --- a/lib/interviewer/ducks/modules/setServerSession.ts +++ b/lib/interviewer/ducks/modules/setServerSession.ts @@ -14,6 +14,7 @@ type ServerSession = { participantId: string; protocolId: string; currentStep: number; + sessionMetadata?: Prisma.JsonValue; }; export const SET_SERVER_SESSION = 'INIT/SET_SERVER_SESSION'; diff --git a/lib/interviewer/hooks/useForceSimulation.js b/lib/interviewer/hooks/useForceSimulation.js index 8da68f03..ffe24251 100644 --- a/lib/interviewer/hooks/useForceSimulation.js +++ b/lib/interviewer/hooks/useForceSimulation.js @@ -1,7 +1,7 @@ +/* eslint-disable react-hooks/exhaustive-deps */ import { useRef, useCallback, - useState, useEffect, } from 'react'; import get from 'lodash/get'; diff --git a/lib/interviewer/hooks/useNavigationHelpers.ts b/lib/interviewer/hooks/useNavigationHelpers.ts deleted file mode 100644 index 17834b0a..00000000 --- a/lib/interviewer/hooks/useNavigationHelpers.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { getNavigationInfo } from '../selectors/session'; -import { getSkipMap } from '../selectors/skip-logic'; -import { actionCreators as sessionActions } from '../ducks/modules/session'; -import useReadyForNextStage from '../hooks/useReadyForNextStage'; -import usePrevious from '~/hooks/usePrevious'; -import type { AnyAction } from '@reduxjs/toolkit'; -import { parseAsInteger, useQueryState } from 'nuqs'; -import { atom, useAtom } from 'jotai'; - -export const forceNavigationDisabledAtom = atom(false); - -export type directions = 'forwards' | 'backwards'; - -type NavigationOptions = { - forceChangeStage?: boolean; -}; - -export const useNavigationHelpers = () => { - const dispatch = useDispatch(); - const skipMap = useSelector(getSkipMap); - - const [currentStage, setCurrentStage] = useQueryState( - 'stage', - parseAsInteger.withDefault(0), - ); - - const prevCurrentStage = usePrevious(currentStage); - - const { isReady: isReadyForNextStage } = useReadyForNextStage(); - - const [forceNavigationDisabled, setForceNavigationDisabled] = useAtom( - forceNavigationDisabledAtom, - ); - - const { - progress, - currentStep, - isLastPrompt, - isFirstPrompt, - isLastStage, - promptIndex, - canMoveForward, - canMoveBackward, - } = useSelector(getNavigationInfo); - - useEffect(() => { - if (currentStep && currentStage === null) { - void setCurrentStage(currentStep); - return; - } - }, [currentStage, currentStep, setCurrentStage]); - - const beforeNextFunction = useRef< - ((direction: directions) => Promise) | null - >(null); - - // Stages call this to register a function to be called before - // moving to the next stage. Return true to allow the move, false to - // prevent it. - const registerBeforeNext = useCallback( - (beforeNext: (direction: directions) => Promise) => { - beforeNextFunction.current = beforeNext; - }, - [], - ); - - const calculateNextStage = useCallback(() => { - const nextStage = Object.keys(skipMap).find( - (stage) => - parseInt(stage) > currentStage && skipMap[parseInt(stage)] === false, - ); - - if (!nextStage) { - return currentStage; - } - - return parseInt(nextStage); - }, [currentStage, skipMap]); - - const calculatePreviousStage = useCallback(() => { - const previousStage = Object.keys(skipMap) - .reverse() - .find( - (stage) => - parseInt(stage) < currentStage && skipMap[parseInt(stage)] === false, - ); - - if (!previousStage) { - return currentStage; - } - - return parseInt(previousStage); - }, [currentStage, skipMap]); - - const checkCanNavigate = async (direction: directions) => { - if (beforeNextFunction.current) { - const canNavigate = await beforeNextFunction.current(direction); - if (!canNavigate) { - return false; - } - } - - return true; - }; - - const moveForward = async (options?: NavigationOptions) => { - if (!(await checkCanNavigate('forwards'))) { - return; - } - - // forceChangeStage used in Dyad Census and Tie Strength Census when there are no steps - if (isLastPrompt || options?.forceChangeStage) { - const nextStage = calculateNextStage(); - void setCurrentStage(nextStage); - return; - } - - dispatch( - sessionActions.updatePrompt(promptIndex + 1) as unknown as AnyAction, - ); - }; - - const moveBackward = async (options?: NavigationOptions) => { - if (!(await checkCanNavigate('backwards'))) { - return; - } - - // forceChangeStage used in Dyad Census and Tie Strength Census when there are no steps - if (isFirstPrompt || options?.forceChangeStage) { - const previousStage = calculatePreviousStage(); - void setCurrentStage(previousStage); - return; - } - - dispatch( - sessionActions.updatePrompt(promptIndex - 1) as unknown as AnyAction, - ); - }; - - const resetBeforeNext = () => { - beforeNextFunction.current = null; - }; - - // Check the stage changes, reset the beforeNextFunction - useEffect(() => { - resetBeforeNext(); - }, [currentStage]); - - // Check if the current stage is valid for us to be on. - useEffect(() => { - if (!currentStage) { - return; - } - - // If the current stage should be skipped, move to the previous available - // stage that isn't. - if (!skipMap[currentStage] === false) { - // This should always return a valid stage, because we know that the - // first stage is always valid. - const previousValidStage = calculatePreviousStage(); - - if (previousValidStage) { - void setCurrentStage(previousValidStage); - } - } - }, [currentStage, skipMap, setCurrentStage, calculatePreviousStage]); - - const setReduxStage = useCallback( - (stage: number) => - dispatch(sessionActions.updateStage(stage) as unknown as AnyAction), - [dispatch], - ); - - // When currentStage changes, dispatch an action to update currentStep - useEffect(() => { - if (currentStage === null) { - return; - } - - if (currentStage === prevCurrentStage) { - return; - } - - setReduxStage(currentStage); - }, [currentStage, prevCurrentStage, setReduxStage]); - - return { - progress, - isReadyForNextStage, - canMoveForward: !forceNavigationDisabled && canMoveForward, - canMoveBackward: !forceNavigationDisabled && canMoveBackward, - moveForward, - moveBackward, - isFirstPrompt, - isLastPrompt, - isLastStage, - registerBeforeNext, - currentStep, - setForceNavigationDisabled, - }; -}; diff --git a/lib/interviewer/selectors/interface.js b/lib/interviewer/selectors/interface.js index 8eb84044..736f7eba 100644 --- a/lib/interviewer/selectors/interface.js +++ b/lib/interviewer/selectors/interface.js @@ -50,7 +50,9 @@ export const getVariableOptions = createSelector( return getNodeVariables(state, props); }, (state, { variable }) => variable, - (variables, variable) => variables[variable]?.options || [], + (variables, variable) => { + return variables[variable]?.options || [] + }, ); export const makeGetVariableOptions = (includeOtherVariable = false) => diff --git a/lib/interviewer/selectors/session.ts b/lib/interviewer/selectors/session.ts index 7b7b655a..d286f08a 100644 --- a/lib/interviewer/selectors/session.ts +++ b/lib/interviewer/selectors/session.ts @@ -16,8 +16,7 @@ export const getActiveSession = createSelector( getActiveSessionId, getSessions, (activeSessionId, sessions) => { - if (!activeSessionId) return null; - return sessions[activeSessionId]; + return sessions[activeSessionId]!; }, ); @@ -41,15 +40,23 @@ export const getLastActiveSession = createSelector(getSessions, (sessions) => { }); export const getStageIndex = createSelector(getActiveSession, (session) => { - return session?.currentStep ?? null; + return session.currentStep; }); +// Stage stage is temporary storage for stages used by TieStrengthCensus and DyadCensus +export const getStageMetadata = createSelector( + getActiveSession, + getStageIndex, + (session, stageIndex) => { + return session.stageMetadata?.[stageIndex] ?? undefined; + }, +); + export const getCurrentStage = createSelector( getProtocolStages, getStageIndex, (stages: Stage[], currentStep) => { - if (currentStep === null) return null; - return stages[currentStep]; + return stages[currentStep]!; }, ); diff --git a/lib/interviewer/selectors/skip-logic.ts b/lib/interviewer/selectors/skip-logic.ts index abb13ae5..b0605a52 100644 --- a/lib/interviewer/selectors/skip-logic.ts +++ b/lib/interviewer/selectors/skip-logic.ts @@ -5,6 +5,7 @@ import { SkipLogicAction } from '../protocol-consts'; import { createSelector } from '@reduxjs/toolkit'; import type { RootState } from '../store'; import type { NcNetwork, SkipDefinition, Stage } from '@codaco/shared-consts'; +import { getStageIndex } from './session'; const rotateIndex = (max: number, nextIndex: number) => (nextIndex + max) % max; @@ -99,3 +100,30 @@ export const isStageSkipped = (index: number) => return isSkipped; }, ); + +// Selector that uses the skipMap to determine the idex of the next and previous +// valid stages. +export const getNavigableStages = createSelector( + getSkipMap, + getStageIndex, + (skipMap, currentStep) => { + const nextStage = Object.keys(skipMap).find( + (stage) => + parseInt(stage) > currentStep && skipMap[parseInt(stage)] === false, + ); + + const previousStage = Object.keys(skipMap) + .reverse() + .find( + (stage) => + parseInt(stage) < currentStep && skipMap[parseInt(stage)] === false, + ); + + return { + nextValidStageIndex: nextStage ? parseInt(nextStage) : currentStep, + previousValidStageIndex: previousStage + ? parseInt(previousStage) + : currentStep, + }; + }, +); diff --git a/lib/interviewer/store.ts b/lib/interviewer/store.ts index bab3baf8..b73648d3 100644 --- a/lib/interviewer/store.ts +++ b/lib/interviewer/store.ts @@ -24,17 +24,21 @@ export const store = configureStore({ middleware: [thunk, logger, sound], }); +export type StageMetadataEntry = [number, string, string, boolean]; +export type StageMetadata = StageMetadataEntry[]; + export type Session = { id: string; protocolUid: string; promptIndex: number; - currentStep: number | null; + currentStep: number; caseId: string; network: NcNetwork; startedAt: Date; lastUpdated: Date; finishedAt: Date; exportedAt: Date; + stageMetadata?: Record; // Used as temporary storage by DyadCensus/TieStrengthCensus }; export type SessionsState = Record; @@ -55,7 +59,7 @@ export type Dialogs = { export type RootState = { form: Record; - activeSessionId: string | null; + activeSessionId: keyof SessionsState; sessions: SessionsState; installedProtocols: InstalledProtocols; deviceSettings: Record; diff --git a/lib/interviewer/styles/components/_information-interface.scss b/lib/interviewer/styles/components/_information-interface.scss index 65391705..14c4b7ca 100644 --- a/lib/interviewer/styles/components/_information-interface.scss +++ b/lib/interviewer/styles/components/_information-interface.scss @@ -14,7 +14,7 @@ $asset-size-default: 17vh; &__frame { width: 100%; - max-width: 55rem; + max-width: 80ch; } &__title { @@ -78,7 +78,7 @@ $asset-size-default: 17vh; flex-direction: column; & > * { - max-width: 55rem; + max-width: 80ch; align-self: center; width: 100%; } diff --git a/lib/interviewer/styles/components/_node-form.scss b/lib/interviewer/styles/components/_node-form.scss index 36d94fdb..c94638b9 100644 --- a/lib/interviewer/styles/components/_node-form.scss +++ b/lib/interviewer/styles/components/_node-form.scss @@ -1,7 +1,7 @@ .overlay { &:not(.overlay--fullscreen) { &.node-form { - max-width: 65rem; + max-width: 80ch; &.overlay--fullscreen { max-height: 100%; @@ -9,7 +9,7 @@ .overlay__content { .scrollable { - padding: 0 units.unit(4); + padding: 0 units.unit(1); } } } diff --git a/lib/interviewer/styles/components/_pairing-code-input.scss b/lib/interviewer/styles/components/_pairing-code-input.scss index dc0acf3f..79bc2eca 100644 --- a/lib/interviewer/styles/components/_pairing-code-input.scss +++ b/lib/interviewer/styles/components/_pairing-code-input.scss @@ -37,7 +37,7 @@ margin-right: 0; } - &--error { + &--nc-error { background-color: var(--nc-error); } } diff --git a/lib/interviewer/styles/components/_quick-add.scss b/lib/interviewer/styles/components/_quick-add.scss index 16333499..2dc91eda 100644 --- a/lib/interviewer/styles/components/_quick-add.scss +++ b/lib/interviewer/styles/components/_quick-add.scss @@ -13,7 +13,7 @@ display: flex; align-items: center; z-index: var(--z-global-ui); - overflow: hidden; + // overflow: hidden; .button-container { position: absolute; diff --git a/lib/interviewer/styles/containers/_alter-form-interface.scss b/lib/interviewer/styles/containers/_alter-form-interface.scss index 19a8c3fe..b83eabbc 100644 --- a/lib/interviewer/styles/containers/_alter-form-interface.scss +++ b/lib/interviewer/styles/containers/_alter-form-interface.scss @@ -17,7 +17,7 @@ align-self: center; height: auto; max-height: 75%; - max-width: 55rem; // Same as ego form container with - for readability + max-width: 80ch; // Same as ego form container with - for readability border-radius: var(--nc-border-radius); background: var(--form-intro-panel-background); width: 100%; diff --git a/lib/interviewer/styles/containers/_dyad-census.scss b/lib/interviewer/styles/containers/_dyad-census.scss index c82bf5f7..b2dc8002 100644 --- a/lib/interviewer/styles/containers/_dyad-census.scss +++ b/lib/interviewer/styles/containers/_dyad-census.scss @@ -35,15 +35,13 @@ .dyad-census { @include interface-centering; - - height: 100%; - display: flex; flex-direction: column; justify-content: center; align-items: center; + flex: 1; &__wrapper { - height: 100%; + flex: 1; display: flex; flex-direction: column; width: 100%; @@ -54,18 +52,15 @@ } &__main { - position: relative; + display: flex; flex: 1 auto; - height: 100%; - min-height: 0; } &__introduction { - max-height: 100%; background: var(--nc-light-background); border-radius: var(--nc-border-radius); padding: units.unit(2) units.unit(4); - max-width: 55rem; + max-width: 80ch; h1 { text-align: center; @@ -73,11 +68,7 @@ } &__layout { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; + flex: 1; display: flex; justify-content: center; align-items: center; diff --git a/lib/interviewer/styles/containers/_ego-form-interface.scss b/lib/interviewer/styles/containers/_ego-form-interface.scss index ff9d411a..80d9d650 100644 --- a/lib/interviewer/styles/containers/_ego-form-interface.scss +++ b/lib/interviewer/styles/containers/_ego-form-interface.scss @@ -15,7 +15,7 @@ padding: units.unit(2) units.unit(4); flex-direction: column; margin-bottom: units.unit(4); - max-width: 55rem; // same as ego form max width + max-width: 80ch; // same as ego form max width h1 { text-align: center; @@ -31,13 +31,13 @@ } &__form-container-scroller { - padding: units.unit(4) 0; + padding: units.unit(4) units.unit(2); max-height: 100%; display: flex; flex-direction: column; & > * { - max-width: 55rem; + max-width: 80ch; align-self: center; width: 100%; } diff --git a/lib/interviewer/styles/containers/_interfaces.scss b/lib/interviewer/styles/containers/_interfaces.scss index 696fb344..c5e1422b 100644 --- a/lib/interviewer/styles/containers/_interfaces.scss +++ b/lib/interviewer/styles/containers/_interfaces.scss @@ -11,7 +11,7 @@ // positioned child elements aren't clipped by overflow hidden. @mixin interface-centering { display: flex; - height: 100%; + flex: 1; overflow: hidden; padding: units.unit(2) units.unit(4); position: relative; diff --git a/lib/interviewer/styles/containers/_name-generator-roster-interface.scss b/lib/interviewer/styles/containers/_name-generator-roster-interface.scss index d0f2c08e..959deea5 100644 --- a/lib/interviewer/styles/containers/_name-generator-roster-interface.scss +++ b/lib/interviewer/styles/containers/_name-generator-roster-interface.scss @@ -57,7 +57,7 @@ &__search-panel { flex: 1 1 50%; - min-width: 40rem; + min-width: 30rem; height: 100%; display: flex; padding-right: spacing(large); diff --git a/lib/interviewer/styles/containers/_tie-strength-census.scss b/lib/interviewer/styles/containers/_tie-strength-census.scss index ee5bb063..eed69712 100644 --- a/lib/interviewer/styles/containers/_tie-strength-census.scss +++ b/lib/interviewer/styles/containers/_tie-strength-census.scss @@ -54,7 +54,7 @@ background: var(--nc-light-background); border-radius: var(--nc-border-radius); padding: units.unit(2) units.unit(4); - max-width: 55rem; // Same as ego form for readability + max-width: 80ch; // Same as ego form for readability h1 { text-align: center; diff --git a/lib/ui/components/Prompts/Pips.js b/lib/ui/components/Prompts/Pips.js index a6c5a0fd..c6609297 100644 --- a/lib/ui/components/Prompts/Pips.js +++ b/lib/ui/components/Prompts/Pips.js @@ -3,8 +3,8 @@ import PropTypes from 'prop-types'; import { motion } from 'framer-motion'; const container = { - hidden: { opacity: 0 }, - show: { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { staggerChildren: 0.05, @@ -15,8 +15,8 @@ const container = { }; const item = { - hidden: { opacity: 0, y: '-200%' }, - show: { opacity: 1, y: 0 }, + initial: { opacity: 0, y: '-200%' }, + animate: { opacity: 1, y: 0 }, }; /** @@ -39,15 +39,12 @@ const Pips = (props) => { {[...Array(count)].map((_, index) => ( ))} diff --git a/lib/ui/components/Prompts/Prompts.js b/lib/ui/components/Prompts/Prompts.js index e8a303e9..6221effb 100644 --- a/lib/ui/components/Prompts/Prompts.js +++ b/lib/ui/components/Prompts/Prompts.js @@ -25,14 +25,15 @@ const Prompts = (props) => { const backwards = useMemo(() => currentIndex < prevPromptRef.current, [currentIndex]); + const variants = { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { when: 'beforeChildren' } }, + } + return ( {prompts.length > 1 ? () : (
)} diff --git a/lib/ui/styles/components/_dialog.scss b/lib/ui/styles/components/_dialog.scss index bbd9f0b0..5039a1a8 100644 --- a/lib/ui/styles/components/_dialog.scss +++ b/lib/ui/styles/components/_dialog.scss @@ -89,7 +89,7 @@ border-color: var(--warning); } - &--error { + &--nc-error { border-color: var(--nc-error); .error__stack-trace { diff --git a/lib/ui/styles/components/_prompts.scss b/lib/ui/styles/components/_prompts.scss index dbe6bb3a..2c86fb91 100644 --- a/lib/ui/styles/components/_prompts.scss +++ b/lib/ui/styles/components/_prompts.scss @@ -7,7 +7,7 @@ $module-name: prompts; --spacer-size: calc(var(--pip-size) + #{unit(1)}); width: 100%; - max-width: 55rem; + max-width: 80ch; display: flex; flex-direction: column; align-items: center; diff --git a/lib/ui/styles/components/form/fields/_date-picker.scss b/lib/ui/styles/components/form/fields/_date-picker.scss index 81782dd3..ab2577fd 100644 --- a/lib/ui/styles/components/form/fields/_date-picker.scss +++ b/lib/ui/styles/components/form/fields/_date-picker.scss @@ -13,6 +13,7 @@ $darken: rgba(0, 0, 0, 0.2); } &__error-message { + display: flex; color: var(--form-error-text); background: var(--nc-error); padding: 0.5rem 0.25rem; diff --git a/lib/ui/styles/components/form/fields/_toggle-button-group.scss b/lib/ui/styles/components/form/fields/_toggle-button-group.scss index 2143084e..079cf901 100644 --- a/lib/ui/styles/components/form/fields/_toggle-button-group.scss +++ b/lib/ui/styles/components/form/fields/_toggle-button-group.scss @@ -10,7 +10,7 @@ $component-name: form-field-togglebutton-group; &__error { opacity: 0; - background: var(--error); + background: var(--nc-error); color: var(--nc-text); transition: opacity var(--animation-easing) var(--animation-duration-standard), diff --git a/lib/ui/styles/components/toasts/_toasts.scss b/lib/ui/styles/components/toasts/_toasts.scss index ca0c4b86..a9075d3f 100644 --- a/lib/ui/styles/components/toasts/_toasts.scss +++ b/lib/ui/styles/components/toasts/_toasts.scss @@ -39,7 +39,7 @@ border-color: var(--warning); } - &--error { + &--nc-error { border-color: var(--nc-error); } diff --git a/package.json b/package.json index 132bc07a..91f62a75 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "eslint-plugin-local-rules": "^2.0.0", "eventemitter3": "^5.0.1", "file-loader": "^6.2.0", - "framer-motion": "^11.0.3", + "framer-motion": "^11.0.5", "fuse.js": "^7.0.0", "jotai": "^2.6.4", "jssha": "^3.3.1", @@ -132,7 +132,7 @@ }, "devDependencies": { "@faker-js/faker": "^8.2.0", - "@prisma/client": "^5.9.1", + "@prisma/client": "^5.10.2", "@redux-devtools/extension": "^3.2.6", "@storybook/addon-essentials": "^7.5.1", "@storybook/addon-interactions": "^7.5.1", @@ -167,7 +167,7 @@ "postcss": "^8.4.31", "prettier": "^3.0.3", "prettier-plugin-tailwindcss": "^0.5.11", - "prisma": "^5.9.1", + "prisma": "^5.10.2", "sass": "^1.69.4", "storybook": "^7.5.1", "tailwindcss": "^3.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5919feed..c03110c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ dependencies: version: 3.3.2(react-hook-form@7.47.0) '@lucia-auth/adapter-prisma': specifier: ^3.0.2 - version: 3.0.2(@prisma/client@5.9.1)(lucia@2.7.6) + version: 3.0.2(@prisma/client@5.10.2)(lucia@2.7.6) '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.2.2 @@ -181,10 +181,10 @@ dependencies: version: 5.0.1 file-loader: specifier: ^6.2.0 - version: 6.2.0(webpack@5.90.1) + version: 6.2.0(webpack@5.90.3) framer-motion: - specifier: ^11.0.3 - version: 11.0.3(react-dom@18.2.0)(react@18.2.0) + specifier: ^11.0.5 + version: 11.0.5(react-dom@18.2.0)(react@18.2.0) fuse.js: specifier: ^7.0.0 version: 7.0.0 @@ -329,8 +329,8 @@ devDependencies: specifier: ^8.2.0 version: 8.2.0 '@prisma/client': - specifier: ^5.9.1 - version: 5.9.1(prisma@5.9.1) + specifier: ^5.10.2 + version: 5.10.2(prisma@5.10.2) '@redux-devtools/extension': specifier: ^3.2.6 version: 3.2.6(redux@4.2.1) @@ -348,7 +348,7 @@ devDependencies: version: 7.5.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) '@storybook/nextjs': specifier: ^7.5.1 - version: 7.5.1(@swc/core@1.3.94)(@types/react-dom@18.2.18)(@types/react@18.2.48)(esbuild@0.18.20)(next@14.1.0)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.4)(typescript@5.3.3)(webpack@5.90.1) + version: 7.5.1(@swc/core@1.3.94)(@types/react-dom@18.2.18)(@types/react@18.2.48)(esbuild@0.18.20)(next@14.1.0)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.4)(typescript@5.3.3)(webpack@5.90.3) '@storybook/react': specifier: ^7.5.1 version: 7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3) @@ -434,8 +434,8 @@ devDependencies: specifier: ^0.5.11 version: 0.5.11(prettier@3.0.3) prisma: - specifier: ^5.9.1 - version: 5.9.1 + specifier: ^5.10.2 + version: 5.10.2 sass: specifier: ^1.69.4 version: 1.69.4 @@ -2457,6 +2457,10 @@ packages: resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} engines: {node: '>=6.0.0'} + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + /@jridgewell/set-array@1.1.2: resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} engines: {node: '>=6.0.0'} @@ -2479,7 +2483,7 @@ packages: /@jridgewell/trace-mapping@0.3.22: resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} dependencies: - '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 /@jridgewell/trace-mapping@0.3.9: @@ -2492,13 +2496,13 @@ packages: resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} dev: true - /@lucia-auth/adapter-prisma@3.0.2(@prisma/client@5.9.1)(lucia@2.7.6): + /@lucia-auth/adapter-prisma@3.0.2(@prisma/client@5.10.2)(lucia@2.7.6): resolution: {integrity: sha512-EyJWZene1/zasPwPctv8wwNErZt5mwwm5JATbhg+kXr3R8pbC7lJfVzDTAeeFClVH5k/FywRcsBl3JkPaNIcow==} peerDependencies: '@prisma/client': ^4.2.0 || ^5.0.0 lucia: ^2.0.0 dependencies: - '@prisma/client': 5.9.1(prisma@5.9.1) + '@prisma/client': 5.10.2(prisma@5.10.2) lucia: 2.7.6 dev: false @@ -2706,8 +2710,8 @@ packages: webpack: 5.89.0(@swc/core@1.3.94)(esbuild@0.18.20) dev: true - /@prisma/client@5.9.1(prisma@5.9.1): - resolution: {integrity: sha512-caSOnG4kxcSkhqC/2ShV7rEoWwd3XrftokxJqOCMVvia4NYV/TPtJlS9C2os3Igxw/Qyxumj9GBQzcStzECvtQ==} + /@prisma/client@5.10.2(prisma@5.10.2): + resolution: {integrity: sha512-ef49hzB2yJZCvM5gFHMxSFL9KYrIP9udpT5rYo0CsHD4P9IKj473MbhU1gjKKftiwWBTIyrt9jukprzZXazyag==} engines: {node: '>=16.13'} requiresBuild: true peerDependencies: @@ -2716,34 +2720,34 @@ packages: prisma: optional: true dependencies: - prisma: 5.9.1 + prisma: 5.10.2 - /@prisma/debug@5.9.1: - resolution: {integrity: sha512-yAHFSFCg8KVoL0oRUno3m60GAjsUKYUDkQ+9BA2X2JfVR3kRVSJFc/GpQ2fSORi4pSHZR9orfM4UC9OVXIFFTA==} + /@prisma/debug@5.10.2: + resolution: {integrity: sha512-bkBOmH9dpEBbMKFJj8V+Zp8IZHIBjy3fSyhLhxj4FmKGb/UBSt9doyfA6k1UeUREsMJft7xgPYBbHSOYBr8XCA==} - /@prisma/engines-version@5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64: - resolution: {integrity: sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ==} + /@prisma/engines-version@5.10.0-34.5a9203d0590c951969e85a7d07215503f4672eb9: + resolution: {integrity: sha512-uCy/++3Jx/O3ufM+qv2H1L4tOemTNqcP/gyEVOlZqTpBvYJUe0tWtW0y3o2Ueq04mll4aM5X3f6ugQftOSLdFQ==} - /@prisma/engines@5.9.1: - resolution: {integrity: sha512-gkdXmjxQ5jktxWNdDA5aZZ6R8rH74JkoKq6LD5mACSvxd2vbqWeWIOV0Py5wFC8vofOYShbt6XUeCIUmrOzOnQ==} + /@prisma/engines@5.10.2: + resolution: {integrity: sha512-HkSJvix6PW8YqEEt3zHfCYYJY69CXsNdhU+wna+4Y7EZ+AwzeupMnUThmvaDA7uqswiHkgm5/SZ6/4CStjaGmw==} requiresBuild: true dependencies: - '@prisma/debug': 5.9.1 - '@prisma/engines-version': 5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64 - '@prisma/fetch-engine': 5.9.1 - '@prisma/get-platform': 5.9.1 + '@prisma/debug': 5.10.2 + '@prisma/engines-version': 5.10.0-34.5a9203d0590c951969e85a7d07215503f4672eb9 + '@prisma/fetch-engine': 5.10.2 + '@prisma/get-platform': 5.10.2 - /@prisma/fetch-engine@5.9.1: - resolution: {integrity: sha512-l0goQOMcNVOJs1kAcwqpKq3ylvkD9F04Ioe1oJoCqmz05mw22bNAKKGWuDd3zTUoUZr97va0c/UfLNru+PDmNA==} + /@prisma/fetch-engine@5.10.2: + resolution: {integrity: sha512-dSmXcqSt6DpTmMaLQ9K8ZKzVAMH3qwGCmYEZr/uVnzVhxRJ1EbT/w2MMwIdBNq1zT69Rvh0h75WMIi0mrIw7Hg==} dependencies: - '@prisma/debug': 5.9.1 - '@prisma/engines-version': 5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64 - '@prisma/get-platform': 5.9.1 + '@prisma/debug': 5.10.2 + '@prisma/engines-version': 5.10.0-34.5a9203d0590c951969e85a7d07215503f4672eb9 + '@prisma/get-platform': 5.10.2 - /@prisma/get-platform@5.9.1: - resolution: {integrity: sha512-6OQsNxTyhvG+T2Ksr8FPFpuPeL4r9u0JF0OZHUBI/Uy9SS43sPyAIutt4ZEAyqWQt104ERh70EZedkHZKsnNbg==} + /@prisma/get-platform@5.10.2: + resolution: {integrity: sha512-nqXP6vHiY2PIsebBAuDeWiUYg8h8mfjBckHh6Jezuwej0QJNnjDiOq30uesmg+JXxGk99nqyG3B7wpcOODzXvg==} dependencies: - '@prisma/debug': 5.9.1 + '@prisma/debug': 5.10.2 /@radix-ui/number@1.0.1: resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} @@ -4879,7 +4883,7 @@ packages: resolution: {integrity: sha512-TXJJd5RAKakWx4BtpwvSNdgTDkKM6RkXU8GK34S/LhidQ5Pjz3wcnqb0TxEkfhK/ztbP8nKHqXFwLfa2CYkvQw==} dev: true - /@storybook/nextjs@7.5.1(@swc/core@1.3.94)(@types/react-dom@18.2.18)(@types/react@18.2.48)(esbuild@0.18.20)(next@14.1.0)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.4)(typescript@5.3.3)(webpack@5.90.1): + /@storybook/nextjs@7.5.1(@swc/core@1.3.94)(@types/react-dom@18.2.18)(@types/react@18.2.48)(esbuild@0.18.20)(next@14.1.0)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.4)(typescript@5.3.3)(webpack@5.90.3): resolution: {integrity: sha512-DezMv3UZYzqltzOgLw1TOQOct3IQ9zGffvfP3T/mRQxmW7UOYXDbAtmD/d3Ud6Fi59HuEnu4hEwyJNacZvuNqw==} engines: {node: '>=16.0.0'} peerDependencies: @@ -4921,28 +4925,28 @@ packages: '@storybook/preview-api': 7.5.1 '@storybook/react': 7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3) '@types/node': 18.18.6 - css-loader: 6.8.1(webpack@5.90.1) + css-loader: 6.8.1(webpack@5.90.3) find-up: 5.0.0 fs-extra: 11.1.1 image-size: 1.0.2 loader-utils: 3.2.1 next: 14.1.0(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.4) - node-polyfill-webpack-plugin: 2.0.1(webpack@5.90.1) + node-polyfill-webpack-plugin: 2.0.1(webpack@5.90.3) pnp-webpack-plugin: 1.7.0(typescript@5.3.3) postcss: 8.4.31 - postcss-loader: 7.3.3(postcss@8.4.31)(typescript@5.3.3)(webpack@5.90.1) + postcss-loader: 7.3.3(postcss@8.4.31)(typescript@5.3.3)(webpack@5.90.3) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) resolve-url-loader: 5.0.0 - sass-loader: 12.6.0(sass@1.69.4)(webpack@5.90.1) + sass-loader: 12.6.0(sass@1.69.4)(webpack@5.90.3) semver: 7.5.4 - style-loader: 3.3.3(webpack@5.90.1) + style-loader: 3.3.3(webpack@5.90.3) styled-jsx: 5.1.1(@babel/core@7.23.2)(react@18.2.0) ts-dedent: 2.2.0 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.1.0 typescript: 5.3.3 - webpack: 5.90.1(@swc/core@1.3.94)(esbuild@0.18.20) + webpack: 5.90.3(@swc/core@1.3.94)(esbuild@0.18.20) transitivePeerDependencies: - '@swc/core' - '@swc/helpers' @@ -7102,6 +7106,17 @@ packages: electron-to-chromium: 1.4.650 node-releases: 2.0.14 update-browserslist-db: 1.0.13(browserslist@4.22.3) + dev: true + + /browserslist@4.23.0: + resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001589 + electron-to-chromium: 1.4.679 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.23.0) /bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -7217,6 +7232,9 @@ packages: /caniuse-lite@1.0.30001581: resolution: {integrity: sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==} + /caniuse-lite@1.0.30001589: + resolution: {integrity: sha512-vNQWS6kI+q6sBlHbh71IIeC+sRwK2N3EDySc/updIGhIee2x5z00J4c1242/5/d6EpEMdOnk/m+6tuk4/tcsqg==} + /case-sensitive-paths-webpack-plugin@2.4.0: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} engines: {node: '>=4'} @@ -7763,7 +7781,7 @@ packages: webpack: 5.89.0(@swc/core@1.3.94)(esbuild@0.18.20) dev: true - /css-loader@6.8.1(webpack@5.90.1): + /css-loader@6.8.1(webpack@5.90.3): resolution: {integrity: sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==} engines: {node: '>= 12.13.0'} peerDependencies: @@ -7777,7 +7795,7 @@ packages: postcss-modules-values: 4.0.0(postcss@8.4.31) postcss-value-parser: 4.2.0 semver: 7.5.4 - webpack: 5.90.1(@swc/core@1.3.94)(esbuild@0.18.20) + webpack: 5.90.3(@swc/core@1.3.94)(esbuild@0.18.20) dev: true /css-select@4.3.0: @@ -7800,10 +7818,6 @@ packages: engines: {node: '>=4'} hasBin: true - /csstype@3.1.2: - resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} - dev: false - /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -8151,7 +8165,7 @@ packages: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: '@babel/runtime': 7.23.9 - csstype: 3.1.2 + csstype: 3.1.3 dev: false /dom-serializer@1.4.1: @@ -8232,6 +8246,10 @@ packages: /electron-to-chromium@1.4.650: resolution: {integrity: sha512-sYSQhJCJa4aGA1wYol5cMQgekDBlbVfTRavlGZVr3WZpDdOPcp6a6xUnFfrt8TqZhsBYYbDxJZCjGfHuGupCRQ==} + dev: true + + /electron-to-chromium@1.4.679: + resolution: {integrity: sha512-NhQMsz5k0d6m9z3qAxnsOR/ebal4NAGsrNVRwcDo4Kc/zQ7KdsTKZUxZoygHcVRb0QDW3waEDIcE3isZ79RP6g==} /elliptic@6.5.4: resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} @@ -9072,7 +9090,7 @@ packages: dependencies: flat-cache: 3.1.1 - /file-loader@6.2.0(webpack@5.90.1): + /file-loader@6.2.0(webpack@5.90.3): resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==} engines: {node: '>= 10.13.0'} peerDependencies: @@ -9080,7 +9098,7 @@ packages: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.90.1(@swc/core@1.3.94)(esbuild@0.18.20) + webpack: 5.90.3(@swc/core@1.3.94)(esbuild@0.18.20) dev: false /file-selector@0.6.0: @@ -9271,8 +9289,8 @@ packages: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} dev: true - /framer-motion@11.0.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-6x2poQpIWBdbZwLd73w6cKZ1I9IEPIU94C6/Swp1Zt3LJ+sB5bPe1E2wC6EH5hSISXNkMJ4afH7AdwS7MrtkWw==} + /framer-motion@11.0.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Lb0EYbQcSK/pgyQUJm+KzsQrKrJRX9sFRyzl9hSr9gFG4Mk8yP7BjhuxvRXzblOM/+JxycrJdCDVmOQBsjpYlw==} peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 @@ -11833,7 +11851,7 @@ packages: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true - /node-polyfill-webpack-plugin@2.0.1(webpack@5.90.1): + /node-polyfill-webpack-plugin@2.0.1(webpack@5.90.3): resolution: {integrity: sha512-ZUMiCnZkP1LF0Th2caY6J/eKKoA0TefpoVa68m/LQU1I/mE8rGt4fNYGgNuCcK+aG8P8P43nbeJ2RqJMOL/Y1A==} engines: {node: '>=12'} peerDependencies: @@ -11864,7 +11882,7 @@ packages: url: 0.11.3 util: 0.12.5 vm-browserify: 1.1.2 - webpack: 5.90.1(@swc/core@1.3.94)(esbuild@0.18.20) + webpack: 5.90.3(@swc/core@1.3.94)(esbuild@0.18.20) dev: true /node-releases@2.0.13: @@ -12418,7 +12436,7 @@ packages: ts-node: 10.9.1(@swc/core@1.3.94)(@types/node@20.8.8)(typescript@5.3.3) yaml: 2.3.3 - /postcss-loader@7.3.3(postcss@8.4.31)(typescript@5.3.3)(webpack@5.90.1): + /postcss-loader@7.3.3(postcss@8.4.31)(typescript@5.3.3)(webpack@5.90.3): resolution: {integrity: sha512-YgO/yhtevGO/vJePCQmTxiaEwER94LABZN0ZMT4A0vsak9TpO+RvKRs7EmJ8peIlB9xfXCsS7M8LjqncsUZ5HA==} engines: {node: '>= 14.15.0'} peerDependencies: @@ -12429,7 +12447,7 @@ packages: jiti: 1.20.0 postcss: 8.4.31 semver: 7.5.4 - webpack: 5.90.1(@swc/core@1.3.94)(esbuild@0.18.20) + webpack: 5.90.3(@swc/core@1.3.94)(esbuild@0.18.20) transitivePeerDependencies: - typescript dev: true @@ -12633,13 +12651,13 @@ packages: engines: {node: '>= 0.8'} dev: true - /prisma@5.9.1: - resolution: {integrity: sha512-Hy/8KJZz0ELtkw4FnG9MS9rNWlXcJhf98Z2QMqi0QiVMoS8PzsBkpla0/Y5hTlob8F3HeECYphBjqmBxrluUrQ==} + /prisma@5.10.2: + resolution: {integrity: sha512-hqb/JMz9/kymRE25pMWCxkdyhbnIWrq+h7S6WysJpdnCvhstbJSNP/S6mScEcqiB8Qv2F+0R3yG+osRaWqZacQ==} engines: {node: '>=16.13'} hasBin: true requiresBuild: true dependencies: - '@prisma/engines': 5.9.1 + '@prisma/engines': 5.10.2 /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -13111,7 +13129,7 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' dependencies: - '@babel/runtime': 7.23.2 + '@babel/runtime': 7.23.9 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -13635,7 +13653,7 @@ packages: truncate-utf8-bytes: 1.0.2 dev: false - /sass-loader@12.6.0(sass@1.69.4)(webpack@5.90.1): + /sass-loader@12.6.0(sass@1.69.4)(webpack@5.90.3): resolution: {integrity: sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==} engines: {node: '>= 12.13.0'} peerDependencies: @@ -13657,7 +13675,7 @@ packages: klona: 2.0.6 neo-async: 2.6.2 sass: 1.69.4 - webpack: 5.90.1(@swc/core@1.3.94)(esbuild@0.18.20) + webpack: 5.90.3(@swc/core@1.3.94)(esbuild@0.18.20) dev: true /sass@1.69.4: @@ -14172,13 +14190,13 @@ packages: webpack: 5.89.0(@swc/core@1.3.94)(esbuild@0.18.20) dev: true - /style-loader@3.3.3(webpack@5.90.1): + /style-loader@3.3.3(webpack@5.90.3): resolution: {integrity: sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==} engines: {node: '>= 12.13.0'} peerDependencies: webpack: ^5.0.0 dependencies: - webpack: 5.90.1(@swc/core@1.3.94)(esbuild@0.18.20) + webpack: 5.90.3(@swc/core@1.3.94)(esbuild@0.18.20) dev: true /style-to-object@1.0.5: @@ -14400,7 +14418,7 @@ packages: unique-string: 2.0.0 dev: true - /terser-webpack-plugin@5.3.10(@swc/core@1.3.94)(esbuild@0.18.20)(webpack@5.90.1): + /terser-webpack-plugin@5.3.10(@swc/core@1.3.94)(esbuild@0.18.20)(webpack@5.90.3): resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} engines: {node: '>= 10.13.0'} peerDependencies: @@ -14422,8 +14440,8 @@ packages: jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 - terser: 5.27.0 - webpack: 5.90.1(@swc/core@1.3.94)(esbuild@0.18.20) + terser: 5.27.2 + webpack: 5.90.3(@swc/core@1.3.94)(esbuild@0.18.20) /terser-webpack-plugin@5.3.9(@swc/core@1.3.94)(esbuild@0.18.20)(webpack@5.89.0): resolution: {integrity: sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==} @@ -14462,8 +14480,8 @@ packages: source-map-support: 0.5.21 dev: true - /terser@5.27.0: - resolution: {integrity: sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==} + /terser@5.27.2: + resolution: {integrity: sha512-sHXmLSkImesJ4p5apTeT63DsV4Obe1s37qT8qvwHRmVxKTBH7Rv9Wr26VcAMmLbmk9UliiwK8z+657NyJHHy/w==} engines: {node: '>=10'} hasBin: true dependencies: @@ -14949,6 +14967,17 @@ packages: browserslist: 4.22.3 escalade: 3.1.1 picocolors: 1.0.0 + dev: true + + /update-browserslist-db@1.0.13(browserslist@4.23.0): + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.23.0 + escalade: 3.1.1 + picocolors: 1.0.0 /uploadthing@6.3.3: resolution: {integrity: sha512-YYHgh6YUKNySRW6P6FrCjrbs3TUxydtRzpL4mmhTwYoaP30gc0Uf9LWygPl4bSQJ/1zkBmdSBbZpYdg4VV/NJw==} @@ -15230,8 +15259,8 @@ packages: - uglify-js dev: true - /webpack@5.90.1(@swc/core@1.3.94)(esbuild@0.18.20): - resolution: {integrity: sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==} + /webpack@5.90.3(@swc/core@1.3.94)(esbuild@0.18.20): + resolution: {integrity: sha512-h6uDYlWCctQRuXBs1oYpVe6sFcWedl0dpcVaTf/YF67J9bKvwJajFulMVSYKHrksMB3I/pIagRzDxwxkebuzKA==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -15247,7 +15276,7 @@ packages: '@webassemblyjs/wasm-parser': 1.11.6 acorn: 8.11.3 acorn-import-assertions: 1.9.0(acorn@8.11.3) - browserslist: 4.22.3 + browserslist: 4.23.0 chrome-trace-event: 1.0.3 enhanced-resolve: 5.15.0 es-module-lexer: 1.4.1 @@ -15261,7 +15290,7 @@ packages: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.3.94)(esbuild@0.18.20)(webpack@5.90.1) + terser-webpack-plugin: 5.3.10(@swc/core@1.3.94)(esbuild@0.18.20)(webpack@5.90.3) watchpack: 2.4.0 webpack-sources: 3.2.3 transitivePeerDependencies: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d52a5220..e0069a8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,7 @@ model Interview { protocol Protocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) protocolId String @map("protocolId") currentStep Int @default(0) + stageMetadata Json? // Used to store negative responses in tiestrength census and dyadcensus @@index(fields: [protocolId]) @@index([participantId]) diff --git a/server/routers/interview.ts b/server/routers/interview.ts index b403284e..2fdc859d 100644 --- a/server/routers/interview.ts +++ b/server/routers/interview.ts @@ -9,6 +9,8 @@ import { revalidatePath, revalidateTag } from 'next/cache'; import { trackEvent } from '~/analytics/utils'; import { createId } from '@paralleldrive/cuid2'; +const NumberStringBoolean = z.union([z.number(), z.string(), z.boolean()]); + export const interviewRouter = router({ sync: publicProcedure .input( @@ -16,30 +18,36 @@ export const interviewRouter = router({ id: z.string(), network: NcNetworkZod, currentStep: z.number(), + stageMetadata: z + .record(z.string(), z.array(z.array(NumberStringBoolean))) + .optional(), // Sorry about this. :/ }), ) - .mutation(async ({ input: { id, network, currentStep } }) => { - try { - await prisma.interview.update({ - where: { - id, - }, - data: { - network, - currentStep, - lastUpdated: new Date(), - }, - }); + .mutation( + async ({ input: { id, network, currentStep, stageMetadata } }) => { + try { + await prisma.interview.update({ + where: { + id, + }, + data: { + network, + currentStep, + stageMetadata, + lastUpdated: new Date(), + }, + }); - revalidateTag('interview.get.all'); - revalidatePath('/dashboard/interviews'); + revalidateTag('interview.get.all'); + revalidatePath('/dashboard/interviews'); - return { success: true }; - } catch (error) { - const message = ensureError(error).message; - return { success: false, error: message }; - } - }), + return { success: true }; + } catch (error) { + const message = ensureError(error).message; + return { success: false, error: message }; + } + }, + ), create: publicProcedure .input( z.object({