From 4af084ae0652d52d6e2148b5c7f3dcbdab5f1651 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 8 Dec 2023 13:06:56 +0200 Subject: [PATCH] somewhat working stage index syncing --- .../_components/ParticipantsTable/Columns.tsx | 4 +- .../interview/[interviewId]/page.tsx | 1 + .../interview/_components/InterviewShell.tsx | 38 ++- app/(interview)/interview/new/page.tsx | 12 +- .../behaviours/DragAndDrop/DropTarget.js | 10 +- lib/interviewer/components/Navigation.tsx | 11 +- lib/interviewer/components/NodeList.js | 234 ++++++------------ lib/interviewer/containers/Overlay.js | 22 +- .../styles/components/_quick-add.scss | 4 +- .../styles/components/_stack-button.scss | 2 +- lib/ui/components/Modal.js | 6 - .../components/form/fields/_slider.scss | 2 +- lib/ui/styles/components/toasts/_toasts.scss | 2 +- lib/ui/styles/global/core/_palette.scss | 2 +- server/routers/interview.ts | 39 ++- 15 files changed, 173 insertions(+), 216 deletions(-) diff --git a/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns.tsx b/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns.tsx index 03f3f96c5..8bd8c843f 100644 --- a/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns.tsx +++ b/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns.tsx @@ -43,9 +43,9 @@ export const ParticipantColumns = cell: ({ row }) => ( - Participant page + Participant link ), enableSorting: false, diff --git a/app/(interview)/interview/[interviewId]/page.tsx b/app/(interview)/interview/[interviewId]/page.tsx index fe2e0c43a..20f875f78 100644 --- a/app/(interview)/interview/[interviewId]/page.tsx +++ b/app/(interview)/interview/[interviewId]/page.tsx @@ -31,6 +31,7 @@ export default async function Page({
diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index 0b9a3cba2..ac29d15e2 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -1,32 +1,62 @@ 'use client'; -import { Provider } from 'react-redux'; +import { Provider, useSelector } from 'react-redux'; import DialogManager from '~/lib/interviewer/components/DialogManager'; import ProtocolScreen from '~/lib/interviewer/containers/ProtocolScreen'; import { store } from '~/lib/interviewer/store'; import UserBanner from './UserBanner'; import { useEffect, useState } from 'react'; -import { parseAsInteger, useQueryState } from 'next-usequerystate'; import type { Protocol } from '@codaco/shared-consts'; import type { ServerSession } from '../[interviewId]/page'; import { SET_SERVER_SESSION, type SetServerSessionAction, } from '~/lib/interviewer/ducks/modules/setServerSession'; +import { getStageIndex } from '~/lib/interviewer/selectors/session'; +import { api } from '~/trpc/client'; + +// The job of ServerSync is to listen to actions in the redux store, and to sync +// data with the server. +const ServerSync = ({ interviewId }: { interviewId: string }) => { + const [init, setInit] = useState(false); + // Current stage + const currentStage = useSelector(getStageIndex); + const { mutate: updateStage } = + api.interview.sync.updateStageIndex.useMutation(); + + useEffect(() => { + if (!init) { + setInit(true); + return; + } + + console.log(`⬆️ Syncing stage index (${currentStage}) to server...`); + updateStage({ interviewId, stageIndex: currentStage }); + }, [currentStage, updateStage, interviewId, init]); + + return null; +}; // The job of interview shell is to receive the server-side session and protocol // and create a redux store with that data. // Eventually it will handle syncing this data back. const InterviewShell = ({ + interviewID, serverProtocol, serverSession, }: { + interviewID: string; serverProtocol: Protocol; serverSession: ServerSession; }) => { const [loading, setLoading] = useState(true); + const [init, setInit] = useState(false); useEffect(() => { + if (init) { + return; + } + store.dispatch({ type: SET_SERVER_SESSION, payload: { @@ -35,7 +65,8 @@ const InterviewShell = ({ }, }); setLoading(false); - }, [serverSession, serverProtocol]); + setInit(true); + }, [serverSession, serverProtocol, init]); if (loading) { return 'Second loading stage...'; @@ -43,6 +74,7 @@ const InterviewShell = ({ return ( + diff --git a/app/(interview)/interview/new/page.tsx b/app/(interview)/interview/new/page.tsx index b5a4fbe46..1adf3e36d 100644 --- a/app/(interview)/interview/new/page.tsx +++ b/app/(interview)/interview/new/page.tsx @@ -40,7 +40,7 @@ export default async function Page({ // Anonymous recruitment is enabled // Use the identifier from the URL, or generate a new one - const identifier = searchParams.identifier || faker.string.uuid(); + const identifier = searchParams.identifier ?? faker.string.uuid(); // Validate the identifier const isValid = participantIdentifierSchema.parse(identifier); @@ -55,15 +55,13 @@ export default async function Page({ } // Create the interview - const { error, createdInterview } = + const { createdInterviewId, error } = await api.interview.create.mutate(identifier); - if (error || !createdInterview) { - throw new Error(error || 'An error occurred while creating the interview'); + if (error) { + throw new Error('An error occurred while creating the interview'); } - // TODO: check if the identifier already exists. - // Redirect to the interview/[id] route - redirect(`/interview/${createdInterview.id}`); + redirect(`/interview/${createdInterviewId}`); } diff --git a/lib/interviewer/behaviours/DragAndDrop/DropTarget.js b/lib/interviewer/behaviours/DragAndDrop/DropTarget.js index cf22d254d..29ca2867a 100644 --- a/lib/interviewer/behaviours/DragAndDrop/DropTarget.js +++ b/lib/interviewer/behaviours/DragAndDrop/DropTarget.js @@ -62,7 +62,7 @@ const dropTarget = (WrappedComponent) => { onDrag, onDragEnd, accepts, - meta: meta(), + meta: meta?.(), width: boundingClientRect.width, height: boundingClientRect.height, y: boundingClientRect.top, @@ -96,14 +96,6 @@ const dropTarget = (WrappedComponent) => { meta: PropTypes.func, }; - DropTarget.defaultProps = { - meta: () => ({}), - accepts: () => false, - onDrop: () => {}, - onDrag: () => {}, - onDragEnd: () => {}, - }; - return DropTarget; }; diff --git a/lib/interviewer/components/Navigation.tsx b/lib/interviewer/components/Navigation.tsx index 4846f04d2..0176fb31e 100644 --- a/lib/interviewer/components/Navigation.tsx +++ b/lib/interviewer/components/Navigation.tsx @@ -20,6 +20,7 @@ const useNavigationHelpers = ( const { progress, + stageIndex, isLastPrompt, isFirstPrompt, isLastStage, @@ -94,6 +95,14 @@ const useNavigationHelpers = ( dispatch(sessionActions.updateStage(currentStage)); }, [currentStage, dispatch]); + // If currentStage is null, this is the first run. We need to set it based on + // the sessions current stage index. + useEffect(() => { + if (currentStage === null) { + setCurrentStage(stageIndex); + } + }, [currentStage, setCurrentStage, stageIndex]); + return { progress, isReadyForNextStage, @@ -139,7 +148,7 @@ const NavigationButton = ({ const Navigation = () => { const [currentStage, setCurrentStage] = useQueryState( 'stage', - parseAsInteger.withDefault(1), + parseAsInteger, ); const { diff --git a/lib/interviewer/components/NodeList.js b/lib/interviewer/components/NodeList.js index d39884895..b3fe8bbd9 100644 --- a/lib/interviewer/components/NodeList.js +++ b/lib/interviewer/components/NodeList.js @@ -1,13 +1,11 @@ -import React, { Component } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { compose } from 'redux'; import PropTypes from 'prop-types'; -import { find, isEqual } from 'lodash'; +import { find } from 'lodash'; import cx from 'classnames'; -import { TransitionGroup } from 'react-transition-group'; import { getCSSVariableAsString, getCSSVariableAsNumber } from '~/lib/ui/utils/CSSVariables'; import { entityPrimaryKeyProperty } from '@codaco/shared-consts'; import Node from './Node'; -import NodeTransition from './Transition/Node'; import scrollable from '../behaviours/scrollable'; import { DragSource, @@ -16,150 +14,82 @@ import { MonitorDragSource, } from '../behaviours/DragAndDrop'; import createSorter from '../utils/createSorter'; -import { get } from '../utils/lodash-replacements'; +import { motion, AnimatePresence } from 'framer-motion'; +import { v4 } from 'uuid'; const EnhancedNode = DragSource(Node); -/** - * Renders a list of Nodes. - */ -class NodeList extends Component { - constructor(props) { - super(props); - - const sorter = createSorter(props.sortOrder); - const sortedNodes = sorter(props.items); - - this.state = { - items: sortedNodes, - stagger: true, - exit: true, - }; - - this.refreshTimer = null; - } - - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(newProps) { - const { - items, - listId, - disableDragNew, - } = this.props; - - if (this.refreshTimer) { clearTimeout(this.refreshTimer); } - - // Don't update if items are the same - if (isEqual(newProps.items, items) && isEqual(newProps.disableDragNew, disableDragNew)) { - return; - } - - const sorter = createSorter(newProps.sortOrder); - const sortedNodes = sorter(newProps.items); - // if we provided the same id, then just update normally - if (newProps.listId === listId) { - this.setState({ exit: false }, () => { - this.setState({ items: sortedNodes, stagger: false }); - }); - return; - } - - // Otherwise, transition out and in again - this.setState({ exit: true }, () => { - this.setState( - { items: [], stagger: true }, - () => { - this.refreshTimer = setTimeout( - () => this.setState({ - items: sortedNodes, - stagger: true, - }), - getCSSVariableAsNumber('--animation-duration-slow-ms'), +const NodeList = (props) => { + const { + disableDragNew, + items: initialItems = [], + label = () => '', + itemType = 'NODE', + isOver, + willAccept, + meta = {}, + hoverColor, + className, + stage: { id: stageId }, + externalData, + sortOrder = [], + onDrop = () => { }, + onItemClick = () => { }, + } = props; + + const [items, setItems] = useState(createSorter(sortOrder)(initialItems)); + const [stagger, setStagger] = useState(true); + const instanceId = useRef(v4()); + + // useEffect(() => { + // // When items or sorting changes, change the instance id to trigger + // // the animation. + // instanceId.current = v4(); + + // const sorter = createSorter(sortOrder); + // const sortedNodes = sorter(initialItems); + + // setItems(sortedNodes); + // }, [initialItems, sortOrder]); + + const isSource = !!find(items, [entityPrimaryKeyProperty, meta.entityPrimaryKeyProperty ?? null]); + const isValidTarget = !isSource && willAccept; + const isHovering = isValidTarget && isOver; + + const classNames = cx('node-list', className, { 'node-list--drag': isValidTarget }); + const hoverBackgroundColor = hoverColor || getCSSVariableAsString('--nc-light-background'); + const styles = isHovering ? { backgroundColor: hoverBackgroundColor } : {}; + + return ( + + + {initialItems.map((node, index) => { + const isDraggable = + !(externalData && disableDragNew) && !(disableDragNew && node.stageId !== stageId); + + return ( + onItemClick(node)} + key={`${instanceId.current}-${node.entityPrimaryKeyProperty}`} + initial={{ opacity: 0, y: '50%', scale: 0.5 }} + animate={{ opacity: 1, y: 0, scale: 1, transition: { delay: stagger ? index * 0.1 : 0 } }} + exit={{ opacity: 0, scale: 0, }} + > + ({ ...node, itemType })} + itemType={itemType} + {...node} + /> + ); - }, - ); - }); - } - - componentWillUnmount() { - if (this.refreshTimer) { clearTimeout(this.refreshTimer); } - } - - render() { - const { - label, - onItemClick, - itemType, - isOver, - willAccept, - meta, - hoverColor, - className, - stage: { - id: stageId, - }, - disableDragNew, - externalData, - } = this.props; - - const { - stagger, - items, - exit, - } = this.state; - - const isSource = !!find( - items, - [entityPrimaryKeyProperty, get(meta, entityPrimaryKeyProperty, null)], - ); - - const isValidTarget = !isSource && willAccept; - const isHovering = isValidTarget && isOver; - - const classNames = cx( - 'node-list', - className, - { 'node-list--drag': isValidTarget }, - ); - - const hoverBackgroundColor = hoverColor || getCSSVariableAsString('--nc-light-background'); - - const styles = isHovering ? { backgroundColor: hoverBackgroundColor } : {}; - - return ( - - { - items.map((node, index) => { - const isDraggable = !(externalData && disableDragNew) - && !(disableDragNew && node.stageId !== stageId); - - return ( - -
onItemClick(node)}> - ({ ...node, itemType })} - itemType={itemType} - {...node} - /> -
-
- ); - }) - } -
- ); - } -} + })} +
+
+ ); +}; NodeList.propTypes = { disableDragNew: PropTypes.bool, @@ -180,22 +110,6 @@ NodeList.propTypes = { willAccept: PropTypes.bool, }; -NodeList.defaultProps = { - disableDragNew: false, - className: null, - hoverColor: null, - isDragging: false, - isOver: false, - items: [], - itemType: 'NODE', - label: () => (''), - meta: {}, - onDrop: () => { }, - onItemClick: () => { }, - sortOrder: [], - willAccept: false, -}; - export default compose( DropTarget, MonitorDropTarget(['isOver', 'willAccept']), diff --git a/lib/interviewer/containers/Overlay.js b/lib/interviewer/containers/Overlay.js index dac7774e3..7381a17af 100644 --- a/lib/interviewer/containers/Overlay.js +++ b/lib/interviewer/containers/Overlay.js @@ -19,11 +19,11 @@ const Overlay = (props) => { show, title, footer, - fullheight, + fullheight = false, fullscreen: fullscreenProp, - forceDisableFullscreen, - forceEnableFullscreen, - allowMaximize, + forceDisableFullscreen = false, + forceEnableFullscreen = false, + allowMaximize = true, className, } = props; const useFullScreenFormsPref = useSelector((state) => state.deviceSettings.useFullScreenForms); @@ -109,20 +109,6 @@ Overlay.propTypes = { className: PropTypes.string, }; -Overlay.defaultProps = { - onBlur: () => { }, - onClose: () => { }, - title: null, - className: '', - show: false, - children: null, - footer: null, - fullheight: false, - forceDisableFullscreen: false, - forceEnableFullscreen: false, - allowMaximize: true, -}; - export { Overlay, }; diff --git a/lib/interviewer/styles/components/_quick-add.scss b/lib/interviewer/styles/components/_quick-add.scss index c2c064a9a..b13dd11c6 100644 --- a/lib/interviewer/styles/components/_quick-add.scss +++ b/lib/interviewer/styles/components/_quick-add.scss @@ -37,7 +37,7 @@ .tool-tip { height: units.unit(2); margin-bottom: units.unit(1); - text-shadow: 0 0 0.5rem var(--text-dark); + text-shadow: 0 0 0.5rem var(--nc-text-dark); } .label-input { @@ -46,7 +46,7 @@ border-radius: 5rem; border: 0; background: var(--nc-text); - color: var(--text-dark); + color: var(--nc-text-dark); font-size: 1.2rem; font-weight: bold; font-family: var(--body-font-family); diff --git a/lib/interviewer/styles/components/_stack-button.scss b/lib/interviewer/styles/components/_stack-button.scss index adad7b0de..7682ee0c9 100644 --- a/lib/interviewer/styles/components/_stack-button.scss +++ b/lib/interviewer/styles/components/_stack-button.scss @@ -13,7 +13,7 @@ } &__content { - color: var(--text-dark); + color: var(--nc-text-dark); position: absolute; height: 100%; width: 100%; diff --git a/lib/ui/components/Modal.js b/lib/ui/components/Modal.js index 9f1b99a1a..d6a6343b7 100644 --- a/lib/ui/components/Modal.js +++ b/lib/ui/components/Modal.js @@ -63,11 +63,5 @@ Modal.propTypes = { onBlur: PropTypes.func, }; -Modal.defaultProps = { - show: false, - zIndex: null, - children: null, - onBlur: () => { }, -}; export default Modal; \ No newline at end of file diff --git a/lib/ui/styles/components/form/fields/_slider.scss b/lib/ui/styles/components/form/fields/_slider.scss index 8dbb84609..568edce5d 100644 --- a/lib/ui/styles/components/form/fields/_slider.scss +++ b/lib/ui/styles/components/form/fields/_slider.scss @@ -10,7 +10,7 @@ $module-name: form-field-slider; --disabled-color: var(--color-charcoal); --label-color: var(--color-white); --tooltip-color: var(--color-white); - --tooltip-label: var(--text-dark); + --tooltip-label: var(--nc-text-dark); // border-radius: 1rem 1rem 0 0; // background-color: var(--input-background); diff --git a/lib/ui/styles/components/toasts/_toasts.scss b/lib/ui/styles/components/toasts/_toasts.scss index 6276c939e..6fe0e0bb3 100644 --- a/lib/ui/styles/components/toasts/_toasts.scss +++ b/lib/ui/styles/components/toasts/_toasts.scss @@ -29,7 +29,7 @@ flex: 0 0 auto; position: relative; display: flex; - color: var(--text-dark); + color: var(--nc-text-dark); &--info { border-color: var(--info); diff --git a/lib/ui/styles/global/core/_palette.scss b/lib/ui/styles/global/core/_palette.scss index 97a5cb1c1..09daa6e87 100644 --- a/lib/ui/styles/global/core/_palette.scss +++ b/lib/ui/styles/global/core/_palette.scss @@ -10,7 +10,7 @@ $-palette-map: () !default; @if $use-mapped-value == true { @return map-get($-palette-map, $palette-name); } @else { - @return var(--#{$palette-name}); + @return var(--nc-#{$palette-name}); } } diff --git a/server/routers/interview.ts b/server/routers/interview.ts index 5d1b84361..fa153936c 100644 --- a/server/routers/interview.ts +++ b/server/routers/interview.ts @@ -5,8 +5,34 @@ import { participantIdentifierSchema } from '~/shared/schemas/schemas'; import { z } from 'zod'; import { Prisma } from '@prisma/client'; import { NcNetworkZod } from '~/shared/schemas/network-canvas'; +import { ensureError } from '~/utils/ensureError'; export const interviewRouter = router({ + sync: router({ + updateStageIndex: publicProcedure + .input( + z.object({ + interviewId: z.string().cuid(), + stageIndex: z.number(), + }), + ) + .mutation(async ({ input: { interviewId, stageIndex } }) => { + try { + await prisma.interview.update({ + where: { + id: interviewId, + }, + data: { + currentStep: stageIndex, + }, + }); + + return { success: true, error: null }; + } catch (error) { + return { success: false, error: 'Failed to update interview' }; + } + }), + }), create: publicProcedure .input(participantIdentifierSchema) .mutation(async ({ input: identifier }) => { @@ -20,7 +46,7 @@ export const interviewRouter = router({ return { errorType: 'NO_ACTIVE_PROTOCOL', error: 'Failed to create interview: no active protocol', - createdInterview: null, + createdInterviewId: null, }; } @@ -42,12 +68,17 @@ export const interviewRouter = router({ }, }); - return { error: null, createdInterview, errorType: null }; + return { + error: null, + createdInterviewId: createdInterview.id, + errorType: null, + }; } catch (error) { + const e = ensureError(error); return { - errorType: 'UNKNOWN', + errorType: e.message, error: 'Failed to create interview', - createdInterview: null, + createdInterviewId: null, }; } }),