From b852961ed6f9da05a9a09b3cd3c6c089cf77415e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Katar=C3=ADna=20Platkov=C3=A1?= <xplatkov@fi.muni.cz> Date: Thu, 19 Sep 2024 14:39:56 +0200 Subject: [PATCH] Editor - inject specification forms --- frontend/src/editor/EditorPage/index.tsx | 1 + .../InjectSpecification/EmailInjectForm.tsx | 116 ++++++++++ .../InformationInjectForm.tsx | 86 ++++++++ .../QuestionnaireForm/CustomLabelForm.tsx | 72 +++++++ .../QuestionCustomLabels.tsx | 105 +++++++++ .../QuestionnaireForm/QuestionRangeLabels.tsx | 68 ++++++ .../QuestionnaireQuestion.tsx | 88 ++++++++ .../QuestionnaireQuestionForm.tsx | 202 ++++++++++++++++++ .../QuestionnaireQuestions.tsx | 37 ++++ .../QuestionnaireForm/index.tsx | 97 +++++++++ .../src/editor/InjectSpecification/index.tsx | 64 ++++++ frontend/src/editor/InjectsOverview/index.tsx | 36 ++++ .../EmailTemplateForm.tsx | 6 +- .../ToolResponseForm.tsx | 6 +- .../LearningActivitySpecification/index.tsx | 6 +- frontend/src/editor/Navbar/index.tsx | 6 +- frontend/src/editor/indexeddb/db.tsx | 13 ++ frontend/src/editor/indexeddb/operations.tsx | 85 ++++++++ frontend/src/editor/indexeddb/types.tsx | 31 +++ frontend/src/editor/utils.tsx | 12 +- .../[activityId]/index.tsx | 4 +- .../create/activity-specification/index.tsx | 3 +- .../inject-specification/[injectId]/index.tsx | 22 ++ .../create/inject-specification/index.tsx | 17 ++ frontend/src/router.ts | 3 + 25 files changed, 1170 insertions(+), 16 deletions(-) create mode 100644 frontend/src/editor/InjectSpecification/EmailInjectForm.tsx create mode 100644 frontend/src/editor/InjectSpecification/InformationInjectForm.tsx create mode 100644 frontend/src/editor/InjectSpecification/QuestionnaireForm/CustomLabelForm.tsx create mode 100644 frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionCustomLabels.tsx create mode 100644 frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionRangeLabels.tsx create mode 100644 frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestion.tsx create mode 100644 frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestionForm.tsx create mode 100644 frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestions.tsx create mode 100644 frontend/src/editor/InjectSpecification/QuestionnaireForm/index.tsx create mode 100644 frontend/src/editor/InjectSpecification/index.tsx create mode 100644 frontend/src/editor/InjectsOverview/index.tsx create mode 100644 frontend/src/pages/editor/create/inject-specification/[injectId]/index.tsx create mode 100644 frontend/src/pages/editor/create/inject-specification/index.tsx diff --git a/frontend/src/editor/EditorPage/index.tsx b/frontend/src/editor/EditorPage/index.tsx index c56fe028d..63d77e9f4 100644 --- a/frontend/src/editor/EditorPage/index.tsx +++ b/frontend/src/editor/EditorPage/index.tsx @@ -70,6 +70,7 @@ const EditorPage: FC<EditorPageProps> = ({ } EditorPage.defaultProps = { + nextPath: '/', nextDisabled: false, nextVisible: true, } diff --git a/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx b/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx new file mode 100644 index 000000000..c9bc58818 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx @@ -0,0 +1,116 @@ +import { + Button, + InputGroup, + Label, + NumericInput, + TextArea, +} from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo, useCallback, useEffect, useState, type FC } from 'react' +import EmailAddressSelector from '../LearningActivitySpecification/EmailAddressSelector' +import { + addEmailInject, + getEmailInjectByInjectInfoId, + updateEmailInject, +} from '../indexeddb/operations' +import type { EmailInject } from '../indexeddb/types' + +interface EmailInjectFormProps { + injectInfoId: number +} + +const EmailInjectForm: FC<EmailInjectFormProps> = ({ injectInfoId }) => { + const emailInject = useLiveQuery( + () => getEmailInjectByInjectInfoId(injectInfoId), + [injectInfoId], + null + ) as EmailInject + + const { notify } = useNotifyContext() + + const [subject, setSubject] = useState<string>('') + const [content, setContent] = useState<string>('') + const [selectedAddressId, setSelectedAddressId] = useState<number>(0) + const [extraCopies, setExtraCopies] = useState<number>(0) + + useEffect(() => { + setSubject(emailInject?.subject || '') + setContent(emailInject?.content || '') + setSelectedAddressId(emailInject?.emailAddressId || 0) + setExtraCopies(emailInject?.extraCopies || 0) + }, [emailInject]) + + const handleUpdateButton = useCallback( + async (newEmailInject: EmailInject | Omit<EmailInject, 'id'>) => { + try { + if (emailInject) { + await updateEmailInject({ id: emailInject.id, ...newEmailInject }) + } else { + await addEmailInject(newEmailInject) + } + } catch (err) { + notify(`Failed to update email inject: ${err}`, { + intent: 'danger', + }) + } + }, + [notify, emailInject] + ) + + return ( + <div> + <EmailAddressSelector + emailAddressId={selectedAddressId} + onChange={id => setSelectedAddressId(id)} + /> + <Label> + Subject + <InputGroup + placeholder='Input text' + value={subject} + onChange={e => setSubject(e.target.value)} + /> + </Label> + <Label> + Content + <TextArea + value={content} + style={{ + width: '100%', + height: '10rem', + resize: 'none', + overflowY: 'auto', + }} + placeholder='Input text' + onChange={e => setContent(e.target.value)} + /> + </Label> + <Label> + Extra copies + <NumericInput + placeholder='Input number' + min={0} + value={extraCopies} + onValueChange={(value: number) => setExtraCopies(value)} + /> + </Label> + <Button + onClick={() => + handleUpdateButton({ + injectInfoId, + subject, + content, + emailAddressId: selectedAddressId, + extraCopies, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + </div> + ) +} + +export default memo(EmailInjectForm) diff --git a/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx b/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx new file mode 100644 index 000000000..c9d37f26d --- /dev/null +++ b/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx @@ -0,0 +1,86 @@ +import { Button, Label, TextArea } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo, useCallback, useEffect, useState, type FC } from 'react' +import { + addInformationInject, + getInformationInjectByInjectInfoId, + updateInformationInject, +} from '../indexeddb/operations' +import type { InformationInject } from '../indexeddb/types' + +interface InformationInjectFormProps { + injectInfoId: number +} + +const InformationInjectForm: FC<InformationInjectFormProps> = ({ + injectInfoId, +}) => { + const informationInject = useLiveQuery( + () => getInformationInjectByInjectInfoId(injectInfoId), + [injectInfoId], + null + ) as InformationInject + + const { notify } = useNotifyContext() + + const [content, setContent] = useState<string>('') + + useEffect(() => { + setContent(informationInject?.content || '') + }, [informationInject]) + + const handleUpdateButton = useCallback( + async ( + newInformationInject: InformationInject | Omit<InformationInject, 'id'> + ) => { + try { + if (informationInject) { + await updateInformationInject({ + id: informationInject.id, + ...newInformationInject, + }) + } else { + await addInformationInject(newInformationInject) + } + } catch (err) { + notify(`Failed to update information inject: ${err}`, { + intent: 'danger', + }) + } + }, + [notify, informationInject] + ) + + return ( + <div> + <Label> + Content + <TextArea + value={content} + style={{ + width: '100%', + height: '10rem', + resize: 'none', + overflowY: 'auto', + }} + placeholder='Input text' + onChange={e => setContent(e.target.value)} + /> + </Label> + <Button + onClick={() => + handleUpdateButton({ + injectInfoId, + content, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + </div> + ) +} + +export default memo(InformationInjectForm) diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/CustomLabelForm.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/CustomLabelForm.tsx new file mode 100644 index 000000000..55b78cb3d --- /dev/null +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/CustomLabelForm.tsx @@ -0,0 +1,72 @@ +import { + Button, + Colors, + Icon, + InputGroup, + Intent, + Label, +} from '@blueprintjs/core' +import type { FC } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' + +interface CustomLabelFormProps { + onAdd: (value: string) => void +} + +const CustomLabelForm: FC<CustomLabelFormProps> = ({ onAdd }) => { + const [newAnswer, setNewAnswer] = useState<string>('') + const [isValid, setIsValid] = useState<boolean>(true) + + useEffect(() => { + setIsValid(!newAnswer.includes(',')) + }, [newAnswer]) + + const onAddClick = useCallback( + (answer: string) => { + onAdd(answer) + setNewAnswer('') + }, + [onAdd] + ) + + return ( + <div> + <div style={{ display: 'flex', alignItems: 'end' }}> + <Label style={{ flexGrow: '1', marginBottom: '0' }}> + New answer + <InputGroup + placeholder='Input text' + value={newAnswer} + onChange={e => setNewAnswer(e.target.value)} + intent={isValid ? Intent.NONE : Intent.DANGER} + /> + </Label> + <Button + icon='plus' + minimal + onClick={() => onAddClick(newAnswer)} + disabled={!isValid} + /> + </div> + {!isValid && ( + <div + style={{ + display: 'flex', + alignItems: 'center', + padding: '0.5rem 0', + color: Colors.RED3, + }} + > + <Icon + icon='error' + intent='danger' + style={{ marginRight: '0.5rem' }} + /> + <span>The answer cannot contain ','.</span> + </div> + )} + </div> + ) +} + +export default memo(CustomLabelForm) diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionCustomLabels.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionCustomLabels.tsx new file mode 100644 index 000000000..f3a9c94f3 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionCustomLabels.tsx @@ -0,0 +1,105 @@ +import type { OptionProps } from '@blueprintjs/core' +import { + Button, + ControlGroup, + Icon, + Radio, + RadioGroup, +} from '@blueprintjs/core' +import type { FC } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' +import CustomLabelForm from './CustomLabelForm' + +interface QuestionCustomLabelsProps { + labels: string + correct: number + onLabelsChange: (value: string) => void + onCorrectChange: (value: number) => void +} + +const QuestionCustomLabels: FC<QuestionCustomLabelsProps> = ({ + labels, + correct, + onLabelsChange, + onCorrectChange, +}) => { + const [options, setOptions] = useState<OptionProps[]>([]) + + useEffect(() => { + if (!labels) { + setOptions([]) + } else { + setOptions( + labels.split(', ').map((label, index) => ({ + label: label, + value: index + 1, + })) + ) + } + }, [labels]) + + const onAdd = useCallback( + (answer: string) => { + if (!labels) { + onLabelsChange(answer) + } else { + onLabelsChange(`${labels}, ${answer}`) + } + }, + [labels] + ) + + const onDelete = useCallback( + (option: OptionProps) => { + const value = Number(option.value) + if (correct === value) { + onCorrectChange(1) + } + if (correct > value) { + onCorrectChange(correct - 1) + } + onLabelsChange( + [...options.slice(0, value - 1), ...options.slice(value)] + .map((option: OptionProps) => option?.label || '') + .join(', ') + ) + }, + [correct, options] + ) + + return ( + <> + <RadioGroup label={'Answers'} onChange={() => {}}> + {options.length > 0 ? ( + options.map(option => ( + <ControlGroup key={option.value} style={{ display: 'flex' }}> + <Radio + label={option.label} + value={option.value} + checked={correct === option.value} + onClick={e => onCorrectChange(Number(e.currentTarget.value))} + onChange={() => {}} + style={{ flexGrow: '1' }} + /> + <Button icon='trash' minimal onClick={() => onDelete(option)} /> + </ControlGroup> + )) + ) : ( + <div + style={{ + display: 'flex', + alignItems: 'center', + padding: '0 0 1rem', + }} + > + <Icon icon='disable' style={{ marginRight: '0.5rem' }} /> + <span>No answers specified.</span> + </div> + )} + </RadioGroup> + <CustomLabelForm onAdd={(answer: string) => onAdd(answer)} /> + </> + ) +} + +export default memo(QuestionCustomLabels) diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionRangeLabels.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionRangeLabels.tsx new file mode 100644 index 000000000..a713b2125 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionRangeLabels.tsx @@ -0,0 +1,68 @@ +import type { OptionProps } from '@blueprintjs/core' +import { + ControlGroup, + Label, + NumericInput, + Radio, + RadioGroup, +} from '@blueprintjs/core' +import { range } from 'lodash' +import type { FC } from 'react' +import { memo, useEffect, useState } from 'react' + +interface QuestionRangeLabelsProps { + max: number + correct: number + onMaxChange: (value: number) => void + onCorrectChange: (value: number) => void +} + +const QuestionRangeLabels: FC<QuestionRangeLabelsProps> = ({ + max, + correct, + onMaxChange, + onCorrectChange, +}) => { + const [options, setOptions] = useState<OptionProps[]>([]) + + useEffect(() => { + setOptions( + range(1, max + 1).map(value => ({ + label: value.toString(), + value: value, + })) + ) + if (max < correct) { + onCorrectChange(1) + } + }, [max, correct]) + + return ( + <div> + <Label> + Number of answers + <NumericInput + placeholder='Input number' + min={1} + value={max} + onValueChange={(value: number) => onMaxChange(value)} + /> + </Label> + <RadioGroup label={'Answers'} onChange={() => {}}> + {options.map(option => ( + <ControlGroup key={option.value}> + <Radio + label={option.label} + value={option.value} + checked={correct === option.value} + onClick={e => onCorrectChange(Number(e.currentTarget.value))} + onChange={() => {}} + /> + </ControlGroup> + ))} + </RadioGroup> + </div> + ) +} + +export default memo(QuestionRangeLabels) diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestion.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestion.tsx new file mode 100644 index 000000000..fe281c662 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestion.tsx @@ -0,0 +1,88 @@ +import { Button, ButtonGroup, Card } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import { range } from 'lodash' +import type { FC } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' +import { deleteQuestionnaireQuestion } from '../../indexeddb/operations' +import { type QuestionnaireQuestion } from '../../indexeddb/types' +import QuestionnaireQuestionForm from './QuestionnaireQuestionForm' + +interface QuestionnaireQuestionProps { + questionnaireQuestion: QuestionnaireQuestion +} + +const QuestionnaireQuestionItem: FC<QuestionnaireQuestionProps> = ({ + questionnaireQuestion, +}) => { + const { notify } = useNotifyContext() + const [answers, setAnswers] = useState<string[]>([]) + + const handleDeleteButton = useCallback( + async (question: QuestionnaireQuestion) => { + try { + await deleteQuestionnaireQuestion(question.id) + } catch (err) { + notify(`Failed to delete question '${question.text}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + useEffect(() => { + setAnswers( + questionnaireQuestion.labels + ? questionnaireQuestion.labels.split(', ') + : range(1, questionnaireQuestion.max + 1).map(value => value.toString()) + ) + }, [questionnaireQuestion]) + + return ( + <Card + style={{ display: 'flex', flexDirection: 'column', alignItems: 'start' }} + > + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + }} + > + <span style={{ flexGrow: 1 }}>{questionnaireQuestion.text}</span> + <ButtonGroup> + <QuestionnaireQuestionForm + questionnaireQuestion={questionnaireQuestion} + questionnaireId={questionnaireQuestion.questionnaireId} + buttonProps={{ + minimal: true, + icon: 'edit', + style: { marginRight: '1rem' }, + }} + /> + <Button + minimal + icon='cross' + onClick={() => handleDeleteButton(questionnaireQuestion)} + /> + </ButtonGroup> + </div> + <ol> + {answers.map((answer, i) => ( + <li + key={i} + style={{ + fontWeight: + questionnaireQuestion.correct === i + 1 ? 'bold' : 'normal', + }} + > + {answer} + </li> + ))} + </ol> + </Card> + ) +} + +export default memo(QuestionnaireQuestionItem) diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestionForm.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestionForm.tsx new file mode 100644 index 000000000..bb4c63802 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestionForm.tsx @@ -0,0 +1,202 @@ +import { + addQuestionnaireQuestion, + updateQuestionnaireQuestion, +} from '@/editor/indexeddb/operations' +import type { ButtonProps } from '@blueprintjs/core' +import { + Button, + Dialog, + DialogBody, + DialogFooter, + InputGroup, + Label, + Switch, +} from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import type { FC } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' +import type { QuestionnaireQuestion } from '../../indexeddb/types' +import QuestionCustomLabels from './QuestionCustomLabels' +import QuestionRangeLabels from './QuestionRangeLabels' + +interface QuestionnaireQuestionFormProps { + questionnaireQuestion?: QuestionnaireQuestion + questionnaireId: number + buttonProps: ButtonProps +} + +const QuestionnaireQuestionForm: FC<QuestionnaireQuestionFormProps> = ({ + questionnaireQuestion, + questionnaireId, + buttonProps, +}) => { + const [isOpen, setIsOpen] = useState(false) + const [isValid, setIsValid] = useState(false) + const [text, setText] = useState<string>('') + const [max, setMax] = useState<number>(1) + const [correct, setCorrect] = useState<number>(0) + const [customLabels, setCustomLabels] = useState<boolean>(false) + const [labels, setLabels] = useState<string>('') + + const { notify } = useNotifyContext() + + useEffect(() => { + setText(questionnaireQuestion?.text || '') + setMax(questionnaireQuestion?.max || 1) + setCorrect(questionnaireQuestion?.correct || 0) + setCustomLabels( + (questionnaireQuestion?.labels !== '' && + questionnaireQuestion?.labels !== undefined) || + false + ) + setLabels(questionnaireQuestion?.labels || '') + }, [questionnaireQuestion, isOpen]) + + useEffect(() => { + setIsValid( + text !== '' && + ((customLabels && labels !== '') || (!customLabels && max > 0)) + ) + }, [text, customLabels, labels, max]) + + const clearInput = useCallback(() => { + setText('') + setMax(1) + setCorrect(0) + setCustomLabels(false) + setLabels('') + }, []) + + const handleAddButton = useCallback( + async (questionnaireQuestion: Omit<QuestionnaireQuestion, 'id'>) => { + try { + await addQuestionnaireQuestion(questionnaireQuestion) + clearInput() + setIsOpen(false) + } catch (err) { + notify( + `Failed to add questionnaire question '${questionnaireQuestion.text}': ${err}`, + { + intent: 'danger', + } + ) + } + }, + [notify] + ) + + const handleUpdateButton = useCallback( + async (questionnaireQuestion: QuestionnaireQuestion) => { + try { + await updateQuestionnaireQuestion(questionnaireQuestion) + setIsOpen(false) + } catch (err) { + notify( + `Failed to update questionnaire question '${questionnaireQuestion.text}': ${err}`, + { + intent: 'danger', + } + ) + } + }, + [notify] + ) + + const onCorrectChange = useCallback( + (newCorrect: number) => { + if (correct === newCorrect) { + setCorrect(0) + } else { + setCorrect(newCorrect) + } + }, + [correct] + ) + + return ( + <> + <Button {...buttonProps} onClick={() => setIsOpen(true)} /> + <Dialog + isOpen={isOpen} + onClose={() => setIsOpen(false)} + icon={questionnaireQuestion ? 'edit' : 'plus'} + title={ + questionnaireQuestion + ? 'Edit questionnaire question' + : 'New questionnaire question' + } + > + <DialogBody> + <Label> + Text + <InputGroup + placeholder='Input text' + value={text} + onChange={e => setText(e.target.value)} + /> + </Label> + <Switch + label='Custom labels' + checked={customLabels} + onChange={() => setCustomLabels(prev => !prev)} + /> + {customLabels ? ( + <QuestionCustomLabels + labels={labels} + correct={correct} + onLabelsChange={(value: string) => setLabels(value)} + onCorrectChange={(value: number) => onCorrectChange(value)} + /> + ) : ( + <QuestionRangeLabels + max={max} + correct={correct} + onMaxChange={(value: number) => setMax(value)} + onCorrectChange={(value: number) => onCorrectChange(value)} + /> + )} + </DialogBody> + <DialogFooter + actions={ + questionnaireQuestion ? ( + <Button + disabled={!isValid} + onClick={() => + handleUpdateButton({ + id: questionnaireQuestion.id, + questionnaireId, + text, + max: customLabels ? labels.split(', ').length : max, + correct, + labels: customLabels ? labels : '', + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + ) : ( + <Button + disabled={!isValid} + onClick={() => + handleAddButton({ + questionnaireId, + text, + max: customLabels ? labels.split(', ').length : max, + correct, + labels: customLabels ? labels : '', + }) + } + intent='primary' + icon='plus' + text='Add' + /> + ) + } + /> + </Dialog> + </> + ) +} + +export default memo(QuestionnaireQuestionForm) diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestions.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestions.tsx new file mode 100644 index 000000000..25a492f71 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestions.tsx @@ -0,0 +1,37 @@ +import { db } from '@/editor/indexeddb/db' +import type { QuestionnaireQuestion } from '@/editor/indexeddb/types' +import { CardList } from '@blueprintjs/core' +import { useLiveQuery } from 'dexie-react-hooks' +import type { FC } from 'react' +import { memo } from 'react' +import QuestionnaireQuestionItem from './QuestionnaireQuestion' + +interface QuestionnaireQuestionsProps { + questionnaireId: number +} + +const QuestionnaireQuestions: FC<QuestionnaireQuestionsProps> = ({ + questionnaireId, +}) => { + const questionnaireQuestions = useLiveQuery( + () => db.questionnaireQuestions.where({ questionnaireId }).toArray(), + [questionnaireId], + [] + ) + + return ( + <> + <p>Questions</p> + <CardList> + {questionnaireQuestions?.map((question: QuestionnaireQuestion) => ( + <QuestionnaireQuestionItem + key={question.id} + questionnaireQuestion={question} + /> + ))} + </CardList> + </> + ) +} + +export default memo(QuestionnaireQuestions) diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/index.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/index.tsx new file mode 100644 index 000000000..2bd9d5238 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/index.tsx @@ -0,0 +1,97 @@ +import { Button, InputGroup, Label } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo, useCallback, useEffect, useState, type FC } from 'react' +import { + addQuestionnaire, + getQuestionnaireByInjectInfoId, + updateQuestionnaire, +} from '../../indexeddb/operations' +import type { Questionnaire } from '../../indexeddb/types' +import QuestionnaireQuestionForm from './QuestionnaireQuestionForm' +import QuestionnaireQuestions from './QuestionnaireQuestions' + +interface QuestionnaireFormProps { + injectInfoId: number +} + +const QuestionnaireForm: FC<QuestionnaireFormProps> = ({ injectInfoId }) => { + const questionnaire = useLiveQuery( + () => getQuestionnaireByInjectInfoId(injectInfoId), + [injectInfoId], + null + ) as Questionnaire + + const { notify } = useNotifyContext() + + const [title, setTitle] = useState<string>('') + + useEffect(() => { + setTitle(questionnaire?.title || '') + + if (questionnaire === undefined) { + addQuestionnaire({ injectInfoId, title: '' }) + } + }, [questionnaire, injectInfoId]) + + const handleUpdateButton = useCallback( + async (newQuestionnaire: Questionnaire | Omit<Questionnaire, 'id'>) => { + try { + if (questionnaire) { + await updateQuestionnaire({ + id: questionnaire.id, + ...newQuestionnaire, + }) + } else { + await addQuestionnaire(newQuestionnaire) + } + } catch (err) { + notify(`Failed to update questionnaire: ${err}`, { + intent: 'danger', + }) + } + }, + [notify, questionnaire] + ) + + return ( + <div> + <Label> + Title + <InputGroup + placeholder='Input text' + value={title} + onChange={e => setTitle(e.target.value)} + /> + </Label> + {questionnaire && ( + <> + <QuestionnaireQuestions questionnaireId={questionnaire.id} /> + <QuestionnaireQuestionForm + questionnaireId={questionnaire.id} + buttonProps={{ + minimal: true, + text: 'Add new question', + alignText: 'left', + icon: 'plus', + style: { padding: '1rem', width: '100%', marginBottom: '1rem' }, + }} + /> + </> + )} + <Button + onClick={() => + handleUpdateButton({ + injectInfoId, + title, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + </div> + ) +} + +export default memo(QuestionnaireForm) diff --git a/frontend/src/editor/InjectSpecification/index.tsx b/frontend/src/editor/InjectSpecification/index.tsx new file mode 100644 index 000000000..883b3ec52 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/index.tsx @@ -0,0 +1,64 @@ +import { Divider, NonIdealState } from '@blueprintjs/core' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo, type FC } from 'react' +import InjectForm from '../InjectForm' +import { getInjectInfoById } from '../indexeddb/operations' +import type { InjectInfo } from '../indexeddb/types' +import { InjectType } from '../indexeddb/types' +import EmailInjectForm from './EmailInjectForm' +import InformationInjectForm from './InformationInjectForm' +import QuestionnaireForm from './QuestionnaireForm' + +interface InjectSpecificationProps { + injectInfoId: number +} + +const InjectSpecification: FC<InjectSpecificationProps> = ({ + injectInfoId, +}) => { + const injectInfo = useLiveQuery( + () => getInjectInfoById(injectInfoId), + [injectInfoId], + null + ) as InjectInfo + + if (!injectInfo) { + return ( + <NonIdealState + icon='low-voltage-pole' + title='No inject' + description='Inject not found' + /> + ) + } + + return ( + <div> + <div> + <p>Name: {injectInfo.name}</p> + <p>Description: {injectInfo.description}</p> + <p>Type: {injectInfo.type}</p> + <InjectForm + inject={injectInfo} + buttonProps={{ + text: 'Edit inject', + icon: 'edit', + style: { marginRight: '1rem' }, + }} + /> + </div> + <Divider style={{ margin: '1rem 0' }} /> + {injectInfo.type === InjectType.EMAIL && ( + <EmailInjectForm injectInfoId={injectInfoId} /> + )} + {injectInfo.type === InjectType.INFORMATION && ( + <InformationInjectForm injectInfoId={injectInfoId} /> + )} + {injectInfo.type === InjectType.QUESTIONNAIRE && ( + <QuestionnaireForm injectInfoId={injectInfoId} /> + )} + </div> + ) +} + +export default memo(InjectSpecification) diff --git a/frontend/src/editor/InjectsOverview/index.tsx b/frontend/src/editor/InjectsOverview/index.tsx new file mode 100644 index 000000000..5c24961f8 --- /dev/null +++ b/frontend/src/editor/InjectsOverview/index.tsx @@ -0,0 +1,36 @@ +import { db } from '@/editor/indexeddb/db' +import type { InjectInfo } from '@/editor/indexeddb/types' +import { useNavigate } from '@/router' +import { Card, CardList, Icon } from '@blueprintjs/core' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo } from 'react' +import { getInjectIcon } from '../utils' + +const InjectsOverview = () => { + const injectInfos = useLiveQuery(() => db.injectInfos.toArray(), [], []) + const nav = useNavigate() + + return ( + <CardList> + {injectInfos?.map((injectInfo: InjectInfo) => ( + <Card + interactive + key={injectInfo.id} + onClick={() => + nav(`/editor/create/inject-specification/:injectId`, { + params: { injectId: injectInfo.id.toString() }, + }) + } + > + <Icon + icon={getInjectIcon(injectInfo)} + style={{ marginRight: '1rem' }} + /> + {injectInfo.name} + </Card> + ))} + </CardList> + ) +} + +export default memo(InjectsOverview) diff --git a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx index 9c1e82bb7..cbd4c24d6 100644 --- a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx @@ -20,7 +20,7 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ const template = useLiveQuery( () => getEmailTemplateByActivityId(learningActivityId), [learningActivityId], - [] + null ) as EmailTemplate const { notify } = useNotifyContext() @@ -53,7 +53,7 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ ) return ( - <> + <div> <EmailAddressSelector emailAddressId={selectedAddressId} onChange={id => setSelectedAddressId(id)} @@ -93,7 +93,7 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ icon='edit' text='Save changes' /> - </> + </div> ) } diff --git a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx index ac9bae28c..324c7d109 100644 --- a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx @@ -26,7 +26,7 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ const response = useLiveQuery( () => getToolResponseByActivityId(learningActivityId), [learningActivityId], - [] + null ) as ToolResponse const { notify } = useNotifyContext() @@ -61,7 +61,7 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ ) return ( - <> + <div> <ToolSelector toolId={selectedToolId} onChange={id => setSelectedToolId(id)} @@ -107,7 +107,7 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ icon='edit' text='Save changes' /> - </> + </div> ) } diff --git a/frontend/src/editor/LearningActivitySpecification/index.tsx b/frontend/src/editor/LearningActivitySpecification/index.tsx index 8a2ee6040..e99315e95 100644 --- a/frontend/src/editor/LearningActivitySpecification/index.tsx +++ b/frontend/src/editor/LearningActivitySpecification/index.tsx @@ -16,12 +16,12 @@ const LearningActivitySpecification: FC<LearningActivitySpecificationProps> = ({ learningActivityId, }) => { const activity = useLiveQuery( - () => getLearningActivityById(Number(learningActivityId)), + () => getLearningActivityById(learningActivityId), [learningActivityId], - [] + null ) as LearningActivityInfo - if (activity === undefined) { + if (!activity) { return ( <NonIdealState icon='low-voltage-pole' diff --git a/frontend/src/editor/Navbar/index.tsx b/frontend/src/editor/Navbar/index.tsx index cc3bd3c14..2bc48d11d 100644 --- a/frontend/src/editor/Navbar/index.tsx +++ b/frontend/src/editor/Navbar/index.tsx @@ -14,7 +14,11 @@ const Navbar = () => ( <NavbarButton path='/editor/create/injects' name='Injects' /> <NavbarButton path='/editor/create/activity-specification' - name='Activities' + name='Activities specification' + /> + <NavbarButton + path='/editor/create/inject-specification' + name='Injects specification' /> <NavbarButton path='/editor/create/final-information' diff --git a/frontend/src/editor/indexeddb/db.tsx b/frontend/src/editor/indexeddb/db.tsx index a6ec2242e..9d5198082 100644 --- a/frontend/src/editor/indexeddb/db.tsx +++ b/frontend/src/editor/indexeddb/db.tsx @@ -1,10 +1,14 @@ import Dexie, { type EntityTable } from 'dexie' import type { EmailAddressInfo, + EmailInject, EmailTemplate, + InformationInject, InjectInfo, LearningActivityInfo, LearningObjectiveInfo, + Questionnaire, + QuestionnaireQuestion, ToolInfo, ToolResponse, } from './types' @@ -20,6 +24,10 @@ const db = new Dexie(dbName) as Dexie & { toolResponses: EntityTable<ToolResponse, 'id'> emailAddresses: EntityTable<EmailAddressInfo, 'id'> emailTemplates: EntityTable<EmailTemplate, 'id'> + emailInjects: EntityTable<EmailInject, 'id'> + informationInjects: EntityTable<InformationInject, 'id'> + questionnaires: EntityTable<Questionnaire, 'id'> + questionnaireQuestions: EntityTable<QuestionnaireQuestion, 'id'> } db.version(dbVersion).stores({ @@ -31,6 +39,11 @@ db.version(dbVersion).stores({ '++id, &learningActivityId, toolId, parameter, isRegex, content', // TODO file emailAddresses: '++id, address, organization, description, teamVisible', emailTemplates: '++id, &learningActivityId, emailAddressId, context, content', // TODO file + emailInjects: + '++id, &injectInfoId, emailAddressId, subject, content, extraCopies', // TODO file + informationInjects: '++id, &injectInfoId, content', // TODO file + questionnaires: '++id, &injectInfoId, title', + questionnaireQuestions: '++id, questionnaireId, text, max, correct, labels', }) export { db } diff --git a/frontend/src/editor/indexeddb/operations.tsx b/frontend/src/editor/indexeddb/operations.tsx index cbfb605bb..c51e8e58c 100644 --- a/frontend/src/editor/indexeddb/operations.tsx +++ b/frontend/src/editor/indexeddb/operations.tsx @@ -1,10 +1,14 @@ import { db } from './db' import type { EmailAddressInfo, + EmailInject, EmailTemplate, + InformationInject, InjectInfo, LearningActivityInfo, LearningObjectiveInfo, + Questionnaire, + QuestionnaireQuestion, ToolInfo, ToolResponse, } from './types' @@ -50,6 +54,9 @@ export const deleteLearningActivity = async (id: number) => await db.learningActivities.delete(id) // inject info operations +export const getInjectInfoById = async (id: number) => + await db.injectInfos.get(id) + export const addInjectInfo = async (injectInfo: Omit<InjectInfo, 'id'>) => await db.transaction('rw', db.injectInfos, async () => { await db.injectInfos.add(injectInfo) @@ -122,3 +129,81 @@ export const updateEmailTemplate = async (template: EmailTemplate) => export const deleteEmailTemplate = async (id: number) => await db.emailTemplates.delete(id) + +// email inject operations +export const getEmailInjectByInjectInfoId = async (injectInfoId: number) => + await db.emailInjects.get({ injectInfoId }) + +export const addEmailInject = async (emailInject: Omit<EmailInject, 'id'>) => + await db.transaction('rw', db.emailInjects, async () => { + await db.emailInjects.add(emailInject) + }) + +export const updateEmailInject = async (emailInject: EmailInject) => + await db.emailInjects.put(emailInject) + +export const deleteEmailInject = async (id: number) => + await db.emailInjects.delete(id) + +// information inject operations +export const getInformationInjectByInjectInfoId = async ( + injectInfoId: number +) => await db.informationInjects.get({ injectInfoId }) + +export const addInformationInject = async ( + informationInject: Omit<InformationInject, 'id'> +) => + await db.transaction('rw', db.informationInjects, async () => { + await db.informationInjects.add(informationInject) + }) + +export const updateInformationInject = async ( + informationInject: InformationInject +) => await db.informationInjects.put(informationInject) + +export const deleteInformationInject = async (id: number) => + await db.informationInjects.delete(id) + +// questionnaire operations +export const getQuestionnaireByInjectInfoId = async (injectInfoId: number) => + await db.questionnaires.get({ injectInfoId }) + +export const addQuestionnaire = async ( + questionnaire: Omit<Questionnaire, 'id'> +) => + await db.transaction('rw', db.questionnaires, async () => { + await db.questionnaires.add(questionnaire) + }) + +export const updateQuestionnaire = async (questionnaire: Questionnaire) => + await db.questionnaires.put(questionnaire) + +export const deleteQuestionnaire = async (id: number) => + await db.transaction( + 'rw', + db.questionnaires, + db.questionnaireQuestions, + async () => { + await db.questionnaires.delete(id) + await db.questionnaireQuestions.where({ questionnaireId: id }).delete() + } + ) + +// questionnaire question operations +export const getQuestionnaireQuestionsByQuestionnaireId = async ( + questionnaireId: number +) => await db.questionnaires.get({ questionnaireId }) + +export const addQuestionnaireQuestion = async ( + questionnaireQuestion: Omit<QuestionnaireQuestion, 'id'> +) => + await db.transaction('rw', db.questionnaireQuestions, async () => { + await db.questionnaireQuestions.add(questionnaireQuestion) + }) + +export const updateQuestionnaireQuestion = async ( + questionnaireQuestion: QuestionnaireQuestion +) => await db.questionnaireQuestions.put(questionnaireQuestion) + +export const deleteQuestionnaireQuestion = async (id: number) => + await db.questionnaireQuestions.delete(id) diff --git a/frontend/src/editor/indexeddb/types.tsx b/frontend/src/editor/indexeddb/types.tsx index c8fa15a1f..d80b9e54c 100644 --- a/frontend/src/editor/indexeddb/types.tsx +++ b/frontend/src/editor/indexeddb/types.tsx @@ -10,6 +10,7 @@ export enum LearningActivityType { export enum InjectType { INFORMATION = 'Information', EMAIL = 'Email', + QUESTIONNAIRE = 'Questionnaire', } export type LearningObjectiveInfo = Pick<LearningObjective, 'id' | 'name'> @@ -56,3 +57,33 @@ export type EmailTemplate = { context: string content: string } + +export type EmailInject = { + id: number + injectInfoId: number + emailAddressId: number + subject: string + content: string + extraCopies: number +} + +export type InformationInject = { + id: number + injectInfoId: number + content: string +} + +export type Questionnaire = { + id: number + injectInfoId: number + title: string +} + +export type QuestionnaireQuestion = { + id: number + questionnaireId: number + text: string + max: number + correct: number + labels: string +} diff --git a/frontend/src/editor/utils.tsx b/frontend/src/editor/utils.tsx index 9f8efc0a1..ff85a8790 100644 --- a/frontend/src/editor/utils.tsx +++ b/frontend/src/editor/utils.tsx @@ -28,5 +28,13 @@ export const getLearningActivityIcon = (activity: LearningActivityInfo) => { } } -export const getInjectIcon = (inject: InjectInfo) => - inject.type === InjectType.EMAIL ? 'envelope' : 'clipboard' +export const getInjectIcon = (inject: InjectInfo) => { + switch (inject.type) { + case InjectType.EMAIL: + return 'envelope' + case InjectType.QUESTIONNAIRE: + return 'th-list' + default: + return 'clipboard' + } +} diff --git a/frontend/src/pages/editor/create/activity-specification/[activityId]/index.tsx b/frontend/src/pages/editor/create/activity-specification/[activityId]/index.tsx index c8bc7b0a0..d9ad2a921 100644 --- a/frontend/src/pages/editor/create/activity-specification/[activityId]/index.tsx +++ b/frontend/src/pages/editor/create/activity-specification/[activityId]/index.tsx @@ -3,7 +3,7 @@ import LearningActivitySpecification from '@/editor/LearningActivitySpecificatio import { useParams } from '@/router' import { memo } from 'react' -const ActivityDefinitionPage = () => { +const ActivitySpecificationPage = () => { const { activityId } = useParams( '/editor/create/activity-specification/:activityId' ) @@ -19,4 +19,4 @@ const ActivityDefinitionPage = () => { ) } -export default memo(ActivityDefinitionPage) +export default memo(ActivitySpecificationPage) diff --git a/frontend/src/pages/editor/create/activity-specification/index.tsx b/frontend/src/pages/editor/create/activity-specification/index.tsx index dd3e8d6b2..df3c09ac7 100644 --- a/frontend/src/pages/editor/create/activity-specification/index.tsx +++ b/frontend/src/pages/editor/create/activity-specification/index.tsx @@ -7,8 +7,7 @@ const ActivitiesSpecificationPage = () => ( title='Define activities' description='Description.' prevPath='/editor/create/injects' - nextPath='/editor' - nextDisabled + nextPath='/editor/create/inject-specification' > <LearningActivitiesOverview /> </EditorPage> diff --git a/frontend/src/pages/editor/create/inject-specification/[injectId]/index.tsx b/frontend/src/pages/editor/create/inject-specification/[injectId]/index.tsx new file mode 100644 index 000000000..7dbd0e394 --- /dev/null +++ b/frontend/src/pages/editor/create/inject-specification/[injectId]/index.tsx @@ -0,0 +1,22 @@ +import EditorPage from '@/editor/EditorPage' +import InjectSpecification from '@/editor/InjectSpecification' +import { useParams } from '@/router' +import { memo } from 'react' + +const InjectSpecificationPage = () => { + const { injectId } = useParams( + '/editor/create/inject-specification/:injectId' + ) + return ( + <EditorPage + title='Define inject' + description='Description.' + prevPath='/editor/create/inject-specification' + nextVisible={false} + > + <InjectSpecification injectInfoId={Number(injectId)} /> + </EditorPage> + ) +} + +export default memo(InjectSpecificationPage) diff --git a/frontend/src/pages/editor/create/inject-specification/index.tsx b/frontend/src/pages/editor/create/inject-specification/index.tsx new file mode 100644 index 000000000..8dc412ee9 --- /dev/null +++ b/frontend/src/pages/editor/create/inject-specification/index.tsx @@ -0,0 +1,17 @@ +import EditorPage from '@/editor/EditorPage' +import InjectsOverview from '@/editor/InjectsOverview' +import { memo } from 'react' + +const ActivitiesSpecificationPage = () => ( + <EditorPage + title='Define injects' + description='Description.' + prevPath='/editor/create/activity-specification' + nextPath='/editor' + nextDisabled + > + <InjectsOverview /> + </EditorPage> +) + +export default memo(ActivitiesSpecificationPage) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 38049c90a..bc19fedf1 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -18,6 +18,8 @@ export type Path = | `/editor/create/conclusion` | `/editor/create/exercise-information` | `/editor/create/final-information` + | `/editor/create/inject-specification` + | `/editor/create/inject-specification/:injectId` | `/editor/create/injects` | `/editor/create/introduction` | `/editor/create/learning-objectives` @@ -62,6 +64,7 @@ export type Params = { '/analyst/:exerciseId/milestones': { exerciseId: string } '/analyst/:exerciseId/tools': { exerciseId: string } '/editor/create/activity-specification/:activityId': { activityId: string } + '/editor/create/inject-specification/:injectId': { injectId: string } '/exercise-panel/definition/:definitionId': { definitionId: string } '/exercise-panel/exercise/:exerciseId': { exerciseId: string } '/instructor/:exerciseId/:teamId': { exerciseId: string; teamId: string } -- GitLab