diff --git a/app/(interview)/interview/[interviewId]/page.tsx b/app/(interview)/interview/[interviewId]/page.tsx index 29d15b15..bea7a735 100644 --- a/app/(interview)/interview/[interviewId]/page.tsx +++ b/app/(interview)/interview/[interviewId]/page.tsx @@ -1,9 +1,12 @@ import { api } from '~/trpc/server'; import InterviewShell from '../_components/InterviewShell'; import NoSSRWrapper from '~/utils/NoSSRWrapper'; +import type { Prisma } from '@prisma/client'; export const dynamic = 'force-dynamic'; +export type ServerSession = Prisma.InterviewGetPayload; + export default async function Page({ params, }: { @@ -22,14 +25,14 @@ export default async function Page({ return 'No interview found'; } - const { protocol, ...serverInterview } = interview; + const { protocol, ...serverSession } = interview; return (
diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index b115eb6d..0b9a3cba 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -5,31 +5,45 @@ import DialogManager from '~/lib/interviewer/components/DialogManager'; import ProtocolScreen from '~/lib/interviewer/containers/ProtocolScreen'; import { store } from '~/lib/interviewer/store'; import UserBanner from './UserBanner'; -import { useSession } from '~/providers/SessionProvider'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { parseAsInteger, useQueryState } from 'next-usequerystate'; +import type { Protocol } from '@codaco/shared-consts'; +import type { ServerSession } from '../[interviewId]/page'; +import { + SET_SERVER_SESSION, + type SetServerSessionAction, +} from '~/lib/interviewer/ducks/modules/setServerSession'; // The job of interview shell is to receive the server-side session and protocol // and create a redux store with that data. // Eventually it will handle syncing this data back. -const InterviewShell = ({ serverProtocol, serverSession }) => { - const { session } = useSession(); +const InterviewShell = ({ + serverProtocol, + serverSession, +}: { + serverProtocol: Protocol; + serverSession: ServerSession; +}) => { + const [loading, setLoading] = useState(true); useEffect(() => { - store.dispatch({ - type: 'SET_SERVER_SESSION', - payload: serverSession, + store.dispatch({ + type: SET_SERVER_SESSION, + payload: { + protocol: serverProtocol, + session: serverSession, + }, }); - }, [serverSession]); + setLoading(false); + }, [serverSession, serverProtocol]); - const [stage, setStage] = useQueryState( - 'stage', - parseAsInteger.withDefault(1), - ); + if (loading) { + return 'Second loading stage...'; + } return ( - {session && } + diff --git a/app/(interview)/interview/_components/UserBanner.tsx b/app/(interview)/interview/_components/UserBanner.tsx index 9b3fbf62..f71f5944 100644 --- a/app/(interview)/interview/_components/UserBanner.tsx +++ b/app/(interview)/interview/_components/UserBanner.tsx @@ -4,7 +4,12 @@ import { Button } from '~/components/ui/Button'; import { useSession } from '~/providers/SessionProvider'; export default function UserBanner() { - const { signOut } = useSession(); + const { session, signOut } = useSession(); + + if (!session) { + return null; + } + return (
{ + const nextStage = Object.keys(skipMap).find( + (stage) => + parseInt(stage) > currentStage && skipMap[parseInt(stage)] === false, + ); - const isCurrentStageValid = useMemo(() => { - return skipMap[currentStage] === false; + if (!nextStage) { + return currentStage; + } + + return parseInt(nextStage); }, [currentStage, skipMap]); - const getPreviousValidStage = useCallback(() => { - return Object.keys(skipMap) + const calculatePreviousStage = useCallback(() => { + const previousStage = Object.keys(skipMap) .reverse() - .find( - (stage) => parseInt(stage) < currentStage && skipMap[stage] === false, - ); + .find((stage) => parseInt(stage) < currentStage); + + if (!previousStage) { + return currentStage; + } + + return parseInt(previousStage); }, [currentStage, skipMap]); const validateCurrentStage = useCallback(() => { - if (!isCurrentStageValid) { - const previousValidStage = getPreviousValidStage(); + if (!skipMap[currentStage] === false) { + const previousValidStage = calculatePreviousStage(); if (previousValidStage) { - setCurrentStage(parseInt(previousValidStage)); + setCurrentStage(previousValidStage); } } - }, [isCurrentStageValid, getPreviousValidStage, setCurrentStage]); - - // Ddetermine if we can navigate to a given stage based on the skip logic - const canNavigateToStage = useCallback( - (stage: number) => { - return skipMap[stage]; - }, - [skipMap], - ); + }, [calculatePreviousStage, setCurrentStage, currentStage, skipMap]); - // Move to the next available stage in the interview based on the current stage and skip logic - const moveForward = () => { - const nextAvailableStage = Object.keys(skipMap).find( - (stage) => parseInt(stage) > currentStage && skipMap[stage] === false, - ); + const moveForward = useCallback(() => { + if (isLastPrompt) { + const nextStage = calculateNextStage(); + setCurrentStage(nextStage); + return; + } - dispatch(sessionActions.updateStage({ stageIndex: nextAvailableStage })); - }; + dispatch(sessionActions.updatePrompt(promptIndex + 1)); + }, [ + dispatch, + isLastPrompt, + promptIndex, + calculateNextStage, + setCurrentStage, + ]); // Move to the previous available stage in the interview based on the current stage and skip logic const moveBackward = () => { - const previousAvailableStage = Object.keys(skipMap) - .reverse() - .find( - (stage) => parseInt(stage) < currentStage && skipMap[stage] === false, - ); + if (isFirstPrompt) { + const previousStage = calculatePreviousStage(); + setCurrentStage(previousStage); + return; + } - dispatch( - sessionActions.updateStage({ stageIndex: previousAvailableStage }), - ); + dispatch(sessionActions.updatePrompt(promptIndex - 1)); }; - // When the current stage changes, try to set the session currentStage to the new value using the - // `canNavigateToStage` method. useEffect(() => { - if (canNavigateToStage(currentStage)) { - dispatch(sessionActions.updateStage({ stageIndex: currentStage })); - } - }, [currentStage, canNavigateToStage, dispatch]); + dispatch(sessionActions.updateStage(currentStage)); + }, [currentStage, dispatch]); return { progress, isReadyForNextStage, - canMoveForward: true, - canMoveBackward: true, + canMoveForward, + canMoveBackward, moveForward, moveBackward, validateCurrentStage, + isFirstPrompt, + isLastPrompt, + isLastStage, }; }; diff --git a/lib/interviewer/components/RealtimeCanvas/EdgeLayout.js b/lib/interviewer/components/RealtimeCanvas/EdgeLayout.js index 262a8642..72378217 100644 --- a/lib/interviewer/components/RealtimeCanvas/EdgeLayout.js +++ b/lib/interviewer/components/RealtimeCanvas/EdgeLayout.js @@ -41,7 +41,7 @@ const EdgeLayout = () => { const svgNS = svg.current.namespaceURI; const el = document.createElementNS(svgNS, 'line'); const color = get(edgeDefinitions, [edge.type, 'color'], 'edge-color-seq-1'); - el.setAttributeNS(null, 'stroke', `var(--${color})`); + el.setAttributeNS(null, 'stroke', `var(--nc-${color})`); return { edge, el, link: links[index] }; }); diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/Pair.js b/lib/interviewer/containers/Interfaces/DyadCensus/Pair.js index 840c7120..f2373187 100644 --- a/lib/interviewer/containers/Interfaces/DyadCensus/Pair.js +++ b/lib/interviewer/containers/Interfaces/DyadCensus/Pair.js @@ -64,7 +64,7 @@ const Pair = ({ { - const currentStage = useSelector(getCurrentStage); +const registerBeforeNext = () => { + // console.log('TODO: implement registerBeforeNext lib/interviewer/containers/ProtocolScreen.js'); +}; - const registerBeforeNext = () => { - console.log('TODO: implement registerBeforeNext lib/interviewer/containers/ProtocolScreen.js'); - }; +const onComplete = () => { + // console.log('TODO: implement onComplete lib/interviewer/containers/ProtocolScreen.js'); +}; - const onComplete = () => { - console.log('TODO: implement onComplete lib/interviewer/containers/ProtocolScreen.js'); - }; +const ProtocolScreen = () => { + const currentStage = useSelector(getCurrentStage); return ( diff --git a/lib/interviewer/containers/SlidesForm/SlideFormEdge.js b/lib/interviewer/containers/SlidesForm/SlideFormEdge.js index 985eca9c..1dfc3ff4 100644 --- a/lib/interviewer/containers/SlidesForm/SlideFormEdge.js +++ b/lib/interviewer/containers/SlidesForm/SlideFormEdge.js @@ -33,7 +33,7 @@ class SlideFormEdge extends PureComponent {
-
+
diff --git a/lib/interviewer/ducks/modules/activeSessionId.js b/lib/interviewer/ducks/modules/activeSessionId.js index ecb69b5f..6051f32f 100644 --- a/lib/interviewer/ducks/modules/activeSessionId.js +++ b/lib/interviewer/ducks/modules/activeSessionId.js @@ -1,5 +1,6 @@ import { actionTypes as SessionsActionTypes, actionCreators as sessionActions } from './session'; import { actionTypes as installedProtocolsActionTypes } from './installedProtocols'; +import { SET_SERVER_SESSION } from './setServerSession'; const { ADD_SESSION } = SessionsActionTypes; const SET_SESSION = 'SET_SESSION'; @@ -9,6 +10,15 @@ const initialState = null; export default function reducer(state = initialState, action = {}) { switch (action.type) { + case SET_SERVER_SESSION: { + if (!action.payload.session) { + return state; + } + + const { id } = action.payload.session; + + return id; + } case SET_SESSION: case ADD_SESSION: return action.sessionId; diff --git a/lib/interviewer/ducks/modules/installedProtocols.js b/lib/interviewer/ducks/modules/installedProtocols.js index f37c54cb..f9950d94 100644 --- a/lib/interviewer/ducks/modules/installedProtocols.js +++ b/lib/interviewer/ducks/modules/installedProtocols.js @@ -2,37 +2,13 @@ import React from 'react'; import { omit, findKey, get } from 'lodash'; import { actionCreators as dialogActions } from './dialogs'; import { withErrorDialog } from './errors'; +import { SET_SERVER_SESSION } from './setServerSession'; const IMPORT_PROTOCOL_COMPLETE = 'IMPORT_PROTOCOL_COMPLETE'; const IMPORT_PROTOCOL_FAILED = 'IMPORT_PROTOCOL_FAILED'; const DELETE_PROTOCOL = 'INSTALLED_PROTOCOLS/DELETE_PROTOCOL'; -const initialState = { - '1': { - name: 'test protocol', - stages: [{ - "id": "1151e210-7969-11ee-a112-2fa2ba6c2d8e", - "type": "Information", - "items": [ - { - "id": "b21c9dca-fa6a-40a2-8652-ec542939f71c", - "size": "MEDIUM", - "type": "text", - "content": "We can render text.\n\n
\n\n\nThe text has **markdown**!\n\n
\n\n\n\n- Including a\n- list!\n\n" - }, - { - "id": "ae526a7f-28d5-4c75-a5eb-c30a0b263bcd", - "size": "MEDIUM", - "type": "asset", - "content": "38dc9035-02b4-4906-b0c5-9849415960fe" - } - ], - "label": "Information Interface", - "title": "Information Interface" - }], - codebook: {}, - } -}; +const initialState = {}; const protocolHasSessions = (state, protocolUID) => new Promise((resolve) => { const hasNotExportedSession = !!findKey( @@ -104,6 +80,20 @@ const deleteProtocolAction = (protocolUID) => (dispatch, getState) => export default function reducer(state = initialState, action = {}) { switch (action.type) { + case SET_SERVER_SESSION: { + if (!action.payload.protocol) { return state; } + + const { protocol } = action.payload; + const uid = protocol.id; + + return { + ...state, + [uid]: { + ...omit(protocol, 'id'), + installationDate: Date.now(), + }, + }; + } case DELETE_PROTOCOL: return omit(state, [action.protocolUID]); case IMPORT_PROTOCOL_COMPLETE: { diff --git a/lib/interviewer/ducks/modules/navigate.js b/lib/interviewer/ducks/modules/navigate.js deleted file mode 100644 index 482c3ed8..00000000 --- a/lib/interviewer/ducks/modules/navigate.js +++ /dev/null @@ -1,138 +0,0 @@ -import { isStageSkipped } from '../../selectors/skip-logic'; -import { getSessionPath, getSessionProgress } from '../../selectors/session'; -import { actionCreators as sessionActions } from './session'; - -/** - * Turn a positive or negative number into either +1/-1 - * @param {number} direction A positive or negative number indicating - * forwards or backwards respectively. - */ -const getStep = (direction) => Math.abs(direction) / direction; -const isBackwards = (direction) => direction < 0; -const isForwards = (direction) => direction > 0; - -/** - * Go to the stage at the index provided - * @param {number} index Index of the stage in protocol - */ -const goToStage = (index, direction) => (dispatch, getState) => { - const state = getState(); - const back = direction === -1 ? '/?back' : ''; - const sessionPath = `${getSessionPath(state, index)}${back}`; - - console.log('TODO: implement goToStage lib/interviewer/ducks/modules/navigate.js'); - - // return dispatch(push(sessionPath)); -}; - -/** - * Get the next (or last) stage, will skip past stages as specified by skipLogic. - * @param {number} direction either +1/-1 - */ -const getNextStage = (direction = 1) => (dispatch, getState) => { - const state = getState(); - - const { - currentStage, - screenCount, - } = getSessionProgress(state); - - const step = getStep(direction); - - // starting point - let nextIndex = currentStage + step; - - // iterate past any skipped steps - while (isStageSkipped(nextIndex)(state)) { - nextIndex += step; - - // If we're at either end of the inteview, stop and stay where we are - if (nextIndex >= screenCount || nextIndex < 0) { - return null; - } - } - - return nextIndex; -}; - -/** - * Go to the next (or last) stage, will skip past stages as specified by skipLogic. - * @param {number} direction either +1/-1 - */ -const goToNextStage = (direction = 1) => (dispatch) => { - const nextIndex = dispatch(getNextStage(direction)); - - if (nextIndex === null) { return null; } - - return dispatch(goToStage(nextIndex, direction)); -}; - -/** - * Go to the next (or last) prompt. - * @param {number} direction either +1/-1 - */ -const goToNextPrompt = (direction = 1) => (dispatch, getState) => { - const state = getState(); - const { - promptCount, - currentPrompt, - } = getSessionProgress(state); - - const step = getStep(direction); - const nextPrompt = (promptCount + currentPrompt + step) % promptCount; - - return dispatch(sessionActions.updatePrompt(nextPrompt)); -}; - -/** - * Go to the next prompt or stage depending on the session state - * @param {number} direction - */ -const goToNext = (direction = 1) => (dispatch, getState) => { - const state = getState(); - const { - promptCount, - isFirstPrompt, - isLastPrompt, - isFirstStage, - isLastScreen, - } = getSessionProgress(state); - - const isLastPromptOrHasNone = !promptCount || isLastPrompt; - const isFirstPromptOrHasNone = !promptCount || isFirstPrompt; - const isLastScreenAndLastPrompt = isLastScreen && isLastPromptOrHasNone; - - if ( - // first screen: - (isBackwards(direction) && isFirstStage && isFirstPromptOrHasNone) - // when finish screen is absent we need to also check prompts: - || (isForwards(direction) && isLastScreenAndLastPrompt) - ) { - return null; - } - - if (!promptCount) { - return dispatch(goToNextStage(direction)); - } - - // At the end of the prompts it's time to go to the next stage - if ( - (isBackwards(direction) && isFirstPrompt) - || (isForwards(direction) && isLastPrompt) - ) { - return dispatch(goToNextStage(direction)); - } - - return dispatch(goToNextPrompt(direction)); -}; - -const actionCreators = { - goToNextStage, - goToNextPrompt, - goToNext, - goToStage, -}; - -export { - actionCreators, -}; diff --git a/lib/interviewer/ducks/modules/network.js b/lib/interviewer/ducks/modules/network.js index 15f173bf..4236e62b 100644 --- a/lib/interviewer/ducks/modules/network.js +++ b/lib/interviewer/ducks/modules/network.js @@ -3,6 +3,7 @@ import { reject, find, isMatch, omit, keys, get, } from 'lodash'; import { v4 as uuid } from 'uuid'; +import { SET_SERVER_SESSION } from './setServerSession'; /* * For actionCreators see `src/ducks/modules/sessions` @@ -27,14 +28,14 @@ const UPDATE_EGO = 'UPDATE_EGO'; const ADD_SESSION = 'ADD_SESSION'; // Initial network model structure -export const getInitialNetworkState = () => ({ +export const initialState = { ego: { [entityPrimaryKeyProperty]: uuid(), [entityAttributesProperty]: {}, }, nodes: [], edges: [], -}); +}; // action creators @@ -129,8 +130,16 @@ const removeEdge = (state, edgeId) => ({ edges: reject(state.edges, (edge) => edge[entityPrimaryKeyProperty] === edgeId), }); -export default function reducer(state = getInitialNetworkState(), action = {}) { +export default function reducer(state = initialState, action = {}) { + console.log('network action', action); switch (action.type) { + case SET_SERVER_SESSION: { + if (!action.payload.session.network) { + return state; + } + + return action.session.network; + } case ADD_NODE: { return { ...state, diff --git a/lib/interviewer/ducks/modules/session.js b/lib/interviewer/ducks/modules/session.js index 2f85a7e3..66382a81 100644 --- a/lib/interviewer/ducks/modules/session.js +++ b/lib/interviewer/ducks/modules/session.js @@ -3,6 +3,7 @@ import { v4 as uuid } from 'uuid'; import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { actionTypes as installedProtocolsActionTypes } from './installedProtocols'; import networkReducer, { actionTypes as networkActionTypes, actionCreators as networkActions } from './network'; +import { SET_SERVER_SESSION } from './setServerSession'; const ADD_SESSION = 'ADD_SESSION'; const SET_SESSION_FINISHED = 'SET_SESSION_FINISHED'; @@ -25,6 +26,23 @@ const sessionExists = (sessionId, sessions) => has(sessions, sessionId); const getReducer = (network) => (state = initialState, action = {}) => { switch (action.type) { + case SET_SERVER_SESSION: { + if (!action.payload.session) { + return state; + } + + const { session: { id } } = action.payload; + const { protocol: { id: protocolUID } } = action.payload; + + return { + ...state, + [id]: { + protocolUID, + ...action.payload.session, + network: action.payload.session.network ?? network(state.network, action), + }, + } + } case installedProtocolsActionTypes.DELETE_PROTOCOL: return reduce(state, (result, sessionData, sessionId) => { if (sessionData.protocolUID !== action.protocolUID) { diff --git a/lib/interviewer/ducks/modules/setServerSession.ts b/lib/interviewer/ducks/modules/setServerSession.ts new file mode 100644 index 00000000..4eb4157e --- /dev/null +++ b/lib/interviewer/ducks/modules/setServerSession.ts @@ -0,0 +1,12 @@ +import type { Protocol } from '@codaco/shared-consts'; +import type { ServerSession } from '~/app/(interview)/interview/[interviewId]/page'; + +export const SET_SERVER_SESSION = 'INIT/SET_SERVER_SESSION'; + +export type SetServerSessionAction = { + type: typeof SET_SERVER_SESSION; + payload: { + protocol: Protocol; + session: ServerSession; + }; +}; diff --git a/lib/interviewer/hooks/useReadyForNextStage.ts b/lib/interviewer/hooks/useReadyForNextStage.ts new file mode 100644 index 00000000..3571a3e3 --- /dev/null +++ b/lib/interviewer/hooks/useReadyForNextStage.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { actionCreators as uiActions } from '../ducks/modules/ui'; +import type { RootState } from '../store'; + +const useReadyForNextStage = () => { + const dispatch = useDispatch(); + + const updateReady = useCallback( + (isReady: boolean) => { + dispatch(uiActions.update({ FORM_IS_READY: isReady })); + }, + [dispatch], + ); + + const isReady = useSelector( + (state: RootState) => !!state.ui.FORM_IS_READY ?? false, + ); + + useEffect(() => { + updateReady(false); + + return () => updateReady(false); + }, [updateReady]); + + return { isReady, updateReady }; +}; + +export default useReadyForNextStage; diff --git a/lib/interviewer/selectors/network.js b/lib/interviewer/selectors/network.js deleted file mode 100644 index 47a2f5e9..00000000 --- a/lib/interviewer/selectors/network.js +++ /dev/null @@ -1,163 +0,0 @@ -import { findKey, find, get } from 'lodash'; -import { getActiveSession } from './session'; -import { createDeepEqualSelector } from './utils'; -import { getProtocolCodebook } from './protocol'; -import { getEntityAttributes } from '../ducks/modules/network'; -import customFilter from '~/lib/network-query/filter'; -import { createSelector } from '@reduxjs/toolkit'; -import { getStageSubject, getSubjectType } from './prop'; - -export const getNetwork = createSelector( - getActiveSession, - (session) => session?.network, -); - -export const getPropStageFilter = (_, props) => props && props.stage && props.stage.filter; - -// Filtered network -export const getFilteredNetwork = createSelector( - getNetwork, - getPropStageFilter, - (network, nodeFilter) => { - if (nodeFilter && typeof nodeFilter !== 'function') { - const filterFunction = customFilter(nodeFilter); - return filterFunction(network); - } - return network; - }, -); - -export const getNetworkNodes = createSelector( - getFilteredNetwork, - (network) => network.nodes, -); - -export const getNetworkEgo = createSelector( - getFilteredNetwork, - (network) => network.ego, -); - -export const getNetworkEdges = createSelector( - getFilteredNetwork, - (network) => network.edges, -); - -export const getNodeTypeDefinition = createSelector( - getProtocolCodebook, - getStageSubject, - (codebook, { type }) => { - const nodeDefinitions = codebook && codebook.node; - return nodeDefinitions && nodeDefinitions[type]; - } -) - -// The user-defined name of a node type; e.g. `codebook.node[uuid].name == 'person'` -export const makeGetNodeTypeDefinition = () => getNodeTypeDefinition; - -// See: https://github.com/complexdatacollective/Network-Canvas/wiki/Node-Labeling -export const labelLogic = (codebookForNodeType, nodeAttributes) => { - // 1. In the codebook for the stage's subject, look for a variable with a name - // property of "name", and try to retrieve this value by key in the node's - // attributes - const variableCalledName = codebookForNodeType - && codebookForNodeType.variables - // Ignore case when looking for 'name' - && findKey(codebookForNodeType.variables, (variable) => variable.name.toLowerCase() === 'name'); - - if (variableCalledName && nodeAttributes[variableCalledName]) { - return nodeAttributes[variableCalledName]; - } - - // 2. Look for a property on the node with a key of ‘name’, and try to retrieve this - // value as a key in the node's attributes. - // const nodeVariableCalledName = get(nodeAttributes, 'name'); - - const nodeVariableCalledName = find( - nodeAttributes, - (_, key) => key.toLowerCase() === 'name', - ); - - if (nodeVariableCalledName) { - return nodeVariableCalledName; - } - - // 3. Last resort! - return 'No \'name\' variable!'; -}; - -const getNodeLabel = createSelector( - getNodeTypeDefinition, - (nodeTypeDefinition) => (node) => labelLogic(nodeTypeDefinition, getEntityAttributes(node)), -) - -// Gets the node label variable and returns its value, or "No label". -// See: https://github.com/complexdatacollective/Network-Canvas/wiki/Node-Labeling -export const makeGetNodeLabel = () => getNodeLabel - -const getType = (_, props) => props.type; - -export const getNodeColorSelector = createSelector( - getProtocolCodebook, - getType, - (codebook, nodeType) => { - const nodeDefinitions = codebook.node; - const nodeColor = get(nodeDefinitions, [nodeType, 'color'], 'node-color-seq-1'); - return nodeColor; - }, -) - -export const makeGetNodeColor = () => getNodeColorSelector; - -// Pure state selector variant of makeGetNodeColor -export const getNodeColor = (nodeType) => (state) => getNodeColorSelector(state, { type: nodeType }); - -export const getNodeTypeLabel = (nodeType) => (state) => { - const codebook = getProtocolCodebook(state); - const nodeDefinitions = codebook.node; - const nodeLabel = get(nodeDefinitions, [nodeType, 'name'], ''); - return nodeLabel; -}; - -export const makeGetEdgeLabel = () => createDeepEqualSelector( - getProtocolCodebook, - (_, props) => props.type, - (codebook, edgeType) => { - const edgeInfo = codebook.edge; - const edgeLabel = get(edgeInfo, [edgeType, 'name'], ''); - return edgeLabel; - }, -); - -export const makeGetEdgeColor = () => createDeepEqualSelector( - getProtocolCodebook, - (_, props) => props.type, - (codebook, edgeType) => { - const edgeInfo = codebook.edge; - const edgeColor = get(edgeInfo, [edgeType, 'color'], 'edge-color-seq-1'); - return edgeColor; - }, -); - -export const makeGetNodeAttributeLabel = () => createDeepEqualSelector( - getProtocolCodebook, - getSubjectType, - (_, props) => props.variableId, - (codebook, subjectType, variableId) => { - const nodeDefinitions = codebook.node; - const variables = get(nodeDefinitions, [subjectType, 'variables'], {}); - const attributeLabel = get(variables, [variableId, 'name'], variableId); - return attributeLabel; - }, -); - -export const makeGetCategoricalOptions = () => createDeepEqualSelector( - (state, props) => getProtocolCodebook(state, props), - getSubjectType, - (_, props) => props.variableId, - (codebook, subjectType, variableId) => { - const nodeDefinitions = codebook.node; - const variables = get(nodeDefinitions, [subjectType, 'variables'], {}); - const options = get(variables, [variableId, 'options'], []); - return options; - }, -); diff --git a/lib/interviewer/selectors/network.ts b/lib/interviewer/selectors/network.ts new file mode 100644 index 00000000..aee988fb --- /dev/null +++ b/lib/interviewer/selectors/network.ts @@ -0,0 +1,215 @@ +import { findKey, find } from 'lodash'; +import { getActiveSession } from './session'; +import { createDeepEqualSelector } from './utils'; +import { getProtocolCodebook } from './protocol'; +import { getEntityAttributes } from '../ducks/modules/network'; +import customFilter from '~/lib/network-query/filter'; +import { createSelector } from '@reduxjs/toolkit'; +import { getStageSubject, getSubjectType } from './prop'; +import type { + Codebook, + FilterDefinition, + NcNetwork, + NcNode, + NodeTypeDefinition, + Stage, + StageSubject, +} from '@codaco/shared-consts'; +import type { RootState } from '../store'; + +export const getNetwork = createSelector( + getActiveSession, + (session) => session?.network, +); + +export const getPropStageFilter = (_: unknown, props: { stage: Stage }) => + props?.stage?.filter ?? null; + +type FilterFunction = (network: NcNetwork) => NcNetwork; + +// Filtered network +export const getFilteredNetwork = createSelector( + getNetwork, + getPropStageFilter, + (network, nodeFilter: FilterDefinition | null) => { + if (!network) { + return null; + } + + if (nodeFilter && typeof nodeFilter !== 'function') { + const filterFunction: FilterFunction = customFilter(nodeFilter); + return filterFunction(network); + } + + return network; + }, +); + +export const getNetworkNodes = createSelector( + getFilteredNetwork, + (network) => network?.nodes ?? [], +); + +export const getNetworkEgo = createSelector( + getFilteredNetwork, + (network) => network?.ego ?? null, +); + +export const getNetworkEdges = createSelector( + getFilteredNetwork, + (network) => network?.edges ?? [], +); + +export const getNodeTypeDefinition = createSelector( + getProtocolCodebook, + getStageSubject, + (codebook: Codebook, { type }: StageSubject) => { + return codebook.node?.[type] ?? null; + }, +); + +// The user-defined name of a node type; e.g. `codebook.node[uuid].name == 'person'` +export const makeGetNodeTypeDefinition = () => getNodeTypeDefinition; + +// See: https://github.com/complexdatacollective/Network-Canvas/wiki/Node-Labeling +export const labelLogic = ( + codebookForNodeType: NodeTypeDefinition, + nodeAttributes: Record, +): string => { + // 1. In the codebook for the stage's subject, look for a variable with a name + // property of "name", and try to retrieve this value by key in the node's + // attributes + const variableCalledName = + codebookForNodeType && + codebookForNodeType.variables && + // Ignore case when looking for 'name' + findKey( + codebookForNodeType.variables, + (variable) => variable.name.toLowerCase() === 'name', + ); + + if (variableCalledName && nodeAttributes[variableCalledName]) { + return nodeAttributes[variableCalledName] as string; + } + + // 2. Look for a property on the node with a key of ‘name’, and try to retrieve this + // value as a key in the node's attributes. + // const nodeVariableCalledName = get(nodeAttributes, 'name'); + + const nodeVariableCalledName = find( + nodeAttributes, + (_, key) => key.toLowerCase() === 'name', + ); + + if (nodeVariableCalledName) { + return nodeVariableCalledName as string; + } + + // 3. Last resort! + return "No 'name' variable!"; +}; + +const getNodeLabel = createSelector( + getNodeTypeDefinition, + (nodeTypeDefinition: NodeTypeDefinition | null) => (node: NcNode) => { + if (!nodeTypeDefinition) { + return 'Node'; + } + + const nodeAttributes = getEntityAttributes(node) as Record; + + return labelLogic(nodeTypeDefinition, nodeAttributes); + }, +); + +// Gets the node label variable and returns its value, or "No label". +// See: https://github.com/complexdatacollective/Network-Canvas/wiki/Node-Labeling +export const makeGetNodeLabel = () => getNodeLabel; + +const getType = (_: unknown, props: Record) => + props.type ?? null; + +export const getNodeColorSelector = createSelector( + getProtocolCodebook, + getType, + (codebook: Codebook, nodeType: string | null) => { + if (!nodeType) { + return 'node-color-seq-1'; + } + + return codebook.node?.[nodeType]?.color ?? 'node-color-seq-1'; + }, +); + +export const makeGetNodeColor = () => getNodeColorSelector; + +// Pure state selector variant of makeGetNodeColor +export const getNodeColor = (nodeType: string) => (state: RootState) => + getNodeColorSelector(state, { type: nodeType }); + +export const getNodeTypeLabel = (nodeType: string) => (state: RootState) => { + const codebook = getProtocolCodebook(state) as unknown as Codebook; + return codebook.node?.[nodeType]?.name ?? ''; +}; + +export const makeGetEdgeLabel = () => + createSelector( + getProtocolCodebook, + (_, props: Record) => props.type ?? null, + (codebook, edgeType: string | null) => { + if (!edgeType) { + return ''; + } + + return (codebook as Codebook)?.edge?.[edgeType]?.name ?? ''; + }, + ); + +export const makeGetEdgeColor = () => + createSelector( + getProtocolCodebook, + (_, props: Record) => props.type ?? null, + (codebook, edgeType: string | null) => { + if (!edgeType) { + return 'edge-color-seq-1'; + } + + return ( + (codebook as Codebook)?.edge?.[edgeType]?.color ?? 'edge-color-seq-1' + ); + }, + ); + +export const makeGetNodeAttributeLabel = () => + createDeepEqualSelector( + getProtocolCodebook, + getSubjectType, + (_, props: Record) => props.variableId ?? null, + (codebook, subjectType: string | null, variableId: string | null) => { + if (!subjectType || !variableId) { + return ''; + } + + return ( + (codebook as Codebook).node?.[subjectType]?.variables?.[variableId] + ?.name ?? undefined + ); + }, + ); + +export const makeGetCategoricalOptions = () => + createDeepEqualSelector( + getProtocolCodebook, + getSubjectType, + (_, props: Record) => props.variableId ?? null, + (codebook, subjectType: string | null, variableId: string | null) => { + if (!subjectType || !variableId) { + return []; + } + + return ( + (codebook as Codebook).node?.[subjectType]?.variables?.[variableId] + ?.options ?? [] + ); + }, + ); diff --git a/lib/interviewer/selectors/prop.js b/lib/interviewer/selectors/prop.js index 0cb7ef13..e00ef262 100644 --- a/lib/interviewer/selectors/prop.js +++ b/lib/interviewer/selectors/prop.js @@ -37,7 +37,7 @@ export const getStageSubject = createSelector( export const getSubjectType = createSelector( getStageSubject, (subject) => { - return subject && subject.type; + return subject?.type ?? null; }, ); diff --git a/lib/interviewer/selectors/session.ts b/lib/interviewer/selectors/session.ts index 5735bd76..e42b1db7 100644 --- a/lib/interviewer/selectors/session.ts +++ b/lib/interviewer/selectors/session.ts @@ -28,7 +28,10 @@ export const getSessions = (state: RootState) => state.sessions; export const getActiveSession = createSelector( getActiveSessionId, getSessions, - (activeSessionId, sessions) => sessions.activeSessionId, + (activeSessionId, sessions) => { + if (!activeSessionId) return null; + return sessions[activeSessionId]; + }, ); export const getLastActiveSession = createSelector(getSessions, (sessions) => { @@ -50,10 +53,9 @@ export const getLastActiveSession = createSelector(getSessions, (sessions) => { return lastActiveSession; }); -export const getStageIndex = createSelector( - getActiveSession, - (session) => session?.stageIndex ?? 0, -); +export const getStageIndex = createSelector(getActiveSession, (session) => { + return session?.stageIndex ?? 0; +}); export const getCurrentStage = createSelector( getProtocolStages, @@ -84,7 +86,7 @@ export const getPrompts = createSelector( export const getPromptCount = createSelector( getPrompts, - (prompts) => prompts?.length ?? 0, + (prompts) => prompts?.length ?? 1, // If there are no prompts we have "1" prompt ); export const getIsFirstPrompt = createSelector( @@ -121,9 +123,13 @@ export const getSessionProgress = createSelector( getPromptCount, (stageIndex, stageCount, promptIndex, promptCount) => { const stageProgress = stageIndex / (stageCount - 1); - const promptProgress = promptCount ? promptIndex / promptCount : 0; - const percentProgress = - stageProgress + (promptProgress / (stageCount - 1)) * 100; + const stageWorth = 1 / stageCount; // The amount of progress each stage is worth + + const promptProgress = promptCount === 1 ? 1 : promptIndex / promptCount; // 1 when finished + + const promptWorth = promptProgress * stageWorth; + + const percentProgress = (stageProgress + promptWorth) * 100; return percentProgress; }, @@ -153,6 +159,8 @@ export const getNavigationInfo = createSelector( isLastPrompt, isFirstStage, isLastStage, + canMoveForward: !(isLastPrompt && isLastStage), + canMoveBackward: !(isFirstPrompt && isFirstStage), }), ); diff --git a/lib/interviewer/selectors/skip-logic.ts b/lib/interviewer/selectors/skip-logic.ts new file mode 100644 index 00000000..abb13ae5 --- /dev/null +++ b/lib/interviewer/selectors/skip-logic.ts @@ -0,0 +1,101 @@ +import getQuery from '~/lib/network-query/query'; +import { getProtocolStages } from './protocol'; +import { getNetwork } from './network'; +import { SkipLogicAction } from '../protocol-consts'; +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '../store'; +import type { NcNetwork, SkipDefinition, Stage } from '@codaco/shared-consts'; + +const rotateIndex = (max: number, nextIndex: number) => (nextIndex + max) % max; + +const maxLength = (state: RootState) => getProtocolStages(state).length; + +export const getNextIndex = (index: number) => + createSelector(maxLength, (max) => rotateIndex(max, index)); + +const getSkipLogic = (index: number) => + createSelector( + getProtocolStages, + (stages: Stage[]) => stages?.[index]?.skipLogic, + ); + +/** + * @returns {boolean} true for skip (when query matches), false for show (when query matches) + */ +const isSkipAction = (index: number) => + createSelector( + getSkipLogic(index), + (logic) => logic && logic.action === SkipLogicAction.SKIP, + ); + +const formatQueryParameters = (params: Record) => ({ + rules: [], + join: null, + ...params, +}); + +// Hacked together version of isStageSkipped that returns a map of all stages. +// This is more convinient to use with useSelector. +export const getSkipMap = createSelector( + getProtocolStages, + getNetwork, + (stages: Stage[], network: NcNetwork | undefined): Record => + stages.reduce( + (acc: Record, stage: Stage, index: number) => { + const skipLogic: SkipDefinition | null = stage.skipLogic ?? null; + + if (!skipLogic) { + return { + ...acc, + [index]: false, + }; + } + + const skipOnMatch = skipLogic.action === SkipLogicAction.SKIP; + + const queryParameters = formatQueryParameters(skipLogic.filter); + const result = getQuery(queryParameters)(network); + const isSkipped = (skipOnMatch && result) || (!skipOnMatch && !result); + + return { + ...acc, + [index]: isSkipped, + }; + }, + {}, + ), +); + +export const isStageSkipped = (index: number) => + createSelector( + getSkipLogic(index), + isSkipAction(index), + getNetwork, + (logic, skipOnMatch, network) => { + if (!logic) { + return false; + } + + // Handle skipLogic with no rules defined differently depending on action. + // skipLogic.action === SHOW <- always show the stage + // skipLogic.action === SKIP <- always skip the stage + // Allows for a quick way to disable a stage by setting SKIP if, and then + // not defining rules. + // Should be changed with https://github.com/complexdatacollective/Architect/issues/517 + if (logic.filter?.rules && logic.filter.rules.length === 0) { + // eslint-disable-next-line no-console + console.warn( + 'Encountered skip logic with no rules defined at index', + index, + ); // eslint-disable-line no-console + return !!skipOnMatch; + } + + const queryParameters = formatQueryParameters(logic.filter); + const result = getQuery(queryParameters)(network); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isSkipped = (skipOnMatch && result) || (!skipOnMatch && !result); + + return isSkipped; + }, + ); diff --git a/lib/network-query/query.js b/lib/network-query/query.js index 35149891..7abc1394 100644 --- a/lib/network-query/query.js +++ b/lib/network-query/query.js @@ -1,4 +1,4 @@ -const { getSingleRule } = require('./rules'); +import { getSingleRule } from './rules'; const getGroup = (rule) => { const { type, options } = rule; @@ -77,7 +77,7 @@ const getQuery = ({ rules, join }) => { // whole network must be evaluated if (type === 'alter_not_exists' || type === 'edge_not_exists') { return ruleIterator.call(typeRules, (rule) => - network.nodes.every((nodes) => rule(nodes, network.edges)), + network?.nodes?.every((nodes) => rule(nodes, network.edges)), ); } @@ -85,7 +85,7 @@ const getQuery = ({ rules, join }) => { * 'alter' and 'edge' type rules * If any of the nodes match, this rule passes. */ - return network.nodes.some((node) => + return network?.nodes?.some((node) => ruleIterator.call(typeRules, (rule) => rule(node, network.edges)), ); }); @@ -96,4 +96,4 @@ Object.defineProperty(exports, '__esModule', { value: true, }); -exports.default = getQuery; +export default getQuery; diff --git a/lib/ui/components/Node.js b/lib/ui/components/Node.js index 9a734793..f01862c8 100644 --- a/lib/ui/components/Node.js +++ b/lib/ui/components/Node.js @@ -33,8 +33,8 @@ class Node extends Component { return `node__label-text len-${labelLength}`; }; - const nodeBaseColor = `var(--${color})`; - const nodeFlashColor = `var(--${color}--dark)`; + const nodeBaseColor = `var(--nc-${color})`; + const nodeFlashColor = `var(--nc-${color}--dark)`; const labelWithEllipsis = label.length < 22 ? label : `${label.substring(0, 18)}\u{AD}...`; // Add ellipsis for really long labels