diff --git a/frontend/src/editor/EmailTemplateFormContent/index.tsx b/frontend/src/editor/EmailTemplateFormContent/index.tsx index 00b9f9e4d877add6e6431efb6b523b989a28c283..aed6d96a839d6b1cf4cd55e8d1dbce06754ce32f 100644 --- a/frontend/src/editor/EmailTemplateFormContent/index.tsx +++ b/frontend/src/editor/EmailTemplateFormContent/index.tsx @@ -1,6 +1,7 @@ import { InputGroup, Label, TextArea } from '@blueprintjs/core' import { memo, type FC } from 'react' import EmailAddressSelector from '../EmailAddressSelector' +import FileSelector from '../FileSelector' interface EmailTemplateFormProps { context: string @@ -9,6 +10,8 @@ interface EmailTemplateFormProps { onContentChange: (value: string) => void emailAddressId: number onEmailAddressIdChange: (value: number) => void + fileId: number + onFileIdChange: (value: number) => void } const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ @@ -18,6 +21,8 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ onContentChange, emailAddressId, onEmailAddressIdChange, + fileId, + onFileIdChange, }) => ( <div> <EmailAddressSelector @@ -46,6 +51,7 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ onChange={e => onContentChange(e.target.value)} /> </Label> + <FileSelector fileId={fileId} onChange={id => onFileIdChange(id)} /> </div> ) diff --git a/frontend/src/editor/EmailTemplateFormDialog/index.tsx b/frontend/src/editor/EmailTemplateFormDialog/index.tsx index 604aa52541796344b2ccf3438324cecf04108940..6ee86c7274295357e8efe1b609990d73b63e7dd9 100644 --- a/frontend/src/editor/EmailTemplateFormDialog/index.tsx +++ b/frontend/src/editor/EmailTemplateFormDialog/index.tsx @@ -23,16 +23,19 @@ const EmailTemplateFormDialog: FC<EmailTemplateFormDialogProps> = ({ const [context, setContext] = useState<string>('') const [content, setContent] = useState<string>('') const [selectedAddressId, setSelectedAddressId] = useState<number>(0) + const [fileId, setFileId] = useState<number>(0) useEffect(() => { setContext(template?.context || '') setContent(template?.content || '') setSelectedAddressId(template?.emailAddressId || emailAddressId) + setFileId(template?.fileId || 0) }, [template, isOpen]) const clearInput = useCallback(() => { setContext('') setContent('') + setFileId(0) }, []) const handleAddButton = useCallback( @@ -78,11 +81,13 @@ const EmailTemplateFormDialog: FC<EmailTemplateFormDialogProps> = ({ context={context} content={content} emailAddressId={selectedAddressId} + fileId={fileId} onContextChange={(value: string) => setContext(value)} onContentChange={(value: string) => setContent(value)} onEmailAddressIdChange={(value: number) => setSelectedAddressId(value) } + onFileIdChange={(value: number) => setFileId(value)} /> </DialogBody> <DialogFooter @@ -96,6 +101,7 @@ const EmailTemplateFormDialog: FC<EmailTemplateFormDialogProps> = ({ context, content, emailAddressId: selectedAddressId, + fileId, }) } intent='primary' @@ -110,6 +116,7 @@ const EmailTemplateFormDialog: FC<EmailTemplateFormDialogProps> = ({ context, content, emailAddressId: selectedAddressId, + fileId, }) } intent='primary' diff --git a/frontend/src/editor/FileSelector/index.tsx b/frontend/src/editor/FileSelector/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..67e6195ea73459edacb1ab956cc19c7c8b8343f4 --- /dev/null +++ b/frontend/src/editor/FileSelector/index.tsx @@ -0,0 +1,66 @@ +import type { OptionProps } from '@blueprintjs/core' +import { HTMLSelect, Label } from '@blueprintjs/core' +import { useLiveQuery } from 'dexie-react-hooks' +import type { FC } from 'react' +import { useEffect, useMemo } from 'react' +import FileUploader from '../FileUploader' +import { db } from '../indexeddb/db' +import type { ContentFile } from '../indexeddb/types' + +interface FileSelectorProps { + fileId: number + onChange: (id: number) => void +} + +const FileSelector: FC<FileSelectorProps> = ({ fileId, onChange }) => { + const files = useLiveQuery(() => db.files.toArray(), [], []) + + const fileOptions: OptionProps[] = useMemo(() => { + if (files === undefined || files.length === 0) { + return [ + { + label: 'No files', + value: 0, + disabled: true, + }, + ] + } + + return [ + { label: 'No file', value: 0 }, + ...(files ?? []).map((file: ContentFile) => ({ + value: file.id, + label: file.name, + })), + ] + }, [files]) + + useEffect(() => { + if (!fileId) { + onChange(0) + } + }, [files, fileId]) + + return ( + <div style={{ display: 'flex', width: '100%' }}> + <Label style={{ flexGrow: '1' }}> + File + <HTMLSelect + options={fileOptions} + value={fileId} + onChange={event => onChange(Number(event.currentTarget.value))} + /> + </Label> + <FileUploader + buttonProps={{ + minimal: true, + icon: 'plus', + style: { marginRight: '1rem' }, + }} + onAdd={fileId => onChange(fileId)} + /> + </div> + ) +} + +export default FileSelector diff --git a/frontend/src/editor/FileUploader/index.tsx b/frontend/src/editor/FileUploader/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..86645bc5bac2048772c266c91468880394469705 --- /dev/null +++ b/frontend/src/editor/FileUploader/index.tsx @@ -0,0 +1,160 @@ +import type { ButtonProps } from '@blueprintjs/core' +import { + Button, + Classes, + Dialog, + DialogBody, + DialogFooter, + FileInput, + InputGroup, + Label, +} from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import type { ChangeEvent, FC } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { addFile, updateFile } from '../indexeddb/operations' +import type { ContentFile } from '../indexeddb/types' + +interface FileUploaderProps { + contentFile?: ContentFile + buttonProps: ButtonProps + onAdd?: (id: number) => void +} + +const FileUploader: FC<FileUploaderProps> = ({ + contentFile, + buttonProps, + onAdd, +}) => { + const [isOpen, setIsOpen] = useState<boolean>(false) + const [name, setName] = useState<string>('') + const [file, setFile] = useState<File | undefined>() + const { notify } = useNotifyContext() + + const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => { + if (e.target.files) { + setFile(e.target.files[0]) + } + } + + const placeholder = useMemo(() => file?.name || 'Input text', [file?.name]) + + const clearInput = useCallback(() => { + setName('') + setFile(undefined) + }, []) + + const handleAddButton = useCallback( + async (file: Omit<ContentFile, 'id'>) => { + try { + const id = await addFile(file) + if (onAdd) onAdd(Number(id)) + clearInput() + setIsOpen(false) + } catch (err) { + notify(`Failed to add file '${file.name}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + const handleUpdateButton = useCallback( + async (file: ContentFile) => { + try { + await updateFile(file) + setIsOpen(false) + } catch (err) { + notify(`Failed to update file '${file.name}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + useEffect(() => { + setName(contentFile?.name || '') + setFile( + contentFile?.blob + ? new File([contentFile?.blob], contentFile?.name) + : undefined + ) + }, [contentFile]) + + return ( + <> + <Button {...buttonProps} onClick={() => setIsOpen(true)} /> + <Dialog + isOpen={isOpen} + onClose={() => setIsOpen(false)} + icon={contentFile ? 'edit' : 'plus'} + title={contentFile ? 'Edit file' : 'New file'} + > + <DialogBody> + <Label> + Name + <InputGroup + placeholder={placeholder} + value={name} + onChange={e => setName(e.target.value)} + /> + </Label> + <Label> + File + <div + style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }} + > + <FileInput + className={Classes.INPUT} + fill + hasSelection={file !== undefined} + text={file ? file.name : 'Choose file...'} + onInputChange={handleFileChange} + /> + </div> + </Label> + </DialogBody> + <DialogFooter + actions={ + contentFile ? ( + <Button + disabled={!file} + onClick={() => + handleUpdateButton({ + id: contentFile.id, + name: file && name === '' ? file?.name : name, + blob: file + ? new Blob([file], { type: file.type }) + : new Blob(), + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + ) : ( + <Button + disabled={!file} + onClick={() => + handleAddButton({ + name: file && name === '' ? file?.name : name, + blob: file + ? new Blob([file], { type: file.type }) + : new Blob(), + }) + } + intent='primary' + icon='plus' + text='Add' + /> + ) + } + /> + </Dialog> + </> + ) +} + +export default FileUploader diff --git a/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx b/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aacaaa3aa72debd511d136b528229b6af4e2813e --- /dev/null +++ b/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx @@ -0,0 +1,98 @@ +import { Button, Label, NumericInput } 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 { + addInjectControl, + getInjectControlByInjectInfoId, + updateInjectControl, +} from '../indexeddb/operations' +import type { InjectControl } from '../indexeddb/types' +import { InjectType } from '../indexeddb/types' + +interface ConnectionsFormProps { + injectInfoId: number + injectType: InjectType +} + +const ConnectionsForm: FC<ConnectionsFormProps> = ({ + injectInfoId, + injectType, +}) => { + const injectControl = useLiveQuery( + () => getInjectControlByInjectInfoId(injectInfoId), + [injectInfoId], + null + ) as InjectControl + + const { notify } = useNotifyContext() + + const [start, setStart] = useState<number>(0) + const [delay, setDelay] = useState<number>(0) + + useEffect(() => { + setStart(injectControl?.start || 0) + injectType === InjectType.QUESTIONNAIRE + ? setDelay(0) + : setDelay(injectControl?.delay || 0) + }, [injectControl, injectType]) + + const handleUpdateButton = useCallback( + async (newInjectControl: InjectControl | Omit<InjectControl, 'id'>) => { + try { + if (injectControl) { + await updateInjectControl({ + id: injectControl.id, + ...newInjectControl, + }) + } else { + await addInjectControl(newInjectControl) + } + } catch (err) { + notify(`Failed to update inject control: ${err}`, { + intent: 'danger', + }) + } + }, + [notify, injectControl] + ) + + return ( + <div> + <Label> + Start + <NumericInput + placeholder='Input number' + min={0} + value={start} + onValueChange={(value: number) => setStart(value)} + /> + </Label> + {injectType !== InjectType.QUESTIONNAIRE && ( + <Label> + Delay + <NumericInput + placeholder='Input number' + min={0} + value={delay} + onValueChange={(value: number) => setDelay(value)} + /> + </Label> + )} + <Button + onClick={() => + handleUpdateButton({ + injectInfoId, + start, + delay, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + </div> + ) +} + +export default memo(ConnectionsForm) diff --git a/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx b/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx index 0163856bf502eceeabd7a5baf89c25f7e9bbd360..47bf4da2afc149ebfe250a8566b65ea7ae088c26 100644 --- a/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx +++ b/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx @@ -9,6 +9,7 @@ import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyCon import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' import EmailAddressSelector from '../EmailAddressSelector' +import FileSelector from '../FileSelector' import { addEmailInject, getEmailInjectByInjectInfoId, @@ -33,12 +34,14 @@ const EmailInjectForm: FC<EmailInjectFormProps> = ({ injectInfoId }) => { const [content, setContent] = useState<string>('') const [selectedAddressId, setSelectedAddressId] = useState<number>(0) const [extraCopies, setExtraCopies] = useState<number>(0) + const [fileId, setFileId] = useState<number>(0) useEffect(() => { setSubject(emailInject?.subject || '') setContent(emailInject?.content || '') setSelectedAddressId(emailInject?.emailAddressId || 0) setExtraCopies(emailInject?.extraCopies || 0) + setFileId(emailInject?.fileId || 0) }, [emailInject]) const handleUpdateButton = useCallback( @@ -95,6 +98,7 @@ const EmailInjectForm: FC<EmailInjectFormProps> = ({ injectInfoId }) => { onValueChange={(value: number) => setExtraCopies(value)} /> </Label> + <FileSelector fileId={fileId} onChange={id => setFileId(id)} /> <Button onClick={() => handleUpdateButton({ @@ -103,6 +107,7 @@ const EmailInjectForm: FC<EmailInjectFormProps> = ({ injectInfoId }) => { content, emailAddressId: selectedAddressId, extraCopies, + fileId, }) } intent='primary' diff --git a/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx b/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx index c9d37f26dfbfc5faab63702cabd90a1fb76efa89..3c0efd073381fc41c9fb48d9574ba83de6fc7056 100644 --- a/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx +++ b/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx @@ -2,6 +2,7 @@ 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 FileSelector from '../FileSelector' import { addInformationInject, getInformationInjectByInjectInfoId, @@ -25,9 +26,11 @@ const InformationInjectForm: FC<InformationInjectFormProps> = ({ const { notify } = useNotifyContext() const [content, setContent] = useState<string>('') + const [fileId, setFileId] = useState<number>(0) useEffect(() => { setContent(informationInject?.content || '') + setFileId(informationInject?.fileId || 0) }, [informationInject]) const handleUpdateButton = useCallback( @@ -68,11 +71,13 @@ const InformationInjectForm: FC<InformationInjectFormProps> = ({ onChange={e => setContent(e.target.value)} /> </Label> + <FileSelector fileId={fileId} onChange={id => setFileId(id)} /> <Button onClick={() => handleUpdateButton({ injectInfoId, content, + fileId, }) } intent='primary' diff --git a/frontend/src/editor/InjectSpecification/OverlayForm.tsx b/frontend/src/editor/InjectSpecification/OverlayForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a39e83f71b9eed95bb2c71c028fc37aa53cfa155 --- /dev/null +++ b/frontend/src/editor/InjectSpecification/OverlayForm.tsx @@ -0,0 +1,92 @@ +import { Button, Checkbox, Label, NumericInput } 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 { + addOverlay, + deleteOverlay, + getOverlayByInjectInfoId, + updateOverlay, +} from '../indexeddb/operations' +import type { Overlay } from '../indexeddb/types' + +interface OverlayFormProps { + injectInfoId: number +} + +const OverlayForm: FC<OverlayFormProps> = ({ injectInfoId }) => { + const overlay = useLiveQuery( + () => getOverlayByInjectInfoId(injectInfoId), + [injectInfoId], + null + ) as Overlay + + const { notify } = useNotifyContext() + + const [enableOverlay, setEnableOverlay] = useState<boolean>(false) + const [duration, setDuration] = useState<number>(1) + + useEffect(() => { + setEnableOverlay(overlay !== undefined) + setDuration(overlay?.duration || 1) + }, [overlay]) + + const handleUpdateButton = useCallback( + async (newOverlay: Overlay | Omit<Overlay, 'id'>) => { + try { + if (overlay) { + if (!enableOverlay) { + await deleteOverlay(overlay.id) + return + } + + await updateOverlay({ + id: overlay.id, + ...newOverlay, + }) + } else { + await addOverlay(newOverlay) + } + } catch (err) { + notify(`Failed to update overlay: ${err}`, { + intent: 'danger', + }) + } + }, + [notify, overlay] + ) + + return ( + <div> + <Checkbox + checked={enableOverlay} + onChange={() => setEnableOverlay(prev => !prev)} + label={'Use overlay'} + /> + {enableOverlay && ( + <Label> + Duration in minutes + <NumericInput + placeholder='Input number' + min={1} + value={duration} + onValueChange={(value: number) => setDuration(value)} + /> + </Label> + )} + <Button + onClick={() => + handleUpdateButton({ + injectInfoId, + duration, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + </div> + ) +} + +export default memo(OverlayForm) diff --git a/frontend/src/editor/InjectSpecification/index.tsx b/frontend/src/editor/InjectSpecification/index.tsx index 883b3ec5201e37455366f7b8768a366fe32386f3..cc1681a52ad7d3e2b0f00a6a22068a2a7c27b549 100644 --- a/frontend/src/editor/InjectSpecification/index.tsx +++ b/frontend/src/editor/InjectSpecification/index.tsx @@ -1,12 +1,14 @@ -import { Divider, NonIdealState } from '@blueprintjs/core' +import { NonIdealState, Tab, Tabs, TabsExpander } 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 ConnectionsForm from './ConnectionsForm' import EmailInjectForm from './EmailInjectForm' import InformationInjectForm from './InformationInjectForm' +import OverlayForm from './OverlayForm' import QuestionnaireForm from './QuestionnaireForm' interface InjectSpecificationProps { @@ -34,7 +36,7 @@ const InjectSpecification: FC<InjectSpecificationProps> = ({ return ( <div> - <div> + <div style={{ marginBottom: '1rem' }}> <p>Name: {injectInfo.name}</p> <p>Description: {injectInfo.description}</p> <p>Type: {injectInfo.type}</p> @@ -47,16 +49,41 @@ const InjectSpecification: FC<InjectSpecificationProps> = ({ }} /> </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} /> - )} + <Tabs> + <Tab + id='parameters' + title='Parameters' + panel={ + <> + {injectInfo.type === InjectType.EMAIL && ( + <EmailInjectForm injectInfoId={injectInfoId} /> + )} + {injectInfo.type === InjectType.INFORMATION && ( + <InformationInjectForm injectInfoId={injectInfoId} /> + )} + {injectInfo.type === InjectType.QUESTIONNAIRE && ( + <QuestionnaireForm injectInfoId={injectInfoId} /> + )} + </> + } + /> + <Tab + id='connections' + title='Connections' + panel={ + <ConnectionsForm + injectInfoId={injectInfoId} + injectType={injectInfo.type} + /> + } + /> + <Tab + id='overlay' + title='Overlay' + panel={<OverlayForm injectInfoId={injectInfoId} />} + /> + <TabsExpander /> + </Tabs> </div> ) } diff --git a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx index a9ba887d60daf3508e2fc9879c8cad0d65cb144f..3d8ac06985c6dc108fc3280ed7723559175cf170 100644 --- a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx @@ -28,11 +28,13 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ const [context, setContext] = useState<string>('') const [content, setContent] = useState<string>('') const [selectedAddressId, setSelectedAddressId] = useState<number>(0) + const [fileId, setFileId] = useState<number>(0) useEffect(() => { setContext(template?.context || '') setContent(template?.content || '') setSelectedAddressId(template?.emailAddressId || 0) + setFileId(template?.fileId || 0) }, [template]) const handleUpdateButton = useCallback( @@ -58,9 +60,11 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ context={context} content={content} emailAddressId={selectedAddressId} + fileId={fileId} onContextChange={(value: string) => setContext(value)} onContentChange={(value: string) => setContent(value)} onEmailAddressIdChange={(value: number) => setSelectedAddressId(value)} + onFileIdChange={(value: number) => setFileId(value)} /> <Button disabled={!context || !selectedAddressId} @@ -70,6 +74,7 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ context, content, emailAddressId: selectedAddressId, + fileId, }) } intent='primary' diff --git a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx index 4387df86f20de1759fc8b1decbed9b7b5fe41d85..5c4abae05077e989a60c4e7b4398bf1a1c8ace46 100644 --- a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx @@ -29,12 +29,16 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ const [content, setContent] = useState<string>('') const [isRegex, setIsRegex] = useState<boolean>(false) const [selectedToolId, setSelectedToolId] = useState<number>(0) + const [fileId, setFileId] = useState<number>(0) + const [time, setTime] = useState<number>(0) useEffect(() => { setParameter(response?.parameter || '') setContent(response?.content || '') setIsRegex(response?.isRegex || false) setSelectedToolId(response?.toolId || 0) + setTime(response?.time || 0) + setFileId(response?.fileId || 0) }, [response]) const handleUpdateButton = useCallback( @@ -61,10 +65,14 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ content={content} isRegex={isRegex} toolId={selectedToolId} + fileId={fileId} + time={time} onParameterChange={(value: string) => setParameter(value)} onContentChange={(value: string) => setContent(value)} onIsRegexChange={(value: boolean) => setIsRegex(value)} onToolIdChange={(value: number) => setSelectedToolId(value)} + onFileIdChange={(value: number) => setFileId(value)} + onTimeChange={(value: number) => setTime(value)} /> <Button disabled={!parameter || !selectedToolId} @@ -75,6 +83,8 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ content, isRegex, toolId: selectedToolId, + time, + fileId, }) } intent='primary' diff --git a/frontend/src/editor/ToolResponseFormContent/index.tsx b/frontend/src/editor/ToolResponseFormContent/index.tsx index 167139611f816c2295bb72caa9d3cadb6c8cba4e..0e6b17e89c504de9e8b10414375d2faabac3d0a8 100644 --- a/frontend/src/editor/ToolResponseFormContent/index.tsx +++ b/frontend/src/editor/ToolResponseFormContent/index.tsx @@ -1,5 +1,15 @@ -import { Checkbox, InputGroup, Label, TextArea } from '@blueprintjs/core' +import { + Checkbox, + InputGroup, + Label, + NumericInput, + Tab, + Tabs, + TabsExpander, + TextArea, +} from '@blueprintjs/core' import { memo, type FC } from 'react' +import FileSelector from '../FileSelector' import ToolSelector from '../ToolSelector' interface ToolResponseFormProps { @@ -11,6 +21,10 @@ interface ToolResponseFormProps { onIsRegexChange: (value: boolean) => void toolId: number onToolIdChange: (value: number) => void + fileId: number + onFileIdChange: (value: number) => void + time: number + onTimeChange: (value: number) => void } const ToolResponseForm: FC<ToolResponseFormProps> = ({ @@ -22,37 +36,68 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ onIsRegexChange, toolId, onToolIdChange, + fileId, + onFileIdChange, + time, + onTimeChange, }) => ( - <div> - <ToolSelector toolId={toolId} onChange={id => onToolIdChange(id)} /> - <Label> - Parameter - <InputGroup - placeholder='Input text' - value={parameter} - onChange={e => onParameterChange(e.target.value)} - /> - </Label> - <Checkbox - label='Is parameter regex?' - checked={isRegex} - onChange={e => onIsRegexChange(e.target.checked)} + <Tabs> + <Tab + id='parameters' + title='Parameters' + panel={ + <div> + <ToolSelector toolId={toolId} onChange={id => onToolIdChange(id)} /> + <Label> + Parameter + <InputGroup + placeholder='Input text' + value={parameter} + onChange={e => onParameterChange(e.target.value)} + /> + </Label> + <Checkbox + label='Is parameter regex?' + checked={isRegex} + onChange={e => onIsRegexChange(e.target.checked)} + /> + <Label> + Response + <TextArea + value={content} + style={{ + width: '100%', + height: '10rem', + resize: 'none', + overflowY: 'auto', + }} + placeholder='Input text' + onChange={e => onContentChange(e.target.value)} + /> + </Label> + <FileSelector fileId={fileId} onChange={id => onFileIdChange(id)} /> + </div> + } /> - <Label> - Response - <TextArea - value={content} - style={{ - width: '100%', - height: '10rem', - resize: 'none', - overflowY: 'auto', - }} - placeholder='Input text' - onChange={e => onContentChange(e.target.value)} - /> - </Label> - </div> + <Tab + id='connections' + title='Connections' + panel={ + <div> + <Label> + Time + <NumericInput + placeholder='Input number' + min={0} + value={time} + onValueChange={(value: number) => onTimeChange(value)} + /> + </Label> + </div> + } + /> + <TabsExpander /> + </Tabs> ) export default memo(ToolResponseForm) diff --git a/frontend/src/editor/ToolResponseFormDialog/index.tsx b/frontend/src/editor/ToolResponseFormDialog/index.tsx index be96fc4c7ba12cc2bbb77c368ccb0c7ef958d381..b4adbfe74999ad9d9458b065ff861ae676cbcb89 100644 --- a/frontend/src/editor/ToolResponseFormDialog/index.tsx +++ b/frontend/src/editor/ToolResponseFormDialog/index.tsx @@ -24,11 +24,15 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ const [content, setContent] = useState<string>('') const [isRegex, setIsRegex] = useState<boolean>(false) const [selectedToolId, setSelectedToolId] = useState<number>(0) + const [fileId, setFileId] = useState<number>(0) + const [time, setTime] = useState<number>(0) const clearInput = useCallback(() => { setParameter('') setContent('') setIsRegex(false) + setTime(0) + setFileId(0) }, []) useEffect(() => { @@ -36,6 +40,8 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ setContent(response?.content || '') setIsRegex(response?.isRegex || false) setSelectedToolId(response?.toolId || toolId) + setFileId(response?.fileId || 0) + setTime(response?.time || 0) }, [response, isOpen]) const handleAddButton = useCallback( @@ -88,10 +94,14 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ content={content} isRegex={isRegex} toolId={selectedToolId} + fileId={fileId} + time={time} onParameterChange={(value: string) => setParameter(value)} onContentChange={(value: string) => setContent(value)} onIsRegexChange={(value: boolean) => setIsRegex(value)} onToolIdChange={(value: number) => setSelectedToolId(value)} + onFileIdChange={(value: number) => setFileId(value)} + onTimeChange={(value: number) => setTime(value)} /> </DialogBody> <DialogFooter @@ -106,6 +116,8 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ content, isRegex, toolId: selectedToolId, + fileId, + time, }) } intent='primary' @@ -121,6 +133,8 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ content, isRegex, toolId: selectedToolId, + fileId, + time, }) } intent='primary' diff --git a/frontend/src/editor/indexeddb/db.tsx b/frontend/src/editor/indexeddb/db.tsx index 9d519808270488762b180fd848034c66b7e035bb..ef7f0b82616fedc2a61e7cdd8061f8586f491635 100644 --- a/frontend/src/editor/indexeddb/db.tsx +++ b/frontend/src/editor/indexeddb/db.tsx @@ -1,12 +1,15 @@ import Dexie, { type EntityTable } from 'dexie' import type { + ContentFile, EmailAddressInfo, EmailInject, EmailTemplate, InformationInject, + InjectControl, InjectInfo, LearningActivityInfo, LearningObjectiveInfo, + Overlay, Questionnaire, QuestionnaireQuestion, ToolInfo, @@ -28,6 +31,9 @@ const db = new Dexie(dbName) as Dexie & { informationInjects: EntityTable<InformationInject, 'id'> questionnaires: EntityTable<Questionnaire, 'id'> questionnaireQuestions: EntityTable<QuestionnaireQuestion, 'id'> + overlays: EntityTable<Overlay, 'id'> + injectControls: EntityTable<InjectControl, 'id'> + files: EntityTable<ContentFile, 'id'> } db.version(dbVersion).stores({ @@ -36,14 +42,18 @@ db.version(dbVersion).stores({ injectInfos: '++id, &name, description, type', tools: '++id, &name, tooltipDescription, hint, defaultResponse, category', toolResponses: - '++id, &learningActivityId, toolId, parameter, isRegex, content', // TODO file + '++id, &learningActivityId, toolId, parameter, isRegex, content, fileId', emailAddresses: '++id, address, organization, description, teamVisible', - emailTemplates: '++id, &learningActivityId, emailAddressId, context, content', // TODO file + emailTemplates: + '++id, &learningActivityId, emailAddressId, context, content, fileId', emailInjects: - '++id, &injectInfoId, emailAddressId, subject, content, extraCopies', // TODO file - informationInjects: '++id, &injectInfoId, content', // TODO file + '++id, &injectInfoId, emailAddressId, subject, content, extraCopies, fileId', + informationInjects: '++id, &injectInfoId, content, fileId', questionnaires: '++id, &injectInfoId, title', questionnaireQuestions: '++id, questionnaireId, text, max, correct, labels', + overlays: '++id, &injectInfoId, duration', + injectControls: '++id, &injectInfoId, start, delay, milestoneCondition', + files: '++id, &name, blob', }) export { db } diff --git a/frontend/src/editor/indexeddb/operations.tsx b/frontend/src/editor/indexeddb/operations.tsx index c51e8e58c44b3b992b057c07f1f5a8f61861af37..edde591f676116a107352294642d811b7e1cd547 100644 --- a/frontend/src/editor/indexeddb/operations.tsx +++ b/frontend/src/editor/indexeddb/operations.tsx @@ -1,12 +1,15 @@ import { db } from './db' import type { + ContentFile, EmailAddressInfo, EmailInject, EmailTemplate, InformationInject, + InjectControl, InjectInfo, LearningActivityInfo, LearningObjectiveInfo, + Overlay, Questionnaire, QuestionnaireQuestion, ToolInfo, @@ -66,7 +69,17 @@ export const updateInjectInfo = async (injectInfo: InjectInfo) => await db.injectInfos.put(injectInfo) export const deleteInjectInfo = async (id: number) => - await db.injectInfos.delete(id) + await db.transaction( + 'rw', + db.injectInfos, + db.overlays, + db.injectControls, + async () => { + await db.injectInfos.delete(id) + await db.overlays.where({ injectInfoId: id }).delete() + await db.injectControls.where({ injectInfoId: id }).delete() + } + ) // tool operations export const addTool = async (tool: Omit<ToolInfo, 'id'>) => @@ -207,3 +220,47 @@ export const updateQuestionnaireQuestion = async ( export const deleteQuestionnaireQuestion = async (id: number) => await db.questionnaireQuestions.delete(id) + +// overlay operations +export const getOverlayByInjectInfoId = async (injectInfoId: number) => + await db.overlays.get({ injectInfoId }) + +export const addOverlay = async (overlay: Omit<Overlay, 'id'>) => + await db.transaction('rw', db.overlays, async () => { + await db.overlays.add(overlay) + }) + +export const updateOverlay = async (overlay: Overlay) => + await db.overlays.put(overlay) + +export const deleteOverlay = async (id: number) => await db.overlays.delete(id) + +// inject control operations +export const getInjectControlByInjectInfoId = async (injectInfoId: number) => + await db.injectControls.get({ injectInfoId }) + +export const addInjectControl = async ( + injectControl: Omit<InjectControl, 'id'> +) => + await db.transaction('rw', db.injectControls, async () => { + await db.injectControls.add(injectControl) + }) + +export const updateInjectControl = async (injectControl: InjectControl) => + await db.injectControls.put(injectControl) + +export const deleteInjectControl = async (id: number) => + await db.injectControls.delete(id) + +// file operations +export const getFileById = async (id: number) => await db.files.get(id) + +export const addFile = async (file: Omit<ContentFile, 'id'>) => + await db.transaction('rw', db.files, async () => { + const id = await db.files.add(file) + return id + }) + +export const updateFile = async (file: ContentFile) => await db.files.put(file) + +export const deleteFile = async (id: number) => await db.files.delete(id) diff --git a/frontend/src/editor/indexeddb/types.tsx b/frontend/src/editor/indexeddb/types.tsx index d308ae7f2a3526e4f3e42cd2eeb3039b2a65e71c..8201baf290eb782d276c308081eb8a19f477ae8a 100644 --- a/frontend/src/editor/indexeddb/types.tsx +++ b/frontend/src/editor/indexeddb/types.tsx @@ -2,7 +2,6 @@ import type { EmailAddress } from '@inject/graphql/fragments/EmailAddress.genera import type { LearningObjective } from '@inject/graphql/fragments/LearningObjective.generated' export enum LearningActivityType { - CONFIRMATION = 'Confirmation', TOOL = 'Tool', EMAIL = 'Email', } @@ -13,6 +12,9 @@ export enum InjectType { QUESTIONNAIRE = 'Questionnaire', } +const eventTypes = { ...LearningActivityType, ...InjectType } +export type EventTypes = typeof eventTypes + export type LearningObjectiveInfo = Pick<LearningObjective, 'id' | 'name'> export type LearningActivityInfo = { @@ -46,6 +48,9 @@ export type ToolResponse = { parameter: string isRegex: boolean content: string + fileId?: number + time: number + milestoneCondition?: string[] } export type EmailAddressInfo = Omit<EmailAddress, 'control'> @@ -56,6 +61,7 @@ export type EmailTemplate = { emailAddressId: number context: string content: string + fileId?: number } export type EmailInject = { @@ -65,12 +71,14 @@ export type EmailInject = { subject: string content: string extraCopies: number + fileId?: number } export type InformationInject = { id: number injectInfoId: number content: string + fileId?: number } export type Questionnaire = { @@ -87,3 +95,23 @@ export type QuestionnaireQuestion = { correct: number labels: string } + +export type InjectControl = { + id: number + injectInfoId: number + start: number + delay: number + milestoneCondition?: string[] +} + +export type Overlay = { + id: number + injectInfoId: number + duration: number +} + +export type ContentFile = { + id: number + name: string + blob: Blob +}