diff --git a/.env.example b/.env.example index 1d9858c0..5287d997 100644 --- a/.env.example +++ b/.env.example @@ -25,5 +25,11 @@ MAXMIND_ACCOUNT_ID=xxxxxxxx # optional global override for analytics # can be used to diable analytics in development -#NEXT_PUBLIC_DISABLE_ANALYTICS=true +#DISABLE_ANALYTICS=true + +# optional manual specification for installation ID. Useful in scenarios such as +# CI/CD where the installation ID cannot be automatically determined because +# there is no database. Also useful for ensuring consistent ID between DB +# resets. +#INSTALLATION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx diff --git a/.github/workflows/lint.yml b/.github/workflows/build.yml similarity index 93% rename from .github/workflows/lint.yml rename to .github/workflows/build.yml index 73981ae5..6fa203ab 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,11 @@ jobs: build: runs-on: ubuntu-latest + env: + SKIP_ENV_VALIDATION: true + DISABLE_ANALYTICS: true + INSTALLATION_ID: gh-action + steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/.github/workflows/push.yml b/.github/workflows/docker.yml similarity index 100% rename from .github/workflows/push.yml rename to .github/workflows/docker.yml diff --git a/analytics/utils.ts b/analytics/utils.ts index 65de462f..1bd7ca84 100644 --- a/analytics/utils.ts +++ b/analytics/utils.ts @@ -1,24 +1,19 @@ import { makeEventTracker } from '@codaco/analytics'; import { cache } from 'react'; -import { api } from '~/trpc/server'; -import { getBaseUrl } from '~/trpc/shared'; +import { env } from '~/env.mjs'; +import { prisma } from '~/utils/db'; export const getInstallationId = cache(async () => { - const installationId = await api.appSettings.getInstallationId.query(); - - if (installationId) { - return installationId; + if (env.INSTALLATION_ID) { + return env.INSTALLATION_ID; } - return 'Unknown'; -}); + // eslint-disable-next-line local-rules/require-data-mapper + const appSettings = await prisma.appSettings.findFirst(); -// eslint-disable-next-line no-process-env -const globalAnalyticsEnabled = process.env.NEXT_PUBLIC_ANALYTICS_ENABLED; + return appSettings?.installationId ?? 'Unknown'; +}); -export const trackEvent = - globalAnalyticsEnabled !== 'false' - ? makeEventTracker({ - endpoint: getBaseUrl() + '/api/analytics', - }) - : () => {}; +export const trackEvent = !env.DISABLE_ANALYTICS + ? makeEventTracker() + : () => null; diff --git a/app/(interview)/interview/_components/FinishInterviewModal.tsx b/app/(interview)/interview/_components/FinishInterviewModal.tsx new file mode 100644 index 00000000..0d89a95e --- /dev/null +++ b/app/(interview)/interview/_components/FinishInterviewModal.tsx @@ -0,0 +1,75 @@ +import { type Dispatch, type SetStateAction } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '~/components/ui/AlertDialog'; +import { useRouter } from 'next/navigation'; +import { api } from '~/trpc/client'; +import { usePathname } from 'next/navigation'; +import { clientRevalidateTag } from '~/utils/clientRevalidate'; + +type FinishInterviewModalProps = { + open: boolean; + setOpen: Dispatch>; +}; + +const FinishInterviewModal = ({ open, setOpen }: FinishInterviewModalProps) => { + const router = useRouter(); + const pathname = usePathname(); + + const interviewId = pathname.split('/').pop(); + const { mutateAsync: finishInterview } = api.interview.finish.useMutation({ + onError(error) { + throw new Error(error.message); + }, + async onSuccess() { + await clientRevalidateTag('interview.get.byId'); + + router.push('/interview/finished'); + }, + }); + const handleFinishInterview = async () => { + if (!interviewId) { + throw new Error('No interview id found'); + } + await finishInterview({ id: interviewId }); + }; + return ( + + + + + Are you sure you want finish the interview? + + + Your responses cannot be changed after you finish the interview. + + + + { + setOpen(false); + }} + > + Cancel + + { + await handleFinishInterview(); + }} + > + Finish Interview + + + + + ); +}; + +export default FinishInterviewModal; diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index dc8a197f..40364bb1 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -14,6 +14,7 @@ import { import { getActiveSession } from '~/lib/interviewer/selectors/session'; import { store } from '~/lib/interviewer/store'; import { api } from '~/trpc/client'; +import { useRouter } from 'next/navigation'; // The job of ServerSync is to listen to actions in the redux store, and to sync // data with the server. @@ -68,6 +69,7 @@ const ServerSync = ({ interviewId }: { interviewId: string }) => { // Eventually it will handle syncing this data back. const InterviewShell = ({ interviewID }: { interviewID: string }) => { const [currentStage, setCurrentStage] = useQueryState('stage'); + const router = useRouter(); const { isLoading } = api.interview.get.byId.useQuery( { id: interviewID }, @@ -79,6 +81,9 @@ const InterviewShell = ({ interviewID }: { interviewID: string }) => { if (!data) { return; } + if (data.finishTime) { + router.push('/interview/finished'); + } const { protocol, ...serverSession } = data; diff --git a/app/(interview)/interview/finished/page.tsx b/app/(interview)/interview/finished/page.tsx new file mode 100644 index 00000000..962d367d --- /dev/null +++ b/app/(interview)/interview/finished/page.tsx @@ -0,0 +1,15 @@ +import { BadgeCheck } from 'lucide-react'; + +export default function InterviewCompleted() { + return ( +
+ +

+ Thank you for participating! +

+

+ Your interview has been successfully completed. +

+
+ ); +} diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts index a39bc92e..07e45d28 100644 --- a/app/api/analytics/route.ts +++ b/app/api/analytics/route.ts @@ -3,12 +3,20 @@ import { env } from '~/env.mjs'; import { createRouteHandler } from '@codaco/analytics'; import { WebServiceClient } from '@maxmind/geoip2-node'; +const maxMindClient = new WebServiceClient( + env.MAXMIND_ACCOUNT_ID, + env.MAXMIND_LICENSE_KEY, + { + host: 'geolite.info', + }, +); + +const installationId = await getInstallationId(); + const routeHandler = createRouteHandler({ - maxMindAccountId: env.MAXMIND_ACCOUNT_ID, - maxMindLicenseKey: env.MAXMIND_LICENSE_KEY, - getInstallationId, + installationId, platformUrl: 'https://frescoanalytics.networkcanvas.dev', - WebServiceClient, + maxMindClient, }); export { routeHandler as POST }; diff --git a/env.mjs b/env.mjs index 6a271a4f..75c8401b 100644 --- a/env.mjs +++ b/env.mjs @@ -9,6 +9,10 @@ export const env = createEnv({ */ server: { DATABASE_URL: z.string().url(), + MAXMIND_ACCOUNT_ID: z.string(), + MAXMIND_LICENSE_KEY: z.string(), + + INSTALLATION_ID: z.string().optional(), }, /** @@ -23,9 +27,7 @@ export const env = createEnv({ NODE_ENV: z .enum(['development', 'test', 'production']) .default('development'), - MAXMIND_ACCOUNT_ID: z.string(), - MAXMIND_LICENSE_KEY: z.string(), - NEXT_PUBLIC_DISABLE_ANALYTICS: z.string().optional(), + DISABLE_ANALYTICS: z.boolean().optional(), }, /** * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. @@ -38,12 +40,12 @@ export const env = createEnv({ VERCEL_URL: process.env.VERCEL_URL, MAXMIND_ACCOUNT_ID: process.env.MAXMIND_ACCOUNT_ID, MAXMIND_LICENSE_KEY: process.env.MAXMIND_LICENSE_KEY, - NEXT_PUBLIC_DISABLE_ANALYTICS: process.env.NEXT_PUBLIC_DISABLE_ANALYTICS, + DISABLE_ANALYTICS: !!process.env.DISABLE_ANALYTICS, + INSTALLATION_ID: process.env.INSTALLATION_ID, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially * useful for Docker builds. */ - // skipValidation: !!process.env.SKIP_ENV_VALIDATION, - skipValidation: true, + skipValidation: !!process.env.SKIP_ENV_VALIDATION, }); diff --git a/lib/development-protocol/nodeLabelWorker.js b/lib/development-protocol/nodeLabelWorker.js index 470c07a0..62f479f5 100644 --- a/lib/development-protocol/nodeLabelWorker.js +++ b/lib/development-protocol/nodeLabelWorker.js @@ -27,8 +27,8 @@ * @return {string|Promise} a label for the input node, or * a promise that resolves to the label */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars function nodeLabelWorker({ node, network }) { - // Examples: // // 1. Given name, surname initial @@ -69,7 +69,6 @@ function nodeLabelWorker({ node, network }) { // }, 100); // }); - // For our example worker we will return a different label dependant on the node type. let label = node.nickname || node.name; @@ -79,9 +78,13 @@ function nodeLabelWorker({ node, network }) { switch (node.networkCanvasType) { case 'person': - if (network.edges.some( - edge => edge.from === node.networkCanvasId || edge.to === node.networkCanvasId - )) { + if ( + network.edges.some( + (edge) => + edge.from === node.networkCanvasId || + edge.to === node.networkCanvasId, + ) + ) { label = `🔗\u{0a}${label}`; } else if (node.close_friend) { label = `❤️\u{0a}${label}`; diff --git a/lib/interviewer/behaviours/DragAndDrop/DragSource.js b/lib/interviewer/behaviours/DragAndDrop/DragSource.js index dc94274c..084c4b14 100644 --- a/lib/interviewer/behaviours/DragAndDrop/DragSource.js +++ b/lib/interviewer/behaviours/DragAndDrop/DragSource.js @@ -1,16 +1,12 @@ -/* eslint-disable react/display-name */ -/* eslint-disable react/no-find-dom-node, react/sort-comp, react/jsx-props-no-spreading */ - -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { throttle } from 'lodash'; import DragPreview from './DragPreview'; import DragManager, { VERTICAL_SCROLL } from './DragManager'; import { actionCreators as actions } from './reducer'; import store from './store'; -const dragSource = - (WrappedComponent) => - ({ +const dragSource = (WrappedComponent) => { + const DragSourceInner = ({ allowDrag = true, meta = () => ({}), scrollDirection = VERTICAL_SCROLL, @@ -19,57 +15,63 @@ const dragSource = }) => { const node = useRef(); const previewRef = useRef(); - let dragManager = null; - let previewEl = null; + const dragManager = useRef(null); + const previewEl = useRef(null); const [isDragging, setIsDragging] = useState(false); const cleanupDragManager = () => { - if (dragManager) { - dragManager.unmount(); - dragManager = null; + if (dragManager.current) { + dragManager.current.unmount(); + dragManager.current = null; } }; - const cleanupPreview = () => { - if (previewEl) { - previewEl.cleanup(); - previewEl = null; + const cleanupPreview = useCallback(() => { + if (previewEl.current) { + previewEl.current.cleanup(); + previewEl.current = null; } - }; + }, []); - const createPreview = () => { + const createPreview = useCallback(() => { if (!preview) { - previewEl = new DragPreview(node.current); + previewEl.current = new DragPreview(node.current); return; } - previewEl = new DragPreview(previewRef.current); - }; + previewEl.current = new DragPreview(previewRef.current); + }, [preview]); - const updatePreview = ({ x, y }) => { - if (previewEl) { - previewEl.position({ x, y }); - } - }; + const updatePreview = useCallback( + ({ x, y }) => { + if (previewEl.current) { + previewEl.current.position({ x, y }); + } + }, + [previewEl], + ); const setValidMove = (valid) => { - if (!previewEl) return; - previewEl.setValidMove(valid); + if (!previewEl.current) return; + previewEl.current.setValidMove(valid); }; - const onDragStart = (movement) => { - createPreview(); + const onDragStart = useCallback( + (movement) => { + createPreview(); - store.dispatch( - actions.dragStart({ - ...movement, - meta: meta(), - }), - ); + store.dispatch( + actions.dragStart({ + ...movement, + meta: meta(), + }), + ); - setIsDragging(true); - }; + setIsDragging(true); + }, + [createPreview, meta], + ); const throttledDragAction = throttle(({ x, y, ...other }) => { store.dispatch( @@ -82,21 +84,27 @@ const dragSource = ); }, 250); - const onDragMove = ({ x, y, ...other }) => { - updatePreview({ x, y }); - throttledDragAction({ x, y, ...other }); - }; + const onDragMove = useCallback( + ({ x, y, ...other }) => { + updatePreview({ x, y }); + throttledDragAction({ x, y, ...other }); + }, + [throttledDragAction, updatePreview], + ); - const onDragEnd = (movement) => { - cleanupPreview(); - setIsDragging(false); + const onDragEnd = useCallback( + (movement) => { + cleanupPreview(); + setIsDragging(false); - store.dispatch(actions.dragEnd(movement)); - }; + store.dispatch(actions.dragEnd(movement)); + }, + [cleanupPreview], + ); useEffect(() => { if (node.current && allowDrag) { - dragManager = new DragManager({ + dragManager.current = new DragManager({ el: node.current, onDragStart, onDragMove, @@ -109,7 +117,15 @@ const dragSource = cleanupPreview(); cleanupDragManager(); }; - }, [node, allowDrag]); + }, [ + node, + allowDrag, + cleanupPreview, + onDragEnd, + onDragMove, + onDragStart, + scrollDirection, + ]); const styles = () => (isDragging ? { visibility: 'hidden' } : {}); @@ -143,4 +159,7 @@ const dragSource = ); }; + return DragSourceInner; +}; + export default dragSource; diff --git a/lib/interviewer/behaviours/DragAndDrop/DropTarget.js b/lib/interviewer/behaviours/DragAndDrop/DropTarget.js index 1f4ec0eb..ce9963f4 100644 --- a/lib/interviewer/behaviours/DragAndDrop/DropTarget.js +++ b/lib/interviewer/behaviours/DragAndDrop/DropTarget.js @@ -12,7 +12,9 @@ const maxFramesPerSecond = 10; const dropTarget = (WrappedComponent) => { class DropTarget extends Component { componentDidMount() { - if (!this.component) { return; } + if (!this.component) { + return; + } this.node = findDOMNode(this.component); this.update(); } @@ -25,10 +27,8 @@ const dropTarget = (WrappedComponent) => { removeTarget = () => { const { id } = this.props; - store.dispatch( - actions.removeTarget(id), - ); - } + store.dispatch(actions.removeTarget(id)); + }; update = () => { this.updateTarget(); @@ -39,19 +39,14 @@ const dropTarget = (WrappedComponent) => { // }, // 1000 / maxFramesPerSecond, // ); - } + }; updateTarget = () => { - if (!this.node) { return; } + if (!this.node) { + return; + } - const { - id, - onDrop, - onDrag, - onDragEnd, - accepts, - meta, - } = this.props; + const { id, onDrop, onDrag, onDragEnd, accepts, meta } = this.props; const boundingClientRect = getAbsoluteBoundingRect(this.node); store.dispatch( @@ -68,18 +63,16 @@ const dropTarget = (WrappedComponent) => { x: boundingClientRect.left, }), ); - } + }; render() { - const { - accepts, - meta, - ...props - } = this.props; + const { ...props } = this.props; return ( { this.component = component; }} + ref={(component) => { + this.component = component; + }} {...props} /> ); diff --git a/lib/interviewer/behaviours/DragAndDrop/Monitor.js b/lib/interviewer/behaviours/DragAndDrop/Monitor.js index c90996b0..95507e44 100644 --- a/lib/interviewer/behaviours/DragAndDrop/Monitor.js +++ b/lib/interviewer/behaviours/DragAndDrop/Monitor.js @@ -1,4 +1,3 @@ -/* eslint-disable implicit-arrow-linebreak, react/jsx-props-no-spreading */ import React, { PureComponent } from 'react'; import { pick, isEqual } from 'lodash'; import store from './store'; @@ -19,12 +18,15 @@ const monitor = (getMonitorProps, types) => (WrappedComponent) => } updateMonitorProps = () => { - const monitorPropsNew = pick(getMonitorProps(store.getState(), this.props), types); + const monitorPropsNew = pick( + getMonitorProps(store.getState(), this.props), + types, + ); const { monitorProps } = this.state; if (!isEqual(monitorProps, monitorPropsNew)) { this.setState({ monitorProps: monitorPropsNew }); } - } + }; render() { const { monitorProps } = this.state; diff --git a/lib/interviewer/behaviours/DragAndDrop/store.js b/lib/interviewer/behaviours/DragAndDrop/store.js index 04c13713..1f0c3c0f 100644 --- a/lib/interviewer/behaviours/DragAndDrop/store.js +++ b/lib/interviewer/behaviours/DragAndDrop/store.js @@ -1,14 +1,10 @@ import thunk from 'redux-thunk'; import reducer from './reducer'; -import logger from '../../ducks/middleware/logger'; import { configureStore } from '@reduxjs/toolkit'; const store = configureStore({ reducer, - middleware: [ - thunk, - // logger - ], + middleware: [thunk], }); export default store; diff --git a/lib/interviewer/behaviours/withPrompt.js b/lib/interviewer/behaviours/withPrompt.js index 449cedd9..eec2cb3d 100644 --- a/lib/interviewer/behaviours/withPrompt.js +++ b/lib/interviewer/behaviours/withPrompt.js @@ -1,12 +1,15 @@ -import React, { Component, useEffect } from 'react'; -import { connect, useDispatch, useSelector } from 'react-redux'; -import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; import { actionCreators as sessionActions } from '../ducks/modules/session'; -import { getAllVariableUUIDsByEntity, getProtocolStages } from '../selectors/protocol'; -import { get } from '../utils/lodash-replacements'; +import { getAllVariableUUIDsByEntity } from '../selectors/protocol'; +import { + getIsFirstPrompt, + getIsLastPrompt, + getPromptIndex, + getPrompts, +} from '../selectors/session'; import { processProtocolSortRule } from '../utils/createSorter'; -import { getIsFirstPrompt, getIsLastPrompt, getPromptIndex, getPrompts } from '../selectors/session'; +import { get } from '../utils/lodash-replacements'; /** * Convert sort rules to new format. See `processProtocolSortRule` for details. @@ -14,7 +17,7 @@ import { getIsFirstPrompt, getIsLastPrompt, getPromptIndex, getPrompts } from '. * @param {Object} codebookVariables * @returns {Array} * @private - */ + */ const processSortRules = (prompts = [], codebookVariables) => { const sortProperties = ['bucketSortOrder', 'binSortOrder']; @@ -22,7 +25,9 @@ const processSortRules = (prompts = [], codebookVariables) => { const sortOptions = {}; sortProperties.forEach((property) => { const sortRules = get(prompt, property, []); - sortOptions[property] = sortRules.map(processProtocolSortRule(codebookVariables)); + sortOptions[property] = sortRules.map( + processProtocolSortRule(codebookVariables), + ); }); return { ...prompt, @@ -34,7 +39,8 @@ const processSortRules = (prompts = [], codebookVariables) => { const withPrompt = (WrappedComponent) => { const WithPrompt = (props) => { const dispatch = useDispatch(); - const updatePrompt = (promptIndex) => dispatch(sessionActions.updatePrompt(promptIndex)); + const updatePrompt = (promptIndex) => + dispatch(sessionActions.updatePrompt(promptIndex)); const codebookVariables = useSelector(getAllVariableUUIDsByEntity); const prompts = useSelector(getPrompts); @@ -51,7 +57,9 @@ const withPrompt = (WrappedComponent) => { }; const promptBackward = () => { - updatePrompt((promptIndex - 1 + processedPrompts.length) % processedPrompts.length); + updatePrompt( + (promptIndex - 1 + processedPrompts.length) % processedPrompts.length, + ); }; const prompt = () => { @@ -82,7 +90,8 @@ const withPrompt = (WrappedComponent) => { export const usePrompts = () => { const dispatch = useDispatch(); - const updatePrompt = (promptIndex) => dispatch(sessionActions.updatePrompt(promptIndex)); + const updatePrompt = (promptIndex) => + dispatch(sessionActions.updatePrompt(promptIndex)); const codebookVariables = useSelector(getAllVariableUUIDsByEntity); const prompts = useSelector(getPrompts); @@ -98,7 +107,9 @@ export const usePrompts = () => { }; const promptBackward = () => { - updatePrompt((promptIndex - 1 + processedPrompts.length) % processedPrompts.length); + updatePrompt( + (promptIndex - 1 + processedPrompts.length) % processedPrompts.length, + ); }; const currentPrompt = () => { @@ -114,7 +125,6 @@ export const usePrompts = () => { isLastPrompt, isFirstPrompt, }; -} - +}; -export default withPrompt; \ No newline at end of file +export default withPrompt; diff --git a/lib/interviewer/components/Canvas/__tests__/ConvexHulls.test.js b/lib/interviewer/components/Canvas/__tests__/ConvexHulls.test.js index 6614a5e8..d179b91d 100644 --- a/lib/interviewer/components/Canvas/__tests__/ConvexHulls.test.js +++ b/lib/interviewer/components/Canvas/__tests__/ConvexHulls.test.js @@ -1,6 +1,5 @@ /* eslint-env jest */ - import React from 'react'; import { shallow } from 'enzyme'; import { createStore } from 'redux'; @@ -9,7 +8,10 @@ import ConvexHulls from '../ConvexHulls'; const mockState = { activeSessionId: '62415a79-cd46-409a-98b3-5a0a2fef1f97', - activeSessionWorkers: { nodeLabelWorker: 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c' }, + activeSessionWorkers: { + nodeLabelWorker: + 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c', + }, deviceSettings: { description: 'Kirby (macOS)', useDynamicScaling: true, @@ -30,7 +32,6 @@ const mockState = { stages: [], }, }, - pairedServer: null, search: { collapsed: true, selectedResults: [], @@ -50,7 +51,14 @@ const mockState = { describe('', () => { it('renders a ConvexHull for each group', () => { - const props = { nodesByGroup: { groupA: [], groupB: [] }, layoutVariable: '' }; - expect(shallow( mockState)} />).find('ConvexHull')).toHaveLength(2); + const props = { + nodesByGroup: { groupA: [], groupB: [] }, + layoutVariable: '', + }; + expect( + shallow( + mockState)} />, + ).find('ConvexHull'), + ).toHaveLength(2); }); }); diff --git a/lib/interviewer/components/CardList.js b/lib/interviewer/components/CardList.js index a06594c7..5bacb0f5 100644 --- a/lib/interviewer/components/CardList.js +++ b/lib/interviewer/components/CardList.js @@ -135,12 +135,6 @@ class CardList extends Component { items, // we don't want the following props polluting `rest` which is used to indicate // prop changes like `sortBy` - dispatch, - details, - label, - onItemClick, - isItemSelected, - getKey, ...rest } = this.props; diff --git a/lib/interviewer/components/Cards/ServerCard.js b/lib/interviewer/components/Cards/ServerCard.js deleted file mode 100644 index 0856456a..00000000 --- a/lib/interviewer/components/Cards/ServerCard.js +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { motion } from 'framer-motion'; -import { ServerCard as UIServerCard } from '~/lib/ui/components/Cards'; - -const ServerCard = (props) => { - const { - name, - host, - addresses, - handleServerCardClick, - disabled, - } = props; - - return ( - - - - ); -}; - -export default ServerCard; diff --git a/lib/interviewer/components/CollapsablePrompts.js b/lib/interviewer/components/CollapsablePrompts.js index 7875d7a0..c8b612ea 100644 --- a/lib/interviewer/components/CollapsablePrompts.js +++ b/lib/interviewer/components/CollapsablePrompts.js @@ -4,8 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'; import Prompts from './Prompts'; const CollapsablePrompts = React.memo((props) => { - const { prompts, currentPromptIndex, interfaceRef, handleResetInterface } = - props; + const { prompts, currentPromptIndex, interfaceRef } = props; const ref = useRef(null); const [minimized, setMinimized] = useState(false); @@ -27,7 +26,7 @@ const CollapsablePrompts = React.memo((props) => { if (minimized) { setMinimized(false); } - }, [currentPromptIndex]); + }, [currentPromptIndex, minimized]); return ( ( undefined, onItemClick = () => undefined, - onDrag = () => undefined, }) => { - const [items, setItems] = useState(createSorter(sortOrder)(initialItems)); - const [stagger, setStagger] = useState(true); + const [items] = useState(createSorter(sortOrder)(initialItems)); + const [stagger] = useState(true); const instanceId = useRef(v4()); const isSource = !!find(items, [ diff --git a/lib/interviewer/components/RealtimeCanvas/EdgeLayout.js b/lib/interviewer/components/RealtimeCanvas/EdgeLayout.js index 72378217..32e59ea2 100644 --- a/lib/interviewer/components/RealtimeCanvas/EdgeLayout.js +++ b/lib/interviewer/components/RealtimeCanvas/EdgeLayout.js @@ -14,16 +14,22 @@ const EdgeLayout = () => { getPosition, } = useContext(LayoutContext); const timer = useRef(); - const edgeDefinitions = useSelector((state) => getProtocolCodebook(state).edge); + const edgeDefinitions = useSelector( + (state) => getProtocolCodebook(state).edge, + ); const update = useRef(() => { lines.current.forEach(({ link, el }) => { - if (!link) { return; } + if (!link) { + return; + } const from = getPosition.current(link.source); const to = getPosition.current(link.target); - if (!from || !to) { return; } + if (!from || !to) { + return; + } el.setAttributeNS(null, 'x1', from.x * 100); el.setAttributeNS(null, 'y1', from.y * 100); @@ -35,22 +41,30 @@ const EdgeLayout = () => { }); useEffect(() => { - if (!svg.current) { return () => cancelAnimationFrame(timer.current); } + const currentSvg = svg.current; + + if (!currentSvg) { + return () => cancelAnimationFrame(timer.current); + } lines.current = edges.map((edge, index) => { - const svgNS = svg.current.namespaceURI; + const svgNS = currentSvg.namespaceURI; const el = document.createElementNS(svgNS, 'line'); - const color = get(edgeDefinitions, [edge.type, 'color'], 'edge-color-seq-1'); + const color = get( + edgeDefinitions, + [edge.type, 'color'], + 'edge-color-seq-1', + ); el.setAttributeNS(null, 'stroke', `var(--nc-${color})`); return { edge, el, link: links[index] }; }); - lines.current.forEach(({ el }) => svg.current.appendChild(el)); + lines.current.forEach(({ el }) => currentSvg.appendChild(el)); timer.current = requestAnimationFrame(() => update.current()); return () => { - lines.current.forEach(({ el }) => svg.current.removeChild(el)); + lines.current.forEach(({ el }) => currentSvg.removeChild(el)); cancelAnimationFrame(timer.current); }; }, [edges, links, edgeDefinitions]); diff --git a/lib/interviewer/components/RealtimeCanvas/LayoutNode.js b/lib/interviewer/components/RealtimeCanvas/LayoutNode.js index 39b98c41..d7bea3e0 100644 --- a/lib/interviewer/components/RealtimeCanvas/LayoutNode.js +++ b/lib/interviewer/components/RealtimeCanvas/LayoutNode.js @@ -36,15 +36,25 @@ const LayoutNode = ({ dragManager.current.unmount(); } }; - }, [portal, index, onDragStart, onDragMove, onDragEnd]); + }, [ + portal, + index, + onDragStart, + onDragMove, + onDragEnd, + allowPositioning, + node, + ]); useEffect(() => { const handleSelected = () => onSelected(node); portal.addEventListener('click', handleSelected); - return () => { portal.removeEventListener('click', handleSelected); }; - }, [onSelected, node]); + return () => { + portal.removeEventListener('click', handleSelected); + }; + }, [onSelected, node, portal]); return ReactDOM.createPortal( typeof TouchEvent !== 'undefined' && event instanceof TouchEvent; +const isTouch = (event) => + typeof TouchEvent !== 'undefined' && event instanceof TouchEvent; const getCoords = (event) => { if (isTouch(event)) { @@ -41,28 +42,35 @@ const useDrag = () => { dy, dx, }; - }); + }, []); const handleDragStart = useCallback((e, id) => { state.current.id = id; state.current.lastPosition = getCoords(e); - }); - - const handleDragMove = useCallback((e) => { - if (!state.current.id) { return; } - state.current.move = getMove(e); - state.current.hasMoved = true; - }); + }, []); + + const handleDragMove = useCallback( + (e) => { + if (!state.current.id) { + return; + } + state.current.move = getMove(e); + state.current.hasMoved = true; + }, + [getMove], + ); const handleDragEnd = useCallback(() => { - if (!state.current.id) { return; } + if (!state.current.id) { + return; + } state.current.id = null; // TODO: capture last move // hasMoved = false; // move = null; // move = getMove(e); // console.log('end', getMove(e)); - }); + }, []); const getDelta = useCallback(() => { if (!state.current.hasMoved) { @@ -89,7 +97,7 @@ const useDrag = () => { state.current.hasMoved = false; return delta; - }); + }, []); return { state, diff --git a/lib/interviewer/components/__tests__/CardList.test.js b/lib/interviewer/components/__tests__/CardList.test.js index 8889c2dc..dfcbe66f 100644 --- a/lib/interviewer/components/__tests__/CardList.test.js +++ b/lib/interviewer/components/__tests__/CardList.test.js @@ -1,6 +1,5 @@ /* eslint-env jest */ - import React from 'react'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; @@ -11,7 +10,10 @@ jest.mock('~/lib/ui/utils/CSSVariables'); const mockState = { activeSessionId: '62415a79-cd46-409a-98b3-5a0a2fef1f97', - activeSessionWorkers: { nodeLabelWorker: 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c' }, + activeSessionWorkers: { + nodeLabelWorker: + 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c', + }, deviceSettings: { description: 'Kirby (macOS)', useDynamicScaling: true, @@ -32,7 +34,6 @@ const mockState = { stages: [], }, }, - pairedServer: null, search: { collapsed: true, selectedResults: [], @@ -64,7 +65,7 @@ describe('CardList component', () => { nodes={nodes} label={(node) => node.name} details={(node) => [{ age: `${node.age}` }]} - onToggleCard={() => { }} + onToggleCard={() => {}} selected={() => false} /> , diff --git a/lib/interviewer/containers/Canvas/__tests__/ConvexHulls.test.js b/lib/interviewer/containers/Canvas/__tests__/ConvexHulls.test.js index b52a4d8a..afbe70b1 100644 --- a/lib/interviewer/containers/Canvas/__tests__/ConvexHulls.test.js +++ b/lib/interviewer/containers/Canvas/__tests__/ConvexHulls.test.js @@ -1,6 +1,5 @@ /* eslint-env jest */ - import React from 'react'; import { shallow } from 'enzyme'; import { createStore } from 'redux'; @@ -10,7 +9,10 @@ import ConvexHulls from '../ConvexHulls'; describe('Connect(ConvexHulls)', () => { const mockState = { activeSessionId: '62415a79-cd46-409a-98b3-5a0a2fef1f97', - activeSessionWorkers: { nodeLabelWorker: 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c' }, + activeSessionWorkers: { + nodeLabelWorker: + 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c', + }, deviceSettings: { description: 'Kirby (macOS)', useDynamicScaling: true, @@ -28,15 +30,16 @@ describe('Connect(ConvexHulls)', () => { description: '', forms: {}, lastModified: '2018-10-01T00:00:00.000Z', - stages: [{ - subject: { - entity: 'node', - type: 'person', + stages: [ + { + subject: { + entity: 'node', + type: 'person', + }, }, - }], + ], }, }, - pairedServer: null, search: { collapsed: true, selectedResults: [], @@ -57,7 +60,9 @@ describe('Connect(ConvexHulls)', () => { subject: {}, nodes: [], }; - const subject = shallow( mockState)} />); + const subject = shallow( + mockState)} />, + ); it('provides a nodesByGroup prop', () => { expect(subject.props().children.props.nodesByGroup).toBeDefined(); diff --git a/lib/interviewer/containers/Field.js b/lib/interviewer/containers/Field.js index 24e2769c..9eff17cb 100644 --- a/lib/interviewer/containers/Field.js +++ b/lib/interviewer/containers/Field.js @@ -54,7 +54,7 @@ const Field = ({ label = '', name, validation = {}, ...rest }) => { ); const validate = useMemo( () => rest.validate || getValidation(validation, store), - [], + [rest.validate, store, validation], ); return ( diff --git a/lib/interviewer/containers/HyperList/HyperList.js b/lib/interviewer/containers/HyperList/HyperList.js index 44e8020b..9a9ada35 100644 --- a/lib/interviewer/containers/HyperList/HyperList.js +++ b/lib/interviewer/containers/HyperList/HyperList.js @@ -1,17 +1,15 @@ -/* eslint-disable no-nested-ternary */ -import React, { - useContext, - useMemo, - useCallback, -} from 'react'; -import { compose } from 'recompose'; +import cx from 'classnames'; import { AnimatePresence, motion } from 'framer-motion'; import PropTypes from 'prop-types'; +import React, { useContext, useMemo } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { VariableSizeList as List } from 'react-window'; -import cx from 'classnames'; -import { DragSource, DropTarget, MonitorDropTarget } from '../../behaviours/DragAndDrop'; -import { simpleRenderToString } from '~/utils/simpleRenderToString'; +import { compose } from 'recompose'; +import { + DragSource, + DropTarget, + MonitorDropTarget, +} from '../../behaviours/DragAndDrop'; const LargeRosterNotice = () => (
({ - index, - style, -}) => { - const { - items, - itemType, - dynamicProperties, - } = useContext(ListContext); +const getRowRenderer = (Component, DragComponent, allowDragging) => { + const GetRowRenderContent = ({ index, style }) => { + const { items, itemType, dynamicProperties } = useContext(ListContext); - const item = items[index]; + const item = items[index]; - if (!item) { return null; } + if (!item) { + return null; + } - const { id, data, props } = item; - const { disabled } = dynamicProperties; + const { id, data, props } = item; + const { disabled } = dynamicProperties; - const isDisabled = disabled && disabled.includes(id); + const isDisabled = disabled && disabled.includes(id); - const preview = DragComponent - ? - : null; + const preview = DragComponent ? : null; - return ( -
- ({ data, id, itemType })} - disabled={isDisabled} - allowDrag={allowDragging && !isDisabled} - preview={preview} - /> -
- ); + return ( +
+ ({ data, id, itemType })} + disabled={isDisabled} + allowDrag={allowDragging && !isDisabled} + preview={preview} + /> +
+ ); + }; + + return GetRowRenderContent; }; /** - * Renders an arbitrary list of items using itemComponent. - * - * Includes drag and drop functionality. - * - * @prop {Array} items Items in format [{ id, props: {}, data: {} }, ...] - * @prop {Object} dynamicProperties Can be used for mutating properties, - * that aren't necessarily part of item data. This is because items may - * go through several filters before reaching HyperList, and these dynamic - * properties may not be relevant (e.g. recomputing search results when - * item values haven't changed). Currently only used to get the list of - * disabled items. - * @prop {React Component} emptyComponent React component to render when items is an empty array. - * @prop {React Component} itemComponent React component, rendered with `{ props }` from item. - * `{ data }`, `id`, and `itemType` is passed to the drag and drop state. - * @prop {React node} placeholder React node. If provided will override rendering of - * items/emptyComponent and will be rendered instead. - * example usage: `placeholder
)} />` - * @prop {number} columns Number of columns - * @prop {string} itemType itemType used by drag and drop functionality - */ + * Renders an arbitrary list of items using itemComponent. + * + * Includes drag and drop functionality. + * + * @prop {Array} items Items in format [{ id, props: {}, data: {} }, ...] + * @prop {Object} dynamicProperties Can be used for mutating properties, + * that aren't necessarily part of item data. This is because items may + * go through several filters before reaching HyperList, and these dynamic + * properties may not be relevant (e.g. recomputing search results when + * item values haven't changed). Currently only used to get the list of + * disabled items. + * @prop {React Component} emptyComponent React component to render when items is an empty array. + * @prop {React Component} itemComponent React component, rendered with `{ props }` from item. + * `{ data }`, `id`, and `itemType` is passed to the drag and drop state. + * @prop {React node} placeholder React node. If provided will override rendering of + * items/emptyComponent and will be rendered instead. + * example usage: `placeholder)} />` + * @prop {number} columns Number of columns + * @prop {string} itemType itemType used by drag and drop functionality + */ const HyperList = ({ className, @@ -125,29 +119,36 @@ const HyperList = ({ allowDragging, }) => { const RowRenderer = useMemo( - () => getRowRenderer(DragSource(ItemComponent), DragComponent, allowDragging), + () => + getRowRenderer(DragSource(ItemComponent), DragComponent, allowDragging), [ItemComponent, DragComponent, allowDragging], ); - const context = useMemo(() => ({ - items, - dynamicProperties, - itemType, - }), [items, dynamicProperties, itemType]); - - const classNames = cx( - 'hyper-list', - className, + const context = useMemo( + () => ({ + items, + dynamicProperties, + itemType, + }), + [items, dynamicProperties, itemType], ); + const classNames = cx('hyper-list', className); + const getItemSize = (item, listWidth) => { - if (!listWidth) { return 0; } + if (!listWidth) { + return 0; + } - const itemData = items[item]; - const { props } = itemData; + // const itemData = items[item]; + // const { props } = itemData; - const sizingElement = simpleRenderToString(
); - const height = sizingElement.clientHeight; + // const sizingElement = simpleRenderToString( + //
+ // + //
, + // ); + // const height = sizingElement.clientHeight; // return height + GUTTER_SIZE; @@ -172,39 +173,41 @@ const HyperList = ({
- - {showPlaceholder ? placeholder : ( - showEmpty ? : ( - - {(containerSize) => { - if (!showResults) { return null; } - return ( - getItemSize(item, containerSize.width)} - estimatedItemSize={getItemSize(0)} - itemCount={items.length} - > - {RowRenderer} - - ); - }} - - ) + + {showPlaceholder ? ( + placeholder + ) : showEmpty ? ( + + ) : ( + + {(containerSize) => { + if (!showResults) { + return null; + } + return ( + + getItemSize(item, containerSize.width) + } + estimatedItemSize={getItemSize(0)} + itemCount={items.length} + > + {RowRenderer} + + ); + }} + )}
- - {showTooMany && ( - - )} - + {showTooMany && } ); }; diff --git a/lib/interviewer/containers/HyperList/useGridSizer.js b/lib/interviewer/containers/HyperList/useGridSizer.js index fd1e8876..1210a09a 100644 --- a/lib/interviewer/containers/HyperList/useGridSizer.js +++ b/lib/interviewer/containers/HyperList/useGridSizer.js @@ -77,7 +77,7 @@ const useGridSizer = ( setHiddenSizingElement(newHiddenSizingEl); return () => document.body.removeChild(newHiddenSizingEl); - }, []); + }, [hiddenSizingEl, id]); const rowHeight = useCallback( (rowIndex) => { @@ -101,7 +101,7 @@ const useGridSizer = ( return height > 0 ? height + 14 : defaultHeight; }, - [hiddenSizingEl, items, columnWidth()], + [hiddenSizingEl, items, columnWidth, ItemComponent, columns, defaultHeight], ); return [ diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js b/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js index 43834f1d..5a75fefd 100644 --- a/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js +++ b/lib/interviewer/containers/Interfaces/DyadCensus/DyadCensus.js @@ -31,7 +31,11 @@ const optionsVariants = { }; const choiceVariants = { - show: { opacity: 1, translateY: '0%', transition: { delay: 0.15, type: 'spring' } }, + show: { + opacity: 1, + translateY: '0%', + transition: { delay: 0.15, type: 'spring' }, + }, hide: { opacity: 0, translateY: '120%' }, }; @@ -41,8 +45,8 @@ const introVariants = { }; /** - * Dyad Census Interface - */ + * Dyad Census Interface + */ const DyadCensus = ({ registerBeforeNext, promptId: promptIndex, // TODO: what is going on here? @@ -79,7 +83,7 @@ const DyadCensus = ({ [stepsState.step], ); - const next = () => { + const next = useCallback(() => { setForwards(true); setIsValid(true); @@ -104,12 +108,22 @@ const DyadCensus = ({ navigationActions.moveForward(); } - if (stepsState.isEnd) { return; } + if (stepsState.isEnd) { + return; + } nextStep(); - }; - - const back = () => { + }, [ + hasEdge, + isIntroduction, + navigationActions, + nextStep, + stepsState.isEnd, + stepsState.isStageEnd, + stepsState.totalSteps, + ]); + + const back = useCallback(() => { setForwards(false); setIsValid(true); @@ -122,152 +136,154 @@ const DyadCensus = ({ navigationActions.moveBackward(); } - if (stepsState.isStart) { return; } - - previousStep(); - }; - - const beforeNext = useCallback((direction, index = -1) => { - if (index !== -1) { - onComplete(); + if (stepsState.isStart) { return; } - if (direction < 0) { - back(); - return; - } + previousStep(); + }, [ + isIntroduction, + navigationActions, + previousStep, + stepsState.isStageStart, + stepsState.isStart, + ]); + + const beforeNext = useCallback( + (direction, index = -1) => { + if (index !== -1) { + onComplete(); + return; + } - next(); - }, [back, next]); + if (direction < 0) { + back(); + return; + } + + next(); + }, + [back, next, onComplete], + ); useEffect(() => { registerBeforeNext(beforeNext); - }, [beforeNext]); + }, [registerBeforeNext, beforeNext]); useAutoAdvance(next, isTouched, isChanged); const handleChange = (nextValue) => () => { // 'debounce' clicks, one click (isTouched) should start auto-advance // so ignore further clicks - if (isTouched) { return; } + if (isTouched) { + return; + } setEdge(nextValue); }; - const choiceClasses = cx( - 'dyad-census__choice', - { 'dyad-census__choice--invalid': !isValid }, - ); + const choiceClasses = cx('dyad-census__choice', { + 'dyad-census__choice--invalid': !isValid, + }); return (
- - {isIntroduction - && ( - -

{stage.introductionPanel.title}

- + {isIntroduction && ( + +

{stage.introductionPanel.title}

+ +
+ )} + {!isIntroduction && ( + +
+ - - )} - {!isIntroduction - && ( - -
- -
- - -
-
- - - -
- -
- - -
-
-
-
-

Yes

} - /> -

No

} - negative - /> -
+
+ + +
+
+ + + +
+ +
+ + +
+
+
+
+

Yes

} + /> +

No

} + negative + />
- - -
-
-
-
- - - )} +
+
+
+
+ +
+
+
+ + )}
); @@ -301,11 +317,6 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -export default compose( - withPrompt, - connect(makeMapStateToProps), -)(DyadCensus); +export default compose(withPrompt, connect(makeMapStateToProps))(DyadCensus); -export { - DyadCensus as UnconnectedDyadCensus, -}; +export { DyadCensus as UnconnectedDyadCensus }; diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/useAutoAdvance.js b/lib/interviewer/containers/Interfaces/DyadCensus/useAutoAdvance.js index c9ce8b35..6aafd1ba 100644 --- a/lib/interviewer/containers/Interfaces/DyadCensus/useAutoAdvance.js +++ b/lib/interviewer/containers/Interfaces/DyadCensus/useAutoAdvance.js @@ -37,7 +37,7 @@ const useAutoAdvance = (_next, isTouched, isChanged) => { } return clearTimeout(timer.current); }; - }, [isTouched]); + }, [isTouched, delay, isChanged]); }; export default useAutoAdvance; diff --git a/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.js b/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.js index 7d0b0fdd..f34cb4a7 100644 --- a/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.js +++ b/lib/interviewer/containers/Interfaces/DyadCensus/useEdgeState.js @@ -3,29 +3,36 @@ import { useState, useEffect } from 'react'; import { actionCreators as sessionActions } from '../../../ducks/modules/session'; export const getEdgeInNetwork = (edges, pair, edgeType) => { - if (!pair) { return null; } + if (!pair) { + return null; + } const [a, b] = pair; - const edge = edges.find(({ from, to, type }) => ( - type === edgeType - && ((from === a && to === b) || (to === a && from === b)) - )); + const edge = edges.find( + ({ from, to, type }) => + type === edgeType && + ((from === a && to === b) || (to === a && from === b)), + ); - if (!edge) { return null; } + if (!edge) { + return null; + } return edge; }; -export const matchEntry = (prompt, pair) => ([p, a, b]) => ( - (p === prompt && a === pair[0] && b === pair[1]) - || (p === prompt && b === pair[0] && a === pair[1]) -); +export const matchEntry = + (prompt, pair) => + ([p, a, b]) => + (p === prompt && a === pair[0] && b === pair[1]) || + (p === prompt && b === pair[0] && a === pair[1]); export const getIsPreviouslyAnsweredNo = (state, prompt, pair) => { - if (!state || pair.length !== 2) { return false; } + if (!state || pair.length !== 2) { + return false; + } - const answer = state - .find(matchEntry(prompt, pair)); + const answer = state.find(matchEntry(prompt, pair)); if (answer && answer[3] === false) { return true; @@ -76,10 +83,14 @@ const useEdgeState = ( const [isChanged, setIsChanged] = useState(false); const getHasEdge = () => { - if (!pair) { return null; } + if (!pair) { + return null; + } // Either we set a value for this or it already has an edge - if (edgeState !== null) { return !!edgeState; } + if (edgeState !== null) { + return !!edgeState; + } // Check if this pair was marked as no before if (getIsPreviouslyAnsweredNo(stageState, promptIndex, pair)) { @@ -91,7 +102,9 @@ const useEdgeState = ( }; const setEdge = (value = true) => { - if (!pair) { return; } + if (!pair) { + return; + } const existingEdge = getEdgeInNetwork(edges, pair, edgeType); @@ -102,12 +115,20 @@ const useEdgeState = ( const addEdge = value && !existingEdge; const removeEdge = !value && existingEdge; - const newStageState = stageStateReducer(stageState, { pair, prompt: promptIndex, value }); + const newStageState = stageStateReducer(stageState, { + pair, + prompt: promptIndex, + value, + }); if (addEdge) { - dispatch(sessionActions.addEdge({ from: pair[0], to: pair[1], type: edgeType })); + dispatch( + sessionActions.addEdge({ from: pair[0], to: pair[1], type: edgeType }), + ); } else if (removeEdge) { - dispatch(sessionActions.removeEdge(existingEdge[entityPrimaryKeyProperty])); + dispatch( + sessionActions.removeEdge(existingEdge[entityPrimaryKeyProperty]), + ); } dispatch(sessionActions.updateStageState(newStageState)); @@ -119,6 +140,7 @@ const useEdgeState = ( setEdgeState(getEdgeInNetwork(edges, pair, edgeType)); setIsTouched(false); setIsChanged(false); + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return [getHasEdge(), setEdge, isTouched, isChanged]; diff --git a/lib/interviewer/containers/Interfaces/EgoForm.js b/lib/interviewer/containers/Interfaces/EgoForm.js index a62744aa..70b8a693 100644 --- a/lib/interviewer/containers/Interfaces/EgoForm.js +++ b/lib/interviewer/containers/Interfaces/EgoForm.js @@ -87,52 +87,61 @@ const EgoForm = ({ setIsOverflowing(elementHasOverflow(element)); }, []); - const submitForm = () => { + const submitForm = useCallback(() => { reduxFormSubmit(formName); - }; + }, [formName, reduxFormSubmit]); - const checkShouldProceed = () => { + const checkShouldProceed = useCallback(() => { if (!formState.current.isFormDirty) { return Promise.resolve(true); } return openDialog(confirmDialog); - }; + }, [openDialog]); - const onConfirmProceed = (confirm) => { - if (confirm) { - onComplete(); - return; - } + const onConfirmProceed = useCallback( + (confirm) => { + if (confirm) { + onComplete(); + return; + } - submitForm(); - }; + submitForm(); + }, + [onComplete, submitForm], + ); - const checkAndProceed = () => checkShouldProceed().then(onConfirmProceed); + const checkAndProceed = useCallback( + () => checkShouldProceed().then(onConfirmProceed), + [checkShouldProceed, onConfirmProceed], + ); - const beforeNext = (direction, index = -1) => { - const isPendingStageChange = index !== -1; - const isBackwards = direction < 0; + const beforeNext = useCallback( + (direction, index = -1) => { + const isPendingStageChange = index !== -1; + const isBackwards = direction < 0; + + if (isPendingStageChange) { + if (formState.current.isFormValid) { + submitForm(); + } else { + checkAndProceed(); + } + return; + } - if (isPendingStageChange) { - if (formState.current.isFormValid) { - submitForm(); - } else { + if (!isFirstStage && isBackwards && !formState.current.isFormValid) { checkAndProceed(); + return; } - return; - } - - if (!isFirstStage && isBackwards && !formState.current.isFormValid) { - checkAndProceed(); - return; - } - submitForm(); - }; + submitForm(); + }, + [checkAndProceed, isFirstStage, submitForm], + ); useEffect(() => { registerBeforeNext(beforeNext); - }, []); + }, [beforeNext, registerBeforeNext]); const handleSubmitForm = (formData) => { updateEgo({}, formData); @@ -140,10 +149,11 @@ const EgoForm = ({ }; const updateReadyStatus = useCallback( - debounce((progress) => { - const nextIsReady = isFormValid && progress === 1; - setIsReadyForNext(nextIsReady); - }, 200), + () => + debounce((progress) => { + const nextIsReady = isFormValid && progress === 1; + setIsReadyForNext(nextIsReady); + }, 200), [isFormValid, setIsReadyForNext], ); @@ -154,14 +164,14 @@ const EgoForm = ({ updateReadyStatus(progress); }, - [isFormValid, setShowScrollStatus, setScrollProgress, updateReadyStatus], + [setShowScrollStatus, setScrollProgress, updateReadyStatus], ); useEffect(() => { if (!isFormValid) { setIsReadyForNext(false); } - }, [isFormValid]); + }, [isFormValid, setIsReadyForNext]); const showScrollNudge = useMemo( () => scrollProgress !== 1 && showScrollStatus && isOverflowing, diff --git a/lib/interviewer/containers/Interfaces/FinishSession.js b/lib/interviewer/containers/Interfaces/FinishSession.js index 2da637f1..cbf5551b 100644 --- a/lib/interviewer/containers/Interfaces/FinishSession.js +++ b/lib/interviewer/containers/Interfaces/FinishSession.js @@ -1,22 +1,23 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; +import Button from '~/lib/ui/components/Button'; +import FinishInterviewModal from '~/app/(interview)/interview/_components/FinishInterviewModal'; -const FinishSession = ({ endSession }) => { +const FinishSession = () => { const dispatch = useDispatch(); - const handleFinishSession = () => { - // eslint-disable-next-line no-console - console.log( - 'handleFinishSession /lib/interviewer/containers/Interfaces/FinishSession.js', - ); - // endSession(false, true); - }; + const [openFinishInterviewModal, setOpenFinishInterviewModal] = + useState(false); useEffect(() => { dispatch({ type: 'PLAY_SOUND', sound: 'finishSession' }); - }, []); + }, [dispatch]); return (
+

Finish Interview @@ -27,6 +28,12 @@ const FinishSession = ({ endSession }) => { the information you have entered, you may finish the interview now.

+ +
+ +
); diff --git a/lib/interviewer/containers/Interfaces/NameGenerator.js b/lib/interviewer/containers/Interfaces/NameGenerator.js index 581a56f9..f1dd0553 100644 --- a/lib/interviewer/containers/Interfaces/NameGenerator.js +++ b/lib/interviewer/containers/Interfaces/NameGenerator.js @@ -1,22 +1,32 @@ import React, { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; -import { - has, isUndefined, omit, -} from 'lodash'; +import { has, isUndefined, omit } from 'lodash'; import { createPortal } from 'react-dom'; -import { entityAttributesProperty, entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '@codaco/shared-consts'; import Prompts from '../../components/Prompts'; import { usePrompts } from '../../behaviours/withPrompt'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; -import { getStageNodeCount, getNetworkNodesForPrompt } from '../../selectors/interface'; -import { getPromptModelData as getPromptNodeModelData, getNodeIconName } from '../../selectors/name-generator'; +import { + getStageNodeCount, + getNetworkNodesForPrompt, +} from '../../selectors/interface'; +import { + getPromptModelData as getPromptNodeModelData, + getNodeIconName, +} from '../../selectors/name-generator'; import NodePanels from '../NodePanels'; import NodeForm from '../NodeForm'; import NodeList from '~/lib/interviewer/components/NodeList'; import NodeBin from '~/lib/interviewer/components/NodeBin'; import { - MaxNodesMet, maxNodesWithDefault, MinNodesNotMet, minNodesWithDefault, + MaxNodesMet, + maxNodesWithDefault, + MinNodesNotMet, + minNodesWithDefault, } from './utils/StageLevelValidation'; import { get } from '../../utils/lodash-replacements'; import QuickNodeForm from '../QuickNodeForm'; @@ -25,27 +35,13 @@ import usePropSelector from '../../hooks/usePropSelector'; import { getAdditionalAttributesSelector } from '../../selectors/prop'; const NameGenerator = (props) => { - const { - registerBeforeNext, - onComplete, - stage, - } = props; - - const { - form, - quickAdd, - behaviours, - subject, - } = stage; + const { registerBeforeNext, onComplete, stage } = props; + + const { form, quickAdd, behaviours, subject } = stage; const interfaceRef = useRef(null); - const { - currentPrompt, - isFirstPrompt, - isLastPrompt, - prompts - } = usePrompts(); + const { currentPrompt, isFirstPrompt, isLastPrompt } = usePrompts(); const [selectedNode, setSelectedNode] = useState(null); const [showMinWarning, setShowMinWarning] = useState(false); @@ -54,7 +50,10 @@ const NameGenerator = (props) => { const maxNodes = maxNodesWithDefault(behaviours?.maxNodes); const stageNodeCount = usePropSelector(getStageNodeCount, props); // 1 - const newNodeAttributes = usePropSelector(getAdditionalAttributesSelector, props); // 2 + const newNodeAttributes = usePropSelector( + getAdditionalAttributesSelector, + props, + ); // 2 const newNodeModelData = usePropSelector(getPromptNodeModelData, props); // 3 const nodesForPrompt = usePropSelector(getNetworkNodesForPrompt, props); // 4 const nodeIconName = usePropSelector(getNodeIconName, props); @@ -63,10 +62,10 @@ const NameGenerator = (props) => { const dispatch = useDispatch(); - const addNode = (...properties) => dispatch(sessionActions.addNode(...properties)); - const addNodeToPrompt = (...properties) => dispatch( - sessionActions.addNodeToPrompt(...properties), - ); + const addNode = (...properties) => + dispatch(sessionActions.addNode(...properties)); + const addNodeToPrompt = (...properties) => + dispatch(sessionActions.addNodeToPrompt(...properties)); const removeNode = (uid) => { dispatch(sessionActions.removeNode(uid)); }; @@ -81,13 +80,17 @@ const NameGenerator = (props) => { // Prevent leaving the stage if the minimum number of nodes has not been met const handleBeforeLeaving = (direction, destination) => { - const isLeavingStage = (isFirstPrompt && direction === -1) - || (isLastPrompt && direction === 1); + const isLeavingStage = + (isFirstPrompt && direction === -1) || (isLastPrompt && direction === 1); // Implementation quirk that destination is only provided when navigation // is triggered by Stages Menu. Use this to skip message if user has // navigated directly using stages menu. - if (isUndefined(destination) && isLeavingStage && stageNodeCount < minNodes) { + if ( + isUndefined(destination) && + isLeavingStage && + stageNodeCount < minNodes + ) { setShowMinWarning(true); return; } @@ -108,11 +111,9 @@ 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], - currentPrompt.id, - { ...newNodeAttributes }, - ); + addNodeToPrompt(node[entityPrimaryKeyProperty], currentPrompt.id, { + ...newNodeAttributes, + }); } else { const droppedAttributeData = node[entityAttributesProperty]; const droppedModelData = omit(node, entityAttributesProperty); @@ -139,7 +140,11 @@ const NameGenerator = (props) => {
- +
{ />
- {interfaceRef.current && createPortal( - , - interfaceRef.current, - )} - {interfaceRef.current && createPortal( setShowMinWarning(false)} - />, interfaceRef.current)} - - {form - && ( - setSelectedNode(null)} - /> + {interfaceRef.current && + createPortal( + , + interfaceRef.current, + )} + {interfaceRef.current && + createPortal( + setShowMinWarning(false)} + />, + interfaceRef.current, )} + + {form && ( + setSelectedNode(null)} + /> + )} {!form && ( { onComplete(); }, - [stageNodeCount, minNodes, onComplete], + [stageNodeCount, minNodes, onComplete, isFirstPrompt, isLastPrompt], ); registerBeforeNext(handleBeforeLeaving); diff --git a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useFuseOptions.js b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useFuseOptions.js index 0b51543b..1a64f3f3 100644 --- a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useFuseOptions.js +++ b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useFuseOptions.js @@ -21,7 +21,7 @@ const useFuseOptions = ( const keys = useMemo( () => matchProperties.map((property) => compact([...path, property])), - [matchProperties], + [matchProperties, path], ); if (!searchOptions || isEmpty(searchOptions)) { diff --git a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useItems.js b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useItems.js index 9883ead7..0ee7d47f 100644 --- a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useItems.js +++ b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useItems.js @@ -1,5 +1,8 @@ -import { useMemo } from 'react'; -import { entityAttributesProperty, entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { useCallback, useMemo } from 'react'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '@codaco/shared-consts'; import { getNetworkNodes, getNodeTypeDefinition, @@ -15,10 +18,7 @@ import { getEntityAttributes } from '../../../ducks/modules/network'; * Format details needed for list cards */ export const detailsWithVariableUUIDs = (props) => (node) => { - const { - nodeTypeDefinition, - visibleSupplementaryFields, - } = props; + const { nodeTypeDefinition, visibleSupplementaryFields } = props; const nodeTypeVariables = nodeTypeDefinition.variables; const attrs = getEntityAttributes(node); @@ -40,29 +40,47 @@ export const detailsWithVariableUUIDs = (props) => (node) => { // Returns all nodes associated with lists (external data) const useItems = (props) => { const nodeTypeDefinition = usePropSelector(getNodeTypeDefinition, props); - const [externalData, status] = useExternalData(props.stage.dataSource, props.stage.subject); + const [externalData, status] = useExternalData( + props.stage.dataSource, + props.stage.subject, + ); const networkNodes = usePropSelector(getNetworkNodes, props); - const visibleSupplementaryFields = usePropSelector(getCardAdditionalProperties, props); - const excludeItems = networkNodes.map((item) => item[entityPrimaryKeyProperty]); - const getNodeLabel = (node) => labelLogic(nodeTypeDefinition, node[entityAttributesProperty]); + const visibleSupplementaryFields = usePropSelector( + getCardAdditionalProperties, + props, + ); + const excludeItems = networkNodes.map( + (item) => item[entityPrimaryKeyProperty], + ); + const getNodeLabel = useCallback( + (node) => labelLogic(nodeTypeDefinition, node[entityAttributesProperty]), + [nodeTypeDefinition], + ); const items = useMemo(() => { - if (!externalData) { return []; } + if (!externalData) { + return []; + } - return externalData - .map((item) => ({ - id: item[entityPrimaryKeyProperty], - data: item, - props: { - label: getNodeLabel(item), - data: detailsWithVariableUUIDs({ - ...props, - nodeTypeDefinition, - visibleSupplementaryFields, - })(item), - }, - })); - }, [externalData, getNodeLabel, nodeTypeDefinition, visibleSupplementaryFields]); + return externalData.map((item) => ({ + id: item[entityPrimaryKeyProperty], + data: item, + props: { + label: getNodeLabel(item), + data: detailsWithVariableUUIDs({ + ...props, + nodeTypeDefinition, + visibleSupplementaryFields, + })(item), + }, + })); + }, [ + externalData, + getNodeLabel, + nodeTypeDefinition, + visibleSupplementaryFields, + props, + ]); return [status, items, excludeItems]; }; diff --git a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useSortableProperties.js b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useSortableProperties.js index 3f6f4a53..3f4559e1 100644 --- a/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useSortableProperties.js +++ b/lib/interviewer/containers/Interfaces/NameGeneratorRoster/useSortableProperties.js @@ -28,7 +28,7 @@ const useSortableProperties = ( property: compact([...path, property]), type: mapNCType(type), }; - }, [initialSortOrder]); + }, [initialSortOrder, initialSortProperty, variableDefinitions, path]); const enhancedSortableProperties = useMemo(() => { if (!sortableProperties) { @@ -43,7 +43,7 @@ const useSortableProperties = ( type: mapNCType(type), }; }); - }, [sortableProperties]); + }, [sortableProperties, variableDefinitions, path]); if (!sortOptions) { return { sortableProperties: [], initialSortOrder: undefined }; diff --git a/lib/interviewer/containers/Interfaces/TieStrengthCensus/TieStrengthCensus.js b/lib/interviewer/containers/Interfaces/TieStrengthCensus/TieStrengthCensus.js index a9c736ef..31f86158 100644 --- a/lib/interviewer/containers/Interfaces/TieStrengthCensus/TieStrengthCensus.js +++ b/lib/interviewer/containers/Interfaces/TieStrengthCensus/TieStrengthCensus.js @@ -31,7 +31,11 @@ const optionsVariants = { }; const choiceVariants = { - show: { opacity: 1, translateY: '0%', transition: { delay: 0.25, type: 'spring' } }, + show: { + opacity: 1, + translateY: '0%', + transition: { delay: 0.25, type: 'spring' }, + }, hide: { opacity: 0, translateY: '120%' }, }; @@ -41,8 +45,8 @@ const introVariants = { }; /** - * Dyad Census Interface - */ + * Dyad Census Interface + */ const TieStrengthCensus = (props) => { const { registerBeforeNext, @@ -82,18 +86,19 @@ const TieStrengthCensus = (props) => { // - false: user denied // - null: not yet decided // - true: edge exists - const [hasEdge, edgeVariableValue, setEdge, isTouched, isChanged] = useNetworkEdgeState( - dispatch, - edges, - createEdge, // Type of edge to create - edgeVariable, // Edge ordinal variable - pair, - promptIndex, - stageState, - [stepsState.step], - ); + const [hasEdge, edgeVariableValue, setEdge, isTouched, isChanged] = + useNetworkEdgeState( + dispatch, + edges, + createEdge, // Type of edge to create + edgeVariable, // Edge ordinal variable + pair, + promptIndex, + stageState, + [stepsState.step], + ); - const next = () => { + const next = useCallback(() => { setForwards(true); setIsValid(true); @@ -120,12 +125,23 @@ const TieStrengthCensus = (props) => { navigateActions.moveForward(); } - if (stepsState.isEnd) { return; } + if (stepsState.isEnd) { + return; + } nextStep(); - }; - - const back = () => { + }, [ + edgeVariableValue, + hasEdge, + isIntroduction, + navigateActions, + nextStep, + stepsState.isEnd, + stepsState.isStageEnd, + stepsState.totalSteps, + ]); + + const back = useCallback(() => { setForwards(false); setIsValid(true); @@ -138,161 +154,168 @@ const TieStrengthCensus = (props) => { navigateActions.moveBackward(); } - if (stepsState.isStart) { return; } - - previousStep(); - }; - - const beforeNext = useCallback((direction, index = -1) => { - if (index !== -1) { - onComplete(); + if (stepsState.isStart) { return; } - if (direction < 0) { - back(); - return; - } + previousStep(); + }, [ + isIntroduction, + navigateActions, + previousStep, + stepsState.isStageStart, + stepsState.isStart, + ]); + + const beforeNext = useCallback( + (direction, index = -1) => { + if (index !== -1) { + onComplete(); + return; + } + + if (direction < 0) { + back(); + return; + } - next(); - }, [back, next]); + next(); + }, + [back, next, onComplete], + ); useEffect(() => { registerBeforeNext(beforeNext); - }, [beforeNext]); + }, [beforeNext, registerBeforeNext]); useAutoAdvance(next, isTouched, isChanged); const handleChange = (nextValue) => () => { // 'debounce' clicks, one click (isTouched) should start auto-advance // so ignore further clicks - if (isTouched) { return; } + if (isTouched) { + return; + } setEdge(nextValue); }; - const choiceClasses = cx( - 'tie-strength-census__choice', - { 'tie-strength-census__choice--invalid': !isValid }, - ); + const choiceClasses = cx('tie-strength-census__choice', { + 'tie-strength-census__choice--invalid': !isValid, + }); return (
- - {isIntroduction - && ( - -

{stage.introductionPanel.title}

- + {isIntroduction && ( + +

{stage.introductionPanel.title}

+ +
+ )} + {!isIntroduction && ( + +
+ - - )} - {!isIntroduction - && ( - -
- -
- - -
-
- - - -
- -
- - -
-
-
-
- {edgeVariableOptions.map((option) => ( - - ))} +
+ + +
+
+ + + +
+ +
+ + +
+
+
+
+ {edgeVariableOptions.map((option) => ( -
+ ))} +
-
-
-
-
-
-
-
- - )} +
+ + +
+ +
+
+
+ + )}
); @@ -311,7 +334,17 @@ const makeMapStateToProps = () => { const edges = getEdges(state, props); const codebook = getProtocolCodebook(state, props); const edgeColor = get(codebook, ['edge', props.prompt.createEdge, 'color']); - const edgeVariableOptions = get(codebook, ['edge', props.prompt.createEdge, 'variables', props.prompt.edgeVariable, 'options'], []); + const edgeVariableOptions = get( + codebook, + [ + 'edge', + props.prompt.createEdge, + 'variables', + props.prompt.edgeVariable, + 'options', + ], + [], + ); const pairs = getPairs(nodes); const stageState = getStageIndex(state); @@ -333,6 +366,4 @@ export default compose( connect(makeMapStateToProps), )(TieStrengthCensus); -export { - TieStrengthCensus as UnconnectedTieStrengthCensus, -}; +export { TieStrengthCensus as UnconnectedTieStrengthCensus }; diff --git a/lib/interviewer/containers/Interfaces/TieStrengthCensus/useAutoAdvance.js b/lib/interviewer/containers/Interfaces/TieStrengthCensus/useAutoAdvance.js index c9ce8b35..6aafd1ba 100644 --- a/lib/interviewer/containers/Interfaces/TieStrengthCensus/useAutoAdvance.js +++ b/lib/interviewer/containers/Interfaces/TieStrengthCensus/useAutoAdvance.js @@ -37,7 +37,7 @@ const useAutoAdvance = (_next, isTouched, isChanged) => { } return clearTimeout(timer.current); }; - }, [isTouched]); + }, [isTouched, delay, isChanged]); }; export default useAutoAdvance; diff --git a/lib/interviewer/containers/Interfaces/TieStrengthCensus/useEdgeState.js b/lib/interviewer/containers/Interfaces/TieStrengthCensus/useEdgeState.js index 7b659283..18e7087c 100644 --- a/lib/interviewer/containers/Interfaces/TieStrengthCensus/useEdgeState.js +++ b/lib/interviewer/containers/Interfaces/TieStrengthCensus/useEdgeState.js @@ -1,34 +1,45 @@ import { useState, useEffect } from 'react'; -import { entityAttributesProperty, entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '@codaco/shared-consts'; import { actionCreators as sessionActions } from '../../../ducks/modules/session'; import { get } from '../../../utils/lodash-replacements'; export const getEdgeInNetwork = (edges, pair, edgeType) => { - if (!pair) { return null; } + if (!pair) { + return null; + } const [a, b] = pair; - const edge = edges.find(({ from, to, type }) => ( - type === edgeType - && ((from === a && to === b) || (to === a && from === b)) - )); + const edge = edges.find( + ({ from, to, type }) => + type === edgeType && + ((from === a && to === b) || (to === a && from === b)), + ); - if (!edge) { return null; } + if (!edge) { + return null; + } return edge; }; -const edgeExistsInNetwork = (edges, pair, edgeType) => !!getEdgeInNetwork(edges, pair, edgeType); +const edgeExistsInNetwork = (edges, pair, edgeType) => + !!getEdgeInNetwork(edges, pair, edgeType); -export const matchEntry = (prompt, pair) => ([p, a, b]) => ( - (p === prompt && a === pair[0] && b === pair[1]) - || (p === prompt && b === pair[0] && a === pair[1]) -); +export const matchEntry = + (prompt, pair) => + ([p, a, b]) => + (p === prompt && a === pair[0] && b === pair[1]) || + (p === prompt && b === pair[0] && a === pair[1]); export const getIsPreviouslyAnsweredNo = (state, prompt, pair) => { - if (!state || pair.length !== 2) { return false; } + if (!state || pair.length !== 2) { + return false; + } - const answer = state - .find(matchEntry(prompt, pair)); + const answer = state.find(matchEntry(prompt, pair)); if (answer && answer[3] === false) { return true; @@ -79,7 +90,11 @@ const useEdgeState = ( // Internal state for edge variable value. `value` or null, const [edgeValueState, setEdgeValueState] = useState( - get(getEdgeInNetwork(edges, pair, edgeType), [entityAttributesProperty, edgeVariable], null), + get( + getEdgeInNetwork(edges, pair, edgeType), + [entityAttributesProperty, edgeVariable], + null, + ), ); const [isTouched, setIsTouched] = useState(false); @@ -90,7 +105,9 @@ const useEdgeState = ( // False: user declined edge (based on stageState) // Null: user hasn't decided const getHasEdge = () => { - if (!pair) { return null; } + if (!pair) { + return null; + } // Check if this pair was marked as no before if (getIsPreviouslyAnsweredNo(stageState, promptIndex, pair)) { @@ -103,7 +120,9 @@ const useEdgeState = ( // Return current edgeValue const getEdgeValue = () => { - if (!pair) { return null; } + if (!pair) { + return null; + } return edgeValueState; }; @@ -112,31 +131,49 @@ const useEdgeState = ( // False for user denying edge // any value for setting edge variable value const setEdge = (value) => { - if (!pair) { return; } + if (!pair) { + return; + } // Determine what we need to do: // If truthy value and edge exists, we are changing an edge - const changeEdge = value !== false - && edgeExistsInNetwork(edges, pair, edgeType) - && value !== get( - getEdgeInNetwork(edges, pair, edgeType), [entityAttributesProperty, edgeVariable], - ); + const changeEdge = + value !== false && + edgeExistsInNetwork(edges, pair, edgeType) && + value !== + get(getEdgeInNetwork(edges, pair, edgeType), [ + entityAttributesProperty, + edgeVariable, + ]); // If truthy value but no existing edge, adding an edge - const addEdge = value !== false && !edgeExistsInNetwork(edges, pair, edgeType); + const addEdge = + value !== false && !edgeExistsInNetwork(edges, pair, edgeType); // If value is false and edge exists, removing an edge - const removeEdge = value === false && edgeExistsInNetwork(edges, pair, edgeType); + const removeEdge = + value === false && edgeExistsInNetwork(edges, pair, edgeType); - const existingEdgeID = get(getEdgeInNetwork(edges, pair, edgeType), entityPrimaryKeyProperty); + const existingEdgeID = get( + getEdgeInNetwork(edges, pair, edgeType), + entityPrimaryKeyProperty, + ); if (changeEdge) { - dispatch(sessionActions.updateEdge(existingEdgeID, {}, { [edgeVariable]: value })); + dispatch( + sessionActions.updateEdge( + existingEdgeID, + {}, + { [edgeVariable]: value }, + ), + ); } else if (addEdge) { - dispatch(sessionActions.addEdge( - { from: pair[0], to: pair[1], type: edgeType }, - { [edgeVariable]: value }, - )); + dispatch( + sessionActions.addEdge( + { from: pair[0], to: pair[1], type: edgeType }, + { [edgeVariable]: value }, + ), + ); } else if (removeEdge) { dispatch(sessionActions.removeEdge(existingEdgeID)); } @@ -149,7 +186,11 @@ const useEdgeState = ( setIsTouched(true); // Update our private stage state - const newStageState = stageStateReducer(stageState, { pair, prompt: promptIndex, value }); + const newStageState = stageStateReducer(stageState, { + pair, + prompt: promptIndex, + value, + }); dispatch(sessionActions.updateStageState(newStageState)); }; @@ -157,13 +198,16 @@ const useEdgeState = ( // we are internally keeping track of the edge state. useEffect(() => { setEdgeState(edgeExistsInNetwork(edges, pair, edgeType)); - setEdgeValueState(get(getEdgeInNetwork( - edges, - pair, - edgeType, - ), [entityAttributesProperty, edgeVariable], null)); + setEdgeValueState( + get( + getEdgeInNetwork(edges, pair, edgeType), + [entityAttributesProperty, edgeVariable], + null, + ), + ); setIsTouched(false); setIsChanged(false); + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return [getHasEdge(), getEdgeValue(), setEdge, isTouched, isChanged]; diff --git a/lib/interviewer/containers/Interfaces/utils/StageLevelValidation.js b/lib/interviewer/containers/Interfaces/utils/StageLevelValidation.js index 86722c27..28613937 100644 --- a/lib/interviewer/containers/Interfaces/utils/StageLevelValidation.js +++ b/lib/interviewer/containers/Interfaces/utils/StageLevelValidation.js @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import Icon from '~/lib/ui/components/Icon'; import { v4 as uuid } from 'uuid'; @@ -28,12 +28,12 @@ export const SelfDismissingNote = (Wrapped) => { const timeout = useRef(null); const key = useRef(uuid()); - const handleHide = () => { + const handleHide = useCallback(() => { if (timeoutDuration > 0) { setVisible(false); onHideCallback(); } - }; + }, [onHideCallback, timeoutDuration]); useEffect(() => { if (show) { @@ -55,7 +55,7 @@ export const SelfDismissingNote = (Wrapped) => { handleHide(); }, timeoutDuration); } - }, [mouseOver]); + }, [mouseOver, handleHide, timeoutDuration, visible]); useEffect(() => { if (visible) { @@ -75,7 +75,7 @@ export const SelfDismissingNote = (Wrapped) => { clearTimeout(timeout.current); } }; - }, [visible, timeoutDuration, onHideCallback]); + }, [visible, timeoutDuration, onHideCallback, handleHide]); return (
{ const dispatch = useDispatch(); const submitForm = () => dispatch(submit(reduxFormName)); - const addNode = (...properties) => - dispatch(sessionActions.addNode(...properties)); - const updateNode = (...properties) => - dispatch(sessionActions.updateNode(...properties)); + 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, @@ -59,7 +64,14 @@ const NodeForm = (props) => { setShow(false); onClose(); }, - [selectedNode, newNodeModelData, newNodeAttributes, onClose], + [ + selectedNode, + newNodeModelData, + newNodeAttributes, + onClose, + addNode, + updateNode, + ], ); // When a selected node is passed in, we are editing an existing node. diff --git a/lib/interviewer/containers/NodePanel.js b/lib/interviewer/containers/NodePanel.js index a12b5bd9..c4ce818e 100644 --- a/lib/interviewer/containers/NodePanel.js +++ b/lib/interviewer/containers/NodePanel.js @@ -1,19 +1,17 @@ -import React, { PureComponent } from 'react'; +import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import { PureComponent } from 'react'; import { connect } from 'react-redux'; import { compose } from 'recompose'; -import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; +import NodeList from '~/lib/interviewer/components/NodeList'; +import Panel from '~/lib/interviewer/components/Panel'; +import customFilter from '~/lib/network-query/filter'; import { - makeNetworkNodesForPrompt as makeGetNodesForPrompt, - makeNetworkNodesForOtherPrompts as makeGetNodesForOtherPrompts, - getNetworkNodesForPrompt, getNetworkNodesForOtherPrompts, + getNetworkNodesForPrompt, } from '../selectors/interface'; import { getNetworkEdges, getNetworkEgo } from '../selectors/network'; -import Panel from '~/lib/interviewer/components/Panel'; -import NodeList from '~/lib/interviewer/components/NodeList'; -import withExternalData from './withExternalData'; -import customFilter from '~/lib/network-query/filter'; import { get } from '../utils/lodash-replacements'; +import withExternalData from './withExternalData'; class NodePanel extends PureComponent { componentDidMount() { @@ -30,16 +28,12 @@ class NodePanel extends PureComponent { // Because the index is used to determine whether node originated in this list // we need to supply an index for the unfiltered list for externalData. fullNodeIndex = () => { - const { - dataSource, - externalData, - nodes, - } = this.props; + const { dataSource, externalData, nodes } = this.props; const externalNodes = get(externalData, 'nodes', []); - const allNodes = (dataSource === 'existing' ? nodes : externalNodes); + const allNodes = dataSource === 'existing' ? nodes : externalNodes; return new Set(allNodes.map((node) => node[entityPrimaryKeyProperty])); - } + }; // This can use the displayed nodes for a count as it is used to see whether the panel // is 'empty' @@ -50,40 +44,21 @@ class NodePanel extends PureComponent { sendNodesUpdate = () => { const { onUpdate } = this.props; - onUpdate( - this.nodeDisplayCount(), - this.fullNodeIndex(), - ); - } + onUpdate(this.nodeDisplayCount(), this.fullNodeIndex()); + }; handleDrop = (item) => { - const { - onDrop, - dataSource, - } = this.props; + const { onDrop, dataSource } = this.props; return onDrop(item, dataSource); }; render = () => { - const { - title, - highlight, - dataSource, - id, - listId, - minimize, - onDrop, - nodes, - ...nodeListProps - } = this.props; + const { title, highlight, id, listId, minimize, nodes, ...nodeListProps } = + this.props; return ( - + ); - } + }; } const getNodeId = (node) => node[entityPrimaryKeyProperty]; @@ -110,20 +85,20 @@ const getNodes = (state, props) => { const notInSet = (set) => (node) => !set.has(node[entityPrimaryKeyProperty]); if (props.dataSource === 'existing') { - const nodes = nodesForOtherPrompts - .filter(notInSet(new Set(nodeIds.prompt))); + const nodes = nodesForOtherPrompts.filter( + notInSet(new Set(nodeIds.prompt)), + ); return nodes; } - if (!props.externalData) { return []; } + if (!props.externalData) { + return []; + } - const nodes = get( - props.externalData, - 'nodes', - [], - ) - .filter(notInSet(new Set([...nodeIds.prompt, ...nodeIds.other]))); + const nodes = get(props.externalData, 'nodes', []).filter( + notInSet(new Set([...nodeIds.prompt, ...nodeIds.other])), + ); return nodes; }; diff --git a/lib/interviewer/containers/Overlay.js b/lib/interviewer/containers/Overlay.js index 0ee963c1..6876d46a 100644 --- a/lib/interviewer/containers/Overlay.js +++ b/lib/interviewer/containers/Overlay.js @@ -51,7 +51,7 @@ const Overlay = (props) => { if (fullscreen !== startFullscreen) { setFullscreen(startFullscreen); } - }, [startFullscreen]); + }, [startFullscreen, fullscreen]); const overlayClasses = cx( 'overlay', diff --git a/lib/interviewer/containers/SearchableList.js b/lib/interviewer/containers/SearchableList.js index a4ab1c88..b309567d 100644 --- a/lib/interviewer/containers/SearchableList.js +++ b/lib/interviewer/containers/SearchableList.js @@ -32,9 +32,7 @@ const SortButton = ({ > {label} - {isActive && ( - sortDirection === 'asc' ? ' \u25B2' : ' \u25BC' - )} + {isActive && (sortDirection === 'asc' ? ' \u25B2' : ' \u25BC')}
); @@ -55,11 +53,11 @@ const EmptyComponent = () => ( ); /** - * SearchableList - * - * This adds UI around the HyperList component which enables - * sorting and searching. - */ + * SearchableList + * + * This adds UI around the HyperList component which enables + * sorting and searching. + */ const SearchableList = (props) => { const { @@ -83,7 +81,10 @@ const SearchableList = (props) => { const { initialSortOrder = {} } = sortOptions; const id = useRef(uuid()); - const [results, query, setQuery, isWaiting, hasQuery] = useSearch(items, searchOptions); + const [results, query, setQuery, isWaiting, hasQuery] = useSearch( + items, + searchOptions, + ); const [ sortedResults, @@ -103,15 +104,14 @@ const SearchableList = (props) => { } setSortByProperty(); - }, [hasQuery]); + }, [hasQuery, setSortByProperty, setSortDirection, setSortType]); - const filteredResults = useMemo( - () => { - if (!excludeItems || !sortedResults) { return sortedResults; } - return sortedResults.filter((item) => !excludeItems.includes(item.id)); - }, - [sortedResults, excludeItems], - ); + const filteredResults = useMemo(() => { + if (!excludeItems || !sortedResults) { + return sortedResults; + } + return sortedResults.filter((item) => !excludeItems.includes(item.id)); + }, [sortedResults, excludeItems]); const handleChangeSearch = (eventOrValue) => { const value = get(eventOrValue, ['target', 'value'], eventOrValue); @@ -120,26 +120,25 @@ const SearchableList = (props) => { const mode = items.length > 100 ? modes.LARGE : modes.SMALL; - const hyperListPlaceholder = placeholder || ( - isWaiting - ? ( - - - - ) - : null - ); + const hyperListPlaceholder = + placeholder || + (isWaiting ? ( + + + + ) : null); const showTooMany = mode === modes.LARGE && !hasQuery; const numberOfSortOptions = get(sortOptions, 'sortableProperties', []).length; const canSort = numberOfSortOptions > 0; - const animationDuration = getCSSVariableAsNumber('--animation-duration-standard-ms') / 1000; + const animationDuration = + getCSSVariableAsNumber('--animation-duration-standard-ms') / 1000; const variants = { visible: { opacity: 1, transition: { duration: animationDuration } }, @@ -152,7 +151,10 @@ const SearchableList = (props) => { { 'searchable-list__list--too-many': showTooMany }, ); - const { willAccept, isOver } = useDropMonitor(`hyper-list-${id.current}`) || { willAccept: false, isOver: false }; + const { willAccept, isOver } = useDropMonitor(`hyper-list-${id.current}`) || { + willAccept: false, + isOver: false, + }; return ( { animate="visible" className="searchable-list" > - + {canSort && (
- { - hasQuery && ( -
{ - setSortByProperty(['relevance']); - setSortType('number'); - setSortDirection('desc'); - }} - role="button" - tabIndex={0} - > - Relevance - {isEqual(sortByProperty, ['relevance']) && ( - sortDirection === 'asc' ? ' \u25B2' : ' \u25BC' - )} -
- ) - } + {hasQuery && ( +
{ + setSortByProperty(['relevance']); + setSortType('number'); + setSortDirection('desc'); + }} + role="button" + tabIndex={0} + > + Relevance + {isEqual(sortByProperty, ['relevance']) && + (sortDirection === 'asc' ? ' \u25B2' : ' \u25BC')} +
+ )} {sortOptions.sortableProperties.map(({ property, type, label }) => { const isActive = isEqual(property, sortByProperty); const color = isActive ? 'primary' : 'platinum'; @@ -249,10 +248,7 @@ const SearchableList = (props) => { }; SearchableList.propTypes = { - columns: PropTypes.oneOfType([ - PropTypes.number, - PropTypes.func, - ]), + columns: PropTypes.oneOfType([PropTypes.number, PropTypes.func]), itemComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), dragComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), items: PropTypes.array, @@ -264,6 +260,4 @@ SearchableList.propTypes = { disabled: PropTypes.bool, }; - - export default SearchableList; diff --git a/lib/interviewer/containers/SessionManagementScreen/SessionManagementScreen.js b/lib/interviewer/containers/SessionManagementScreen/SessionManagementScreen.js index 271aaa9a..0d45b817 100644 --- a/lib/interviewer/containers/SessionManagementScreen/SessionManagementScreen.js +++ b/lib/interviewer/containers/SessionManagementScreen/SessionManagementScreen.js @@ -10,8 +10,7 @@ import ExportOptions from '../../components/SettingsMenu/Sections/ExportOptions' import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import { withErrorDialog } from '../../ducks/modules/errors'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; -import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; -import { exportToFile, exportToServer } from '../../utils/exportProcess'; +import { exportToFile } from '../../utils/exportProcess'; import { get } from '../../utils/lodash-replacements'; import { asNetworkWithSessionVariables } from '../../utils/networkFormat'; import { Overlay } from '../Overlay'; @@ -30,8 +29,6 @@ const DataExportScreen = ({ show, onClose }) => { const [filename, setFilename] = useState(`networkCanvasExport-${Date.now()}`); const [abortHandlers, setAbortHandlers] = useState(null); - const pairedServer = useSelector((state) => state.pairedServer); - const pairedServerConnection = useServerConnectionStatus(pairedServer); const { statusText, percentProgress } = useSelector( (state) => state.exportProgress, ); @@ -67,20 +64,9 @@ const DataExportScreen = ({ show, onClose }) => { ); }); - const exportSessions = (toServer = false) => { + const exportSessions = () => { setStep(3); - if (toServer) { - exportToServer(getExportableSessions()) - .then(onClose) - .catch((error) => { - // Fatal error handling - dispatch(fatalExportErrorAction(error)); - onClose(); - }); - return; - } - exportToFile(getExportableSessions(), filename) .then(({ run, abort, setConsideringAbort }) => { setAbortHandlers({ @@ -184,18 +170,6 @@ const DataExportScreen = ({ show, onClose }) => { Delete Selected
- {pairedServerConnection === 'ok' && ( - - )}
{selectedSessions.length > 0 && ( - {selectedSessions.length} - {' '} - selected session - {selectedSessions.length > 1 ? ('s') : null} - . + {selectedSessions.length} selected session + {selectedSessions.length > 1 ? 's' : null}. )}
diff --git a/lib/interviewer/ducks/modules/toasts.js b/lib/interviewer/ducks/modules/toasts.js index 0bb08073..72e89893 100644 --- a/lib/interviewer/ducks/modules/toasts.js +++ b/lib/interviewer/ducks/modules/toasts.js @@ -1,5 +1,4 @@ import { v4 as uuid } from 'uuid'; -import React from 'react'; const ADD_TOAST = 'TOASTS/ADD_TOAST'; const UPDATE_TOAST = 'TOASTS/UPDATE_TOAST'; @@ -34,14 +33,13 @@ const removeToast = (id) => ({ function reducer(state = initialState, action = {}) { switch (action.type) { case ADD_TOAST: - return [ - ...state, - { ...action.toast }, - ]; + return [...state, { ...action.toast }]; case UPDATE_TOAST: { return [ ...state.map((toast) => { - if (toast.id !== action.id) { return toast; } + if (toast.id !== action.id) { + return toast; + } return { ...toast, ...action.toast, @@ -50,35 +48,12 @@ function reducer(state = initialState, action = {}) { ]; } case REMOVE_TOAST: - return [ - ...state.filter((toast) => toast.id !== action.id), - ]; + return [...state.filter((toast) => toast.id !== action.id)]; default: return state; } } -const withToast = (actionCreator) => (...args) => (dispatch) => { - const action = actionCreator(...args); - dispatch(action); - switch (action.type) { - case 'SET_SERVER': { - return dispatch(addToast({ - type: 'success', - title: 'Pairing complete!', - content: ( -

- You have successfully paired with Server. You may now fetch protocols - and upload data. -

- ), - })); - } - default: - return null; - } -}; - const actionCreators = { addToast, updateToast, @@ -90,10 +65,6 @@ const actionTypes = { REMOVE_TOAST, }; -export { - actionCreators, - actionTypes, - withToast, -}; +export { actionCreators, actionTypes }; export default reducer; diff --git a/lib/interviewer/selectors/__tests__/protocol.test.js b/lib/interviewer/selectors/__tests__/protocol.test.js index 90bbd909..9ce6aa25 100644 --- a/lib/interviewer/selectors/__tests__/protocol.test.js +++ b/lib/interviewer/selectors/__tests__/protocol.test.js @@ -1,6 +1,5 @@ /* eslint-env jest */ - import * as Protocol from '../protocol'; const nodeVariables = { @@ -28,7 +27,10 @@ const mockProtocol = { const mockState = { activeSessionId: 'mockSession', - activeSessionWorkers: { nodeLabelWorker: 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c' }, + activeSessionWorkers: { + nodeLabelWorker: + 'blob:http://192.168.1.196:3000/b6cac5c5-1b4d-4db0-be86-fa55239fd62c', + }, deviceSettings: { description: 'Kirby (macOS)', useDynamicScaling: true, @@ -50,7 +52,6 @@ const mockState = { }, mockProtocol, }, - pairedServer: null, search: { collapsed: true, selectedResults: [], @@ -64,7 +65,7 @@ const mockState = { currentStep: 0, updatedAt: 1554130548004, }, - mockSession: { + 'mockSession': { protocolUID: 'mockProtocol', }, }, @@ -78,7 +79,9 @@ const emptyState = { describe('protocol selector', () => { describe('memoed selectors', () => { it('should get protocol codebook', () => { - expect(Protocol.getProtocolCodebook(mockState)).toEqual({ node: nodeVariables }); + expect(Protocol.getProtocolCodebook(mockState)).toEqual({ + node: nodeVariables, + }); expect(Protocol.getProtocolCodebook(emptyState)).toEqual(undefined); }); }); diff --git a/lib/interviewer/utils/ApiClient.js b/lib/interviewer/utils/ApiClient.js deleted file mode 100644 index df9cfe80..00000000 --- a/lib/interviewer/utils/ApiClient.js +++ /dev/null @@ -1,438 +0,0 @@ -/* globals cordova */ -import axios from 'axios'; -import EventEmitter from 'eventemitter3'; -import { isString } from 'lodash'; -import { - decrypt, - deriveSecretKeyBytes, - encrypt, - fromHex, - toHex, -} from 'secure-comms-api/cipher'; -import { DEVICE_API_VERSION } from '../config'; -import UserCancelledExport from './network-exporters/src/errors/UserCancelledExport'; - -const ProgressMessages = { - BeginExport: { - progress: 0, - statusText: 'Starting export...', - }, - ExportSession: (sessionExportCount, sessionExportTotal) => ({ - progress: 10 + ((95 - 10) * sessionExportCount) / sessionExportTotal, - statusText: `Uploaded ${sessionExportCount} of ${sessionExportTotal} sessions...`, - }), - Finished: { - progress: 100, - statusText: 'Export finished.', - }, - UnexpectedResponseMessage: 'Unexpected Response', - NoResponseMessage: - 'Server could not be reached at the address you provided. Check your networking settings on this device, and on the computer running Server and try again. Consult our documentation on pairing for detailed information on this topic.', -}; - -const ApiMismatchStatus = 'version_mismatch'; -const ApiErrorStatus = 'error'; - -const defaultHeaders = { - 'Content-Type': 'application/json', - 'X-Device-API-Version': DEVICE_API_VERSION, -}; - -// A throwable 'friendly' error containing message from server -const apiError = (respJson) => { - const error = new Error('API error'); - - // Provide a friendly message, if available. - if (respJson.message) { - error.caseID = respJson.caseID; - error.friendlyMessage = respJson.message; - error.code = respJson.code; - error.stack = null; - } - - return error; -}; - -const apiMismatchError = (code, response) => { - const error = new Error('Device API mismatch'); - - error.status = ApiMismatchStatus; - error.friendlyMessage = - 'The device does not match the server API version. Ensure that Interviewer and Server are running compatible versions.'; - error.code = code; - error.stack = JSON.stringify(response); - - return error; -}; - -const getResponseError = (response) => { - if (!response) { - return null; - } - - switch (response.data.status) { - case ApiMismatchStatus: - return apiMismatchError(response.status, response.data); - case ApiErrorStatus: - return apiError({ ...response.data, code: response.status }); - default: - return null; - } -}; - -const handleError = (err) => { - if (axios.isCancel(err)) { - return false; - } - // Handle errors from the response - if (getResponseError(err.response)) { - throw getResponseError(err.response); - } - // Handle errors with the request - if (err.request) { - throw new Error(ProgressMessages.NoResponseMessage); - } - - throw err; -}; - -/** - * @class - * - * Provides both a pairing client (http) and a secure client (https) once paired. - * - * ## Format - * - * See server documentation for API requests & responses. - * - * In general, error responses take the shape: - * - * { - * "status": "error", - * "message": "..." - * } - * - * Successful responses have a `data` key. With axios, responses also have a `data` property - * representing the (JSON) response body, so parsing looks a little strange: `resp.data.data`. - * - * ## Cancellation - * - * All pending requests can be cancelled by calling cancelAll(). This will not reject the promised - * response; rather, it will resolve with empty data. - * - * @param {string|Object} pairingUrlOrPairedServer Either a pairing API URL (http), or an - * already-paired Server - * @param {string} [pairingUrlOrPairedServer.secureServiceUrl] HTTPS url for secure endpoints, - * if a paired server is provied - */ -class ApiClient { - constructor(pairingUrlOrPairedServer) { - let pairingUrl; - let pairedServer; - - this.events = new EventEmitter(); - this.cancelled = false; - - if (isString(pairingUrlOrPairedServer)) { - // We have a pairing URL - pairingUrl = pairingUrlOrPairedServer; - } else if (pairingUrlOrPairedServer) { - // We are already paired - pairedServer = pairingUrlOrPairedServer; - } - - this.cancelTokenSource = axios.CancelToken.source(); - this.pairedServer = pairedServer; - if (pairingUrl) { - this.pairingClient = axios.create({ - baseURL: pairingUrl.replace(/\/$/, ''), - headers: defaultHeaders, - }); - } - } - - on = (...args) => { - this.events.on(...args); - }; - - emit(event, payload) { - if (!event) { - return; - } - - this.events.emit(event, payload); - } - - removeAllListeners = () => { - this.events.removeAllListeners(); - }; - - /** - * @description Call this to add add the paired server's SSL certificate to the trust store. - * Calling this method without initializing the ApiClient with a paired server is an error. - * - * @method ApiClient#addTrustedCert - * @async - * @return {Promise} resolves if cert has been trusted; - * rejects if there is no paired Server, or trust cannot be established - */ - addTrustedCert() { - if (!this.httpsClient) { - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject('No secure client available'); // TODO: return Error() - } - - return Promise.reject( - new Error('SSL connections to Server are not supported on this platform'), - ); - } - - cancelAll() { - this.cancelTokenSource.cancel(); - } - - get authHeader() { - if (!this.pairedServer) { - return null; - } - return { - auth: { - username: this.pairedServer.deviceId, - }, - cancelToken: this.cancelTokenSource.token, - }; - } - - /** - * Get a new pairing code - * @async - * @return {Object} data - * @return {Object.string} data.pairingRequestId - * @return {Object.string} data.salt - * @throws {Error} - */ - requestPairing() { - if (!this.pairingClient) { - // TODO: reject with error - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject('No pairing client available'); - } - return this.pairingClient - .get('/devices/new', { cancelToken: this.cancelTokenSource.token }) - .then((resp) => resp.data) - .then((json) => json.data) - .catch(handleError); - } - - /** - * Second step in pairing process - * @param {string} pairingCode User-entered (GUI) - * @param {string} pairingRequestId from the requestPairing() response - * @param {string} pairingRequestSalt from the requestPairing() response - * @async - * @return {Object} pairingInfo.device - decorated with the generated secret - * @return {string} pairingInfo.device.id - * @return {string} pairingInfo.device.secret - * @return {string} pairingInfo.sslCertificate - * @throws {Error} - */ - confirmPairing( - pairingCode, - pairingRequestId, - pairingRequestSalt, - deviceName = '', - ) { - if (!this.pairingClient) { - // TODO: reject with error - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject('No pairing client available'); - } - - const saltBytes = fromHex(pairingRequestSalt); - const secretBytes = deriveSecretKeyBytes(pairingCode, saltBytes); - const secretHex = toHex(secretBytes); - - const plaintext = JSON.stringify({ - pairingRequestId, - pairingCode, - deviceName, - }); - - const encryptedMessage = encrypt(plaintext, secretHex); - - return this.pairingClient - .post( - '/devices', - { - message: encryptedMessage, - }, - { - cancelToken: this.cancelTokenSource.token, - }, - ) - .then((resp) => resp.data) - .then((json) => decrypt(json.data.message, secretHex)) - .then(JSON.parse) - .then((decryptedData) => { - if (!decryptedData.device || !decryptedData.device.id) { - throw new Error(ProgressMessages.UnexpectedResponseMessage); - } - const { device } = decryptedData; - device.secret = secretHex; - return { - device, - sslCertificate: decryptedData.certificate, - securePort: decryptedData.securePort, - }; - }) - .catch(handleError); - } - - /** - * @async - * @return {Array} protocols - * @throws {Error} - */ - getProtocols() { - if (!this.httpsClient) { - // TODO: reject with error - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject('No secure client available'); - } - - return this.httpsClient - .get('/protocols', { - ...this.authHeader, - cancelToken: this.cancelTokenSource.token, - }) - .then((resp) => resp.data) - .then((json) => json.data) - .catch((err) => handleError(err)); - } - - downloadProtocol(path, destination) { - if (!this.httpsClient) { - // TODO: reject with error - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject('No secure client available'); - } - - return Promise.reject( - new Error('Downloads not supported on this platform'), - ); - } - - /** - * @async - * @param {Object} sessionData - * @return {Object} - * @throws {Error} - */ - exportSession(sessionData) { - const { - sessionVariables: { sessionId, protocolUID }, - } = sessionData; - - const payload = { - uuid: sessionId, - data: sessionData, - }; - - return this.httpsClient - .post(`/protocols/${protocolUID}/sessions`, payload, this.authHeader) - .then((resp) => resp.data) - .then((json) => json.data); - } - - exportSessions(sessionList) { - let cancelled = false; - - this.emit('begin', ProgressMessages.BeginExport); - const exportPromise = sessionList - .reduce(async (previousPromise, nextSession, index) => { - await previousPromise; - - return this.exportSession(nextSession) - .then(() => - this.emit( - 'session-exported', - sessionList[index].sessionVariables.sessionId, - ), - ) - .catch((error) => { - if (axios.isCancel(error)) { - return; - } - - // Handle errors from the response - const responseError = getResponseError(error.response); - if (responseError) { - this.emit( - 'error', - `${sessionList[index].sessionVariables.caseId}: ${responseError.message} - ${responseError.friendlyMessage}`, - ); - return; - } - // Handle errors with the request - if (error.request) { - // Todo: there are other types of request error! - this.emit('error', ProgressMessages.NoResponseMessage); - } - }) - .then(() => { - if (!cancelled) { - this.emit( - 'update', - ProgressMessages.ExportSession(index + 1, sessionList.length), - ); - } - Promise.resolve(); - }); - }, Promise.resolve()) - .then(() => { - if (cancelled) { - throw new UserCancelledExport(); - } - - this.emit('finished', ProgressMessages.Finished); - Promise.resolve(); - }) - .catch((err) => { - // We don't throw if this is an error from user cancelling - if (err instanceof UserCancelledExport) { - return; - } - - throw err; - }); - - exportPromise.abort = () => { - cancelled = true; - this.cancelAll(); - }; - - return exportPromise; - } - - /** - * Check the status of the connection to Server - */ - requestHeartbeat() { - if (!this.httpsClient) { - // TODO: reject with error - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject('No secure client available'); - } - - return this.httpsClient - .get('/health', { - ...this.authHeader, - cancelToken: this.cancelTokenSource.token, - }) - .then((resp) => resp.data) - .then((json) => json.data) - .catch((err) => handleError(err)); - } -} - -export default ApiClient; diff --git a/lib/interviewer/utils/__tests__/ApiClient.test.js b/lib/interviewer/utils/__tests__/ApiClient.test.js deleted file mode 100644 index cc04fb89..00000000 --- a/lib/interviewer/utils/__tests__/ApiClient.test.js +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-env jest */ - -import axios from 'axios'; -import { decrypt } from 'secure-comms-api/cipher'; - -import ApiClient from '../ApiClient'; - -jest.mock('axios'); -jest.mock('secure-comms-api/cipher'); - -describe('ApiClient', () => { - const respData = { - device: { id: '1' }, - certificate: 'CERTIFICATE', - securePort: 443, - }; - const axiosResp = { data: { data: respData } }; - const url = 'http://example.com:123'; - let client; - - beforeAll(() => { - axios.post.mockResolvedValue(axiosResp); - axios.CancelToken = { - source: jest.fn().mockReturnValue({ token: '' }), - }; - axios.create.mockReturnValue(axios); - }); - - beforeEach(() => { - axios.post.mockClear(); - client = new ApiClient(url); - }); - - describe('constructor', () => { - it('creates a pairing client from a pairingUrl', () => { - expect(client.pairingClient).toBeDefined(); - expect(client.httpsClient).not.toBeDefined(); - }); - it('creates an https client from a pairedServer', () => { - const pairedClient = new ApiClient({ - secureServiceUrl: 'https://example.com:1234', - }); - expect(pairedClient.httpsClient).toBeDefined(); - expect(pairedClient.pairingClient).not.toBeDefined(); - }); - }); - - describe('pairing confirmation', () => { - let pairingInfo; - beforeEach(async () => { - // data payload is encrypted; mock it on cipher - decrypt.mockReturnValue(JSON.stringify(respData)); - pairingInfo = await client.confirmPairing(); - }); - - it('returns device ID and secret', () => { - expect(pairingInfo.device.id).toEqual(respData.device.id); - expect(pairingInfo.device).toHaveProperty('secret'); - }); - - it('returns an SSL cert for Server', () => { - expect(pairingInfo.sslCertificate).toEqual(respData.certificate); - }); - - it('returns the secure port for SSL', () => { - expect(pairingInfo.securePort).toEqual(respData.securePort); - }); - }); -}); diff --git a/lib/interviewer/utils/__tests__/serverAddressing.test.js b/lib/interviewer/utils/__tests__/serverAddressing.test.js deleted file mode 100644 index 77db14d4..00000000 --- a/lib/interviewer/utils/__tests__/serverAddressing.test.js +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-env jest */ - -import * as util from '../serverAddressing'; - -describe('the serverAddressing util', () => { - it('augments a service with the API URL', () => { - const mockService = { port: 123, addresses: ['127.0.0.1'] }; - const augmented = util.addPairingUrlToService(mockService); - expect(augmented).toMatchObject(mockService); - expect(augmented).toHaveProperty('pairingServiceUrl'); - expect(augmented.pairingServiceUrl).toMatch('127.0.0.1:123'); - }); - - it('contains a null pairingServiceUrl if none can be created', () => { - const linkLocal = 'fe80::'; - const unusableService = { port: 123, addresses: [linkLocal] }; - const augmented = util.addPairingUrlToService(unusableService); - expect(augmented.pairingServiceUrl).toBe(null); - }); - - describe('port validation', () => { - it('validates 9999', () => expect(util.isValidPort(9999)).toBe(true)); - it('validates a string port', () => expect(util.isValidPort('9999')).toBe(true)); - it('rejects port 0', () => expect(util.isValidPort(0)).toBe(false)); - it('rejects port 10e6', () => expect(util.isValidPort(10e6)).toBe(false)); - it('rejects port "10e6"', () => expect(util.isValidPort('10e6')).toBe(false)); - it('rejects port > max', () => expect(util.isValidPort(2 ** 16)).toBe(false)); - it('rejects a negative port', () => expect(util.isValidPort(-1)).toBe(false)); - }); - - describe('address validation', () => { - it('detects a valid address', () => expect(util.isValidAddress('192.168.0.1')).toBe(true)); - it('detects an invalid address', () => expect(util.isValidAddress('192.1068.0.1')).toBe(false)); - it('rejects paths', () => expect(util.isValidAddress('192.1068.0.1/foo')).toBe(false)); - it('rejects full URLs', () => expect(util.isValidAddress('http://192.1068.0.1')).toBe(false)); - }); -}); diff --git a/lib/interviewer/utils/exportProcess.js b/lib/interviewer/utils/exportProcess.js index fc95d00a..43b7db66 100644 --- a/lib/interviewer/utils/exportProcess.js +++ b/lib/interviewer/utils/exportProcess.js @@ -6,8 +6,6 @@ import { actionCreators as toastActions } from '../ducks/modules/toasts'; import { actionCreators as sessionActions } from '../ducks/modules/session'; import { actionCreators as dialogActions } from '../ducks/modules/dialogs'; import { actionCreators as exportProgressActions } from '../ducks/modules/exportProgress'; -import { withErrorDialog } from '../ducks/modules/errors'; -import ApiClient from './ApiClient'; import FileExportManager from './network-exporters/src/FileExportManager'; import { getRemoteProtocolID } from './networkFormat'; @@ -15,29 +13,28 @@ const { dispatch } = store; const { getState } = store; const setInitialExportStatus = () => { - dispatch(exportProgressActions.update({ - statusText: 'Starting export...', - percentProgress: 0, - })); + dispatch( + exportProgressActions.update({ + statusText: 'Starting export...', + percentProgress: 0, + }), + ); }; const showCancellationToast = () => { - dispatch(toastActions.addToast({ - type: 'warning', - title: 'Export cancelled', - content: ( - <> -

You cancelled the export process.

- - ), - })); + dispatch( + toastActions.addToast({ + type: 'warning', + title: 'Export cancelled', + content: ( + <> +

You cancelled the export process.

+ + ), + }), + ); }; -const fatalExportErrorAction = withErrorDialog((error) => ({ - type: 'SESSION_EXPORT_FATAL_ERROR', - error, -})); - export const exportToFile = (sessionList, filename) => { // Reset exportProgress state dispatch(exportProgressActions.reset()); @@ -77,10 +74,12 @@ export const exportToFile = (sessionList, filename) => { }); fileExportManager.on('update', ({ statusText, progress }) => { - dispatch(exportProgressActions.update({ - statusText, - percentProgress: progress, - })); + dispatch( + exportProgressActions.update({ + statusText, + percentProgress: progress, + }), + ); }); fileExportManager.on('cancelled', () => { @@ -106,8 +105,8 @@ export const exportToFile = (sessionList, filename) => { if (succeeded.length > 0) { batch(() => { - succeeded.forEach( - (successfulExport) => dispatch(sessionActions.setSessionExported(successfulExport)), + succeeded.forEach((successfulExport) => + dispatch(sessionActions.setSessionExported(successfulExport)), ); }); } @@ -115,143 +114,57 @@ export const exportToFile = (sessionList, filename) => { if (errors.length > 0) { const errorList = errors.map((error, index) => (
  • - - {' '} - {error} + {error}
  • )); - dispatch(dialogActions.openDialog({ - type: 'Warning', - title: 'Errors encountered during export', - canCancel: false, - message: ( - <> -

    - Your export completed, but non-fatal errors were encountered during the process. This - may mean that not all sessions or all formats were able to be exported. - Review the details of these errors below, and ensure that you check the data you - received. Contact the Network Canvas team for support. -

    - Errors: -
      {errorList}
    - - ), - })); + dispatch( + dialogActions.openDialog({ + type: 'Warning', + title: 'Errors encountered during export', + canCancel: false, + message: ( + <> +

    + Your export completed, but non-fatal errors were encountered + during the process. This may mean that not all sessions or all + formats were able to be exported. Review the details of these + errors below, and ensure that you check the data you received. + Contact the Network Canvas team for support. +

    + Errors: +
      {errorList}
    + + ), + }), + ); return; } - dispatch(toastActions.addToast({ - type: 'success', - title: 'Export Complete!', - autoDismiss: true, - content: ( - <> -

    Your sessions were exported successfully.

    - - ), - })); + dispatch( + toastActions.addToast({ + type: 'success', + title: 'Export Complete!', + autoDismiss: true, + content: ( + <> +

    Your sessions were exported successfully.

    + + ), + }), + ); }); // The protocol object needs to be reformatted so that it is keyed by - // the sha of protocol.name, since this is what Server and network-exporters - // use. - const reformatedProtocols = Object.values(installedProtocols) - .reduce((acc, protocol) => ({ + // the sha of protocol.name, since this is what network-exporters use. + const reformatedProtocols = Object.values(installedProtocols).reduce( + (acc, protocol) => ({ ...acc, [getRemoteProtocolID(protocol.name)]: protocol, - }), {}); + }), + {}, + ); return fileExportManager.exportSessions(sessionList, reformatedProtocols); }; - -export const exportToServer = (sessionList) => { - const errors = []; - const succeeded = []; - - const { pairedServer } = getState(); - - const client = new ApiClient(pairedServer); - client.addTrustedCert(); - - client.on('begin', () => { - setInitialExportStatus(); - }); - - client.on('update', ({ statusText, progress }) => { - dispatch(exportProgressActions.update({ - statusText, - percentProgress: progress, - })); - }); - - client.on('session-exported', (sessionId) => { - succeeded.push(sessionId); - }); - - client.on('error', (error) => { - errors.push(error); - }); - - client.on('finished', () => { - dispatch(exportProgressActions.reset()); - - if (succeeded.length > 0) { - batch(() => { - succeeded.forEach( - (successfulExport) => dispatch(sessionActions.setSessionExported(successfulExport)), - ); - }); - } - - if (errors.length > 0) { - const errorList = errors.map((error, index) => ( -
  • - - {error} -
  • - )); - - dispatch(dialogActions.openDialog({ - type: 'Warning', - title: 'Errors encountered during export', - canCancel: false, - message: ( - <> -

    - Your export completed, but non-fatal errors were encountered during the process. This - may mean that not all sessions were transferred to Server. - Review the details of these errors below, and ensure that you check the data you - received. -

    - Errors: -
      {errorList}
    - - ), - })); - - return; - } - - dispatch(toastActions.addToast({ - type: 'success', - title: 'Export Complete!', - autoDismiss: true, - content: ( - <> -

    Your sessions were exported successfully.

    - - ), - })); - }); - - const exportPromise = client.exportSessions(sessionList); - - exportPromise.catch((error) => { - dispatch(fatalExportErrorAction(error)); - exportPromise.abort(); - }); - - return exportPromise; -}; diff --git a/lib/interviewer/utils/networkFormat.js b/lib/interviewer/utils/networkFormat.js index 63a93ea2..757bbc6e 100644 --- a/lib/interviewer/utils/networkFormat.js +++ b/lib/interviewer/utils/networkFormat.js @@ -1,18 +1,13 @@ -import { omit } from 'lodash'; -import crypto from 'crypto'; -import objectHash from 'object-hash'; import { + caseProperty, entityAttributesProperty, entityPrimaryKeyProperty, - sessionProperty, - caseProperty, - codebookHashProperty, - protocolProperty, protocolName, - sessionStartTimeProperty, - sessionFinishTimeProperty, sessionExportTimeProperty, + sessionFinishTimeProperty, + sessionProperty, } from '@codaco/shared-consts'; +import { omit } from 'lodash'; import { getEntityAttributes, nodeTypePropertyForWorker, @@ -29,8 +24,11 @@ import { * * @private */ -export const getEntityAttributesWithNamesResolved = (entity, entityVariables, - ignoreExternalProps = false) => { +export const getEntityAttributesWithNamesResolved = ( + entity, + entityVariables, + ignoreExternalProps = false, +) => { if (!entityVariables) { return {}; } @@ -78,12 +76,6 @@ export const getNodeWithIdAttributes = (node, nodeVariables) => { }; }; -/** - * Get the remote protocol name for a protocol, which Server uses to uniquely identify it - * @param {string} name the name of a protocol - */ -export const getRemoteProtocolID = (name) => name && crypto.createHash('sha256').update(name).digest('hex'); - /** * Creates an object containing all required session metadata for export * and appends it to the session @@ -92,8 +84,6 @@ export const asNetworkWithSessionVariables = (sessionId, session, protocol) => { // Required: // caseId, // sessionId, - // remoteProtocolID - format Server uniquely identifies protocols by - // codebookHash - used to compare server version with local version // protocol name // interview start and finish. If not available don't include // export date @@ -101,22 +91,17 @@ export const asNetworkWithSessionVariables = (sessionId, session, protocol) => { const sessionVariables = { [caseProperty]: session.caseId, [sessionProperty]: sessionId, - [protocolProperty]: getRemoteProtocolID(protocol.name), [protocolName]: protocol.name, - [codebookHashProperty]: objectHash(protocol.codebook), - ...(session.startedAt && { - [sessionStartTimeProperty]: new Date(session.startedAt).toISOString(), - }), ...(session.finishedAt && { [sessionFinishTimeProperty]: new Date(session.finishedAt).toISOString(), }), [sessionExportTimeProperty]: new Date().toISOString(), }; - return ({ + return { ...session.network, sessionVariables, - }); + }; }; /** @@ -131,8 +116,12 @@ export const asNetworkWithSessionVariables = (sessionId, session, protocol) => { */ export const asWorkerAgentEntity = (entity, entityTypeDefinition) => ({ [primaryKeyPropertyForWorker]: entity[entityPrimaryKeyProperty], - [nodeTypePropertyForWorker]: entityTypeDefinition && entityTypeDefinition.name, - ...getEntityAttributesWithNamesResolved(entity, (entityTypeDefinition || {}).variables), + [nodeTypePropertyForWorker]: + entityTypeDefinition && entityTypeDefinition.name, + ...getEntityAttributesWithNamesResolved( + entity, + (entityTypeDefinition || {}).variables, + ), }); export const asWorkerAgentEdge = (edge, edgeTypeDefinition) => ({ @@ -149,10 +138,18 @@ export const asWorkerAgentEdge = (edge, edgeTypeDefinition) => ({ */ export const asWorkerAgentNetwork = (network = {}, registry = {}) => { const { nodes = [], edges = [], ego = {} } = network; - const { node: nodeRegistry = {}, edge: edgeRegistry = {}, ego: egoRegistry = {} } = registry; - return ({ - nodes: nodes.map((node) => asWorkerAgentEntity(node, nodeRegistry[node.type])), - edges: edges.map((edge) => asWorkerAgentEdge(edge, edgeRegistry[edge.type])), + const { + node: nodeRegistry = {}, + edge: edgeRegistry = {}, + ego: egoRegistry = {}, + } = registry; + return { + nodes: nodes.map((node) => + asWorkerAgentEntity(node, nodeRegistry[node.type]), + ), + edges: edges.map((edge) => + asWorkerAgentEdge(edge, edgeRegistry[edge.type]), + ), ego: asWorkerAgentEntity(ego, egoRegistry), - }); + }; }; diff --git a/lib/ui/assets/icons/pair-a-server.svg b/lib/ui/assets/icons/pair-a-server.svg deleted file mode 100644 index b220823a..00000000 --- a/lib/ui/assets/icons/pair-a-server.svg +++ /dev/null @@ -1 +0,0 @@ -Pair-Server \ No newline at end of file diff --git a/lib/ui/assets/images/Srv-Flat.svg b/lib/ui/assets/images/Srv-Flat.svg deleted file mode 100644 index fb353dc7..00000000 --- a/lib/ui/assets/images/Srv-Flat.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/ui/components/Boolean/BooleanOption.js b/lib/ui/components/Boolean/BooleanOption.js index 94ad707d..335a379e 100644 --- a/lib/ui/components/Boolean/BooleanOption.js +++ b/lib/ui/components/Boolean/BooleanOption.js @@ -32,7 +32,7 @@ const BooleanOption = ({ }; return ( -
    +
    {resizeListener} {customIcon || } {renderLabel()} diff --git a/lib/ui/components/Button.js b/lib/ui/components/Button.js index 0ae40502..1709d420 100644 --- a/lib/ui/components/Button.js +++ b/lib/ui/components/Button.js @@ -4,7 +4,7 @@ import cx from 'classnames'; const renderButtonIcon = ({ icon, iconPosition }) => { const iconClassNames = cx({ - button__icon: true, + 'button__icon': true, 'button__icon--right': iconPosition === 'right', }); @@ -15,10 +15,7 @@ const renderButtonIcon = ({ icon, iconPosition }) => { const Icon = require('./Icon').default; iconElement = ; } else { - iconElement = React.cloneElement( - icon, - { className: iconClassNames }, - ); + iconElement = React.cloneElement(icon, { className: iconClassNames }); } } return iconElement; @@ -40,7 +37,7 @@ class Button extends PureComponent { } = this.props; const buttonClassNames = cx({ - button: true, + 'button': true, [`button--${color}`]: !!color, [`button--${size}`]: !!size, 'button--has-icon': !!icon, @@ -52,37 +49,32 @@ class Button extends PureComponent { // eslint-disable-next-line react/button-has-type type={type} className={buttonClassNames} - onClick={onClick?.()} + onClick={onClick} disabled={disabled} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} > {renderButtonIcon({ icon, iconPosition })} - {(content || children) && {children || content}} + {(content || children) && ( + {children || content} + )} ); } } Button.propTypes = { - content: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.element, - ]), + content: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), children: PropTypes.node, icon: PropTypes.oneOfType([ PropTypes.string, PropTypes.element, PropTypes.object, ]), - iconPosition: PropTypes.oneOf([ - 'left', 'right', - ]), + iconPosition: PropTypes.oneOf(['left', 'right']), size: PropTypes.string, color: PropTypes.string, - type: PropTypes.oneOf([ - 'button', 'submit', 'reset', - ]), + type: PropTypes.oneOf(['button', 'submit', 'reset']), onClick: PropTypes.func, disabled: PropTypes.bool, }; diff --git a/lib/ui/components/Cards/ServerCard.js b/lib/ui/components/Cards/ServerCard.js deleted file mode 100644 index 92c3f0fd..00000000 --- a/lib/ui/components/Cards/ServerCard.js +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import logo from '../../assets/images/Srv-Flat.svg'; -import HoverMarquee from '../HoverMarquee'; - -/** - * Renders a server icon & label. The label defaults to server name, falling back - * to its first address (both provided via the `data` prop). If `secondaryLabel` - * is provided, then it will be appended. - */ -const ServerCard = ({ - name, - addresses, - host, - onClickHandler, - disabled = false, -}) => { - const label = name || addresses[0]; - - const modifierClasses = cx( - 'server-card', - { 'server-card--clickable': onClickHandler }, - { 'server-card--disabled': disabled }, - ); - - return ( -
    onClickHandler?.()}> -
    -
    - -
    -
    -
    -

    - {label} -

    -
    - - Addresses: - {addresses.map((address, index) => ( - - [{address}]{index !== addresses.length - 1 && ','} - - ))} - -
    -
    - - Host: - {host} - -
    -
    -
    - ); -}; - -ServerCard.propTypes = { - name: PropTypes.string, - addresses: PropTypes.array.isRequired, - host: PropTypes.string, - onClickHandler: PropTypes.func, - disabled: PropTypes.bool, -}; - -export default ServerCard; diff --git a/lib/ui/components/Cards/index.js b/lib/ui/components/Cards/index.js index 0d697bf9..fcb65848 100644 --- a/lib/ui/components/Cards/index.js +++ b/lib/ui/components/Cards/index.js @@ -1,4 +1,3 @@ export { default as DataCard } from './DataCard'; export { default as ProtocolCard } from './ProtocolCard'; -export { default as ServerCard } from './ServerCard'; export { default as SessionCard } from './SessionCard'; diff --git a/lib/ui/components/icons/index.js b/lib/ui/components/icons/index.js index 9afe5485..99376663 100644 --- a/lib/ui/components/icons/index.js +++ b/lib/ui/components/icons/index.js @@ -38,7 +38,6 @@ import menuQuit from './menu-quit.svg.react'; import menuSociogram from './menu-sociogram.svg.react'; import menu from './menu.svg.react'; import nextArrow from './next-arrow.svg.react'; -import pairAServer from './pair-a-server.svg.react'; import primaryButton from './primary-button.svg.react'; import protocolCard from './protocol-card.svg.react'; import reset from './reset.svg.react'; @@ -96,7 +95,6 @@ export default { 'menu-sociogram': menuSociogram, menu, 'next-arrow': nextArrow, - 'pair-a-server': pairAServer, 'primary-button': primaryButton, 'protocol-card': protocolCard, reset, @@ -110,6 +108,6 @@ export default { 'chevron-up': chevronUp, 'chevron-down': chevronDown, add, - delete: remove, + 'delete': remove, move, }; diff --git a/lib/ui/components/icons/pair-a-server.svg.react.js b/lib/ui/components/icons/pair-a-server.svg.react.js deleted file mode 100644 index d04f228c..00000000 --- a/lib/ui/components/icons/pair-a-server.svg.react.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; - -export default class SVG extends React.PureComponent { - render() { - return ( - - - - - - - - - - - Pair-Server - - - - - - - - - - - - - - - - - - - - - - ); - } -} diff --git a/lib/ui/styles/components/_icons.scss b/lib/ui/styles/components/_icons.scss index fd9b6b4a..f2cc2535 100644 --- a/lib/ui/styles/components/_icons.scss +++ b/lib/ui/styles/components/_icons.scss @@ -22,12 +22,10 @@ $-icon-color-modifiers: ( 'cerulean-blue', 'paradise-pink', 'slate-blue', - 'white', + 'white' ) !default; -@mixin special-icon-colors( - $main-color: 'sea-green' -) { +@mixin special-icon-colors($main-color: 'sea-green') { // action / notification icons are a bit more detailed // and need to be styled individually &[name='add-a-context-button'] { @@ -66,14 +64,13 @@ $-icon-color-modifiers: ( &[name='add-a-context'] { .cls-9 { - fill: rgba(0,0,0, 0.1); + fill: rgba(0, 0, 0, 0.1); } .cls-10 { - fill: rgba(0,0,0, 0.2); + fill: rgba(0, 0, 0, 0.2); } } - &[name='add-a-person-button'], &[name='add-a-relationship-button'] { .cls-1 { @@ -146,7 +143,10 @@ $-icon-color-modifiers: ( } .cls-5 { - fill: rgba(var(--color-navy-taupe---rgb), 0.5); //sass-lint:disable-line no-color-literals + fill: rgba( + var(--color-navy-taupe---rgb), + 0.5 + ); //sass-lint:disable-line no-color-literals } .cls-6 { @@ -192,29 +192,6 @@ $-icon-color-modifiers: ( } } - &[name='pair-a-server'] { - .cls-8 { - fill: color('#{$main-color}'); - } - - .cls-2 { - fill: color('mustard'); - } - - .cls-3 { - fill: color('neon-coral'); - clip-path: url('#clip-path'); - } - - .cls-4 { - fill: color('platinum'); - } - - .cls-5 { - fill: color('platinum--dark'); - } - } - &[name='tick'], &[name='cross'] { .cls-1 { @@ -275,7 +252,6 @@ $-icon-color-modifiers: ( } &[name='protocol-card'] { - .cls-1 { fill: color('platinum--dark'); } @@ -293,7 +269,10 @@ $-icon-color-modifiers: ( } .cls-4 { - fill: rgba(var(--color-navy-taupe---rgb), 0.5); //sass-lint:disable-line no-color-literals + fill: rgba( + var(--color-navy-taupe---rgb), + 0.5 + ); //sass-lint:disable-line no-color-literals } .cls-5 { @@ -303,7 +282,6 @@ $-icon-color-modifiers: ( .cls-6 { fill: var(--color-slate-blue); } - } &[name='warning'] { @@ -370,7 +348,6 @@ $-icon-color-modifiers: ( .cls-3 { fill: color('#{$main-color}--dark'); } - } &[name='settings'] { @@ -395,7 +372,7 @@ $-icon-color-modifiers: ( } .cls-7 { - fill: rgba(255, 255, 255, 0.2); //sass-lint:disable-line no-color-literals + fill: rgba(255, 255, 255, 0.2); //sass-lint:disable-line no-color-literals } @each $ordinal-color in $ordinal-colors { @@ -405,17 +382,15 @@ $-icon-color-modifiers: ( @include color-icon($ordinal-color); } } - } &[name='contexts'] { - .cls-6 { fill: var(--color-cyber-grape); } .cls-7 { - fill: rgba(255, 255, 255, 0.2); //sass-lint:disable-line no-color-literals + fill: rgba(255, 255, 255, 0.2); //sass-lint:disable-line no-color-literals } @each $categorical-color in $categorical-colors { @@ -449,10 +424,7 @@ $-icon-color-modifiers: ( } } -@mixin icon ( - $icon-class: 'icon' -) { - +@mixin icon($icon-class: 'icon') { .#{$icon-class} { height: 50px; @@ -476,7 +448,6 @@ $-icon-color-modifiers: ( @include special-icon-colors; // simpler icons can be targeted with color modifiers @each $color-name in $-icon-color-modifiers { - @include modifier('primary') { @include color-icon; } diff --git a/lib/ui/styles/components/cards/_server-card.scss b/lib/ui/styles/components/cards/_server-card.scss deleted file mode 100644 index be313b6b..00000000 --- a/lib/ui/styles/components/cards/_server-card.scss +++ /dev/null @@ -1,57 +0,0 @@ -$component: 'server-card'; - -.#{$component} { - background: var(--color-platinum); - color: var(--color-navy-taupe); - border-radius: var(--nc-border-radius); - display: flex; - overflow: hidden; - - &__icon-section { - display: flex; - justify-content: center; - align-items: center; - background: var(--color-mustard); - flex-direction: row; - width: 7rem; - - .server-icon { - width: 4rem; - height: 4rem; - } - } - - &__main-section { - width: calc(100% - 7rem); - padding: unit(2) unit(4); - - .server-name { - margin: 0; - } - - h6 { - margin: 0.3rem 0; - text-transform: uppercase; - letter-spacing: 0.15em; - display: flex; - align-items: center; - justify-content: flex-end; - font-size: 0.64rem; - } - - } - - &--clickable { - @include clickable(2); - } - - &--disabled { - .#{$component}__icon-section { - background: var(--color-platinum--dark); - } - - .server-name, h6 { - opacity: 0.35; - } - } -} \ No newline at end of file diff --git a/lib/ui/styles/components/cards/cards.scss b/lib/ui/styles/components/cards/cards.scss index 803e3ab4..c28aed7d 100644 --- a/lib/ui/styles/components/cards/cards.scss +++ b/lib/ui/styles/components/cards/cards.scss @@ -1,4 +1,3 @@ @import './data-card'; @import './protocol-card'; -@import './server-card'; @import './session-card'; diff --git a/next.config.mjs b/next.config.mjs index 314abfd7..1913832d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -10,6 +10,7 @@ const config = { reactStrictMode: true, experimental: { typedRoutes: true, + webpackBuildWorker: true }, webpack: (config) => { config.module.rules.push({ diff --git a/package.json b/package.json index fc388bc4..33e17db9 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { - "@codaco/analytics": "1.0.1-alpha-1", + "@codaco/analytics": "^2.0.0", "@codaco/protocol-validation": "3.0.0-alpha.4", "@codaco/shared-consts": "^0.0.2", "@headlessui/react": "^1.7.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a88a0d5..bef9032a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@codaco/analytics': - specifier: 1.0.1-alpha-1 - version: 1.0.1-alpha-1(next@14.0.0) + specifier: ^2.0.0 + version: 2.0.0(@maxmind/geoip2-node@5.0.0)(next@14.0.0) '@codaco/protocol-validation': specifier: 3.0.0-alpha.4 version: 3.0.0-alpha.4(@types/eslint@8.44.6)(eslint-config-prettier@9.0.0)(eslint@8.52.0) @@ -1788,9 +1788,10 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true - /@codaco/analytics@1.0.1-alpha-1(next@14.0.0): - resolution: {integrity: sha512-opMtf/+44TjRAPNZtWuS/nj7Nza2gHsA4PE5DM6bZ2USGpu2+vx6euJFUin4uwlZGXqitgLPZfmHGxPchzbnHQ==} + /@codaco/analytics@2.0.0(@maxmind/geoip2-node@5.0.0)(next@14.0.0): + resolution: {integrity: sha512-HR9tlHVu+g2/Lwg11N9GmGXRNkai04uvRplLQpSJ92Ypszx2sc4xs19qEK10Sy5wJSotyCCNkTtvag1HQ6sFEQ==} peerDependencies: + '@maxmind/geoip2-node': ^5.0.0 next: 13 || 14 dependencies: '@maxmind/geoip2-node': 5.0.0 diff --git a/public/interviewer/icons/pair-a-server.svg b/public/interviewer/icons/pair-a-server.svg deleted file mode 100644 index b220823a..00000000 --- a/public/interviewer/icons/pair-a-server.svg +++ /dev/null @@ -1 +0,0 @@ -Pair-Server \ No newline at end of file diff --git a/server/routers/interview.ts b/server/routers/interview.ts index a6957474..a7c6560a 100644 --- a/server/routers/interview.ts +++ b/server/routers/interview.ts @@ -114,6 +114,28 @@ export const interviewRouter = router({ return interview; }), }), + finish: protectedProcedure + .input( + z.object({ + id: z.string(), + }), + ) + .mutation(async ({ input: { id } }) => { + try { + const updatedInterview = await prisma.interview.update({ + where: { + id, + }, + data: { + finishTime: new Date(), + }, + }); + + return { error: null, interview: updatedInterview }; + } catch (error) { + return { error: 'Failed to update interview', interview: null }; + } + }), delete: protectedProcedure .input( z.array(