diff --git a/frontend/src/editor/DefinitionUploader/index.tsx b/frontend/src/editor/DefinitionUploader/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cfa98556cd0b17016bc789f1c1de791a414c7315 --- /dev/null +++ b/frontend/src/editor/DefinitionUploader/index.tsx @@ -0,0 +1,99 @@ +import { useNavigate } from '@/router' +import { + Button, + ButtonGroup, + Classes, + Dialog, + DialogBody, + DialogFooter, + FileInput, +} from '@blueprintjs/core' +import type { ChangeEvent } from 'react' +import { memo, useCallback, useState } from 'react' +import TooltipLabel from '../Tooltips/TooltipLabel' +import { + DEFINITION_UPLOAD_FORM, + LANDING_PAGE_ACTIONS, +} from '../assets/pageContent/landingPage' +import { clearDb } from '../indexeddb/operations' +import useEditorStorage from '../useEditorStorage' +import { loadDbData, loadEditorConfig } from '../zip/loadDefinitionZip' + +const DefinitionUploader = () => { + const [isOpen, setIsOpen] = useState(false) + const [file, setFile] = useState<File | undefined>() + const [, setConfig] = useEditorStorage() + const nav = useNavigate() + + const handleAddButton = useCallback(async (zip: File) => { + clearDb() + + const config = await loadEditorConfig(zip) + const finalMilestoneId = await loadDbData(zip) + + setConfig({ ...config, finalMilestone: finalMilestoneId }) + nav('/editor/create/introduction') + }, []) + + const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => { + if (e.target.files) { + setFile(e.target.files[0]) + } + } + + return ( + <> + <Button + text={LANDING_PAGE_ACTIONS.upload} + icon='import' + onClick={() => setIsOpen(true)} + /> + <Dialog + isOpen={isOpen} + onClose={() => setIsOpen(false)} + icon='import' + title='Upload definition' + > + <DialogBody> + <p> + This will permanently delete all data in the editor. Are you sure + you want to upload new definition? + </p> + <TooltipLabel label={DEFINITION_UPLOAD_FORM.file}> + <div + style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }} + > + <FileInput + className={Classes.INPUT} + fill + hasSelection={file !== undefined} + text={file ? file.name : 'Choose file...'} + onInputChange={handleFileChange} + /> + </div> + </TooltipLabel> + </DialogBody> + <DialogFooter + actions={ + <ButtonGroup> + <Button + onClick={() => setIsOpen(false)} + icon='cross' + text='Cancel' + /> + <Button + disabled={!file} + onClick={() => handleAddButton(file!)} + intent='primary' + icon='plus' + text='Add' + /> + </ButtonGroup> + } + /> + </Dialog> + </> + ) +} + +export default memo(DefinitionUploader) diff --git a/frontend/src/editor/DownloadDefinition/index.tsx b/frontend/src/editor/DownloadDefinition/index.tsx index da0bb69a92658fcc7be56422a2d808286d1b51e0..97bcf506af33e1bd7b0ed5204ef230734cfa7861 100644 --- a/frontend/src/editor/DownloadDefinition/index.tsx +++ b/frontend/src/editor/DownloadDefinition/index.tsx @@ -1,5 +1,5 @@ -import { downloadDefinitionZip } from '@/editor/downloadDefinitionZip' import useEditorStorage from '@/editor/useEditorStorage' +import { createDefinitionZip } from '@/editor/zip/createDefinitionZip' import { Button, ButtonGroup } from '@blueprintjs/core' import { memo } from 'react' import DataRemovalDialog from '../DataRemovalDialog' @@ -8,7 +8,7 @@ const DownloadDefinition = () => { const [config] = useEditorStorage() const handleDownloadYaml = async () => { - downloadDefinitionZip(config || {}) + createDefinitionZip(config || {}) } return ( diff --git a/frontend/src/editor/LandingPage/index.tsx b/frontend/src/editor/LandingPage/index.tsx index 00ddcc1106bcc57cec929afa68dea2f4dc6a97c1..6d8c7fa2f0dcbcc27733f931bcdf64e6417adedd 100644 --- a/frontend/src/editor/LandingPage/index.tsx +++ b/frontend/src/editor/LandingPage/index.tsx @@ -6,6 +6,7 @@ import Container from '@inject/shared/components/Container' import { isEmpty } from 'lodash' import { useEffect, useState } from 'react' import DataRemovalDialog from '../DataRemovalDialog' +import DefinitionUploader from '../DefinitionUploader' import { LANDING_PAGE_ACTIONS } from '../assets/pageContent/landingPage' import { PAGE_INFORMATION } from '../assets/pageInformation' import { isDbEmpty } from '../indexeddb/operations' @@ -76,6 +77,7 @@ const LandingPage = () => { redirectTo='/editor/create/introduction' /> )} + <DefinitionUploader /> </div> </Container> ) diff --git a/frontend/src/editor/assets/pageContent/landingPage.tsx b/frontend/src/editor/assets/pageContent/landingPage.tsx index a04192ff0bde41a8b1dca1d2b662a6c768a13423..b03e31db37f58e107131fafbd67bd731e08965aa 100644 --- a/frontend/src/editor/assets/pageContent/landingPage.tsx +++ b/frontend/src/editor/assets/pageContent/landingPage.tsx @@ -1,4 +1,15 @@ +import type { Form } from '@/editor/types' + export const LANDING_PAGE_ACTIONS = { create: 'Create', edit: 'Continue editing', + upload: 'Upload', +} + +export const DEFINITION_UPLOAD_FORM: Form = { + file: { + label: 'Definition', + tooltip: '', + optional: true, + }, } diff --git a/frontend/src/editor/indexeddb/db.tsx b/frontend/src/editor/indexeddb/db.tsx index cd4c017a3684d1db290a24222f4897d80d31cc6f..745f012bf0704963ebe1efbbf008e90655360cc5 100644 --- a/frontend/src/editor/indexeddb/db.tsx +++ b/frontend/src/editor/indexeddb/db.tsx @@ -9,6 +9,7 @@ import type { InjectInfo, LearningActivityInfo, LearningObjectiveInfo, + MarkdownContent, Milestone, Overlay, Questionnaire, @@ -36,6 +37,7 @@ const db = new Dexie(dbName) as Dexie & { injectControls: EntityTable<InjectControl, 'id'> files: EntityTable<ContentFile, 'id'> milestones: EntityTable<Milestone, 'id'> + markdownContents: EntityTable<MarkdownContent, 'id'> } db.version(dbVersion).stores({ @@ -45,18 +47,19 @@ db.version(dbVersion).stores({ tools: '++id, &name, tooltipDescription, hint, defaultResponse, category', toolResponses: '++id, &learningActivityId, toolId, parameter, isRegex, content, fileId', - emailAddresses: '++id, address, organization, description, teamVisible', + emailAddresses: '++id, &address, organization, description, teamVisible', emailTemplates: '++id, &learningActivityId, emailAddressId, context, content, fileId', emailInjects: '++id, &injectInfoId, emailAddressId, subject, content, extraCopies, fileId', informationInjects: '++id, &injectInfoId, content, fileId', - questionnaires: '++id, &injectInfoId, title', + questionnaires: '++id, &injectInfoId, &title', questionnaireQuestions: '++id, questionnaireId, text, max, correct, labels', overlays: '++id, &injectInfoId, duration', injectControls: '++id, &injectInfoId, start, delay, milestoneCondition', files: '++id, &name, blob', milestones: '++id, [type+referenceId]', + markdownContents: '++id, &fileName, content', }) export { db } diff --git a/frontend/src/editor/indexeddb/joins.tsx b/frontend/src/editor/indexeddb/joins.tsx index d96e7c0bcc8094043a973398b3edeed8bde992a9..071826da3ee612d9a5ab2ddea185cdea0921027a 100644 --- a/frontend/src/editor/indexeddb/joins.tsx +++ b/frontend/src/editor/indexeddb/joins.tsx @@ -13,6 +13,7 @@ import { getLearningActivityById, getOverlayByInjectInfoId, getQuestionnaireByInjectInfoId, + getToolById, getToolResponseById, } from './operations' import type { @@ -44,16 +45,23 @@ export const getMilestonesWithNames = async () => { } case MilestoneEventType.TOOL: { const response = await getToolResponseById(milestone.referenceId) - return response - ? { - ...milestone, - name: response.parameter, - } - : null + if (!response) return + + const tool = await getToolById(response?.id) + return { + ...milestone, + name: `${tool ? `${tool?.name} ` : ''}${response.parameter}`, + } } case MilestoneEventType.EMAIL: { const template = await getEmailTemplateById(milestone.referenceId) - return template ? { ...milestone, name: template.context } : null + if (!template) return + + const address = await getEmailAddressById(template.emailAddressId) + return { + ...milestone, + name: `${address ? `${address?.address} ` : ''}${template.context}`, + } } default: return diff --git a/frontend/src/editor/indexeddb/operations.tsx b/frontend/src/editor/indexeddb/operations.tsx index 838b9564c19bb98ac402a668649383e3631bf25c..e893cf1e6aa34fa93e2ca2d9a9a003b5c9e7c1ed 100644 --- a/frontend/src/editor/indexeddb/operations.tsx +++ b/frontend/src/editor/indexeddb/operations.tsx @@ -1,5 +1,6 @@ import { isEmpty } from 'lodash' import { db } from './db' +import type { MarkdownContent } from './types' import { InjectType, LearningActivityType, @@ -38,7 +39,8 @@ export const addLearningObjective = async ( objective: Omit<LearningObjectiveInfo, 'id'> ) => await db.transaction('rw', db.learningObjectives, async () => { - await db.learningObjectives.add(objective) + const id = await db.learningObjectives.add(objective) + return id }) export const updateLearningObjective = async ( @@ -69,6 +71,9 @@ export const deleteLearningObjective = async (id: number) => export const getLearningActivityById = async (id: number) => await db.learningActivities.get(id) +export const getLearningActivityByName = async (name: string) => + await db.learningActivities.get({ name }) + export const addLearningActivity = async ( activity: Omit<LearningActivityInfo, 'id'> ) => @@ -78,6 +83,7 @@ export const addLearningActivity = async ( type: MilestoneEventType.LEARNING_ACTIVITY, referenceId: id, }) + return id }) export const updateLearningActivity = async (activity: LearningActivityInfo) => @@ -149,6 +155,7 @@ export const addInjectInfo = async (injectInfo: Omit<InjectInfo, 'id'>) => await db.transaction('rw', db.injectInfos, db.milestones, async () => { const id = await db.injectInfos.add(injectInfo) await addMilestone({ type: MilestoneEventType.INJECT, referenceId: id }) + return id }) export const updateInjectInfo = async (injectInfo: InjectInfo) => @@ -251,11 +258,17 @@ export const addToolResponse = async (response: Omit<ToolResponse, 'id'>) => if (!response.learningActivityId) { await addMilestone({ type: MilestoneEventType.TOOL, referenceId: id }) } + return id }) export const updateToolResponse = async (response: ToolResponse) => await db.toolResponses.put(response) +export const updateToolResponseMilestoneCondition = async ( + id: number, + milestoneCondition: number[] +) => await db.toolResponses.update(id, { milestoneCondition }) + export const deleteToolResponse = async (id: number) => await db.transaction('rw', db.toolResponses, db.milestones, async () => { await db.toolResponses.delete(id) @@ -280,6 +293,9 @@ export const doToolsHaveResponses = async () => { export const getEmailAddressById = async (id: number) => await db.emailAddresses.get(id) +export const getEmailAddressByName = async (address: string) => + await db.emailAddresses.get({ address }) + export const addEmailAddress = async (address: Omit<EmailAddressInfo, 'id'>) => await db.transaction('rw', db.emailAddresses, async () => { const id = await db.emailAddresses.add(address) @@ -319,6 +335,7 @@ export const addEmailTemplate = async (template: Omit<EmailTemplate, 'id'>) => if (!template.learningActivityId) { await addMilestone({ type: MilestoneEventType.EMAIL, referenceId: id }) } + return id }) export const updateEmailTemplate = async (template: EmailTemplate) => @@ -374,7 +391,8 @@ export const addQuestionnaire = async ( questionnaire: Omit<Questionnaire, 'id'> ) => await db.transaction('rw', db.questionnaires, async () => { - await db.questionnaires.add(questionnaire) + const id = await db.questionnaires.add(questionnaire) + return id }) export const updateQuestionnaire = async (questionnaire: Questionnaire) => @@ -440,6 +458,9 @@ export const deleteInjectControl = async (id: number) => // file operations export const getFileById = async (id: number) => await db.files.get(id) +export const getFileByName = async (name: string) => + await db.files.get({ name }) + export const addFile = async (file: Omit<ContentFile, 'id'>) => await db.transaction('rw', db.files, async () => { const id = await db.files.add(file) @@ -454,6 +475,11 @@ export const deleteFile = async (id: number) => await db.files.delete(id) export const getMilestoneById = async (id: number) => await db.milestones.get(id) +export const getMilestoneByTypeAndReferenceId = async ( + type: MilestoneEventType, + referenceId: number +) => await db.milestones.get({ type, referenceId }) + export const addMilestone = async (milestone: Omit<Milestone, 'id'>) => await db.transaction('rw', db.milestones, async () => { const id = await db.milestones.add(milestone) @@ -465,3 +491,20 @@ export const updateMilestone = async (milestone: Milestone) => export const deleteMilestone = async (id: number) => await db.milestones.delete(id) + +// markdown content operations +export const getMarkdownContentByName = async (fileName: string) => + await db.markdownContents.get({ fileName }) + +export const addMarkdownContent = async ( + content: Omit<MarkdownContent, 'id'> +) => + await db.transaction('rw', db.markdownContents, async () => { + await db.markdownContents.add(content) + }) + +export const updateMarkdownContent = async (content: MarkdownContent) => + await db.markdownContents.put(content) + +export const deleteMarkdownContent = async (id: number) => + await db.markdownContents.delete(id) diff --git a/frontend/src/editor/indexeddb/types.tsx b/frontend/src/editor/indexeddb/types.tsx index 83629f1ac0119aa6d7925500ad7dbc9b0a508875..d14f9bcfea8d3012565f8c23dfe56219803c6676 100644 --- a/frontend/src/editor/indexeddb/types.tsx +++ b/frontend/src/editor/indexeddb/types.tsx @@ -26,7 +26,7 @@ export type LearningObjectiveInfo = { export type LearningActivityInfo = { id: number name: string - description: string + description?: string type: LearningActivityType learningObjectiveId: number } @@ -34,17 +34,17 @@ export type LearningActivityInfo = { export type InjectInfo = { id: number name: string - description: string + description?: string type: InjectType } export type ToolInfo = { id: number name: string - category: string - tooltipDescription: string + category?: string + tooltipDescription?: string defaultResponse: string - hint: string + hint?: string } export type ToolResponse = { @@ -52,10 +52,10 @@ export type ToolResponse = { learningActivityId?: number toolId: number parameter: string - isRegex: boolean - content: string + isRegex?: boolean + content?: string fileId?: number - time: number + time?: number milestoneCondition?: number[] } @@ -63,8 +63,8 @@ export type EmailAddressInfo = { id: number address: string description: string - teamVisible: boolean - organization: string + teamVisible?: boolean + organization?: string } export type EmailTemplate = { @@ -72,7 +72,7 @@ export type EmailTemplate = { learningActivityId?: number emailAddressId: number context: string - content: string + content?: string fileId?: number } @@ -81,15 +81,15 @@ export type EmailInject = { injectInfoId: number emailAddressId: number subject: string - content: string - extraCopies: number + content?: string + extraCopies?: number fileId?: number } export type InformationInject = { id: number injectInfoId: number - content: string + content?: string fileId?: number } @@ -104,15 +104,15 @@ export type QuestionnaireQuestion = { questionnaireId: number text: string max: number - correct: number - labels: string + correct?: number + labels?: string } export type InjectControl = { id: number injectInfoId: number - start: number - delay: number + start?: number + delay?: number milestoneCondition?: number[] } @@ -134,6 +134,12 @@ export type Milestone = { referenceId: number } +export type MarkdownContent = { + id: number + fileName: string + content: string +} + // joined types export type MilestoneName = Milestone & { name: string diff --git a/frontend/src/editor/utils.tsx b/frontend/src/editor/utils.tsx index 95e18f77a3dcfe51f045ff5679aca386b4eda64c..40d47494d036987952ed37f3d08d38732a3d8535 100644 --- a/frontend/src/editor/utils.tsx +++ b/frontend/src/editor/utils.tsx @@ -45,11 +45,10 @@ 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[] = [ +export const AND: OptionProps = { value: '-4', label: 'and' } +export const OR: OptionProps = { value: '-5', label: 'or' } +export const OPERATORS: OptionProps[] = [AND, OR] +export const ALL_OPERATORS: OptionProps[] = [ CLOSING_BRACKET, OPENING_BRACKET, NOT, @@ -69,7 +68,7 @@ export const getVariables = (milestones: MilestoneName[]) => label: getMilestoneName(milestone), })) -const getPrefixByMilestoneType = (type: MilestoneEventType) => { +export const getPrefixByMilestoneType = (type: MilestoneEventType) => { switch (type) { case MilestoneEventType.LEARNING_ACTIVITY: return 'la' @@ -85,7 +84,7 @@ const getPrefixByMilestoneType = (type: MilestoneEventType) => { } export const getMilestoneName = (milestone: MilestoneName) => - `${getPrefixByMilestoneType(milestone.type)}_${milestone.name.replace(' ', '_')}${milestone.type === MilestoneEventType.TOOL || milestone.type === MilestoneEventType.EMAIL ? milestone.id : ''}` + `${getPrefixByMilestoneType(milestone.type)}_${milestone.name.replace(/\s+/g, '_')}${milestone.type === MilestoneEventType.TOOL || milestone.type === MilestoneEventType.EMAIL ? `_${milestone.id}` : ''}` type ValidationResult = { isValid: boolean diff --git a/frontend/src/editor/yaml/generate/email.tsx b/frontend/src/editor/yaml/generate/email.tsx index ba8eaf9213c1a5034c09ecd199545ce2477e7bca..ae99971d87c5094b3842caa23238a544eedd3b42 100644 --- a/frontend/src/editor/yaml/generate/email.tsx +++ b/frontend/src/editor/yaml/generate/email.tsx @@ -1,18 +1,24 @@ import { getEmailAddresses } from '@/editor/indexeddb/joins' -import type { - JoinedEmailAddress, - JoinedEmailTemplate, +import { + type JoinedEmailAddress, + type JoinedEmailTemplate, } from '@/editor/indexeddb/types' import { isEmpty, pickBy } from 'lodash' import { generateContent, generateControl } from './shared' -const generateTemplates = (emailTemplates: JoinedEmailTemplate[]) => { - const generatedTemplates = emailTemplates.map(emailTemplate => - pickBy({ - context: emailTemplate.context, - content: generateContent(emailTemplate.content, emailTemplate.file), - control: generateControl(emailTemplate.activateMilestone), - }) +const generateTemplates = async (emailTemplates: JoinedEmailTemplate[]) => { + const generatedTemplates = await Promise.all( + emailTemplates.map(async emailTemplate => + pickBy({ + context: emailTemplate.context, + content: await generateContent( + `et_${emailTemplate.id}`, + emailTemplate.content, + emailTemplate.file + ), + control: generateControl(emailTemplate.activateMilestone), + }) + ) ) return isEmpty(generatedTemplates) ? undefined : generatedTemplates @@ -21,15 +27,16 @@ const generateTemplates = (emailTemplates: JoinedEmailTemplate[]) => { export const generateEmailAddresses = async () => { const emailAddresses = await getEmailAddresses() - const generatedAddresses = emailAddresses.map( - (emailAddress: JoinedEmailAddress) => + const generatedAddresses = await Promise.all( + emailAddresses.map(async (emailAddress: JoinedEmailAddress) => pickBy({ address: emailAddress.address, team_visible: emailAddress.teamVisible, description: emailAddress.description, organization: emailAddress.organization, - templates: generateTemplates(emailAddress.emailTemplates), + templates: await generateTemplates(emailAddress.emailTemplates), }) + ) ) return isEmpty(generatedAddresses) ? undefined : generatedAddresses diff --git a/frontend/src/editor/yaml/generate/injects.tsx b/frontend/src/editor/yaml/generate/injects.tsx index f6d38131b467cc1e5814c4421bb3e9e95130b773..66ebd902dcb6fc33d6e58a57a92e8d9fe6f21d8d 100644 --- a/frontend/src/editor/yaml/generate/injects.tsx +++ b/frontend/src/editor/yaml/generate/injects.tsx @@ -2,34 +2,34 @@ import { getEmailInjects, getInformationInjects, } from '@/editor/indexeddb/joins' -import type { - JoinedEmailInject, - JoinedEmailInjectCategory, - JoinedInformationInject, - JoinedInformationInjectCategory, +import { + type JoinedEmailInject, + type JoinedEmailInjectCategory, + type JoinedInformationInject, + type JoinedInformationInjectCategory, } from '@/editor/indexeddb/types' import notEmpty from '@inject/shared/utils/notEmpty' import { isEmpty, pickBy } from 'lodash' import { generateContent, generateControl, generateOverlay } from './shared' -const generateInformationInject = ( +const generateInformationInject = async ( name: string, info: JoinedInformationInject ) => pickBy({ name: name, - content: generateContent(info.content, info.file), + content: await generateContent(`ii_${info.id}`, info.content, info.file), control: generateControl(info.activateMilestone, info.milestoneCondition), overlay: generateOverlay(info.overlay), }) -const generateEmailInject = (name: string, email: JoinedEmailInject) => +const generateEmailInject = async (name: string, email: JoinedEmailInject) => pickBy({ name: name, sender: email.sender?.address, subject: email.subject, extra_copies: email.extraCopies, - content: generateContent(email.content, email.file), + content: await generateContent(`ei_${email.id}`, email.content, email.file), control: generateControl(email.activateMilestone, email.milestoneCondition), overlay: generateOverlay(email.overlay), }) @@ -38,29 +38,35 @@ export const generateInjects = async () => { const informationInjects = await getInformationInjects() const emailInjects = await getEmailInjects() - const generatedInformationInjects = informationInjects - .filter(notEmpty) - .map((inject: JoinedInformationInjectCategory) => - pickBy({ - name: inject.name, - time: inject.injectControl?.start, - delay: inject.injectControl?.delay, - type: 'info', - alternatives: [generateInformationInject(inject.name, inject.info)], - }) - ) + const generatedInformationInjects = await Promise.all( + informationInjects + .filter(notEmpty) + .map(async (inject: JoinedInformationInjectCategory) => + pickBy({ + name: inject.name, + time: inject.injectControl?.start, + delay: inject.injectControl?.delay, + type: 'info', + alternatives: [ + await generateInformationInject(inject.name, inject.info), + ], + }) + ) + ) - const generatedEmailInjects = emailInjects - .filter(notEmpty) - .map((inject: JoinedEmailInjectCategory) => - pickBy({ - name: inject.name, - time: inject.injectControl?.start, - delay: inject.injectControl?.delay, - type: 'email', - alternatives: [generateEmailInject(inject.name, inject.email)], - }) - ) + const generatedEmailInjects = await Promise.all( + emailInjects + .filter(notEmpty) + .map(async (inject: JoinedEmailInjectCategory) => + pickBy({ + name: inject.name, + time: inject.injectControl?.start, + delay: inject.injectControl?.delay, + type: 'email', + alternatives: [await generateEmailInject(inject.name, inject.email)], + }) + ) + ) const generatedInjects = [ ...generatedInformationInjects, diff --git a/frontend/src/editor/yaml/generate/questionnaires.tsx b/frontend/src/editor/yaml/generate/questionnaires.tsx index 1f496a70e16873517cac43f2cfe9dc4cdabb4ec2..db50e2556ed670e579a4c409fc8b8ed2cfcf43a1 100644 --- a/frontend/src/editor/yaml/generate/questionnaires.tsx +++ b/frontend/src/editor/yaml/generate/questionnaires.tsx @@ -7,35 +7,44 @@ import notEmpty from '@inject/shared/utils/notEmpty' import { isEmpty, pickBy } from 'lodash' import { generateContent, generateControl, generateOverlay } from './shared' -const generateQuestionnaireQuestions = ( +const generateQuestionnaireQuestions = async ( questionnaireQuestions: QuestionnaireQuestion[] ) => - questionnaireQuestions.map(questionnaireQuestion => - pickBy({ - content: generateContent(questionnaireQuestion.text), - max: questionnaireQuestion.max, - correct: questionnaireQuestion.correct, - labels: questionnaireQuestion.labels, - }) + await Promise.all( + questionnaireQuestions.map(async questionnaireQuestion => + pickBy({ + content: await generateContent( + `qq_${questionnaireQuestion.id}`, + questionnaireQuestion.text + ), + max: questionnaireQuestion.max, + correct: questionnaireQuestion.correct, + labels: questionnaireQuestion.labels, + }) + ) ) export const generateQuestionnaires = async () => { const questionnaires = await getQuestionnaires() - const generatedQuestionnaires = questionnaires - .filter(notEmpty) - .map((questionnaire: JoinedQuestionnaireInject) => - pickBy({ - title: questionnaire.title, - time: questionnaire.injectControl?.start, - control: generateControl( - questionnaire.activateMilestone, - questionnaire.milestoneCondition - ), - overlay: generateOverlay(questionnaire.overlay), - questions: generateQuestionnaireQuestions(questionnaire.questions), - }) - ) + const generatedQuestionnaires = await Promise.all( + questionnaires + .filter(notEmpty) + .map(async (questionnaire: JoinedQuestionnaireInject) => + pickBy({ + title: questionnaire.title, + time: questionnaire.injectControl?.start, + control: generateControl( + questionnaire.activateMilestone, + questionnaire.milestoneCondition + ), + overlay: generateOverlay(questionnaire.overlay), + questions: await generateQuestionnaireQuestions( + questionnaire.questions + ), + }) + ) + ) return isEmpty(generatedQuestionnaires) ? undefined : generatedQuestionnaires } diff --git a/frontend/src/editor/yaml/generate/shared.tsx b/frontend/src/editor/yaml/generate/shared.tsx index 67de38bdcc363c49ec9e5736c4ba6e258f04daea..819eb38f39681c7c69a4b05d89601de4975fab76 100644 --- a/frontend/src/editor/yaml/generate/shared.tsx +++ b/frontend/src/editor/yaml/generate/shared.tsx @@ -1,3 +1,4 @@ +import { addMarkdownContent } from '@/editor/indexeddb/operations' import type { ContentFile, MilestoneName, @@ -7,8 +8,17 @@ import { getMilestoneName } from '@/editor/utils' import type { OptionProps } from '@blueprintjs/core' import { isEmpty, pickBy } from 'lodash' -export const generateContent = (content?: string, file?: ContentFile) => { - const generatedContent = pickBy({ content: content, fileName: file?.name }) +export const generateContent = async ( + contentName: string, + content?: string, + file?: ContentFile +) => { + const contentFileName = `${contentName}.md` + content && (await addMarkdownContent({ content, fileName: contentFileName })) + const generatedContent = pickBy({ + content_path: contentFileName, + fileName: file?.name, + }) return isEmpty(generatedContent) ? undefined : generatedContent } diff --git a/frontend/src/editor/yaml/generate/tools.tsx b/frontend/src/editor/yaml/generate/tools.tsx index 2930f07909022462a060c497e93633c5b1291eaa..fd434529f0b0c417e5f7f52ec8a55492420858eb 100644 --- a/frontend/src/editor/yaml/generate/tools.tsx +++ b/frontend/src/editor/yaml/generate/tools.tsx @@ -1,20 +1,29 @@ import { getTools } from '@/editor/indexeddb/joins' -import type { JoinedTool, JoinedToolResponse } from '@/editor/indexeddb/types' +import { + type JoinedTool, + type JoinedToolResponse, +} from '@/editor/indexeddb/types' import { isEmpty, pickBy } from 'lodash' import { generateContent, generateControl } from './shared' -const generateToolResponses = (toolResponses: JoinedToolResponse[]) => { - const generatedResponses = toolResponses.map(toolResponse => - pickBy({ - param: toolResponse.parameter, - regex: toolResponse.isRegex, - time: toolResponse.time, - content: generateContent(toolResponse.content, toolResponse.file), - control: generateControl( - toolResponse.activateMilestone, - toolResponse.milestoneConditionWithNames - ), - }) +const generateToolResponses = async (toolResponses: JoinedToolResponse[]) => { + const generatedResponses = await Promise.all( + toolResponses.map(async toolResponse => + pickBy({ + param: toolResponse.parameter, + regex: toolResponse.isRegex, + time: toolResponse.time, + content: await generateContent( + `tr_${toolResponse.id}`, + toolResponse.content, + toolResponse.file + ), + control: generateControl( + toolResponse.activateMilestone, + toolResponse.milestoneConditionWithNames + ), + }) + ) ) return isEmpty(generatedResponses) ? undefined : generatedResponses @@ -22,14 +31,16 @@ const generateToolResponses = (toolResponses: JoinedToolResponse[]) => { export const generateTools = async () => { const tools = await getTools() - const generatedTools = tools.map((tool: JoinedTool) => - pickBy({ - name: tool.name, - tooltip_description: tool.tooltipDescription, - hint: tool.hint, - default_response: tool.defaultResponse, - responses: generateToolResponses(tool.toolResponses), - }) + const generatedTools = await Promise.all( + tools.map(async (tool: JoinedTool) => + pickBy({ + name: tool.name, + tooltip_description: tool.tooltipDescription, + hint: tool.hint, + default_response: tool.defaultResponse, + responses: await generateToolResponses(tool.toolResponses), + }) + ) ) return isEmpty(generatedTools) ? undefined : generatedTools diff --git a/frontend/src/editor/yaml/parse/channels.tsx b/frontend/src/editor/yaml/parse/channels.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e7ccbeb6d210b51a1036c53e9188670871c1e68b --- /dev/null +++ b/frontend/src/editor/yaml/parse/channels.tsx @@ -0,0 +1,14 @@ +import type { EditorConfig } from '@/editor/useEditorStorage' +import type { ChannelYaml } from '../types' + +export const parseChannels = ( + channels: ChannelYaml[] +): Pick< + EditorConfig, + 'infoChannelName' | 'emailChannelName' | 'toolChannelName' | 'formChannelName' +> => ({ + infoChannelName: channels.find(channel => channel.type === 'info')?.name, + emailChannelName: channels.find(channel => channel.type === 'email')?.name, + toolChannelName: channels.find(channel => channel.type === 'tool')?.name, + formChannelName: channels.find(channel => channel.type === 'form')?.name, +}) diff --git a/frontend/src/editor/yaml/parse/config.tsx b/frontend/src/editor/yaml/parse/config.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3f5cf93674c063327f11c22b21387ac920adcdaa --- /dev/null +++ b/frontend/src/editor/yaml/parse/config.tsx @@ -0,0 +1,17 @@ +import type { EditorConfig } from '@/editor/useEditorStorage' +import type { ConfigYaml } from '../types' + +export const parseConfig = ( + config: ConfigYaml +): Pick< + EditorConfig, + | 'exerciseDuration' + | 'emailBetweenTeams' + | 'customEmailSuffix' + | 'showExerciseTime' +> => ({ + exerciseDuration: config.exercise_duration, + emailBetweenTeams: config.email_between_teams, + customEmailSuffix: config.custom_email_suffix, + showExerciseTime: config.show_exercise_time, +}) diff --git a/frontend/src/editor/yaml/parse/email.tsx b/frontend/src/editor/yaml/parse/email.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a7942a4e57d1735fcf9d1479d3b94b9c23e1ef6 --- /dev/null +++ b/frontend/src/editor/yaml/parse/email.tsx @@ -0,0 +1,70 @@ +import { + addEmailAddress, + addEmailTemplate, + getMilestoneByTypeAndReferenceId, +} from '@/editor/indexeddb/operations' +import { MilestoneEventType } from '@/editor/indexeddb/types' +import type { + EmailAddressYaml, + EmailTemplateYaml, + MappedActivity, +} from '../types' +import { extractFirstMilestone } from './milestones' +import { getContentFile, getContentText } from './shared' + +const loadEmailTemplates = async ( + emailAddressId: number, + templates: EmailTemplateYaml[], + activityMilestones: MappedActivity[] +) => + await Promise.all( + templates.map(async template => { + const file = await getContentFile(template.content) + const content = await getContentText(template.content) + const learningActivityId = activityMilestones.find( + activity => + extractFirstMilestone(template.control?.activate_milestone) === + activity.milestoneName + )?.id + + const id = await addEmailTemplate({ + emailAddressId, + learningActivityId, + context: template.context, + content, + fileId: file?.id, + }) + const milestone = learningActivityId + ? await getMilestoneByTypeAndReferenceId( + MilestoneEventType.LEARNING_ACTIVITY, + learningActivityId + ) + : await getMilestoneByTypeAndReferenceId(MilestoneEventType.EMAIL, id) + + return { + id, + milestoneId: milestone?.id, + milestoneName: extractFirstMilestone( + template.control?.activate_milestone + ), + } + }) + ) + +export const loadEmailAddresses = async ( + activityMilestones: MappedActivity[], + addresses: EmailAddressYaml[] +) => + await Promise.all( + addresses.map(async address => { + const id = await addEmailAddress({ + address: address.address, + teamVisible: address.team_visible, + description: address.description, + organization: address.organization, + }) + return address.templates + ? await loadEmailTemplates(id, address.templates, activityMilestones) + : [] + }) + ) diff --git a/frontend/src/editor/yaml/parse/injects.tsx b/frontend/src/editor/yaml/parse/injects.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b46451ff38c1488fb8dc9d58df358d1f907a8a5 --- /dev/null +++ b/frontend/src/editor/yaml/parse/injects.tsx @@ -0,0 +1,179 @@ +import { + addEmailInject, + addInformationInject, + addInjectControl, + addInjectInfo, + getEmailAddressByName, + getMilestoneByTypeAndReferenceId, +} from '@/editor/indexeddb/operations' +import { InjectType, MilestoneEventType } from '@/editor/indexeddb/types' +import { AND, CLOSING_BRACKET, NOT, OPENING_BRACKET } from '@/editor/utils' +import type { + InjectAlternativeEmailYaml, + InjectAlternativeInfoYaml, + InjectCategoryYaml, + MappedInjectControl, + MappedMilestone, +} from '../types' +import { extractFirstMilestone } from './milestones' +import { + getContentFile, + getContentText, + getMilestoneCondition, + loadOverlay, +} from './shared' + +const loadInfoInject = async ( + injectCategory: InjectCategoryYaml, + infoInject: InjectAlternativeInfoYaml, + injectInfoId: number +) => { + const file = await getContentFile(infoInject.content) + const content = await getContentText(infoInject.content) + + await addInformationInject({ + injectInfoId, + content, + fileId: file?.id, + }) + await loadOverlay(injectInfoId, infoInject.overlay) + const milestone = await getMilestoneByTypeAndReferenceId( + MilestoneEventType.INJECT, + injectInfoId + ) + + return { + id: injectInfoId, + milestoneId: milestone?.id, + milestoneName: extractFirstMilestone( + infoInject.control?.activate_milestone + ), + milestoneCondition: infoInject.control?.milestone_condition, + time: injectCategory.time, + delay: injectCategory.delay, + } +} + +const loadEmailInject = async ( + injectCategory: InjectCategoryYaml, + emailInject: InjectAlternativeEmailYaml, + injectInfoId: number +) => { + const file = await getContentFile(emailInject.content) + const content = await getContentText(emailInject.content) + const sender = await getEmailAddressByName(emailInject.sender) + + await addEmailInject({ + injectInfoId, + emailAddressId: sender?.id || 0, + subject: emailInject.subject, + content, + extraCopies: emailInject.extra_copies, + fileId: file?.id, + }) + await loadOverlay(injectInfoId, emailInject.overlay) + const milestone = await getMilestoneByTypeAndReferenceId( + MilestoneEventType.INJECT, + injectInfoId + ) + + return { + id: injectInfoId, + milestoneId: milestone?.id, + milestoneName: extractFirstMilestone( + emailInject.control?.activate_milestone + ), + milestoneCondition: emailInject.control?.milestone_condition, + time: injectCategory.time, + delay: injectCategory.delay, + } +} + +export const loadInjects = async (injects: InjectCategoryYaml[]) => + await Promise.all( + injects.map(async inject => { + if (!inject.type || inject.type === 'info') { + return await Promise.all( + inject.alternatives.map(async alternative => { + const id = await addInjectInfo({ + name: `${inject.name}${inject.alternatives.length > 1 ? ` - ${alternative.name}` : ''}`, + type: InjectType.INFORMATION, + }) + return await loadInfoInject(inject, alternative, id) + }) + ) + } + if (inject.type === 'email') { + return await Promise.all( + inject.alternatives.map(async alternative => { + const id = await addInjectInfo({ + name: `${inject.name}${inject.alternatives.length > 1 ? ` - ${alternative.name}` : ''}`, + type: InjectType.EMAIL, + }) + return await loadEmailInject( + inject, + alternative as InjectAlternativeEmailYaml, + id + ) + }) + ) + } + }) + ) + +export const loadInjectControls = ( + injectControlGroups: MappedInjectControl[][], + milestonesWithIds: MappedMilestone[] +) => { + injectControlGroups.forEach(controlGroup => { + const milestoneIds = controlGroup + .map(control => control.milestoneId) + .filter(id => id !== undefined) as number[] + controlGroup.map(async control => { + await addInjectControl({ + injectInfoId: control.id, + start: control.time, + delay: control.delay, + milestoneCondition: control.milestoneCondition + ? getInjectMilestoneCondition( + control.milestoneCondition, + milestonesWithIds, + milestoneIds, + control.milestoneId + ) + : [], + }) + }) + }) +} + +const getInjectMilestoneCondition = ( + condition: string, + milestonesWithIds: MappedMilestone[], + groupMilestoneIds: number[], + milestoneId?: number +) => { + const originalCondition = getMilestoneCondition(condition, milestonesWithIds) + // excluding other inject alternatives + const exclusiveMilestoneIds = groupMilestoneIds.filter( + id => id !== milestoneId + ) + + if (exclusiveMilestoneIds.length) { + const originalConditionWithBrackets = originalCondition.length + ? [ + Number(OPENING_BRACKET.value), + ...originalCondition, + Number(CLOSING_BRACKET.value), + ] + : [] + + return [ + ...originalConditionWithBrackets, + ...exclusiveMilestoneIds + .map(id => [Number(AND.value), Number(NOT.value), id]) + .flat(1), + ] + } + return originalCondition +} diff --git a/frontend/src/editor/yaml/parse/milestones.tsx b/frontend/src/editor/yaml/parse/milestones.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9a980c58cbcb232149fa59a6db129384fffc8f3a --- /dev/null +++ b/frontend/src/editor/yaml/parse/milestones.tsx @@ -0,0 +1,68 @@ +import type { + EmailAddressYaml, + MappedEmailTemplateControl, + MappedInjectControl, + MappedMilestone, + MappedQuestionnaireControl, + MappedToolResponseControl, + MilestoneYaml, + ToolYaml, +} from '../types' + +export const extractFirstMilestone = (activateMilestone?: string) => + activateMilestone?.split(',')[0].trim() + +export const filterToolMilestones = ( + activityMilestones: MilestoneYaml[], + tools: ToolYaml[] +) => + activityMilestones.filter((milestone: MilestoneYaml) => + tools.find((tool: ToolYaml) => + tool.responses.find( + response => + extractFirstMilestone(response.control?.activate_milestone) === + milestone.name + ) + ) + ) + +export const filterEmailMilestones = ( + activityMilestones: MilestoneYaml[], + emails: EmailAddressYaml[] +) => + activityMilestones.filter((milestone: MilestoneYaml) => + emails.find((email: EmailAddressYaml) => + email.templates?.find( + template => + extractFirstMilestone(template.control?.activate_milestone) === + milestone.name + ) + ) + ) + +export const extractMilestonesFromControls = ( + toolControls: MappedToolResponseControl[], + emailControls: MappedEmailTemplateControl[], + injectControls: MappedInjectControl[], + questionnaireControls: MappedQuestionnaireControl[] +) => + [ + ...toolControls.map(control => ({ + id: control.milestoneId, + name: control.milestoneName, + })), + ...emailControls.map(control => ({ + id: control.milestoneId, + name: control.milestoneName, + })), + ...injectControls.map(control => ({ + id: control.milestoneId, + name: control.milestoneName, + })), + ...questionnaireControls.map(control => ({ + id: control.milestoneId, + name: control.milestoneName, + })), + ].filter( + control => control.id !== undefined && control.name !== undefined + ) as MappedMilestone[] diff --git a/frontend/src/editor/yaml/parse/objectives.tsx b/frontend/src/editor/yaml/parse/objectives.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21e6e905ca72666b0e92b564f62b018ccf617bb6 --- /dev/null +++ b/frontend/src/editor/yaml/parse/objectives.tsx @@ -0,0 +1,92 @@ +import { + addLearningActivity, + addLearningObjective, +} from '@/editor/indexeddb/operations' +import type { LearningActivityInfo } from '@/editor/indexeddb/types' +import { LearningActivityType } from '@/editor/indexeddb/types' +import type { + LearningActivityYaml, + LearningActivityYamlWithType, + LearningObjectiveYaml, + LearningObjectiveYamlWithTypes, + MappedActivity, + MilestoneYaml, +} from '../types' + +export const getActivityType = ( + activity: LearningActivityYaml, + toolMilestones: MilestoneYaml[], + emailMilestones: MilestoneYaml[] +) => { + if ( + emailMilestones.find( + (milestone: MilestoneYaml) => milestone.name === activity.name + ) + ) { + return LearningActivityType.EMAIL + } + if ( + toolMilestones.find( + (milestone: MilestoneYaml) => milestone.name === activity.name + ) + ) { + return LearningActivityType.TOOL + } + // TODO no demonstration + return LearningActivityType.TOOL +} + +export const addTypesToActivities = ( + objectives: LearningObjectiveYaml[], + toolMilestones: MilestoneYaml[], + emailMilestones: MilestoneYaml[] +) => + objectives.map((objective: LearningObjectiveYaml) => ({ + ...objective, + activities: objective.activities.map(activity => ({ + ...activity, + type: getActivityType(activity, toolMilestones, emailMilestones), + })), + })) + +export const addActivityIdsToActivityMilestones = ( + activityMilestones: MilestoneYaml[], + activities: LearningActivityInfo[] +): (MappedActivity | undefined)[] => + activityMilestones.map((milestone: MilestoneYaml) => { + if (milestone.activity) { + const activity = activities.find(a => a.name === milestone.activity) + if (activity) { + return { + id: activity.id, + milestoneName: milestone.name, + } + } + } + }) + +const loadActivities = async ( + activities: LearningActivityYamlWithType[], + objectiveId: number +) => + Promise.all( + activities.map(async activity => { + const newActivity = { + name: activity.name, + type: activity.type, + learningObjectiveId: objectiveId, + } + const id = await addLearningActivity(newActivity) + return { id, ...newActivity } + }) + ) + +export const loadObjectives = async ( + objectives: LearningObjectiveYamlWithTypes[] +) => + Promise.all( + objectives.map(async objective => { + const id = await addLearningObjective({ name: objective.name }) + return await loadActivities(objective.activities, id) + }) + ) diff --git a/frontend/src/editor/yaml/parse/questionnaires.tsx b/frontend/src/editor/yaml/parse/questionnaires.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d9f3c199b3870b1e427d18b59ae937e70c2fd78 --- /dev/null +++ b/frontend/src/editor/yaml/parse/questionnaires.tsx @@ -0,0 +1,78 @@ +import { + addInjectControl, + addInjectInfo, + addQuestionnaire, + addQuestionnaireQuestion, + getMilestoneByTypeAndReferenceId, +} from '@/editor/indexeddb/operations' +import { InjectType, MilestoneEventType } from '@/editor/indexeddb/types' +import type { + MappedMilestone, + MappedQuestionnaireControl, + QuestionYaml, + QuestionnaireYaml, +} from '../types' +import { extractFirstMilestone } from './milestones' +import { getContentText, getMilestoneCondition, loadOverlay } from './shared' + +const loadQuestions = async ( + questions: QuestionYaml[], + questionnaireId: number +) => { + questions.map(async question => { + const content = await getContentText(question.content) + + await addQuestionnaireQuestion({ + questionnaireId, + text: content || '', + max: question.max, + correct: question.correct, + labels: question.labels, + }) + }) +} + +export const loadQuestionnaires = async (questionnaires: QuestionnaireYaml[]) => + await Promise.all( + questionnaires.map(async questionnaire => { + const injectInfoId = await addInjectInfo({ + name: questionnaire.title, + type: InjectType.QUESTIONNAIRE, + }) + const id = await addQuestionnaire({ + injectInfoId: injectInfoId, + title: questionnaire.title, + }) + await loadOverlay(injectInfoId, questionnaire.overlay) + await loadQuestions(questionnaire.questions, id) + const milestone = await getMilestoneByTypeAndReferenceId( + MilestoneEventType.INJECT, + injectInfoId + ) + + return { + id: injectInfoId, + milestoneId: milestone?.id, + milestoneName: extractFirstMilestone( + questionnaire.control?.activate_milestone + ), + milestoneCondition: questionnaire.control?.milestone_condition, + time: questionnaire.time, + } + }) + ) + +export const loadQuestionnaireControls = ( + questionnaireControls: MappedQuestionnaireControl[], + milestonesWithIds: MappedMilestone[] +) => { + questionnaireControls.forEach(async control => { + await addInjectControl({ + injectInfoId: control.id, + start: control.time, + milestoneCondition: control.milestoneCondition + ? getMilestoneCondition(control.milestoneCondition, milestonesWithIds) + : [], + }) + }) +} diff --git a/frontend/src/editor/yaml/parse/shared.tsx b/frontend/src/editor/yaml/parse/shared.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0ea75bf953335b59aa0dab4ea244c7a9d8da2f11 --- /dev/null +++ b/frontend/src/editor/yaml/parse/shared.tsx @@ -0,0 +1,43 @@ +import { + addOverlay, + getFileByName, + getMarkdownContentByName, +} from '@/editor/indexeddb/operations' +import { ALL_OPERATORS } from '@/editor/utils' +import type { ContentYaml, MappedMilestone, OverlayYaml } from '../types' + +export const getContentFile = async (content?: ContentYaml) => { + const fileName = content?.file_name + return fileName ? await getFileByName(fileName) : undefined +} + +export const getContentText = async (content?: ContentYaml) => { + const contentFileName = content?.content_path + if (!contentFileName) return content?.content + + const contentEntry = await getMarkdownContentByName(contentFileName) + return contentEntry?.content +} + +export const loadOverlay = async ( + injectInfoId: number, + overlay?: OverlayYaml +) => { + if (overlay) { + await addOverlay({ ...overlay, injectInfoId }) + } +} + +export const getMilestoneCondition = ( + condition: string, + milestonesWithIds: MappedMilestone[] +) => + condition + .split(' ') + .map( + value => + Number( + ALL_OPERATORS.find(operator => operator.label === value)?.value + ) || milestonesWithIds.find(milestone => milestone.name === value)?.id + ) + .filter(id => id !== undefined) as number[] diff --git a/frontend/src/editor/yaml/parse/tools.tsx b/frontend/src/editor/yaml/parse/tools.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a18fcd939ee2f4ca3f425977b7eb7ff45e0a7e6b --- /dev/null +++ b/frontend/src/editor/yaml/parse/tools.tsx @@ -0,0 +1,88 @@ +import { + addTool, + addToolResponse, + getMilestoneByTypeAndReferenceId, + updateToolResponseMilestoneCondition, +} from '@/editor/indexeddb/operations' +import { MilestoneEventType } from '@/editor/indexeddb/types' +import type { + MappedActivity, + MappedMilestone, + MappedToolResponseControl, + ToolResponseYaml, + ToolYaml, +} from '../types' +import { extractFirstMilestone } from './milestones' +import { getContentFile, getContentText, getMilestoneCondition } from './shared' + +const loadToolResponses = async ( + toolId: number, + responses: ToolResponseYaml[], + activityMilestones: MappedActivity[] +) => + await Promise.all( + responses.map(async response => { + const file = await getContentFile(response.content) + const content = await getContentText(response.content) + const learningActivityId = activityMilestones.find( + activity => + extractFirstMilestone(response.control?.activate_milestone) === + activity.milestoneName + )?.id + + const id = await addToolResponse({ + toolId, + learningActivityId, + parameter: response.param, + isRegex: response.regex, + content, + fileId: file?.id, + time: response.time, + }) + const milestone = learningActivityId + ? await getMilestoneByTypeAndReferenceId( + MilestoneEventType.LEARNING_ACTIVITY, + learningActivityId + ) + : await getMilestoneByTypeAndReferenceId(MilestoneEventType.TOOL, id) + + return { + id, + milestoneId: milestone?.id, + milestoneName: extractFirstMilestone( + response.control?.activate_milestone + ), + milestoneCondition: response.control?.milestone_condition, + } + }) + ) + +export const loadTools = async ( + activityMilestones: MappedActivity[], + tools: ToolYaml[] +) => + await Promise.all( + tools.map(async tool => { + const id = await addTool({ + name: tool.name, + tooltipDescription: tool.tooltip_description, + defaultResponse: tool.default_response, + hint: tool.hint, + }) + return await loadToolResponses(id, tool.responses, activityMilestones) + }) + ) + +export const loadToolResponseControls = ( + toolControls: MappedToolResponseControl[], + milestonesWithIds: MappedMilestone[] +) => { + toolControls.forEach( + async control => + control.milestoneCondition && + (await updateToolResponseMilestoneCondition( + control.id, + getMilestoneCondition(control.milestoneCondition, milestonesWithIds) + )) + ) +} diff --git a/frontend/src/editor/yaml/types.tsx b/frontend/src/editor/yaml/types.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8e2a861ab2a3480c82e69ea04e80d33415ab5fd --- /dev/null +++ b/frontend/src/editor/yaml/types.tsx @@ -0,0 +1,176 @@ +import type { LearningActivityType } from '../indexeddb/types' + +export type ConfigYaml = { + exercise_duration: number + email_between_teams?: boolean + custom_email_suffix?: string + show_exercise_time?: boolean + enable_roles?: boolean + version: string +} + +export type ChannelYaml = { + name: string + type: string +} + +export type LearningObjectiveYaml = { + name: string + tags?: string + activities: LearningActivityYaml[] +} + +export type LearningObjectiveYamlWithTypes = { + name: string + tags?: string + activities: LearningActivityYamlWithType[] +} + +export type LearningActivityYaml = { + name: string + tags?: string +} + +export type LearningActivityYamlWithType = LearningActivityYaml & { + type: LearningActivityType +} + +export type ContentYaml = { + content?: string + content_path?: string + file_name?: string +} + +export type ControlYaml = { + milestone_condition?: string + activate_milestone?: string + deactivate_milestone?: string + roles?: string +} + +export type OverlayYaml = { + duration: number +} + +export type ToolYaml = { + name: string + tooltip_description?: string + hint?: string + default_response: string + roles?: string + responses: ToolResponseYaml[] +} + +export type ToolResponseYaml = { + param: string + regex?: boolean + time?: number + content?: ContentYaml + control?: ControlYaml +} + +export type EmailAddressYaml = { + address: string + team_visible?: boolean + description: string + control?: ControlYaml + organization?: string + templates?: EmailTemplateYaml[] +} + +export type EmailTemplateYaml = { + context: string + content?: ContentYaml + control?: ControlYaml +} + +export type InjectCategoryYaml = { + name: string + time?: number + delay?: number + organization?: string + type?: string + alternatives: InjectAlternativeInfoYaml[] | InjectAlternativeEmailYaml[] +} + +export type InjectAlternativeInfoYaml = { + name: string + content?: ContentYaml + control?: ControlYaml + overlay?: OverlayYaml +} + +export type InjectAlternativeEmailYaml = { + name: string + sender: string + subject: string + content?: ContentYaml + control?: ControlYaml + extra_copies?: number + overlay?: OverlayYaml +} + +export type QuestionnaireYaml = { + title: string + time?: number + control?: ControlYaml + overlay?: OverlayYaml + questions: QuestionYaml[] +} + +export type QuestionYaml = { + content: ContentYaml + max: number + labels?: string + correct?: number + control?: ControlYaml +} + +export type MilestoneYaml = { + name: string + roles?: string + file_names?: string + final?: boolean + activity?: string + initial_state?: boolean +} + +export type MappedActivity = { + id: number + milestoneName: string +} + +export type MappedMilestone = { + id: number + name: string +} + +export type MappedInjectControl = { + id: number + milestoneId?: number + milestoneName?: string + milestoneCondition?: string + time?: number + delay?: number +} + +export type MappedQuestionnaireControl = { + id: number + milestoneId?: number + milestoneName?: string + milestoneCondition?: string + time?: number +} + +export type MappedToolResponseControl = { + id: number + milestoneId?: number + milestoneName?: string + milestoneCondition?: string +} + +export type MappedEmailTemplateControl = { + id: number + milestoneId?: number + milestoneName?: string +} diff --git a/frontend/src/editor/downloadDefinitionZip.tsx b/frontend/src/editor/zip/createDefinitionZip.tsx similarity index 59% rename from frontend/src/editor/downloadDefinitionZip.tsx rename to frontend/src/editor/zip/createDefinitionZip.tsx index b276867755c3ae9143f98eda5544c068a4d235fb..9ce541f0829a5a3bdeb9fca3569e353df7b7f164 100644 --- a/frontend/src/editor/downloadDefinitionZip.tsx +++ b/frontend/src/editor/zip/createDefinitionZip.tsx @@ -9,10 +9,22 @@ import { generateQuestionnaires } from '@/editor/yaml/generate/questionnaires' import { generateTools } from '@/editor/yaml/generate/tools' import JSZip from 'jszip' import { stringify } from 'yaml' -import { InjectType } from './indexeddb/types' -import type { EditorConfig } from './useEditorStorage' +import { InjectType } from '../indexeddb/types' +import type { EditorConfig } from '../useEditorStorage' +import { + CHANNELS_FILE_NAME, + CONFIG_FILE_NAME, + CONTENT_FOLDER_NAME, + EMAIL_FILE_NAME, + FILES_FOLDER_NAME, + INJECTS_FILE_NAME, + MILESTONES_FILE_NAME, + OBJECTIVES_FILE_NAME, + QUESTIONNAIRES_FILE_NAME, + TOOLS_FILE_NAME, +} from './utils' -export const downloadDefinitionZip = async (config: EditorConfig) => { +export const createDefinitionZip = async (config: EditorConfig) => { const zip = new JSZip() const generatedChannels = await generateChannels(config) @@ -21,11 +33,11 @@ export const downloadDefinitionZip = async (config: EditorConfig) => { const generatedMilestones = await generateMilestones(config) const generatedObjectives = await generateObjectives() - zip.file('channels.yml', stringify(generatedChannels)) - zip.file('config.yml', stringify(generatedConfig)) - zip.file('injects.yml', stringify(generatedInjects)) - zip.file('milestones.yml', stringify(generatedMilestones)) - zip.file('objectives.yml', stringify(generatedObjectives)) + zip.file(CHANNELS_FILE_NAME, stringify(generatedChannels)) + zip.file(CONFIG_FILE_NAME, stringify(generatedConfig)) + zip.file(INJECTS_FILE_NAME, stringify(generatedInjects)) + zip.file(MILESTONES_FILE_NAME, stringify(generatedMilestones)) + zip.file(OBJECTIVES_FILE_NAME, stringify(generatedObjectives)) const emailAddressesCount = await db.emailAddresses.count() const toolsCount = await db.tools.count() @@ -35,26 +47,36 @@ export const downloadDefinitionZip = async (config: EditorConfig) => { if (emailAddressesCount) { const generatedEmail = await generateEmailAddresses() - zip.file('email.yml', stringify(generatedEmail)) + zip.file(EMAIL_FILE_NAME, stringify(generatedEmail)) } if (questionnairesCount) { const generatedQuestionnaires = await generateQuestionnaires() - zip.file('questionnaires.yml', stringify(generatedQuestionnaires)) + zip.file(QUESTIONNAIRES_FILE_NAME, stringify(generatedQuestionnaires)) } if (toolsCount) { const generatedTools = await generateTools() - zip.file('tools.yml', stringify(generatedTools)) + zip.file(TOOLS_FILE_NAME, stringify(generatedTools)) } const files = await db.files.toArray() if (files.length) { - const filesFolder = zip.folder('files') + const filesFolder = zip.folder(FILES_FOLDER_NAME) files.forEach(file => { filesFolder?.file(file.name, file.blob) }) } + const markdowns = await db.markdownContents.toArray() + + if (markdowns.length) { + const markdownsFolder = zip.folder(CONTENT_FOLDER_NAME) + markdowns.forEach(file => { + markdownsFolder?.file(file.fileName, file.content) + }) + await db.markdownContents.clear() + } + zip.generateAsync({ type: 'blob' }).then(content => { const link = document.createElement('a') link.href = URL.createObjectURL(content) diff --git a/frontend/src/editor/zip/loadDefinitionZip.tsx b/frontend/src/editor/zip/loadDefinitionZip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5f5ee65451042f9e71b1651b48c6738ffa183d3d --- /dev/null +++ b/frontend/src/editor/zip/loadDefinitionZip.tsx @@ -0,0 +1,167 @@ +import notEmpty from '@inject/shared/utils/notEmpty' +import JSZip from 'jszip' +import { parse } from 'yaml' +import { CONCLUSION_CONDITIONS } from '../assets/pageContent/conclusion' +import { INTRODUCTION_CONDITIONS } from '../assets/pageContent/introduction' +import { db } from '../indexeddb/db' +import { addFile, addMarkdownContent } from '../indexeddb/operations' +import type { EditorConfig } from '../useEditorStorage' +import { parseChannels } from '../yaml/parse/channels' +import { parseConfig } from '../yaml/parse/config' +import { loadEmailAddresses } from '../yaml/parse/email' +import { loadInjectControls, loadInjects } from '../yaml/parse/injects' +import { + extractMilestonesFromControls, + filterEmailMilestones, + filterToolMilestones, +} from '../yaml/parse/milestones' +import { + addActivityIdsToActivityMilestones, + addTypesToActivities, + loadObjectives, +} from '../yaml/parse/objectives' +import { + loadQuestionnaireControls, + loadQuestionnaires, +} from '../yaml/parse/questionnaires' +import { loadToolResponseControls, loadTools } from '../yaml/parse/tools' +import type { MilestoneYaml } from '../yaml/types' +import { + CHANNELS_FILE_NAME, + CONFIG_FILE_NAME, + CONTENT_FOLDER_NAME, + EMAIL_FILE_NAME, + FILES_FOLDER_NAME, + INJECTS_FILE_NAME, + MILESTONES_FILE_NAME, + OBJECTIVES_FILE_NAME, + QUESTIONNAIRES_FILE_NAME, + TOOLS_FILE_NAME, +} from './utils' + +const parseZipFile = async (zip: JSZip, fileName: string) => { + const file = await zip.file(fileName)?.async('string') + return parse(file || '') +} + +export const loadEditorConfig = async (file: File): Promise<EditorConfig> => { + const zip = await new JSZip().loadAsync(file) + + const configYaml = await parseZipFile(zip, CONFIG_FILE_NAME) + const parsedConfig = parseConfig(configYaml) + + const channelsYaml = await parseZipFile(zip, CHANNELS_FILE_NAME) + const parsedChannels = parseChannels(channelsYaml) + + const config = { + introChecked: INTRODUCTION_CONDITIONS.map(() => true), + conclusionChecked: CONCLUSION_CONDITIONS.map(() => true), + name: file.name, + description: 'Description', + trainee: 'Trainee', + ...parsedConfig, + ...parsedChannels, + } + + return config +} + +const preloadFilesToDb = async (zip: JSZip) => { + const filesFolder = zip.folder(FILES_FOLDER_NAME) + filesFolder?.forEach(async (path, entry) => { + const fileContent = await entry.async('blob') + await addFile({ name: path, blob: fileContent }) + }) + + const contentFolder = zip.folder(CONTENT_FOLDER_NAME) + contentFolder?.forEach(async (path, entry) => { + const fileContent = await entry.async('string') + await addMarkdownContent({ fileName: path, content: fileContent }) + }) +} + +export const loadDbData = async (file: File) => { + const zip = await new JSZip().loadAsync(file) + + await preloadFilesToDb(zip) + + const emailYaml = await parseZipFile(zip, EMAIL_FILE_NAME) + const injectsYaml = await parseZipFile(zip, INJECTS_FILE_NAME) + const milestonesYaml = await parseZipFile(zip, MILESTONES_FILE_NAME) + const objectivesYaml = await parseZipFile(zip, OBJECTIVES_FILE_NAME) + const questionnairesYaml = await parseZipFile(zip, QUESTIONNAIRES_FILE_NAME) + const toolsYaml = await parseZipFile(zip, TOOLS_FILE_NAME) + + const activityMilestones = milestonesYaml.filter( + (milestone: MilestoneYaml) => milestone.activity + ) + + // get tool responses that activate LA milestone + const toolMilestones = toolsYaml + ? filterToolMilestones(activityMilestones, toolsYaml) + : [] + // get email templates that activate LA milestone + const emailMilestones = emailYaml + ? filterEmailMilestones(activityMilestones, emailYaml) + : [] + // add channel type to activities + const objectivesWithActivityTypes = addTypesToActivities( + objectivesYaml, + toolMilestones, + emailMilestones + ) + const activities = (await loadObjectives(objectivesWithActivityTypes)).flat(1) + + // get mapping: definition milestone name -> learning activity id + milestone id + const activityMilestonesWithIds = addActivityIdsToActivityMilestones( + activityMilestones, + activities + ).filter(notEmpty) + + /* + * when adding email templates: add LA id AND get mapping milestone name - email template id + * needs to happen before loading injects, so the email addresses are already stored + */ + const emailControls = emailYaml + ? (await loadEmailAddresses(activityMilestonesWithIds, emailYaml)).flat(1) + : [] + + // when adding tool responses: add LA id AND get mapping milestone name - tool response id + save milestone condition + const toolControls = toolsYaml + ? (await loadTools(activityMilestonesWithIds, toolsYaml)).flat(1) + : [] + + // when adding questionnaires: get mapping milestone name - inject id + save milestone condition + const questionnaireControls = questionnairesYaml + ? await loadQuestionnaires(questionnairesYaml) + : [] + + // when adding injects: get mapping milestone name - inject id + save milestone condition + group alternatives + const injectControlGroups = (await loadInjects(injectsYaml)).filter(notEmpty) + const injectControls = injectControlGroups.flat(1) + + // milestone from definition to milestone id + const milestonesWithIds = extractMilestonesFromControls( + toolControls, + emailControls, + injectControls, + questionnaireControls + ) + + loadToolResponseControls(toolControls, milestonesWithIds) + loadQuestionnaireControls(questionnaireControls, milestonesWithIds) + loadInjectControls(injectControlGroups, milestonesWithIds) + + // get final milestone + const finalMilestone: MilestoneYaml = milestonesYaml.find( + (milestone: MilestoneYaml) => milestone.final + ) + const finalMilestoneId = milestonesWithIds.find( + milestone => milestone.name === finalMilestone.name + )?.id + + // remove temporary content + await db.markdownContents.clear() + + return finalMilestoneId +} diff --git a/frontend/src/editor/zip/utils.tsx b/frontend/src/editor/zip/utils.tsx new file mode 100644 index 0000000000000000000000000000000000000000..859e97c8452d70d820248d8f299aeeb4c1de72ac --- /dev/null +++ b/frontend/src/editor/zip/utils.tsx @@ -0,0 +1,11 @@ +export const CHANNELS_FILE_NAME = 'channels.yml' +export const CONFIG_FILE_NAME = 'config.yml' +export const EMAIL_FILE_NAME = 'email.yml' +export const INJECTS_FILE_NAME = 'injects.yml' +export const MILESTONES_FILE_NAME = 'milestones.yml' +export const OBJECTIVES_FILE_NAME = 'objectives.yml' +export const QUESTIONNAIRES_FILE_NAME = 'questionnaires.yml' +export const TOOLS_FILE_NAME = 'tools.yml' + +export const FILES_FOLDER_NAME = 'files' +export const CONTENT_FOLDER_NAME = 'content' diff --git a/frontend/src/pages/editor/create/other.tsx b/frontend/src/pages/editor/create/other.tsx index 5efc3b510699ff27e7795e3725cb524bbb50ebcd..b171943363d50ed982033ecc775186d70593d61e 100644 --- a/frontend/src/pages/editor/create/other.tsx +++ b/frontend/src/pages/editor/create/other.tsx @@ -15,6 +15,7 @@ const OtherPage = () => { prevPath='/editor/create/inject-specification' nextPath='/editor/create/final-information' pageVisible={access?.injectsFilled} + nextDisabled={!access?.specificationsFilled} > <CardList> <Card interactive onClick={() => nav(`/editor/create/tools`)}>