From d843880c6273b3a8857382f3063bc2b134a9cb53 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 5 Dec 2024 14:57:28 +0200 Subject: [PATCH 01/15] set up blank interfaces --- .../containers/Interfaces/Anonymisation.tsx | 3 + .../containers/Interfaces/DyadCensus/index.js | 1 - .../Interfaces/OneToManyDyadCensus.tsx | 3 + .../containers/Interfaces/index.js | 82 ----------------- .../containers/Interfaces/index.tsx | 91 +++++++++++++++++++ lib/interviewer/protocol-consts.js | 2 + 6 files changed, 99 insertions(+), 83 deletions(-) create mode 100644 lib/interviewer/containers/Interfaces/Anonymisation.tsx delete mode 100644 lib/interviewer/containers/Interfaces/DyadCensus/index.js create mode 100644 lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx delete mode 100644 lib/interviewer/containers/Interfaces/index.js create mode 100644 lib/interviewer/containers/Interfaces/index.tsx diff --git a/lib/interviewer/containers/Interfaces/Anonymisation.tsx b/lib/interviewer/containers/Interfaces/Anonymisation.tsx new file mode 100644 index 00000000..cd42bb51 --- /dev/null +++ b/lib/interviewer/containers/Interfaces/Anonymisation.tsx @@ -0,0 +1,3 @@ +export default function Anonymisation() { + return <>Anonymisation; +} diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/index.js b/lib/interviewer/containers/Interfaces/DyadCensus/index.js deleted file mode 100644 index fc2b435a..00000000 --- a/lib/interviewer/containers/Interfaces/DyadCensus/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './DyadCensus'; diff --git a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx new file mode 100644 index 00000000..711b7dcb --- /dev/null +++ b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx @@ -0,0 +1,3 @@ +export default function OneToManyDyadCensus() { + return <>One to many dyad census; +} diff --git a/lib/interviewer/containers/Interfaces/index.js b/lib/interviewer/containers/Interfaces/index.js deleted file mode 100644 index 609624d7..00000000 --- a/lib/interviewer/containers/Interfaces/index.js +++ /dev/null @@ -1,82 +0,0 @@ -import Icon from '~/lib/ui/components/Icon'; -import dynamic from 'next/dynamic'; -import { StageType } from '../../protocol-consts'; -import { Loader2 } from 'lucide-react'; - -const StageLoading = () => ( -
- -
-); - -const NameGenerator = dynamic(() => import('./NameGenerator'), { loading: StageLoading }); -const NameGeneratorQuickAdd = dynamic(() => import('./NameGeneratorQuickAdd'), { loading: StageLoading }); -const NameGeneratorRoster = dynamic(() => import('./NameGeneratorRoster'), { loading: StageLoading }); -const Sociogram = dynamic(() => import('./Sociogram'), { loading: StageLoading }); -const Information = dynamic(() => import('./Information'), { loading: StageLoading }); -const OrdinalBin = dynamic(() => import('./OrdinalBin'), { loading: StageLoading }); -const CategoricalBin = dynamic(() => import('./CategoricalBin'), { loading: StageLoading }); -const Narrative = dynamic(() => import('./Narrative'), { loading: StageLoading }); -const AlterForm = dynamic(() => import('./AlterForm'), { loading: StageLoading }); -const EgoForm = dynamic(() => import('./EgoForm'), { loading: StageLoading }); -const AlterEdgeForm = dynamic(() => import('./AlterEdgeForm'), { loading: StageLoading }); -const DyadCensus = dynamic(() => import('./DyadCensus'), { loading: StageLoading }); -const TieStrengthCensus = dynamic(() => import('./TieStrengthCensus'), { loading: StageLoading }); -const FinishSession = dynamic(() => import('./FinishSession'), { loading: StageLoading }); - -const NotFoundInterface = ({ interfaceType }) => ( -
-
- -

- No " - {interfaceType} - " interface found. -

-
-
-); - -const getInterface = (interfaceType) => { - switch (interfaceType) { - case StageType.NameGenerator: - return NameGenerator; - case StageType.NameGeneratorQuickAdd: - return NameGeneratorQuickAdd; - case StageType.NameGeneratorRoster: - return NameGeneratorRoster; - case StageType.Sociogram: - return Sociogram; - case StageType.Information: - return Information; - case StageType.OrdinalBin: - return OrdinalBin; - case StageType.CategoricalBin: - return CategoricalBin; - case StageType.Narrative: - return Narrative; - case StageType.AlterForm: - return AlterForm; - case StageType.EgoForm: - return EgoForm; - case StageType.AlterEdgeForm: - return AlterEdgeForm; - case StageType.DyadCensus: - return DyadCensus; - case StageType.TieStrengthCensus: - return TieStrengthCensus; - case 'FinishSession': - return FinishSession; - default: - return ; - } -}; - -export default getInterface; diff --git a/lib/interviewer/containers/Interfaces/index.tsx b/lib/interviewer/containers/Interfaces/index.tsx new file mode 100644 index 00000000..7cc49868 --- /dev/null +++ b/lib/interviewer/containers/Interfaces/index.tsx @@ -0,0 +1,91 @@ +import { Loader2 } from 'lucide-react'; +import dynamic from 'next/dynamic'; +import Icon from '~/lib/ui/components/Icon'; +import { StageType } from '../../protocol-consts'; + +const StageLoading = () => ( +
+ +
+); + +const NotFoundInterface = ({ interfaceType }: { interfaceType: string }) => ( +
+
+ +

+ No " + {interfaceType} + " interface found. +

+
+
+); + +const getInterface = (interfaceType: string) => { + switch (interfaceType) { + case StageType.NameGenerator: + return dynamic(() => import('./NameGenerator'), { + loading: StageLoading, + }); + case StageType.NameGeneratorQuickAdd: + return dynamic(() => import('./NameGeneratorQuickAdd'), { + loading: StageLoading, + }); + case StageType.NameGeneratorRoster: + return dynamic(() => import('./NameGeneratorRoster'), { + loading: StageLoading, + }); + case StageType.Sociogram: + return dynamic(() => import('./Sociogram'), { loading: StageLoading }); + case StageType.Information: + return dynamic(() => import('./Information'), { loading: StageLoading }); + case StageType.OrdinalBin: + return dynamic(() => import('./OrdinalBin'), { loading: StageLoading }); + case StageType.CategoricalBin: + return dynamic(() => import('./CategoricalBin'), { + loading: StageLoading, + }); + case StageType.Narrative: + return dynamic(() => import('./Narrative'), { loading: StageLoading }); + case StageType.AlterForm: + return dynamic(() => import('./AlterForm'), { loading: StageLoading }); + case StageType.EgoForm: + return dynamic(() => import('./EgoForm'), { loading: StageLoading }); + case StageType.AlterEdgeForm: + return dynamic(() => import('./AlterEdgeForm'), { + loading: StageLoading, + }); + case StageType.DyadCensus: + return dynamic(() => import('./DyadCensus/DyadCensus'), { + loading: StageLoading, + }); + case StageType.TieStrengthCensus: + return dynamic(() => import('./TieStrengthCensus'), { + loading: StageLoading, + }); + case StageType.OneToManyDyadCensus: + return dynamic(() => import('./OneToManyDyadCensus'), { + loading: StageLoading, + }); + case StageType.Anonymisation: + return dynamic(() => import('./Anonymisation'), { + loading: StageLoading, + }); + case 'FinishSession': + return dynamic(() => import('./FinishSession'), { + loading: StageLoading, + }); + default: + return ; + } +}; + +export default getInterface; diff --git a/lib/interviewer/protocol-consts.js b/lib/interviewer/protocol-consts.js index 2d3b4d4e..814a469c 100644 --- a/lib/interviewer/protocol-consts.js +++ b/lib/interviewer/protocol-consts.js @@ -102,6 +102,8 @@ export const StageType = Object.freeze({ AlterEdgeForm: 'AlterEdgeForm', DyadCensus: 'DyadCensus', TieStrengthCensus: 'TieStrengthCensus', + OneToManyDyadCensus: 'OneToManyDyadCensus', + Anonymisation: 'Anonymisation', }); // VariableTYpe imported from network-exporters submodule From 1d546f1883ae293d241888fcc10027017ec9b532 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 5 Dec 2024 17:44:19 +0200 Subject: [PATCH 02/15] first draught --- .../containers/Interfaces/AlterForm.js | 8 +- .../Interfaces/DyadCensus/DyadCensus.js | 9 +- .../Interfaces/OneToManyDyadCensus.tsx | 139 +++++++++++++++++- .../containers/Interfaces/index.tsx | 102 ++++++++----- lib/interviewer/containers/Stage.tsx | 8 +- lib/interviewer/hooks/usePropSelector.js | 36 ----- lib/interviewer/hooks/usePropSelector.ts | 53 +++++++ lib/interviewer/selectors/network.ts | 2 +- lib/protocol-validation/schemas/src/8.zod.ts | 1 + lib/test-protocol.ts | 14 +- 10 files changed, 280 insertions(+), 92 deletions(-) delete mode 100644 lib/interviewer/hooks/usePropSelector.js create mode 100644 lib/interviewer/hooks/usePropSelector.ts diff --git a/lib/interviewer/containers/Interfaces/AlterForm.js b/lib/interviewer/containers/Interfaces/AlterForm.js index e403288f..197bf67f 100644 --- a/lib/interviewer/containers/Interfaces/AlterForm.js +++ b/lib/interviewer/containers/Interfaces/AlterForm.js @@ -1,4 +1,3 @@ -import React from 'react'; import { connect } from 'react-redux'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; import { makeNetworkNodesForType } from '../../selectors/interface'; @@ -6,11 +5,7 @@ import SlideFormNode from '../SlidesForm/SlideFormNode'; import SlidesForm from '../SlidesForm/SlidesForm'; const AlterForm = (props) => ( - + ); function makeMapStateToProps() { @@ -29,5 +24,4 @@ const mapDispatchToProps = { const withAlterStore = connect(makeMapStateToProps, mapDispatchToProps); - export default withAlterStore(AlterForm); diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js b/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js index 915cbbe8..f467bfe6 100644 --- a/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js +++ b/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js @@ -3,13 +3,16 @@ import { get } from 'es-toolkit/compat'; import { AnimatePresence, motion } from 'motion/react'; import PropTypes from 'prop-types'; import { useState } from 'react'; +import { usePrompts } from '~/lib/interviewer/behaviours/withPrompt'; +import Prompts from '~/lib/interviewer/components/Prompts'; import usePropSelector from '~/lib/interviewer/hooks/usePropSelector'; import { getNetworkNodesForType } from '~/lib/interviewer/selectors/interface'; +import { + getEdgeColor, + getNetworkEdges, +} from '~/lib/interviewer/selectors/network'; import BooleanOption from '~/lib/ui/components/Boolean/BooleanOption'; import { Markdown } from '~/lib/ui/components/Fields'; -import { usePrompts } from '../../../behaviours/withPrompt'; -import Prompts from '../../../components/Prompts'; -import { getEdgeColor, getNetworkEdges } from '../../../selectors/network'; import { getNodePair, getPairs } from './helpers'; import Pair from './Pair'; import useAutoAdvance from './useAutoAdvance'; diff --git a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx index 711b7dcb..068cf08e 100644 --- a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx +++ b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx @@ -1,3 +1,138 @@ -export default function OneToManyDyadCensus() { - return <>One to many dyad census; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, + type NcNode, +} from '@codaco/shared-consts'; +import { AnimatePresence, motion } from 'motion/react'; +import { useEffect, useState } from 'react'; +import { usePrompts } from '~/lib/interviewer/behaviours/withPrompt'; +import usePropSelector from '~/lib/interviewer/hooks/usePropSelector'; +import Node from '../../components/Node'; +import Prompts from '../../components/Prompts'; +import { getNetworkNodesForType } from '../../selectors/interface'; +import { getNetworkEdges } from '../../selectors/network'; +import { type StageProps } from '../Stage'; + +/** + * For a given nodelist, we need to cycle through each node, and offer the + * participant a list of the rest of the nodes that could be linked to. + * + * Because edges are assumed to be undirected, we can remove pairs that have + * already been considered. + * + * This function generates a data structure that allows this, which looks like + * the following: + * + * [ + * { + * source: NcNode, + * targets: NcNode[] + * } + * ] + */ +const generateEdgeOptions = (nodes: NcNode[]) => { + const options = []; + + for (const source of nodes) { + const targets = nodes.filter( + (node) => + node[entityAttributesProperty] !== source[entityAttributesProperty], + ); + + options.push({ + source, + targets, + }); + } + + return options; +}; + +const cardvariants = { + hide: { opacity: 0, scale: 0.9 }, + show: { opacity: 1, scale: 1 }, +}; + +type OneToManyDyadCensusProps = StageProps & { + // add any additional props here +}; + +export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { + const { registerBeforeNext, stage } = props; + const [currentStep, setCurrentStep] = useState(0); + const nodes = usePropSelector(getNetworkNodesForType, props); + const edges = usePropSelector(getNetworkEdges, props); + + const options = generateEdgeOptions(nodes); + + /** + * Hijack stage navigation: + * - If we are moving forward and not on the last step, increment the step + * - If we are moving forward and on the last step, allow navigation + * - If we are moving backward, decrement the step until we reach 0 + * - If we are moving backward and on step 0, allow navigation + */ + // eslint-disable-next-line @typescript-eslint/require-await + registerBeforeNext(async () => { + if (currentStep < options.length - 1) { + setCurrentStep(currentStep + 1); + return false; + } + + return true; + }); + + const { + prompt: { createEdge }, + promptIndex, + prompts, + } = usePrompts(); + + console.log({ nodes, edges, options: generateEdgeOptions(nodes) }); + + // Reset the step when the prompt changes + useEffect(() => { + setCurrentStep(0); + }, [promptIndex]); + + return ( +
+
+ +
+ + +
+ + + +
+
+ {options[currentStep]?.targets.map((node) => ( + + + + ))} +
+
+
+
+ ); } diff --git a/lib/interviewer/containers/Interfaces/index.tsx b/lib/interviewer/containers/Interfaces/index.tsx index 7cc49868..0c685f2d 100644 --- a/lib/interviewer/containers/Interfaces/index.tsx +++ b/lib/interviewer/containers/Interfaces/index.tsx @@ -9,6 +9,53 @@ const StageLoading = () => ( ); +const NameGenerator = dynamic(() => import('./NameGenerator'), { + loading: StageLoading, +}); +const NameGeneratorQuickAdd = dynamic(() => import('./NameGeneratorQuickAdd'), { + loading: StageLoading, +}); +const NameGeneratorRoster = dynamic(() => import('./NameGeneratorRoster'), { + loading: StageLoading, +}); +const Sociogram = dynamic(() => import('./Sociogram'), { + loading: StageLoading, +}); +const Information = dynamic(() => import('./Information'), { + loading: StageLoading, +}); +const OrdinalBin = dynamic(() => import('./OrdinalBin'), { + loading: StageLoading, +}); +const CategoricalBin = dynamic(() => import('./CategoricalBin'), { + loading: StageLoading, +}); +const Narrative = dynamic(() => import('./Narrative'), { + loading: StageLoading, +}); +const AlterForm = dynamic(() => import('./AlterForm'), { + loading: StageLoading, +}); +const EgoForm = dynamic(() => import('./EgoForm'), { loading: StageLoading }); +const AlterEdgeForm = dynamic(() => import('./AlterEdgeForm'), { + loading: StageLoading, +}); +const DyadCensus = dynamic(() => import('./DyadCensus/DyadCensus'), { + loading: StageLoading, +}); +const TieStrengthCensus = dynamic(() => import('./TieStrengthCensus'), { + loading: StageLoading, +}); +const FinishSession = dynamic(() => import('./FinishSession'), { + loading: StageLoading, +}); +const Anonymisation = dynamic(() => import('./Anonymisation'), { + loading: StageLoading, +}); +const OneToManyDyadCensus = dynamic(() => import('./OneToManyDyadCensus'), { + loading: StageLoading, +}); + const NotFoundInterface = ({ interfaceType }: { interfaceType: string }) => (
( const getInterface = (interfaceType: string) => { switch (interfaceType) { case StageType.NameGenerator: - return dynamic(() => import('./NameGenerator'), { - loading: StageLoading, - }); + return NameGenerator; case StageType.NameGeneratorQuickAdd: - return dynamic(() => import('./NameGeneratorQuickAdd'), { - loading: StageLoading, - }); + return NameGeneratorQuickAdd; case StageType.NameGeneratorRoster: - return dynamic(() => import('./NameGeneratorRoster'), { - loading: StageLoading, - }); + return NameGeneratorRoster; case StageType.Sociogram: - return dynamic(() => import('./Sociogram'), { loading: StageLoading }); + return Sociogram; case StageType.Information: - return dynamic(() => import('./Information'), { loading: StageLoading }); + return Information; case StageType.OrdinalBin: - return dynamic(() => import('./OrdinalBin'), { loading: StageLoading }); + return OrdinalBin; case StageType.CategoricalBin: - return dynamic(() => import('./CategoricalBin'), { - loading: StageLoading, - }); + return CategoricalBin; case StageType.Narrative: - return dynamic(() => import('./Narrative'), { loading: StageLoading }); + return Narrative; case StageType.AlterForm: - return dynamic(() => import('./AlterForm'), { loading: StageLoading }); + return AlterForm; case StageType.EgoForm: - return dynamic(() => import('./EgoForm'), { loading: StageLoading }); + return EgoForm; case StageType.AlterEdgeForm: - return dynamic(() => import('./AlterEdgeForm'), { - loading: StageLoading, - }); + return AlterEdgeForm; case StageType.DyadCensus: - return dynamic(() => import('./DyadCensus/DyadCensus'), { - loading: StageLoading, - }); + return DyadCensus; case StageType.TieStrengthCensus: - return dynamic(() => import('./TieStrengthCensus'), { - loading: StageLoading, - }); - case StageType.OneToManyDyadCensus: - return dynamic(() => import('./OneToManyDyadCensus'), { - loading: StageLoading, - }); + return TieStrengthCensus; case StageType.Anonymisation: - return dynamic(() => import('./Anonymisation'), { - loading: StageLoading, - }); + return Anonymisation; + case StageType.OneToManyDyadCensus: + return OneToManyDyadCensus; case 'FinishSession': - return dynamic(() => import('./FinishSession'), { - loading: StageLoading, - }); + return FinishSession; + default: return ; } diff --git a/lib/interviewer/containers/Stage.tsx b/lib/interviewer/containers/Stage.tsx index 89b4f18e..0141ae0d 100644 --- a/lib/interviewer/containers/Stage.tsx +++ b/lib/interviewer/containers/Stage.tsx @@ -1,9 +1,9 @@ -import getInterface from './Interfaces'; -import StageErrorBoundary from '../components/StageErrorBoundary'; import { type ElementType, memo } from 'react'; +import StageErrorBoundary from '../components/StageErrorBoundary'; +import getInterface from './Interfaces'; import { type BeforeNextFunction } from './ProtocolScreen'; -type StageProps = { +export type StageProps = { stage: { id: string; type: string; @@ -24,7 +24,7 @@ function Stage(props: StageProps) { return (
diff --git a/lib/interviewer/hooks/usePropSelector.js b/lib/interviewer/hooks/usePropSelector.js deleted file mode 100644 index dbe03c69..00000000 --- a/lib/interviewer/hooks/usePropSelector.js +++ /dev/null @@ -1,36 +0,0 @@ -import { useMemo, useCallback } from 'react'; -import { useSelector } from 'react-redux'; - -/** - * Converts legacy react-redux selectors that take a props argument - * into ones that can be used with useSelector. - * - * Usage: - * - * const oldSelector = (state, props) => {}; - * - * const results = usePropSelector(oldSelector, props); - * - * or for factory style selectors: - * - * const makeOldSelector = (config) => (state, props) => {}; - * - * const results = usePropSelector(makeOldSelector, props, true); - */ -const usePropSelector = (selector, props, isFactory = false, equalityFn) => { - const memoizedSelector = useMemo(() => { - if (isFactory) { return selector(); } - return selector; - }, [isFactory, selector]); - - const selectorWithProps = useCallback( - (state) => memoizedSelector(state, props), - [props, memoizedSelector], - ); - - const state = useSelector(selectorWithProps, equalityFn); - - return state; -}; - -export default usePropSelector; diff --git a/lib/interviewer/hooks/usePropSelector.ts b/lib/interviewer/hooks/usePropSelector.ts new file mode 100644 index 00000000..458adcc6 --- /dev/null +++ b/lib/interviewer/hooks/usePropSelector.ts @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +type Selector = ( + state: TState, + props: TProps, +) => TResult; +type SelectorFactory = () => Selector< + TState, + TProps, + TResult +>; + +/** + * Converts legacy react-redux selectors that take a props argument + * into ones that can be used with useSelector. + */ +function usePropSelector( + selector: Selector, + props: TProps, + isFactory?: false, + equalityFn?: (left: TResult, right: TResult) => boolean, +): TResult; +function usePropSelector( + selector: SelectorFactory, + props: TProps, + isFactory: true, + equalityFn?: (left: TResult, right: TResult) => boolean, +): TResult; +function usePropSelector( + selector: + | Selector + | SelectorFactory, + props: TProps, + isFactory = false, + equalityFn?: (left: TResult, right: TResult) => boolean, +): TResult { + const memoizedSelector = useMemo(() => { + if (isFactory) { + return (selector as SelectorFactory)(); + } + return selector as Selector; + }, [isFactory, selector]); + + const selectorWithProps = useCallback( + (state: TState) => memoizedSelector(state, props), + [props, memoizedSelector], + ); + + return useSelector(selectorWithProps, equalityFn); +} + +export default usePropSelector; diff --git a/lib/interviewer/selectors/network.ts b/lib/interviewer/selectors/network.ts index 7289df8c..1690aa93 100644 --- a/lib/interviewer/selectors/network.ts +++ b/lib/interviewer/selectors/network.ts @@ -43,7 +43,7 @@ const getFilteredNetwork = createSelector( return filterFunction(network); } - return network; + return network as NcNetwork; }, ); diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index 9888de3d..3a8d5760 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -455,6 +455,7 @@ const oneToManyDyadCensusStage = baseStageSchema.extend({ .array( promptSchema.extend({ createEdge: z.string(), + bucketSortOrder: sortOrderSchema.optional(), }), ) .min(1), diff --git a/lib/test-protocol.ts b/lib/test-protocol.ts index ba61c81b..00a5f4c3 100644 --- a/lib/test-protocol.ts +++ b/lib/test-protocol.ts @@ -27,7 +27,11 @@ export const protocol: Protocol = { quickAdd: '28c8ae72-2b6a-438f-ab09-35169aaffdeb', prompts: [ { - id: 'a9ad8715-6bce-46be-a9c4-10e6766dfe62', + id: 'people', + text: 'Please name some people\n', + }, + { + id: 'places', text: 'Please name some people\n', }, ], @@ -39,13 +43,19 @@ export const protocol: Protocol = { type: 'OneToManyDyadCensus', subject: { entity: 'node', - type: 'person_node_type', + type: 'd88fa70b-cbfa-4f4b-8536-85d1dc14de1e', }, prompts: [ { id: 'friends', text: 'Are these people friends?', createEdge: 'friend_edge_type', + bucketSortOrder: [ + { + property: '*', + direction: 'desc', + }, + ], }, { id: 'professional', From faf9c65e219eb16129c26a3c798ff79b1aae2bde Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 5 Dec 2024 19:45:34 +0200 Subject: [PATCH 03/15] make Node components compatible with framer motion --- lib/interviewer/components/Node.js | 30 ------------ lib/interviewer/components/Node.tsx | 26 ++++++++++ .../Interfaces/OneToManyDyadCensus.tsx | 35 +++++++------ lib/ui/components/{Node.js => Node.tsx} | 49 +++++++++---------- 4 files changed, 65 insertions(+), 75 deletions(-) delete mode 100644 lib/interviewer/components/Node.js create mode 100644 lib/interviewer/components/Node.tsx rename lib/ui/components/{Node.js => Node.tsx} (73%) diff --git a/lib/interviewer/components/Node.js b/lib/interviewer/components/Node.js deleted file mode 100644 index 4cd950f4..00000000 --- a/lib/interviewer/components/Node.js +++ /dev/null @@ -1,30 +0,0 @@ -import { useSelector } from 'react-redux'; -import UINode from '~/lib/ui/components/Node'; -import { - getNodeColor, labelLogic, -} from '../selectors/network'; -import { getProtocolCodebook } from '../selectors/protocol'; -import { getEntityAttributes } from '~/lib/interviewer/ducks/modules/network'; - -/** - * Renders a Node. - */ - -const Node = (props) => { - const { type } = props; - - const color = useSelector(getNodeColor(type)); - const codebook = useSelector(getProtocolCodebook); - const label = labelLogic(codebook.node[type], getEntityAttributes(props)); - - return ( - - ); -} - -export default Node; - diff --git a/lib/interviewer/components/Node.tsx b/lib/interviewer/components/Node.tsx new file mode 100644 index 00000000..c9f7789e --- /dev/null +++ b/lib/interviewer/components/Node.tsx @@ -0,0 +1,26 @@ +import { type Codebook, type NcNode } from '@codaco/shared-consts'; +import { forwardRef } from 'react'; +import { useSelector } from 'react-redux'; +import { getEntityAttributes } from '~/lib/interviewer/ducks/modules/network'; +import UINode, { type UINodeProps } from '~/lib/ui/components/Node'; +import { getNodeColor, labelLogic } from '../selectors/network'; +import { getProtocolCodebook } from '../selectors/protocol'; + +type NodeProps = { + type: string; +} & UINodeProps; + +const Node = forwardRef((props, ref) => { + const { type } = props; + + const color = useSelector(getNodeColor(type)); + const codebook = useSelector(getProtocolCodebook) as Codebook; + const attributes = getEntityAttributes(props) as NcNode['attributes']; + const label = labelLogic(codebook.node![type]!, attributes); + + return ; +}); + +Node.displayName = 'Node'; + +export default Node; diff --git a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx index 068cf08e..b3f62b96 100644 --- a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx +++ b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx @@ -3,7 +3,7 @@ import { entityPrimaryKeyProperty, type NcNode, } from '@codaco/shared-consts'; -import { AnimatePresence, motion } from 'motion/react'; +import { AnimatePresence, motion, type Variants } from 'motion/react'; import { useEffect, useState } from 'react'; import { usePrompts } from '~/lib/interviewer/behaviours/withPrompt'; import usePropSelector from '~/lib/interviewer/hooks/usePropSelector'; @@ -13,6 +13,8 @@ import { getNetworkNodesForType } from '../../selectors/interface'; import { getNetworkEdges } from '../../selectors/network'; import { type StageProps } from '../Stage'; +const MotionNode = motion.create(Node); + /** * For a given nodelist, we need to cycle through each node, and offer the * participant a list of the rest of the nodes that could be linked to. @@ -48,9 +50,9 @@ const generateEdgeOptions = (nodes: NcNode[]) => { return options; }; -const cardvariants = { +const cardvariants: Variants = { hide: { opacity: 0, scale: 0.9 }, - show: { opacity: 1, scale: 1 }, + show: { opacity: 1, scale: 1, transition: { when: 'beforeChildren' } }, }; type OneToManyDyadCensusProps = StageProps & { @@ -109,26 +111,23 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { exit="hide" animate="show" > -
- - - -
+ +
{options[currentStep]?.targets.map((node) => ( - - - + /> ))}
diff --git a/lib/ui/components/Node.js b/lib/ui/components/Node.tsx similarity index 73% rename from lib/ui/components/Node.js rename to lib/ui/components/Node.tsx index 27393284..63cea393 100644 --- a/lib/ui/components/Node.js +++ b/lib/ui/components/Node.tsx @@ -1,14 +1,23 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { forwardRef } from 'react'; + +export type UINodeProps = { + color?: string; + inactive?: boolean; + label?: string; + selected?: boolean; + selectedColor?: string; + linking?: boolean; + handleClick?: () => void; +}; /** * Renders a Node. */ -class Node extends Component { - render() { - const { +const Node = forwardRef( + ( + { label = 'Node', color = 'node-color-seq-1', inactive = false, @@ -16,8 +25,9 @@ class Node extends Component { selectedColor = '', linking = false, handleClick, - } = this.props; - + }, + ref, + ) => { const classes = classNames('node', { 'node--inactive': inactive, 'node--selected': selected, @@ -37,7 +47,7 @@ class Node extends Component { label.length < 22 ? label : `${label.substring(0, 18)}\u{AD}...`; // Add ellipsis for really long labels return ( -
handleClick?.()}> +
handleClick?.()} ref={ref}>
-
{ - this.labelText = labelText; - }} - > - {labelWithEllipsis} -
+
{labelWithEllipsis}
); - } -} + }, +); -Node.propTypes = { - color: PropTypes.string, - inactive: PropTypes.bool, - label: PropTypes.string, - selected: PropTypes.bool, - selectedColor: PropTypes.string, - linking: PropTypes.bool, - handleClick: PropTypes.func, -}; +Node.displayName = 'Node'; export default Node; From 814251029e4e574dc8ab0648d678b94f03bba18e Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 5 Dec 2024 20:45:50 +0200 Subject: [PATCH 04/15] tidy up --- .../Interfaces/OneToManyDyadCensus.tsx | 94 ++++++++++++++----- lib/protocol-validation/schemas/src/8.zod.ts | 6 +- lib/test-protocol.ts | 14 ++- 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx index b3f62b96..5c1a9dc8 100644 --- a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx +++ b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx @@ -1,11 +1,14 @@ import { entityAttributesProperty, entityPrimaryKeyProperty, + type NcEdge, type NcNode, } from '@codaco/shared-consts'; import { AnimatePresence, motion, type Variants } from 'motion/react'; import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; import { usePrompts } from '~/lib/interviewer/behaviours/withPrompt'; +import { actionCreators as sessionActions } from '~/lib/interviewer/ducks/modules/session'; import usePropSelector from '~/lib/interviewer/hooks/usePropSelector'; import Node from '../../components/Node'; import Prompts from '../../components/Prompts'; @@ -60,7 +63,7 @@ type OneToManyDyadCensusProps = StageProps & { }; export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { - const { registerBeforeNext, stage } = props; + const { registerBeforeNext } = props; const [currentStep, setCurrentStep] = useState(0); const nodes = usePropSelector(getNetworkNodesForType, props); const edges = usePropSelector(getNetworkEdges, props); @@ -75,10 +78,23 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { * - If we are moving backward and on step 0, allow navigation */ // eslint-disable-next-line @typescript-eslint/require-await - registerBeforeNext(async () => { - if (currentStep < options.length - 1) { - setCurrentStep(currentStep + 1); - return false; + registerBeforeNext(async (direction) => { + if (direction === 'forwards') { + if (currentStep < options.length - 1) { + setCurrentStep((prev) => prev + 1); + return false; + } + + return true; + } + + if (direction === 'backwards') { + if (currentStep > 0) { + setCurrentStep((prev) => prev - 1); + return false; + } + + return true; } return true; @@ -87,9 +103,10 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { const { prompt: { createEdge }, promptIndex, - prompts, } = usePrompts(); + const dispatch = useDispatch(); + console.log({ nodes, edges, options: generateEdgeOptions(nodes) }); // Reset the step when the prompt changes @@ -97,36 +114,69 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { setCurrentStep(0); }, [promptIndex]); + const sourceNode = options[currentStep]?.source!; + + function edgeExists( + targetId: string, + sourceId: string, + edges: NcEdge[], + ): boolean { + return edges.some( + (edge) => + (edge.from === targetId && edge.to === sourceId) || + (edge.from === sourceId && edge.to === targetId), + ); + } + + const handleNodeClick = (node: NcNode) => () => { + console.log('Creating edge between', sourceNode, node); + dispatch( + sessionActions.toggleEdge({ + from: sourceNode[entityPrimaryKeyProperty], + to: node[entityPrimaryKeyProperty], + type: createEdge, + }), + ); + }; + return ( -
-
- -
- +
+ - - -
+
+ +
+ +
+
+ +
{options[currentStep]?.targets.map((node) => ( ))}
diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index 3a8d5760..efda315b 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -98,7 +98,7 @@ const nodeSchema = z name: z.string(), displayVariable: z.string().optional(), iconVariant: z.string().optional(), - variables: VariablesSchema, + variables: VariablesSchema.optional(), color: z.string(), }) .strict(); @@ -107,13 +107,13 @@ const edgeSchema = z .object({ name: z.string(), color: z.string(), - variables: VariablesSchema, + variables: VariablesSchema.optional(), }) .strict(); const egoSchema = z .object({ - variables: VariablesSchema, + variables: VariablesSchema.optional(), }) .strict(); diff --git a/lib/test-protocol.ts b/lib/test-protocol.ts index 00a5f4c3..8366c8d0 100644 --- a/lib/test-protocol.ts +++ b/lib/test-protocol.ts @@ -48,7 +48,7 @@ export const protocol: Protocol = { prompts: [ { id: 'friends', - text: 'Are these people friends?', + text: 'Tap on all the people who would consider this person a friend', createEdge: 'friend_edge_type', bucketSortOrder: [ { @@ -59,7 +59,7 @@ export const protocol: Protocol = { }, { id: 'professional', - text: 'Do these people work together?', + text: 'Tap on all the people who work with this person.', createEdge: 'professional_edge_type', }, ], @@ -85,6 +85,16 @@ export const protocol: Protocol = { name: 'Person', }, }, + edge: { + friend_edge_type: { + color: 'edge-color-seq-1', + name: 'Friend', + }, + professional_edge_type: { + color: 'edge-color-seq-2', + name: 'Professional', + }, + }, }, assetManifest: {}, schemaVersion: 8, From df4e0c45b82651218492fd4e327192260633b1dd Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 5 Dec 2024 20:58:08 +0200 Subject: [PATCH 05/15] improve edgeExists --- .../containers/Interfaces/OneToManyDyadCensus.tsx | 8 ++++++-- lib/interviewer/containers/Interfaces/index.tsx | 14 ++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx index 5c1a9dc8..87074279 100644 --- a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx +++ b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx @@ -123,8 +123,12 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { ): boolean { return edges.some( (edge) => - (edge.from === targetId && edge.to === sourceId) || - (edge.from === sourceId && edge.to === targetId), + (edge.from === targetId && + edge.to === sourceId && + edge.type === createEdge) || + (edge.from === sourceId && + edge.to === targetId && + edge.type === createEdge), ); } diff --git a/lib/interviewer/containers/Interfaces/index.tsx b/lib/interviewer/containers/Interfaces/index.tsx index 0c685f2d..cbbd52ef 100644 --- a/lib/interviewer/containers/Interfaces/index.tsx +++ b/lib/interviewer/containers/Interfaces/index.tsx @@ -57,15 +57,8 @@ const OneToManyDyadCensus = dynamic(() => import('./OneToManyDyadCensus'), { }); const NotFoundInterface = ({ interfaceType }: { interfaceType: string }) => ( -
-
+
+

No " @@ -112,7 +105,8 @@ const getInterface = (interfaceType: string) => { return FinishSession; default: - return ; + // eslint-disable-next-line react/display-name + return () => ; } }; From af9a94bb6c607b034073814b6a3bfc53fc409f20 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 5 Dec 2024 21:12:30 +0200 Subject: [PATCH 06/15] ts linting --- .../Interfaces/OneToManyDyadCensus.tsx | 67 +++++-------------- lib/interviewer/containers/Stage.tsx | 6 +- lib/protocol-validation/schemas/src/8.zod.ts | 2 + 3 files changed, 20 insertions(+), 55 deletions(-) diff --git a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx index 87074279..6f0d2f33 100644 --- a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx +++ b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx @@ -4,6 +4,7 @@ import { type NcEdge, type NcNode, } from '@codaco/shared-consts'; +import { type AnyAction } from '@reduxjs/toolkit'; import { AnimatePresence, motion, type Variants } from 'motion/react'; import { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; @@ -18,41 +19,6 @@ import { type StageProps } from '../Stage'; const MotionNode = motion.create(Node); -/** - * For a given nodelist, we need to cycle through each node, and offer the - * participant a list of the rest of the nodes that could be linked to. - * - * Because edges are assumed to be undirected, we can remove pairs that have - * already been considered. - * - * This function generates a data structure that allows this, which looks like - * the following: - * - * [ - * { - * source: NcNode, - * targets: NcNode[] - * } - * ] - */ -const generateEdgeOptions = (nodes: NcNode[]) => { - const options = []; - - for (const source of nodes) { - const targets = nodes.filter( - (node) => - node[entityAttributesProperty] !== source[entityAttributesProperty], - ); - - options.push({ - source, - targets, - }); - } - - return options; -}; - const cardvariants: Variants = { hide: { opacity: 0, scale: 0.9 }, show: { opacity: 1, scale: 1, transition: { when: 'beforeChildren' } }, @@ -68,7 +34,13 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { const nodes = usePropSelector(getNetworkNodesForType, props); const edges = usePropSelector(getNetworkEdges, props); - const options = generateEdgeOptions(nodes); + const targets = nodes.filter( + (node) => + node[entityAttributesProperty] !== + nodes[currentStep]?.[entityAttributesProperty], + ); + + const source = nodes[currentStep]; /** * Hijack stage navigation: @@ -80,7 +52,7 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { // eslint-disable-next-line @typescript-eslint/require-await registerBeforeNext(async (direction) => { if (direction === 'forwards') { - if (currentStep < options.length - 1) { + if (currentStep < nodes.length - 1) { setCurrentStep((prev) => prev + 1); return false; } @@ -107,15 +79,11 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { const dispatch = useDispatch(); - console.log({ nodes, edges, options: generateEdgeOptions(nodes) }); - // Reset the step when the prompt changes useEffect(() => { setCurrentStep(0); }, [promptIndex]); - const sourceNode = options[currentStep]?.source!; - function edgeExists( targetId: string, sourceId: string, @@ -133,13 +101,12 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) { } const handleNodeClick = (node: NcNode) => () => { - console.log('Creating edge between', sourceNode, node); dispatch( sessionActions.toggleEdge({ - from: sourceNode[entityPrimaryKeyProperty], + from: source![entityPrimaryKeyProperty], to: node[entityPrimaryKeyProperty], type: createEdge, - }), + }) as unknown as AnyAction, ); }; @@ -158,26 +125,24 @@ export default function OneToManyDyadCensus(props: OneToManyDyadCensusProps) {

- {options[currentStep]?.targets.map((node) => ( + {targets.map((node) => ( void; getNavigationHelpers: () => { moveForward: () => void; diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index efda315b..2641e4b3 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -229,6 +229,8 @@ const baseStageSchema = z.object({ .optional(), }); +export type BaseStage = z.infer; + const formFieldsSchema = z .object({ title: z.string().optional(), From 81311fe947f37ce58eb3b905b32b168e640a3b74 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 5 Dec 2024 21:14:38 +0200 Subject: [PATCH 07/15] knip --- lib/protocol-validation/schemas/src/8.zod.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index 2641e4b3..efda315b 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -229,8 +229,6 @@ const baseStageSchema = z.object({ .optional(), }); -export type BaseStage = z.infer; - const formFieldsSchema = z .object({ title: z.string().optional(), From 26d319c13202119d2a4de2aefad0de4b7a3d8440 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 6 Dec 2024 14:55:31 +0200 Subject: [PATCH 08/15] create encrypted background --- components/BackgroundBlobs/Canvas.tsx | 7 +- hooks/useCanvas.ts | 2 +- .../components/EncryptedBackground.tsx | 238 ++++++++++++++++++ .../containers/Interfaces/Anonymisation.tsx | 8 +- 4 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 lib/interviewer/components/EncryptedBackground.tsx diff --git a/components/BackgroundBlobs/Canvas.tsx b/components/BackgroundBlobs/Canvas.tsx index 4a4618b4..43edf6a9 100644 --- a/components/BackgroundBlobs/Canvas.tsx +++ b/components/BackgroundBlobs/Canvas.tsx @@ -1,7 +1,6 @@ -"use client"; +'use client'; -import React from "react"; -import useCanvas from "~/hooks/useCanvas"; +import useCanvas from '~/hooks/useCanvas'; type CanvasProps = { draw: (ctx: CanvasRenderingContext2D, time: number) => void; @@ -13,7 +12,7 @@ const Canvas = (props: CanvasProps) => { const { draw, predraw, postdraw } = props; const canvasRef = useCanvas(draw, predraw, postdraw); - return ; + return ; }; export default Canvas; diff --git a/hooks/useCanvas.ts b/hooks/useCanvas.ts index c217019b..89b445dc 100644 --- a/hooks/useCanvas.ts +++ b/hooks/useCanvas.ts @@ -1,4 +1,4 @@ -import { useRef, useEffect } from 'react'; +import { useEffect, useRef } from 'react'; const resizeCanvas = ( context: CanvasRenderingContext2D, diff --git a/lib/interviewer/components/EncryptedBackground.tsx b/lib/interviewer/components/EncryptedBackground.tsx new file mode 100644 index 00000000..c64d4bc7 --- /dev/null +++ b/lib/interviewer/components/EncryptedBackground.tsx @@ -0,0 +1,238 @@ +import { useEffect, useState } from 'react'; + +type Stream = { + id: number; + word: string; + x: number; + y: number; + speed: number; + encrypted: boolean; + letters: { + original: string; + current: string; + target: string; + isScrambling: boolean; + scrambleCount: number; + maxScrambles: number; + }[]; +}; + +const names = [ + 'Emma', + 'James', + 'Sofia', + 'Michael', + 'Olivia', + 'Lucas', + 'Ava', + 'Noah', + 'Isabella', + 'Ethan', + 'Mia', + 'Alexander', + 'Charlotte', + 'Benjamin', + 'Sophia', + 'William', + 'Luna', + 'Oliver', + 'Aria', + 'Liam', + 'Elena', + 'Adrian', + 'Maya', + 'Gabriel', + 'Zara', + 'Nathan', + 'Alice', + 'Daniel', + 'Ruby', + 'David', + 'Sarah', + 'Marcus', + 'Nina', + 'Thomas', + 'Lily', + 'Felix', + 'Hannah', + 'Leo', + 'Julia', + 'Oscar', + 'Diana', + 'Henry', + 'Clara', + 'Samuel', + 'Eva', + 'Isaac', + 'Anna', + 'Xavier', + 'Stella', + 'Jackson', + 'Nora', + 'Sebastian', + 'Chloe', + 'Max', + 'Violet', + 'Owen', + 'Audrey', + 'Felix', + 'Rose', + 'Theodore', + 'Hazel', + 'Atlas', + 'Iris', + 'River', + 'Jade', + 'Kai', + 'Nova', + 'August', + 'Eden', + 'Phoenix', + 'Ivy', + 'Atlas', + 'Winter', + 'Sky', + 'Sage', + 'Joshua', + 'Katelyn', + 'Michelle', + 'Patrick', + 'Bernie', + 'Gregory', + 'Sarietha', + 'Caden', +]; + +const encryptionChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*'; + +const getRandomChar = (): string => { + return encryptionChars[Math.floor(Math.random() * encryptionChars.length)]!; +}; + +const createStream = (yPosition = -20) => { + const name = names[Math.floor(Math.random() * names.length)]!; + return { + id: Math.random(), + word: name, + x: Math.random() * 100, + y: yPosition, + speed: 0.1 + Math.random() * 0.2, + encrypted: false, + letters: Array.from(name).map((letter) => ({ + original: letter, + current: letter, + target: '', + isScrambling: false, + scrambleCount: 0, + maxScrambles: 5 + Math.floor(Math.random() * 5), + })), + }; +}; + +const EncryptionBackground = () => { + const [streams, setStreams] = useState([]); + + useEffect(() => { + const initialStreams = Array.from({ length: 40 }, (_, index) => + createStream(index * 6), + ); + setStreams(initialStreams); + + const interval = setInterval(() => { + setStreams((currentStreams) => { + return currentStreams.map((stream) => { + const newY = stream.y + stream.speed; + + if (newY > 120) { + return createStream(); + } + + const shouldStartEncrypt = Math.random() < 0.01; + const shouldStartDecrypt = Math.random() < 0.01; + + const newLetters = stream.letters.map((letterState) => { + if ( + shouldStartEncrypt && + !stream.encrypted && + !letterState.isScrambling + ) { + return { + ...letterState, + isScrambling: true, + scrambleCount: 0, + target: getRandomChar(), + }; + } + if ( + shouldStartDecrypt && + stream.encrypted && + !letterState.isScrambling + ) { + return { + ...letterState, + isScrambling: true, + scrambleCount: 0, + target: letterState.original, + }; + } + if (letterState.isScrambling) { + const newScrambleCount = letterState.scrambleCount + 1; + if (newScrambleCount >= letterState.maxScrambles) { + return { + ...letterState, + current: letterState.target, + isScrambling: false, + }; + } + return { + ...letterState, + current: getRandomChar(), + scrambleCount: newScrambleCount, + }; + } + return letterState; + }); + + const isNowEncrypted = newLetters.every( + (l) => !l.isScrambling && l.current !== l.original, + ); + + return { + ...stream, + y: newY, + encrypted: isNowEncrypted, + letters: newLetters, + }; + }); + }); + }, 50); + + return () => clearInterval(interval); + }, []); + + return ( +
+ {streams.map((stream) => ( +
+ {stream.letters.map((letterState, index) => ( + + {letterState.current} + + ))} +
+ ))} +
+ ); +}; + +export default EncryptionBackground; diff --git a/lib/interviewer/containers/Interfaces/Anonymisation.tsx b/lib/interviewer/containers/Interfaces/Anonymisation.tsx index cd42bb51..6c2794d8 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation.tsx +++ b/lib/interviewer/containers/Interfaces/Anonymisation.tsx @@ -1,3 +1,9 @@ +import EncryptionBackground from '../../components/EncryptedBackground'; + export default function Anonymisation() { - return <>Anonymisation; + return ( +
+ +
+ ); } From 9b79713b28170cd93ad050a9644319483f55c3fb Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 6 Dec 2024 17:10:26 +0200 Subject: [PATCH 09/15] further UI work on anonymisation interface --- .../components/EncryptedBackground.tsx | 202 ++++++++++++------ .../containers/Interfaces/Anonymisation.tsx | 51 ++++- lib/protocol-validation/schemas/src/8.zod.ts | 2 + lib/test-protocol.ts | 2 +- 4 files changed, 181 insertions(+), 76 deletions(-) diff --git a/lib/interviewer/components/EncryptedBackground.tsx b/lib/interviewer/components/EncryptedBackground.tsx index c64d4bc7..ea41b8b0 100644 --- a/lib/interviewer/components/EncryptedBackground.tsx +++ b/lib/interviewer/components/EncryptedBackground.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; type Stream = { id: number; @@ -15,6 +15,7 @@ type Stream = { scrambleCount: number; maxScrambles: number; }[]; + lastScrambleTime?: number; }; const names = [ @@ -110,19 +111,22 @@ const getRandomChar = (): string => { return encryptionChars[Math.floor(Math.random() * encryptionChars.length)]!; }; -const createStream = (yPosition = -20) => { +const createStream = (yPosition = -20, thresholdPosition: number) => { const name = names[Math.floor(Math.random() * names.length)]!; + const shouldBeEncrypted = yPosition > thresholdPosition; + return { id: Math.random(), word: name, x: Math.random() * 100, y: yPosition, - speed: 0.1 + Math.random() * 0.2, - encrypted: false, + speed: 0.05 + Math.random() * 0.1, + encrypted: shouldBeEncrypted, + lastScrambleTime: 0, letters: Array.from(name).map((letter) => ({ original: letter, - current: letter, - target: '', + current: shouldBeEncrypted ? getRandomChar() : letter, + target: shouldBeEncrypted ? getRandomChar() : '', isScrambling: false, scrambleCount: 0, maxScrambles: 5 + Math.floor(Math.random() * 5), @@ -130,102 +134,158 @@ const createStream = (yPosition = -20) => { }; }; -const EncryptionBackground = () => { +type EncryptionBackgroundProps = { + thresholdPosition?: number; +}; + +const EncryptionBackground = ({ + thresholdPosition = 25, +}: EncryptionBackgroundProps) => { const [streams, setStreams] = useState([]); + const animationFrameRef = useRef(); + const lastUpdateTimeRef = useRef(0); useEffect(() => { - const initialStreams = Array.from({ length: 40 }, (_, index) => - createStream(index * 6), + const initialStreams = Array.from({ length: 20 }, (_, index) => + createStream(index * 6, thresholdPosition), ); setStreams(initialStreams); - const interval = setInterval(() => { - setStreams((currentStreams) => { - return currentStreams.map((stream) => { - const newY = stream.y + stream.speed; + const updateStreams = (currentTime: number) => { + const timeDelta = currentTime - lastUpdateTimeRef.current; - if (newY > 120) { - return createStream(); + setStreams((currentStreams) => + currentStreams.map((stream) => { + const newY = stream.y + (stream.speed * timeDelta) / 16; + + if (newY > 100) { + return createStream(-20, thresholdPosition); } - const shouldStartEncrypt = Math.random() < 0.01; - const shouldStartDecrypt = Math.random() < 0.01; - - const newLetters = stream.letters.map((letterState) => { - if ( - shouldStartEncrypt && - !stream.encrypted && - !letterState.isScrambling - ) { - return { - ...letterState, - isScrambling: true, - scrambleCount: 0, - target: getRandomChar(), - }; - } - if ( - shouldStartDecrypt && - stream.encrypted && - !letterState.isScrambling - ) { - return { - ...letterState, - isScrambling: true, - scrambleCount: 0, - target: letterState.original, - }; - } - if (letterState.isScrambling) { - const newScrambleCount = letterState.scrambleCount + 1; - if (newScrambleCount >= letterState.maxScrambles) { + // Check if crossing threshold + const shouldStartEncrypt = + !stream.encrypted && + stream.y <= thresholdPosition && + newY > thresholdPosition; + + // Force encryption state based on position + const shouldBeEncrypted = newY > thresholdPosition; + + const shouldUpdateScramble = + currentTime - (stream.lastScrambleTime || 0) > 50; + + let newLetters = stream.letters; + if (shouldUpdateScramble || shouldStartEncrypt) { + newLetters = stream.letters.map((letterState) => { + // Start encryption + if (shouldStartEncrypt && !letterState.isScrambling) { + return { + ...letterState, + isScrambling: true, + scrambleCount: 0, + target: getRandomChar(), + }; + } + // Continue scrambling + if (letterState.isScrambling && shouldUpdateScramble) { + const newScrambleCount = letterState.scrambleCount + 1; + if (newScrambleCount >= letterState.maxScrambles) { + return { + ...letterState, + current: letterState.target, + isScrambling: false, + }; + } + return { + ...letterState, + current: getRandomChar(), + scrambleCount: newScrambleCount, + }; + } + // Force encryption state if below threshold + if ( + shouldBeEncrypted && + !letterState.isScrambling && + letterState.current === letterState.original + ) { return { ...letterState, - current: letterState.target, - isScrambling: false, + current: getRandomChar(), + target: getRandomChar(), }; } - return { - ...letterState, - current: getRandomChar(), - scrambleCount: newScrambleCount, - }; - } - return letterState; - }); - - const isNowEncrypted = newLetters.every( - (l) => !l.isScrambling && l.current !== l.original, - ); + return letterState; + }); + } + + const isNowEncrypted = + shouldBeEncrypted || + newLetters.every( + (l) => !l.isScrambling && l.current !== l.original, + ); return { ...stream, y: newY, encrypted: isNowEncrypted, letters: newLetters, + lastScrambleTime: shouldUpdateScramble + ? currentTime + : stream.lastScrambleTime, }; - }); - }); - }, 50); + }), + ); + + lastUpdateTimeRef.current = currentTime; + animationFrameRef.current = requestAnimationFrame(updateStreams); + }; - return () => clearInterval(interval); - }, []); + lastUpdateTimeRef.current = performance.now(); + animationFrameRef.current = requestAnimationFrame(updateStreams); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [thresholdPosition]); return ( -
+
+
+ 🔒 +
+ {streams.map((stream) => (
{stream.letters.map((letterState, index) => ( - + {letterState.current} ))} diff --git a/lib/interviewer/containers/Interfaces/Anonymisation.tsx b/lib/interviewer/containers/Interfaces/Anonymisation.tsx index 6c2794d8..a879fbdc 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation.tsx +++ b/lib/interviewer/containers/Interfaces/Anonymisation.tsx @@ -1,9 +1,52 @@ +import { motion } from 'motion/react'; +import { type AnonymisationStage } from '~/lib/protocol-validation/schemas/src/8.zod'; +import { Markdown } from '~/lib/ui/components/Fields'; import EncryptionBackground from '../../components/EncryptedBackground'; +import { type StageProps } from '../Stage'; -export default function Anonymisation() { +type AnonymisationProps = StageProps & { + stage: AnonymisationStage; +}; + +export default function Anonymisation(props: AnonymisationProps) { + console.log(props.stage.items); return ( -
- -
+ <> + + +

Protect Your Data

+ {props.stage.items.map((item) => { + console.log(item); + + return ; + })} +
+
+ + + + ); } diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index efda315b..0f1a1819 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -448,6 +448,8 @@ const anonymisationStage = baseStageSchema.extend({ ), }); +export type AnonymisationStage = z.infer; + const oneToManyDyadCensusStage = baseStageSchema.extend({ type: z.literal('OneToManyDyadCensus'), subject: subjectSchema, diff --git a/lib/test-protocol.ts b/lib/test-protocol.ts index 8366c8d0..763e2f54 100644 --- a/lib/test-protocol.ts +++ b/lib/test-protocol.ts @@ -11,7 +11,7 @@ export const protocol: Protocol = { size: 'MEDIUM', id: '08964cf2-4c7b-4ecd-a6ef-123456', content: - 'This interview allows you to encrypt the names of the people you mention so that they cannot be seen by anyone but you - even the researchers running this study. \n', + 'This interview allows you to encrypt the names of the people you mention so that they cannot be seen by anyone but you - even the researchers running this study. \n\nTo use this feature, click on the padlock icon below, and enter a passcode when prompted.', type: 'text', }, ], From 1c86e506d13c484ee52a9c18db5730756f707320 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Dec 2024 14:42:15 +0200 Subject: [PATCH 10/15] initial port of @codaco/shared-consts --- actions/protocols.ts | 2 +- .../_components/InterviewsTable/Columns.tsx | 8 +- app/not-found.tsx | 4 +- .../components/Canvas/ConvexHull.js | 2 +- .../components/Canvas/ConvexHulls.js | 2 +- .../components/Canvas/LayoutNode.js | 6 +- .../components/Canvas/NodeLayout.js | 8 +- .../components/EncryptedBackground.tsx | 26 +-- lib/interviewer/components/MultiNodeBucket.js | 10 +- lib/interviewer/components/Node.tsx | 37 +-- lib/interviewer/components/NodeList.js | 2 +- .../containers/Canvas/NodeBucket.js | 2 +- .../containers/Canvas/NodeLayout.js | 8 +- .../CategoricalList/CategoricalList.js | 2 +- .../CategoricalList/CategoricalListItem.js | 4 +- .../{ => Anonymisation}/Anonymisation.tsx | 38 ++- .../Anonymisation/useNodeAttributes.tsx | 73 ++++++ .../Interfaces/Anonymisation/useNodeLabel.tsx | 49 ++++ .../Interfaces/Anonymisation/utils.ts | 87 +++++++ .../containers/Interfaces/CategoricalBin.js | 37 ++- .../Interfaces/DyadCensus/helpers.js | 11 +- .../Interfaces/DyadCensus/useEdgeState.ts | 16 +- .../containers/Interfaces/NameGenerator.js | 8 +- .../NameGeneratorRoster.js | 8 +- .../NameGeneratorRoster/useItems.js | 20 +- .../containers/Interfaces/Narrative.js | 2 +- .../Interfaces/OneToManyDyadCensus.tsx | 12 +- .../containers/Interfaces/OrdinalBin.js | 2 +- .../Interfaces/TieStrengthCensus/helpers.js | 11 +- .../containers/Interfaces/index.tsx | 2 +- lib/interviewer/containers/NodeForm.js | 14 +- lib/interviewer/containers/NodePanel.js | 2 +- lib/interviewer/containers/NodePanels.js | 2 +- lib/interviewer/containers/OrdinalBins.js | 10 +- .../containers/SlidesForm/SlideFormEdge.js | 8 +- .../containers/SlidesForm/SlideFormNode.js | 7 +- lib/interviewer/containers/Stage.tsx | 2 +- .../containers/withExternalData.js | 2 +- lib/interviewer/contexts/LayoutContext.js | 2 +- lib/interviewer/ducks/modules/network.js | 11 +- lib/interviewer/ducks/modules/reset.js | 2 +- lib/interviewer/ducks/modules/session.js | 2 +- .../ducks/modules/setServerSession.ts | 4 +- lib/interviewer/hooks/useExternalData.js | 8 +- lib/interviewer/protocol-consts.js | 2 +- lib/interviewer/selectors/canvas.js | 8 +- lib/interviewer/selectors/network.ts | 14 +- lib/interviewer/selectors/protocol.js | 108 --------- lib/interviewer/selectors/protocol.ts | 143 ++++++++++++ lib/interviewer/selectors/session.ts | 4 +- lib/interviewer/selectors/skip-logic.ts | 8 +- lib/interviewer/store.ts | 4 +- lib/interviewer/utils/Validations.js | 8 +- lib/interviewer/utils/createSorter.js | 2 +- lib/interviewer/utils/loadExternalData.js | 7 +- .../csv/__tests__/attribute-list.test.js | 6 +- .../csv/__tests__/edge-list.test.js | 6 +- .../formatters/csv/__tests__/ego-list.test.js | 10 +- .../formatters/csv/__tests__/matrix.test.js | 3 +- .../formatters/csv/__tests__/mockObjects.js | 4 +- .../formatters/csv/attribute-list.js | 6 +- .../formatters/csv/edge-list.js | 6 +- .../formatters/csv/ego-list.js | 6 +- .../formatters/csv/matrix.js | 2 +- .../formatters/csv/processEntityVariables.js | 6 +- .../formatters/formatExportableSessions.ts | 6 +- .../graphml/__tests__/createGraphML.test.js | 2 +- .../formatters/graphml/createGraphML.js | 10 +- .../formatters/graphml/helpers.js | 2 +- .../formatters/session/exportFile.ts | 2 +- .../formatters/session/generateOutputFiles.ts | 2 +- .../session/groupByProtocolProperty.ts | 2 +- .../session/insertEgoIntoSessionNetworks.ts | 2 +- .../formatters/session/partitionByType.ts | 2 +- .../formatters/session/resequenceIds.ts | 2 +- lib/network-exporters/utils/general.ts | 9 +- lib/network-exporters/utils/types.ts | 4 +- lib/network-query/__tests__/filter.test.js | 2 +- lib/network-query/__tests__/helpers.js | 7 +- lib/network-query/__tests__/query.test.js | 2 +- lib/network-query/__tests__/rules.test.js | 3 +- lib/network-query/filter.js | 4 +- lib/network-query/rules.js | 221 +++++++++--------- lib/protocol-validation/index.ts | 4 +- .../migrations/migrateProtocol.ts | 5 +- lib/protocol-validation/schemas/src/8.zod.ts | 3 +- .../scripts/validateProtocol.ts | 2 +- .../validation/Validator.ts | 2 +- .../validation/validateLogic.ts | 4 +- .../validation/validateSchema.ts | 2 +- lib/shared-consts/assets.ts | 11 + lib/shared-consts/codebook.ts | 29 +++ lib/shared-consts/colors.ts | 16 ++ .../controls/images/BooleanChoice.png | Bin 0 -> 39177 bytes .../controls/images/CheckboxGroup.png | Bin 0 -> 10217 bytes .../controls/images/DatePicker.png | Bin 0 -> 41162 bytes .../controls/images/LikertScale.png | Bin 0 -> 6980 bytes .../controls/images/NumberInput.png | Bin 0 -> 5808 bytes .../controls/images/RadioGroup.png | Bin 0 -> 16425 bytes .../controls/images/RelativeDatePicker.png | Bin 0 -> 41162 bytes .../controls/images/TextArea.png | Bin 0 -> 15559 bytes .../controls/images/TextInput.png | Bin 0 -> 6270 bytes lib/shared-consts/controls/images/Toggle.png | Bin 0 -> 8510 bytes .../controls/images/ToggleButtonGroup.png | Bin 0 -> 27863 bytes .../controls/images/VisualAnalogScale.png | Bin 0 -> 6891 bytes .../controls/images/index.js.ignore | 29 +++ lib/shared-consts/controls/index.ts | 121 ++++++++++ lib/shared-consts/export-process.ts | 12 + lib/shared-consts/index.ts | 10 + lib/shared-consts/network.ts | 38 +++ lib/shared-consts/protocol.ts | 22 ++ lib/shared-consts/session.ts | 8 + lib/shared-consts/stages.ts | 163 +++++++++++++ lib/shared-consts/variables.ts | 53 +++++ lib/test-protocol.ts | 2 +- package.json | 1 - pnpm-lock.yaml | 8 - schemas/network-canvas.ts | 6 +- utils/general.ts | 35 +-- utils/protocolImport.tsx | 2 +- 120 files changed, 1285 insertions(+), 525 deletions(-) rename lib/interviewer/containers/Interfaces/{ => Anonymisation}/Anonymisation.tsx (51%) create mode 100644 lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx create mode 100644 lib/interviewer/containers/Interfaces/Anonymisation/useNodeLabel.tsx create mode 100644 lib/interviewer/containers/Interfaces/Anonymisation/utils.ts delete mode 100644 lib/interviewer/selectors/protocol.js create mode 100644 lib/interviewer/selectors/protocol.ts create mode 100644 lib/shared-consts/assets.ts create mode 100644 lib/shared-consts/codebook.ts create mode 100644 lib/shared-consts/colors.ts create mode 100644 lib/shared-consts/controls/images/BooleanChoice.png create mode 100644 lib/shared-consts/controls/images/CheckboxGroup.png create mode 100644 lib/shared-consts/controls/images/DatePicker.png create mode 100644 lib/shared-consts/controls/images/LikertScale.png create mode 100644 lib/shared-consts/controls/images/NumberInput.png create mode 100644 lib/shared-consts/controls/images/RadioGroup.png create mode 100644 lib/shared-consts/controls/images/RelativeDatePicker.png create mode 100644 lib/shared-consts/controls/images/TextArea.png create mode 100644 lib/shared-consts/controls/images/TextInput.png create mode 100644 lib/shared-consts/controls/images/Toggle.png create mode 100644 lib/shared-consts/controls/images/ToggleButtonGroup.png create mode 100644 lib/shared-consts/controls/images/VisualAnalogScale.png create mode 100644 lib/shared-consts/controls/images/index.js.ignore create mode 100644 lib/shared-consts/controls/index.ts create mode 100644 lib/shared-consts/export-process.ts create mode 100644 lib/shared-consts/index.ts create mode 100644 lib/shared-consts/network.ts create mode 100644 lib/shared-consts/protocol.ts create mode 100644 lib/shared-consts/session.ts create mode 100644 lib/shared-consts/stages.ts create mode 100644 lib/shared-consts/variables.ts diff --git a/actions/protocols.ts b/actions/protocols.ts index 2cd27d0a..2628ba61 100644 --- a/actions/protocols.ts +++ b/actions/protocols.ts @@ -1,10 +1,10 @@ 'use server'; -import { type Protocol } from '@codaco/shared-consts'; import { Prisma } from '@prisma/client'; import { safeRevalidateTag } from 'lib/cache'; import { hash } from 'ohash'; import { type z } from 'zod'; +import { type Protocol } from '~/lib/shared-consts'; import { getUTApi } from '~/lib/uploadthing-server-helpers'; import { protocolInsertSchema } from '~/schemas/protocol'; import { requireApiAuth } from '~/utils/auth'; diff --git a/app/dashboard/_components/InterviewsTable/Columns.tsx b/app/dashboard/_components/InterviewsTable/Columns.tsx index a15c0e2e..ad33f3ca 100644 --- a/app/dashboard/_components/InterviewsTable/Columns.tsx +++ b/app/dashboard/_components/InterviewsTable/Columns.tsx @@ -1,13 +1,13 @@ 'use client'; import { type ColumnDef } from '@tanstack/react-table'; -import { Checkbox } from '~/components/ui/checkbox'; +import Image from 'next/image'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; -import { Progress } from '~/components/ui/progress'; -import type { Stage } from '@codaco/shared-consts'; import { Badge } from '~/components/ui/badge'; +import { Checkbox } from '~/components/ui/checkbox'; +import { Progress } from '~/components/ui/progress'; import TimeAgo from '~/components/ui/TimeAgo'; -import Image from 'next/image'; +import type { Stage } from '~/lib/shared-consts'; import type { GetInterviewsReturnType } from '~/queries/interviews'; export const InterviewColumns = (): ColumnDef< diff --git a/app/not-found.tsx b/app/not-found.tsx index cffcac8d..8b15bb71 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -4,8 +4,8 @@ import Paragraph from '~/components/ui/typography/Paragraph'; export default function NotFound() { return ( -
- +
+ 404 Page not found.
diff --git a/lib/interviewer/components/Canvas/ConvexHull.js b/lib/interviewer/components/Canvas/ConvexHull.js index fdc441ec..26a52812 100644 --- a/lib/interviewer/components/Canvas/ConvexHull.js +++ b/lib/interviewer/components/Canvas/ConvexHull.js @@ -1,7 +1,7 @@ -import { entityAttributesProperty } from '@codaco/shared-consts'; import ConcaveMan from 'concaveman'; import PropTypes from 'prop-types'; import { useEffect, useState } from 'react'; +import { entityAttributesProperty } from '~/lib/shared-consts'; const ConvexHull = ({ color = 'cat-color-seq-1', diff --git a/lib/interviewer/components/Canvas/ConvexHulls.js b/lib/interviewer/components/Canvas/ConvexHulls.js index 01652c2e..53cf45d9 100644 --- a/lib/interviewer/components/Canvas/ConvexHulls.js +++ b/lib/interviewer/components/Canvas/ConvexHulls.js @@ -1,10 +1,10 @@ -import { entityAttributesProperty } from '@codaco/shared-consts'; import { findIndex } from 'es-toolkit/compat'; import PropTypes from 'prop-types'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import useResizeObserver from '~/hooks/useResizeObserver'; import { getCurrentStage } from '~/lib/interviewer/selectors/session'; +import { entityAttributesProperty } from '~/lib/shared-consts'; import { getCategoricalOptions } from '../../selectors/network'; import ConvexHull from './ConvexHull'; diff --git a/lib/interviewer/components/Canvas/LayoutNode.js b/lib/interviewer/components/Canvas/LayoutNode.js index ec0a72f0..d7c4f42a 100644 --- a/lib/interviewer/components/Canvas/LayoutNode.js +++ b/lib/interviewer/components/Canvas/LayoutNode.js @@ -1,8 +1,8 @@ -import React, { useRef, useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; -import UINode from '../Node'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import DragManager from '../../behaviours/DragAndDrop/DragManager'; +import UINode from '../Node'; const LayoutNode = ({ node, diff --git a/lib/interviewer/components/Canvas/NodeLayout.js b/lib/interviewer/components/Canvas/NodeLayout.js index afd40416..0af4965f 100644 --- a/lib/interviewer/components/Canvas/NodeLayout.js +++ b/lib/interviewer/components/Canvas/NodeLayout.js @@ -1,11 +1,11 @@ /* eslint-disable no-param-reassign */ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import { find, get, isEmpty } from 'es-toolkit/compat'; import PropTypes from 'prop-types'; import React from 'react'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import LayoutContext from '../../contexts/LayoutContext'; import LayoutNode from './LayoutNode'; import { getTwoModeLayoutVariable } from './utils'; diff --git a/lib/interviewer/components/EncryptedBackground.tsx b/lib/interviewer/components/EncryptedBackground.tsx index ea41b8b0..e96136d9 100644 --- a/lib/interviewer/components/EncryptedBackground.tsx +++ b/lib/interviewer/components/EncryptedBackground.tsx @@ -118,9 +118,10 @@ const createStream = (yPosition = -20, thresholdPosition: number) => { return { id: Math.random(), word: name, - x: Math.random() * 100, + // random x position clamped between 20 and 80 + x: 20 + Math.random() * 60, y: yPosition, - speed: 0.05 + Math.random() * 0.1, + speed: 0.025 + Math.random() * 0.05, encrypted: shouldBeEncrypted, lastScrambleTime: 0, letters: Array.from(name).map((letter) => ({ @@ -135,18 +136,18 @@ const createStream = (yPosition = -20, thresholdPosition: number) => { }; type EncryptionBackgroundProps = { - thresholdPosition?: number; + thresholdPosition: number; }; const EncryptionBackground = ({ - thresholdPosition = 25, + thresholdPosition, }: EncryptionBackgroundProps) => { const [streams, setStreams] = useState([]); const animationFrameRef = useRef(); const lastUpdateTimeRef = useRef(0); useEffect(() => { - const initialStreams = Array.from({ length: 20 }, (_, index) => + const initialStreams = Array.from({ length: 25 }, (_, index) => createStream(index * 6, thresholdPosition), ); setStreams(initialStreams); @@ -162,17 +163,15 @@ const EncryptionBackground = ({ return createStream(-20, thresholdPosition); } - // Check if crossing threshold const shouldStartEncrypt = !stream.encrypted && stream.y <= thresholdPosition && newY > thresholdPosition; - // Force encryption state based on position const shouldBeEncrypted = newY > thresholdPosition; const shouldUpdateScramble = - currentTime - (stream.lastScrambleTime || 0) > 50; + currentTime - (stream.lastScrambleTime ?? 0) > 50; let newLetters = stream.letters; if (shouldUpdateScramble || shouldStartEncrypt) { @@ -255,17 +254,6 @@ const EncryptionBackground = ({ className="preserve-3d pointer-events-none absolute relative inset-0 h-full w-full overflow-hidden text-white/30 select-none" style={{ perspective: '1000px' }} > -
- 🔒 -
- {streams.map((stream) => (
{ return () => { updateReady(false); - } + }; }, [sortedNodes.length, updateReady]); return ( - {sortedNodes.length === 0 && (
No items to place. Click the down arrow to continue.
)} + {sortedNodes.length === 0 && ( +
+ No items to place. Click the down arrow to continue. +
+ )} {sortedNodes.slice(0, 1).map((node, index) => ( , NcNode>((props: NcNode, ref) => { + const label = useNodeLabel(props); -const Node = forwardRef((props, ref) => { - const { type } = props; + const { type } = props; - const color = useSelector(getNodeColor(type)); - const codebook = useSelector(getProtocolCodebook) as Codebook; - const attributes = getEntityAttributes(props) as NcNode['attributes']; - const label = labelLogic(codebook.node![type]!, attributes); + const color = useSelector(getNodeColor(type)); - return ; -}); + return ; + }), + // Only re-render if attributes change + (prevProps, nextProps) => { + return ( + Object.entries(prevProps[entityAttributesProperty]).sort().toString() === + Object.entries(nextProps[entityAttributesProperty]).sort().toString() + ); + }, +); Node.displayName = 'Node'; diff --git a/lib/interviewer/components/NodeList.js b/lib/interviewer/components/NodeList.js index 4eb8b024..eee532a8 100644 --- a/lib/interviewer/components/NodeList.js +++ b/lib/interviewer/components/NodeList.js @@ -1,4 +1,3 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { compose } from '@reduxjs/toolkit'; import cx from 'classnames'; import { find } from 'es-toolkit/compat'; @@ -6,6 +5,7 @@ import { AnimatePresence, motion } from 'motion/react'; import PropTypes from 'prop-types'; import { useRef, useState } from 'react'; import { v4 } from 'uuid'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { getCSSVariableAsString } from '~/lib/ui/utils/CSSVariables'; import { DragSource, diff --git a/lib/interviewer/containers/Canvas/NodeBucket.js b/lib/interviewer/containers/Canvas/NodeBucket.js index 703f056a..acadf117 100644 --- a/lib/interviewer/containers/Canvas/NodeBucket.js +++ b/lib/interviewer/containers/Canvas/NodeBucket.js @@ -1,9 +1,9 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { isNull, isUndefined } from 'es-toolkit'; import { AnimatePresence, motion } from 'motion/react'; import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { compose } from 'recompose'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { DragSource, DropObstacle } from '../../behaviours/DragAndDrop'; import { NO_SCROLL } from '../../behaviours/DragAndDrop/DragManager'; import Node from '../../components/Node'; diff --git a/lib/interviewer/containers/Canvas/NodeLayout.js b/lib/interviewer/containers/Canvas/NodeLayout.js index 32e13536..827d49f3 100644 --- a/lib/interviewer/containers/Canvas/NodeLayout.js +++ b/lib/interviewer/containers/Canvas/NodeLayout.js @@ -1,10 +1,10 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import { get } from 'es-toolkit/compat'; import { connect } from 'react-redux'; import { compose, withHandlers, withState } from 'recompose'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import { DropTarget } from '../../behaviours/DragAndDrop'; import withBounds from '../../behaviours/withBounds'; import NodeLayout from '../../components/Canvas/NodeLayout'; diff --git a/lib/interviewer/containers/CategoricalList/CategoricalList.js b/lib/interviewer/containers/CategoricalList/CategoricalList.js index f9cef7c0..c7bb3b62 100644 --- a/lib/interviewer/containers/CategoricalList/CategoricalList.js +++ b/lib/interviewer/containers/CategoricalList/CategoricalList.js @@ -1,4 +1,3 @@ -import { entityAttributesProperty } from '@codaco/shared-consts'; import { compose } from '@reduxjs/toolkit'; import cx from 'classnames'; import color from 'color'; @@ -7,6 +6,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { Flipper } from 'react-flip-toolkit'; import { connect } from 'react-redux'; +import { entityAttributesProperty } from '~/lib/shared-consts'; import { getCSSVariableAsString } from '~/lib/ui/utils/CSSVariables'; import { MonitorDragSource } from '../../behaviours/DragAndDrop'; import { diff --git a/lib/interviewer/containers/CategoricalList/CategoricalListItem.js b/lib/interviewer/containers/CategoricalList/CategoricalListItem.js index c7c5d550..6c7ffa8f 100644 --- a/lib/interviewer/containers/CategoricalList/CategoricalListItem.js +++ b/lib/interviewer/containers/CategoricalList/CategoricalListItem.js @@ -1,11 +1,11 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { compose } from '@reduxjs/toolkit'; import { get } from 'es-toolkit/compat'; import PropTypes from 'prop-types'; import { useState } from 'react'; import { connect } from 'react-redux'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; +import { getEntityAttributes } from '~/utils/general'; import CategoricalItem from '../../components/CategoricalItem'; -import { getEntityAttributes } from '../../ducks/modules/network'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; import { getNodeColor, getNodeLabel } from '../../selectors/network'; import { getSubjectType } from '../../selectors/prop'; diff --git a/lib/interviewer/containers/Interfaces/Anonymisation.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx similarity index 51% rename from lib/interviewer/containers/Interfaces/Anonymisation.tsx rename to lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx index a879fbdc..87f848d5 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation.tsx +++ b/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx @@ -1,15 +1,33 @@ +import { type AnyAction } from '@reduxjs/toolkit'; import { motion } from 'motion/react'; +import { type ReactNode } from 'react'; +import { useDispatch } from 'react-redux'; +import { actionCreators as dialogActions } from '~/lib/interviewer/ducks/modules/dialogs'; import { type AnonymisationStage } from '~/lib/protocol-validation/schemas/src/8.zod'; import { Markdown } from '~/lib/ui/components/Fields'; -import EncryptionBackground from '../../components/EncryptedBackground'; -import { type StageProps } from '../Stage'; +import EncryptionBackground from '../../../components/EncryptedBackground'; +import { type StageProps } from '../../Stage'; type AnonymisationProps = StageProps & { stage: AnonymisationStage; }; +const THRESHOLD_POSITION = 25; + +type Dialog = { + id: string; + type: 'Confirm' | 'Notice' | 'Warning' | 'Error'; + title: string; + message: ReactNode; + onConfirm?: () => void; + onCancel?: () => void; +}; + export default function Anonymisation(props: AnonymisationProps) { - console.log(props.stage.items); + const dispatch = useDispatch(); + const openDialog = (dialog: Dialog) => + dispatch(dialogActions.openDialog(dialog) as unknown as AnyAction); + return ( <> @@ -45,7 +63,19 @@ export default function Anonymisation(props: AnonymisationProps) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} > - +
+
+ 🔒 +
+
+
); diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx new file mode 100644 index 00000000..eddbb9b0 --- /dev/null +++ b/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx @@ -0,0 +1,73 @@ +import { useSelector } from 'react-redux'; +import { getCodebookVariablesForNodeType } from '~/lib/interviewer/selectors/protocol'; +import { + entitySecureAttributesMeta, + type NcNode, + type VariableValue, +} from '~/lib/shared-consts'; +import { getEntityAttributes } from '~/utils/general'; +import { decryptData } from './utils'; + +export const useNodeAttributes = ( + node: NcNode, +): { + getById(attributeId: string): Promise; + getByName(attributeName: string): Promise; +} => { + const codebookAttributes = useSelector( + getCodebookVariablesForNodeType(node.type), + ); + const nodeAttributes = getEntityAttributes(node); + const requirePassphrase = usePassphrase(); + + async function getById(attributeId: string) { + const isEncrypted = codebookAttributes[attributeId]?.encrypted; + + if (!isEncrypted) { + return nodeAttributes[attributeId]; + } + + const secureAttributes = node[entitySecureAttributesMeta]?.[attributeId]; + + if (!secureAttributes) { + // eslint-disable-next-line no-console + console.log(`Node ${node._uid} is missing secure attributes`); + return null; + } + + // This will trigger a prompt for the passphrase, and throw an error if it is cancelled. + const passphrase = await requirePassphrase(); + + const decryptedValue = await decryptData( + { + [entitySecureAttributesMeta]: { + salt: secureAttributes.salt, + iv: secureAttributes.iv, + }, + data: nodeAttributes[attributeId] as number[], + }, + passphrase, + ); + + return decryptedValue; + } + + const getByName = async (attributeName: string) => { + const attributeId = Object.keys(codebookAttributes).find( + (id) => + codebookAttributes[id]!.name.toLowerCase() === + attributeName.toLowerCase(), + ); + + if (!attributeId) { + return null; + } + + return await getById(attributeId); + }; + + return { + getByName, + getById, + }; +}; diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/useNodeLabel.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/useNodeLabel.tsx new file mode 100644 index 00000000..a40a938a --- /dev/null +++ b/lib/interviewer/containers/Interfaces/Anonymisation/useNodeLabel.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { type NcNode } from '~/lib/shared-consts'; +import { getEntityAttributes } from '~/utils/general'; +import { useNodeAttributes } from './useNodeAttributes'; +import { UnauthorizedError } from './utils'; + +export function useNodeLabel(node: NcNode) { + const [label, setLabel] = useState(undefined); + const { getByName } = useNodeAttributes(node); + + useEffect(() => { + async function calculateLabel() { + // 1. Look for a variable called 'name'. + try { + const variableCalledName = await getByName('name'); + + if (variableCalledName) { + setLabel(variableCalledName); + return; + } + } catch (e) { + if (e instanceof UnauthorizedError) { + setLabel('🔒'); + return; + } + } + + // 2. Look for a property on the node with a key of ‘name’ + const nodeAttributes = getEntityAttributes(node); + + if ( + Object.keys(nodeAttributes) + .map((a) => a.toLowerCase()) + .includes('name') + ) { + setLabel(nodeAttributes.name as string); + return; + } + + // 3. Last resort! + setLabel("No 'name' variable!"); + return; + } + + void calculateLabel(); + }, [node, getByName]); + + return label; +} diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/utils.ts b/lib/interviewer/containers/Interfaces/Anonymisation/utils.ts new file mode 100644 index 00000000..0e17a403 --- /dev/null +++ b/lib/interviewer/containers/Interfaces/Anonymisation/utils.ts @@ -0,0 +1,87 @@ +export class UnauthorizedError extends Error { + constructor() { + super('Unauthorized'); + this.name = 'UnauthorizedError'; + } +} + +/** + * Creates a key from a passphrase and a random salt. The salt is used to + * ensure the same passphrase results in a unique key each time. + * + * To derive a key from a passphrase, use the PBKDF2 algorithm to make the + * encryption more secure by adding a random salt. This ensures the same + * passphrase results in a unique key each time. + */ +export async function generateKey(passphrase: string, salt: Uint8Array) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(passphrase), + 'PBKDF2', + false, + ['deriveKey'], + ); + + return await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +export async function encryptData(data: string, passphrase: string) { + const encoder = new TextEncoder(); + + // Create a new salt and IV for each encryption + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const key = await generateKey(passphrase, salt); + const encryptedData = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + key, + encoder.encode(data), + ); + + return { + [entitySecureAttributesMeta]: { + salt: Array.from(salt), + iv: Array.from(iv), + }, + data: Array.from(new Uint8Array(encryptedData)), + }; +} + +export type EncryptedData = Awaited>; + +export async function decryptData( + encrypted: EncryptedData, + passphrase: string, +) { + const { + data, + _secureAttributes: { iv, salt }, + } = encrypted; + + const key = await generateKey(passphrase, new Uint8Array(salt)); + + const decryptedData = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: new Uint8Array(iv), + }, + key, + new Uint8Array(data), + ); + + const decoder = new TextDecoder(); + return decoder.decode(decryptedData); +} diff --git a/lib/interviewer/containers/Interfaces/CategoricalBin.js b/lib/interviewer/containers/Interfaces/CategoricalBin.js index 63ad336a..9a11655c 100644 --- a/lib/interviewer/containers/Interfaces/CategoricalBin.js +++ b/lib/interviewer/containers/Interfaces/CategoricalBin.js @@ -1,18 +1,17 @@ -import React from 'react'; import { compose } from '@reduxjs/toolkit'; -import { withStateHandlers } from 'recompose'; import PropTypes from 'prop-types'; -import { entityAttributesProperty } from '@codaco/shared-consts'; +import { withStateHandlers } from 'recompose'; +import { entityAttributesProperty } from '~/lib/shared-consts'; +import { usePrompts } from '../../behaviours/withPrompt'; +import MultiNodeBucket from '../../components/MultiNodeBucket'; import Prompts from '../../components/Prompts'; -import CategoricalList from '../CategoricalList'; +import usePropSelector from '../../hooks/usePropSelector'; import { getNetworkNodesForType } from '../../selectors/interface'; -import MultiNodeBucket from '../../components/MultiNodeBucket'; import { - getPromptVariable, getPromptOtherVariable, + getPromptVariable, } from '../../selectors/prop'; -import { usePrompts } from '../../behaviours/withPrompt'; -import usePropSelector from '../../hooks/usePropSelector'; +import CategoricalList from '../CategoricalList'; const categoricalBinStateHandler = withStateHandlers( { @@ -21,7 +20,7 @@ const categoricalBinStateHandler = withStateHandlers( { handleExpandBin: () => - (expandedBinIndex = null) => ({ expandedBinIndex }), + (expandedBinIndex = null) => ({ expandedBinIndex }), }, ); @@ -29,12 +28,8 @@ const categoricalBinStateHandler = withStateHandlers( * CategoricalBin Interface */ const CategoricalBin = (props) => { - const { - expandedBinIndex, - handleExpandBin, - stage, - } = props; - const { prompt, } = usePrompts(); + const { expandedBinIndex, handleExpandBin, stage } = props; + const { prompt } = usePrompts(); const stageNodes = usePropSelector(getNetworkNodesForType, props); const activePromptVariable = usePropSelector(getPromptVariable, { @@ -46,12 +41,12 @@ const CategoricalBin = (props) => { prompt, }); - const uncategorizedNodes = stageNodes.filter((node) => - !node[entityAttributesProperty][activePromptVariable] && - !node[entityAttributesProperty][promptOtherVariable], + const uncategorizedNodes = stageNodes.filter( + (node) => + !node[entityAttributesProperty][activePromptVariable] && + !node[entityAttributesProperty][promptOtherVariable], ); - return (
@@ -84,6 +79,4 @@ CategoricalBin.propTypes = { handleExpandBin: PropTypes.func.isRequired, }; -export default compose( - categoricalBinStateHandler, -)(CategoricalBin); +export default compose(categoricalBinStateHandler)(CategoricalBin); diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/helpers.js b/lib/interviewer/containers/Interfaces/DyadCensus/helpers.js index 59d49932..c2bcc53d 100644 --- a/lib/interviewer/containers/Interfaces/DyadCensus/helpers.js +++ b/lib/interviewer/containers/Interfaces/DyadCensus/helpers.js @@ -1,4 +1,4 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; /** * Given a list of nodes calculate all unique possible pairs, @@ -18,7 +18,7 @@ export const getPairs = (nodes) => { return result; } - const newPairs = nextPool.map((alterId) => ([id, alterId])); + const newPairs = nextPool.map((alterId) => [id, alterId]); return { result: [...result, ...newPairs], @@ -31,9 +31,12 @@ export const getPairs = (nodes) => { return pairs; }; -const getNode = (nodes, id) => nodes.find((node) => node[entityPrimaryKeyProperty] === id); +const getNode = (nodes, id) => + nodes.find((node) => node[entityPrimaryKeyProperty] === id); export const getNodePair = (nodes, pair) => { - if (!pair) { return []; } + if (!pair) { + return []; + } return pair.map((id) => getNode(nodes, id)); }; diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.ts b/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.ts index 54644d4e..0d39f940 100644 --- a/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.ts +++ b/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.ts @@ -1,19 +1,19 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, - type NcEdge, -} from '@codaco/shared-consts'; +import { type AnyAction } from '@reduxjs/toolkit'; +import { useEffect, useState } from 'react'; 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'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, + type NcEdge, +} from '~/lib/shared-consts'; +import { actionCreators as sessionActions } from '../../../ducks/modules/session'; const matchEntry = (promptIndex: number, pair: Pair) => diff --git a/lib/interviewer/containers/Interfaces/NameGenerator.js b/lib/interviewer/containers/Interfaces/NameGenerator.js index a28fb2ee..ded36215 100644 --- a/lib/interviewer/containers/Interfaces/NameGenerator.js +++ b/lib/interviewer/containers/Interfaces/NameGenerator.js @@ -1,7 +1,3 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import { omit } from 'es-toolkit'; import { get, has } from 'es-toolkit/compat'; import PropTypes from 'prop-types'; @@ -10,6 +6,10 @@ import { createPortal } from 'react-dom'; import { useDispatch, useSelector } from 'react-redux'; import NodeBin from '~/lib/interviewer/components/NodeBin'; import NodeList from '~/lib/interviewer/components/NodeList'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import { usePrompts } from '../../behaviours/withPrompt'; import Prompts from '../../components/Prompts'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; diff --git a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/NameGeneratorRoster.js b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/NameGeneratorRoster.js index cb7171e7..88efb699 100644 --- a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/NameGeneratorRoster.js +++ b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/NameGeneratorRoster.js @@ -1,7 +1,3 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import cx from 'classnames'; import { get, isEmpty } from 'es-toolkit/compat'; import { AnimatePresence, motion } from 'motion/react'; @@ -9,6 +5,10 @@ import PropTypes from 'prop-types'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { getAdditionalAttributesSelector } from '~/lib/interviewer/selectors/prop'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import { DataCard } from '~/lib/ui/components/Cards'; import useDropMonitor from '../../../behaviours/DragAndDrop/useDropMonitor'; import { usePrompts } from '../../../behaviours/withPrompt'; diff --git a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useItems.js b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useItems.js index 119c80f1..27ff54f0 100644 --- a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useItems.js +++ b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useItems.js @@ -1,18 +1,18 @@ import { useCallback, useMemo } from 'react'; -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; +import useExternalData from '~/lib/interviewer/hooks/useExternalData'; +import usePropSelector from '~/lib/interviewer/hooks/usePropSelector'; +import { getCardAdditionalProperties } from '~/lib/interviewer/selectors/name-generator'; import { getNetworkNodes, getNodeTypeDefinition, labelLogic, -} from '../../../selectors/network'; -import { getCardAdditionalProperties } from '../../../selectors/name-generator'; -import getParentKeyByNameValue from '../../../utils/getParentKeyByNameValue'; -import usePropSelector from '../../../hooks/usePropSelector'; -import useExternalData from '../../../hooks/useExternalData'; -import { getEntityAttributes } from '../../../ducks/modules/network'; +} from '~/lib/interviewer/selectors/network'; +import getParentKeyByNameValue from '~/lib/interviewer/utils/getParentKeyByNameValue'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; +import { getEntityAttributes } from '~/utils/general'; /** * Format details needed for list cards diff --git a/lib/interviewer/containers/Interfaces/Narrative.js b/lib/interviewer/containers/Interfaces/Narrative.js index 11cb190f..a678e3e9 100644 --- a/lib/interviewer/containers/Interfaces/Narrative.js +++ b/lib/interviewer/containers/Interfaces/Narrative.js @@ -1,4 +1,3 @@ -import { entityAttributesProperty } from '@codaco/shared-consts'; import { get } from 'es-toolkit/compat'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; @@ -6,6 +5,7 @@ import { connect } from 'react-redux'; import { compose } from 'recompose'; import ConvexHulls from '~/lib/interviewer/components/Canvas/ConvexHulls'; import NodeLayout from '~/lib/interviewer/containers/Canvas/NodeLayout'; +import { entityAttributesProperty } from '~/lib/shared-consts'; import Canvas from '../../components/Canvas/Canvas'; import NarrativeEdgeLayout from '../../components/Canvas/NarrativeEdgeLayout'; import { LayoutProvider } from '../../contexts/LayoutContext'; diff --git a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx index 6f0d2f33..10517a44 100644 --- a/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx +++ b/lib/interviewer/containers/Interfaces/OneToManyDyadCensus.tsx @@ -1,9 +1,3 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, - type NcEdge, - type NcNode, -} from '@codaco/shared-consts'; import { type AnyAction } from '@reduxjs/toolkit'; import { AnimatePresence, motion, type Variants } from 'motion/react'; import { useEffect, useState } from 'react'; @@ -11,6 +5,12 @@ import { useDispatch } from 'react-redux'; import { usePrompts } from '~/lib/interviewer/behaviours/withPrompt'; import { actionCreators as sessionActions } from '~/lib/interviewer/ducks/modules/session'; import usePropSelector from '~/lib/interviewer/hooks/usePropSelector'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, + type NcEdge, + type NcNode, +} from '~/lib/shared-consts'; import Node from '../../components/Node'; import Prompts from '../../components/Prompts'; import { getNetworkNodesForType } from '../../selectors/interface'; diff --git a/lib/interviewer/containers/Interfaces/OrdinalBin.js b/lib/interviewer/containers/Interfaces/OrdinalBin.js index 7915f9a2..fc230594 100644 --- a/lib/interviewer/containers/Interfaces/OrdinalBin.js +++ b/lib/interviewer/containers/Interfaces/OrdinalBin.js @@ -1,6 +1,6 @@ -import { entityAttributesProperty } from '@codaco/shared-consts'; import { isNil } from 'es-toolkit'; import PropTypes from 'prop-types'; +import { entityAttributesProperty } from '~/lib/shared-consts'; import { usePrompts } from '../../behaviours/withPrompt'; import MultiNodeBucket from '../../components/MultiNodeBucket'; import Prompts from '../../components/Prompts'; diff --git a/lib/interviewer/containers/Interfaces/TieStrengthCensus/helpers.js b/lib/interviewer/containers/Interfaces/TieStrengthCensus/helpers.js index 59d49932..c2bcc53d 100644 --- a/lib/interviewer/containers/Interfaces/TieStrengthCensus/helpers.js +++ b/lib/interviewer/containers/Interfaces/TieStrengthCensus/helpers.js @@ -1,4 +1,4 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; /** * Given a list of nodes calculate all unique possible pairs, @@ -18,7 +18,7 @@ export const getPairs = (nodes) => { return result; } - const newPairs = nextPool.map((alterId) => ([id, alterId])); + const newPairs = nextPool.map((alterId) => [id, alterId]); return { result: [...result, ...newPairs], @@ -31,9 +31,12 @@ export const getPairs = (nodes) => { return pairs; }; -const getNode = (nodes, id) => nodes.find((node) => node[entityPrimaryKeyProperty] === id); +const getNode = (nodes, id) => + nodes.find((node) => node[entityPrimaryKeyProperty] === id); export const getNodePair = (nodes, pair) => { - if (!pair) { return []; } + if (!pair) { + return []; + } return pair.map((id) => getNode(nodes, id)); }; diff --git a/lib/interviewer/containers/Interfaces/index.tsx b/lib/interviewer/containers/Interfaces/index.tsx index cbbd52ef..2cce771c 100644 --- a/lib/interviewer/containers/Interfaces/index.tsx +++ b/lib/interviewer/containers/Interfaces/index.tsx @@ -49,7 +49,7 @@ const TieStrengthCensus = dynamic(() => import('./TieStrengthCensus'), { const FinishSession = dynamic(() => import('./FinishSession'), { loading: StageLoading, }); -const Anonymisation = dynamic(() => import('./Anonymisation'), { +const Anonymisation = dynamic(() => import('./Anonymisation/Anonymisation'), { loading: StageLoading, }); const OneToManyDyadCensus = dynamic(() => import('./OneToManyDyadCensus'), { diff --git a/lib/interviewer/containers/NodeForm.js b/lib/interviewer/containers/NodeForm.js index 9aa60ffe..918fdbca 100644 --- a/lib/interviewer/containers/NodeForm.js +++ b/lib/interviewer/containers/NodeForm.js @@ -1,11 +1,11 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import { AnimatePresence, motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { submit } from 'redux-form'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import { ActionButton, Button, Scroller } from '~/lib/ui/components'; import { actionCreators as sessionActions } from '../ducks/modules/session'; import Form from './Form'; @@ -103,7 +103,11 @@ const NodeForm = (props) => { const variants = { initial: { opacity: 0, y: '100%' }, - animate: { opacity: 1, y: '0rem', transition: { delay: FIRST_LOAD_UI_ELEMENT_DELAY } }, + animate: { + opacity: 1, + y: '0rem', + transition: { delay: FIRST_LOAD_UI_ELEMENT_DELAY }, + }, }; return ( diff --git a/lib/interviewer/containers/NodePanel.js b/lib/interviewer/containers/NodePanel.js index 066331cc..a253725c 100644 --- a/lib/interviewer/containers/NodePanel.js +++ b/lib/interviewer/containers/NodePanel.js @@ -1,4 +1,3 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { get } from 'es-toolkit/compat'; import { PureComponent } from 'react'; import { connect } from 'react-redux'; @@ -6,6 +5,7 @@ import { compose } from 'recompose'; import NodeList from '~/lib/interviewer/components/NodeList'; import Panel from '~/lib/interviewer/components/Panel'; import customFilter from '~/lib/network-query/filter'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { getNetworkNodesForOtherPrompts, getNetworkNodesForPrompt, diff --git a/lib/interviewer/containers/NodePanels.js b/lib/interviewer/containers/NodePanels.js index 34658ef4..977ab9c4 100644 --- a/lib/interviewer/containers/NodePanels.js +++ b/lib/interviewer/containers/NodePanels.js @@ -1,9 +1,9 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { compose } from '@reduxjs/toolkit'; import { get } from 'es-toolkit/compat'; import PropTypes from 'prop-types'; import { PureComponent } from 'react'; import { connect } from 'react-redux'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { getCSSVariableAsString } from '~/lib/ui/utils/CSSVariables'; import { MonitorDragSource } from '../behaviours/DragAndDrop'; import Panels from '../components/Panels'; diff --git a/lib/interviewer/containers/OrdinalBins.js b/lib/interviewer/containers/OrdinalBins.js index a5b3c28a..fea1a09e 100644 --- a/lib/interviewer/containers/OrdinalBins.js +++ b/lib/interviewer/containers/OrdinalBins.js @@ -1,18 +1,18 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import { bindActionCreators, compose } from '@reduxjs/toolkit'; import color from 'color'; import { isNil } from 'es-toolkit'; import PropTypes from 'prop-types'; import { PureComponent } from 'react'; import { connect } from 'react-redux'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import { MarkdownLabel } from '~/lib/ui/components/Fields'; import { getCSSVariableAsString } from '~/lib/ui/utils/CSSVariables'; +import { getEntityAttributes } from '~/utils/general'; import { MonitorDragSource } from '../behaviours/DragAndDrop'; import NodeList from '../components/NodeList'; -import { getEntityAttributes } from '../ducks/modules/network'; import { actionCreators as sessionActions } from '../ducks/modules/session'; import { getNetworkNodesForType, diff --git a/lib/interviewer/containers/SlidesForm/SlideFormEdge.js b/lib/interviewer/containers/SlidesForm/SlideFormEdge.js index 8164f96b..f7fac535 100644 --- a/lib/interviewer/containers/SlidesForm/SlideFormEdge.js +++ b/lib/interviewer/containers/SlidesForm/SlideFormEdge.js @@ -1,12 +1,12 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import { find } from 'es-toolkit/compat'; import PropTypes from 'prop-types'; import { PureComponent } from 'react'; import { connect } from 'react-redux'; import { compose, withProps } from 'recompose'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import Scroller from '~/lib/ui/components/Scroller'; import Node from '../../components/Node'; import { getNetworkNodes, makeGetEdgeColor } from '../../selectors/network'; diff --git a/lib/interviewer/containers/SlidesForm/SlideFormNode.js b/lib/interviewer/containers/SlidesForm/SlideFormNode.js index 45263dee..9921b006 100644 --- a/lib/interviewer/containers/SlidesForm/SlideFormNode.js +++ b/lib/interviewer/containers/SlidesForm/SlideFormNode.js @@ -1,11 +1,11 @@ -import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; import { withProps } from 'recompose'; -import Scroller from '~/lib/ui/components/Scroller'; import { entityAttributesProperty, entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; +} from '~/lib/shared-consts'; +import Scroller from '~/lib/ui/components/Scroller'; import Node from '../../components/Node'; import Form from '../Form'; @@ -69,5 +69,4 @@ const withNodeProps = withProps(({ item }) => ({ initialValues: item?.[entityAttributesProperty], })); - export default withNodeProps(SlideFormNode); diff --git a/lib/interviewer/containers/Stage.tsx b/lib/interviewer/containers/Stage.tsx index 936ab0ef..e25ee107 100644 --- a/lib/interviewer/containers/Stage.tsx +++ b/lib/interviewer/containers/Stage.tsx @@ -1,5 +1,5 @@ -import { type Stage } from '@codaco/shared-consts'; import { type ElementType, memo } from 'react'; +import { type Stage } from '~/lib/shared-consts'; import StageErrorBoundary from '../components/StageErrorBoundary'; import getInterface from './Interfaces'; import { type BeforeNextFunction } from './ProtocolScreen'; diff --git a/lib/interviewer/containers/withExternalData.js b/lib/interviewer/containers/withExternalData.js index 0034cedd..7b49e3ee 100644 --- a/lib/interviewer/containers/withExternalData.js +++ b/lib/interviewer/containers/withExternalData.js @@ -1,4 +1,3 @@ -import { entityAttributesProperty } from '@codaco/shared-consts'; import { get, includes, toNumber } from 'es-toolkit/compat'; import { connect } from 'react-redux'; import { @@ -8,6 +7,7 @@ import { withHandlers, withState, } from 'recompose'; +import { entityAttributesProperty } from '~/lib/shared-consts'; import { getSessionMeta, makeVariableUUIDReplacer, diff --git a/lib/interviewer/contexts/LayoutContext.js b/lib/interviewer/contexts/LayoutContext.js index 2439bb4a..219d2260 100644 --- a/lib/interviewer/contexts/LayoutContext.js +++ b/lib/interviewer/contexts/LayoutContext.js @@ -1,9 +1,9 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { clamp, noop } from 'es-toolkit'; import { get } from 'es-toolkit/compat'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { getTwoModeLayoutVariable } from '../components/Canvas/utils'; import { actionCreators as sessionsActions } from '../ducks/modules/session'; import useForceSimulation from '../hooks/useForceSimulation'; diff --git a/lib/interviewer/ducks/modules/network.js b/lib/interviewer/ducks/modules/network.js index 579a3353..3e5e4efb 100644 --- a/lib/interviewer/ducks/modules/network.js +++ b/lib/interviewer/ducks/modules/network.js @@ -1,10 +1,10 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import { omit } from 'es-toolkit'; import { find, get, isMatch } from 'es-toolkit/compat'; import { v4 as uuid } from 'uuid'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import { SET_SERVER_SESSION } from './setServerSession'; /* @@ -89,9 +89,6 @@ export function edgeExists(edges, from, to, type) { return false; } -export const getEntityAttributes = (node) => - node[entityAttributesProperty] || {}; - /** * Correctly construct the node object based on a * node-like object, and an key-value attributes object diff --git a/lib/interviewer/ducks/modules/reset.js b/lib/interviewer/ducks/modules/reset.js index cb8c60b8..3b443965 100644 --- a/lib/interviewer/ducks/modules/reset.js +++ b/lib/interviewer/ducks/modules/reset.js @@ -1,5 +1,5 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { get } from 'es-toolkit/compat'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { actionCreators as deviceActions } from './deviceSettings'; import { actionCreators as sessionActions } from './session'; diff --git a/lib/interviewer/ducks/modules/session.js b/lib/interviewer/ducks/modules/session.js index 95a50d6b..82af743b 100644 --- a/lib/interviewer/ducks/modules/session.js +++ b/lib/interviewer/ducks/modules/session.js @@ -1,7 +1,7 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { omit } from 'es-toolkit'; import { has } from 'es-toolkit/compat'; import { v4 as uuid } from 'uuid'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { actionTypes as installedProtocolsActionTypes } from './installedProtocols'; import networkReducer, { actionTypes as networkActionTypes, diff --git a/lib/interviewer/ducks/modules/setServerSession.ts b/lib/interviewer/ducks/modules/setServerSession.ts index 14b5a1c7..b51b6185 100644 --- a/lib/interviewer/ducks/modules/setServerSession.ts +++ b/lib/interviewer/ducks/modules/setServerSession.ts @@ -1,5 +1,5 @@ -import { type Protocol, type Prisma } from '@prisma/client'; -// import type { Protocol } from '@codaco/shared-consts'; +import { type Prisma, type Protocol } from '@prisma/client'; +// import type { Protocol } from '~/lib/shared-consts'; // import type { ServerSession } from '~/app/(interview)/interview/[interviewId]/page'; // temporarily declaring this type diff --git a/lib/interviewer/hooks/useExternalData.js b/lib/interviewer/hooks/useExternalData.js index 86b276ad..2854af78 100644 --- a/lib/interviewer/hooks/useExternalData.js +++ b/lib/interviewer/hooks/useExternalData.js @@ -1,12 +1,12 @@ -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; import { createSelector } from '@reduxjs/toolkit'; import { mapKeys } from 'es-toolkit'; import { hash } from 'ohash'; import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; import { getVariableTypeReplacements } from '../containers/withExternalData'; import { getAssetManifest, getProtocolCodebook } from '../selectors/protocol'; import { getActiveSession } from '../selectors/session'; diff --git a/lib/interviewer/protocol-consts.js b/lib/interviewer/protocol-consts.js index 814a469c..65b9a57e 100644 --- a/lib/interviewer/protocol-consts.js +++ b/lib/interviewer/protocol-consts.js @@ -1,4 +1,4 @@ -import { VariableType } from '@codaco/shared-consts'; +import { VariableType } from '~/lib/shared-consts'; // String consts used by protocol files // Note: these values are no longer used to produce JSON schemas; the schemas must diff --git a/lib/interviewer/selectors/canvas.js b/lib/interviewer/selectors/canvas.js index af764982..25d3d7b9 100644 --- a/lib/interviewer/selectors/canvas.js +++ b/lib/interviewer/selectors/canvas.js @@ -1,10 +1,10 @@ +import { isNil } from 'es-toolkit'; +import { first, get, has } from 'es-toolkit/compat'; import { entityAttributesProperty, entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; -import { isNil } from 'es-toolkit'; -import { first, get, has } from 'es-toolkit/compat'; -import { getEntityAttributes } from '../ducks/modules/network'; +} from '~/lib/shared-consts'; +import { getEntityAttributes } from '~/utils/general'; import createSorter, { processProtocolSortRule } from '../utils/createSorter'; import { getNetworkEdges, getNetworkNodes } from './network'; import { getStageSubject } from './prop'; diff --git a/lib/interviewer/selectors/network.ts b/lib/interviewer/selectors/network.ts index 1690aa93..ee8706f5 100644 --- a/lib/interviewer/selectors/network.ts +++ b/lib/interviewer/selectors/network.ts @@ -1,3 +1,7 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { findKey } from 'es-toolkit'; +import { toString } from 'es-toolkit/compat'; +import customFilter from '~/lib/network-query/filter'; import { entityAttributesProperty, type Codebook, @@ -7,12 +11,8 @@ import { type NodeTypeDefinition, type Stage, type StageSubject, -} from '@codaco/shared-consts'; -import { createSelector } from '@reduxjs/toolkit'; -import { findKey } from 'es-toolkit'; -import { toString } from 'es-toolkit/compat'; -import { getEntityAttributes } from '~/lib/interviewer/ducks/modules/network'; -import customFilter from '~/lib/network-query/filter'; +} from '~/lib/shared-consts'; +import { getEntityAttributes } from '~/utils/general'; import type { RootState } from '../store'; import { getStageSubject, getSubjectType } from './prop'; import { getProtocolCodebook } from './protocol'; @@ -117,7 +117,7 @@ export const getNodeLabel = createSelector( return 'Node'; } - const nodeAttributes = getEntityAttributes(node) as Record; + const nodeAttributes = getEntityAttributes(node); return labelLogic(nodeTypeDefinition, nodeAttributes); }, diff --git a/lib/interviewer/selectors/protocol.js b/lib/interviewer/selectors/protocol.js deleted file mode 100644 index 92de5f5d..00000000 --- a/lib/interviewer/selectors/protocol.js +++ /dev/null @@ -1,108 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { get } from 'es-toolkit/compat'; -import { v4 as uuid } from 'uuid'; -import { getStageSubject } from './prop'; - -const DefaultFinishStage = { - // `id` is used as component key; must be unique from user input - id: uuid(), - type: 'FinishSession', - label: 'Finish Interview', -}; - -const getActiveSession = (state) => - state.activeSessionId && state.sessions[state.activeSessionId]; - -const getInstalledProtocols = (state) => state.installedProtocols; - -const getCurrentSessionProtocol = createSelector( - getActiveSession, - getInstalledProtocols, - (session, protocols) => protocols[session?.protocolUID], -); - -export const getAssetManifest = createSelector( - getCurrentSessionProtocol, - (protocol) => - protocol.assets.reduce((manifest, asset) => { - manifest[asset.assetId] = asset; - return manifest; - }, {}), -); - -export const getAssetUrlFromId = createSelector( - getAssetManifest, - (manifest) => (id) => manifest[id]?.url, -); - -export const getProtocolCodebook = createSelector( - getCurrentSessionProtocol, - (protocol) => protocol.codebook, -); - -// Get all variables for all subjects in the codebook, adding the entity and type -export const getAllVariableUUIDsByEntity = createSelector( - getProtocolCodebook, - ({ node: nodeTypes = {}, edge: edgeTypes = {}, ego = {} }) => { - const variables = {}; - - // Nodes - Object.keys(nodeTypes).forEach((nodeType) => { - const nodeVariables = get(nodeTypes, [nodeType, 'variables'], {}); - Object.keys(nodeVariables).forEach((variable) => { - variables[variable] = { - entity: 'node', - entityType: nodeType, - ...nodeVariables[variable], - }; - }); - }); - - // Edges - Object.keys(edgeTypes).forEach((edgeType) => { - const edgeVariables = get(edgeTypes, [edgeType, 'variables'], {}); - Object.keys(edgeVariables).forEach((variable) => { - variables[variable] = { - entity: 'edge', - entityType: edgeType, - ...edgeVariables[variable], - }; - }); - }); - - // Ego - const egoVariables = get(ego, 'variables', {}); - Object.keys(egoVariables).forEach((variable) => { - variables[variable] = { - entity: 'ego', - entityType: null, - ...egoVariables[variable], - }; - }); - - return variables; - }, -); - -const withFinishStage = (stages = []) => { - if (!stages) { - return []; - } - - return [...stages, DefaultFinishStage]; -}; - -export const getProtocolStages = createSelector( - getCurrentSessionProtocol, - (protocol) => withFinishStage(protocol?.stages), -); - -export const getCodebookVariablesForType = createSelector( - getProtocolCodebook, - getStageSubject, - (codebook, subject) => - codebook && - (subject - ? codebook[subject.entity][subject.type].variables - : codebook.ego.variables), -); diff --git a/lib/interviewer/selectors/protocol.ts b/lib/interviewer/selectors/protocol.ts new file mode 100644 index 00000000..493b065a --- /dev/null +++ b/lib/interviewer/selectors/protocol.ts @@ -0,0 +1,143 @@ +import { type Asset } from '@prisma/client'; +import { createSelector } from '@reduxjs/toolkit'; +import { get } from 'es-toolkit/compat'; +import { v4 as uuid } from 'uuid'; +import { + type EdgeTypeDefinition, + type EntityTypeDefinition, + type NodeTypeDefinition, + type Protocol, + type StageSubject, + type VariableDefinition, +} from '~/lib/shared-consts'; +import { type RootState, type Session } from '../store'; +import { getStageSubject } from './prop'; + +const DefaultFinishStage = { + // `id` is used as component key; must be unique from user input + id: uuid(), + type: 'FinishSession', + label: 'Finish Interview', +}; + +const getActiveSession = (state: RootState) => + state.sessions[state.activeSessionId]; + +const getInstalledProtocols = (state: RootState) => state.installedProtocols; + +const getCurrentSessionProtocol = createSelector( + getActiveSession, + getInstalledProtocols, + (session: Session | undefined, protocols: Record) => { + if (!session) { + throw new Error('No active session'); + } + return protocols[session.protocolUid]!; + }, +); + +export const getAssetManifest = createSelector( + getCurrentSessionProtocol, + (protocol) => + protocol.assets.reduce( + (manifest, asset) => { + manifest[asset.assetId] = asset; + return manifest; + }, + {} as Record, + ), +); + +export const getAssetUrlFromId = createSelector( + getAssetManifest, + (manifest) => (id: string) => manifest[id]?.url, +); + +export const getProtocolCodebook = createSelector( + getCurrentSessionProtocol, + (protocol) => protocol.codebook, +); + +// Get all variables for all subjects in the codebook, adding the entity and type +export const getAllVariableUUIDsByEntity = createSelector( + getProtocolCodebook, + ({ node: nodeTypes = {}, edge: edgeTypes = {}, ego = {} }) => { + const variables = {} as Record< + string, + VariableDefinition & { + entity: 'node' | 'edge' | 'ego'; + entityType: string | null; + } + >; + + // Nodes + Object.entries(nodeTypes).forEach(([nodeTypeIndex, nodeTypeDefinition]) => { + const nodeVariables = get( + nodeTypeDefinition, + 'variables', + {} as NodeTypeDefinition['variables'], + ); + Object.entries(nodeVariables).forEach(([variableIndex, definition]) => { + variables[variableIndex] = { + entity: 'node', + entityType: nodeTypeIndex, + ...definition, + }; + }); + }); + + // Edges + Object.entries(edgeTypes).forEach(([edgeTypeIndex, edgeTypeDefinition]) => { + const edgeVariables = get( + edgeTypeDefinition, + 'variables', + {} as EdgeTypeDefinition['variables'], + ); + Object.entries(edgeVariables).forEach(([variableIndex, definition]) => { + variables[variableIndex] = { + entity: 'edge', + entityType: edgeTypeIndex, + ...definition, + }; + }); + }); + + // Ego + const egoVariables = get( + ego, + 'variables', + {} as EntityTypeDefinition['variables'], + ); + Object.entries(egoVariables).forEach(([variableIndex, definition]) => { + variables[variableIndex] = { + entity: 'ego', + entityType: null, + ...definition, + }; + }); + + return variables; + }, +); + +export const getProtocolStages = createSelector( + getCurrentSessionProtocol, + // Insert default finish stage here. + ({ stages = [] }) => [...stages, DefaultFinishStage], +); + +export const getCodebookVariablesForSubjectType = createSelector( + getProtocolCodebook, + getStageSubject, + (codebook, subject: StageSubject | undefined) => + subject + ? (codebook[subject.entity as 'node' | 'edge']?.[subject.type] + ?.variables ?? {}) + : (codebook.ego?.variables ?? {}), +); + +export const getCodebookVariablesForNodeType = (type: string) => + createSelector( + getProtocolCodebook, + (codebook) => codebook.node?.[type]?.variables ?? {}, + ); diff --git a/lib/interviewer/selectors/session.ts b/lib/interviewer/selectors/session.ts index b271b093..ea71b0c8 100644 --- a/lib/interviewer/selectors/session.ts +++ b/lib/interviewer/selectors/session.ts @@ -1,7 +1,7 @@ -import type { Stage } from '@codaco/shared-consts'; -import { getProtocolStages } from './protocol'; import { createSelector } from '@reduxjs/toolkit'; +import type { Stage } from '~/lib/shared-consts'; import type { RootState } from '../store'; +import { getProtocolStages } from './protocol'; const getActiveSessionId = (state: RootState) => state.activeSessionId; diff --git a/lib/interviewer/selectors/skip-logic.ts b/lib/interviewer/selectors/skip-logic.ts index b97b2f11..1ed37dc5 100644 --- a/lib/interviewer/selectors/skip-logic.ts +++ b/lib/interviewer/selectors/skip-logic.ts @@ -1,9 +1,9 @@ +import { createSelector } from '@reduxjs/toolkit'; import getQuery from '~/lib/network-query/query'; -import { getProtocolStages } from './protocol'; -import { getNetwork } from './network'; +import type { NcNetwork, SkipDefinition, Stage } from '~/lib/shared-consts'; import { SkipLogicAction } from '../protocol-consts'; -import { createSelector } from '@reduxjs/toolkit'; -import type { NcNetwork, SkipDefinition, Stage } from '@codaco/shared-consts'; +import { getNetwork } from './network'; +import { getProtocolStages } from './protocol'; import { getStageIndex } from './session'; const formatQueryParameters = (params: Record) => ({ diff --git a/lib/interviewer/store.ts b/lib/interviewer/store.ts index e869f448..32a4dd78 100644 --- a/lib/interviewer/store.ts +++ b/lib/interviewer/store.ts @@ -1,4 +1,3 @@ -import type { Protocol } from '@prisma/client'; import { configureStore } from '@reduxjs/toolkit'; import { reducer as form } from 'redux-form'; import thunk from 'redux-thunk'; @@ -9,6 +8,7 @@ import installedProtocols from '~/lib/interviewer/ducks/modules/installedProtoco import sessions from '~/lib/interviewer/ducks/modules/session'; import ui from '~/lib/interviewer/ducks/modules/ui'; import type { NcNetwork } from '~/schemas/network-canvas'; +import { type Protocol } from '../shared-consts'; import logger from './ducks/middleware/logger'; import sound from './ducks/middleware/sound'; @@ -28,7 +28,7 @@ export const store = configureStore({ export type StageMetadataEntry = [number, string, string, boolean]; export type StageMetadata = StageMetadataEntry[]; -type Session = { +export type Session = { id: string; protocolUid: string; promptIndex: number; diff --git a/lib/interviewer/utils/Validations.js b/lib/interviewer/utils/Validations.js index 804815da..bf4cd5ea 100644 --- a/lib/interviewer/utils/Validations.js +++ b/lib/interviewer/utils/Validations.js @@ -1,8 +1,8 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import { isEqual, isNil, isString } from 'es-toolkit'; import { filter, get, isNumber, some } from 'es-toolkit/compat'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { getNetworkEntitiesForType } from '../selectors/interface'; -import { getCodebookVariablesForType } from '../selectors/protocol'; +import { getCodebookVariablesForSubjectType } from '../selectors/protocol'; // Return an array of values given either a collection, an array, // or a single value @@ -90,14 +90,14 @@ const unique = (_, store) => { }; const getVariableName = (variableId, store) => { - const codebookVariablesForType = getCodebookVariablesForType( + const codebookVariablesForType = getCodebookVariablesForSubjectType( store.getState(), ); return get(codebookVariablesForType, [variableId, 'name']); }; const getVariableType = (variableId, store) => { - const codebookVariablesForType = getCodebookVariablesForType( + const codebookVariablesForType = getCodebookVariablesForSubjectType( store.getState(), ); return get(codebookVariablesForType, [variableId, 'type']); diff --git a/lib/interviewer/utils/createSorter.js b/lib/interviewer/utils/createSorter.js index 2a78f026..3f901e5c 100644 --- a/lib/interviewer/utils/createSorter.js +++ b/lib/interviewer/utils/createSorter.js @@ -1,5 +1,5 @@ -import { entityAttributesProperty } from '@codaco/shared-consts'; import { get } from 'es-toolkit/compat'; +import { entityAttributesProperty } from '~/lib/shared-consts'; /** * Creating a collator that is reused by string comparison is significantly faster diff --git a/lib/interviewer/utils/loadExternalData.js b/lib/interviewer/utils/loadExternalData.js index ffae98b4..cf5c4123 100644 --- a/lib/interviewer/utils/loadExternalData.js +++ b/lib/interviewer/utils/loadExternalData.js @@ -1,6 +1,6 @@ // import CSVWorker from './csvDecoder.worker'; -import { entityAttributesProperty } from '@codaco/shared-consts'; import csv from 'csvtojson'; +import { entityAttributesProperty } from '~/lib/shared-consts'; /** * Converting data from CSV to our network JSON format is expensive, and so happens @@ -20,7 +20,6 @@ import csv from 'csvtojson'; // }; // }); - const CSVToJSONNetworkFormat = async (data) => { const withTypeAndAttributes = (node) => ({ [entityAttributesProperty]: { @@ -35,8 +34,7 @@ const CSVToJSONNetworkFormat = async (data) => { const convertCSVToJsonWithWorker = async (data) => { return CSVToJSONNetworkFormat(data); -} - +}; /** * Loads network data from assets and appends objectHash uids. @@ -62,7 +60,6 @@ const loadExternalData = async (fileName, url) => { } return { nodes }; - } catch (e) { // eslint-disable-next-line no-console console.error('error with LoadExternalData:', e); diff --git a/lib/network-exporters/formatters/csv/__tests__/attribute-list.test.js b/lib/network-exporters/formatters/csv/__tests__/attribute-list.test.js index 1d39f21b..5923a6c4 100644 --- a/lib/network-exporters/formatters/csv/__tests__/attribute-list.test.js +++ b/lib/network-exporters/formatters/csv/__tests__/attribute-list.test.js @@ -1,12 +1,12 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { makeWriteableStream } from '~/lib/network-exporters/utils/setupTestEnv'; import { egoProperty, entityAttributesProperty, entityPrimaryKeyProperty, ncUUIDProperty, nodeExportIDProperty, -} from '@codaco/shared-consts'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { makeWriteableStream } from '~/lib/network-exporters/utils/setupTestEnv'; +} from '~/lib/shared-consts'; import { AttributeListFormatter, asAttributeList, diff --git a/lib/network-exporters/formatters/csv/__tests__/edge-list.test.js b/lib/network-exporters/formatters/csv/__tests__/edge-list.test.js index fbcaf00f..029c2006 100644 --- a/lib/network-exporters/formatters/csv/__tests__/edge-list.test.js +++ b/lib/network-exporters/formatters/csv/__tests__/edge-list.test.js @@ -1,3 +1,5 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { makeWriteableStream } from '~/lib/network-exporters/utils/setupTestEnv'; import { edgeExportIDProperty, edgeSourceProperty, @@ -8,9 +10,7 @@ import { ncSourceUUID, ncTargetUUID, ncUUIDProperty, -} from '@codaco/shared-consts'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { makeWriteableStream } from '~/lib/network-exporters/utils/setupTestEnv'; +} from '~/lib/shared-consts'; import { EdgeListFormatter, asEdgeList, toCSVStream } from '../edge-list'; import { mockCodebook, mockExportOptions } from './mockObjects'; diff --git a/lib/network-exporters/formatters/csv/__tests__/ego-list.test.js b/lib/network-exporters/formatters/csv/__tests__/ego-list.test.js index d834fbe8..37276945 100644 --- a/lib/network-exporters/formatters/csv/__tests__/ego-list.test.js +++ b/lib/network-exporters/formatters/csv/__tests__/ego-list.test.js @@ -1,3 +1,5 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { makeWriteableStream } from '~/lib/network-exporters/utils/setupTestEnv'; import { caseProperty, egoProperty, @@ -11,9 +13,7 @@ import { sessionFinishTimeProperty, sessionProperty, sessionStartTimeProperty, -} from '@codaco/shared-consts'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { makeWriteableStream } from '~/lib/network-exporters/utils/setupTestEnv'; +} from '~/lib/shared-consts'; import { EgoListFormatter, asEgoAndSessionVariablesList, @@ -45,8 +45,8 @@ const baseCSVAttributes = [ sessionStartTimeProperty, sessionFinishTimeProperty, sessionExportTimeProperty, - "APP_VERSION", - "COMMIT_HASH" + 'APP_VERSION', + 'COMMIT_HASH', ]; describe('asEgoAndSessionVariablesList', () => { diff --git a/lib/network-exporters/formatters/csv/__tests__/matrix.test.js b/lib/network-exporters/formatters/csv/__tests__/matrix.test.js index adc57986..f2dab45b 100644 --- a/lib/network-exporters/formatters/csv/__tests__/matrix.test.js +++ b/lib/network-exporters/formatters/csv/__tests__/matrix.test.js @@ -1,9 +1,8 @@ -import { ncSourceUUID, ncTargetUUID } from '@codaco/shared-consts'; import { beforeEach, describe, expect, it } from 'vitest'; import { makeWriteableStream } from '~/lib/network-exporters/utils/setupTestEnv'; +import { ncSourceUUID, ncTargetUUID } from '~/lib/shared-consts'; import { AdjacencyMatrixFormatter, asAdjacencyMatrix } from '../matrix'; - const mockNetwork = (edges) => ({ edges, nodes: Object.values( diff --git a/lib/network-exporters/formatters/csv/__tests__/mockObjects.js b/lib/network-exporters/formatters/csv/__tests__/mockObjects.js index d37bb8ee..29295568 100644 --- a/lib/network-exporters/formatters/csv/__tests__/mockObjects.js +++ b/lib/network-exporters/formatters/csv/__tests__/mockObjects.js @@ -1,3 +1,4 @@ +import { groupBy } from 'es-toolkit'; import { caseProperty, codebookHashProperty, @@ -9,8 +10,7 @@ import { sessionFinishTimeProperty, sessionProperty, sessionStartTimeProperty, -} from '@codaco/shared-consts'; -import { groupBy } from 'es-toolkit'; +} from '~/lib/shared-consts'; import { insertEgoIntoSessionNetworks } from '../../session/insertEgoIntoSessionNetworks'; import { resequenceIds } from '../../session/resequenceIds'; diff --git a/lib/network-exporters/formatters/csv/attribute-list.js b/lib/network-exporters/formatters/csv/attribute-list.js index 9a5b0847..e1fe0d1f 100644 --- a/lib/network-exporters/formatters/csv/attribute-list.js +++ b/lib/network-exporters/formatters/csv/attribute-list.js @@ -1,11 +1,11 @@ +import { Readable } from 'node:stream'; import { egoProperty, entityAttributesProperty, entityPrimaryKeyProperty, ncUUIDProperty, nodeExportIDProperty, -} from '@codaco/shared-consts'; -import { Readable } from 'node:stream'; +} from '~/lib/shared-consts'; import { csvEOL, sanitizedCellValue } from './csv'; import processEntityVariables from './processEntityVariables'; @@ -111,4 +111,4 @@ class AttributeListFormatter { } } -export { AttributeListFormatter, asAttributeList, toCSVStream }; +export { asAttributeList, AttributeListFormatter, toCSVStream }; diff --git a/lib/network-exporters/formatters/csv/edge-list.js b/lib/network-exporters/formatters/csv/edge-list.js index e261a6da..28495bdf 100644 --- a/lib/network-exporters/formatters/csv/edge-list.js +++ b/lib/network-exporters/formatters/csv/edge-list.js @@ -1,3 +1,4 @@ +import { Readable } from 'stream'; import { edgeExportIDProperty, egoProperty, @@ -6,8 +7,7 @@ import { ncSourceUUID, ncTargetUUID, ncUUIDProperty, -} from '@codaco/shared-consts'; -import { Readable } from 'stream'; +} from '~/lib/shared-consts'; import { csvEOL, sanitizedCellValue } from './csv'; import processEntityVariables from './processEntityVariables'; @@ -164,4 +164,4 @@ class EdgeListFormatter { } } -export { EdgeListFormatter, asEdgeList, toCSVStream }; +export { asEdgeList, EdgeListFormatter, toCSVStream }; diff --git a/lib/network-exporters/formatters/csv/ego-list.js b/lib/network-exporters/formatters/csv/ego-list.js index 01521e47..212216c9 100644 --- a/lib/network-exporters/formatters/csv/ego-list.js +++ b/lib/network-exporters/formatters/csv/ego-list.js @@ -1,3 +1,4 @@ +import { Readable } from 'stream'; import { caseProperty, egoProperty, @@ -11,8 +12,7 @@ import { sessionFinishTimeProperty, sessionProperty, sessionStartTimeProperty, -} from '@codaco/shared-consts'; -import { Readable } from 'stream'; +} from '~/lib/shared-consts'; import { csvEOL, sanitizedCellValue } from './csv'; import processEntityVariables from './processEntityVariables'; @@ -161,4 +161,4 @@ class EgoListFormatter { } } -export { EgoListFormatter, asEgoAndSessionVariablesList, toCSVStream }; +export { asEgoAndSessionVariablesList, EgoListFormatter, toCSVStream }; diff --git a/lib/network-exporters/formatters/csv/matrix.js b/lib/network-exporters/formatters/csv/matrix.js index 55095f54..16c4b2dc 100644 --- a/lib/network-exporters/formatters/csv/matrix.js +++ b/lib/network-exporters/formatters/csv/matrix.js @@ -5,7 +5,7 @@ import { entityPrimaryKeyProperty, ncSourceUUID, ncTargetUUID, -} from '@codaco/shared-consts'; +} from '~/lib/shared-consts'; import { csvEOL } from './csv'; /** diff --git a/lib/network-exporters/formatters/csv/processEntityVariables.js b/lib/network-exporters/formatters/csv/processEntityVariables.js index f7ab1c6d..dd52dccb 100644 --- a/lib/network-exporters/formatters/csv/processEntityVariables.js +++ b/lib/network-exporters/formatters/csv/processEntityVariables.js @@ -1,10 +1,8 @@ // Determine which variables to include import { includes } from 'es-toolkit/compat'; -import { - getAttributePropertyFromCodebook, - getEntityAttributes, -} from '../../utils/general'; +import { getEntityAttributes } from '~/utils/general'; +import { getAttributePropertyFromCodebook } from '../../utils/general'; const processEntityVariables = ( entity, diff --git a/lib/network-exporters/formatters/formatExportableSessions.ts b/lib/network-exporters/formatters/formatExportableSessions.ts index ec94530d..36e6c7f1 100644 --- a/lib/network-exporters/formatters/formatExportableSessions.ts +++ b/lib/network-exporters/formatters/formatExportableSessions.ts @@ -1,3 +1,5 @@ +import { hash } from 'ohash'; +import { env } from '~/env'; import { caseProperty, codebookHashProperty, @@ -7,9 +9,7 @@ import { sessionFinishTimeProperty, sessionProperty, sessionStartTimeProperty, -} from '@codaco/shared-consts'; -import { hash } from 'ohash'; -import { env } from '~/env'; +} from '~/lib/shared-consts'; import type { getInterviewsForExport } from '~/queries/interviews'; import type { NcNetwork } from '~/schemas/network-canvas'; import { type SessionVariables } from '../utils/types'; diff --git a/lib/network-exporters/formatters/graphml/__tests__/createGraphML.test.js b/lib/network-exporters/formatters/graphml/__tests__/createGraphML.test.js index c1ef0e80..4253ed4a 100644 --- a/lib/network-exporters/formatters/graphml/__tests__/createGraphML.test.js +++ b/lib/network-exporters/formatters/graphml/__tests__/createGraphML.test.js @@ -1,6 +1,6 @@ -import { ncUUIDProperty } from '@codaco/shared-consts'; import { DOMParser } from '@xmldom/xmldom'; import { beforeEach, describe, expect, it } from 'vitest'; +import { ncUUIDProperty } from '~/lib/shared-consts'; import { mockCodebook, mockExportOptions, diff --git a/lib/network-exporters/formatters/graphml/createGraphML.js b/lib/network-exporters/formatters/graphml/createGraphML.js index b068469d..3024dfcd 100644 --- a/lib/network-exporters/formatters/graphml/createGraphML.js +++ b/lib/network-exporters/formatters/graphml/createGraphML.js @@ -3,6 +3,8 @@ import { includes } from 'es-toolkit/compat'; import jsSHA from 'jssha/dist/sha1'; import { createDataElement, formatXml, getGraphMLTypeForKey } from './helpers'; +import dom from '@xmldom/xmldom'; +import { getAttributePropertyFromCodebook } from '~/lib/network-exporters/utils/general'; import { VariableType, caseProperty, @@ -23,12 +25,8 @@ import { sessionFinishTimeProperty, sessionProperty, sessionStartTimeProperty, -} from '@codaco/shared-consts'; -import dom from '@xmldom/xmldom'; -import { - getAttributePropertyFromCodebook, - getEntityAttributes, -} from '~/lib/network-exporters/utils/general'; +} from '~/lib/shared-consts'; +import { getEntityAttributes } from '~/utils/general'; // In a browser process, window provides a globalContext; // in an electron main process, we can inject required globals diff --git a/lib/network-exporters/formatters/graphml/helpers.js b/lib/network-exporters/formatters/graphml/helpers.js index 059399b6..e50935b3 100644 --- a/lib/network-exporters/formatters/graphml/helpers.js +++ b/lib/network-exporters/formatters/graphml/helpers.js @@ -1,5 +1,5 @@ import { isNil } from 'es-toolkit'; -import { getEntityAttributes } from '../../utils/general'; +import { getEntityAttributes } from '~/utils/general'; // Gephi does not support long lines in graphML, meaning we need to "beautify" the output const formatXml = (xml, tab = '\t') => { diff --git a/lib/network-exporters/formatters/session/exportFile.ts b/lib/network-exporters/formatters/session/exportFile.ts index 7d593361..edbcb81c 100644 --- a/lib/network-exporters/formatters/session/exportFile.ts +++ b/lib/network-exporters/formatters/session/exportFile.ts @@ -1,7 +1,7 @@ -import type { Codebook } from '@codaco/shared-consts'; import fs from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { Codebook } from '~/lib/shared-consts'; import { getFileExtension, makeFilename } from '../../utils/general'; import getFormatterClass from '../../utils/getFormatterClass'; import type { diff --git a/lib/network-exporters/formatters/session/generateOutputFiles.ts b/lib/network-exporters/formatters/session/generateOutputFiles.ts index 9d66302f..b2fdb0ba 100644 --- a/lib/network-exporters/formatters/session/generateOutputFiles.ts +++ b/lib/network-exporters/formatters/session/generateOutputFiles.ts @@ -1,5 +1,5 @@ -import type { Codebook } from '@codaco/shared-consts'; import type { InstalledProtocols } from '~/lib/interviewer/store'; +import type { Codebook } from '~/lib/shared-consts'; import { getFilePrefix } from '../../utils/general'; import type { ExportFormat, diff --git a/lib/network-exporters/formatters/session/groupByProtocolProperty.ts b/lib/network-exporters/formatters/session/groupByProtocolProperty.ts index 2a9da7dc..922b908b 100644 --- a/lib/network-exporters/formatters/session/groupByProtocolProperty.ts +++ b/lib/network-exporters/formatters/session/groupByProtocolProperty.ts @@ -1,5 +1,5 @@ -import { protocolProperty } from '@codaco/shared-consts'; import { groupBy } from 'es-toolkit'; +import { protocolProperty } from '~/lib/shared-consts'; import type { SessionWithNetworkEgo, SessionsByProtocol, diff --git a/lib/network-exporters/formatters/session/insertEgoIntoSessionNetworks.ts b/lib/network-exporters/formatters/session/insertEgoIntoSessionNetworks.ts index 17c00073..cf245f76 100644 --- a/lib/network-exporters/formatters/session/insertEgoIntoSessionNetworks.ts +++ b/lib/network-exporters/formatters/session/insertEgoIntoSessionNetworks.ts @@ -4,7 +4,7 @@ * @returns The session network with the ego inserted into the nodes and edges. */ -import { egoProperty, entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { egoProperty, entityPrimaryKeyProperty } from '~/lib/shared-consts'; import type { FormattedSession, SessionWithNetworkEgo, diff --git a/lib/network-exporters/formatters/session/partitionByType.ts b/lib/network-exporters/formatters/session/partitionByType.ts index 967e37ba..c2f81e28 100644 --- a/lib/network-exporters/formatters/session/partitionByType.ts +++ b/lib/network-exporters/formatters/session/partitionByType.ts @@ -1,4 +1,4 @@ -import type { Codebook } from '@codaco/shared-consts'; +import type { Codebook } from '~/lib/shared-consts'; import type { EdgeWithResequencedID, ExportFormat, diff --git a/lib/network-exporters/formatters/session/resequenceIds.ts b/lib/network-exporters/formatters/session/resequenceIds.ts index 1635bab5..4ce63dac 100644 --- a/lib/network-exporters/formatters/session/resequenceIds.ts +++ b/lib/network-exporters/formatters/session/resequenceIds.ts @@ -6,7 +6,7 @@ import { ncSourceUUID, ncTargetUUID, nodeExportIDProperty, -} from '@codaco/shared-consts'; +} from '~/lib/shared-consts'; import type { SessionWithNetworkEgo, SessionWithResequencedIDs, diff --git a/lib/network-exporters/utils/general.ts b/lib/network-exporters/utils/general.ts index 2eb1e5d1..40c5502c 100644 --- a/lib/network-exporters/utils/general.ts +++ b/lib/network-exporters/utils/general.ts @@ -1,17 +1,12 @@ +import sanitizeFilename from 'sanitize-filename'; import { caseProperty, - entityAttributesProperty, sessionProperty, type Codebook, - type NcEntity, type StageSubject, -} from '@codaco/shared-consts'; -import sanitizeFilename from 'sanitize-filename'; +} from '~/lib/shared-consts'; import type { ExportFormat, SessionWithResequencedIDs } from './types'; -export const getEntityAttributes = (entity: NcEntity) => - entity?.[entityAttributesProperty] || {}; - const escapeFilePart = (part: string) => part.replace(/\W/g, ''); export const makeFilename = ( diff --git a/lib/network-exporters/utils/types.ts b/lib/network-exporters/utils/types.ts index 9600dc7f..e0da0f10 100644 --- a/lib/network-exporters/utils/types.ts +++ b/lib/network-exporters/utils/types.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import { caseProperty, codebookHashProperty, @@ -10,8 +11,7 @@ import { sessionFinishTimeProperty, sessionProperty, sessionStartTimeProperty, -} from '@codaco/shared-consts'; -import { z } from 'zod'; +} from '~/lib/shared-consts'; import { ZNcEdge, ZNcNetwork, ZNcNode } from '~/schemas/network-canvas'; const ZNodeWithEgo = ZNcNode.extend({ diff --git a/lib/network-query/__tests__/filter.test.js b/lib/network-query/__tests__/filter.test.js index b26ac6b7..9a1a8a7d 100644 --- a/lib/network-query/__tests__/filter.test.js +++ b/lib/network-query/__tests__/filter.test.js @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import getFilter from '../filter'; import { operators } from '../predicate'; import { generateRuleConfig, getEntityGenerator } from './helpers'; -const { entityAttributesProperty } = require('@codaco/shared-consts'); +const { entityAttributesProperty } = require('~/lib/shared-consts'); const generateEntity = getEntityGenerator(); diff --git a/lib/network-query/__tests__/helpers.js b/lib/network-query/__tests__/helpers.js index 22ca7dbe..a5c5bdbf 100644 --- a/lib/network-query/__tests__/helpers.js +++ b/lib/network-query/__tests__/helpers.js @@ -1,4 +1,7 @@ -import { entityAttributesProperty, entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '~/lib/shared-consts'; export const getEntityGenerator = () => { const counts = { @@ -27,4 +30,4 @@ export const getEntityGenerator = () => { export const generateRuleConfig = (type, options) => ({ type, options, -}); \ No newline at end of file +}); diff --git a/lib/network-query/__tests__/query.test.js b/lib/network-query/__tests__/query.test.js index d58d67bf..ce2a5daa 100644 --- a/lib/network-query/__tests__/query.test.js +++ b/lib/network-query/__tests__/query.test.js @@ -1,5 +1,5 @@ -import { entityAttributesProperty } from '@codaco/shared-consts'; import { describe, expect, it } from 'vitest'; +import { entityAttributesProperty } from '~/lib/shared-consts'; import getQuery from '../query'; import { generateRuleConfig, getEntityGenerator } from './helpers'; diff --git a/lib/network-query/__tests__/rules.test.js b/lib/network-query/__tests__/rules.test.js index f3b62d0e..18b2ed0b 100644 --- a/lib/network-query/__tests__/rules.test.js +++ b/lib/network-query/__tests__/rules.test.js @@ -1,6 +1,5 @@ - -import { entityAttributesProperty } from '@codaco/shared-consts'; import { describe, expect, it } from 'vitest'; +import { entityAttributesProperty } from '~/lib/shared-consts'; import { getRule } from '../rules'; import { generateRuleConfig, getEntityGenerator } from './helpers'; diff --git a/lib/network-query/filter.js b/lib/network-query/filter.js index de3a31e2..2611214c 100644 --- a/lib/network-query/filter.js +++ b/lib/network-query/filter.js @@ -1,4 +1,4 @@ -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; import { getRule } from './rules'; // remove orphaned edges @@ -162,4 +162,4 @@ const filter = ({ rules, join }) => { }; }; -export default filter; \ No newline at end of file +export default filter; diff --git a/lib/network-query/rules.js b/lib/network-query/rules.js index af637ae4..0512d5fa 100644 --- a/lib/network-query/rules.js +++ b/lib/network-query/rules.js @@ -1,103 +1,102 @@ import { entityAttributesProperty, entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; -import { operators } from './predicate'; -import predicate from './predicate'; +} from '~/lib/shared-consts'; +import predicate, { operators } from './predicate'; const singleEdgeRule = ({ type, attribute, operator, value: other }) => - (node, edges) => { - const nodeEdges = edges.filter( + (node, edges) => { + const nodeEdges = edges.filter( + (edge) => + edge.from === node[entityPrimaryKeyProperty] || + edge.to === node[entityPrimaryKeyProperty], + ); + + const nodeHasEdgeOfType = nodeEdges.some((edge) => edge.type === type); + + if (!attribute) { + switch (operator) { + case 'EXISTS': + return nodeHasEdgeOfType; + default: + return !nodeHasEdgeOfType; + } + } + + const nodeHasEdgeWithAttribute = + nodeHasEdgeOfType && + nodeEdges.some( (edge) => - edge.from === node[entityPrimaryKeyProperty] || - edge.to === node[entityPrimaryKeyProperty], + edge.type === type && + predicate(operator)({ + value: edge[entityAttributesProperty][attribute], + other, + }), ); - const nodeHasEdgeOfType = nodeEdges.some((edge) => edge.type === type); - - if (!attribute) { - switch (operator) { - case 'EXISTS': - return nodeHasEdgeOfType; - default: - return !nodeHasEdgeOfType; - } - } - - const nodeHasEdgeWithAttribute = - nodeHasEdgeOfType && - nodeEdges.some( - (edge) => - edge.type === type && - predicate(operator)({ - value: edge[entityAttributesProperty][attribute], - other, - }), - ); - - return nodeHasEdgeWithAttribute; - }; + return nodeHasEdgeWithAttribute; + }; const singleNodeRule = ({ type, attribute, operator, value: other }) => - (node) => { - if (!attribute) { - switch (operator) { - case operators.EXISTS: - return node.type === type; - default: - return node.type !== type; - } + (node) => { + if (!attribute) { + switch (operator) { + case operators.EXISTS: + return node.type === type; + default: + return node.type !== type; } + } - return ( - node.type === type && - predicate(operator)({ - value: node[entityAttributesProperty][attribute], - other, - }) - ); - }; + return ( + node.type === type && + predicate(operator)({ + value: node[entityAttributesProperty][attribute], + other, + }) + ); + }; // Reduce edges to any that match the rule // Filter nodes by the resulting edges const edgeRule = ({ attribute, operator, type, value: other }) => - (nodes, edges) => { - let filteredEdges; - // If there is no attribute, we just care about filtering by type - if (!attribute) { - switch (operator) { - case operators.EXISTS: - filteredEdges = edges.filter((edge) => edge.type === type); - break; - default: - filteredEdges = edges.filter((edge) => edge.type !== type); - } - } else { - // If there is an attribute we check that, too. - filteredEdges = edges.filter( - (edge) => - edge.type === type && - predicate(operator)({ - value: edge[entityAttributesProperty][attribute], - other, - }), - ); + (nodes, edges) => { + let filteredEdges; + // If there is no attribute, we just care about filtering by type + if (!attribute) { + switch (operator) { + case operators.EXISTS: + filteredEdges = edges.filter((edge) => edge.type === type); + break; + default: + filteredEdges = edges.filter((edge) => edge.type !== type); } + } else { + // If there is an attribute we check that, too. + filteredEdges = edges.filter( + (edge) => + edge.type === type && + predicate(operator)({ + value: edge[entityAttributesProperty][attribute], + other, + }), + ); + } - const edgeMap = filteredEdges.flatMap((edge) => [edge.from, edge.to]); + const edgeMap = filteredEdges.flatMap((edge) => [edge.from, edge.to]); - const filteredNodes = nodes.filter((node) => - edgeMap.includes(node[entityPrimaryKeyProperty]), - ); + const filteredNodes = nodes.filter((node) => + edgeMap.includes(node[entityPrimaryKeyProperty]), + ); - return { - nodes: filteredNodes, - edges: filteredEdges, - }; + return { + nodes: filteredNodes, + edges: filteredEdges, }; + }; /** * Creates an alter rule, which can be called with `rule(node)` @@ -128,40 +127,40 @@ const edgeRule = */ const nodeRule = ({ attribute, operator, type, value: other }) => - (nodes = [], edges = []) => { - let filteredNodes; - // If there is no attribute, we just care about filtering by type - if (!attribute) { - switch (operator) { - case operators.EXISTS: - filteredNodes = nodes.filter((node) => node.type === type); - break; - default: - filteredNodes = nodes.filter((node) => node.type !== type); - } - } else { - // If there is an attribute we check that, too. - filteredNodes = nodes.filter( - (node) => - node.type === type && - predicate(operator)({ - value: node[entityAttributesProperty][attribute], - other, - }), - ); + (nodes = [], edges = []) => { + let filteredNodes; + // If there is no attribute, we just care about filtering by type + if (!attribute) { + switch (operator) { + case operators.EXISTS: + filteredNodes = nodes.filter((node) => node.type === type); + break; + default: + filteredNodes = nodes.filter((node) => node.type !== type); } + } else { + // If there is an attribute we check that, too. + filteredNodes = nodes.filter( + (node) => + node.type === type && + predicate(operator)({ + value: node[entityAttributesProperty][attribute], + other, + }), + ); + } - const nodeIds = filteredNodes.map((node) => node[entityPrimaryKeyProperty]); + const nodeIds = filteredNodes.map((node) => node[entityPrimaryKeyProperty]); - const filteredEdges = edges.filter( - (edge) => nodeIds.includes(edge.from) && nodeIds.includes(edge.to), - ); + const filteredEdges = edges.filter( + (edge) => nodeIds.includes(edge.from) && nodeIds.includes(edge.to), + ); - return { - nodes: filteredNodes, - edges: filteredEdges, - }; + return { + nodes: filteredNodes, + edges: filteredEdges, }; + }; /** * Creates an ego rule, which can be called with `rule(ego)` @@ -173,11 +172,11 @@ const nodeRule = */ const egoRule = ({ attribute, operator, value: other }) => - (ego) => - predicate(operator)({ - value: ego[entityAttributesProperty][attribute], - other, - }); + (ego) => + predicate(operator)({ + value: ego[entityAttributesProperty][attribute], + other, + }); /** * Adds type parameter to rule function @@ -230,5 +229,3 @@ export const getSingleRule = ({ type, options }) => { return () => false; } }; - - diff --git a/lib/protocol-validation/index.ts b/lib/protocol-validation/index.ts index 4d38f555..d304be28 100644 --- a/lib/protocol-validation/index.ts +++ b/lib/protocol-validation/index.ts @@ -1,4 +1,4 @@ -import { type Protocol } from '@codaco/shared-consts'; +import { type Protocol } from '~/lib/shared-consts'; import { ensureError } from '~/utils/ensureError'; import { validateLogic } from './validation/validateLogic'; import { validateSchema } from './validation/validateSchema'; @@ -46,4 +46,4 @@ const validateProtocol = async ( } }; -export { validateProtocol, }; +export { validateProtocol }; diff --git a/lib/protocol-validation/migrations/migrateProtocol.ts b/lib/protocol-validation/migrations/migrateProtocol.ts index 87eb2e1d..f7951de4 100644 --- a/lib/protocol-validation/migrations/migrateProtocol.ts +++ b/lib/protocol-validation/migrations/migrateProtocol.ts @@ -1,7 +1,5 @@ -import { type Protocol } from '@codaco/shared-consts'; -import canUpgrade from './canUpgrade'; +import { type Protocol } from '~/lib/shared-consts'; import { MigrationStepError } from './errors'; -import getMigrationNotes from './getMigrationNotes'; import getMigrationPath from './getMigrationPath'; export type ProtocolMigration = { @@ -43,4 +41,3 @@ export const migrateProtocol = ( return resultProtocol; }; - diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index 0f1a1819..35c6cafe 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -84,7 +84,8 @@ const variableSchema = z validation: validationSchema.optional(), }) .strict(); -type Variable = z.infer; + +export type Variable = z.infer; const VariablesSchema = z.record( z.string().regex(validVariableName), diff --git a/lib/protocol-validation/scripts/validateProtocol.ts b/lib/protocol-validation/scripts/validateProtocol.ts index 2106a025..84923554 100644 --- a/lib/protocol-validation/scripts/validateProtocol.ts +++ b/lib/protocol-validation/scripts/validateProtocol.ts @@ -4,11 +4,11 @@ * * Errors & Validation failures are written to stderr. */ -import { type Protocol } from '@codaco/shared-consts'; import chalk from 'chalk'; import JSZip from 'jszip'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; +import { type Protocol } from '~/lib/shared-consts'; import { ensureError } from '~/utils/ensureError'; import { getProtocolJson } from '~/utils/protocolImport'; import { validateProtocol } from '..'; diff --git a/lib/protocol-validation/validation/Validator.ts b/lib/protocol-validation/validation/Validator.ts index a7edfd10..dafbf51f 100644 --- a/lib/protocol-validation/validation/Validator.ts +++ b/lib/protocol-validation/validation/Validator.ts @@ -1,5 +1,5 @@ -import { type Protocol, type StageSubject } from '@codaco/shared-consts'; import { get } from 'es-toolkit/compat'; +import { type Protocol, type StageSubject } from '~/lib/shared-consts'; import { type ValidationError } from '..'; /** diff --git a/lib/protocol-validation/validation/validateLogic.ts b/lib/protocol-validation/validation/validateLogic.ts index 1b13e2d7..866ffaba 100644 --- a/lib/protocol-validation/validation/validateLogic.ts +++ b/lib/protocol-validation/validation/validateLogic.ts @@ -1,3 +1,4 @@ +import { get, isObject } from 'es-toolkit/compat'; import { type AdditionalAttributes, type Codebook, @@ -8,8 +9,7 @@ import { type StageSubject, type VariableDefinition, type VariableValidation, -} from '@codaco/shared-consts'; -import { get, isObject } from 'es-toolkit/compat'; +} from '~/lib/shared-consts'; import Validator from './Validator'; import { duplicateId, diff --git a/lib/protocol-validation/validation/validateSchema.ts b/lib/protocol-validation/validation/validateSchema.ts index 76a19a1b..114508b8 100644 --- a/lib/protocol-validation/validation/validateSchema.ts +++ b/lib/protocol-validation/validation/validateSchema.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ -import { type Protocol } from '@codaco/shared-consts'; import type { ValidateFunction } from 'ajv'; +import { type Protocol } from '~/lib/shared-consts'; export const validateSchema = async ( protocol: Protocol, diff --git a/lib/shared-consts/assets.ts b/lib/shared-consts/assets.ts new file mode 100644 index 00000000..62c01b6e --- /dev/null +++ b/lib/shared-consts/assets.ts @@ -0,0 +1,11 @@ +// Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/Information-Interface#content-types +export enum InformationContentType { + text = 'text', + asset = 'asset', +} + +export enum AssetType { + image = 'image', + video = 'video', + audio = 'audio', +} diff --git a/lib/shared-consts/codebook.ts b/lib/shared-consts/codebook.ts new file mode 100644 index 00000000..d1627daa --- /dev/null +++ b/lib/shared-consts/codebook.ts @@ -0,0 +1,29 @@ +import { type Color } from './colors.js'; +import { type VariableDefinition } from './variables.js'; + +// Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/protocol.json#variable-registry +export enum EntityTypes { + edge = 'edge', + node = 'node', +} + +export type EntityTypeDefinition = { + name?: string; + color?: Color; + iconVariant?: string; + variables: Record; +}; + +export type NodeTypeDefinition = EntityTypeDefinition & { + name: string; + color: Color; + iconVariant?: string; +}; + +export type EdgeTypeDefinition = NodeTypeDefinition; + +export type Codebook = { + node?: Record; + edge?: Record; + ego?: EntityTypeDefinition; +}; diff --git a/lib/shared-consts/colors.ts b/lib/shared-consts/colors.ts new file mode 100644 index 00000000..6902e845 --- /dev/null +++ b/lib/shared-consts/colors.ts @@ -0,0 +1,16 @@ +// Below needs to be updated with actual string values used in the app + +// export enum Color { +// CHARCOAL = 'var(--color-charcoal)', +// CERULEAN_BLUE = 'var(--color-cerulean-blue)', +// PARADISE_PINK = 'var(--color-paradise-pink)', +// NEON_CARROT = 'var(--color-neon-carrot)', +// SEA_GREEN = 'var(--color-sea-green)', +// MUSTARD = 'var(--color-mustard)', +// KIWI = 'var(--color-kiwi)', +// TOMATO = 'var(--color-tomato)', +// PURPLE_PIZAZZ = 'var(--color-purple-pizazz)', +// SLATE_BLUE_DARK = 'var(--color-slate-blue--dark)', +// } + +export type Color = string; diff --git a/lib/shared-consts/controls/images/BooleanChoice.png b/lib/shared-consts/controls/images/BooleanChoice.png new file mode 100644 index 0000000000000000000000000000000000000000..f7002020aceb6cb046d7e4431b9d267f88701b9c GIT binary patch literal 39177 zcmeFZWmKHWwg8H2fW|GjyGw9)*M`PDI0OyexVr=ikl^m_t|1|K;~G3zkRXpaGn0Go zoXmQE-p|*3y}GC_*6jZ#rRxCf`W&}f`a{{$@3o+G!Yd1f67o$3ed#A${Nu0 zf3<;uf(m~P1@~7Q{pZg=UJ1|d=iYyP!sbE$&xm<2f47E9%7gv83@!dgw^)6KKYAiN z%jvy=g2JQu;|(pZLH+%CG`-haKsTVWl7P9B1FNZplbI!}mxJ>ky`Y4=1fGiymTsox zUJmw-Zv?!ADgV<#;JN%qH5(=Qf10@22~z@<)yO5CTrJ6YS=m|HDMe7o$;pLWEvy7I zq-6f;{`{XXrHz}Lvj7{Lr>7^YCl{-et2Ghs*g4raIa!`tu)OhhbTjp0aePDd z=OBNNBW3x<-1W7y+iNFB@;}BkHFI)z6Q-p6W1?TLKjCzHZS_AhIllP|t!D(;{`iKC zgO#1_*VxZph5o1&P<4H6`8@L<?}F#YPl`W3M?mO7;3TKJiSun>P+=g1MQ1e^-e#$ zgt{Nv@1obn%D`nSDMq&Qj$+o~!w?8l)8pHDF2&PN9oWbTP``P!=wZ4c1A>zd%@0jx zgL{nJsVa;v6s2$W9%qI2KjquHxbaoSDimva<71IS|JM^VCMhE3cV}KJXHj1PGjh(dKx2u3bSPZ|aow9pI^k0zuIbTo@)@OFfUxL^^ z04c;>BQca38&!V!>i}(zLUC&c>hFP15UWM3&PupgalytOFvEs;S*J z{RI3j0xux~fHRQe z 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 I_}z#92YRbl_W%F@ literal 0 HcmV?d00001 diff --git a/lib/shared-consts/controls/images/CheckboxGroup.png b/lib/shared-consts/controls/images/CheckboxGroup.png new file mode 100644 index 0000000000000000000000000000000000000000..1fd0eeb3e7b533fa9facd36811040aa28a049853 GIT binary patch literal 10217 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 NzOK>v5*??o{{?xAgVO*2 literal 0 HcmV?d00001 diff --git a/lib/shared-consts/controls/images/DatePicker.png b/lib/shared-consts/controls/images/DatePicker.png new file mode 100644 index 0000000000000000000000000000000000000000..6af6172481c36649ddb832728ad22eaae0d860bf GIT binary patch literal 41162 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 eTG4)E*vr8iKv;Uj2Obo^_ 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 E0kC{K7XSbN literal 0 HcmV?d00001 diff --git a/lib/shared-consts/controls/images/NumberInput.png b/lib/shared-consts/controls/images/NumberInput.png new file mode 100644 index 0000000000000000000000000000000000000000..6af95772205eddc4ad6602ae40e41b08fdc4b3be GIT binary patch literal 5808 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

U8X00{X7;}s{pD~^>!_-yf$w+8PaBy(Q6cuDO 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 eTG4)E*vr8iKv^Btz6qx%D7 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 literal 0 HcmV?d00001 diff --git a/lib/shared-consts/controls/images/Toggle.png b/lib/shared-consts/controls/images/Toggle.png new file mode 100644 index 0000000000000000000000000000000000000000..2d3b017442c72fd20629f9d1e77fb5ec33502f19 GIT binary patch literal 8510 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 literal 0 HcmV?d00001 diff --git a/lib/shared-consts/controls/images/ToggleButtonGroup.png b/lib/shared-consts/controls/images/ToggleButtonGroup.png new file mode 100644 index 0000000000000000000000000000000000000000..7f227b8fbaa790069bb259b27c70cf1e17505b62 GIT binary patch literal 27863 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 literal 0 HcmV?d00001 diff --git a/lib/shared-consts/controls/images/index.js.ignore b/lib/shared-consts/controls/images/index.js.ignore new file mode 100644 index 00000000..09244532 --- /dev/null +++ b/lib/shared-consts/controls/images/index.js.ignore @@ -0,0 +1,29 @@ +// import CheckboxGroup from './CheckboxGroup.png'; +// import RadioGroup from './RadioGroup.png'; +// import TextInput from './TextInput.png'; +// import TextArea from './TextArea.png'; +// import NumberInput from './NumberInput.png'; +// import Toggle from './Toggle.png'; +// import ToggleButtonGroup from './ToggleButtonGroup.png'; +// import DatePicker from './DatePicker.png'; +// import RelativeDatePicker from './RelativeDatePicker.png'; +// import LikertScale from './LikertScale.png'; +// import VisualAnalogScale from './VisualAnalogScale.png'; +// import BooleanChoice from './BooleanChoice.png'; + +// export default { +// CheckboxGroup, +// RadioGroup, +// TextInput, +// TextArea, +// NumberInput, +// Toggle, +// ToggleButtonGroup, +// DatePicker, +// RelativeDatePicker, +// LikertScale, +// VisualAnalogScale, +// BooleanChoice, +// }; + +export default {}; diff --git a/lib/shared-consts/controls/index.ts b/lib/shared-consts/controls/index.ts new file mode 100644 index 00000000..0579df74 --- /dev/null +++ b/lib/shared-consts/controls/index.ts @@ -0,0 +1,121 @@ +export type InputControlDefinition = { + label: string; + description: string; + image?: string; +}; + +export enum InputComponents { + Text = 'Text', + TextArea = 'TextArea', + Number = 'Number', + CheckboxGroup = 'CheckboxGroup', + Toggle = 'Toggle', + RadioGroup = 'RadioGroup', + ToggleButtonGroup = 'ToggleButtonGroup', + LikertScale = 'LikertScale', + VisualAnalogScale = 'VisualAnalogScale', + DatePicker = 'DatePicker', + RelativeDatePicker = 'RelativeDatePicker', + BooleanChoice = 'BooleanChoice', +} + +export const textInput = { + label: 'Text Input', + description: + 'This is a standard text input, allowing for simple data entry up to approximately 30 characters.', + // Reinstate: image: TextInputImage, +}; + +export const textArea = { + label: 'Text Area', + description: + 'This is an extra large text input, allowing for simple data entry for more than 30 characters.', + // Reinstate: image: TextAreaImage, +}; + +export const numberInput = { + label: 'Number Input', + description: + 'This input is optimized for collecting numerical data, and will show a number pad if available.', + // Reinstate: image: NumberInputImage, +}; + +export const checkboxGroup = { + label: 'Checkbox Group', + description: + 'This component provides a group of checkboxes so that multiple values can be toggled on or off.', + // Reinstate: image: CheckboxGroupImage, +}; + +export const toggle = { + label: 'Toggle', + description: + 'This component renders a switch, which can be tapped or clicked to indicate "on" or "off". By default it is in the "off" position. If you require a boolean input without a default, use the BooleanChoice component', + // Reinstate: image: ToggleImage, +}; + +export const radioGroup = { + label: 'Radio Group', + description: + 'This component renders a group of options and allow the user to choose one.', + // Reinstate: image: RadioGroupImage, +}; + +export const toggleButtonGroup = { + label: 'Toggle Button Group', + description: + 'This component provides a colorful button that can be toggled "on" or "off". It is an alternative to the Checkbox Group, and allows multiple selection by default.', + // Reinstate: image: ToggleButtonGroupImage, +}; + +export const likertScale = { + label: 'LikertScale', + description: + 'A component providing a likert-type scale in the form of a slider. Values are derived from the option properties of this variable, with labels for each option label.', + // Reinstate: image: LikertScaleImage, +}; + +export const visualAnalogScale = { + label: 'VisualAnalogScale', + description: + 'A Visual Analog Scale (VAS) component, which sets a normalized value between 0 and 1 representing the position of the slider between each end of the scale.', + // Reinstate: image: VisualAnalogScaleImage, +}; + +export const datePicker = { + label: 'DatePicker', + description: + 'A calendar date picker that allows a respondent to quickly enter year, month, and day data.', + // Reinstate: image: DatePickerImage, +}; + +export const relativeDatePicker = { + label: 'RelativeDatePicker', + description: + 'A calendar date picker that automatically limits available dates relative to an "anchor date", which can be configured to the date of the interview session. ', + // Reinstate: image: RelativeDatePickerImage, +}; + +export const booleanChoice = { + label: 'BooleanChoice', + description: + 'A component for boolean variables that requires the participant to actively select an option. Unlike the toggle component, this component accepts the "required" validation.', + // Reinstate: image: BooleanChoiceImage, +}; + +const inputControls = { + textInput, + textArea, + numberInput, + checkboxGroup, + toggle, + radioGroup, + toggleButtonGroup, + likertScale, + visualAnalogScale, + datePicker, + relativeDatePicker, + booleanChoice, +}; + +export default inputControls; diff --git a/lib/shared-consts/export-process.ts b/lib/shared-consts/export-process.ts new file mode 100644 index 00000000..dcca7f6b --- /dev/null +++ b/lib/shared-consts/export-process.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +export const nodeExportIDProperty = 'nodeID'; +export const edgeExportIDProperty = 'edgeID'; +export const egoProperty = 'networkCanvasEgoUUID'; +export const ncTypeProperty = 'networkCanvasType'; +export const ncProtocolNameProperty = 'networkCanvasProtocolName'; +export const ncCaseProperty = 'networkCanvasCaseID'; +export const ncSessionProperty = 'networkCanvasSessionID'; +export const ncUUIDProperty = 'networkCanvasUUID'; +export const ncSourceUUID = 'networkCanvasSourceUUID'; +export const ncTargetUUID = 'networkCanvasTargetUUID'; diff --git a/lib/shared-consts/index.ts b/lib/shared-consts/index.ts new file mode 100644 index 00000000..fc66fa69 --- /dev/null +++ b/lib/shared-consts/index.ts @@ -0,0 +1,10 @@ +export * from './assets'; +export * from './codebook'; +export * from './colors'; +export * from './controls'; +export * from './export-process'; +export * from './network'; +export * from './protocol'; +export * from './session'; +export * from './stages'; +export * from './variables'; diff --git a/lib/shared-consts/network.ts b/lib/shared-consts/network.ts new file mode 100644 index 00000000..23d353cf --- /dev/null +++ b/lib/shared-consts/network.ts @@ -0,0 +1,38 @@ +import { type VariableValue } from './variables'; + +export const entityPrimaryKeyProperty = '_uid' as const; +export const entitySecureAttributesMeta = '_secureAttributes' as const; +export const entityAttributesProperty = 'attributes' as const; +export const edgeSourceProperty = 'from' as const; +export const edgeTargetProperty = 'to' as const; + +export type NcEntity = { + readonly [entityPrimaryKeyProperty]: string; + type?: string; + [entityAttributesProperty]: Record; + [entitySecureAttributesMeta]?: Record< + string, + { iv: number[]; salt: number[] } + >; +}; + +export type NcNode = NcEntity & { + type: string; + stageId?: string; + promptIDs?: string[]; + displayVariable?: string; // @deprecated +}; + +export type NcEdge = NcEntity & { + type: string; + from: string; + to: string; +}; + +export type NcEgo = NcEntity; + +export type NcNetwork = { + nodes: NcNode[]; + edges: NcEdge[]; + ego?: NcEgo; +}; diff --git a/lib/shared-consts/protocol.ts b/lib/shared-consts/protocol.ts new file mode 100644 index 00000000..d2eb7458 --- /dev/null +++ b/lib/shared-consts/protocol.ts @@ -0,0 +1,22 @@ +import type { Asset } from '@prisma/client'; +import type { Codebook } from './codebook.js'; +import type { Stage } from './stages.js'; + +export type AssetDefinition = { + source: string; + name: string; + type: 'network' | 'image' | 'audio' | 'video'; + id?: string; +}; + +export type AssetManifest = Record; + +export type Protocol = { + name: string; + description?: string; + lastModified: string; + schemaVersion: number; + stages: Stage[]; + codebook: Codebook; + assets: Asset[]; +}; diff --git a/lib/shared-consts/session.ts b/lib/shared-consts/session.ts new file mode 100644 index 00000000..df812df8 --- /dev/null +++ b/lib/shared-consts/session.ts @@ -0,0 +1,8 @@ +export const caseProperty = 'caseId'; +export const sessionProperty = 'sessionId'; +export const protocolProperty = 'protocolUID'; +export const protocolName = 'protocolName'; +export const sessionStartTimeProperty = 'sessionStart'; +export const sessionFinishTimeProperty = 'sessionFinish'; +export const sessionExportTimeProperty = 'sessionExported'; +export const codebookHashProperty = 'codebookHash'; diff --git a/lib/shared-consts/stages.ts b/lib/shared-consts/stages.ts new file mode 100644 index 00000000..70fbb005 --- /dev/null +++ b/lib/shared-consts/stages.ts @@ -0,0 +1,163 @@ +import type { Color } from './colors.js'; + +export enum StageTypes { + NameGenerator = 'NameGenerator', + NameGeneratorQuickAdd = 'NameGeneratorQuickAdd', + NameGeneratorRoster = 'NameGeneratorRoster', + NameGeneratorList = 'NameGeneratorList', + NameGeneratorAutoComplete = 'NameGeneratorAutoComplete', + Sociogram = 'Sociogram', + Information = 'Information', + OrdinalBin = 'OrdinalBin', + CategoricalBin = 'CategoricalBin', + Narrative = 'Narrative', + AlterForm = 'AlterForm', + EgoForm = 'EgoForm', + AlterEdgeForm = 'AlterEdgeForm', + DyadCensus = 'DyadCensus', + TieStrengthCensus = 'TieStrengthCensus', +} + +export type SortOption = { + property: string; + direction: 'asc' | 'desc'; +}; + +export type PromptEdges = { + display?: string[]; + create?: string; +}; + +export type AdditionalAttribute = { + variable: string; + value: boolean; +}; + +export type AdditionalAttributes = AdditionalAttribute[]; + +export type Prompt = { + id: string; + text: string; + additionalAttributes?: AdditionalAttributes; + createEdge?: string; + edgeVariable?: string; + negativeLabel?: string; + variable?: string; + bucketSortOrder?: SortOption[]; + binSortOrder?: SortOption[]; + color?: Color; + sortOrder?: SortOption[]; + layout?: { + layoutVariable?: string; + }; + edges?: PromptEdges; + highlight?: { + allowHighlighting?: boolean; + variable?: string; + }; + otherVariable?: string; + otherVariablePrompt?: string; + otherOptionLabel?: string; +}; + +export type FilterRule = { + id: string; + type: 'alter' | 'ego' | 'edge'; + options: { + type?: string; + operator: + | 'EXISTS' + | 'NOT_EXISTS' + | 'EXACTLY' + | 'NOT' + | 'GREATER_THAN' + | 'GREATER_THAN_OR_EQUAL' + | 'LESS_THAN' + | 'LESS_THAN_OR_EQUAL' + | 'INCLUDES' + | 'EXCLUDES' + | 'OPTIONS_GREATER_THAN' + | 'OPTIONS_LESS_THAN' + | 'OPTIONS_EQUALS' + | 'OPTIONS_NOT_EQUALS'; + attribute?: string; + value?: boolean | number | string; + }; +}; + +export type FilterDefinition = { + join: 'AND' | 'OR'; + rules: FilterRule[]; +}; + +export type SkipDefinition = { + action: 'SKIP' | 'SHOW'; + filter: FilterDefinition; +}; + +export type PresetDefinition = { + id: string; + label: string; + layoutVariable: string; + groupVariable?: string; + edges?: { + display?: string[]; + }; + highlight?: string[]; +}; + +export type ItemDefinition = { + id: string; + type: 'asset' | 'text'; + content: string; + size: 'SMALL' | 'MEDIUM' | 'LARGE'; +}; + +export type StageSubject = { + entity: 'ego' | 'node' | 'edge'; + type: string; +}; + +export type FormField = { + variable: string; + prompt: string; +}; + +export type Form = { + title: string; + fields: FormField[]; +}; + +export interface Stage { + id: string; + type: string; + label: string; + title?: string; // Todo: remove this + interviewScript?: string; + form?: Form; + introductionPanel?: object; // Todo: create a Panel type + subject?: StageSubject | StageSubject[]; + panels?: object[]; + prompts?: Prompt[]; + quickAdd?: string; + behaviours?: object; + filter?: FilterDefinition; + skipLogic?: SkipDefinition; + dataSource?: string; + cardOptions?: object; // Todo: create a CardOptions type + sortOptions?: { + sortOrder: SortOption[]; + sortableProperties: object[]; // Todo: create a SortableProperty type + }; + background?: { + image?: string; + concentricCircles?: number; + skewedTowardCenter?: boolean; + }; + searchOptions?: { + fuzziness?: number; + matchProperties?: string[]; + }; + presets?: PresetDefinition[]; + items?: ItemDefinition[]; +} diff --git a/lib/shared-consts/variables.ts b/lib/shared-consts/variables.ts new file mode 100644 index 00000000..b9e500f7 --- /dev/null +++ b/lib/shared-consts/variables.ts @@ -0,0 +1,53 @@ +export type VariableValue = + | string + | unknown[] + | boolean + | number + | Record; + +// This isn't working currently +export enum VariableType { + boolean = 'boolean', + text = 'text', + number = 'number', + datetime = 'datetime', + ordinal = 'ordinal', + scalar = 'scalar', + categorical = 'categorical', + layout = 'layout', + location = 'location', +} + +export type OptionsOption = { + label: string; + value: string | number | boolean; +}; + +export type VariableValidation = { + required?: boolean; + minValue?: number; + maxValue?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + unique?: boolean; + sameAs?: string; + differentFrom?: string; + greaterThanVariable?: string; + lessThanVariable?: string; +}; + +export type VariableDefinition = { + name: string; + type: string; + component?: string; + validation?: VariableValidation; + options?: OptionsOption[]; + parameters?: { + minLabel?: string; + maxLabel?: string; + before?: number; + type?: string; // Todo: map out possible values for this + min?: string; + }; +}; diff --git a/lib/test-protocol.ts b/lib/test-protocol.ts index 763e2f54..7d8f6714 100644 --- a/lib/test-protocol.ts +++ b/lib/test-protocol.ts @@ -11,7 +11,7 @@ export const protocol: Protocol = { size: 'MEDIUM', id: '08964cf2-4c7b-4ecd-a6ef-123456', content: - 'This interview allows you to encrypt the names of the people you mention so that they cannot be seen by anyone but you - even the researchers running this study. \n\nTo use this feature, click on the padlock icon below, and enter a passcode when prompted.', + 'This interview allows you to encrypt the names of the people you mention so that they cannot be seen by anyone but you - even the researchers running this study. \n\nTo use this feature, click on the lock icon above, and enter a passcode when prompted.', type: 'text', }, ], diff --git a/package.json b/package.json index f35c4f11..1e2a5fc9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ }, "dependencies": { "@codaco/analytics": "7.0.0", - "@codaco/shared-consts": "^0.0.2", "@hookform/resolvers": "^3.9.1", "@lucia-auth/adapter-prisma": "^3.0.2", "@paralleldrive/cuid2": "^2.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0294c97..8197e36c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@codaco/analytics': specifier: 7.0.0 version: 7.0.0(next@14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.81.0)) - '@codaco/shared-consts': - specifier: ^0.0.2 - version: 0.0.2 '@hookform/resolvers': specifier: ^3.9.1 version: 3.9.1(react-hook-form@7.53.2(react@18.3.1)) @@ -576,9 +573,6 @@ packages: peerDependencies: next: 13 || 14 || 15 - '@codaco/shared-consts@0.0.2': - resolution: {integrity: sha512-qPmZQm50NhIuQGznQx00kdCxXJ/rOW8UkCrLPQObmPFjq3iIOTwBjAWddq7gRBsDByRfdB/gMCwcd7Pay2aOFQ==} - '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -5773,8 +5767,6 @@ snapshots: next: 14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.81.0) zod: 3.23.8 - '@codaco/shared-consts@0.0.2': {} - '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 diff --git a/schemas/network-canvas.ts b/schemas/network-canvas.ts index 93080308..aff27688 100644 --- a/schemas/network-canvas.ts +++ b/schemas/network-canvas.ts @@ -1,8 +1,8 @@ +import { z } from 'zod'; import { entityAttributesProperty, entityPrimaryKeyProperty, -} from '@codaco/shared-consts'; -import { z } from 'zod'; +} from '~/lib/shared-consts'; const ZNcEntity = z.object({ [entityPrimaryKeyProperty]: z.string().readonly(), @@ -23,7 +23,7 @@ export const ZNcEdge = ZNcEntity.extend({ to: z.string(), }); -// Always use this instead of @codaco/shared-consts. Main difference is that ego is not optional. +// Always use this instead of ~/lib/shared-consts. Main difference is that ego is not optional. export const ZNcNetwork = z.object({ nodes: z.array(ZNcNode), edges: z.array(ZNcEdge), diff --git a/utils/general.ts b/utils/general.ts index e3d58849..488cf462 100644 --- a/utils/general.ts +++ b/utils/general.ts @@ -1,3 +1,8 @@ +import { entityAttributesProperty, type NcEntity } from '~/lib/shared-consts'; + +export const getEntityAttributes = (entity: NcEntity) => + entity?.[entityAttributesProperty] || {}; + export const random = (a = 1, b = 0) => { const lower = Math.min(a, b); const upper = Math.max(a, b); @@ -14,19 +19,19 @@ export const randomInt = (a = 1, b = 0) => { * Formats a list of numbers into a human-readable string. */ export function formatNumberList(numbers: number[]): string { - // "1" - if (numbers.length === 1) { - return numbers[0]!.toString(); - } + // "1" + if (numbers.length === 1) { + return numbers[0]!.toString(); + } + + // "1 and 2" + if (numbers.length === 2) { + return numbers.join(' and '); + } + + // "1, 2, and 3" + const lastNumber = numbers.pop(); + const formattedList = numbers.join(', ') + `, and ${lastNumber}`; - // "1 and 2" - if (numbers.length === 2) { - return numbers.join(' and '); - } - - // "1, 2, and 3" - const lastNumber = numbers.pop(); - const formattedList = numbers.join(', ') + `, and ${lastNumber}`; - - return formattedList; -} \ No newline at end of file + return formattedList; +} diff --git a/utils/protocolImport.tsx b/utils/protocolImport.tsx index 84a4817c..16be8cc1 100644 --- a/utils/protocolImport.tsx +++ b/utils/protocolImport.tsx @@ -1,5 +1,5 @@ -import type { Protocol } from '@codaco/shared-consts'; import type Zip from 'jszip'; +import type { Protocol } from '~/lib/shared-consts'; // Fetch protocol.json as a parsed object from the protocol zip. export const getProtocolJson = async (protocolZip: Zip) => { From 8ffafb159053bd118ba37441f1521ba3b30dbb89 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 10 Dec 2024 14:45:15 +0200 Subject: [PATCH 11/15] ignore eslint during build --- next.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index a776baf2..656b4309 100644 --- a/next.config.js +++ b/next.config.js @@ -7,7 +7,7 @@ let commitHash = 'Unknown commit hash'; try { commitHash = ChildProcess.execSync('git log --pretty=format:"%h" -n1') .toString() - .trim() + .trim(); } catch (error) { // eslint-disable-next-line no-console console.info('Error getting commit hash:', error.message ?? 'Unknown error'); @@ -39,6 +39,7 @@ const config = { }, eslint: { dirs: ['./'], + ignoreDuringBuilds: true, }, }; export default config; From 5d0215ab0d1e45195bb99944c60435fb4085650c Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 12 Dec 2024 13:17:45 +0200 Subject: [PATCH 12/15] WIP fix selectors --- lib/interviewer/components/DialogManager.js | 8 +- .../Anonymisation/Anonymisation.tsx | 22 +-- .../Anonymisation/useNodeAttributes.tsx | 60 ++++--- .../Anonymisation/usePassphrase.tsx | 57 +++++++ .../Interfaces/Anonymisation/utils.ts | 11 +- .../containers/Interfaces/EgoForm.js | 2 +- .../containers/SlidesForm/SlidesForm.js | 2 +- .../ducks/modules/{dialogs.js => dialogs.ts} | 77 +++++---- lib/interviewer/protocol-consts.js | 2 +- lib/interviewer/selectors/protocol.ts | 23 ++- lib/interviewer/store.ts | 2 +- .../formatters/graphml/createGraphML.js | 2 +- lib/protocol-validation/schemas/src/8.zod.ts | 128 +-------------- lib/shared-consts/codebook.ts | 64 +++++--- lib/shared-consts/network.ts | 67 +++++--- lib/shared-consts/variables.ts | 148 +++++++++++++----- tsconfig.json | 2 +- 17 files changed, 372 insertions(+), 305 deletions(-) create mode 100644 lib/interviewer/containers/Interfaces/Anonymisation/usePassphrase.tsx rename lib/interviewer/ducks/modules/{dialogs.js => dialogs.ts} (51%) diff --git a/lib/interviewer/components/DialogManager.js b/lib/interviewer/components/DialogManager.js index e18a4900..ec2a5cfe 100644 --- a/lib/interviewer/components/DialogManager.js +++ b/lib/interviewer/components/DialogManager.js @@ -1,7 +1,7 @@ +import { bindActionCreators, compose } from '@reduxjs/toolkit'; import { connect } from 'react-redux'; -import { compose, bindActionCreators } from '@reduxjs/toolkit'; import Dialogs from '~/lib/ui/components/Dialogs'; -import { actionCreators as dialogsActions } from '../ducks/modules/dialogs'; +import { actionCreators as dialogsActions } from '../ducks/modules/dialogs.ts'; const mapStateToProps = (state) => ({ dialogs: state.dialogs, @@ -11,6 +11,4 @@ const mapDispatchToProps = (dispatch) => ({ closeDialog: bindActionCreators(dialogsActions.closeDialog, dispatch), }); -export default compose( - connect(mapStateToProps, mapDispatchToProps), -)(Dialogs); +export default compose(connect(mapStateToProps, mapDispatchToProps))(Dialogs); diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx index 87f848d5..cb0f14f9 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx +++ b/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx @@ -1,8 +1,4 @@ -import { type AnyAction } from '@reduxjs/toolkit'; import { motion } from 'motion/react'; -import { type ReactNode } from 'react'; -import { useDispatch } from 'react-redux'; -import { actionCreators as dialogActions } from '~/lib/interviewer/ducks/modules/dialogs'; import { type AnonymisationStage } from '~/lib/protocol-validation/schemas/src/8.zod'; import { Markdown } from '~/lib/ui/components/Fields'; import EncryptionBackground from '../../../components/EncryptedBackground'; @@ -14,19 +10,13 @@ type AnonymisationProps = StageProps & { const THRESHOLD_POSITION = 25; -type Dialog = { - id: string; - type: 'Confirm' | 'Notice' | 'Warning' | 'Error'; - title: string; - message: ReactNode; - onConfirm?: () => void; - onCancel?: () => void; -}; - export default function Anonymisation(props: AnonymisationProps) { - const dispatch = useDispatch(); - const openDialog = (dialog: Dialog) => - dispatch(dialogActions.openDialog(dialog) as unknown as AnyAction); + // const dispatch = useDispatch(); + // const openDialog = useCallback( + // (dialog: Dialog) => + // dispatch(dialogActions.openDialog(dialog) as unknown as AnyAction), + // [dispatch], + // ); return ( <> diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx index eddbb9b0..5b9dd725 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx +++ b/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx @@ -6,13 +6,16 @@ import { type VariableValue, } from '~/lib/shared-consts'; import { getEntityAttributes } from '~/utils/general'; -import { decryptData } from './utils'; +import { usePassphrase } from './usePassphrase'; +import { decryptData, UnauthorizedError } from './utils'; export const useNodeAttributes = ( node: NcNode, ): { - getById(attributeId: string): Promise; - getByName(attributeName: string): Promise; + getById(attributeId: string): Promise; + getByName( + attributeName: string, + ): Promise; } => { const codebookAttributes = useSelector( getCodebookVariablesForNodeType(node.type), @@ -20,11 +23,13 @@ export const useNodeAttributes = ( const nodeAttributes = getEntityAttributes(node); const requirePassphrase = usePassphrase(); - async function getById(attributeId: string) { + const getById = async ( + attributeId: string, + ): Promise => { const isEncrypted = codebookAttributes[attributeId]?.encrypted; if (!isEncrypted) { - return nodeAttributes[attributeId]; + return nodeAttributes[attributeId] as T | undefined; } const secureAttributes = node[entitySecureAttributesMeta]?.[attributeId]; @@ -32,27 +37,42 @@ export const useNodeAttributes = ( if (!secureAttributes) { // eslint-disable-next-line no-console console.log(`Node ${node._uid} is missing secure attributes`); - return null; + return undefined; } // This will trigger a prompt for the passphrase, and throw an error if it is cancelled. - const passphrase = await requirePassphrase(); + try { + const passphrase = await requirePassphrase(); - const decryptedValue = await decryptData( - { - [entitySecureAttributesMeta]: { - salt: secureAttributes.salt, - iv: secureAttributes.iv, + const decryptedValue = await decryptData( + { + [entitySecureAttributesMeta]: { + salt: secureAttributes.salt, + iv: secureAttributes.iv, + }, + data: nodeAttributes[attributeId] as number[], }, - data: nodeAttributes[attributeId] as number[], - }, - passphrase, - ); + passphrase, + ); + + return decryptedValue as T; + } catch (e) { + // User cancelled or passphrase was incorrect + if (e instanceof UnauthorizedError) { + return undefined; + } + + // Internal error should be logged - return decryptedValue; - } + // eslint-disable-next-line no-console + console.error(e); + return undefined; + } + }; - const getByName = async (attributeName: string) => { + const getByName = async ( + attributeName: string, + ): Promise => { const attributeId = Object.keys(codebookAttributes).find( (id) => codebookAttributes[id]!.name.toLowerCase() === @@ -60,7 +80,7 @@ export const useNodeAttributes = ( ); if (!attributeId) { - return null; + return undefined; } return await getById(attributeId); diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/usePassphrase.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/usePassphrase.tsx new file mode 100644 index 00000000..d417077d --- /dev/null +++ b/lib/interviewer/containers/Interfaces/Anonymisation/usePassphrase.tsx @@ -0,0 +1,57 @@ +import { UnauthorizedError } from './utils'; + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Window { + getting: boolean; + passphrase: string | null; + } +} + +// Custom hook that allows for storing and retriving a passphrase from session storage. +// Emits a custom event when the passphrase is set, and triggers a dialog if the passphrase +// is not set. +export function usePassphrase() { + // const dispatch = useDispatch(); + // const openDialog = useCallback( + // (dialog: Dialog) => + // dispatch(dialogActions.openDialog(dialog) as unknown as AnyAction), + // [dispatch], + // ); + + async function requirePassphrase(): Promise { + if (window.passphrase) { + return Promise.resolve(window.passphrase); + } + + if (window.getting) { + // Return a promise that resolves based on events from the dialog. + return new Promise((resolve, reject) => { + window.addEventListener('passphrase-set', () => { + resolve(window.passphrase!); + }); + + window.addEventListener('passphrase-cancel', () => { + reject(new UnauthorizedError()); + }); + }); + } + + window.getting = true; + + window.passphrase = prompt('Please enter your passphrase to continue.'); + + window.getting = false; + + if (!window.passphrase) { + window.dispatchEvent(new CustomEvent('passphrase-cancel')); + throw new UnauthorizedError(); + } + + window.dispatchEvent(new CustomEvent('passphrase-set')); + + return Promise.resolve(window.passphrase); + } + + return requirePassphrase; +} diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/utils.ts b/lib/interviewer/containers/Interfaces/Anonymisation/utils.ts index 0e17a403..bf304558 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation/utils.ts +++ b/lib/interviewer/containers/Interfaces/Anonymisation/utils.ts @@ -1,3 +1,7 @@ +import { entitySecureAttributesMeta } from '~/lib/shared-consts'; + +export const SESSION_STORAGE_KEY = 'passphrase'; + export class UnauthorizedError extends Error { constructor() { super('Unauthorized'); @@ -65,10 +69,10 @@ export type EncryptedData = Awaited>; export async function decryptData( encrypted: EncryptedData, passphrase: string, -) { +): Promise { const { data, - _secureAttributes: { iv, salt }, + [entitySecureAttributesMeta]: { iv, salt }, } = encrypted; const key = await generateKey(passphrase, new Uint8Array(salt)); @@ -83,5 +87,8 @@ export async function decryptData( ); const decoder = new TextDecoder(); + + // TODO: We need to look up the variable type and re-cast it here. + return decoder.decode(decryptedData); } diff --git a/lib/interviewer/containers/Interfaces/EgoForm.js b/lib/interviewer/containers/Interfaces/EgoForm.js index 46ba6988..c4dd6817 100644 --- a/lib/interviewer/containers/Interfaces/EgoForm.js +++ b/lib/interviewer/containers/Interfaces/EgoForm.js @@ -7,7 +7,7 @@ import { isDirty, isValid, submit } from 'redux-form'; import Markdown from '~/lib/ui/components/Fields/Markdown'; import Icon from '~/lib/ui/components/Icon'; import Scroller from '~/lib/ui/components/Scroller'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs.ts'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; import useFlipflop from '../../hooks/useFlipflop'; import useReadyForNextStage from '../../hooks/useReadyForNextStage'; diff --git a/lib/interviewer/containers/SlidesForm/SlidesForm.js b/lib/interviewer/containers/SlidesForm/SlidesForm.js index 771ca7ef..50bbb686 100644 --- a/lib/interviewer/containers/SlidesForm/SlidesForm.js +++ b/lib/interviewer/containers/SlidesForm/SlidesForm.js @@ -9,7 +9,7 @@ import { isDirty, isValid, submit } from 'redux-form'; import { v4 as uuid } from 'uuid'; import { Markdown } from '~/lib/ui/components/Fields'; import ProgressBar from '~/lib/ui/components/ProgressBar'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs.ts'; import useReadyForNextStage from '../../hooks/useReadyForNextStage'; const confirmDialog = { diff --git a/lib/interviewer/ducks/modules/dialogs.js b/lib/interviewer/ducks/modules/dialogs.ts similarity index 51% rename from lib/interviewer/ducks/modules/dialogs.js rename to lib/interviewer/ducks/modules/dialogs.ts index 4d025711..316b7fd7 100644 --- a/lib/interviewer/ducks/modules/dialogs.js +++ b/lib/interviewer/ducks/modules/dialogs.ts @@ -1,8 +1,25 @@ +import { type Dispatch } from '@reduxjs/toolkit'; +import { type ReactNode } from 'react'; import { v4 as uuid } from 'uuid'; const OPEN_DIALOG = Symbol('PROTOCOL/OPEN_DIALOG'); const CLOSE_DIALOG = Symbol('PROTOCOL/CLOSE_DIALOG'); +type Action = { + type: typeof OPEN_DIALOG | typeof CLOSE_DIALOG; + id: string; + dialog?: Dialog; +}; + +export type Dialog = { + id: string; + type: 'Confirm' | 'Notice' | 'Warning' | 'Error'; + title: string; + message: ReactNode; + onConfirm?: () => void; + onCancel?: () => void; +}; + const initialState = [ // { // id: '1234-1234-1', @@ -34,46 +51,46 @@ const initialState = [ // onConfirm: () => {}, // onCancel: () => {}, // }, -]; +] as Dialog[]; -const openDialog = (dialog) => (dispatch) => new Promise((resolve) => { - const onConfirm = () => { - if (dialog.onConfirm) { dialog.onConfirm(); } - resolve(true); - }; +const openDialog = (dialog: Dialog) => (dispatch: Dispatch) => + new Promise((resolve) => { + const onConfirm = () => { + if (dialog.onConfirm) { + dialog.onConfirm(); + } + resolve(true); + }; - const onCancel = () => { - if (dialog.onCancel) { dialog.onCancel(); } - resolve(false); - }; + const onCancel = () => { + if (dialog.onCancel) { + dialog.onCancel(); + } + resolve(false); + }; - dispatch({ - id: uuid(), - type: OPEN_DIALOG, - dialog: { - ...dialog, - onConfirm, - onCancel, - }, + dispatch({ + id: uuid(), + type: OPEN_DIALOG, + dialog: { + ...dialog, + onConfirm, + onCancel, + }, + }); }); -}); -const closeDialog = (id) => ({ +const closeDialog = (id: Dialog['id']) => ({ type: CLOSE_DIALOG, id, }); -function reducer(state = initialState, action = {}) { +function reducer(state = initialState, action: Action) { switch (action.type) { case OPEN_DIALOG: - return [ - ...state, - { id: action.id, ...action.dialog }, - ]; + return [...state, { id: action.id, ...action.dialog }]; case CLOSE_DIALOG: - return [ - ...state.filter((dialog) => dialog.id !== action.id), - ]; + return [...state.filter((dialog) => dialog.id !== action.id)]; default: return state; } @@ -84,8 +101,6 @@ const actionCreators = { closeDialog, }; -export { - actionCreators, -}; +export { actionCreators }; export default reducer; diff --git a/lib/interviewer/protocol-consts.js b/lib/interviewer/protocol-consts.js index 65b9a57e..717f29b6 100644 --- a/lib/interviewer/protocol-consts.js +++ b/lib/interviewer/protocol-consts.js @@ -1,4 +1,4 @@ -import { VariableType } from '~/lib/shared-consts'; +import { VariableTypeEnum as VariableType } from '~/lib/shared-consts'; // String consts used by protocol files // Note: these values are no longer used to produce JSON schemas; the schemas must diff --git a/lib/interviewer/selectors/protocol.ts b/lib/interviewer/selectors/protocol.ts index 493b065a..8e93b1ef 100644 --- a/lib/interviewer/selectors/protocol.ts +++ b/lib/interviewer/selectors/protocol.ts @@ -6,11 +6,10 @@ import { type EdgeTypeDefinition, type EntityTypeDefinition, type NodeTypeDefinition, - type Protocol, type StageSubject, type VariableDefinition, } from '~/lib/shared-consts'; -import { type RootState, type Session } from '../store'; +import { type RootState } from '../store'; import { getStageSubject } from './prop'; const DefaultFinishStage = { @@ -28,24 +27,22 @@ const getInstalledProtocols = (state: RootState) => state.installedProtocols; const getCurrentSessionProtocol = createSelector( getActiveSession, getInstalledProtocols, - (session: Session | undefined, protocols: Record) => { - if (!session) { - throw new Error('No active session'); - } - return protocols[session.protocolUid]!; + (session, protocols) => { + if (!session) return undefined; + return protocols[session.protocolUID]; }, ); export const getAssetManifest = createSelector( getCurrentSessionProtocol, (protocol) => - protocol.assets.reduce( + protocol?.assets.reduce( (manifest, asset) => { manifest[asset.assetId] = asset; return manifest; }, {} as Record, - ), + ) ?? {}, ); export const getAssetUrlFromId = createSelector( @@ -55,13 +52,13 @@ export const getAssetUrlFromId = createSelector( export const getProtocolCodebook = createSelector( getCurrentSessionProtocol, - (protocol) => protocol.codebook, + (protocol) => protocol?.codebook ?? { node: {}, edge: {}, ego: {} }, ); // Get all variables for all subjects in the codebook, adding the entity and type export const getAllVariableUUIDsByEntity = createSelector( getProtocolCodebook, - ({ node: nodeTypes = {}, edge: edgeTypes = {}, ego = {} }) => { + ({ node: nodeTypes, edge: edgeTypes, ego }) => { const variables = {} as Record< string, VariableDefinition & { @@ -123,7 +120,7 @@ export const getAllVariableUUIDsByEntity = createSelector( export const getProtocolStages = createSelector( getCurrentSessionProtocol, // Insert default finish stage here. - ({ stages = [] }) => [...stages, DefaultFinishStage], + (protocol) => [...(protocol?.stages ?? []), DefaultFinishStage], ); export const getCodebookVariablesForSubjectType = createSelector( @@ -133,7 +130,7 @@ export const getCodebookVariablesForSubjectType = createSelector( subject ? (codebook[subject.entity as 'node' | 'edge']?.[subject.type] ?.variables ?? {}) - : (codebook.ego?.variables ?? {}), + : codebook, ); export const getCodebookVariablesForNodeType = (type: string) => diff --git a/lib/interviewer/store.ts b/lib/interviewer/store.ts index 32a4dd78..d31ea7fa 100644 --- a/lib/interviewer/store.ts +++ b/lib/interviewer/store.ts @@ -30,7 +30,7 @@ export type StageMetadata = StageMetadataEntry[]; export type Session = { id: string; - protocolUid: string; + protocolUID: string; promptIndex: number; currentStep: number; caseId: string; diff --git a/lib/network-exporters/formatters/graphml/createGraphML.js b/lib/network-exporters/formatters/graphml/createGraphML.js index 3024dfcd..7be245a7 100644 --- a/lib/network-exporters/formatters/graphml/createGraphML.js +++ b/lib/network-exporters/formatters/graphml/createGraphML.js @@ -6,7 +6,7 @@ import { createDataElement, formatXml, getGraphMLTypeForKey } from './helpers'; import dom from '@xmldom/xmldom'; import { getAttributePropertyFromCodebook } from '~/lib/network-exporters/utils/general'; import { - VariableType, + VariableTypeEnum as VariableType, caseProperty, codebookHashProperty, edgeExportIDProperty, diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index 35c6cafe..d1645d36 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -1,131 +1,5 @@ import { z } from 'zod'; - -// Constants for repeated values -const validVariableName = /^[a-zA-Z0-9._:-]+$/; - -// Enums -const componentEnum = z.enum([ - 'Boolean', - 'CheckboxGroup', - 'Number', - 'RadioGroup', - 'Text', - 'TextArea', - 'Toggle', - 'ToggleButtonGroup', - 'Slider', - 'VisualAnalogScale', - 'LikertScale', - 'DatePicker', - 'RelativeDatePicker', -]); - -const typeEnum = z.enum([ - 'boolean', - 'text', - 'number', - 'datetime', - 'ordinal', - 'scalar', - 'categorical', - 'layout', - 'location', -]); - -// Validation Schema -const validationSchema = z - .object({ - required: z.boolean().optional(), - requiredAcceptsNull: z.boolean().optional(), - minLength: z.number().int().optional(), - maxLength: z.number().int().optional(), - minValue: z.number().int().optional(), - maxValue: z.number().int().optional(), - minSelected: z.number().int().optional(), - maxSelected: z.number().int().optional(), - unique: z.boolean().optional(), - differentFrom: z.string().optional(), - sameAs: z.string().optional(), - greaterThanVariable: z.string().optional(), - lessThanVariable: z.string().optional(), - }) - .strict(); - -// Options Schema -const optionsSchema = z - .array( - z.union([ - z - .object({ - label: z.string(), - value: z.union([ - z.number().int(), - z.string().regex(validVariableName), - z.boolean(), - ]), - negative: z.boolean().optional(), - }) - .strict(), - z.number().int(), - z.string(), - ]), - ) - .optional(); - -// Variable Schema -const variableSchema = z - .object({ - name: z.string().regex(validVariableName), - type: typeEnum, - encrypted: z.boolean().optional(), - component: componentEnum.optional(), - options: optionsSchema, - parameters: z.record(z.any()).optional(), - validation: validationSchema.optional(), - }) - .strict(); - -export type Variable = z.infer; - -const VariablesSchema = z.record( - z.string().regex(validVariableName), - variableSchema, -); -type Variables = z.infer; - -// Node, Edge, and Ego Schemas -const nodeSchema = z - .object({ - name: z.string(), - displayVariable: z.string().optional(), - iconVariant: z.string().optional(), - variables: VariablesSchema.optional(), - color: z.string(), - }) - .strict(); - -const edgeSchema = z - .object({ - name: z.string(), - color: z.string(), - variables: VariablesSchema.optional(), - }) - .strict(); - -const egoSchema = z - .object({ - variables: VariablesSchema.optional(), - }) - .strict(); - -// Codebook Schema -const codebookSchema = z - .object({ - node: z.record(z.union([nodeSchema, z.never()])), - edge: z.record(z.union([edgeSchema, z.never()])).optional(), - ego: egoSchema.optional(), - }) - .strict(); +import { codebookSchema } from '~/lib/shared-consts'; // Filter and Sort Options Schemas const filterRuleSchema = z diff --git a/lib/shared-consts/codebook.ts b/lib/shared-consts/codebook.ts index d1627daa..47252681 100644 --- a/lib/shared-consts/codebook.ts +++ b/lib/shared-consts/codebook.ts @@ -1,5 +1,5 @@ -import { type Color } from './colors.js'; -import { type VariableDefinition } from './variables.js'; +import { z } from 'zod'; +import { VariablesSchema } from './variables'; // Docs: https://github.com/complexdatacollective/Network-Canvas/wiki/protocol.json#variable-registry export enum EntityTypes { @@ -7,23 +7,43 @@ export enum EntityTypes { node = 'node', } -export type EntityTypeDefinition = { - name?: string; - color?: Color; - iconVariant?: string; - variables: Record; -}; - -export type NodeTypeDefinition = EntityTypeDefinition & { - name: string; - color: Color; - iconVariant?: string; -}; - -export type EdgeTypeDefinition = NodeTypeDefinition; - -export type Codebook = { - node?: Record; - edge?: Record; - ego?: EntityTypeDefinition; -}; +// Node, Edge, and Ego Schemas +const nodeSchema = z + .object({ + name: z.string(), + iconVariant: z.string().optional(), + variables: VariablesSchema.optional(), + color: z.string(), + }) + .strict(); + +export type NodeTypeDefinition = z.infer; + +const edgeSchema = z + .object({ + name: z.string(), + color: z.string(), + variables: VariablesSchema.optional(), + }) + .strict(); + +export type EdgeTypeDefinition = z.infer; + +const egoSchema = z + .object({ + variables: VariablesSchema.optional(), + }) + .strict(); + +export type EntityTypeDefinition = z.infer; + +// Codebook Schema +export const codebookSchema = z + .object({ + node: z.record(z.union([nodeSchema, z.never()])), + edge: z.record(z.union([edgeSchema, z.never()])).optional(), + ego: egoSchema.optional(), + }) + .strict(); + +export type Codebook = z.infer; diff --git a/lib/shared-consts/network.ts b/lib/shared-consts/network.ts index 23d353cf..44d4f18f 100644 --- a/lib/shared-consts/network.ts +++ b/lib/shared-consts/network.ts @@ -1,4 +1,20 @@ -import { type VariableValue } from './variables'; +import { z } from 'zod'; +import { validVariableNameSchema } from './variables'; + +const encryptedValueSchema = z.array(z.number()); + +export type EncryptedValue = z.infer; + +const variableValueSchema = z.union([ + z.string(), + z.array(z.unknown()), // remove + z.boolean(), + z.number(), + encryptedValueSchema, + z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])), +]); + +export type VariableValue = z.infer; export const entityPrimaryKeyProperty = '_uid' as const; export const entitySecureAttributesMeta = '_secureAttributes' as const; @@ -6,28 +22,37 @@ export const entityAttributesProperty = 'attributes' as const; export const edgeSourceProperty = 'from' as const; export const edgeTargetProperty = 'to' as const; -export type NcEntity = { - readonly [entityPrimaryKeyProperty]: string; - type?: string; - [entityAttributesProperty]: Record; - [entitySecureAttributesMeta]?: Record< - string, - { iv: number[]; salt: number[] } - >; -}; +const NcEntitySchema = z.object({ + [entityPrimaryKeyProperty]: z.string().readonly(), + [entityAttributesProperty]: z.record( + validVariableNameSchema, + variableValueSchema, + ), + [entitySecureAttributesMeta]: z.record( + z.object({ + iv: z.array(z.number()), + salt: z.array(z.number()), + }), + ), +}); -export type NcNode = NcEntity & { - type: string; - stageId?: string; - promptIDs?: string[]; - displayVariable?: string; // @deprecated -}; +export type NcEntity = z.infer; -export type NcEdge = NcEntity & { - type: string; - from: string; - to: string; -}; +const NcNodeSchema = NcEntitySchema.extend({ + type: z.string(), + stageId: z.string().optional(), + promptIDs: z.array(z.string()).optional(), +}); + +export type NcNode = z.infer; + +export const NcEdgeSchema = NcEntitySchema.extend({ + type: z.string(), + from: z.string(), + to: z.string(), +}); + +export type NcEdge = z.infer; export type NcEgo = NcEntity; diff --git a/lib/shared-consts/variables.ts b/lib/shared-consts/variables.ts index b9e500f7..e1e7ceab 100644 --- a/lib/shared-consts/variables.ts +++ b/lib/shared-consts/variables.ts @@ -1,12 +1,42 @@ -export type VariableValue = - | string - | unknown[] - | boolean - | number - | Record; - -// This isn't working currently -export enum VariableType { +import { z } from 'zod'; + +// Constants for repeated values +const validVariableName = /^[a-zA-Z0-9._:-]+$/; + +export const validVariableNameSchema = z.string().regex(validVariableName); + +export type ValidVariableName = z.infer; + +// Enums +const componentEnum = z.enum([ + 'Boolean', + 'CheckboxGroup', + 'Number', + 'RadioGroup', + 'Text', + 'TextArea', + 'Toggle', + 'ToggleButtonGroup', + 'Slider', + 'VisualAnalogScale', + 'LikertScale', + 'DatePicker', + 'RelativeDatePicker', +]); + +const typeEnum = z.enum([ + 'boolean', + 'text', + 'number', + 'datetime', + 'ordinal', + 'scalar', + 'categorical', + 'layout', + 'location', +]); + +export enum VariableTypeEnum { boolean = 'boolean', text = 'text', number = 'number', @@ -18,36 +48,70 @@ export enum VariableType { location = 'location', } -export type OptionsOption = { - label: string; - value: string | number | boolean; -}; - -export type VariableValidation = { - required?: boolean; - minValue?: number; - maxValue?: number; - minLength?: number; - maxLength?: number; - pattern?: string; - unique?: boolean; - sameAs?: string; - differentFrom?: string; - greaterThanVariable?: string; - lessThanVariable?: string; -}; - -export type VariableDefinition = { - name: string; - type: string; - component?: string; - validation?: VariableValidation; - options?: OptionsOption[]; - parameters?: { - minLabel?: string; - maxLabel?: string; - before?: number; - type?: string; // Todo: map out possible values for this - min?: string; - }; -}; +export type VariableType = z.infer; + +// Validation Schema +const validationSchema = z + .object({ + required: z.boolean().optional(), + requiredAcceptsNull: z.boolean().optional(), + minLength: z.number().int().optional(), + maxLength: z.number().int().optional(), + minValue: z.number().int().optional(), + maxValue: z.number().int().optional(), + minSelected: z.number().int().optional(), + maxSelected: z.number().int().optional(), + unique: z.boolean().optional(), + differentFrom: z.string().optional(), + sameAs: z.string().optional(), + greaterThanVariable: z.string().optional(), + lessThanVariable: z.string().optional(), + }) + .strict(); + +export type VariableValidation = z.infer; + +const OptionsOptionSchema = z.object({ + label: z.string(), + value: z.union([ + z.number().int(), + z.string().regex(validVariableName), + z.boolean(), + ]), + negative: z.boolean().optional(), +}); + +export type OptionsOption = z.infer; + +// Options Schema +const optionsSchema = z + .array(z.union([OptionsOptionSchema.strict(), z.number().int(), z.string()])) + .optional(); + +// Variable Schema +const variableSchema = z + .object({ + name: z.string().regex(validVariableName), + type: typeEnum, + encrypted: z.boolean().optional(), + component: componentEnum.optional(), + options: optionsSchema, + parameters: z + .object({ + minLabel: z.string().optional(), + maxLabel: z.string().optional(), + before: z.number().int().optional(), + type: z.string().optional(), + min: z.string().optional(), + }) + .optional(), + validation: validationSchema.optional(), + }) + .strict(); + +export type VariableDefinition = z.infer; + +export const VariablesSchema = z.record( + validVariableNameSchema, + variableSchema, +); diff --git a/tsconfig.json b/tsconfig.json index 5bd16f7d..2b61a208 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,7 +46,7 @@ "eslint-local-rules/index.js", "actions/auth.ts", "env.tjs" - ], +, "lib/interviewer/ducks/modules/dialogs.jts" ], "exclude": [ "node_modules", ] From 4068362d34fe143a74515fe1dc93d9843a729daf Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 12 Dec 2024 13:21:32 +0200 Subject: [PATCH 13/15] rename protocolUID (and protocolUid) => protocolId to match schema and remove duplication --- .../containers/withExternalData.js | 5 ++-- .../ducks/modules/installedProtocols.js | 2 +- lib/interviewer/ducks/modules/reset.js | 4 ++-- lib/interviewer/ducks/modules/session.js | 24 +++++++++---------- lib/interviewer/hooks/useExternalData.js | 8 +++---- lib/interviewer/selectors/protocol.ts | 2 +- lib/interviewer/store.ts | 2 +- lib/interviewer/utils/loadExternalData.js | 2 +- .../formatters/graphml/createGraphML.js | 2 +- .../formatters/session/generateOutputFiles.ts | 4 ++-- lib/shared-consts/session.ts | 2 +- 11 files changed, 28 insertions(+), 29 deletions(-) diff --git a/lib/interviewer/containers/withExternalData.js b/lib/interviewer/containers/withExternalData.js index 7b49e3ee..61cb6bdf 100644 --- a/lib/interviewer/containers/withExternalData.js +++ b/lib/interviewer/containers/withExternalData.js @@ -16,11 +16,10 @@ import ProtocolConsts from '../protocol-consts'; import loadExternalData from '../utils/loadExternalData'; const mapStateToProps = (state) => { - const { protocolUID, assetManifest, protocolCodebook } = - getSessionMeta(state); + const { protocolId, assetManifest, protocolCodebook } = getSessionMeta(state); return { - protocolUID, + protocolId, assetManifest, protocolCodebook, }; diff --git a/lib/interviewer/ducks/modules/installedProtocols.js b/lib/interviewer/ducks/modules/installedProtocols.js index 9f92b21a..dd5b2c79 100644 --- a/lib/interviewer/ducks/modules/installedProtocols.js +++ b/lib/interviewer/ducks/modules/installedProtocols.js @@ -26,7 +26,7 @@ export default function reducer(state = initialState, action = {}) { }; } case DELETE_PROTOCOL: - return omit(state, [action.protocolUID]); + return omit(state, [action.protocolId]); case IMPORT_PROTOCOL_COMPLETE: { const newProtocol = action.protocolData; diff --git a/lib/interviewer/ducks/modules/reset.js b/lib/interviewer/ducks/modules/reset.js index 3b443965..c18b30f1 100644 --- a/lib/interviewer/ducks/modules/reset.js +++ b/lib/interviewer/ducks/modules/reset.js @@ -11,11 +11,11 @@ const resetPropertyForAllNodes = (property) => (dispatch, getState) => { sessions: { [activeSessionId]: { network: { nodes }, - protocolUID, + protocolId, }, }, installedProtocols: { - [protocolUID]: { + [protocolId]: { codebook: { node: nodeRegistry }, }, }, diff --git a/lib/interviewer/ducks/modules/session.js b/lib/interviewer/ducks/modules/session.js index 82af743b..6a2b2a6c 100644 --- a/lib/interviewer/ducks/modules/session.js +++ b/lib/interviewer/ducks/modules/session.js @@ -41,13 +41,13 @@ const getReducer = session: { id }, } = action.payload; const { - protocol: { id: protocolUID }, + protocol: { id: protocolId }, } = action.payload; return { ...state, [id]: { - protocolUID, + protocolId, ...action.payload.session, network: action.payload.session.network ?? network(state.network, action), @@ -57,7 +57,7 @@ const getReducer = } case installedProtocolsActionTypes.DELETE_PROTOCOL: return Object.keys(state).reduce((result, sessionData, sessionId) => { - if (sessionData.protocolUID !== action.protocolUID) { + if (sessionData.protocolId !== action.protocolId) { return { ...result, [sessionId]: sessionData }; } return result; @@ -92,7 +92,7 @@ const getReducer = return { ...state, [action.sessionId]: withTimestamp({ - protocolUID: action.protocolUID, + protocolId: action.protocolId, promptIndex: 0, currentStep: null, caseId: action.caseId, @@ -237,7 +237,7 @@ const batchAddNodes = const { activeSessionId, sessions, installedProtocols } = getState(); const session = sessions[activeSessionId]; - const activeProtocol = installedProtocols[session.protocolUID]; + const activeProtocol = installedProtocols[session.protocolId]; const nodeRegistry = activeProtocol.codebook.node; const registryForType = nodeRegistry[type].variables; const defaultAttributes = @@ -260,7 +260,7 @@ const addNode = const { activeSessionId, sessions, installedProtocols } = getState(); const activeProtocol = - installedProtocols[sessions[activeSessionId].protocolUID]; + installedProtocols[sessions[activeSessionId].protocolId]; const nodeRegistry = activeProtocol.codebook.node; const registryForType = nodeRegistry[modelData.type].variables; @@ -344,7 +344,7 @@ const updateEgo = const { activeSessionId, sessions, installedProtocols } = getState(); const activeProtocol = - installedProtocols[sessions[activeSessionId].protocolUID]; + installedProtocols[sessions[activeSessionId].protocolId]; const egoRegistry = activeProtocol.codebook.ego || {}; dispatch({ @@ -364,7 +364,7 @@ const addEdge = const { activeSessionId, sessions, installedProtocols } = getState(); const activeProtocol = - installedProtocols[sessions[activeSessionId].protocolUID]; + installedProtocols[sessions[activeSessionId].protocolId]; const edgeRegistry = activeProtocol.codebook.edge; const registryForType = edgeRegistry[modelData.type].variables; @@ -400,7 +400,7 @@ const toggleEdge = const { activeSessionId, sessions, installedProtocols } = getState(); const activeProtocol = - installedProtocols[sessions[activeSessionId].protocolUID]; + installedProtocols[sessions[activeSessionId].protocolId]; const edgeRegistry = activeProtocol.codebook.edge; const registryForType = edgeRegistry[modelData.type].variables; @@ -427,11 +427,11 @@ const removeEdge = (edgeId) => (dispatch, getState) => { }; const addSession = - (caseId, protocolUID, sessionNetwork) => (dispatch, getState) => { + (caseId, protocolId, sessionNetwork) => (dispatch, getState) => { const id = uuid(); const { installedProtocols } = getState(); - const activeProtocol = installedProtocols[protocolUID]; + const activeProtocol = installedProtocols[protocolId]; const egoRegistry = activeProtocol.codebook.ego || {}; const egoAttributeData = getDefaultAttributesForEntityType( egoRegistry.variables, @@ -442,7 +442,7 @@ const addSession = sessionId: id, ...(sessionNetwork && { network: sessionNetwork }), caseId, - protocolUID, + protocolId, egoAttributeData, // initial values for ego }); }; diff --git a/lib/interviewer/hooks/useExternalData.js b/lib/interviewer/hooks/useExternalData.js index 2854af78..0999d11c 100644 --- a/lib/interviewer/hooks/useExternalData.js +++ b/lib/interviewer/hooks/useExternalData.js @@ -18,10 +18,10 @@ export const getSessionMeta = createSelector( getProtocolCodebook, getAssetManifest, (session, protocolCodebook, assetManifest) => { - const { protocolUID } = session; + const { protocolId } = session; return { - protocolUID, + protocolId, assetManifest, protocolCodebook, }; @@ -51,7 +51,7 @@ export const makeVariableUUIDReplacer = }; const useExternalData = (dataSource, subject) => { - const { protocolUID, assetManifest, protocolCodebook } = + const { protocolId, assetManifest, protocolCodebook } = useSelector(getSessionMeta); const [externalData, setExternalData] = useState(null); @@ -86,7 +86,7 @@ const useExternalData = (dataSource, subject) => { console.error(e); updateStatus({ isLoading: false, error: e }); }); - }, [dataSource, protocolUID, assetManifest, protocolCodebook, subject]); + }, [dataSource, protocolId, assetManifest, protocolCodebook, subject]); return [externalData, status]; }; diff --git a/lib/interviewer/selectors/protocol.ts b/lib/interviewer/selectors/protocol.ts index 8e93b1ef..727d3bce 100644 --- a/lib/interviewer/selectors/protocol.ts +++ b/lib/interviewer/selectors/protocol.ts @@ -29,7 +29,7 @@ const getCurrentSessionProtocol = createSelector( getInstalledProtocols, (session, protocols) => { if (!session) return undefined; - return protocols[session.protocolUID]; + return protocols[session.protocolId]; }, ); diff --git a/lib/interviewer/store.ts b/lib/interviewer/store.ts index d31ea7fa..4ac881bb 100644 --- a/lib/interviewer/store.ts +++ b/lib/interviewer/store.ts @@ -30,7 +30,7 @@ export type StageMetadata = StageMetadataEntry[]; export type Session = { id: string; - protocolUID: string; + protocolId: string; promptIndex: number; currentStep: number; caseId: string; diff --git a/lib/interviewer/utils/loadExternalData.js b/lib/interviewer/utils/loadExternalData.js index cf5c4123..347c4c66 100644 --- a/lib/interviewer/utils/loadExternalData.js +++ b/lib/interviewer/utils/loadExternalData.js @@ -39,7 +39,7 @@ const convertCSVToJsonWithWorker = async (data) => { /** * Loads network data from assets and appends objectHash uids. * - * @param {string} protocolUID - UID of the protocol + * @param {string} protocolId - UID of the protocol * @param {string} fileName - Filename of the network assets * @returns {object} Network object in format { nodes, edges } * diff --git a/lib/network-exporters/formatters/graphml/createGraphML.js b/lib/network-exporters/formatters/graphml/createGraphML.js index 7be245a7..4f75bd22 100644 --- a/lib/network-exporters/formatters/graphml/createGraphML.js +++ b/lib/network-exporters/formatters/graphml/createGraphML.js @@ -79,7 +79,7 @@ const getGraphHeader = ( let metaAttributes = `nc:caseId="${sessionVariables[caseProperty]}" nc:sessionUUID="${sessionVariables[sessionProperty]}" nc:protocolName="${sessionVariables[protocolName]}" - nc:protocolUID="${sessionVariables[protocolProperty]}" + nc:protocolId="${sessionVariables[protocolProperty]}" nc:codebookHash="${sessionVariables[codebookHashProperty]}" nc:sessionExportTime="${sessionVariables[sessionExportTimeProperty]}"`; diff --git a/lib/network-exporters/formatters/session/generateOutputFiles.ts b/lib/network-exporters/formatters/session/generateOutputFiles.ts index b2fdb0ba..4aa8aa27 100644 --- a/lib/network-exporters/formatters/session/generateOutputFiles.ts +++ b/lib/network-exporters/formatters/session/generateOutputFiles.ts @@ -20,11 +20,11 @@ export const generateOutputFiles = const exportPromises: Promise[] = []; - Object.entries(unifiedSessions).forEach(([protocolUID, sessions]) => { + Object.entries(unifiedSessions).forEach(([protocolId, sessions]) => { sessions.forEach((session) => { // Skip if sessions don't have required sessionVariables - const protocol = protocols[protocolUID]!; + const protocol = protocols[protocolId]!; const prefix = getFilePrefix(session); exportFormats.forEach((format) => { diff --git a/lib/shared-consts/session.ts b/lib/shared-consts/session.ts index df812df8..8a56230e 100644 --- a/lib/shared-consts/session.ts +++ b/lib/shared-consts/session.ts @@ -1,6 +1,6 @@ export const caseProperty = 'caseId'; export const sessionProperty = 'sessionId'; -export const protocolProperty = 'protocolUID'; +export const protocolProperty = 'protocolId'; export const protocolName = 'protocolName'; export const sessionStartTimeProperty = 'sessionStart'; export const sessionFinishTimeProperty = 'sessionFinish'; From 23adc4c446993507b14708f18fe72c9898917db8 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 12 Dec 2024 21:43:40 +0200 Subject: [PATCH 14/15] WIP extensive rework of types for redux --- actions/interviews.ts | 16 +- .../interview/[interviewId]/page.tsx | 2 +- .../interview/_components/InterviewShell.tsx | 39 +-- .../Anonymisation/Anonymisation.tsx | 12 +- .../Anonymisation/useEncryptionState.ts | 66 +++++ .../Anonymisation/useNodeAttributes.tsx | 2 +- .../Anonymisation/usePassphrase.tsx | 151 ++++++++--- .../ducks/modules/activeSessionId.js | 31 --- .../ducks/modules/activeSessionId.ts | 65 +++++ lib/interviewer/ducks/modules/dialogs.ts | 17 +- .../ducks/modules/installedProtocols.js | 69 ----- .../ducks/modules/installedProtocols.ts | 32 +++ .../ducks/modules/{network.js => network.ts} | 175 ++++++++++--- .../ducks/modules/{session.js => session.ts} | 236 +++++++++--------- .../ducks/modules/setServerSession.ts | 27 +- lib/interviewer/ducks/modules/ui.js | 47 ---- lib/interviewer/ducks/modules/ui.ts | 54 ++++ lib/interviewer/selectors/protocol.ts | 9 +- lib/interviewer/selectors/session.ts | 12 +- lib/interviewer/store.ts | 68 ++--- lib/shared-consts/network.ts | 17 +- queries/interviews.ts | 21 +- schemas/interviews.ts | 4 +- schemas/network-canvas.ts | 33 --- 24 files changed, 721 insertions(+), 484 deletions(-) create mode 100644 lib/interviewer/containers/Interfaces/Anonymisation/useEncryptionState.ts delete mode 100644 lib/interviewer/ducks/modules/activeSessionId.js create mode 100644 lib/interviewer/ducks/modules/activeSessionId.ts delete mode 100644 lib/interviewer/ducks/modules/installedProtocols.js create mode 100644 lib/interviewer/ducks/modules/installedProtocols.ts rename lib/interviewer/ducks/modules/{network.js => network.ts} (71%) rename lib/interviewer/ducks/modules/{session.js => session.ts} (72%) delete mode 100644 lib/interviewer/ducks/modules/ui.js create mode 100644 lib/interviewer/ducks/modules/ui.ts delete mode 100644 schemas/network-canvas.ts diff --git a/actions/interviews.ts b/actions/interviews.ts index b2a3e4aa..e3893885 100644 --- a/actions/interviews.ts +++ b/actions/interviews.ts @@ -1,11 +1,12 @@ 'use server'; import { createId } from '@paralleldrive/cuid2'; -import { Prisma, type Interview, type Protocol } from '@prisma/client'; +import { Prisma, type Interview } from '@prisma/client'; import { cookies } from 'next/headers'; import trackEvent from '~/lib/analytics'; import { safeRevalidateTag } from '~/lib/cache'; -import type { InstalledProtocols } from '~/lib/interviewer/store'; +import { type ProtocolWithAssets } from '~/lib/interviewer/ducks/modules/setServerSession'; +import { type RootState } from '~/lib/interviewer/store'; import { formatExportableSessions } from '~/lib/network-exporters/formatters/formatExportableSessions'; import archive from '~/lib/network-exporters/formatters/session/archive'; import { generateOutputFiles } from '~/lib/network-exporters/formatters/session/generateOutputFiles'; @@ -17,6 +18,7 @@ import type { ExportReturn, FormattedSession, } from '~/lib/network-exporters/utils/types'; +import { type NcNetwork } from '~/lib/shared-consts'; import { getAppSetting } from '~/queries/appSettings'; import { getInterviewsForExport } from '~/queries/interviews'; import type { @@ -24,7 +26,6 @@ import type { DeleteInterviews, SyncInterview, } from '~/schemas/interviews'; -import { type NcNetwork } from '~/schemas/network-canvas'; import { requireApiAuth } from '~/utils/auth'; import { prisma } from '~/utils/db'; import { ensureError } from '~/utils/ensureError'; @@ -91,13 +92,14 @@ export const prepareExportData = async (interviewIds: Interview['id'][]) => { const interviewsSessions = await getInterviewsForExport(interviewIds); - const protocolsMap = new Map(); + const protocolsMap = new Map(); interviewsSessions.forEach((session) => { protocolsMap.set(session.protocol.hash, session.protocol); }); - const formattedProtocols: InstalledProtocols = + const formattedProtocols: RootState['installedProtocols'] = Object.fromEntries(protocolsMap); + const formattedSessions = formatExportableSessions(interviewsSessions); return { formattedSessions, formattedProtocols }; @@ -105,7 +107,7 @@ export const prepareExportData = async (interviewIds: Interview['id'][]) => { export const exportSessions = async ( formattedSessions: FormattedSession[], - formattedProtocols: InstalledProtocols, + formattedProtocols: RootState['installedProtocols'], interviewIds: Interview['id'][], exportOptions: ExportOptions, ): Promise => { @@ -260,7 +262,7 @@ export async function syncInterview(data: SyncInterview) { network, currentStep, stageMetadata, - lastUpdated: new Date(), + lastUpdated: new Date(), // TODO: this is present in the store - shouldn't we be using that value? }, }); diff --git a/app/(interview)/interview/[interviewId]/page.tsx b/app/(interview)/interview/[interviewId]/page.tsx index bb9b9021..62290ff1 100644 --- a/app/(interview)/interview/[interviewId]/page.tsx +++ b/app/(interview)/interview/[interviewId]/page.tsx @@ -43,7 +43,7 @@ export default async function Page({ return ( <> {session && } - + ); } diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index a1685c2c..3b587a2b 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -1,6 +1,9 @@ 'use client'; +import { parseAsInteger, useQueryState } from 'nuqs'; +import { useEffect, useState } from 'react'; import { Provider } from 'react-redux'; +import type { SyncInterviewType } from '~/actions/interviews'; import DialogManager from '~/lib/interviewer/components/DialogManager'; import ProtocolScreen from '~/lib/interviewer/containers/ProtocolScreen'; import { @@ -8,62 +11,60 @@ import { type SetServerSessionAction, } from '~/lib/interviewer/ducks/modules/setServerSession'; import { store } from '~/lib/interviewer/store'; -import ServerSync from './ServerSync'; -import { useEffect, useState } from 'react'; -import { parseAsInteger, useQueryState } from 'nuqs'; -import type { SyncInterviewType } from '~/actions/interviews'; import type { getInterviewById } from '~/queries/interviews'; +import ServerSync from './ServerSync'; // 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 = ({ - interview, + serverPayload, syncInterview, }: { - interview: Awaited>; + serverPayload: Awaited>; syncInterview: SyncInterviewType; }) => { const [initialized, setInitialized] = useState(false); const [currentStage, setCurrentStage] = useQueryState('step', parseAsInteger); useEffect(() => { - if (initialized || !interview) { + if (initialized || !serverPayload) { return; } - const { protocol, ...serverSession } = interview; - // 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; + void setCurrentStage(serverPayload.currentStep); + } else if (currentStage !== serverPayload.currentStep) { + serverPayload.currentStep = currentStage; } // If there's no current stage in the URL bar, set it. store.dispatch({ type: SET_SERVER_SESSION, - payload: { - protocol, - session: serverSession, - }, + payload: serverPayload, }); setInitialized(true); - }, [initialized, setInitialized, currentStage, setCurrentStage, interview]); + }, [ + initialized, + setInitialized, + currentStage, + setCurrentStage, + serverPayload, + ]); - if (!initialized || !interview) { + if (!initialized || !serverPayload) { return null; } return ( - + diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx index cb0f14f9..3977cd0c 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx +++ b/lib/interviewer/containers/Interfaces/Anonymisation/Anonymisation.tsx @@ -3,6 +3,7 @@ import { type AnonymisationStage } from '~/lib/protocol-validation/schemas/src/8 import { Markdown } from '~/lib/ui/components/Fields'; import EncryptionBackground from '../../../components/EncryptedBackground'; import { type StageProps } from '../../Stage'; +import { usePassphrase } from './usePassphrase'; type AnonymisationProps = StageProps & { stage: AnonymisationStage; @@ -11,6 +12,7 @@ type AnonymisationProps = StageProps & { const THRESHOLD_POSITION = 25; export default function Anonymisation(props: AnonymisationProps) { + const { requirePassphrase, isEnabled, isPrompting } = usePassphrase(); // const dispatch = useDispatch(); // const openDialog = useCallback( // (dialog: Dialog) => @@ -61,9 +63,13 @@ export default function Anonymisation(props: AnonymisationProps) { 'linear-gradient(rgba(255,255, 255, 0) 40%, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0) 60%)', }} > -

+
diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/useEncryptionState.ts b/lib/interviewer/containers/Interfaces/Anonymisation/useEncryptionState.ts new file mode 100644 index 00000000..43324fe0 --- /dev/null +++ b/lib/interviewer/containers/Interfaces/Anonymisation/useEncryptionState.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useState } from 'react'; + +// Define a type for the encryption state +type EncryptionState = { + isEnabled: boolean; + passphrase: string | null; +}; + +// Create a singleton instance to store the state +const globalState: { + state: EncryptionState; + listeners: Set<(state: EncryptionState) => void>; +} = { + state: { + isEnabled: false, + passphrase: null, + }, + listeners: new Set(), +}; + +export const useEncryptionState = () => { + // Local state that mirrors the global state + const [state, setState] = useState(globalState.state); + + useEffect(() => { + // Subscribe to changes + const listener = (newState: EncryptionState) => { + setState(newState); + }; + + globalState.listeners.add(listener); + + // Initial state sync + setState(globalState.state); + + // Cleanup + return () => { + globalState.listeners.delete(listener); + }; + }, []); + + const enableEncryption = useCallback((passphrase: string) => { + const newState = { + isEnabled: true, + passphrase, + }; + globalState.state = newState; + globalState.listeners.forEach((listener) => listener(newState)); + }, []); + + const disableEncryption = useCallback(() => { + const newState = { + isEnabled: false, + passphrase: null, + }; + globalState.state = newState; + globalState.listeners.forEach((listener) => listener(newState)); + }, []); + + return { + isEnabled: state.isEnabled, + passphrase: state.passphrase, + enableEncryption, + disableEncryption, + }; +}; diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx index 5b9dd725..1b2b28b1 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx +++ b/lib/interviewer/containers/Interfaces/Anonymisation/useNodeAttributes.tsx @@ -21,7 +21,7 @@ export const useNodeAttributes = ( getCodebookVariablesForNodeType(node.type), ); const nodeAttributes = getEntityAttributes(node); - const requirePassphrase = usePassphrase(); + const { requirePassphrase } = usePassphrase(); const getById = async ( attributeId: string, diff --git a/lib/interviewer/containers/Interfaces/Anonymisation/usePassphrase.tsx b/lib/interviewer/containers/Interfaces/Anonymisation/usePassphrase.tsx index d417077d..75a7b62b 100644 --- a/lib/interviewer/containers/Interfaces/Anonymisation/usePassphrase.tsx +++ b/lib/interviewer/containers/Interfaces/Anonymisation/usePassphrase.tsx @@ -1,57 +1,128 @@ -import { UnauthorizedError } from './utils'; +import { useCallback, useEffect, useState } from 'react'; -declare global { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Window { - getting: boolean; - passphrase: string | null; +export class UnauthorizedError extends Error { + constructor() { + super('Unauthorized: No passphrase provided'); + this.name = 'UnauthorizedError'; } } -// Custom hook that allows for storing and retriving a passphrase from session storage. -// Emits a custom event when the passphrase is set, and triggers a dialog if the passphrase -// is not set. -export function usePassphrase() { - // const dispatch = useDispatch(); - // const openDialog = useCallback( - // (dialog: Dialog) => - // dispatch(dialogActions.openDialog(dialog) as unknown as AnyAction), - // [dispatch], - // ); - - async function requirePassphrase(): Promise { - if (window.passphrase) { - return Promise.resolve(window.passphrase); +type EncryptionState = { + isEnabled: boolean; + passphrase: string | null; + isPrompting: boolean; +}; + +// Singleton to store the global state and promise management +const globalState: { + state: EncryptionState; + listeners: Set<(state: EncryptionState) => void>; + currentPromise: Promise | null; +} = { + state: { + isEnabled: false, + passphrase: null, + isPrompting: false, + }, + listeners: new Set(), + currentPromise: null, +}; + +const updateState = (newState: Partial) => { + globalState.state = { ...globalState.state, ...newState }; + globalState.listeners.forEach((listener) => listener(globalState.state)); +}; + +export const usePassphrase = () => { + const [state, setState] = useState(globalState.state); + + useEffect(() => { + const listener = (newState: EncryptionState) => { + setState(newState); + }; + + globalState.listeners.add(listener); + setState(globalState.state); + + return () => { + globalState.listeners.delete(listener); + }; + }, []); + + const requirePassphrase = useCallback(async (): Promise => { + // If we already have a passphrase, return it + if (globalState.state.passphrase) { + return globalState.state.passphrase; } - if (window.getting) { - // Return a promise that resolves based on events from the dialog. - return new Promise((resolve, reject) => { - window.addEventListener('passphrase-set', () => { - resolve(window.passphrase!); - }); + // If we're already prompting, return the existing promise + if (globalState.currentPromise) { + return globalState.currentPromise; + } - window.addEventListener('passphrase-cancel', () => { - reject(new UnauthorizedError()); - }); + // Create a new promise for the passphrase prompt + globalState.currentPromise = new Promise((resolve, reject) => { + updateState({ isPrompting: true }); + + // We're using prompt here, but you could replace this with a custom modal + const userInput = prompt('Please enter your passphrase to continue.'); + + updateState({ isPrompting: false }); + + if (!userInput) { + const error = new UnauthorizedError(); + window.dispatchEvent( + new CustomEvent('passphrase-cancel', { detail: error }), + ); + reject(error); + return; + } + + updateState({ + passphrase: userInput, + isEnabled: true, }); - } - window.getting = true; + window.dispatchEvent( + new CustomEvent('passphrase-set', { detail: userInput }), + ); + resolve(userInput); + }).finally(() => { + globalState.currentPromise = null; + }); - window.passphrase = prompt('Please enter your passphrase to continue.'); + return globalState.currentPromise; + }, []); - window.getting = false; + const clearPassphrase = useCallback(() => { + updateState({ + passphrase: null, + isEnabled: false, + }); + window.dispatchEvent(new CustomEvent('passphrase-cleared')); + }, []); - if (!window.passphrase) { - window.dispatchEvent(new CustomEvent('passphrase-cancel')); + const setPassphrase = useCallback((newPassphrase: string) => { + if (!newPassphrase) { throw new UnauthorizedError(); } - window.dispatchEvent(new CustomEvent('passphrase-set')); + updateState({ + passphrase: newPassphrase, + isEnabled: true, + }); - return Promise.resolve(window.passphrase); - } + window.dispatchEvent( + new CustomEvent('passphrase-set', { detail: newPassphrase }), + ); + }, []); - return requirePassphrase; -} + return { + isEnabled: state.isEnabled, + passphrase: state.passphrase, + isPrompting: state.isPrompting, + requirePassphrase, + clearPassphrase, + setPassphrase, + }; +}; diff --git a/lib/interviewer/ducks/modules/activeSessionId.js b/lib/interviewer/ducks/modules/activeSessionId.js deleted file mode 100644 index a708085c..00000000 --- a/lib/interviewer/ducks/modules/activeSessionId.js +++ /dev/null @@ -1,31 +0,0 @@ -import { actionTypes as SessionsActionTypes } from './session'; -import { actionTypes as installedProtocolsActionTypes } from './installedProtocols'; -import { SET_SERVER_SESSION } from './setServerSession'; - -const { ADD_SESSION } = SessionsActionTypes; -const SET_SESSION = 'SET_SESSION'; -const END_SESSION = 'END_SESSION'; - -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; - case END_SESSION: - case installedProtocolsActionTypes.DELETE_PROTOCOL: - return initialState; - default: - return state; - } -} \ No newline at end of file diff --git a/lib/interviewer/ducks/modules/activeSessionId.ts b/lib/interviewer/ducks/modules/activeSessionId.ts new file mode 100644 index 00000000..bd4063ee --- /dev/null +++ b/lib/interviewer/ducks/modules/activeSessionId.ts @@ -0,0 +1,65 @@ +import { type Session } from '../../store'; +import { actionTypes as installedProtocolsActionTypes } from './installedProtocols'; +import { SET_SERVER_SESSION } from './setServerSession'; + +type SetServerSessionAction = { + type: typeof SET_SERVER_SESSION; + session: Session; +}; + +type SetSessionAction = { + type: 'SET_SESSION'; + sessionId: string; +}; + +type EndSessionAction = { + type: 'END_SESSION'; +}; + +type DeleteProtocolAction = { + type: typeof installedProtocolsActionTypes.DELETE_PROTOCOL; +}; + +type SessionActionTypes = + | SetServerSessionAction + | SetSessionAction + | EndSessionAction + | DeleteProtocolAction; + +// Initial State +const initialState: Session['id'] | null = null; + +// Reducer +export default function sessionReducer( + state = initialState, + action: SessionActionTypes, +): Session['id'] | null { + switch (action.type) { + case SET_SERVER_SESSION: { + if (!action.session) { + return state; + } + return action.session.id; + } + + case 'SET_SESSION': + return action.sessionId; + + case 'END_SESSION': + case installedProtocolsActionTypes.DELETE_PROTOCOL: + return initialState; + + default: + return state; + } +} + +// Action Creators +export const setSession = (sessionId: string): SetSessionAction => ({ + type: 'SET_SESSION', + sessionId, +}); + +export const endSession = (): EndSessionAction => ({ + type: 'END_SESSION', +}); diff --git a/lib/interviewer/ducks/modules/dialogs.ts b/lib/interviewer/ducks/modules/dialogs.ts index 316b7fd7..ef38eb52 100644 --- a/lib/interviewer/ducks/modules/dialogs.ts +++ b/lib/interviewer/ducks/modules/dialogs.ts @@ -5,12 +5,19 @@ import { v4 as uuid } from 'uuid'; const OPEN_DIALOG = Symbol('PROTOCOL/OPEN_DIALOG'); const CLOSE_DIALOG = Symbol('PROTOCOL/CLOSE_DIALOG'); -type Action = { - type: typeof OPEN_DIALOG | typeof CLOSE_DIALOG; +type OpenDialogAction = { + type: typeof OPEN_DIALOG; id: string; - dialog?: Dialog; + dialog: Omit; }; +type CloseDialogAction = { + type: typeof CLOSE_DIALOG; + id: Dialog['id']; +}; + +type Action = OpenDialogAction | CloseDialogAction; + export type Dialog = { id: string; type: 'Confirm' | 'Notice' | 'Warning' | 'Error'; @@ -69,7 +76,7 @@ const openDialog = (dialog: Dialog) => (dispatch: Dispatch) => resolve(false); }; - dispatch({ + dispatch({ id: uuid(), type: OPEN_DIALOG, dialog: { @@ -85,7 +92,7 @@ const closeDialog = (id: Dialog['id']) => ({ id, }); -function reducer(state = initialState, action: Action) { +function reducer(state = initialState, action: Action): Dialog[] { switch (action.type) { case OPEN_DIALOG: return [...state, { id: action.id, ...action.dialog }]; diff --git a/lib/interviewer/ducks/modules/installedProtocols.js b/lib/interviewer/ducks/modules/installedProtocols.js deleted file mode 100644 index dd5b2c79..00000000 --- a/lib/interviewer/ducks/modules/installedProtocols.js +++ /dev/null @@ -1,69 +0,0 @@ -import { findKey, omit } from 'es-toolkit'; -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 = {}; - -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.protocolId]); - case IMPORT_PROTOCOL_COMPLETE: { - const newProtocol = action.protocolData; - - // If the protocol name (which is the true UID of protocol) already exists, - // overwrite. We only get here after user has confirmed. - const existingIndex = findKey( - state, - (protocol) => protocol.name === newProtocol.name, - ); - - if (existingIndex) { - return { - ...state, - [existingIndex]: { - ...omit(newProtocol, 'uid'), - installationDate: Date.now(), - }, - }; - } - - return { - ...state, - [newProtocol.uid]: { - ...omit(newProtocol, 'uid'), - installationDate: Date.now(), - }, - }; - } - default: - return state; - } -} - -const actionTypes = { - DELETE_PROTOCOL, - IMPORT_PROTOCOL_COMPLETE, - IMPORT_PROTOCOL_FAILED, -}; - -export { actionTypes }; diff --git a/lib/interviewer/ducks/modules/installedProtocols.ts b/lib/interviewer/ducks/modules/installedProtocols.ts new file mode 100644 index 00000000..fa400d1c --- /dev/null +++ b/lib/interviewer/ducks/modules/installedProtocols.ts @@ -0,0 +1,32 @@ +import { omit } from 'es-toolkit'; +import { + type ProtocolWithAssets, + SET_SERVER_SESSION, + type SetServerSessionAction, +} from './setServerSession'; + +const initialState = {} as Record; + +type Actions = SetServerSessionAction; + +export default function reducer(state = initialState, action: Actions) { + 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']), + }, + }; + } + default: + return state; + } +} diff --git a/lib/interviewer/ducks/modules/network.js b/lib/interviewer/ducks/modules/network.ts similarity index 71% rename from lib/interviewer/ducks/modules/network.js rename to lib/interviewer/ducks/modules/network.ts index 3e5e4efb..6ee1ab35 100644 --- a/lib/interviewer/ducks/modules/network.js +++ b/lib/interviewer/ducks/modules/network.ts @@ -3,29 +3,33 @@ import { find, get, isMatch } from 'es-toolkit/compat'; import { v4 as uuid } from 'uuid'; import { entityAttributesProperty, + type EntityPrimaryKey, entityPrimaryKeyProperty, + type NcEdge, + type NcNetwork, + type NcNode, } from '~/lib/shared-consts'; -import { SET_SERVER_SESSION } from './setServerSession'; /* * For actionCreators see `src/ducks/modules/sessions` */ -const ADD_NODE = 'ADD_NODE'; -const ADD_NODE_TO_PROMPT = 'ADD_NODE_TO_PROMPT'; -const BATCH_ADD_NODES = 'BATCH_ADD_NODES'; -const REMOVE_NODE = 'REMOVE_NODE'; -const REMOVE_NODE_FROM_PROMPT = 'REMOVE_NODE_FROM_PROMPT'; -const UPDATE_NODE = 'UPDATE_NODE'; -const TOGGLE_NODE_ATTRIBUTES = 'TOGGLE_NODE_ATTRIBUTES'; -const ADD_EDGE = 'ADD_EDGE'; -const UPDATE_EDGE = 'UPDATE_EDGE'; -const TOGGLE_EDGE = 'TOGGLE_EDGE'; -const REMOVE_EDGE = 'REMOVE_EDGE'; -const UPDATE_EGO = 'UPDATE_EGO'; -const ADD_SESSION = 'ADD_SESSION'; +const INITIALIZE = 'NETWORK/INITIALIZE' as const; +const ADD_NODE = 'NETWORK/ADD_NODE' as const; +const ADD_NODE_TO_PROMPT = 'NETWORK/ADD_NODE_TO_PROMPT' as const; +const BATCH_ADD_NODES = 'NETWORK/BATCH_ADD_NODES' as const; +const REMOVE_NODE = 'NETWORK/REMOVE_NODE' as const; +const REMOVE_NODE_FROM_PROMPT = 'NETWORK/REMOVE_NODE_FROM_PROMPT' as const; +const UPDATE_NODE = 'NETWORK/UPDATE_NODE' as const; +const TOGGLE_NODE_ATTRIBUTES = 'NETWORK/TOGGLE_NODE_ATTRIBUTES' as const; +const ADD_EDGE = 'NETWORK/ADD_EDGE' as const; +const UPDATE_EDGE = 'NETWORK/UPDATE_EDGE' as const; +const TOGGLE_EDGE = 'NETWORK/TOGGLE_EDGE' as const; +const REMOVE_EDGE = 'NETWORK/REMOVE_EDGE' as const; +const UPDATE_EGO = 'NETWORK/UPDATE_EGO' as const; +const ADD_SESSION = 'NETWORK/ADD_SESSION' as const; // Initial network model structure -const initialState = { +export const initialState: NcNetwork = { ego: { [entityPrimaryKeyProperty]: uuid(), [entityAttributesProperty]: {}, @@ -44,9 +48,9 @@ const initialState = { * @param defaultAttributes Is added before other attributes */ const batchAddNodes = ( - nodeList, - attributeData = {}, - defaultAttributes = {}, + nodeList: NcNode[], + attributeData: NcNode['attributes'] = {}, + defaultAttributes: NcNode['attributes'] = {}, ) => ({ type: BATCH_ADD_NODES, nodeList, @@ -55,8 +59,7 @@ const batchAddNodes = ( }); // reducer helpers: - -function flipEdge(edge) { +function flipEdge(edge: Partial) { return { from: edge.to, to: edge.from, type: edge.type }; } @@ -74,15 +77,17 @@ function flipEdge(edge) { * } * */ -export function edgeExists(edges, from, to, type) { +export function edgeExists( + edges: NcEdge[], + from: NcEdge['from'], + to: NcEdge['to'], + type: NcEdge['type'], +): NcEdge[EntityPrimaryKey] | false { const forwardsEdge = find(edges, { from, to, type }); const reverseEdge = find(edges, flipEdge({ from, to, type })); - if ( - (forwardsEdge && forwardsEdge !== -1) || - (reverseEdge && reverseEdge !== -1) - ) { - const foundEdge = forwardsEdge || reverseEdge; + if (forwardsEdge ?? reverseEdge) { + const foundEdge = (forwardsEdge ?? reverseEdge)!; return get(foundEdge, entityPrimaryKeyProperty); } @@ -142,14 +147,117 @@ const removeEdge = (state, edgeId) => ({ ), }); -export default function reducer(state = initialState, action = {}) { - switch (action.type) { - case SET_SERVER_SESSION: { - if (!action.payload.session.network) { - return state; - } +export type ModelData = { + [entityPrimaryKeyProperty]: string; + promptId?: string[]; +}; + +export type AddNodeAction = { + type: typeof ADD_NODE; + sessionId: string; + payload: { + modelData: ModelData; + attributeData: NcNode['attributes']; + }; +}; + +type UpdateNodeAction = { + type: typeof UPDATE_NODE; + nodeId: NcNode[EntityPrimaryKey]; + newModelData: ModelData; + newAttributeData: NcNode['attributes']; +}; + +type ToggleNodeAttributesAction = { + type: typeof TOGGLE_NODE_ATTRIBUTES; + [entityPrimaryKeyProperty]: NcNode[EntityPrimaryKey]; + attributes: NcNode['attributes']; +}; + +type RemoveNodeAction = { + type: typeof REMOVE_NODE; + [entityPrimaryKeyProperty]: NcNode[EntityPrimaryKey]; +}; + +type AddNodeToPromptAction = { + type: typeof ADD_NODE_TO_PROMPT; + nodeId: NcNode[EntityPrimaryKey]; + promptId: string; + promptAttributes: NcNode['attributes']; +}; + +type RemoveNodeFromPromptAction = { + type: typeof REMOVE_NODE_FROM_PROMPT; + nodeId: NcNode[EntityPrimaryKey]; + promptId: string; + promptAttributes: NcNode['attributes']; +}; - return action.session.network; +type BatchAddNodesAction = { + type: typeof BATCH_ADD_NODES; + nodeList: NcNode[]; + defaultAttributes: NcNode['attributes']; + attributeData: NcNode['attributes']; +}; + +type AddEdgeAction = { + type: typeof ADD_EDGE; + modelData: NcEdge; + attributeData: NcEdge['attributes']; +}; + +type UpdateEdgeAction = { + type: typeof UPDATE_EDGE; + edgeId: NcEdge[EntityPrimaryKey]; + newModelData: NcEdge; + newAttributeData: NcEdge['attributes']; +}; + +type ToggleEdgeAction = { + type: typeof TOGGLE_EDGE; + modelData: NcEdge; +}; + +type RemoveEdgeAction = { + type: typeof REMOVE_EDGE; + edgeId: NcEdge[EntityPrimaryKey]; +}; + +type UpdateEgoAction = { + type: typeof UPDATE_EGO; + modelData: NcNode; + attributeData: NcNode['attributes']; +}; + +type AddSessionAction = { + type: typeof ADD_SESSION; + egoAttributeData: NcNode['attributes']; +}; + +type InitializeAction = { + type: typeof INITIALIZE; +}; + +export type NetworkActions = + | InitializeAction + | AddNodeAction + | UpdateNodeAction + | ToggleNodeAttributesAction + | RemoveNodeAction + | AddNodeToPromptAction + | RemoveNodeFromPromptAction + | BatchAddNodesAction + | AddEdgeAction + | UpdateEdgeAction + | ToggleEdgeAction + | RemoveEdgeAction + | UpdateEgoAction + | AddSessionAction; + +export default function reducer(state = initialState, action: NetworkActions) { + switch (action.type) { + case INITIALIZE: { + return initialState; } case ADD_NODE: { return { @@ -364,6 +472,7 @@ export default function reducer(state = initialState, action = {}) { } const actionTypes = { + INITIALIZE, ADD_NODE, ADD_NODE_TO_PROMPT, BATCH_ADD_NODES, diff --git a/lib/interviewer/ducks/modules/session.js b/lib/interviewer/ducks/modules/session.ts similarity index 72% rename from lib/interviewer/ducks/modules/session.js rename to lib/interviewer/ducks/modules/session.ts index 6a2b2a6c..1438d32f 100644 --- a/lib/interviewer/ducks/modules/session.js +++ b/lib/interviewer/ducks/modules/session.ts @@ -1,67 +1,81 @@ +import { type Protocol } from '@prisma/client'; +import { type Dispatch } from '@reduxjs/toolkit'; import { omit } from 'es-toolkit'; import { has } from 'es-toolkit/compat'; import { v4 as uuid } from 'uuid'; -import { entityPrimaryKeyProperty } from '~/lib/shared-consts'; -import { actionTypes as installedProtocolsActionTypes } from './installedProtocols'; +import { + entityPrimaryKeyProperty, + type EntityAttributesProperty, + type EntityPrimaryKey, + type EntityTypeDefinition, + type NcNode, +} from '~/lib/shared-consts'; +import { getPromptId } from '../../selectors/interface'; +import { + getCodebookVariablesForNodeType, + getProtocolCodebook, +} from '../../selectors/protocol'; +import { getActiveSession } from '../../selectors/session'; +import { type GetState, type Session } from '../../store'; import networkReducer, { actionTypes as networkActionTypes, actionCreators as networkActions, + type AddNodeAction, + type ModelData, + type NetworkActions, } from './network'; import { SET_SERVER_SESSION } from './setServerSession'; const ADD_SESSION = 'ADD_SESSION'; const SET_SESSION_FINISHED = 'SET_SESSION_FINISHED'; -const SET_SESSION_EXPORTED = 'SET_SESSION_EXPORTED'; -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_METADATA = 'UPDATE_STAGE_METADATA'; -const REMOVE_SESSION = 'REMOVE_SESSION'; -const initialState = {}; +const initialState = {} as Record; -const withTimestamp = (session) => ({ +const withTimestamp = (session: SessionWithoutId): SessionWithoutId => ({ ...session, - updatedAt: Date.now(), + lastUpdated: new Date(), }); -const sessionExists = (sessionId, sessions) => has(sessions, sessionId); +const sessionExists = ( + sessionId: Session['id'], + sessions: SessionWithoutId[], +) => has(sessions, sessionId); + +type setServerSessionAction = { + type: typeof SET_SERVER_SESSION; + payload: { + protocol: Protocol; + session: Session; + }; +}; + +type Action = setServerSessionAction | NetworkActions; +export type SessionWithoutId = Omit; const getReducer = - (network) => - (state = initialState, action = {}) => { + (network: typeof networkReducer) => + (state = initialState, action: Action): Record => { switch (action.type) { case SET_SERVER_SESSION: { if (!action.payload.session) { return state; } - const { - session: { id }, - } = action.payload; - const { - protocol: { id: protocolId }, - } = action.payload; - return { ...state, - [id]: { - protocolId, - ...action.payload.session, + [action.payload.session.id]: { + ...(omit(action.payload.session, ['id']) as SessionWithoutId), network: - action.payload.session.network ?? network(state.network, action), - stageMetadata: action.payload.session.stageMetadata ?? {}, + action.payload.session.network ?? + network(undefined, { type: networkActionTypes.INITIALIZE }), + stageMetadata: action.payload.session.stageMetadata, }, }; } - case installedProtocolsActionTypes.DELETE_PROTOCOL: - return Object.keys(state).reduce((result, sessionData, sessionId) => { - if (sessionData.protocolId !== action.protocolId) { - return { ...result, [sessionId]: sessionData }; - } - return result; - }, {}); + // Whenever a network action occurs, pass the action through to the network reducer case networkActionTypes.ADD_NODE: case networkActionTypes.ADD_NODE_TO_PROMPT: case networkActionTypes.BATCH_ADD_NODES: @@ -74,34 +88,21 @@ const getReducer = case networkActionTypes.TOGGLE_EDGE: case networkActionTypes.REMOVE_EDGE: case networkActionTypes.UPDATE_EGO: { - if (!sessionExists(action.sessionId, state)) { - return state; - } + const third = state[action.sessionId]!.network; + const test = action; + const newNetwork = network(third, action); + return { ...state, [action.sessionId]: withTimestamp({ ...state[action.sessionId], // Reset finished and exported state if network changes - finishedAt: null, - exportedAt: null, + finishTime: null, + exportTime: null, network: network(state[action.sessionId].network, action), }), }; } - case ADD_SESSION: - return { - ...state, - [action.sessionId]: withTimestamp({ - protocolId: action.protocolId, - promptIndex: 0, - currentStep: null, - caseId: action.caseId, - network: action.network - ? action.network - : network(state.network, action), - startedAt: Date.now(), - }), - }; case SET_SESSION_FINISHED: { if (!sessionExists(action.sessionId, state)) { return state; @@ -110,11 +111,10 @@ const getReducer = ...state, [action.sessionId]: withTimestamp({ ...state[action.sessionId], - finishedAt: Date.now(), + finishTime: new Date(), }), }; } - case SET_SESSION_EXPORTED: { if (!sessionExists(action.sessionId, state)) { return state; @@ -123,12 +123,10 @@ const getReducer = ...state, [action.sessionId]: { ...state[action.sessionId], - exportedAt: Date.now(), + exportTime: new Date(), }, }; } - case LOAD_SESSION: - return state; case UPDATE_PROMPT: { if (!sessionExists(action.sessionId, state)) { return state; @@ -154,18 +152,6 @@ const getReducer = }), }; } - case UPDATE_CASE_ID: { - if (!sessionExists(action.sessionId, state)) { - return state; - } - return { - ...state, - [action.sessionId]: withTimestamp({ - ...state[action.sessionId], - caseId: action.caseId, - }), - }; - } case UPDATE_STAGE_METADATA: { if (!sessionExists(action.sessionId, state)) { return state; @@ -182,8 +168,6 @@ const getReducer = }), }; } - case REMOVE_SESSION: - return omit(state, [action.sessionId]); default: return state; } @@ -197,8 +181,10 @@ const getReducer = * node type. */ -const getDefaultAttributesForEntityType = (registryForType = {}) => { - const defaultAttributesObject = {}; +const getDefaultAttributesForEntityType = ( + registryForType: EntityTypeDefinition['variables'] = {}, +) => { + const defaultAttributesObject = {} as Record; // ALL variables initialised as `null` Object.keys(registryForType).forEach((variableUUID) => { @@ -213,14 +199,15 @@ const getDefaultAttributesForEntityType = (registryForType = {}) => { * to it. * @param {object} action redux action object */ -const withActiveSessionId = (action) => (dispatch, getState) => { - const { activeSessionId: sessionId } = getState(); +const withActiveSessionId = + (action: Action) => (dispatch: Dispatch, getState: GetState) => { + const { activeSessionId: sessionId } = getState(); - dispatch({ - ...action, - sessionId, - }); -}; + dispatch({ + ...action, + sessionId, + }); + }; /** * Add a batch of nodes to the state. @@ -233,7 +220,12 @@ const withActiveSessionId = (action) => (dispatch, getState) => { * TODO: is `type` superfluous as contained by nodes in nodeList? */ const batchAddNodes = - (nodeList, attributeData, type) => (dispatch, getState) => { + ( + nodeList: NcNode[], + attributeData: NcNode['attributes'], + type: NcNode['type'], + ) => + (dispatch: Dispatch, getState: GetState) => { const { activeSessionId, sessions, installedProtocols } = getState(); const session = sessions[activeSessionId]; @@ -255,30 +247,61 @@ const batchAddNodes = }; const addNode = - (modelData, attributeData = {}) => - (dispatch, getState) => { - const { activeSessionId, sessions, installedProtocols } = getState(); + ( + modelData: ModelData, + attributeData: NcNode[EntityAttributesProperty] = {}, + ) => + (dispatch: Dispatch, getState: GetState) => { + const state = getState(); + const { activeSessionId, sessions, installedProtocols } = state; + + const activeSession = getActiveSession(state); + + const codebook = getProtocolCodebook(state); + const registryForType = getCodebookVariablesForNodeType(modelData.type); + + // We need to create the `entitySecureAttribtuesMeta` object for the node + // for any attributes that have `encrypted` set to true in the codebook. + + const modelDataWithSecureAttributes = Object.keys(registryForType).reduce( + (acc, key) => { + if (registryForType[key].encrypted) { + return { + ...acc, + [key]: { + value: modelData[key], + encrypted: true, + }, + }; + } + return { + ...acc, + [key]: modelData[key], + }; + }, + {}, + ); - const activeProtocol = - installedProtocols[sessions[activeSessionId].protocolId]; - const nodeRegistry = activeProtocol.codebook.node; + console.log('modelDataWithSecureAttributes', modelDataWithSecureAttributes); - const registryForType = nodeRegistry[modelData.type].variables; + // Inject the current promptId into the model data. - dispatch({ + dispatch({ type: networkActionTypes.ADD_NODE, - sessionId: activeSessionId, - modelData, - attributeData: { - ...getDefaultAttributesForEntityType(registryForType), - ...attributeData, + sessionId: activeSessionId!, + payload: { + modelData, + attributeData: { + ...getDefaultAttributesForEntityType(registryForType), + ...attributeData, + }, }, }); }; const updateNode = - (nodeId, newModelData = {}, newAttributeData = {}, sound) => - (dispatch, getState) => { + (nodeId: EntityPrimaryKey, newModelData = {}, newAttributeData = {}, sound) => + (dispatch: Dispatch, getState: GetState) => { const { activeSessionId } = getState(); dispatch({ @@ -292,8 +315,13 @@ const updateNode = }; const addNodeToPrompt = - (nodeId, promptId, promptAttributes) => (dispatch, getState) => { - const { activeSessionId } = getState(); + (nodeId: EntityPrimaryKey, promptAttributes: Record) => + (dispatch: Dispatch, getState: GetState) => { + const state = getState(); + const { activeSessionId } = state; + const promptId = getPromptId(state); + + // fetch dispatch({ type: networkActionTypes.ADD_NODE_TO_PROMPT, @@ -447,22 +475,6 @@ const addSession = }); }; -const updateCaseId = (caseId) => (dispatch, getState) => { - const { activeSessionId } = getState(); - - dispatch({ - type: UPDATE_CASE_ID, - sessionId: activeSessionId, - caseId, - }); -}; - -const loadSession = () => (dispatch) => { - dispatch({ - type: LOAD_SESSION, - }); -}; - const updatePrompt = (promptIndex) => (dispatch, getState) => { const state = getState(); const sessionId = state.activeSessionId; diff --git a/lib/interviewer/ducks/modules/setServerSession.ts b/lib/interviewer/ducks/modules/setServerSession.ts index b51b6185..781fcf5b 100644 --- a/lib/interviewer/ducks/modules/setServerSession.ts +++ b/lib/interviewer/ducks/modules/setServerSession.ts @@ -1,28 +1,11 @@ -import { type Prisma, type Protocol } from '@prisma/client'; -// import type { Protocol } from '~/lib/shared-consts'; -// import type { ServerSession } from '~/app/(interview)/interview/[interviewId]/page'; +import { type getInterviewById } from '~/queries/interviews'; +export const SET_SERVER_SESSION = 'INIT/SET_SERVER_SESSION' as const; -// temporarily declaring this type -// Todo: check if you can import this type from anywhere -type ServerSession = { - id: string; - startTime: Date; - finishTime: Date | null; - exportTime: Date | null; - lastUpdated: Date; - network: Prisma.JsonValue; - participantId: string; - protocolId: string; - currentStep: number; - sessionMetadata?: Prisma.JsonValue; -}; +type Payload = NonNullable>>; -export const SET_SERVER_SESSION = 'INIT/SET_SERVER_SESSION'; +export type ProtocolWithAssets = Omit; export type SetServerSessionAction = { type: typeof SET_SERVER_SESSION; - payload: { - protocol: Protocol; - session: ServerSession; - }; + payload: Payload; }; diff --git a/lib/interviewer/ducks/modules/ui.js b/lib/interviewer/ducks/modules/ui.js deleted file mode 100644 index b7eb3b6c..00000000 --- a/lib/interviewer/ducks/modules/ui.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Global UI state - */ - -const initialState = { - settingsMenuOpen: false, -}; - -const UPDATE = 'UI/UPDATE'; -const TOGGLE = 'UI/TOGGLE'; - -export default function reducer(state = initialState, action = {}) { - switch (action.type) { - case UPDATE: - return { - ...state, - ...action.state, - }; - case TOGGLE: - return { - ...state, - [action.item]: !state[action.item], - }; - default: - return state; - } -} - -const update = (state) => ({ - type: UPDATE, - state, -}); - -const toggle = (item) => ({ - type: TOGGLE, - item, -}); - -const actionCreators = { - update, - toggle, -}; - - -export { - actionCreators, -}; diff --git a/lib/interviewer/ducks/modules/ui.ts b/lib/interviewer/ducks/modules/ui.ts new file mode 100644 index 00000000..68a2c213 --- /dev/null +++ b/lib/interviewer/ducks/modules/ui.ts @@ -0,0 +1,54 @@ +/* + * Global UI state + */ + +const initialState = {} as Record; + +const UPDATE = 'UI/UPDATE' as const; +const TOGGLE = 'UI/TOGGLE' as const; + +type UpdateAction = { + type: typeof UPDATE; + state: Record; +}; + +type ToggleAction = { + type: typeof TOGGLE; + item: string; +}; + +type Action = UpdateAction | ToggleAction; + +export default function reducer(state = initialState, action: Action) { + switch (action.type) { + case UPDATE: + return { + ...state, + ...action.state, + }; + case TOGGLE: + return { + ...state, + [action.item]: !state[action.item], + }; + default: + return state; + } +} + +const update = (state: Record) => ({ + type: UPDATE, + state, +}); + +const toggle = (item: string) => ({ + type: TOGGLE, + item, +}); + +const actionCreators = { + update, + toggle, +}; + +export { actionCreators }; diff --git a/lib/interviewer/selectors/protocol.ts b/lib/interviewer/selectors/protocol.ts index 727d3bce..db41b157 100644 --- a/lib/interviewer/selectors/protocol.ts +++ b/lib/interviewer/selectors/protocol.ts @@ -19,8 +19,13 @@ const DefaultFinishStage = { label: 'Finish Interview', }; -const getActiveSession = (state: RootState) => - state.sessions[state.activeSessionId]; +const getActiveSession = (state: RootState) => { + const activeSessionId = state.activeSessionId; + + if (!activeSessionId) return undefined; + + return state.sessions[activeSessionId]; +}; const getInstalledProtocols = (state: RootState) => state.installedProtocols; diff --git a/lib/interviewer/selectors/session.ts b/lib/interviewer/selectors/session.ts index ea71b0c8..394f7e15 100644 --- a/lib/interviewer/selectors/session.ts +++ b/lib/interviewer/selectors/session.ts @@ -1,6 +1,6 @@ import { createSelector } from '@reduxjs/toolkit'; import type { Stage } from '~/lib/shared-consts'; -import type { RootState } from '../store'; +import { type RootState } from '../store'; import { getProtocolStages } from './protocol'; const getActiveSessionId = (state: RootState) => state.activeSessionId; @@ -11,11 +11,14 @@ export const getActiveSession = createSelector( getActiveSessionId, getSessions, (activeSessionId, sessions) => { - return sessions[activeSessionId]!; + if (!activeSessionId) return undefined; + + return sessions[activeSessionId]; }, ); export const getStageIndex = createSelector(getActiveSession, (session) => { + if (!session) return null; return session.currentStep; }); @@ -24,7 +27,8 @@ export const getStageMetadata = createSelector( getActiveSession, getStageIndex, (session, stageIndex) => { - return session.stageMetadata?.[stageIndex] ?? undefined; + if (!stageIndex) return undefined; + return session?.stageMetadata?.[stageIndex] ?? undefined; }, ); @@ -32,7 +36,7 @@ export const getCurrentStage = createSelector( getProtocolStages, getStageIndex, (stages: Stage[], currentStep) => { - return stages[currentStep]!; + return stages[currentStep!]; }, ); diff --git a/lib/interviewer/store.ts b/lib/interviewer/store.ts index 4ac881bb..b3b9a0ca 100644 --- a/lib/interviewer/store.ts +++ b/lib/interviewer/store.ts @@ -1,4 +1,4 @@ -import { configureStore } from '@reduxjs/toolkit'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { reducer as form } from 'redux-form'; import thunk from 'redux-thunk'; import activeSessionId from '~/lib/interviewer/ducks/modules/activeSessionId'; @@ -7,63 +7,41 @@ import dialogs from '~/lib/interviewer/ducks/modules/dialogs'; import installedProtocols from '~/lib/interviewer/ducks/modules/installedProtocols'; import sessions from '~/lib/interviewer/ducks/modules/session'; import ui from '~/lib/interviewer/ducks/modules/ui'; -import type { NcNetwork } from '~/schemas/network-canvas'; -import { type Protocol } from '../shared-consts'; +import { type NcNetwork } from '../shared-consts'; import logger from './ducks/middleware/logger'; import sound from './ducks/middleware/sound'; +const rootReducer = combineReducers({ + form, + activeSessionId, + sessions, + installedProtocols, + deviceSettings, + dialogs, + ui, // used for FORM_IS_READY +}); + export const store = configureStore({ - reducer: { - form, - activeSessionId, - sessions, - installedProtocols, - deviceSettings, - dialogs, - ui, - }, + reducer: rootReducer, middleware: [thunk, logger, sound], }); +export type GetState = typeof store.getState; +export type RootState = ReturnType; + export type StageMetadataEntry = [number, string, string, boolean]; export type StageMetadata = StageMetadataEntry[]; +// TODO: couldn't make this work extending the Interview prisma schema... export type Session = { id: string; + startTime: Date; + finishTime: Date | null; + exportTime: Date | null; + lastUpdated: Date; + network: NcNetwork; protocolId: string; - promptIndex: number; currentStep: number; - caseId: string; - network: NcNetwork; - startedAt: Date; - lastUpdated: Date; - finishedAt: Date; - exportedAt: Date; + promptIndex?: number; stageMetadata?: Record; // Used as temporary storage by DyadCensus/TieStrengthCensus }; - -type SessionsState = Record; - -export type InstalledProtocols = Record; - -type Dialog = { - id: string; - title: string; - type: string; - confirmLabel?: string; - message: string; -}; - -type Dialogs = { - dialogs: Dialog[]; -}; - -export type RootState = { - form: Record; - activeSessionId: keyof SessionsState; - sessions: SessionsState; - installedProtocols: InstalledProtocols; - deviceSettings: Record; - dialogs: Dialogs; - ui: Record; -}; diff --git a/lib/shared-consts/network.ts b/lib/shared-consts/network.ts index 44d4f18f..48b75932 100644 --- a/lib/shared-consts/network.ts +++ b/lib/shared-consts/network.ts @@ -17,8 +17,11 @@ const variableValueSchema = z.union([ export type VariableValue = z.infer; export const entityPrimaryKeyProperty = '_uid' as const; +export type EntityPrimaryKey = typeof entityPrimaryKeyProperty; export const entitySecureAttributesMeta = '_secureAttributes' as const; +export type EntitySecureAttributesMeta = typeof entitySecureAttributesMeta; export const entityAttributesProperty = 'attributes' as const; +export type EntityAttributesProperty = typeof entityAttributesProperty; export const edgeSourceProperty = 'from' as const; export const edgeTargetProperty = 'to' as const; @@ -28,12 +31,14 @@ const NcEntitySchema = z.object({ validVariableNameSchema, variableValueSchema, ), - [entitySecureAttributesMeta]: z.record( - z.object({ - iv: z.array(z.number()), - salt: z.array(z.number()), - }), - ), + [entitySecureAttributesMeta]: z + .record( + z.object({ + iv: z.array(z.number()), + salt: z.array(z.number()), + }), + ) + .optional(), }); export type NcEntity = z.infer; diff --git a/queries/interviews.ts b/queries/interviews.ts index a53abea6..6bcbf909 100644 --- a/queries/interviews.ts +++ b/queries/interviews.ts @@ -24,7 +24,11 @@ export const getInterviewsForExport = createCachedFunction( }, }, include: { - protocol: true, + protocol: { + include: { + assets: true, + }, + }, participant: true, }, }); @@ -49,7 +53,20 @@ export const getInterviewById = (interviewId: string) => }, }); - return interview; + if (!interview) { + return null; + } + + return { + ...interview, + protocol: { + ...interview.protocol, + stages: protocol.stages, + codebook: protocol.codebook, + }, + stageMetadata: + interview.stageMetadata ?? ({} as Record), + }; }, [`getInterviewById-${interviewId}`, 'getInterviewById'], )(interviewId); diff --git a/schemas/interviews.ts b/schemas/interviews.ts index 8258aa8a..d047d3df 100644 --- a/schemas/interviews.ts +++ b/schemas/interviews.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { ZNcNetwork } from './network-canvas'; +import { type NcNetwork } from '~/lib/shared-consts'; const deleteInterviewsSchema = z.array( z.object({ @@ -20,7 +20,7 @@ const NumberStringBoolean = z.union([z.number(), z.string(), z.boolean()]); const syncInterviewSchema = z.object({ id: z.string(), - network: ZNcNetwork, + network: z.custom(), currentStep: z.number(), stageMetadata: z .record(z.string(), z.array(z.array(NumberStringBoolean))) diff --git a/schemas/network-canvas.ts b/schemas/network-canvas.ts deleted file mode 100644 index aff27688..00000000 --- a/schemas/network-canvas.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from 'zod'; -import { - entityAttributesProperty, - entityPrimaryKeyProperty, -} from '~/lib/shared-consts'; - -const ZNcEntity = z.object({ - [entityPrimaryKeyProperty]: z.string().readonly(), - type: z.string().optional(), - [entityAttributesProperty]: z.record(z.string(), z.any()), -}); - -export const ZNcNode = ZNcEntity.extend({ - type: z.string(), - stageId: z.string().optional(), - promptIDs: z.array(z.string()).optional(), - displayVariable: z.string().optional(), -}); - -export const ZNcEdge = ZNcEntity.extend({ - type: z.string(), - from: z.string(), - to: z.string(), -}); - -// Always use this instead of ~/lib/shared-consts. Main difference is that ego is not optional. -export const ZNcNetwork = z.object({ - nodes: z.array(ZNcNode), - edges: z.array(ZNcEdge), - ego: ZNcEntity, -}); - -export type NcNetwork = z.infer; From df0674a0454952fc98418a659157efc4e695980e Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 13 Dec 2024 00:34:00 +0200 Subject: [PATCH 15/15] WIP addNode --- .eslintrc.cjs | 1 - .../containers/Interfaces/NameGenerator.js | 46 +++-- lib/interviewer/containers/NodeForm.js | 24 +-- lib/interviewer/containers/ProtocolScreen.tsx | 26 +-- lib/interviewer/containers/QuickNodeForm.js | 61 +++---- .../ducks/modules/activeSessionId.ts | 24 +-- lib/interviewer/ducks/modules/network.ts | 91 ++++++---- lib/interviewer/ducks/modules/session.ts | 166 +++++++----------- lib/interviewer/selectors/interface.js | 14 +- lib/interviewer/selectors/session.ts | 13 +- lib/protocol-validation/schemas/src/8.zod.ts | 33 ++-- lib/shared-consts/network.ts | 20 ++- package.json | 1 + pnpm-lock.yaml | 8 + tsconfig.json | 4 +- 15 files changed, 249 insertions(+), 283 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 44770367..d42a1051 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -32,7 +32,6 @@ const config = { '*.test.*', 'public', '.eslintrc.cjs', - 'lib/protocol-validation', // TODO: remove this, and fix the errors ], rules: { '@next/next/no-img-element': 'off', diff --git a/lib/interviewer/containers/Interfaces/NameGenerator.js b/lib/interviewer/containers/Interfaces/NameGenerator.js index ded36215..7656543c 100644 --- a/lib/interviewer/containers/Interfaces/NameGenerator.js +++ b/lib/interviewer/containers/Interfaces/NameGenerator.js @@ -1,7 +1,7 @@ import { omit } from 'es-toolkit'; import { get, has } from 'es-toolkit/compat'; import PropTypes from 'prop-types'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useDispatch, useSelector } from 'react-redux'; import NodeBin from '~/lib/interviewer/components/NodeBin'; @@ -18,10 +18,7 @@ import { getNetworkNodesForPrompt, getStageNodeCount, } from '../../selectors/interface'; -import { - getNodeIconName, - getPromptModelData as getPromptNodeModelData, -} from '../../selectors/name-generator'; +import { getNodeIconName } from '../../selectors/name-generator'; import { getNodeColor, getNodeTypeLabel } from '../../selectors/network'; import { getAdditionalAttributesSelector } from '../../selectors/prop'; import NodeForm from '../NodeForm'; @@ -65,7 +62,7 @@ const NameGenerator = (props) => { prompt, ...props, }); // 2 - const newNodeModelData = usePropSelector(getPromptNodeModelData, props); // 3 + const nodesForPrompt = usePropSelector(getNetworkNodesForPrompt, props); // 4 const nodeIconName = usePropSelector(getNodeIconName, props); const nodeType = useSelector(getNodeTypeLabel(subject?.type)); @@ -73,13 +70,27 @@ const NameGenerator = (props) => { const dispatch = useDispatch(); - const addNode = (...properties) => - dispatch(sessionActions.addNode(...properties)); - const addNodeToPrompt = (...properties) => - dispatch(sessionActions.addNodeToPrompt(...properties)); - const removeNode = (uid) => { - dispatch(sessionActions.removeNode(uid)); - }; + const addNode = useCallback( + (attributes) => dispatch(sessionActions.addNode(subject.type, attributes)), + [dispatch, subject], + ); + + const updateNode = useCallback( + (...properties) => dispatch(sessionActions.updateNode(...properties)), + [dispatch], + ); + + const addNodeToPrompt = useCallback( + (...properties) => dispatch(sessionActions.addNodeToPrompt(...properties)), + [dispatch], + ); + + const removeNode = useCallback( + (uid) => { + dispatch(sessionActions.removeNode(uid)); + }, + [dispatch], + ); const maxNodesReached = stageNodeCount >= maxNodes; @@ -107,9 +118,7 @@ const NameGenerator = (props) => { const node = { ...item.meta }; // Test if we are updating an existing network node, or adding it to the network if (has(node, 'promptIDs')) { - addNodeToPrompt(node[entityPrimaryKeyProperty], prompt.id, { - ...newNodeAttributes, - }); + addNodeToPrompt(node[entityPrimaryKeyProperty], newNodeAttributes); } else { const droppedAttributeData = node[entityAttributesProperty]; const droppedModelData = omit(node, entityAttributesProperty); @@ -180,9 +189,10 @@ const NameGenerator = (props) => { disabled={maxNodesReached} icon={nodeIconName} nodeType={nodeType} - newNodeModelData={newNodeModelData} newNodeAttributes={newNodeAttributes} onClose={() => setSelectedNode(null)} + addNode={addNode} + updateNode={updateNode} /> )} {!form && ( @@ -192,10 +202,10 @@ const NameGenerator = (props) => { icon={nodeIconName} nodeColor={nodeColor} nodeType={nodeType} - newNodeModelData={newNodeModelData} newNodeAttributes={newNodeAttributes} targetVariable={quickAdd} onShowForm={() => setShowMinWarning(false)} + addNode={addNode} /> )}
diff --git a/lib/interviewer/containers/NodeForm.js b/lib/interviewer/containers/NodeForm.js index 918fdbca..e2afc6ba 100644 --- a/lib/interviewer/containers/NodeForm.js +++ b/lib/interviewer/containers/NodeForm.js @@ -7,7 +7,6 @@ import { entityPrimaryKeyProperty, } from '~/lib/shared-consts'; import { ActionButton, Button, Scroller } from '~/lib/ui/components'; -import { actionCreators as sessionActions } from '../ducks/modules/session'; import Form from './Form'; import FormWizard from './FormWizard'; import { FIRST_LOAD_UI_ELEMENT_DELAY } from './Interfaces/utils/constants'; @@ -23,24 +22,16 @@ const NodeForm = (props) => { disabled, icon, nodeType, - newNodeModelData, newNodeAttributes, onClose, + addNode, + updateNode, } = props; const [show, setShow] = useState(false); const dispatch = useDispatch(); const submitForm = () => dispatch(submit(reduxFormName)); - const addNode = useCallback( - (...properties) => dispatch(sessionActions.addNode(...properties)), - [dispatch], - ); - - const updateNode = useCallback( - (...properties) => dispatch(sessionActions.updateNode(...properties)), - [dispatch], - ); const useFullScreenForms = useSelector( (state) => state.deviceSettings.useFullScreenForms, @@ -52,7 +43,7 @@ const NodeForm = (props) => { /** * addNode(modelData, attributeData); */ - addNode(newNodeModelData, { ...newNodeAttributes, ...formData }); + addNode({ ...newNodeAttributes, ...formData }); } else { /** * updateNode(nodeId, newModelData, newAttributeData) @@ -64,14 +55,7 @@ const NodeForm = (props) => { setShow(false); onClose(); }, - [ - selectedNode, - newNodeModelData, - newNodeAttributes, - onClose, - addNode, - updateNode, - ], + [selectedNode, newNodeAttributes, onClose, addNode, updateNode], ); // When a selected node is passed in, we are editing an existing node. diff --git a/lib/interviewer/containers/ProtocolScreen.tsx b/lib/interviewer/containers/ProtocolScreen.tsx index 1ffbb333..51e8c82a 100644 --- a/lib/interviewer/containers/ProtocolScreen.tsx +++ b/lib/interviewer/containers/ProtocolScreen.tsx @@ -2,9 +2,9 @@ import type { AnyAction } from '@reduxjs/toolkit'; import { - motion, - useAnimate, - type ValueAnimationTransition, + motion, + useAnimate, + type ValueAnimationTransition, } from 'motion/react'; import { parseAsInteger, useQueryState } from 'nuqs'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -14,9 +14,9 @@ import Navigation from '../components/Navigation'; import { actionCreators as sessionActions } from '../ducks/modules/session'; import useReadyForNextStage from '../hooks/useReadyForNextStage'; import { - getCurrentStage, - getNavigationInfo, - makeGetFakeSessionProgress, + getCurrentStage, + getNavigationInfo, + makeGetFakeSessionProgress, } from '../selectors/session'; import { getNavigableStages } from '../selectors/skip-logic'; import Stage from './Stage'; @@ -65,7 +65,7 @@ export default function ProtocolScreen() { const makeFakeSessionProgress = useSelector(makeGetFakeSessionProgress); // Selectors - const stage = useSelector(getCurrentStage); + const stage = useSelector(getCurrentStage); // null = loading, undefined = not found const { isReady: isReadyForNextStage } = useReadyForNextStage(); const { currentStep, isLastPrompt, isFirstPrompt, promptIndex } = useSelector(getNavigationInfo); @@ -249,11 +249,13 @@ export default function ProtocolScreen() { variants={variants} custom={{ current: currentStep, previous: prevCurrentStep }} > - + {stage && ( + + )}
diff --git a/lib/interviewer/containers/QuickNodeForm.js b/lib/interviewer/containers/QuickNodeForm.js index f08bf2d9..6c00d9d8 100644 --- a/lib/interviewer/containers/QuickNodeForm.js +++ b/lib/interviewer/containers/QuickNodeForm.js @@ -1,29 +1,25 @@ import { AnimatePresence, motion } from 'motion/react'; -import { - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useDispatch } from 'react-redux'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ActionButton, Node } from '~/lib/ui/components'; -import { actionCreators as sessionActions } from '../ducks/modules/session'; import { FIRST_LOAD_UI_ELEMENT_DELAY } from './Interfaces/utils/constants'; const containerVariants = { - animate: (wide) => wide ? ({ - width: 'var(--open-width)', - y: '0rem', - transition: { - duration: 0, - }, - }) : ({ - width: 'var(--closed-width)', - y: '0rem', - transition: { - delay: FIRST_LOAD_UI_ELEMENT_DELAY, - }, - }), + animate: (wide) => + wide + ? { + width: 'var(--open-width)', + y: '0rem', + transition: { + duration: 0, + }, + } + : { + width: 'var(--closed-width)', + y: '0rem', + transition: { + delay: FIRST_LOAD_UI_ELEMENT_DELAY, + }, + }, initial: { y: '100%', }, @@ -43,7 +39,6 @@ const itemVariants = { opacity: 0, x: '10rem', }, - }; const inputVariants = { @@ -67,19 +62,15 @@ const QuickAddForm = ({ icon, nodeColor, nodeType, - newNodeModelData, newNodeAttributes, targetVariable, + addNode, }) => { const [showForm, setShowForm] = useState(false); const tooltipTimer = useRef(null); const [showTooltip, setShowTooltip] = useState(false); const [nodeLabel, setNodeLabel] = useState(''); - const dispatch = useDispatch(); - - const addNode = (...properties) => dispatch(sessionActions.addNode(...properties)); - const handleBlur = () => { setNodeLabel(''); setShowForm(false); @@ -106,13 +97,10 @@ const QuickAddForm = ({ e.preventDefault(); if (isValid && !disabled) { - addNode( - newNodeModelData, - { - ...newNodeAttributes, - [targetVariable]: nodeLabel, - }, - ); + addNode({ + ...newNodeAttributes, + [targetVariable]: nodeLabel, + }); setNodeLabel(''); } @@ -151,9 +139,7 @@ const QuickAddForm = ({ opacity: showTooltip ? 1 : 0, }} > - - Press enter to add... - + Press enter to add... )} - ); diff --git a/lib/interviewer/ducks/modules/activeSessionId.ts b/lib/interviewer/ducks/modules/activeSessionId.ts index bd4063ee..9ad47883 100644 --- a/lib/interviewer/ducks/modules/activeSessionId.ts +++ b/lib/interviewer/ducks/modules/activeSessionId.ts @@ -1,11 +1,8 @@ import { type Session } from '../../store'; -import { actionTypes as installedProtocolsActionTypes } from './installedProtocols'; -import { SET_SERVER_SESSION } from './setServerSession'; - -type SetServerSessionAction = { - type: typeof SET_SERVER_SESSION; - session: Session; -}; +import { + SET_SERVER_SESSION, + type SetServerSessionAction, +} from './setServerSession'; type SetSessionAction = { type: 'SET_SESSION'; @@ -16,15 +13,10 @@ type EndSessionAction = { type: 'END_SESSION'; }; -type DeleteProtocolAction = { - type: typeof installedProtocolsActionTypes.DELETE_PROTOCOL; -}; - type SessionActionTypes = | SetServerSessionAction | SetSessionAction - | EndSessionAction - | DeleteProtocolAction; + | EndSessionAction; // Initial State const initialState: Session['id'] | null = null; @@ -36,17 +28,13 @@ export default function sessionReducer( ): Session['id'] | null { switch (action.type) { case SET_SERVER_SESSION: { - if (!action.session) { - return state; - } - return action.session.id; + return action.payload.id; } case 'SET_SESSION': return action.sessionId; case 'END_SESSION': - case installedProtocolsActionTypes.DELETE_PROTOCOL: return initialState; default: diff --git a/lib/interviewer/ducks/modules/network.ts b/lib/interviewer/ducks/modules/network.ts index 6ee1ab35..21a43347 100644 --- a/lib/interviewer/ducks/modules/network.ts +++ b/lib/interviewer/ducks/modules/network.ts @@ -2,9 +2,12 @@ import { omit } from 'es-toolkit'; import { find, get, isMatch } from 'es-toolkit/compat'; import { v4 as uuid } from 'uuid'; import { + type EntityAttributesProperty, entityAttributesProperty, type EntityPrimaryKey, entityPrimaryKeyProperty, + entitySecureAttributesMeta, + type EntitySecureAttributesMeta, type NcEdge, type NcNetwork, type NcNode, @@ -49,8 +52,8 @@ export const initialState: NcNetwork = { */ const batchAddNodes = ( nodeList: NcNode[], - attributeData: NcNode['attributes'] = {}, - defaultAttributes: NcNode['attributes'] = {}, + attributeData: NcNode[EntityAttributesProperty] = {}, + defaultAttributes: NcNode[EntityAttributesProperty] = {}, ) => ({ type: BATCH_ADD_NODES, nodeList, @@ -98,18 +101,18 @@ export function edgeExists( * Correctly construct the node object based on a * node-like object, and an key-value attributes object */ -const formatNodeAttributes = (modelData, attributeData) => ({ - ...omit(modelData, 'promptId'), - [entityPrimaryKeyProperty]: modelData[entityPrimaryKeyProperty] || uuid(), - [entityAttributesProperty]: { - ...modelData[entityAttributesProperty], - ...attributeData, - }, - promptIDs: [modelData.promptId], - stageId: modelData.stageId, - type: modelData.type, - itemType: modelData.itemType, -}); +// const formatNodeAttributes = (modelData: ModelData, attributeData: Record): NcNode => ({ +// ...omit(modelData, ['promptId']), +// [entityPrimaryKeyProperty]: modelData[entityPrimaryKeyProperty] || uuid(), +// [entityAttributesProperty]: { +// ...modelData[entityAttributesProperty], +// ...attributeData, +// }, +// promptIDs: [modelData.promptId], +// stageId: modelData.stageId, +// type: modelData.type, +// itemType: modelData.itemType, +// }); const formatEgoAttributes = (modelData, attributeData) => ({ ...modelData, @@ -147,17 +150,17 @@ const removeEdge = (state, edgeId) => ({ ), }); -export type ModelData = { - [entityPrimaryKeyProperty]: string; - promptId?: string[]; -}; - export type AddNodeAction = { type: typeof ADD_NODE; - sessionId: string; + sessionMeta: { + sessionId: string; + promptId: string; + stageId: string; + }; payload: { - modelData: ModelData; - attributeData: NcNode['attributes']; + type: NcNode['type']; + attributeData: NcNode[EntityAttributesProperty]; + secureAttributes?: NcNode[EntitySecureAttributesMeta]; }; }; @@ -165,13 +168,13 @@ type UpdateNodeAction = { type: typeof UPDATE_NODE; nodeId: NcNode[EntityPrimaryKey]; newModelData: ModelData; - newAttributeData: NcNode['attributes']; + newAttributeData: NcNode[EntityAttributesProperty]; }; type ToggleNodeAttributesAction = { type: typeof TOGGLE_NODE_ATTRIBUTES; [entityPrimaryKeyProperty]: NcNode[EntityPrimaryKey]; - attributes: NcNode['attributes']; + attributes: NcNode[EntityAttributesProperty]; }; type RemoveNodeAction = { @@ -183,21 +186,21 @@ type AddNodeToPromptAction = { type: typeof ADD_NODE_TO_PROMPT; nodeId: NcNode[EntityPrimaryKey]; promptId: string; - promptAttributes: NcNode['attributes']; + promptAttributes: NcNode[EntityAttributesProperty]; }; type RemoveNodeFromPromptAction = { type: typeof REMOVE_NODE_FROM_PROMPT; nodeId: NcNode[EntityPrimaryKey]; promptId: string; - promptAttributes: NcNode['attributes']; + promptAttributes: NcNode[EntityAttributesProperty]; }; type BatchAddNodesAction = { type: typeof BATCH_ADD_NODES; nodeList: NcNode[]; - defaultAttributes: NcNode['attributes']; - attributeData: NcNode['attributes']; + defaultAttributes: NcNode[EntityAttributesProperty]; + attributeData: NcNode[EntityAttributesProperty]; }; type AddEdgeAction = { @@ -226,12 +229,12 @@ type RemoveEdgeAction = { type UpdateEgoAction = { type: typeof UPDATE_EGO; modelData: NcNode; - attributeData: NcNode['attributes']; + attributeData: NcNode[EntityAttributesProperty]; }; type AddSessionAction = { type: typeof ADD_SESSION; - egoAttributeData: NcNode['attributes']; + egoAttributeData: NcNode[EntityAttributesProperty]; }; type InitializeAction = { @@ -254,18 +257,36 @@ export type NetworkActions = | UpdateEgoAction | AddSessionAction; -export default function reducer(state = initialState, action: NetworkActions) { +export default function reducer( + state = initialState, + action: NetworkActions, +): NcNetwork { switch (action.type) { case INITIALIZE: { return initialState; } case ADD_NODE: { + // Here is where we need to use codebook data to determine if the attribute is encrypted + // and then store the encrypted data in the secure attributes meta + + // This approach will mean that existing interfaces don't need to update their use + // of addNode. + + const newNode: NcNode = { + [entityPrimaryKeyProperty]: uuid(), + type: action.payload.type, + [entityAttributesProperty]: action.payload.attributeData, + promptIDs: [action.sessionMeta.promptId], + stageId: action.sessionMeta.stageId, + }; + + if (action.payload.secureAttributes) { + newNode[entitySecureAttributesMeta] = action.payload.secureAttributes; + } + return { ...state, - nodes: [ - ...state.nodes, - formatNodeAttributes(action.modelData, action.attributeData), - ], + nodes: [...state.nodes, newNode], }; } case UPDATE_EGO: { diff --git a/lib/interviewer/ducks/modules/session.ts b/lib/interviewer/ducks/modules/session.ts index 1438d32f..6e21df18 100644 --- a/lib/interviewer/ducks/modules/session.ts +++ b/lib/interviewer/ducks/modules/session.ts @@ -1,30 +1,30 @@ -import { type Protocol } from '@prisma/client'; import { type Dispatch } from '@reduxjs/toolkit'; import { omit } from 'es-toolkit'; -import { has } from 'es-toolkit/compat'; +import { has, invariant } from 'es-toolkit/compat'; import { v4 as uuid } from 'uuid'; import { entityPrimaryKeyProperty, type EntityAttributesProperty, type EntityPrimaryKey, type EntityTypeDefinition, + type NcEntity, + type NcNetwork, type NcNode, } from '~/lib/shared-consts'; import { getPromptId } from '../../selectors/interface'; -import { - getCodebookVariablesForNodeType, - getProtocolCodebook, -} from '../../selectors/protocol'; -import { getActiveSession } from '../../selectors/session'; -import { type GetState, type Session } from '../../store'; +import { getCodebookVariablesForNodeType } from '../../selectors/protocol'; +import { getActiveSessionId, getCurrentStageId } from '../../selectors/session'; +import { type GetState, type Session, type StageMetadata } from '../../store'; import networkReducer, { actionTypes as networkActionTypes, actionCreators as networkActions, type AddNodeAction, - type ModelData, type NetworkActions, } from './network'; -import { SET_SERVER_SESSION } from './setServerSession'; +import { + SET_SERVER_SESSION, + type SetServerSessionAction, +} from './setServerSession'; const ADD_SESSION = 'ADD_SESSION'; const SET_SESSION_FINISHED = 'SET_SESSION_FINISHED'; @@ -34,25 +34,17 @@ const UPDATE_STAGE_METADATA = 'UPDATE_STAGE_METADATA'; const initialState = {} as Record; -const withTimestamp = (session: SessionWithoutId): SessionWithoutId => ({ +const withTimestamp = (session: SessionWithoutId) => ({ ...session, lastUpdated: new Date(), }); const sessionExists = ( sessionId: Session['id'], - sessions: SessionWithoutId[], + sessions: Record, ) => has(sessions, sessionId); -type setServerSessionAction = { - type: typeof SET_SERVER_SESSION; - payload: { - protocol: Protocol; - session: Session; - }; -}; - -type Action = setServerSessionAction | NetworkActions; +type Action = SetServerSessionAction | NetworkActions; export type SessionWithoutId = Omit; const getReducer = @@ -60,46 +52,48 @@ const getReducer = (state = initialState, action: Action): Record => { switch (action.type) { case SET_SERVER_SESSION: { - if (!action.payload.session) { - return state; - } + const session = omit(action.payload, ['protocol']); return { ...state, - [action.payload.session.id]: { - ...(omit(action.payload.session, ['id']) as SessionWithoutId), - network: - action.payload.session.network ?? - network(undefined, { type: networkActionTypes.INITIALIZE }), - stageMetadata: action.payload.session.stageMetadata, + [action.payload.id]: { + ...session, + network: (action.payload.network ?? + network(undefined, { + type: networkActionTypes.INITIALIZE, + })) as unknown as NcNetwork, + stageMetadata: (action.payload.stageMetadata ?? {}) as Record< + string, + StageMetadata + >, }, }; } // Whenever a network action occurs, pass the action through to the network reducer - case networkActionTypes.ADD_NODE: - case networkActionTypes.ADD_NODE_TO_PROMPT: - case networkActionTypes.BATCH_ADD_NODES: - case networkActionTypes.REMOVE_NODE: - case networkActionTypes.REMOVE_NODE_FROM_PROMPT: - case networkActionTypes.UPDATE_NODE: - case networkActionTypes.TOGGLE_NODE_ATTRIBUTES: - case networkActionTypes.ADD_EDGE: - case networkActionTypes.UPDATE_EDGE: - case networkActionTypes.TOGGLE_EDGE: - case networkActionTypes.REMOVE_EDGE: - case networkActionTypes.UPDATE_EGO: { - const third = state[action.sessionId]!.network; - const test = action; - const newNetwork = network(third, action); + case networkActionTypes.ADD_NODE: // case networkActionTypes.TOGGLE_NODE_ATTRIBUTES: // case networkActionTypes.UPDATE_NODE: // case networkActionTypes.REMOVE_NODE_FROM_PROMPT: // case networkActionTypes.REMOVE_NODE: // case networkActionTypes.BATCH_ADD_NODES: // case networkActionTypes.ADD_NODE_TO_PROMPT: + // case networkActionTypes.ADD_EDGE: + // case networkActionTypes.UPDATE_EDGE: + // case networkActionTypes.TOGGLE_EDGE: + // case networkActionTypes.REMOVE_EDGE: + // case networkActionTypes.UPDATE_EGO: + { + if (!sessionExists(action.sessionMeta.sessionId, state)) { + return state; + } + + const session = state[action.sessionMeta.sessionId]!; return { ...state, - [action.sessionId]: withTimestamp({ - ...state[action.sessionId], + [action.sessionMeta.sessionId]: withTimestamp({ + ...session, // Reset finished and exported state if network changes finishTime: null, exportTime: null, - network: network(state[action.sessionId].network, action), + network: network( + state[action.sessionMeta.sessionId]!.network, + action, + ), }), }; } @@ -115,18 +109,6 @@ const getReducer = }), }; } - case SET_SESSION_EXPORTED: { - if (!sessionExists(action.sessionId, state)) { - return state; - } - return { - ...state, - [action.sessionId]: { - ...state[action.sessionId], - exportTime: new Date(), - }, - }; - } case UPDATE_PROMPT: { if (!sessionExists(action.sessionId, state)) { return state; @@ -177,17 +159,17 @@ const getReducer = * This function generates default values for all variables in the variable registry for this node * type. * - * @param {object} registryForType - An object containing the variable registry entry for this + * @param {object} variablesForType - An object containing the variable registry entry for this * node type. */ const getDefaultAttributesForEntityType = ( - registryForType: EntityTypeDefinition['variables'] = {}, + variablesForType: EntityTypeDefinition['variables'] = {}, ) => { - const defaultAttributesObject = {} as Record; + const defaultAttributesObject = {} as NcEntity[EntityAttributesProperty]; // ALL variables initialised as `null` - Object.keys(registryForType).forEach((variableUUID) => { + Object.keys(variablesForType).forEach((variableUUID) => { defaultAttributesObject[variableUUID] = null; }); @@ -248,53 +230,43 @@ const batchAddNodes = const addNode = ( - modelData: ModelData, + type: NcNode['type'], attributeData: NcNode[EntityAttributesProperty] = {}, ) => (dispatch: Dispatch, getState: GetState) => { const state = getState(); - const { activeSessionId, sessions, installedProtocols } = state; - const activeSession = getActiveSession(state); + const sessionId = getActiveSessionId(state); + invariant(sessionId, 'Session ID is required to add a node'); - const codebook = getProtocolCodebook(state); - const registryForType = getCodebookVariablesForNodeType(modelData.type); + const variablesForType = getCodebookVariablesForNodeType(type)(state); - // We need to create the `entitySecureAttribtuesMeta` object for the node - // for any attributes that have `encrypted` set to true in the codebook. + const defaultVariablesForType = + getDefaultAttributesForEntityType(variablesForType); - const modelDataWithSecureAttributes = Object.keys(registryForType).reduce( - (acc, key) => { - if (registryForType[key].encrypted) { - return { - ...acc, - [key]: { - value: modelData[key], - encrypted: true, - }, - }; - } - return { - ...acc, - [key]: modelData[key], - }; - }, - {}, - ); + const stageId = getCurrentStageId(state); + invariant(stageId, 'Stage ID is required to add a node'); - console.log('modelDataWithSecureAttributes', modelDataWithSecureAttributes); + const promptId = getPromptId(state); + invariant(promptId, 'Prompt ID is required to add a node'); - // Inject the current promptId into the model data. + // TODO: handle encryption here + const secureAttributes = {}; dispatch({ type: networkActionTypes.ADD_NODE, - sessionId: activeSessionId!, + sessionMeta: { + sessionId, + promptId, + stageId, + }, payload: { - modelData, + type, attributeData: { - ...getDefaultAttributesForEntityType(registryForType), + ...defaultVariablesForType, ...attributeData, }, + secureAttributes, }, }); }; @@ -550,10 +522,8 @@ const actionCreators = { removeEdge, toggleNodeAttributes, addSession, - loadSession, updatePrompt, updateStage, - updateCaseId, updateStageMetadata, removeSession, setSessionFinished, @@ -563,13 +533,9 @@ const actionCreators = { const actionTypes = { ADD_SESSION, SET_SESSION_FINISHED, - SET_SESSION_EXPORTED, - LOAD_SESSION, UPDATE_PROMPT, UPDATE_STAGE, - UPDATE_CASE_ID, UPDATE_STAGE_METADATA, - REMOVE_SESSION, }; export { actionCreators, actionTypes }; diff --git a/lib/interviewer/selectors/interface.js b/lib/interviewer/selectors/interface.js index e2e969b0..e26266e1 100644 --- a/lib/interviewer/selectors/interface.js +++ b/lib/interviewer/selectors/interface.js @@ -87,11 +87,6 @@ export const getNetworkEntitiesForType = createSelector( }, ); -/** - * makeNetworkNodesForType() - * Get the current prompt/stage subject, and filter the network by this node type. - */ - export const getNetworkNodesForType = createSelector( getNetworkNodes, getStageSubject, @@ -100,7 +95,6 @@ export const getNetworkNodesForType = createSelector( export const makeNetworkNodesForType = () => getNetworkNodesForType; -// makeNetworkNodesForStage() export const getStageNodeCount = createSelector( getNetworkNodesForType, stagePromptIds, @@ -121,12 +115,6 @@ export const makeGetStageNodeCount = () => { ); }; -/** - * makeNetworkNodesForPrompt - * - * Return a filtered node list containing only nodes where node IDs contains the current promptId. - */ - export const getPromptId = createSelector( getPrompts, getPromptIndex, @@ -135,7 +123,7 @@ export const getPromptId = createSelector( return null; } - return prompts[promptIndex].id || 0; + return prompts[promptIndex].id ?? null; }, ); diff --git a/lib/interviewer/selectors/session.ts b/lib/interviewer/selectors/session.ts index 394f7e15..43f9ca93 100644 --- a/lib/interviewer/selectors/session.ts +++ b/lib/interviewer/selectors/session.ts @@ -3,7 +3,7 @@ import type { Stage } from '~/lib/shared-consts'; import { type RootState } from '../store'; import { getProtocolStages } from './protocol'; -const getActiveSessionId = (state: RootState) => state.activeSessionId; +export const getActiveSessionId = (state: RootState) => state.activeSessionId; const getSessions = (state: RootState) => state.sessions; @@ -36,7 +36,16 @@ export const getCurrentStage = createSelector( getProtocolStages, getStageIndex, (stages: Stage[], currentStep) => { - return stages[currentStep!]; + if (currentStep === null) return null; + return stages[currentStep]; + }, +); + +export const getCurrentStageId = createSelector( + getCurrentStage, + (currentStage) => { + if (!currentStage) return null; + return currentStage.id; }, ); diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index d1645d36..db3a57d3 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -131,19 +131,8 @@ const alterEdgeFormStage = baseStageSchema.extend({ form: formFieldsSchema, }); -const nameGeneratorStage = baseStageSchema.extend({ - type: z.literal('NameGenerator'), - form: formFieldsSchema, - subject: subjectSchema, - panels: z.array(panelSchema).optional(), - prompts: z.array(promptSchema).min(1), -}); - -const nameGeneratorQuickAddStage = baseStageSchema.extend({ - type: z.literal('NameGeneratorQuickAdd'), - quickAdd: z.string(), +const baseNameGeneratorStage = baseStageSchema.extend({ subject: subjectSchema, - panels: z.array(panelSchema).optional(), prompts: z.array(promptSchema).min(1), behaviours: z .object({ @@ -153,9 +142,24 @@ const nameGeneratorQuickAddStage = baseStageSchema.extend({ .optional(), }); -const nameGeneratorRosterStage = baseStageSchema.extend({ +const nameGeneratorStage = baseNameGeneratorStage.extend({ + type: z.literal('NameGenerator'), + form: formFieldsSchema, + panels: z.array(panelSchema).optional(), +}); + +const nameGeneratorQuickAddStage = baseNameGeneratorStage.extend({ + type: z.literal('NameGeneratorQuickAdd'), + quickAdd: z.string(), + panels: z.array(panelSchema).optional(), +}); + +export type NameGeneratorStageProps = + | z.infer + | z.infer; + +const nameGeneratorRosterStage = baseNameGeneratorStage.extend({ type: z.literal('NameGeneratorRoster'), - subject: subjectSchema, dataSource: z.string(), cardOptions: z .object({ @@ -173,7 +177,6 @@ const nameGeneratorRosterStage = baseStageSchema.extend({ }) .strict() .optional(), - prompts: z.array(promptSchema).min(1), }); const sociogramStage = baseStageSchema.extend({ diff --git a/lib/shared-consts/network.ts b/lib/shared-consts/network.ts index 48b75932..90524a3a 100644 --- a/lib/shared-consts/network.ts +++ b/lib/shared-consts/network.ts @@ -1,18 +1,20 @@ import { z } from 'zod'; import { validVariableNameSchema } from './variables'; +// When vqlues are encrypted, this is the resulting type. const encryptedValueSchema = z.array(z.number()); - export type EncryptedValue = z.infer; -const variableValueSchema = z.union([ - z.string(), - z.array(z.unknown()), // remove - z.boolean(), - z.number(), - encryptedValueSchema, - z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])), -]); +const variableValueSchema = z + .union([ + z.string(), + z.boolean(), + z.number(), + encryptedValueSchema, + z.array(z.union([z.string(), z.number(), z.boolean()])), // Ordinal + z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])), // Categorical + ]) + .nullable(); export type VariableValue = z.infer; diff --git a/package.json b/package.json index 1e2a5fc9..9a007c18 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "sharp": "^0.33.5", "strip-markdown": "^6.0.0", "tailwind-merge": "^2.5.5", + "tiny-invariant": "^1.3.3", "uploadthing": "^7.2.0", "usehooks-ts": "^2.16.0", "uuid": "^11.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8197e36c..c1fe9097 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,6 +233,9 @@ importers: tailwind-merge: specifier: ^2.5.5 version: 2.5.5 + tiny-invariant: + specifier: ^1.3.3 + version: 1.3.3 uploadthing: specifier: ^7.2.0 version: 7.2.0(next@14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.81.0))(tailwindcss@4.0.0-beta.3) @@ -5081,6 +5084,9 @@ packages: text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -10843,6 +10849,8 @@ snapshots: text-table@0.2.0: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.1: {} diff --git a/tsconfig.json b/tsconfig.json index 2b61a208..dbfdbd06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,8 +45,8 @@ ".next/types/**/*.ts", "eslint-local-rules/index.js", "actions/auth.ts", - "env.tjs" -, "lib/interviewer/ducks/modules/dialogs.jts" ], + "env.js", + ], "exclude": [ "node_modules", ]