diff --git a/frontend/src/editor/EmailTemplateFormDialog/index.tsx b/frontend/src/editor/EmailTemplateFormDialog/index.tsx index 6ee86c7274295357e8efe1b609990d73b63e7dd9..02ab20f975f92c03a0f737e7ee306383e72591da 100644 --- a/frontend/src/editor/EmailTemplateFormDialog/index.tsx +++ b/frontend/src/editor/EmailTemplateFormDialog/index.tsx @@ -98,6 +98,7 @@ const EmailTemplateFormDialog: FC<EmailTemplateFormDialogProps> = ({ onClick={() => handleUpdateButton({ id: template.id, + learningActivityId: template.learningActivityId, context, content, emailAddressId: selectedAddressId, diff --git a/frontend/src/editor/ExpressionBuilder/ExpressionBlock.tsx b/frontend/src/editor/ExpressionBuilder/ExpressionBlock.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9667555b93d99e902d888ddf9e48551826bb34fe --- /dev/null +++ b/frontend/src/editor/ExpressionBuilder/ExpressionBlock.tsx @@ -0,0 +1,88 @@ +import type { OptionProps } from '@blueprintjs/core' +import { Button, HTMLSelect } from '@blueprintjs/core' +import { css } from '@emotion/css' +import { isEqual } from 'lodash' +import { memo, type FC } from 'react' +import { CLOSING_BRACKET, NOT, OPENING_BRACKET, OPERATORS } from '../utils' + +const expressionBlock = css` + display: flex; + align-items: start; + position: relative; + padding-top: 0.5rem; +` + +const simpleLabel = css` + height: 100%; + display: flex; + align-items: center; + justify-content: space-around; + padding: 0.4rem 1rem 0.4rem 0.5rem; +` + +const cancelButton = css` + position: absolute; + top: 0; + right: -0.5rem; +` + +interface ExpressionBlockProps { + variables: OptionProps[] + block: OptionProps + onRemove: () => void + onModify: (block: OptionProps) => void +} + +const ExpressionBlock: FC<ExpressionBlockProps> = ({ + variables, + block, + onRemove, + onModify, +}) => ( + <div className={expressionBlock}> + {variables.find(value => isEqual(value, block)) && ( + <HTMLSelect + minimal + options={variables} + value={block.value} + onChange={event => { + const selectedOption = event.currentTarget.selectedOptions[0] + onModify({ + value: selectedOption.value, + label: selectedOption.label, + }) + }} + iconName='caret-down' + /> + )} + {OPERATORS.find(value => isEqual(value, block)) && ( + <HTMLSelect + minimal + options={OPERATORS} + value={block.value} + onChange={event => { + const selectedOption = event.currentTarget.selectedOptions[0] + onModify({ + value: selectedOption.value, + label: selectedOption.label, + }) + }} + iconName='caret-down' + /> + )} + {(isEqual(block, NOT) || + isEqual(block, OPENING_BRACKET) || + isEqual(block, CLOSING_BRACKET)) && ( + <span className={simpleLabel}>{block.label}</span> + )} + <Button + small + onClick={onRemove} + icon='cross' + minimal + className={cancelButton} + /> + </div> +) + +export default memo(ExpressionBlock) diff --git a/frontend/src/editor/ExpressionBuilder/index.tsx b/frontend/src/editor/ExpressionBuilder/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..874160c27536192cb2bee36c9165f52732770786 --- /dev/null +++ b/frontend/src/editor/ExpressionBuilder/index.tsx @@ -0,0 +1,234 @@ +import type { OptionProps } from '@blueprintjs/core' +import { Button, Callout, HTMLSelect, Icon } from '@blueprintjs/core' +import { css } from '@emotion/css' +import notEmpty from '@inject/shared/utils/notEmpty' +import { useLiveQuery } from 'dexie-react-hooks' +import { isEqual } from 'lodash' +import { memo, useCallback, useEffect, useMemo, useState, type FC } from 'react' +import { getMilestonesWithNames } from '../indexeddb/operations' +import { + CLOSING_BRACKET, + DEFAULT_OPTION, + NOT, + OPENING_BRACKET, + OPERATORS, + getBlockFromId, + getMilestoneName, + validateExpression, +} from '../utils' +import ExpressionBlock from './ExpressionBlock' + +const condition = css` + display: flex; + align-items: start; + margin-bottom: 1rem; +` + +const expressionArea = css` + display: flex; + flex-wrap: wrap; + align-items: center; + padding: 0 0.5rem 0.5rem; + margin-right: 0.5rem; +` + +const nextBlock = css` + margin-left: 0.5rem; + padding-top: 0.5rem; +` + +const errorMessage = css` + display: flex; + align-items: center; + margin-bottom: 1rem; +` + +interface ExpressionBuilderProps { + initExpression?: number[] + onExpressionChange: (value: number[]) => void +} + +const ExpressionBuilder: FC<ExpressionBuilderProps> = ({ + initExpression, + onExpressionChange, +}) => { + const milestones = useLiveQuery(() => getMilestonesWithNames(), [], []) + + const [expression, setExpression] = useState<OptionProps[]>([]) + const [openBrackets, setOpenBrackets] = useState(0) + const [lastBlock, setLastBlock] = useState<OptionProps | undefined>(undefined) + const [option, setOption] = useState<OptionProps>(DEFAULT_OPTION) + + const variables = useMemo( + () => + milestones + .map(milestone => + milestone + ? { + value: milestone.id.toString(), + label: getMilestoneName( + milestone.id, + milestone.type, + milestone.name + ), + } + : null + ) + .filter(notEmpty), + [milestones] + ) + + useEffect(() => { + if (variables.length > 0 && initExpression && initExpression.length > 0) { + const newExpression = initExpression + .map(id => getBlockFromId(id, variables)) + .filter(notEmpty) + if (newExpression.length > 0) { + setExpression(newExpression) + setLastBlock(newExpression[newExpression.length - 1]) + } + } + }, [variables, initExpression]) + + useEffect(() => { + onExpressionChange(expression?.map(block => Number(block.value))) + }, [expression]) + + const clearExpression = useCallback(() => { + setExpression([]) + setOpenBrackets(0) + setLastBlock(undefined) + }, []) + + const updateBrackets = useCallback( + (block: OptionProps, increment: number) => { + if (isEqual(block, OPENING_BRACKET)) { + setOpenBrackets(openBrackets + increment) + } else if (isEqual(block, CLOSING_BRACKET)) { + setOpenBrackets(openBrackets - increment) + } + }, + [openBrackets] + ) + + const addBlock = useCallback( + (block: OptionProps) => { + if (isEqual(block, DEFAULT_OPTION)) { + return + } + setExpression([...expression, block]) + updateBrackets(block, 1) + setOption(DEFAULT_OPTION) + setLastBlock(block) + }, + [expression] + ) + + const removeBlock = useCallback( + (index: number) => { + if (index === expression.length - 1) { + const prevBlock = + expression.length > 1 ? expression[expression.length - 2] : undefined + setLastBlock(prevBlock) + } + const block = expression[index] + setExpression(expression.filter((_, i) => i !== index)) + updateBrackets(block, -1) + }, + [expression] + ) + + const modifyBlock = useCallback( + (index: number, newBlock: OptionProps) => { + if (index === expression.length - 1) { + setLastBlock(newBlock) + } + const oldBlock = expression[index] + const newExpression = [...expression] + newExpression[index] = newBlock + setExpression(newExpression) + updateBrackets(oldBlock, -1) + updateBrackets(newBlock, 1) + }, + [expression] + ) + + const options = useMemo(() => { + if ( + expression.length === 0 || + OPERATORS.find(value => isEqual(value, lastBlock)) || + isEqual(lastBlock, OPENING_BRACKET) + ) { + return [...variables, OPENING_BRACKET, NOT] + } else if (isEqual(lastBlock, NOT)) { + return [...variables, OPENING_BRACKET] + } else if ( + variables.find(value => isEqual(value, lastBlock)) || + isEqual(lastBlock, CLOSING_BRACKET) + ) { + return [...OPERATORS, ...(openBrackets > 0 ? [CLOSING_BRACKET] : [])] + } + return [] + }, [expression, variables]) + + const { isValid, error } = useMemo( + () => validateExpression(expression, variables), + [expression, variables] + ) + + return ( + <div style={{ marginBottom: '1rem' }}> + <p>Condition</p> + <div className={condition}> + <Callout + className={expressionArea} + icon={null} + intent={isValid ? 'none' : 'danger'} + > + {expression.map((block, index) => ( + <ExpressionBlock + key={index} + variables={variables} + block={block} + onRemove={() => removeBlock(index)} + onModify={newBlock => modifyBlock(index, newBlock)} + /> + ))} + <div className={nextBlock}> + <HTMLSelect + options={[DEFAULT_OPTION, ...options]} + value={option.value} + onChange={event => { + const selectedOption = event.currentTarget.selectedOptions[0] + addBlock({ + value: selectedOption.value, + label: selectedOption.label, + }) + }} + /> + </div> + </Callout> + <Button + onClick={clearExpression} + disabled={expression.length === 0} + icon='trash' + text='Clear' + /> + </div> + {error && ( + <div className={errorMessage}> + <Icon + icon='error' + intent='danger' + style={{ + marginRight: '0.5rem', + }} + /> + {error} + </div> + )} + </div> + ) +} + +export default memo(ExpressionBuilder) diff --git a/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx b/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx index aacaaa3aa72debd511d136b528229b6af4e2813e..950dd6b21328b23b5af7fff4b3c1593fab11b89d 100644 --- a/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx +++ b/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx @@ -2,6 +2,7 @@ import { Button, Label, NumericInput } from '@blueprintjs/core' import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' +import ExpressionBuilder from '../ExpressionBuilder' import { addInjectControl, getInjectControlByInjectInfoId, @@ -29,12 +30,14 @@ const ConnectionsForm: FC<ConnectionsFormProps> = ({ const [start, setStart] = useState<number>(0) const [delay, setDelay] = useState<number>(0) + const [milestoneCondition, setMilestoneCondition] = useState<number[]>([]) useEffect(() => { setStart(injectControl?.start || 0) injectType === InjectType.QUESTIONNAIRE ? setDelay(0) : setDelay(injectControl?.delay || 0) + setMilestoneCondition(injectControl?.milestoneCondition || []) }, [injectControl, injectType]) const handleUpdateButton = useCallback( @@ -79,12 +82,17 @@ const ConnectionsForm: FC<ConnectionsFormProps> = ({ /> </Label> )} + <ExpressionBuilder + initExpression={injectControl?.milestoneCondition} + onExpressionChange={expression => setMilestoneCondition(expression)} + /> <Button onClick={() => handleUpdateButton({ injectInfoId, start, delay, + milestoneCondition, }) } intent='primary' diff --git a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx index 5c4abae05077e989a60c4e7b4398bf1a1c8ace46..b487ce3a22b11e6bfd0a3dbbe174fe001b51abbe 100644 --- a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx @@ -31,6 +31,7 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ const [selectedToolId, setSelectedToolId] = useState<number>(0) const [fileId, setFileId] = useState<number>(0) const [time, setTime] = useState<number>(0) + const [milestoneCondition, setMilestoneCondition] = useState<number[]>([]) useEffect(() => { setParameter(response?.parameter || '') @@ -39,6 +40,7 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ setSelectedToolId(response?.toolId || 0) setTime(response?.time || 0) setFileId(response?.fileId || 0) + setMilestoneCondition(response?.milestoneCondition || []) }, [response]) const handleUpdateButton = useCallback( @@ -67,12 +69,16 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ toolId={selectedToolId} fileId={fileId} time={time} + milestoneCondition={response?.milestoneCondition} onParameterChange={(value: string) => setParameter(value)} onContentChange={(value: string) => setContent(value)} onIsRegexChange={(value: boolean) => setIsRegex(value)} onToolIdChange={(value: number) => setSelectedToolId(value)} onFileIdChange={(value: number) => setFileId(value)} onTimeChange={(value: number) => setTime(value)} + onMilestoneConditionChange={(value: number[]) => + setMilestoneCondition(value) + } /> <Button disabled={!parameter || !selectedToolId} @@ -85,6 +91,7 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ toolId: selectedToolId, time, fileId, + milestoneCondition, }) } intent='primary' diff --git a/frontend/src/editor/ToolResponseFormContent/index.tsx b/frontend/src/editor/ToolResponseFormContent/index.tsx index 0e6b17e89c504de9e8b10414375d2faabac3d0a8..074dd3a4d45b62b4fc7436052f6ce7c7ab0c31f8 100644 --- a/frontend/src/editor/ToolResponseFormContent/index.tsx +++ b/frontend/src/editor/ToolResponseFormContent/index.tsx @@ -9,6 +9,7 @@ import { TextArea, } from '@blueprintjs/core' import { memo, type FC } from 'react' +import ExpressionBuilder from '../ExpressionBuilder' import FileSelector from '../FileSelector' import ToolSelector from '../ToolSelector' @@ -25,6 +26,8 @@ interface ToolResponseFormProps { onFileIdChange: (value: number) => void time: number onTimeChange: (value: number) => void + milestoneCondition?: number[] + onMilestoneConditionChange: (value: number[]) => void } const ToolResponseForm: FC<ToolResponseFormProps> = ({ @@ -40,6 +43,8 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ onFileIdChange, time, onTimeChange, + milestoneCondition, + onMilestoneConditionChange, }) => ( <Tabs> <Tab @@ -93,6 +98,12 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ onValueChange={(value: number) => onTimeChange(value)} /> </Label> + <ExpressionBuilder + initExpression={milestoneCondition} + onExpressionChange={expression => + onMilestoneConditionChange(expression) + } + /> </div> } /> diff --git a/frontend/src/editor/ToolResponseFormDialog/index.tsx b/frontend/src/editor/ToolResponseFormDialog/index.tsx index b4adbfe74999ad9d9458b065ff861ae676cbcb89..3bf314fcf4de5487396a824b5edef1db825a9425 100644 --- a/frontend/src/editor/ToolResponseFormDialog/index.tsx +++ b/frontend/src/editor/ToolResponseFormDialog/index.tsx @@ -26,6 +26,7 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ const [selectedToolId, setSelectedToolId] = useState<number>(0) const [fileId, setFileId] = useState<number>(0) const [time, setTime] = useState<number>(0) + const [milestoneCondition, setMilestoneCondition] = useState<number[]>([]) const clearInput = useCallback(() => { setParameter('') @@ -33,6 +34,7 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ setIsRegex(false) setTime(0) setFileId(0) + setMilestoneCondition([]) }, []) useEffect(() => { @@ -42,6 +44,7 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ setSelectedToolId(response?.toolId || toolId) setFileId(response?.fileId || 0) setTime(response?.time || 0) + setMilestoneCondition(response?.milestoneCondition || []) }, [response, isOpen]) const handleAddButton = useCallback( @@ -96,12 +99,16 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ toolId={selectedToolId} fileId={fileId} time={time} + milestoneCondition={response?.milestoneCondition} onParameterChange={(value: string) => setParameter(value)} onContentChange={(value: string) => setContent(value)} onIsRegexChange={(value: boolean) => setIsRegex(value)} onToolIdChange={(value: number) => setSelectedToolId(value)} onFileIdChange={(value: number) => setFileId(value)} onTimeChange={(value: number) => setTime(value)} + onMilestoneConditionChange={(value: number[]) => + setMilestoneCondition(value) + } /> </DialogBody> <DialogFooter @@ -112,12 +119,14 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ onClick={() => handleUpdateButton({ id: response.id, + learningActivityId: response.learningActivityId, parameter, content, isRegex, toolId: selectedToolId, fileId, time, + milestoneCondition, }) } intent='primary' @@ -135,6 +144,7 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ toolId: selectedToolId, fileId, time, + milestoneCondition, }) } intent='primary' diff --git a/frontend/src/editor/indexeddb/db.tsx b/frontend/src/editor/indexeddb/db.tsx index ef7f0b82616fedc2a61e7cdd8061f8586f491635..cd4c017a3684d1db290a24222f4897d80d31cc6f 100644 --- a/frontend/src/editor/indexeddb/db.tsx +++ b/frontend/src/editor/indexeddb/db.tsx @@ -9,6 +9,7 @@ import type { InjectInfo, LearningActivityInfo, LearningObjectiveInfo, + Milestone, Overlay, Questionnaire, QuestionnaireQuestion, @@ -34,6 +35,7 @@ const db = new Dexie(dbName) as Dexie & { overlays: EntityTable<Overlay, 'id'> injectControls: EntityTable<InjectControl, 'id'> files: EntityTable<ContentFile, 'id'> + milestones: EntityTable<Milestone, 'id'> } db.version(dbVersion).stores({ @@ -54,6 +56,7 @@ db.version(dbVersion).stores({ overlays: '++id, &injectInfoId, duration', injectControls: '++id, &injectInfoId, start, delay, milestoneCondition', files: '++id, &name, blob', + milestones: '++id, [type+referenceId]', }) export { db } diff --git a/frontend/src/editor/indexeddb/operations.tsx b/frontend/src/editor/indexeddb/operations.tsx index edde591f676116a107352294642d811b7e1cd547..6fccab86571be867d35f276cfa3d9322706ba68d 100644 --- a/frontend/src/editor/indexeddb/operations.tsx +++ b/frontend/src/editor/indexeddb/operations.tsx @@ -1,19 +1,21 @@ import { db } from './db' -import type { - ContentFile, - EmailAddressInfo, - EmailInject, - EmailTemplate, - InformationInject, - InjectControl, - InjectInfo, - LearningActivityInfo, - LearningObjectiveInfo, - Overlay, - Questionnaire, - QuestionnaireQuestion, - ToolInfo, - ToolResponse, +import { + MilestoneEventType, + type ContentFile, + type EmailAddressInfo, + type EmailInject, + type EmailTemplate, + type InformationInject, + type InjectControl, + type InjectInfo, + type LearningActivityInfo, + type LearningObjectiveInfo, + type Milestone, + type Overlay, + type Questionnaire, + type QuestionnaireQuestion, + type ToolInfo, + type ToolResponse, } from './types' // learning objectives operations @@ -46,23 +48,33 @@ export const getLearningActivityById = async (id: number) => export const addLearningActivity = async ( activity: Omit<LearningActivityInfo, 'id'> ) => - await db.transaction('rw', db.learningActivities, async () => { - await db.learningActivities.add(activity) + await db.transaction('rw', db.learningActivities, db.milestones, async () => { + const id = await db.learningActivities.add(activity) + await addMilestone({ + type: MilestoneEventType.LEARNING_ACTIVITY, + referenceId: id, + }) }) export const updateLearningActivity = async (activity: LearningActivityInfo) => await db.learningActivities.put(activity) export const deleteLearningActivity = async (id: number) => - await db.learningActivities.delete(id) + await db.transaction('rw', db.learningActivities, db.milestones, async () => { + await db.learningActivities.delete(id) + await db.milestones + .where({ type: MilestoneEventType.LEARNING_ACTIVITY, referenceId: id }) + .delete() + }) // inject info operations export const getInjectInfoById = async (id: number) => await db.injectInfos.get(id) export const addInjectInfo = async (injectInfo: Omit<InjectInfo, 'id'>) => - await db.transaction('rw', db.injectInfos, async () => { - await db.injectInfos.add(injectInfo) + await db.transaction('rw', db.injectInfos, db.milestones, async () => { + const id = await db.injectInfos.add(injectInfo) + await addMilestone({ type: MilestoneEventType.INJECT, referenceId: id }) }) export const updateInjectInfo = async (injectInfo: InjectInfo) => @@ -72,10 +84,14 @@ export const deleteInjectInfo = async (id: number) => await db.transaction( 'rw', db.injectInfos, + db.milestones, db.overlays, db.injectControls, async () => { await db.injectInfos.delete(id) + await db.milestones + .where({ type: MilestoneEventType.INJECT, referenceId: id }) + .delete() await db.overlays.where({ injectInfoId: id }).delete() await db.injectControls.where({ injectInfoId: id }).delete() } @@ -93,23 +109,36 @@ 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() + await db.toolResponses.where({ toolId: id }).each(async response => { + await deleteToolResponse(response.id) + }) }) // tool response operations +export const getToolResponseById = async (id: number) => + await db.toolResponses.get(id) + 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) + await db.transaction('rw', db.toolResponses, db.milestones, async () => { + const id = await db.toolResponses.add(response) + if (!response.learningActivityId) { + await addMilestone({ type: MilestoneEventType.TOOL, referenceId: id }) + } }) export const updateToolResponse = async (response: ToolResponse) => await db.toolResponses.put(response) export const deleteToolResponse = async (id: number) => - await db.toolResponses.delete(id) + await db.transaction('rw', db.toolResponses, db.milestones, async () => { + await db.toolResponses.delete(id) + await db.milestones + .where({ type: MilestoneEventType.TOOL, referenceId: id }) + .delete() + }) // email address operations export const addEmailAddress = async (address: Omit<EmailAddressInfo, 'id'>) => @@ -124,24 +153,39 @@ export const updateEmailAddress = async (address: EmailAddressInfo) => 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() + await db.emailTemplates + .where({ emailAddressId: id }) + .each(async template => { + await deleteEmailTemplate(template.id) + }) }) // email template operations +export const getEmailTemplateById = async (id: number) => + await db.emailTemplates.get(id) + 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) + await db.transaction('rw', db.emailTemplates, db.milestones, async () => { + const id = await db.emailTemplates.add(template) + if (!template.learningActivityId) { + await addMilestone({ type: MilestoneEventType.EMAIL, referenceId: id }) + } }) export const updateEmailTemplate = async (template: EmailTemplate) => await db.emailTemplates.put(template) export const deleteEmailTemplate = async (id: number) => - await db.emailTemplates.delete(id) + await db.transaction('rw', db.emailTemplates, db.milestones, async () => { + await db.emailTemplates.delete(id) + await db.milestones + .where({ type: MilestoneEventType.EMAIL, referenceId: id }) + .delete() + }) // email inject operations export const getEmailInjectByInjectInfoId = async (injectInfoId: number) => @@ -264,3 +308,59 @@ export const addFile = async (file: Omit<ContentFile, 'id'>) => export const updateFile = async (file: ContentFile) => await db.files.put(file) export const deleteFile = async (id: number) => await db.files.delete(id) + +// milestone operations +export const getMilestoneById = async (id: number) => + await db.milestones.get(id) + +export const getMilestonesWithNames = async () => { + const milestones = await db.milestones.toArray() + return await Promise.all( + milestones.map(async milestone => { + switch (milestone.type) { + case MilestoneEventType.LEARNING_ACTIVITY: { + const activity = await getLearningActivityById(milestone.referenceId) + return activity + ? { id: milestone.id, type: milestone.type, name: activity.name } + : null + } + case MilestoneEventType.INJECT: { + const inject = await getInjectInfoById(milestone.referenceId) + return inject + ? { id: milestone.id, type: milestone.type, name: inject.name } + : null + } + case MilestoneEventType.TOOL: { + const response = await getToolResponseById(milestone.referenceId) + return response + ? { + id: milestone.id, + type: milestone.type, + name: response.parameter, + } + : null + } + case MilestoneEventType.EMAIL: { + const template = await getEmailTemplateById(milestone.referenceId) + return template + ? { id: milestone.id, type: milestone.type, name: template.context } + : null + } + default: + return + } + }) + ) +} + +export const addMilestone = async (milestone: Omit<Milestone, 'id'>) => + await db.transaction('rw', db.milestones, async () => { + const id = await db.milestones.add(milestone) + return id + }) + +export const updateMilestone = async (milestone: Milestone) => + await db.milestones.put(milestone) + +export const deleteMilestone = async (id: number) => + await db.milestones.delete(id) diff --git a/frontend/src/editor/indexeddb/types.tsx b/frontend/src/editor/indexeddb/types.tsx index 8201baf290eb782d276c308081eb8a19f477ae8a..28c54ae83633061a5994887e578cd6d24d488cfa 100644 --- a/frontend/src/editor/indexeddb/types.tsx +++ b/frontend/src/editor/indexeddb/types.tsx @@ -12,8 +12,12 @@ export enum InjectType { QUESTIONNAIRE = 'Questionnaire', } -const eventTypes = { ...LearningActivityType, ...InjectType } -export type EventTypes = typeof eventTypes +export enum MilestoneEventType { + LEARNING_ACTIVITY, + INJECT, + TOOL, + EMAIL, +} export type LearningObjectiveInfo = Pick<LearningObjective, 'id' | 'name'> @@ -50,7 +54,7 @@ export type ToolResponse = { content: string fileId?: number time: number - milestoneCondition?: string[] + milestoneCondition?: number[] } export type EmailAddressInfo = Omit<EmailAddress, 'control'> @@ -101,7 +105,7 @@ export type InjectControl = { injectInfoId: number start: number delay: number - milestoneCondition?: string[] + milestoneCondition?: number[] } export type Overlay = { @@ -115,3 +119,9 @@ export type ContentFile = { name: string blob: Blob } + +export type Milestone = { + id: number + type: MilestoneEventType + referenceId: number +} diff --git a/frontend/src/editor/utils.tsx b/frontend/src/editor/utils.tsx index ff85a87909569c2e6ab97673581cfbefbd1fa230..df5bb1f4f9e4b16314c33b8de6ec0c26d46b9a2c 100644 --- a/frontend/src/editor/utils.tsx +++ b/frontend/src/editor/utils.tsx @@ -1,5 +1,11 @@ +import type { OptionProps } from '@blueprintjs/core' +import { isEqual } from 'lodash' import type { InjectInfo, LearningActivityInfo } from './indexeddb/types' -import { InjectType, LearningActivityType } from './indexeddb/types' +import { + InjectType, + LearningActivityType, + MilestoneEventType, +} from './indexeddb/types' export const INTRO_CONDITIONS = [ { name: 'Purpose', description: 'What do you want to achieve' }, @@ -38,3 +44,132 @@ export const getInjectIcon = (inject: InjectInfo) => { return 'clipboard' } } + +// expression builder +export const DEFAULT_OPTION: OptionProps = { value: '0', label: '+' } +export const OPENING_BRACKET: OptionProps = { value: '-1', label: '(' } +export const CLOSING_BRACKET: OptionProps = { value: '-2', label: ')' } +export const NOT: OptionProps = { value: '-3', label: 'NOT' } +export const OPERATORS: OptionProps[] = [ + { value: '-4', label: 'AND' }, + { value: '-5', label: 'OR' }, +] +const ALL_OPERATORS: OptionProps[] = [ + CLOSING_BRACKET, + OPENING_BRACKET, + NOT, + ...OPERATORS, +] + +export const getBlockFromId = (id: number, variables: OptionProps[]) => + ALL_OPERATORS.find(operator => Number(operator.value) === id) || + variables.find(variable => Number(variable.value) === id) + +const getPrefixByMilestoneType = (type: MilestoneEventType) => { + switch (type) { + case MilestoneEventType.LEARNING_ACTIVITY: + return 'la' + case MilestoneEventType.INJECT: + return 'i' + case MilestoneEventType.TOOL: + return 'tr' + case MilestoneEventType.EMAIL: + return 'et' + default: + return '' + } +} + +export const getMilestoneName = ( + id: number, + type: MilestoneEventType, + name: string +) => + `${getPrefixByMilestoneType(type)}_${name.replace(' ', '_')}${type === MilestoneEventType.TOOL ? id : ''}` + +type ValidationResult = { + isValid: boolean + error: string +} + +export const validateExpression = ( + expression: OptionProps[], + variables: OptionProps[] +): ValidationResult => { + let balance = 0 + let lastBlock: OptionProps | undefined = undefined + + for (const block of expression) { + if (isEqual(block, OPENING_BRACKET)) { + balance++ + if (variables.find(value => isEqual(value, lastBlock))) { + return { isValid: false, error: 'Missing operator after variable' } + } + if (isEqual(lastBlock, CLOSING_BRACKET)) { + return { + isValid: false, + error: 'Missing operator after closing bracket', + } + } + } else if (isEqual(block, CLOSING_BRACKET)) { + balance-- + if (balance < 0) { + return { isValid: false, error: 'More closing brackets' } + } + if (isEqual(lastBlock, OPENING_BRACKET)) { + return { isValid: false, error: 'Empty brackets' } + } + if ( + OPERATORS.find(value => isEqual(value, lastBlock)) || + isEqual(lastBlock, NOT) + ) { + return { isValid: false, error: 'Missing variable after operator' } + } + } else if (OPERATORS.find(value => isEqual(value, block))) { + if (OPERATORS.find(value => isEqual(value, lastBlock))) { + return { isValid: false, error: 'Two operators in a row' } + } + if (isEqual(lastBlock, NOT)) { + return { isValid: false, error: 'Missing variable after NOT' } + } + if (isEqual(lastBlock, OPENING_BRACKET)) { + return { + isValid: false, + error: 'Missing variable after opening bracket', + } + } + } else if (variables.find(value => isEqual(value, block))) { + if (variables.find(value => isEqual(value, lastBlock))) { + return { isValid: false, error: 'Two variables in a row' } + } + if (isEqual(lastBlock, CLOSING_BRACKET)) { + return { + isValid: false, + error: 'Missing operator after closing bracket', + } + } + } else if (isEqual(block, NOT)) { + if (isEqual(lastBlock, CLOSING_BRACKET)) { + return { + isValid: false, + error: 'Missing operator after closing bracket', + } + } + if (variables.find(value => isEqual(value, lastBlock))) { + return { isValid: false, error: 'Missing operator after variable' } + } + } + lastBlock = block + } + + if ( + OPERATORS.find(value => isEqual(value, lastBlock)) || + isEqual(lastBlock, NOT) + ) { + return { isValid: false, error: 'Cannot end with operator' } + } else if (balance > 0) { + return { isValid: false, error: 'Missing closing bracket' } + } else { + return { isValid: true, error: '' } + } +}