From 51d620de39e56faacaad71a2c85428e7e68d68cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Katar=C3=ADna=20Platkov=C3=A1?= <xplatkov@fi.muni.cz> Date: Sat, 28 Sep 2024 10:37:30 +0200 Subject: [PATCH] Editor - tools and emails --- frontend/src/editor/EditorPage/index.tsx | 10 +- .../src/editor/EmailAddressForm/index.tsx | 4 +- .../index.tsx} | 8 +- .../editor/EmailAddresses/EmailAddress.tsx | 81 ++++++++++ frontend/src/editor/EmailAddresses/index.tsx | 20 +++ .../editor/EmailTemplateFormContent/index.tsx | 52 +++++++ .../editor/EmailTemplateFormDialog/index.tsx | 127 ++++++++++++++++ .../editor/EmailTemplates/EmailTemplate.tsx | 54 +++++++ frontend/src/editor/EmailTemplates/index.tsx | 35 +++++ .../InjectSpecification/EmailInjectForm.tsx | 2 +- .../EmailTemplateForm.tsx | 35 ++--- .../ToolResponseForm.tsx | 48 ++---- frontend/src/editor/Navbar/index.tsx | 1 + frontend/src/editor/ToolForm/index.tsx | 16 +- .../editor/ToolResponseFormContent/index.tsx | 58 ++++++++ .../editor/ToolResponseFormDialog/index.tsx | 138 ++++++++++++++++++ .../src/editor/ToolResponses/ToolResponse.tsx | 57 ++++++++ frontend/src/editor/ToolResponses/index.tsx | 29 ++++ .../index.tsx} | 8 +- frontend/src/editor/Tools/Tool.tsx | 76 ++++++++++ frontend/src/editor/Tools/index.tsx | 18 +++ frontend/src/editor/indexeddb/types.tsx | 4 +- frontend/src/pages/editor/create/emails.tsx | 26 ++++ .../pages/editor/create/final-information.tsx | 2 +- .../create/inject-specification/index.tsx | 3 +- frontend/src/pages/editor/create/other.tsx | 28 ++++ frontend/src/pages/editor/create/tools.tsx | 26 ++++ frontend/src/router.ts | 3 + 28 files changed, 874 insertions(+), 95 deletions(-) rename frontend/src/editor/{LearningActivitySpecification/EmailAddressSelector.tsx => EmailAddressSelector/index.tsx} (86%) create mode 100644 frontend/src/editor/EmailAddresses/EmailAddress.tsx create mode 100644 frontend/src/editor/EmailAddresses/index.tsx create mode 100644 frontend/src/editor/EmailTemplateFormContent/index.tsx create mode 100644 frontend/src/editor/EmailTemplateFormDialog/index.tsx create mode 100644 frontend/src/editor/EmailTemplates/EmailTemplate.tsx create mode 100644 frontend/src/editor/EmailTemplates/index.tsx create mode 100644 frontend/src/editor/ToolResponseFormContent/index.tsx create mode 100644 frontend/src/editor/ToolResponseFormDialog/index.tsx create mode 100644 frontend/src/editor/ToolResponses/ToolResponse.tsx create mode 100644 frontend/src/editor/ToolResponses/index.tsx rename frontend/src/editor/{LearningActivitySpecification/ToolSelector.tsx => ToolSelector/index.tsx} (88%) create mode 100644 frontend/src/editor/Tools/Tool.tsx create mode 100644 frontend/src/editor/Tools/index.tsx create mode 100644 frontend/src/pages/editor/create/emails.tsx create mode 100644 frontend/src/pages/editor/create/other.tsx create mode 100644 frontend/src/pages/editor/create/tools.tsx diff --git a/frontend/src/editor/EditorPage/index.tsx b/frontend/src/editor/EditorPage/index.tsx index 63d77e9f4..afb081b54 100644 --- a/frontend/src/editor/EditorPage/index.tsx +++ b/frontend/src/editor/EditorPage/index.tsx @@ -53,8 +53,8 @@ const EditorPage: FC<EditorPageProps> = ({ style={{ marginRight: '0.5rem', }} - />{' '} - {nextVisible && ( + /> + {(nextVisible === undefined || nextVisible) && ( <Button type='button' onClick={() => nav(nextPath || '/')} @@ -69,10 +69,4 @@ const EditorPage: FC<EditorPageProps> = ({ ) } -EditorPage.defaultProps = { - nextPath: '/', - nextDisabled: false, - nextVisible: true, -} - export default memo(EditorPage) diff --git a/frontend/src/editor/EmailAddressForm/index.tsx b/frontend/src/editor/EmailAddressForm/index.tsx index 35cc8d43c..dde59bee9 100644 --- a/frontend/src/editor/EmailAddressForm/index.tsx +++ b/frontend/src/editor/EmailAddressForm/index.tsx @@ -130,7 +130,7 @@ const EmailAddressForm: FC<EmailAddressFormProps> = ({ actions={ emailAddressInfo ? ( <Button - disabled={!address || !description} + disabled={!address} onClick={() => handleUpdateButton({ id: emailAddressInfo.id, @@ -146,7 +146,7 @@ const EmailAddressForm: FC<EmailAddressFormProps> = ({ /> ) : ( <Button - disabled={!address || !description} + disabled={!address} onClick={() => handleAddButton({ address, diff --git a/frontend/src/editor/LearningActivitySpecification/EmailAddressSelector.tsx b/frontend/src/editor/EmailAddressSelector/index.tsx similarity index 86% rename from frontend/src/editor/LearningActivitySpecification/EmailAddressSelector.tsx rename to frontend/src/editor/EmailAddressSelector/index.tsx index bd3d25631..5c9e53c9f 100644 --- a/frontend/src/editor/LearningActivitySpecification/EmailAddressSelector.tsx +++ b/frontend/src/editor/EmailAddressSelector/index.tsx @@ -1,7 +1,7 @@ import type { OptionProps } from '@blueprintjs/core' import { HTMLSelect, Label } from '@blueprintjs/core' import { useLiveQuery } from 'dexie-react-hooks' -import { memo, useMemo, type FC } from 'react' +import { memo, useEffect, useMemo, type FC } from 'react' import EmailAddressForm from '../EmailAddressForm' import { db } from '../indexeddb/db' import type { EmailAddressInfo } from '../indexeddb/types' @@ -34,6 +34,12 @@ const EmailAddressSelector: FC<EmailAddressFormProps> = ({ })) }, [emailAddresses]) + useEffect(() => { + if (!emailAddressId && emailAddresses && emailAddresses.length > 0) { + onChange(Number(emailAddresses[0].id)) + } + }, [emailAddresses, emailAddressId]) + return ( <div style={{ display: 'flex', width: '100%' }}> <Label style={{ flexGrow: '1' }}> diff --git a/frontend/src/editor/EmailAddresses/EmailAddress.tsx b/frontend/src/editor/EmailAddresses/EmailAddress.tsx new file mode 100644 index 000000000..f01aff35f --- /dev/null +++ b/frontend/src/editor/EmailAddresses/EmailAddress.tsx @@ -0,0 +1,81 @@ +import { Button, ButtonGroup, Card } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import type { FC } from 'react' +import { memo, useCallback } from 'react' +import EmailAddressForm from '../EmailAddressForm' +import EmailTemplateFormDialog from '../EmailTemplateFormDialog' +import EmailTemplates from '../EmailTemplates' +import { deleteEmailAddress } from '../indexeddb/operations' +import type { EmailAddressInfo } from '../indexeddb/types' + +interface EmailAddressProps { + emailAddress: EmailAddressInfo +} + +const EmailAddressItem: FC<EmailAddressProps> = ({ emailAddress }) => { + const { notify } = useNotifyContext() + + const handleDeleteButton = useCallback( + async (emailAddressInfo: EmailAddressInfo) => { + try { + await deleteEmailAddress(emailAddressInfo.id) + } catch (err) { + notify( + `Failed to delete email address '${emailAddressInfo.address}': ${err}`, + { + intent: 'danger', + } + ) + } + }, + [notify] + ) + + return ( + <Card style={{ flexDirection: 'column' }}> + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + padding: '0rem 1rem 1rem 0', + }} + > + <span style={{ height: '100%', flexGrow: 1 }}> + {emailAddress.address} + </span> + <ButtonGroup> + <EmailAddressForm + emailAddressInfo={emailAddress} + buttonProps={{ + minimal: true, + icon: 'edit', + style: { marginRight: '1rem' }, + }} + /> + <Button + minimal + icon='cross' + onClick={() => handleDeleteButton(emailAddress)} + /> + </ButtonGroup> + </div> + <div style={{ width: '100%', paddingLeft: '2rem' }}> + <EmailTemplates emailAddressId={Number(emailAddress.id)} /> + <EmailTemplateFormDialog + emailAddressId={Number(emailAddress.id)} + buttonProps={{ + minimal: true, + text: 'Add template', + alignText: 'left', + icon: 'plus', + style: { padding: '1rem', width: '100%' }, + }} + /> + </div> + </Card> + ) +} + +export default memo(EmailAddressItem) diff --git a/frontend/src/editor/EmailAddresses/index.tsx b/frontend/src/editor/EmailAddresses/index.tsx new file mode 100644 index 000000000..56e82bab1 --- /dev/null +++ b/frontend/src/editor/EmailAddresses/index.tsx @@ -0,0 +1,20 @@ +import { db } from '@/editor/indexeddb/db' +import type { EmailAddressInfo } from '@/editor/indexeddb/types' +import { CardList } from '@blueprintjs/core' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo } from 'react' +import EmailAddress from './EmailAddress' + +const EmailAddresses = () => { + const emailAddresses = useLiveQuery(() => db.emailAddresses.toArray(), [], []) + + return ( + <CardList> + {emailAddresses?.map((emailAddress: EmailAddressInfo) => ( + <EmailAddress key={emailAddress.id} emailAddress={emailAddress} /> + ))} + </CardList> + ) +} + +export default memo(EmailAddresses) diff --git a/frontend/src/editor/EmailTemplateFormContent/index.tsx b/frontend/src/editor/EmailTemplateFormContent/index.tsx new file mode 100644 index 000000000..00b9f9e4d --- /dev/null +++ b/frontend/src/editor/EmailTemplateFormContent/index.tsx @@ -0,0 +1,52 @@ +import { InputGroup, Label, TextArea } from '@blueprintjs/core' +import { memo, type FC } from 'react' +import EmailAddressSelector from '../EmailAddressSelector' + +interface EmailTemplateFormProps { + context: string + onContextChange: (value: string) => void + content: string + onContentChange: (value: string) => void + emailAddressId: number + onEmailAddressIdChange: (value: number) => void +} + +const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ + context, + onContextChange, + content, + onContentChange, + emailAddressId, + onEmailAddressIdChange, +}) => ( + <div> + <EmailAddressSelector + emailAddressId={emailAddressId} + onChange={id => onEmailAddressIdChange(id)} + /> + <Label> + Context + <InputGroup + placeholder='Input text' + value={context} + onChange={e => onContextChange(e.target.value)} + /> + </Label> + <Label> + Content + <TextArea + value={content} + style={{ + width: '100%', + height: '10rem', + resize: 'none', + overflowY: 'auto', + }} + placeholder='Input text' + onChange={e => onContentChange(e.target.value)} + /> + </Label> + </div> +) + +export default memo(EmailTemplateForm) diff --git a/frontend/src/editor/EmailTemplateFormDialog/index.tsx b/frontend/src/editor/EmailTemplateFormDialog/index.tsx new file mode 100644 index 000000000..604aa5254 --- /dev/null +++ b/frontend/src/editor/EmailTemplateFormDialog/index.tsx @@ -0,0 +1,127 @@ +import type { ButtonProps } from '@blueprintjs/core' +import { Button, Dialog, DialogBody, DialogFooter } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import { memo, useCallback, useEffect, useState, type FC } from 'react' +import EmailTemplateFormContent from '../EmailTemplateFormContent' +import { addEmailTemplate, updateEmailTemplate } from '../indexeddb/operations' +import type { EmailTemplate } from '../indexeddb/types' + +interface EmailTemplateFormDialogProps { + template?: EmailTemplate + emailAddressId: number + buttonProps: ButtonProps +} + +const EmailTemplateFormDialog: FC<EmailTemplateFormDialogProps> = ({ + template, + emailAddressId, + buttonProps, +}) => { + const { notify } = useNotifyContext() + const [isOpen, setIsOpen] = useState(false) + + const [context, setContext] = useState<string>('') + const [content, setContent] = useState<string>('') + const [selectedAddressId, setSelectedAddressId] = useState<number>(0) + + useEffect(() => { + setContext(template?.context || '') + setContent(template?.content || '') + setSelectedAddressId(template?.emailAddressId || emailAddressId) + }, [template, isOpen]) + + const clearInput = useCallback(() => { + setContext('') + setContent('') + }, []) + + const handleAddButton = useCallback( + async (emailTemplate: Omit<EmailTemplate, 'id'>) => { + try { + await addEmailTemplate(emailTemplate) + clearInput() + setIsOpen(false) + } catch (err) { + notify(`Failed to add template '${emailTemplate.context}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + const handleUpdateButton = useCallback( + async (emailTemplate: EmailTemplate) => { + try { + await updateEmailTemplate(emailTemplate) + setIsOpen(false) + } catch (err) { + notify(`Failed to update template '${emailTemplate.context}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + return ( + <> + <Button {...buttonProps} onClick={() => setIsOpen(true)} /> + <Dialog + isOpen={isOpen} + onClose={() => setIsOpen(false)} + icon={template ? 'edit' : 'plus'} + title={template ? 'Edit template' : 'New template'} + > + <DialogBody> + <EmailTemplateFormContent + context={context} + content={content} + emailAddressId={selectedAddressId} + onContextChange={(value: string) => setContext(value)} + onContentChange={(value: string) => setContent(value)} + onEmailAddressIdChange={(value: number) => + setSelectedAddressId(value) + } + /> + </DialogBody> + <DialogFooter + actions={ + template ? ( + <Button + disabled={!context || !selectedAddressId} + onClick={() => + handleUpdateButton({ + id: template.id, + context, + content, + emailAddressId: selectedAddressId, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + ) : ( + <Button + disabled={!context || !selectedAddressId} + onClick={() => + handleAddButton({ + context, + content, + emailAddressId: selectedAddressId, + }) + } + intent='primary' + icon='plus' + text='Add' + /> + ) + } + /> + </Dialog> + </> + ) +} + +export default memo(EmailTemplateFormDialog) diff --git a/frontend/src/editor/EmailTemplates/EmailTemplate.tsx b/frontend/src/editor/EmailTemplates/EmailTemplate.tsx new file mode 100644 index 000000000..f61b34673 --- /dev/null +++ b/frontend/src/editor/EmailTemplates/EmailTemplate.tsx @@ -0,0 +1,54 @@ +import { Button, ButtonGroup, Card } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import type { FC } from 'react' +import { memo, useCallback } from 'react' +import EmailTemplateFormDialog from '../EmailTemplateFormDialog' +import { deleteEmailTemplate } from '../indexeddb/operations' +import type { EmailTemplate } from '../indexeddb/types' + +interface EmailTemplateProps { + emailTemplate: EmailTemplate +} + +const EmailTemplateItem: FC<EmailTemplateProps> = ({ emailTemplate }) => { + const { notify } = useNotifyContext() + + const handleDeleteButton = useCallback( + async (template: EmailTemplate) => { + try { + await deleteEmailTemplate(template.id) + } catch (err) { + notify(`Failed to delete template '${template.context}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + return ( + <Card style={{ display: 'flex', justifyContent: 'space-between' }}> + <span style={{ height: '100%', flexGrow: 1 }}> + {emailTemplate.context} + </span> + <ButtonGroup> + <EmailTemplateFormDialog + template={emailTemplate} + emailAddressId={emailTemplate.emailAddressId} + buttonProps={{ + minimal: true, + icon: 'edit', + style: { marginRight: '1rem' }, + }} + /> + <Button + minimal + icon='cross' + onClick={() => handleDeleteButton(emailTemplate)} + /> + </ButtonGroup> + </Card> + ) +} + +export default memo(EmailTemplateItem) diff --git a/frontend/src/editor/EmailTemplates/index.tsx b/frontend/src/editor/EmailTemplates/index.tsx new file mode 100644 index 000000000..93c4b0e08 --- /dev/null +++ b/frontend/src/editor/EmailTemplates/index.tsx @@ -0,0 +1,35 @@ +import { db } from '@/editor/indexeddb/db' +import type { EmailTemplate } 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 EmailTemplateItem from './EmailTemplate' + +interface EmailTemplatesProps { + emailAddressId: number +} + +const EmailTemplates: FC<EmailTemplatesProps> = ({ emailAddressId }) => { + const emailTemplates = useLiveQuery( + () => + db.emailTemplates + .where({ emailAddressId: Number(emailAddressId) }) + .toArray(), + [emailAddressId], + [] + ) + + return ( + <CardList> + {emailTemplates?.map((emailTemplate: EmailTemplate) => ( + <EmailTemplateItem + key={emailTemplate.id} + emailTemplate={emailTemplate} + /> + ))} + </CardList> + ) +} + +export default memo(EmailTemplates) diff --git a/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx b/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx index c9bc58818..0163856bf 100644 --- a/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx +++ b/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx @@ -8,7 +8,7 @@ import { 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 EmailAddressSelector from '../EmailAddressSelector' import { addEmailInject, getEmailInjectByInjectInfoId, diff --git a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx index cbd4c24d6..a9ba887d6 100644 --- a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx @@ -1,14 +1,14 @@ -import { Button, InputGroup, Label, TextArea } from '@blueprintjs/core' +import { Button } 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 EmailTemplateFormContent from '../EmailTemplateFormContent' import { addEmailTemplate, getEmailTemplateByActivityId, updateEmailTemplate, } from '../indexeddb/operations' import type { EmailTemplate } from '../indexeddb/types' -import EmailAddressSelector from './EmailAddressSelector' interface EmailTemplateFormProps { learningActivityId: number @@ -54,33 +54,16 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ return ( <div> - <EmailAddressSelector + <EmailTemplateFormContent + context={context} + content={content} emailAddressId={selectedAddressId} - onChange={id => setSelectedAddressId(id)} + onContextChange={(value: string) => setContext(value)} + onContentChange={(value: string) => setContent(value)} + onEmailAddressIdChange={(value: number) => setSelectedAddressId(value)} /> - <Label> - Context - <InputGroup - placeholder='Input text' - value={context} - onChange={e => setContext(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> <Button + disabled={!context || !selectedAddressId} onClick={() => handleUpdateButton({ learningActivityId, diff --git a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx index 324c7d109..4387df86f 100644 --- a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx @@ -1,20 +1,14 @@ -import { - Button, - Checkbox, - InputGroup, - Label, - TextArea, -} from '@blueprintjs/core' +import { Button } 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 ToolResponseFormContent from '../ToolResponseFormContent' import { addToolResponse, getToolResponseByActivityId, updateToolResponse, } from '../indexeddb/operations' import type { ToolResponse } from '../indexeddb/types' -import ToolSelector from './ToolSelector' interface ToolResponseFormProps { learningActivityId: number @@ -62,38 +56,18 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ return ( <div> - <ToolSelector + <ToolResponseFormContent + parameter={parameter} + content={content} + isRegex={isRegex} toolId={selectedToolId} - onChange={id => setSelectedToolId(id)} - /> - <Label> - Parameter - <InputGroup - placeholder='Input text' - value={parameter} - onChange={e => setParameter(e.target.value)} - /> - </Label> - <Checkbox - label='Is parameter regex?' - checked={isRegex} - onChange={e => setIsRegex(e.target.checked)} + onParameterChange={(value: string) => setParameter(value)} + onContentChange={(value: string) => setContent(value)} + onIsRegexChange={(value: boolean) => setIsRegex(value)} + onToolIdChange={(value: number) => setSelectedToolId(value)} /> - <Label> - Response - <TextArea - value={content} - style={{ - width: '100%', - height: '10rem', - resize: 'none', - overflowY: 'auto', - }} - placeholder='Input text' - onChange={e => setContent(e.target.value)} - /> - </Label> <Button + disabled={!parameter || !selectedToolId} onClick={() => handleUpdateButton({ learningActivityId, diff --git a/frontend/src/editor/Navbar/index.tsx b/frontend/src/editor/Navbar/index.tsx index 2bc48d11d..0b6ae3b55 100644 --- a/frontend/src/editor/Navbar/index.tsx +++ b/frontend/src/editor/Navbar/index.tsx @@ -20,6 +20,7 @@ const Navbar = () => ( path='/editor/create/inject-specification' name='Injects specification' /> + <NavbarButton path='/editor/create/other' name='Other' /> <NavbarButton path='/editor/create/final-information' name='Final Information' diff --git a/frontend/src/editor/ToolForm/index.tsx b/frontend/src/editor/ToolForm/index.tsx index 1b0b96a9e..56899e07d 100644 --- a/frontend/src/editor/ToolForm/index.tsx +++ b/frontend/src/editor/ToolForm/index.tsx @@ -137,13 +137,7 @@ const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { actions={ toolInfo ? ( <Button - disabled={ - !name || - !category || - !tooltipDescription || - !hint || - !defaultResponse - } + disabled={!name || !defaultResponse} onClick={() => handleUpdateButton({ id: toolInfo.id, @@ -160,13 +154,7 @@ const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { /> ) : ( <Button - disabled={ - !name || - !category || - !tooltipDescription || - !hint || - !defaultResponse - } + disabled={!name || !defaultResponse} onClick={() => handleAddButton({ name, diff --git a/frontend/src/editor/ToolResponseFormContent/index.tsx b/frontend/src/editor/ToolResponseFormContent/index.tsx new file mode 100644 index 000000000..167139611 --- /dev/null +++ b/frontend/src/editor/ToolResponseFormContent/index.tsx @@ -0,0 +1,58 @@ +import { Checkbox, InputGroup, Label, TextArea } from '@blueprintjs/core' +import { memo, type FC } from 'react' +import ToolSelector from '../ToolSelector' + +interface ToolResponseFormProps { + parameter: string + onParameterChange: (value: string) => void + content: string + onContentChange: (value: string) => void + isRegex: boolean + onIsRegexChange: (value: boolean) => void + toolId: number + onToolIdChange: (value: number) => void +} + +const ToolResponseForm: FC<ToolResponseFormProps> = ({ + parameter, + onParameterChange, + content, + onContentChange, + isRegex, + onIsRegexChange, + toolId, + onToolIdChange, +}) => ( + <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> + </div> +) + +export default memo(ToolResponseForm) diff --git a/frontend/src/editor/ToolResponseFormDialog/index.tsx b/frontend/src/editor/ToolResponseFormDialog/index.tsx new file mode 100644 index 000000000..be96fc4c7 --- /dev/null +++ b/frontend/src/editor/ToolResponseFormDialog/index.tsx @@ -0,0 +1,138 @@ +import type { ButtonProps } from '@blueprintjs/core' +import { Button, Dialog, DialogBody, DialogFooter } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import { memo, useCallback, useEffect, useState, type FC } from 'react' +import ToolResponseForm from '../ToolResponseFormContent' +import { addToolResponse, updateToolResponse } from '../indexeddb/operations' +import type { ToolResponse } from '../indexeddb/types' + +interface ToolResponseFormDialogProps { + response?: ToolResponse + toolId: number + buttonProps: ButtonProps +} + +const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ + response, + toolId, + buttonProps, +}) => { + const { notify } = useNotifyContext() + const [isOpen, setIsOpen] = useState(false) + + const [parameter, setParameter] = useState<string>('') + const [content, setContent] = useState<string>('') + const [isRegex, setIsRegex] = useState<boolean>(false) + const [selectedToolId, setSelectedToolId] = useState<number>(0) + + const clearInput = useCallback(() => { + setParameter('') + setContent('') + setIsRegex(false) + }, []) + + useEffect(() => { + setParameter(response?.parameter || '') + setContent(response?.content || '') + setIsRegex(response?.isRegex || false) + setSelectedToolId(response?.toolId || toolId) + }, [response, isOpen]) + + const handleAddButton = useCallback( + async (toolResponse: Omit<ToolResponse, 'id'>) => { + try { + await addToolResponse(toolResponse) + clearInput() + setIsOpen(false) + } catch (err) { + notify( + `Failed to add tool response '${toolResponse.parameter}': ${err}`, + { + intent: 'danger', + } + ) + } + }, + [notify] + ) + + const handleUpdateButton = useCallback( + async (toolResponse: ToolResponse) => { + try { + await updateToolResponse(toolResponse) + setIsOpen(false) + } catch (err) { + notify( + `Failed to update tool response '${toolResponse.parameter}': ${err}`, + { + intent: 'danger', + } + ) + } + }, + [notify] + ) + + return ( + <> + <Button {...buttonProps} onClick={() => setIsOpen(true)} /> + <Dialog + isOpen={isOpen} + onClose={() => setIsOpen(false)} + icon={response ? 'edit' : 'plus'} + title={response ? 'Edit tool response' : 'New tool response'} + > + <DialogBody> + <ToolResponseForm + parameter={parameter} + content={content} + isRegex={isRegex} + toolId={selectedToolId} + onParameterChange={(value: string) => setParameter(value)} + onContentChange={(value: string) => setContent(value)} + onIsRegexChange={(value: boolean) => setIsRegex(value)} + onToolIdChange={(value: number) => setSelectedToolId(value)} + /> + </DialogBody> + <DialogFooter + actions={ + response ? ( + <Button + disabled={!parameter} + onClick={() => + handleUpdateButton({ + id: response.id, + parameter, + content, + isRegex, + toolId: selectedToolId, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + ) : ( + <Button + disabled={!parameter} + onClick={() => + handleAddButton({ + parameter, + content, + isRegex, + toolId: selectedToolId, + }) + } + intent='primary' + icon='plus' + text='Add' + /> + ) + } + /> + </Dialog> + </> + ) +} + +export default memo(ToolResponseFormDialog) diff --git a/frontend/src/editor/ToolResponses/ToolResponse.tsx b/frontend/src/editor/ToolResponses/ToolResponse.tsx new file mode 100644 index 000000000..025d57952 --- /dev/null +++ b/frontend/src/editor/ToolResponses/ToolResponse.tsx @@ -0,0 +1,57 @@ +import { Button, ButtonGroup, Card } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import type { FC } from 'react' +import { memo, useCallback } from 'react' +import ToolResponseFormDialog from '../ToolResponseFormDialog' +import { deleteToolResponse } from '../indexeddb/operations' +import type { ToolResponse } from '../indexeddb/types' + +interface ToolResponseProps { + toolResponse: ToolResponse +} + +const ToolResponseItem: FC<ToolResponseProps> = ({ toolResponse }) => { + const { notify } = useNotifyContext() + + const handleDeleteButton = useCallback( + async (response: ToolResponse) => { + try { + await deleteToolResponse(response.id) + } catch (err) { + notify( + `Failed to delete tool response '${response.parameter}': ${err}`, + { + intent: 'danger', + } + ) + } + }, + [notify] + ) + + return ( + <Card style={{ display: 'flex', justifyContent: 'space-between' }}> + <span style={{ height: '100%', flexGrow: 1 }}> + {toolResponse.parameter} + </span> + <ButtonGroup> + <ToolResponseFormDialog + response={toolResponse} + toolId={toolResponse.toolId} + buttonProps={{ + minimal: true, + icon: 'edit', + style: { marginRight: '1rem' }, + }} + /> + <Button + minimal + icon='cross' + onClick={() => handleDeleteButton(toolResponse)} + /> + </ButtonGroup> + </Card> + ) +} + +export default memo(ToolResponseItem) diff --git a/frontend/src/editor/ToolResponses/index.tsx b/frontend/src/editor/ToolResponses/index.tsx new file mode 100644 index 000000000..1c96632cc --- /dev/null +++ b/frontend/src/editor/ToolResponses/index.tsx @@ -0,0 +1,29 @@ +import { db } from '@/editor/indexeddb/db' +import type { ToolResponse } 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 ToolResponseItem from './ToolResponse' + +interface ToolResponsesProps { + toolId: number +} + +const ToolResponses: FC<ToolResponsesProps> = ({ toolId }) => { + const toolResponses = useLiveQuery( + () => db.toolResponses.where({ toolId: Number(toolId) }).toArray(), + [toolId], + [] + ) + + return ( + <CardList> + {toolResponses?.map((toolResponse: ToolResponse) => ( + <ToolResponseItem key={toolResponse.id} toolResponse={toolResponse} /> + ))} + </CardList> + ) +} + +export default memo(ToolResponses) diff --git a/frontend/src/editor/LearningActivitySpecification/ToolSelector.tsx b/frontend/src/editor/ToolSelector/index.tsx similarity index 88% rename from frontend/src/editor/LearningActivitySpecification/ToolSelector.tsx rename to frontend/src/editor/ToolSelector/index.tsx index e3a194a2d..e6a2d7bdd 100644 --- a/frontend/src/editor/LearningActivitySpecification/ToolSelector.tsx +++ b/frontend/src/editor/ToolSelector/index.tsx @@ -1,7 +1,7 @@ import type { OptionProps } from '@blueprintjs/core' import { HTMLSelect, Label } from '@blueprintjs/core' import { useLiveQuery } from 'dexie-react-hooks' -import { memo, useMemo, type FC } from 'react' +import { memo, useEffect, useMemo, type FC } from 'react' import ToolForm from '../ToolForm' import { db } from '../indexeddb/db' import type { ToolInfo } from '../indexeddb/types' @@ -31,6 +31,12 @@ const ToolSelector: FC<ToolFormProps> = ({ toolId, onChange }) => { })) }, [tools]) + useEffect(() => { + if (!toolId && tools && tools.length > 0) { + onChange(tools[0].id) + } + }, [tools, toolId]) + return ( <div style={{ display: 'flex', width: '100%' }}> <Label style={{ flexGrow: '1' }}> diff --git a/frontend/src/editor/Tools/Tool.tsx b/frontend/src/editor/Tools/Tool.tsx new file mode 100644 index 000000000..a9effbb81 --- /dev/null +++ b/frontend/src/editor/Tools/Tool.tsx @@ -0,0 +1,76 @@ +import { Button, ButtonGroup, Card } from '@blueprintjs/core' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import type { FC } from 'react' +import { memo, useCallback } from 'react' +import ToolForm from '../ToolForm' +import ToolResponseFormDialog from '../ToolResponseFormDialog' +import ToolResponses from '../ToolResponses' +import { deleteTool } from '../indexeddb/operations' +import type { ToolInfo } from '../indexeddb/types' + +interface ToolProps { + tool: ToolInfo +} + +const ToolItem: FC<ToolProps> = ({ tool }) => { + const { notify } = useNotifyContext() + + const handleDeleteButton = useCallback( + async (toolInfo: ToolInfo) => { + try { + await deleteTool(toolInfo.id) + } catch (err) { + notify(`Failed to delete tool '${toolInfo.name}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + return ( + <Card style={{ flexDirection: 'column' }}> + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + padding: '0rem 1rem 1rem 0', + }} + > + <span style={{ height: '100%', flexGrow: 1 }}>{tool.name}</span> + <ButtonGroup> + <ToolForm + toolInfo={tool} + buttonProps={{ + minimal: true, + icon: 'edit', + style: { marginRight: '1rem' }, + }} + /> + <Button + minimal + icon='cross' + onClick={() => handleDeleteButton(tool)} + /> + </ButtonGroup> + </div> + <div style={{ width: '100%', paddingLeft: '2rem' }}> + <ToolResponses toolId={tool.id} /> + <ToolResponseFormDialog + toolId={tool.id} + buttonProps={{ + minimal: true, + text: 'Add tool response', + alignText: 'left', + icon: 'plus', + style: { padding: '1rem', width: '100%' }, + }} + /> + </div> + </Card> + ) +} + +export default memo(ToolItem) diff --git a/frontend/src/editor/Tools/index.tsx b/frontend/src/editor/Tools/index.tsx new file mode 100644 index 000000000..ef1a97342 --- /dev/null +++ b/frontend/src/editor/Tools/index.tsx @@ -0,0 +1,18 @@ +import { db } from '@/editor/indexeddb/db' +import type { ToolInfo } from '@/editor/indexeddb/types' +import { CardList } from '@blueprintjs/core' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo } from 'react' +import Tool from './Tool' + +const Tools = () => { + const tools = useLiveQuery(() => db.tools.toArray(), [], []) + + return ( + <CardList> + {tools?.map((tool: ToolInfo) => <Tool key={tool.id} tool={tool} />)} + </CardList> + ) +} + +export default memo(Tools) diff --git a/frontend/src/editor/indexeddb/types.tsx b/frontend/src/editor/indexeddb/types.tsx index d80b9e54c..d308ae7f2 100644 --- a/frontend/src/editor/indexeddb/types.tsx +++ b/frontend/src/editor/indexeddb/types.tsx @@ -41,7 +41,7 @@ export type ToolInfo = { export type ToolResponse = { id: number - learningActivityId: number + learningActivityId?: number toolId: number parameter: string isRegex: boolean @@ -52,7 +52,7 @@ export type EmailAddressInfo = Omit<EmailAddress, 'control'> export type EmailTemplate = { id: number - learningActivityId: number + learningActivityId?: number emailAddressId: number context: string content: string diff --git a/frontend/src/pages/editor/create/emails.tsx b/frontend/src/pages/editor/create/emails.tsx new file mode 100644 index 000000000..afac8110e --- /dev/null +++ b/frontend/src/pages/editor/create/emails.tsx @@ -0,0 +1,26 @@ +import EditorPage from '@/editor/EditorPage' +import EmailAddressForm from '@/editor/EmailAddressForm' +import EmailAddresses from '@/editor/EmailAddresses' +import { memo } from 'react' + +const EmailAddressesPage = () => ( + <EditorPage + title='Define email addresses' + description='Description.' + prevPath='/editor/create/other' + nextVisible={false} + > + <EmailAddresses /> + <EmailAddressForm + buttonProps={{ + minimal: true, + text: 'Add email address', + alignText: 'left', + icon: 'plus', + style: { padding: '1rem', width: '100%' }, + }} + /> + </EditorPage> +) + +export default memo(EmailAddressesPage) diff --git a/frontend/src/pages/editor/create/final-information.tsx b/frontend/src/pages/editor/create/final-information.tsx index 5140d3c6f..44e199ef3 100644 --- a/frontend/src/pages/editor/create/final-information.tsx +++ b/frontend/src/pages/editor/create/final-information.tsx @@ -9,7 +9,7 @@ const FinalInformationPage = () => { <EditorPage title='Final information' description='Description.' - prevPath='/editor/create/activity-specification' + prevPath='/editor/create/other' nextPath='/editor/create/conclusion' nextDisabled={nextDisabled} > diff --git a/frontend/src/pages/editor/create/inject-specification/index.tsx b/frontend/src/pages/editor/create/inject-specification/index.tsx index 8dc412ee9..a76a44b36 100644 --- a/frontend/src/pages/editor/create/inject-specification/index.tsx +++ b/frontend/src/pages/editor/create/inject-specification/index.tsx @@ -7,8 +7,7 @@ const ActivitiesSpecificationPage = () => ( title='Define injects' description='Description.' prevPath='/editor/create/activity-specification' - nextPath='/editor' - nextDisabled + nextPath='/editor/create/other' > <InjectsOverview /> </EditorPage> diff --git a/frontend/src/pages/editor/create/other.tsx b/frontend/src/pages/editor/create/other.tsx new file mode 100644 index 000000000..ce24da83b --- /dev/null +++ b/frontend/src/pages/editor/create/other.tsx @@ -0,0 +1,28 @@ +import EditorPage from '@/editor/EditorPage' +import { useNavigate } from '@/router' +import { Card, CardList } from '@blueprintjs/core' +import { memo } from 'react' + +const OtherPage = () => { + const nav = useNavigate() + + return ( + <EditorPage + title='Define other information' + description='Add new tools and email addresses to your exercise.' + prevPath='/editor/create/inject-specification' + nextPath='/editor/create/final-information' + > + <CardList> + <Card interactive onClick={() => nav(`/editor/create/tools`)}> + Tools + </Card> + <Card interactive onClick={() => nav(`/editor/create/emails`)}> + Email addresses + </Card> + </CardList> + </EditorPage> + ) +} + +export default memo(OtherPage) diff --git a/frontend/src/pages/editor/create/tools.tsx b/frontend/src/pages/editor/create/tools.tsx new file mode 100644 index 000000000..c5ba6977e --- /dev/null +++ b/frontend/src/pages/editor/create/tools.tsx @@ -0,0 +1,26 @@ +import EditorPage from '@/editor/EditorPage' +import ToolForm from '@/editor/ToolForm' +import Tools from '@/editor/Tools' +import { memo } from 'react' + +const ToolsPage = () => ( + <EditorPage + title='Define tools' + description='Description.' + prevPath='/editor/create/other' + nextVisible={false} + > + <Tools /> + <ToolForm + buttonProps={{ + minimal: true, + text: 'Add tool', + alignText: 'left', + icon: 'plus', + style: { padding: '1rem', width: '100%' }, + }} + /> + </EditorPage> +) + +export default memo(ToolsPage) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index bc19fedf1..b2ef20e1b 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -16,6 +16,7 @@ export type Path = | `/editor/create/activity-specification` | `/editor/create/activity-specification/:activityId` | `/editor/create/conclusion` + | `/editor/create/emails` | `/editor/create/exercise-information` | `/editor/create/final-information` | `/editor/create/inject-specification` @@ -23,6 +24,8 @@ export type Path = | `/editor/create/injects` | `/editor/create/introduction` | `/editor/create/learning-objectives` + | `/editor/create/other` + | `/editor/create/tools` | `/exercise-panel` | `/exercise-panel/definition/:definitionId` | `/exercise-panel/exercise/:exerciseId` -- GitLab