From 22c9fe343482c19e9e8c6241b57c3a77c2b37335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Katar=C3=ADna=20Platkov=C3=A1?= <xplatkov@fi.muni.cz> Date: Wed, 7 Aug 2024 08:21:20 +0200 Subject: [PATCH] Editor - activity forms --- .../src/editor/EmailAddressForm/index.tsx | 170 ++++++++++++++++ frontend/src/editor/InjectForm/index.tsx | 6 +- .../LearningActivitiesOverview/index.tsx | 40 ++++ .../src/editor/LearningActivityForm/index.tsx | 6 +- .../EmailAddressSelector.tsx | 59 ++++++ .../EmailTemplateForm.tsx | 100 +++++++++ .../ToolResponseForm.tsx | 114 +++++++++++ .../ToolSelector.tsx | 56 +++++ .../LearningActivitySpecification/index.tsx | 61 ++++++ .../editor/LearningObjectiveForm/index.tsx | 2 +- frontend/src/editor/Navbar/index.tsx | 4 + frontend/src/editor/ToolForm/index.tsx | 191 ++++++++++++++++++ frontend/src/editor/indexeddb/db.tsx | 13 ++ frontend/src/editor/indexeddb/operations.tsx | 69 +++++++ frontend/src/editor/indexeddb/types.tsx | 29 +++ .../[activityId]/index.tsx | 49 +++++ .../create/activity-specification/index.tsx | 44 ++++ frontend/src/router.ts | 3 + 18 files changed, 1009 insertions(+), 7 deletions(-) create mode 100644 frontend/src/editor/EmailAddressForm/index.tsx create mode 100644 frontend/src/editor/LearningActivitiesOverview/index.tsx create mode 100644 frontend/src/editor/LearningActivitySpecification/EmailAddressSelector.tsx create mode 100644 frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx create mode 100644 frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx create mode 100644 frontend/src/editor/LearningActivitySpecification/ToolSelector.tsx create mode 100644 frontend/src/editor/LearningActivitySpecification/index.tsx create mode 100644 frontend/src/editor/ToolForm/index.tsx create mode 100644 frontend/src/pages/editor/create/activity-specification/[activityId]/index.tsx create mode 100644 frontend/src/pages/editor/create/activity-specification/index.tsx diff --git a/frontend/src/editor/EmailAddressForm/index.tsx b/frontend/src/editor/EmailAddressForm/index.tsx new file mode 100644 index 000000000..35cc8d43c --- /dev/null +++ b/frontend/src/editor/EmailAddressForm/index.tsx @@ -0,0 +1,170 @@ +import { + addEmailAddress, + updateEmailAddress, +} from '@/editor/indexeddb/operations' +import type { ButtonProps } from '@blueprintjs/core' +import { + Button, + Checkbox, + Dialog, + DialogBody, + DialogFooter, + InputGroup, + Label, +} 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 { EmailAddressInfo } from '../indexeddb/types' + +interface EmailAddressFormProps { + emailAddressInfo?: EmailAddressInfo + buttonProps: ButtonProps + onAdd?: (id: number) => void +} + +const EmailAddressForm: FC<EmailAddressFormProps> = ({ + emailAddressInfo, + buttonProps, + onAdd, +}) => { + const [isOpen, setIsOpen] = useState(false) + const [address, setAddress] = useState<string>('') + const [organization, setOrganization] = useState<string>('') + const [description, setDescription] = useState<string>('') + const [teamVisible, setTeamVisible] = useState<boolean>(true) + + const { notify } = useNotifyContext() + + const clearInput = useCallback(() => { + setAddress('') + setOrganization('') + setDescription('') + setTeamVisible(true) + }, []) + + const handleAddButton = useCallback( + async (emailAddress: Omit<EmailAddressInfo, 'id'>) => { + try { + const id = await addEmailAddress(emailAddress) + if (onAdd) onAdd(Number(id)) + clearInput() + setIsOpen(false) + } catch (err) { + notify( + `Failed to add email address '${emailAddress.address}': ${err}`, + { + intent: 'danger', + } + ) + } + }, + [notify] + ) + + const handleUpdateButton = useCallback( + async (emailAddress: EmailAddressInfo) => { + try { + await updateEmailAddress(emailAddress) + setIsOpen(false) + } catch (err) { + notify( + `Failed to update email address '${emailAddress.address}': ${err}`, + { + intent: 'danger', + } + ) + } + }, + [notify] + ) + + useEffect(() => { + setAddress(emailAddressInfo?.address || '') + setOrganization(emailAddressInfo?.organization || '') + setDescription(emailAddressInfo?.description || '') + setTeamVisible(emailAddressInfo?.teamVisible ?? true) + }, [emailAddressInfo]) + + return ( + <> + <Button {...buttonProps} onClick={() => setIsOpen(true)} /> + <Dialog + isOpen={isOpen} + onClose={() => setIsOpen(false)} + icon={emailAddressInfo ? 'edit' : 'plus'} + title={emailAddressInfo ? 'Edit email address' : 'New email address'} + > + <DialogBody> + <Label> + Address + <InputGroup + placeholder='Input text' + value={address} + onChange={e => setAddress(e.target.value)} + /> + </Label> + <Label> + Organization + <InputGroup + placeholder='Input text' + value={organization} + onChange={e => setOrganization(e.target.value)} + /> + </Label> + <Label> + Description + <InputGroup + placeholder='Input text' + value={description} + onChange={e => setDescription(e.target.value)} + /> + </Label> + <Checkbox + label='Team visible' + checked={teamVisible} + onChange={e => setTeamVisible(e.target.checked)} + /> + </DialogBody> + <DialogFooter + actions={ + emailAddressInfo ? ( + <Button + disabled={!address || !description} + onClick={() => + handleUpdateButton({ + id: emailAddressInfo.id, + address, + organization, + description, + teamVisible, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + ) : ( + <Button + disabled={!address || !description} + onClick={() => + handleAddButton({ + address, + organization, + description, + teamVisible, + }) + } + intent='primary' + icon='plus' + text='Add' + /> + ) + } + /> + </Dialog> + </> + ) +} + +export default memo(EmailAddressForm) diff --git a/frontend/src/editor/InjectForm/index.tsx b/frontend/src/editor/InjectForm/index.tsx index fec430db0..b6b79f501 100644 --- a/frontend/src/editor/InjectForm/index.tsx +++ b/frontend/src/editor/InjectForm/index.tsx @@ -80,7 +80,7 @@ const InjectForm: FC<InjectFormProps> = ({ inject, buttonProps }) => { title={inject ? 'Edit inject' : 'New inject'} > <DialogBody> - <Label style={{ width: '100%' }}> + <Label> Title <InputGroup placeholder='Input text' @@ -88,7 +88,7 @@ const InjectForm: FC<InjectFormProps> = ({ inject, buttonProps }) => { onChange={e => setName(e.target.value)} /> </Label> - <Label style={{ width: '100%' }}> + <Label> What is this inject about? (optional) <TextArea value={description} @@ -102,7 +102,7 @@ const InjectForm: FC<InjectFormProps> = ({ inject, buttonProps }) => { onChange={e => setDescription(e.target.value)} /> </Label> - <Label style={{ width: '100%' }}> + <Label> Channel <HTMLSelect options={INJECT_TYPES} diff --git a/frontend/src/editor/LearningActivitiesOverview/index.tsx b/frontend/src/editor/LearningActivitiesOverview/index.tsx new file mode 100644 index 000000000..c973fec92 --- /dev/null +++ b/frontend/src/editor/LearningActivitiesOverview/index.tsx @@ -0,0 +1,40 @@ +import { db } from '@/editor/indexeddb/db' +import type { LearningActivityInfo } 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 { getLearningActivityIcon } from '../utils' + +const LearningActivitiesOverview = () => { + const learningActivities = useLiveQuery( + () => db.learningActivities.toArray(), + [], + [] + ) + const nav = useNavigate() + + return ( + <CardList> + {learningActivities?.map((activity: LearningActivityInfo) => ( + <Card + interactive + key={activity.id} + onClick={() => + nav(`/editor/create/activity-specification/:activityId`, { + params: { activityId: activity.id.toString() }, + }) + } + > + <Icon + icon={getLearningActivityIcon(activity)} + style={{ marginRight: '1rem' }} + /> + {activity.name} + </Card> + ))} + </CardList> + ) +} + +export default memo(LearningActivitiesOverview) diff --git a/frontend/src/editor/LearningActivityForm/index.tsx b/frontend/src/editor/LearningActivityForm/index.tsx index be724dd8c..1db685f7f 100644 --- a/frontend/src/editor/LearningActivityForm/index.tsx +++ b/frontend/src/editor/LearningActivityForm/index.tsx @@ -95,7 +95,7 @@ const LearningActivityForm: FC<LearningActivityFormProps> = ({ } > <DialogBody> - <Label style={{ width: '100%' }}> + <Label> Title <InputGroup placeholder='Input text' @@ -103,7 +103,7 @@ const LearningActivityForm: FC<LearningActivityFormProps> = ({ onChange={e => setName(e.target.value)} /> </Label> - <Label style={{ width: '100%' }}> + <Label> What is this activity about? (optional) <TextArea value={description} @@ -117,7 +117,7 @@ const LearningActivityForm: FC<LearningActivityFormProps> = ({ onChange={e => setDescription(e.target.value)} /> </Label> - <Label style={{ width: '100%' }}> + <Label> Channel <HTMLSelect options={LEARNING_ACTIVITY_TYPES} diff --git a/frontend/src/editor/LearningActivitySpecification/EmailAddressSelector.tsx b/frontend/src/editor/LearningActivitySpecification/EmailAddressSelector.tsx new file mode 100644 index 000000000..bd3d25631 --- /dev/null +++ b/frontend/src/editor/LearningActivitySpecification/EmailAddressSelector.tsx @@ -0,0 +1,59 @@ +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 EmailAddressForm from '../EmailAddressForm' +import { db } from '../indexeddb/db' +import type { EmailAddressInfo } from '../indexeddb/types' + +interface EmailAddressFormProps { + emailAddressId: number + onChange: (id: number) => void +} + +const EmailAddressSelector: FC<EmailAddressFormProps> = ({ + emailAddressId, + onChange, +}) => { + const emailAddresses = useLiveQuery(() => db.emailAddresses.toArray(), [], []) + + const emailAddressOptions: OptionProps[] = useMemo(() => { + if (emailAddresses === undefined || emailAddresses.length === 0) { + return [ + { + label: 'No email addresses', + value: 0, + disabled: true, + }, + ] + } + + return emailAddresses?.map((emailAddress: EmailAddressInfo) => ({ + value: emailAddress.id, + label: emailAddress.address, + })) + }, [emailAddresses]) + + return ( + <div style={{ display: 'flex', width: '100%' }}> + <Label style={{ flexGrow: '1' }}> + Address + <HTMLSelect + options={emailAddressOptions} + value={emailAddressId} + onChange={event => onChange(Number(event.currentTarget.value))} + /> + </Label> + <EmailAddressForm + buttonProps={{ + minimal: true, + icon: 'plus', + style: { marginRight: '1rem' }, + }} + onAdd={addressId => onChange(addressId)} + /> + </div> + ) +} + +export default memo(EmailAddressSelector) diff --git a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx new file mode 100644 index 000000000..9c1e82bb7 --- /dev/null +++ b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx @@ -0,0 +1,100 @@ +import { Button, InputGroup, 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 { + addEmailTemplate, + getEmailTemplateByActivityId, + updateEmailTemplate, +} from '../indexeddb/operations' +import type { EmailTemplate } from '../indexeddb/types' +import EmailAddressSelector from './EmailAddressSelector' + +interface EmailTemplateFormProps { + learningActivityId: number +} + +const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ + learningActivityId, +}) => { + const template = useLiveQuery( + () => getEmailTemplateByActivityId(learningActivityId), + [learningActivityId], + [] + ) as EmailTemplate + + const { notify } = useNotifyContext() + + 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 || 0) + }, [template]) + + const handleUpdateButton = useCallback( + async (newTemplate: EmailTemplate | Omit<EmailTemplate, 'id'>) => { + try { + if (template) { + await updateEmailTemplate({ id: template.id, ...newTemplate }) + } else { + await addEmailTemplate(newTemplate) + } + } catch (err) { + notify(`Failed to update template: ${err}`, { + intent: 'danger', + }) + } + }, + [notify, template] + ) + + return ( + <> + <EmailAddressSelector + emailAddressId={selectedAddressId} + onChange={id => setSelectedAddressId(id)} + /> + <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 + onClick={() => + handleUpdateButton({ + learningActivityId, + context, + content, + emailAddressId: selectedAddressId, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + </> + ) +} + +export default memo(EmailTemplateForm) diff --git a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx new file mode 100644 index 000000000..ac9bae28c --- /dev/null +++ b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx @@ -0,0 +1,114 @@ +import { + Button, + Checkbox, + InputGroup, + 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 { + addToolResponse, + getToolResponseByActivityId, + updateToolResponse, +} from '../indexeddb/operations' +import type { ToolResponse } from '../indexeddb/types' +import ToolSelector from './ToolSelector' + +interface ToolResponseFormProps { + learningActivityId: number +} + +const ToolResponseForm: FC<ToolResponseFormProps> = ({ + learningActivityId, +}) => { + const response = useLiveQuery( + () => getToolResponseByActivityId(learningActivityId), + [learningActivityId], + [] + ) as ToolResponse + + const { notify } = useNotifyContext() + + const [parameter, setParameter] = useState<string>('') + const [content, setContent] = useState<string>('') + const [isRegex, setIsRegex] = useState<boolean>(false) + const [selectedToolId, setSelectedToolId] = useState<number>(0) + + useEffect(() => { + setParameter(response?.parameter || '') + setContent(response?.content || '') + setIsRegex(response?.isRegex || false) + setSelectedToolId(response?.toolId || 0) + }, [response]) + + const handleUpdateButton = useCallback( + async (newResponse: ToolResponse | Omit<ToolResponse, 'id'>) => { + try { + if (response) { + await updateToolResponse({ id: response.id, ...newResponse }) + } else { + await addToolResponse(newResponse) + } + } catch (err) { + notify(`Failed to update tool response: ${err}`, { + intent: 'danger', + }) + } + }, + [notify, response] + ) + + return ( + <> + <ToolSelector + 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)} + /> + <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 + onClick={() => + handleUpdateButton({ + learningActivityId, + parameter, + content, + isRegex, + toolId: selectedToolId, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + </> + ) +} + +export default memo(ToolResponseForm) diff --git a/frontend/src/editor/LearningActivitySpecification/ToolSelector.tsx b/frontend/src/editor/LearningActivitySpecification/ToolSelector.tsx new file mode 100644 index 000000000..e3a194a2d --- /dev/null +++ b/frontend/src/editor/LearningActivitySpecification/ToolSelector.tsx @@ -0,0 +1,56 @@ +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 ToolForm from '../ToolForm' +import { db } from '../indexeddb/db' +import type { ToolInfo } from '../indexeddb/types' + +interface ToolFormProps { + toolId: number + onChange: (id: number) => void +} + +const ToolSelector: FC<ToolFormProps> = ({ toolId, onChange }) => { + const tools = useLiveQuery(() => db.tools.toArray(), [], []) + + const toolOptions: OptionProps[] = useMemo(() => { + if (tools === undefined || tools.length === 0) { + return [ + { + label: 'No tools', + value: 0, + disabled: true, + }, + ] + } + + return tools?.map((tool: ToolInfo) => ({ + value: tool.id, + label: tool.name, + })) + }, [tools]) + + return ( + <div style={{ display: 'flex', width: '100%' }}> + <Label style={{ flexGrow: '1' }}> + Tool + <HTMLSelect + options={toolOptions} + value={toolId} + onChange={event => onChange(Number(event.currentTarget.value))} + /> + </Label> + <ToolForm + buttonProps={{ + minimal: true, + icon: 'plus', + style: { marginRight: '1rem' }, + }} + onAdd={toolId => onChange(toolId)} + /> + </div> + ) +} + +export default memo(ToolSelector) diff --git a/frontend/src/editor/LearningActivitySpecification/index.tsx b/frontend/src/editor/LearningActivitySpecification/index.tsx new file mode 100644 index 000000000..8a2ee6040 --- /dev/null +++ b/frontend/src/editor/LearningActivitySpecification/index.tsx @@ -0,0 +1,61 @@ +import { Divider, NonIdealState } from '@blueprintjs/core' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo, type FC } from 'react' +import LearningActivityForm from '../LearningActivityForm' +import { getLearningActivityById } from '../indexeddb/operations' +import type { LearningActivityInfo } from '../indexeddb/types' +import { LearningActivityType } from '../indexeddb/types' +import EmailTemplateForm from './EmailTemplateForm' +import ToolResponseForm from './ToolResponseForm' + +interface LearningActivitySpecificationProps { + learningActivityId: number +} + +const LearningActivitySpecification: FC<LearningActivitySpecificationProps> = ({ + learningActivityId, +}) => { + const activity = useLiveQuery( + () => getLearningActivityById(Number(learningActivityId)), + [learningActivityId], + [] + ) as LearningActivityInfo + + if (activity === undefined) { + return ( + <NonIdealState + icon='low-voltage-pole' + title='No activity' + description='Activity not found' + /> + ) + } + + return ( + <div> + <div> + <p>Name: {activity.name}</p> + <p>Description: {activity.description}</p> + <p>Type: {activity.type}</p> + <LearningActivityForm + learningActivity={activity} + learningObjectiveId={activity.learningObjectiveId} + buttonProps={{ + text: 'Edit activity', + icon: 'edit', + style: { marginRight: '1rem' }, + }} + /> + </div> + <Divider style={{ margin: '1rem 0' }} /> + {activity.type === LearningActivityType.TOOL && ( + <ToolResponseForm learningActivityId={learningActivityId} /> + )} + {activity.type === LearningActivityType.EMAIL && ( + <EmailTemplateForm learningActivityId={learningActivityId} /> + )} + </div> + ) +} + +export default memo(LearningActivitySpecification) diff --git a/frontend/src/editor/LearningObjectiveForm/index.tsx b/frontend/src/editor/LearningObjectiveForm/index.tsx index d6404b67e..a69c9f8af 100644 --- a/frontend/src/editor/LearningObjectiveForm/index.tsx +++ b/frontend/src/editor/LearningObjectiveForm/index.tsx @@ -83,7 +83,7 @@ const LearningObjectiveForm: FC<LearningObjectiveFormProps> = ({ } > <DialogBody> - <Label style={{ width: '100%' }}> + <Label> Title <InputGroup placeholder='Input text' diff --git a/frontend/src/editor/Navbar/index.tsx b/frontend/src/editor/Navbar/index.tsx index 8a9d8b32d..bc92caaab 100644 --- a/frontend/src/editor/Navbar/index.tsx +++ b/frontend/src/editor/Navbar/index.tsx @@ -8,6 +8,10 @@ const Navbar = () => ( name='Learning objectives' /> <NavbarButton path='/editor/create/injects' name='Injects' /> + <NavbarButton + path='/editor/create/activity-specification' + name='Activities' + /> </div> ) diff --git a/frontend/src/editor/ToolForm/index.tsx b/frontend/src/editor/ToolForm/index.tsx new file mode 100644 index 000000000..1b0b96a9e --- /dev/null +++ b/frontend/src/editor/ToolForm/index.tsx @@ -0,0 +1,191 @@ +import { addTool, updateTool } from '@/editor/indexeddb/operations' +import type { ButtonProps } from '@blueprintjs/core' +import { + Button, + Dialog, + DialogBody, + DialogFooter, + InputGroup, + Label, + TextArea, +} 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 { ToolInfo } from '../indexeddb/types' + +interface ToolFormProps { + toolInfo?: ToolInfo + buttonProps: ButtonProps + onAdd?: (id: number) => void +} + +const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { + const [isOpen, setIsOpen] = useState(false) + const [name, setName] = useState<string>('') + const [category, setCategory] = useState<string>('') + const [tooltipDescription, setTooltipDescription] = useState<string>('') + const [hint, setHint] = useState<string>('') + const [defaultResponse, setDefaultResponse] = useState<string>('') + + const { notify } = useNotifyContext() + + const clearInput = useCallback(() => { + setName('') + setCategory('') + setTooltipDescription('') + setHint('') + setDefaultResponse('') + }, []) + + const handleAddButton = useCallback( + async (tool: Omit<ToolInfo, 'id'>) => { + try { + const id = await addTool(tool) + if (onAdd) onAdd(Number(id)) + clearInput() + setIsOpen(false) + } catch (err) { + notify(`Failed to add tool '${tool.name}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + const handleUpdateButton = useCallback( + async (tool: ToolInfo) => { + try { + await updateTool(tool) + setIsOpen(false) + } catch (err) { + notify(`Failed to update tool '${tool.name}': ${err}`, { + intent: 'danger', + }) + } + }, + [notify] + ) + + useEffect(() => { + setName(toolInfo?.name || '') + setCategory(toolInfo?.category || '') + setTooltipDescription(toolInfo?.tooltipDescription || '') + setHint(toolInfo?.hint || '') + setDefaultResponse(toolInfo?.defaultResponse || '') + }, [toolInfo]) + + return ( + <> + <Button {...buttonProps} onClick={() => setIsOpen(true)} /> + <Dialog + isOpen={isOpen} + onClose={() => setIsOpen(false)} + icon={toolInfo ? 'edit' : 'plus'} + title={toolInfo ? 'Edit tool' : 'New tool'} + > + <DialogBody> + <Label> + Name + <InputGroup + placeholder='Input text' + value={name} + onChange={e => setName(e.target.value)} + /> + </Label> + <Label> + Category + <InputGroup + placeholder='Input text' + value={category} + onChange={e => setCategory(e.target.value)} + /> + </Label> + <Label> + Tooltip description + <InputGroup + placeholder='Input text' + value={tooltipDescription} + onChange={e => setTooltipDescription(e.target.value)} + /> + </Label> + <Label> + Hint + <InputGroup + placeholder='Input text' + value={hint} + onChange={e => setHint(e.target.value)} + /> + </Label> + <Label> + Default Response + <TextArea + value={defaultResponse} + style={{ + width: '100%', + height: '10rem', + resize: 'none', + overflowY: 'auto', + }} + placeholder='Input text' + onChange={e => setDefaultResponse(e.target.value)} + /> + </Label> + </DialogBody> + <DialogFooter + actions={ + toolInfo ? ( + <Button + disabled={ + !name || + !category || + !tooltipDescription || + !hint || + !defaultResponse + } + onClick={() => + handleUpdateButton({ + id: toolInfo.id, + name, + category, + tooltipDescription, + hint, + defaultResponse, + }) + } + intent='primary' + icon='edit' + text='Save changes' + /> + ) : ( + <Button + disabled={ + !name || + !category || + !tooltipDescription || + !hint || + !defaultResponse + } + onClick={() => + handleAddButton({ + name, + category, + tooltipDescription, + hint, + defaultResponse, + }) + } + intent='primary' + icon='plus' + text='Add' + /> + ) + } + /> + </Dialog> + </> + ) +} + +export default memo(ToolForm) diff --git a/frontend/src/editor/indexeddb/db.tsx b/frontend/src/editor/indexeddb/db.tsx index 99fa33249..a6ec2242e 100644 --- a/frontend/src/editor/indexeddb/db.tsx +++ b/frontend/src/editor/indexeddb/db.tsx @@ -1,8 +1,12 @@ import Dexie, { type EntityTable } from 'dexie' import type { + EmailAddressInfo, + EmailTemplate, InjectInfo, LearningActivityInfo, LearningObjectiveInfo, + ToolInfo, + ToolResponse, } from './types' const dbName = 'EditorDatabase' @@ -12,12 +16,21 @@ const db = new Dexie(dbName) as Dexie & { learningObjectives: EntityTable<LearningObjectiveInfo, 'id'> learningActivities: EntityTable<LearningActivityInfo, 'id'> injectInfos: EntityTable<InjectInfo, 'id'> + tools: EntityTable<ToolInfo, 'id'> + toolResponses: EntityTable<ToolResponse, 'id'> + emailAddresses: EntityTable<EmailAddressInfo, 'id'> + emailTemplates: EntityTable<EmailTemplate, 'id'> } db.version(dbVersion).stores({ learningObjectives: '++id, &name', learningActivities: '++id, &name, description, type, learningObjectiveId', injectInfos: '++id, &name, description, type', + tools: '++id, &name, tooltipDescription, hint, defaultResponse, category', + toolResponses: + '++id, &learningActivityId, toolId, parameter, isRegex, content', // TODO file + emailAddresses: '++id, address, organization, description, teamVisible', + emailTemplates: '++id, &learningActivityId, emailAddressId, context, content', // TODO file }) export { db } diff --git a/frontend/src/editor/indexeddb/operations.tsx b/frontend/src/editor/indexeddb/operations.tsx index cb2722819..cbfb605bb 100644 --- a/frontend/src/editor/indexeddb/operations.tsx +++ b/frontend/src/editor/indexeddb/operations.tsx @@ -1,8 +1,12 @@ import { db } from './db' import type { + EmailAddressInfo, + EmailTemplate, InjectInfo, LearningActivityInfo, LearningObjectiveInfo, + ToolInfo, + ToolResponse, } from './types' // learning objectives operations @@ -29,6 +33,9 @@ export const deleteLearningObjective = async (id: string) => ) // learning activities operations +export const getLearningActivityById = async (id: number) => + await db.learningActivities.get(id) + export const addLearningActivity = async ( activity: Omit<LearningActivityInfo, 'id'> ) => @@ -53,3 +60,65 @@ export const updateInjectInfo = async (injectInfo: InjectInfo) => export const deleteInjectInfo = async (id: number) => await db.injectInfos.delete(id) + +// tool operations +export const addTool = async (tool: Omit<ToolInfo, 'id'>) => + await db.transaction('rw', db.tools, async () => { + const id = await db.tools.add(tool) + return id + }) + +export const updateTool = async (tool: ToolInfo) => await db.tools.put(tool) + +export const deleteTool = async (id: number) => + await db.transaction('rw', db.tools, db.toolResponses, async () => { + await db.tools.delete(id) + await db.toolResponses.where({ toolId: id }).delete() + }) + +// tool response operations +export const getToolResponseByActivityId = async (learningActivityId: number) => + await db.toolResponses.get({ learningActivityId }) + +export const addToolResponse = async (response: Omit<ToolResponse, 'id'>) => + await db.transaction('rw', db.toolResponses, async () => { + await db.toolResponses.add(response) + }) + +export const updateToolResponse = async (response: ToolResponse) => + await db.toolResponses.put(response) + +export const deleteToolResponse = async (id: number) => + await db.toolResponses.delete(id) + +// email address operations +export const addEmailAddress = async (address: Omit<EmailAddressInfo, 'id'>) => + await db.transaction('rw', db.emailAddresses, async () => { + const id = await db.emailAddresses.add(address) + return id + }) + +export const updateEmailAddress = async (address: EmailAddressInfo) => + await db.emailAddresses.put(address) + +export const deleteEmailAddress = async (id: string) => + await db.transaction('rw', db.emailAddresses, db.emailTemplates, async () => { + await db.emailAddresses.delete(id) + await db.emailTemplates.where({ emailAddressId: id }).delete() + }) + +// email template operations +export const getEmailTemplateByActivityId = async ( + learningActivityId: number +) => await db.emailTemplates.get({ learningActivityId }) + +export const addEmailTemplate = async (template: Omit<EmailTemplate, 'id'>) => + await db.transaction('rw', db.emailTemplates, async () => { + await db.emailTemplates.add(template) + }) + +export const updateEmailTemplate = async (template: EmailTemplate) => + await db.emailTemplates.put(template) + +export const deleteEmailTemplate = async (id: number) => + await db.emailTemplates.delete(id) diff --git a/frontend/src/editor/indexeddb/types.tsx b/frontend/src/editor/indexeddb/types.tsx index ebd203a39..c8fa15a1f 100644 --- a/frontend/src/editor/indexeddb/types.tsx +++ b/frontend/src/editor/indexeddb/types.tsx @@ -1,3 +1,4 @@ +import type { EmailAddress } from '@inject/graphql/fragments/EmailAddress.generated' import type { LearningObjective } from '@inject/graphql/fragments/LearningObjective.generated' export enum LearningActivityType { @@ -27,3 +28,31 @@ export type InjectInfo = { description: string type: InjectType } + +export type ToolInfo = { + id: number + name: string + category: string + tooltipDescription: string + defaultResponse: string + hint: string +} + +export type ToolResponse = { + id: number + learningActivityId: number + toolId: number + parameter: string + isRegex: boolean + content: string +} + +export type EmailAddressInfo = Omit<EmailAddress, 'control'> + +export type EmailTemplate = { + id: number + learningActivityId: number + emailAddressId: number + context: string + content: string +} diff --git a/frontend/src/pages/editor/create/activity-specification/[activityId]/index.tsx b/frontend/src/pages/editor/create/activity-specification/[activityId]/index.tsx new file mode 100644 index 000000000..acc88f18d --- /dev/null +++ b/frontend/src/pages/editor/create/activity-specification/[activityId]/index.tsx @@ -0,0 +1,49 @@ +import LearningActivitySpecification from '@/editor/LearningActivitySpecification' +import { useNavigate, useParams } from '@/router' +import { Button } from '@blueprintjs/core' +import { css } from '@emotion/css' +import { memo } from 'react' + +const specificationPage = css` + display: grid; + grid-template-rows: auto auto 1fr auto; + height: 100vh; +` + +const ActivityDefinitionPage = () => { + const { activityId } = useParams( + '/editor/create/activity-specification/:activityId' + ) + const nav = useNavigate() + + return ( + <div className={specificationPage}> + <h1>Define activity</h1> + <p style={{ marginBottom: '1rem' }}>Description.</p> + <div style={{ overflowY: 'auto' }}> + <LearningActivitySpecification + learningActivityId={Number(activityId)} + /> + </div> + <div + style={{ + display: 'flex', + justifyContent: 'center', + padding: '0.5rem 0', + }} + > + <Button + type='button' + onClick={() => nav('/editor/create/activity-specification')} + text='Back' + icon='arrow-left' + style={{ + marginRight: '0.5rem', + }} + /> + </div> + </div> + ) +} + +export default memo(ActivityDefinitionPage) diff --git a/frontend/src/pages/editor/create/activity-specification/index.tsx b/frontend/src/pages/editor/create/activity-specification/index.tsx new file mode 100644 index 000000000..a38ec15dc --- /dev/null +++ b/frontend/src/pages/editor/create/activity-specification/index.tsx @@ -0,0 +1,44 @@ +import LearningActivitiesOverview from '@/editor/LearningActivitiesOverview' +import { Button } from '@blueprintjs/core' +import { css } from '@emotion/css' +import { memo } from 'react' + +const specificationPage = css` + display: grid; + grid-template-rows: auto auto 1fr auto; + height: 100vh; +` + +const ActivitiesSpecificationPage = () => ( + <div className={specificationPage}> + <h1>Define activities</h1> + <p style={{ marginBottom: '1rem' }}>Description.</p> + <div style={{ overflowY: 'auto' }}> + <LearningActivitiesOverview /> + </div> + <div + style={{ + display: 'flex', + justifyContent: 'center', + padding: '0.5rem 0', + }} + > + <Button + type='button' + text='Back' + icon='arrow-left' + style={{ + marginRight: '0.5rem', + }} + /> + <Button + type='button' + text='Continue' + intent='primary' + rightIcon='arrow-right' + /> + </div> + </div> +) + +export default memo(ActivitiesSpecificationPage) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 471eca80f..52dfcb86c 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -13,6 +13,8 @@ export type Path = | `/analyst/:exerciseId/milestones` | `/analyst/:exerciseId/tools` | `/editor` + | `/editor/create/activity-specification` + | `/editor/create/activity-specification/:activityId` | `/editor/create/injects` | `/editor/create/introduction` | `/editor/create/learning-objectives` @@ -55,6 +57,7 @@ export type Params = { '/analyst/:exerciseId/emails/:tab/:threadId': { exerciseId: string; tab: string; threadId: string } '/analyst/:exerciseId/milestones': { exerciseId: string } '/analyst/:exerciseId/tools': { exerciseId: string } + '/editor/create/activity-specification/:activityId': { activityId: string } '/instructor/:exerciseId/:teamId': { exerciseId: string; teamId: string } '/instructor/:exerciseId/:teamId/:channelId/email/:tab': { exerciseId: string; teamId: string; channelId: string; tab: string } '/instructor/:exerciseId/:teamId/:channelId/email/:tab/:threadId': { exerciseId: string; teamId: string; channelId: string; tab: string; threadId: string } -- GitLab