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