diff --git a/apps/frontend/src/components/template/PreviewTemplateModal.tsx b/apps/frontend/src/components/template/PreviewTemplateModal.tsx index 4e186d04a8..cd6429efe5 100644 --- a/apps/frontend/src/components/template/PreviewTemplateModal.tsx +++ b/apps/frontend/src/components/template/PreviewTemplateModal.tsx @@ -22,10 +22,12 @@ import TechnicalReviewQuestionary, { createTechnicalReviewStub, } from 'components/review/TechnicalReviewQuestionary'; import ShipmentContainer from 'components/shipments/ShipmentContainer'; +import VisitRegistrationContainer from 'components/visit/VisitRegistrationContainer'; import { UserContext } from 'context/UserContextProvider'; import { BasicUserDetails, TemplateGroupId } from 'generated/sdk'; import { useBlankQuestionaryStepsData } from 'hooks/questionary/useBlankQuestionaryStepsData'; import { createShipmentStub } from 'hooks/shipment/useBlankShipment'; +import { createRegistrationStub } from 'hooks/visit/useBlankVisitRegistration'; type PreviewTemplateModalProps = { templateId: number | null; @@ -78,8 +80,19 @@ const PreviewTemplateModal = ({ previewMode={true} /> ); - case TemplateGroupId.FEEDBACK: case TemplateGroupId.VISIT_REGISTRATION: + return ( + + ); + case TemplateGroupId.FEEDBACK: case TemplateGroupId.GENERIC_TEMPLATE: return ( (null); - const [selectedQuestion, setSelectedQuestion] = useState( - null - ); - - const [hoveredDependency, setHoveredDependency] = useState(''); - const [openedPreviewTemplateId, setOpenedPreviewTemplateId] = useState< - number | null - >(null); - - const [questionPickerTopicId, setQuestionPickerTopicId] = useState< - number | null - >(null); - const handleEvents = ({ - getState, - }: MiddlewareInputParams) => { - return (next: FunctionType) => (action: Event) => { - next(action); - switch (action.type) { - case EventType.QUESTION_CREATED: - setSelectedQuestion(action.payload); - break; - - case EventType.PICK_QUESTION_REQUESTED: - setQuestionPickerTopicId(action.payload.topic.id); - break; - - case EventType.OPEN_QUESTION_EDITOR: - setSelectedQuestion(action.payload); - break; - - case EventType.OPEN_QUESTIONREL_EDITOR: - const templateRelation = getFieldById( - getState().steps, - action.payload.questionId - ); - if (!templateRelation) { - return; - } - - setSelectedQuestionTemplateRelation( - templateRelation as QuestionTemplateRelation - ); - break; - - case EventType.QUESTION_PICKER_NEW_QUESTION_CLICKED: - setQuestionPickerTopicId(action.payload.topic.id); - break; - case EventType.DEPENDENCY_HOVER: - setHoveredDependency(action.payload.dependency); - break; - } - }; - }; - const { persistModel, isLoading } = usePersistQuestionaryEditorModel(); - const { state, dispatch } = QuestionaryEditorModel([ - persistModel, - handleEvents, - ]); - - const [isTopicReorderMode, setIsTopicReorderMode] = useState(false); - - const theme = useTheme(); - const isExtraLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); - - const getTopicListStyle = (isDraggingOver: boolean) => ({ - background: isDraggingOver - ? theme.palette.primary.light - : theme.palette.grey[100], - transition: 'all 500ms cubic-bezier(0.190, 1.000, 0.220, 1.000)', - display: 'flex', - overflow: 'auto', - maxHeight: isExtraLargeScreen ? '1400px' : '700px', - }); - - const onDragEnd = (result: DropResult): void => { - const dragSource = result.source; - const dragDestination = result.destination; - - if ( - !dragDestination || - (dragDestination.droppableId === dragSource.droppableId && - dragDestination.index === dragSource.index) - ) { - return; - } - - const isDraggingQuestion = result.type === 'field'; - const isDraggingTopic = result.type === 'topic'; - - if (isDraggingQuestion) { - const isDraggingFromQuestionDrawerToTopic = - dragSource.droppableId === 'questionPicker' && - dragDestination.droppableId !== 'questionPicker'; - const isDraggingFromTopicToQuestionDrawer = - dragDestination.droppableId === 'questionPicker' && - dragSource.droppableId !== 'questionPicker'; - const isReorderingInsideTopics = - dragDestination.droppableId !== 'questionPicker' && - dragSource.droppableId !== 'questionPicker'; - - if (isDraggingFromQuestionDrawerToTopic) { - const questionId = result.draggableId; - const topicId = dragDestination.droppableId - ? +dragDestination.droppableId - : undefined; - - const sortOrder = dragDestination.index; - - if (topicId && questionId) { - dispatch({ - type: EventType.CREATE_QUESTION_REL_REQUESTED, - payload: { - topicId, - questionId, - sortOrder, - templateId: state.templateId, - }, - }); - } - } else if (isDraggingFromTopicToQuestionDrawer) { - const topicId = parseInt(dragSource.droppableId); - const step = getQuestionaryStepByTopicId( - state.steps, - topicId - ) as QuestionaryStep; - const question = step.fields[dragSource.index].question; - api() - .deleteQuestionTemplateRelation({ - templateId: state.templateId, - questionId: question.id, - }) - .then((data) => { - if (data.deleteQuestionTemplateRelation) { - dispatch({ - type: EventType.QUESTION_REL_UPDATED, - payload: data.deleteQuestionTemplateRelation, - }); - } - }); - } else if (isReorderingInsideTopics) { - dispatch({ - type: EventType.REORDER_QUESTION_REL_REQUESTED, - payload: { - source: dragSource, - destination: dragDestination, - }, - }); - } - } - if (isDraggingTopic) { - dispatch({ - type: EventType.REORDER_TOPIC_REQUESTED, - payload: { source: dragSource, destination: dragDestination }, - }); - } - }; - - const getContainerStyle = (): React.CSSProperties => { - return isLoading || state.templateId === 0 - ? { - pointerEvents: 'none', - userSelect: 'none', - opacity: 0.5, - } - : {}; - }; - - const progressJsx = isLoading ? : null; - const newTopicFallbackButton = - state.steps.length === 0 ? ( - - ) : null; - - const topControlBarElements = []; - - if (state.steps.length > 1) { - topControlBarElements.push( - setIsTopicReorderMode(!isTopicReorderMode)} - /> - } - label="Reorder topics mode" - /> - ); - } - - topControlBarElements.push( - - setOpenedPreviewTemplateId(state.templateId)} - data-cy="preview-questionary-template" - > - - - - ); - - const topControlBar = topControlBarElements.length ? ( - - {topControlBarElements.map((element, index) => ( -
{element}
- ))} -
- ) : null; - - return ( - - {openedPreviewTemplateId !== null && ( - - )} - - - {progressJsx} - {topControlBar} - - - {(provided, snapshot) => ( -
- {state.steps.map((step, index) => { - const questionPicker = - step.topic.id === questionPickerTopicId ? ( - { - setQuestionPickerTopicId(null); - }} - id="questionPicker" - /> - ) : null; - - return ( - - - {questionPicker} - - ); - })} - {provided.placeholder} -
- )} -
-
- {newTopicFallbackButton} -
- - setSelectedQuestionTemplateRelation(null)} - template={state} - /> - - setSelectedQuestion(null)} - template={state} - /> -
- ); -} +import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; +import Button from '@mui/material/Button'; +import LinearProgress from '@mui/material/LinearProgress'; +import React, { useState } from 'react'; + +import { usePersistQuestionaryEditorModel } from 'hooks/questionary/usePersistQuestionaryEditorModel'; +import { useTemplateEditorSelection } from 'hooks/questionary/useTemplateEditorSelection'; +import QuestionaryEditorModel, { + EventType, +} from 'models/questionary/QuestionaryEditorModel'; +import { handleTemplateDragEnd } from 'models/questionary/templateEditorDragHandlers'; +import { StyledContainer, StyledPaper } from 'styles/StyledComponents'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; + +import PreviewTemplateModal from './PreviewTemplateModal'; +import QuestionEditor from './QuestionEditor'; +import QuestionTemplateRelationEditor from './QuestionTemplateRelationEditor'; +import { TemplateEditorContent } from './TemplateEditorContent'; +import { TemplateEditorToolbar } from './TemplateEditorToolbar'; +import { TemplateMetadataEditor } from './TemplateMetadataEditor'; + +export default function TemplateEditor() { + const { api } = useDataApiWithFeedback(); + + const { + selectedQuestionTemplateRelation, + setSelectedQuestionTemplateRelation, + selectedQuestion, + setSelectedQuestion, + hoveredDependency, + openedPreviewTemplateId, + setOpenedPreviewTemplateId, + questionPickerTopicId, + setQuestionPickerTopicId, + handleEvents, + } = useTemplateEditorSelection(); + + const { persistModel, isLoading } = usePersistQuestionaryEditorModel(); + const { state, dispatch } = QuestionaryEditorModel([ + persistModel, + handleEvents, + ]); + + const [isTopicReorderMode, setIsTopicReorderMode] = useState(false); + + const getContainerStyle = (): React.CSSProperties => + isLoading || state.templateId === 0 + ? { + pointerEvents: 'none', + userSelect: 'none', + opacity: 0.5, + } + : {}; + + return ( + + {openedPreviewTemplateId !== null && ( + + )} + + + {isLoading && } + setIsTopicReorderMode((prev) => !prev)} + onPreview={() => setOpenedPreviewTemplateId(state.templateId)} + /> + setQuestionPickerTopicId(null)} + isTopicReorderMode={isTopicReorderMode} + hoveredDependency={hoveredDependency} + onDragEnd={(result) => + handleTemplateDragEnd(result, { state, dispatch, api }) + } + /> + {state.steps.length === 0 && ( + + )} + + + setSelectedQuestionTemplateRelation(null)} + template={state} + /> + + setSelectedQuestion(null)} + template={state} + /> + + ); +} diff --git a/apps/frontend/src/components/template/TemplateEditorContent.tsx b/apps/frontend/src/components/template/TemplateEditorContent.tsx new file mode 100644 index 0000000000..fa20905345 --- /dev/null +++ b/apps/frontend/src/components/template/TemplateEditorContent.tsx @@ -0,0 +1,85 @@ +import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import React from 'react'; + +import { Template } from 'generated/sdk'; +import { Event } from 'models/questionary/QuestionaryEditorModel'; + +import { QuestionPicker } from './QuestionPicker'; +import QuestionaryEditorTopic from './TemplateTopicEditor'; + +interface TemplateEditorContentProps { + template: Template; + dispatch: React.Dispatch; + questionPickerTopicId: number | null; + closeQuestionPicker: () => void; + isTopicReorderMode: boolean; + hoveredDependency: string; + onDragEnd: (result: DropResult) => void; +} + +export function TemplateEditorContent({ + template, + dispatch, + questionPickerTopicId, + closeQuestionPicker, + isTopicReorderMode, + hoveredDependency, + onDragEnd, +}: TemplateEditorContentProps) { + const theme = useTheme(); + const isExtraLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); + + const getTopicListStyle = (isDraggingOver: boolean) => ({ + background: isDraggingOver + ? theme.palette.primary.light + : theme.palette.grey[100], + transition: 'all 500ms cubic-bezier(0.190, 1.000, 0.220, 1.000)', + display: 'flex', + overflow: 'auto', + maxHeight: isExtraLargeScreen ? '1400px' : '700px', + }); + + return ( + + + {(provided, snapshot) => ( +
+ {template.steps.map((step, index) => { + const questionPicker = + step.topic.id === questionPickerTopicId ? ( + + ) : null; + + return ( + + + {questionPicker} + + ); + })} + {provided.placeholder} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/components/template/TemplateEditorToolbar.tsx b/apps/frontend/src/components/template/TemplateEditorToolbar.tsx new file mode 100644 index 0000000000..e5b224fe21 --- /dev/null +++ b/apps/frontend/src/components/template/TemplateEditorToolbar.tsx @@ -0,0 +1,45 @@ +import Preview from '@mui/icons-material/Preview'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import IconButton from '@mui/material/IconButton'; +import Switch from '@mui/material/Switch'; +import Tooltip from '@mui/material/Tooltip'; +import React from 'react'; + +interface TemplateEditorToolbarProps { + stepCount: number; + isTopicReorderMode: boolean; + onToggleReorderMode: () => void; + onPreview: () => void; +} + +export function TemplateEditorToolbar({ + stepCount, + isTopicReorderMode, + onToggleReorderMode, + onPreview, +}: TemplateEditorToolbarProps) { + return ( + + {stepCount > 1 && ( + + } + label="Reorder topics mode" + /> + )} + + + + + + + ); +} diff --git a/apps/frontend/src/components/visit/VisitRegistrationContainer.tsx b/apps/frontend/src/components/visit/VisitRegistrationContainer.tsx index 7bde126c16..7510c0988a 100644 --- a/apps/frontend/src/components/visit/VisitRegistrationContainer.tsx +++ b/apps/frontend/src/components/visit/VisitRegistrationContainer.tsx @@ -21,6 +21,7 @@ export interface VisitRegistrationContextType extends QuestionaryContextType { export interface VisitRegistrationContainerProps { registration: RegistrationWithQuestionary; onSubmitted?: (registration: RegistrationWithQuestionary) => void; + previewMode?: boolean; } export default function VisitRegistrationContainer( props: VisitRegistrationContainerProps @@ -48,7 +49,10 @@ export default function VisitRegistrationContainer( return ( - + ); } diff --git a/apps/frontend/src/hooks/questionary/useTemplateEditorSelection.ts b/apps/frontend/src/hooks/questionary/useTemplateEditorSelection.ts new file mode 100644 index 0000000000..bca5fb1c2b --- /dev/null +++ b/apps/frontend/src/hooks/questionary/useTemplateEditorSelection.ts @@ -0,0 +1,90 @@ +import { useState } from 'react'; + +import { Question, QuestionTemplateRelation, Template } from 'generated/sdk'; +import { Event, EventType } from 'models/questionary/QuestionaryEditorModel'; +import { getFieldById } from 'models/questionary/QuestionaryFunctions'; +import { + MiddlewareInputParams, + ReducerMiddleware, +} from 'utils/useReducerWithMiddleWares'; +import { FunctionType } from 'utils/utilTypes'; + +/** + * Owns the TemplateEditor's transient UI selection state (which question / + * relation / picker / preview is currently open) together with the reducer + * middleware that drives it. The middleware reacts to editor events and opens + * the matching drawer/picker, keeping that wiring next to the state it mutates. + */ +export function useTemplateEditorSelection() { + const [ + selectedQuestionTemplateRelation, + setSelectedQuestionTemplateRelation, + ] = useState(null); + const [selectedQuestion, setSelectedQuestion] = useState( + null + ); + const [hoveredDependency, setHoveredDependency] = useState(''); + const [openedPreviewTemplateId, setOpenedPreviewTemplateId] = useState< + number | null + >(null); + const [questionPickerTopicId, setQuestionPickerTopicId] = useState< + number | null + >(null); + + const handleEvents: ReducerMiddleware = ({ + getState, + }: MiddlewareInputParams) => { + return (next: FunctionType) => (action: Event) => { + next(action); + switch (action.type) { + case EventType.QUESTION_CREATED: + setSelectedQuestion(action.payload); + break; + + case EventType.PICK_QUESTION_REQUESTED: + setQuestionPickerTopicId(action.payload.topic.id); + break; + + case EventType.OPEN_QUESTION_EDITOR: + setSelectedQuestion(action.payload); + break; + + case EventType.OPEN_QUESTIONREL_EDITOR: { + const templateRelation = getFieldById( + getState().steps, + action.payload.questionId + ); + if (!templateRelation) { + return; + } + + setSelectedQuestionTemplateRelation( + templateRelation as QuestionTemplateRelation + ); + break; + } + + case EventType.QUESTION_PICKER_NEW_QUESTION_CLICKED: + setQuestionPickerTopicId(action.payload.topic.id); + break; + + case EventType.DEPENDENCY_HOVER: + setHoveredDependency(action.payload.dependency); + break; + } + }; + }; + + return { + selectedQuestionTemplateRelation, + setSelectedQuestionTemplateRelation, + selectedQuestion, + setSelectedQuestion, + hoveredDependency, + openedPreviewTemplateId, + setOpenedPreviewTemplateId, + questionPickerTopicId, + setQuestionPickerTopicId, + handleEvents, + }; +} diff --git a/apps/frontend/src/hooks/visit/useBlankVisitRegistration.ts b/apps/frontend/src/hooks/visit/useBlankVisitRegistration.ts index 284dccafa5..ca665e4a90 100644 --- a/apps/frontend/src/hooks/visit/useBlankVisitRegistration.ts +++ b/apps/frontend/src/hooks/visit/useBlankVisitRegistration.ts @@ -9,7 +9,7 @@ import { import { useDataApi } from 'hooks/common/useDataApi'; import { RegistrationWithQuestionary } from 'models/questionary/visit/VisitRegistrationWithQuestionary'; -function createRegistrationStub( +export function createRegistrationStub( userId: number, templateId: number, questionarySteps: QuestionaryStep[], diff --git a/apps/frontend/src/models/questionary/templateEditorDragHandlers.ts b/apps/frontend/src/models/questionary/templateEditorDragHandlers.ts new file mode 100644 index 0000000000..8b8af6bba1 --- /dev/null +++ b/apps/frontend/src/models/questionary/templateEditorDragHandlers.ts @@ -0,0 +1,134 @@ +import { DraggableLocation, DropResult } from '@hello-pangea/dnd'; +import React from 'react'; + +import { QuestionaryStep, Template } from 'generated/sdk'; +import { Event, EventType } from 'models/questionary/QuestionaryEditorModel'; +import { getQuestionaryStepByTopicId } from 'models/questionary/QuestionaryFunctions'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; + +const QUESTION_PICKER_DROPPABLE_ID = 'questionPicker'; + +type DataApi = ReturnType['api']; + +export interface TemplateDragHandlerDeps { + state: Template; + dispatch: React.Dispatch; + api: DataApi; +} + +const isSamePosition = ( + source: DraggableLocation, + destination: DraggableLocation +): boolean => + destination.droppableId === source.droppableId && + destination.index === source.index; + +const isPicker = (location: DraggableLocation): boolean => + location.droppableId === QUESTION_PICKER_DROPPABLE_ID; + +const addQuestionToTopic = ( + result: DropResult, + destination: DraggableLocation, + { state, dispatch }: TemplateDragHandlerDeps +): void => { + const questionId = result.draggableId; + const topicId = destination.droppableId + ? +destination.droppableId + : undefined; + const sortOrder = destination.index; + + if (topicId && questionId) { + dispatch({ + type: EventType.CREATE_QUESTION_REL_REQUESTED, + payload: { + topicId, + questionId, + sortOrder, + templateId: state.templateId, + }, + }); + } +}; + +const removeQuestionFromTopic = ( + source: DraggableLocation, + { state, dispatch, api }: TemplateDragHandlerDeps +): void => { + const topicId = parseInt(source.droppableId); + const step = getQuestionaryStepByTopicId( + state.steps, + topicId + ) as QuestionaryStep; + const question = step.fields[source.index].question; + + api() + .deleteQuestionTemplateRelation({ + templateId: state.templateId, + questionId: question.id, + }) + .then((data) => { + if (data.deleteQuestionTemplateRelation) { + dispatch({ + type: EventType.QUESTION_REL_UPDATED, + payload: data.deleteQuestionTemplateRelation, + }); + } + }); +}; + +const reorderQuestionWithinTopics = ( + source: DraggableLocation, + destination: DraggableLocation, + { dispatch }: TemplateDragHandlerDeps +): void => { + dispatch({ + type: EventType.REORDER_QUESTION_REL_REQUESTED, + payload: { source, destination }, + }); +}; + +const reorderTopic = ( + source: DraggableLocation, + destination: DraggableLocation, + { dispatch }: TemplateDragHandlerDeps +): void => { + dispatch({ + type: EventType.REORDER_TOPIC_REQUESTED, + payload: { source, destination }, + }); +}; + +/** + * Entry point for the template editor's drag-and-drop interactions. Routes a + * drop to the matching handler based on whether a question or a topic was + * dragged, and where it came from / went to (the question picker drawer vs. a + * topic column). + */ +export const handleTemplateDragEnd = ( + result: DropResult, + deps: TemplateDragHandlerDeps +): void => { + const { source, destination } = result; + + if (!destination || isSamePosition(source, destination)) { + return; + } + + if (result.type === 'topic') { + reorderTopic(source, destination, deps); + + return; + } + + if (result.type !== 'field') { + return; + } + + if (isPicker(source) && !isPicker(destination)) { + addQuestionToTopic(result, destination, deps); + } else if (isPicker(destination) && !isPicker(source)) { + removeQuestionFromTopic(source, deps); + } else if (!isPicker(source) && !isPicker(destination)) { + reorderQuestionWithinTopics(source, destination, deps); + } +};