diff --git a/frontend/src/editor/CommitDefinition/ProjectPanel.tsx b/frontend/src/editor/CommitDefinition/ProjectPanel.tsx index 49aba361fb2ba36d4850999451bc3e5be500351c..da0a9ab7409304212e77b9fde63ca8c12be221f1 100644 --- a/frontend/src/editor/CommitDefinition/ProjectPanel.tsx +++ b/frontend/src/editor/CommitDefinition/ProjectPanel.tsx @@ -4,6 +4,10 @@ import type { FC } from 'react' import { memo } from 'react' import TooltipCheckbox from '../Tooltips/TooltipCheckbox' import TooltipLabel from '../Tooltips/TooltipLabel' +import { + COMMIT_PROJECT_PANEL_CONTENT_1, + COMMIT_PROJECT_PANEL_CONTENT_2, +} from '../assets/dialogContent' import { COMMIT_FORM } from '../assets/pageContent/gitlab' import useGitlabStorage from '../useGitlabStorage' @@ -48,9 +52,8 @@ const ProjectPanel: FC<ProjectPanelProps> = ({ )} {!newProject && ( <p> - The definition changes will be commited to the{' '} - <b>{gitlabConfig?.project?.name}</b> project. To create a new project, - click on the new project option and fill in the necessary information. + {COMMIT_PROJECT_PANEL_CONTENT_1} <b>{gitlabConfig?.project?.name}</b>{' '} + {COMMIT_PROJECT_PANEL_CONTENT_2} </p> )} {(!gitlabConfig?.project || newProject) && ( diff --git a/frontend/src/editor/CommitDefinition/index.tsx b/frontend/src/editor/CommitDefinition/index.tsx index 8fd5a2b2aac918e53d222769a8915ad0b220c5fd..7016e305199de567d72ce93d73737631a787ae10 100644 --- a/frontend/src/editor/CommitDefinition/index.tsx +++ b/frontend/src/editor/CommitDefinition/index.tsx @@ -3,6 +3,7 @@ import { Button, ButtonGroup, Dialog, DialogFooter } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import useBranches from '../BranchSelector/useBranches' +import { GENERIC_CONTENT } from '../assets/generalContent' import { commitDefinitionFiles, createBranch, @@ -141,7 +142,8 @@ const CommitDefinition = () => { ]) const nextButtonText = useMemo( - () => (currentStep === STEPS.COMMIT ? 'Commit' : 'Next'), + () => + currentStep === STEPS.COMMIT ? 'Commit' : GENERIC_CONTENT.buttons.next, [currentStep] ) const projectUnfilled = useMemo( @@ -231,7 +233,7 @@ const CommitDefinition = () => { <Button onClick={() => prevStep()} icon='arrow-left' - text='Back' + text={GENERIC_CONTENT.buttons.back} /> )} <Button diff --git a/frontend/src/editor/DataRemovalDialog/index.tsx b/frontend/src/editor/DataRemovalDialog/index.tsx index 7d579872160e7361429d95920380ff616667d998..949ae8d3680f178cb39d7ea67d332d3607229104 100644 --- a/frontend/src/editor/DataRemovalDialog/index.tsx +++ b/frontend/src/editor/DataRemovalDialog/index.tsx @@ -9,6 +9,8 @@ import { DialogFooter, } from '@blueprintjs/core' import { memo, useCallback, useState, type FC } from 'react' +import { DATA_REMOVAL_CONTENT } from '../assets/dialogContent' +import { GENERIC_CONTENT } from '../assets/generalContent' import { clearDb } from '../indexeddb/operations' import useEditorAccessStorage from '../useEditorAccessStorage' import useEditorStorage from '../useEditorStorage' @@ -49,10 +51,7 @@ const DataRemovalDialog: FC<DataRemovalDialogProps> = ({ title='Clear definition' > <DialogBody> - <p> - This will permanently delete all data in the editor. Are you sure - you want to clear the definition? - </p> + <p>{DATA_REMOVAL_CONTENT}</p> </DialogBody> <DialogFooter actions={ @@ -60,7 +59,7 @@ const DataRemovalDialog: FC<DataRemovalDialogProps> = ({ <Button onClick={() => setIsOpen(false)} icon='cross' - text='Cancel' + text={GENERIC_CONTENT.buttons.cancel} /> <Button onClick={() => handleConfirmButton()} diff --git a/frontend/src/editor/DefinitionImportDialog/index.tsx b/frontend/src/editor/DefinitionImportDialog/index.tsx index a00be62ad63d3e38eefd7a62645b2f677708c33c..6708eb61eb608b659f2cfaa146d262a22f29ac5b 100644 --- a/frontend/src/editor/DefinitionImportDialog/index.tsx +++ b/frontend/src/editor/DefinitionImportDialog/index.tsx @@ -7,9 +7,16 @@ import { DialogBody, DialogFooter, } from '@blueprintjs/core' +import { useAuthIdentity } from '@inject/graphql/auth' +import { useHost } from '@inject/graphql/connection/host' +import { validateDefinitionUrl } from '@inject/shared/config' +import { notify } from '@inject/shared/notification/engine' +import csrfFetch from '@inject/shared/utils/csrfFetch' import JSZip from 'jszip' import type { FC, ReactNode } from 'react' import { memo, useCallback, useState } from 'react' +import { DEFINITION_IMPORT_CONTENT } from '../assets/dialogContent' +import { GENERIC_CONTENT } from '../assets/generalContent' import { clearDb } from '../indexeddb/operations' import useEditorStorage from '../useEditorStorage' import type { GitlabConfig } from '../useGitlabStorage' @@ -31,11 +38,32 @@ const DefinitionImportDialog: FC<DefinitionImportDialogProps> = ({ const [isOpen, setIsOpen] = useState(false) const [, setConfig] = useEditorStorage() const nav = useNavigate() + const host = useHost() + const { isStaff } = useAuthIdentity() const handleAdd = useCallback(async () => { const result = await onAdd() if (!result || !result.file) return + const data = new FormData() + data.append('file', result.file) + + if (isStaff) { + csrfFetch(validateDefinitionUrl(host || ''), { + method: 'POST', + body: data, + credentials: 'include', + }) + .then(res => res.json()) + .then((res: { status: string; detail: string }) => { + if (res.status === 'error') { + notify(res.detail, { intent: 'danger' }) + return + } + notify(res.detail, { intent: 'success' }) + }) + } + await clearDb() const zip = await new JSZip().loadAsync(result.file) @@ -57,10 +85,7 @@ const DefinitionImportDialog: FC<DefinitionImportDialogProps> = ({ title='Upload definition' > <DialogBody> - <p> - This will permanently delete all data in the editor. Are you sure - you want to upload new definition? - </p> + <p>{DEFINITION_IMPORT_CONTENT}</p> {children} </DialogBody> <DialogFooter @@ -69,14 +94,14 @@ const DefinitionImportDialog: FC<DefinitionImportDialogProps> = ({ <Button onClick={() => setIsOpen(false)} icon='cross' - text='Cancel' + text={GENERIC_CONTENT.buttons.cancel} /> <Button disabled={addDisabled} onClick={() => handleAdd()} intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> </ButtonGroup> } diff --git a/frontend/src/editor/DownloadDefinition/index.tsx b/frontend/src/editor/DownloadDefinition/index.tsx index b8af1c7537063d823e1159283a5f5d266c9e49fc..ef822f3d0373f7e8ee40697487190f911f061474 100644 --- a/frontend/src/editor/DownloadDefinition/index.tsx +++ b/frontend/src/editor/DownloadDefinition/index.tsx @@ -4,6 +4,7 @@ import { Button, ButtonGroup } from '@blueprintjs/core' import { memo } from 'react' import CommitDefinition from '../CommitDefinition' import DataRemovalDialog from '../DataRemovalDialog' +import { GENERIC_CONTENT } from '../assets/generalContent' const DownloadDefinition = () => { const [config] = useEditorStorage() @@ -26,7 +27,7 @@ const DownloadDefinition = () => { confirmButtonProps={{ intent: 'primary', icon: 'tick', - text: 'Confirm', + text: GENERIC_CONTENT.buttons.confirm, }} redirectTo='/editor' /> diff --git a/frontend/src/editor/EditorPage/index.tsx b/frontend/src/editor/EditorPage/index.tsx index deaaab7751053a59abb9631c771379a7548dfe7e..ade0fbf1631fb2f4692b2015aed73b5535c6d39d 100644 --- a/frontend/src/editor/EditorPage/index.tsx +++ b/frontend/src/editor/EditorPage/index.tsx @@ -1,10 +1,11 @@ import type { Path } from '@/router' import { useNavigate } from '@/router' -import { Button } from '@blueprintjs/core' +import { Button, NonIdealState } from '@blueprintjs/core' import { css } from '@emotion/css' import type { FC, ReactNode } from 'react' import { memo } from 'react' import FaqSection from '../FaqSection' +import { GENERIC_CONTENT } from '../assets/generalContent' import { PAGE_INFORMATION } from '../assets/pageInformation' import type { PageNames } from '../types' @@ -46,7 +47,7 @@ const EditorPage: FC<EditorPageProps> = ({ const content = PAGE_INFORMATION[pageKey] if (!pageVisible) { - nav('/editor') + return <NonIdealState title='Page not found' icon='warning-sign' /> } return ( @@ -77,7 +78,7 @@ const EditorPage: FC<EditorPageProps> = ({ <Button type='button' onClick={() => nav(prevPath)} - text='Back' + text={GENERIC_CONTENT.buttons.back} icon='arrow-left' style={{ marginRight: '0.5rem', @@ -87,7 +88,7 @@ const EditorPage: FC<EditorPageProps> = ({ <Button type='button' onClick={() => nav(nextPath || '/')} - text='Next' + text={GENERIC_CONTENT.buttons.next} intent='primary' rightIcon='arrow-right' disabled={nextDisabled} diff --git a/frontend/src/editor/EmailAddressForm/index.tsx b/frontend/src/editor/EmailAddressForm/index.tsx index 72f1692918485a88db3b669e015473478920d8ba..817e27371e96d1463333d683222b586e14f22abf 100644 --- a/frontend/src/editor/EmailAddressForm/index.tsx +++ b/frontend/src/editor/EmailAddressForm/index.tsx @@ -15,6 +15,7 @@ import type { FC } from 'react' import { memo, useCallback, useEffect, useState } from 'react' import TooltipCheckbox from '../Tooltips/TooltipCheckbox' import TooltipLabel from '../Tooltips/TooltipLabel' +import { GENERIC_CONTENT } from '../assets/generalContent' import { EMAIL_ADDRESS_FORM } from '../assets/pageContent/emails' import type { EmailAddressInfo } from '../indexeddb/types' @@ -140,7 +141,7 @@ const EmailAddressForm: FC<EmailAddressFormProps> = ({ } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -155,7 +156,7 @@ const EmailAddressForm: FC<EmailAddressFormProps> = ({ } intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/EmailAddresses/EmailAddress.tsx b/frontend/src/editor/EmailAddresses/EmailAddress.tsx index e3e95d670478af2fccc5342e52757d2646d48c96..e5f8a08135ad37cb7932b7049c7d8e2ef9308b5a 100644 --- a/frontend/src/editor/EmailAddresses/EmailAddress.tsx +++ b/frontend/src/editor/EmailAddresses/EmailAddress.tsx @@ -1,4 +1,5 @@ import { Button, ButtonGroup, Card } from '@blueprintjs/core' +import { css } from '@emotion/css' import { notify } from '@inject/shared/notification/engine' import type { FC } from 'react' import { memo, useCallback } from 'react' @@ -8,6 +9,24 @@ import EmailTemplates from '../EmailTemplates' import { deleteEmailAddress } from '../indexeddb/operations' import type { EmailAddressInfo } from '../indexeddb/types' +const emailCard = css` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 0rem 1rem 1rem 0; +` + +const emailCardName = css` + height: 100%; + flex-grow: 1; +` + +const emailTemplates = css` + width: 100%; + padding-left: 2rem; +` + interface EmailAddressProps { emailAddress: EmailAddressInfo } @@ -31,18 +50,8 @@ const EmailAddressItem: FC<EmailAddressProps> = ({ emailAddress }) => { return ( <Card style={{ flexDirection: 'column' }}> - <div - style={{ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - padding: '0rem 1rem 1rem 0', - }} - > - <span style={{ height: '100%', flexGrow: 1 }}> - {emailAddress.address} - </span> + <div className={emailCard}> + <span className={emailCardName}>{emailAddress.address}</span> <ButtonGroup> <EmailAddressForm emailAddressInfo={emailAddress} @@ -59,7 +68,7 @@ const EmailAddressItem: FC<EmailAddressProps> = ({ emailAddress }) => { /> </ButtonGroup> </div> - <div style={{ width: '100%', paddingLeft: '2rem' }}> + <div className={emailTemplates}> <EmailTemplates emailAddressId={Number(emailAddress.id)} /> <EmailTemplateFormDialog emailAddressId={Number(emailAddress.id)} diff --git a/frontend/src/editor/EmailTemplateFormDialog/index.tsx b/frontend/src/editor/EmailTemplateFormDialog/index.tsx index 1f06651537ef1a5818a839c1c016017340f2a033..cd1b0a3b331fd17c98fb46a7800f0948000d8416 100644 --- a/frontend/src/editor/EmailTemplateFormDialog/index.tsx +++ b/frontend/src/editor/EmailTemplateFormDialog/index.tsx @@ -3,6 +3,7 @@ import { Button, Dialog, DialogBody, DialogFooter } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { memo, useCallback, useEffect, useState, type FC } from 'react' import EmailTemplateFormContent from '../EmailTemplateFormContent' +import { GENERIC_CONTENT } from '../assets/generalContent' import { addEmailTemplate, updateEmailTemplate } from '../indexeddb/operations' import type { EmailTemplate } from '../indexeddb/types' @@ -106,7 +107,7 @@ const EmailTemplateFormDialog: FC<EmailTemplateFormDialogProps> = ({ } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -121,7 +122,7 @@ const EmailTemplateFormDialog: FC<EmailTemplateFormDialogProps> = ({ } intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/ExpressionBuilder/ExpressionBlock.tsx b/frontend/src/editor/ExpressionBuilder/ExpressionBlock.tsx index 9667555b93d99e902d888ddf9e48551826bb34fe..736deb0b33254e2f314366ca0a52cac0f0acbc8d 100644 --- a/frontend/src/editor/ExpressionBuilder/ExpressionBlock.tsx +++ b/frontend/src/editor/ExpressionBuilder/ExpressionBlock.tsx @@ -48,7 +48,7 @@ const ExpressionBlock: FC<ExpressionBlockProps> = ({ onChange={event => { const selectedOption = event.currentTarget.selectedOptions[0] onModify({ - value: selectedOption.value, + value: Number(selectedOption.value), label: selectedOption.label, }) }} diff --git a/frontend/src/editor/ExpressionBuilder/index.tsx b/frontend/src/editor/ExpressionBuilder/index.tsx index 7d82dd9eab1510b9983ea2caaf48a4dd3c4b1fe6..6fd3c06bae18649332e3ffff21640dff06c86076 100644 --- a/frontend/src/editor/ExpressionBuilder/index.tsx +++ b/frontend/src/editor/ExpressionBuilder/index.tsx @@ -186,7 +186,7 @@ const ExpressionBuilder: FC<ExpressionBuilderProps> = ({ onChange={event => { const selectedOption = event.currentTarget.selectedOptions[0] addBlock({ - value: selectedOption.value, + value: Number(selectedOption.value), label: selectedOption.label, }) }} diff --git a/frontend/src/editor/ExpressionBuilder/useValidateExpression.tsx b/frontend/src/editor/ExpressionBuilder/useValidateExpression.tsx index 71383eddd6efb64dbd7d85ea7a9e43463e96ba71..fe271e9f8ff67314c791e09d2c044b385bb0229b 100644 --- a/frontend/src/editor/ExpressionBuilder/useValidateExpression.tsx +++ b/frontend/src/editor/ExpressionBuilder/useValidateExpression.tsx @@ -1,20 +1,14 @@ import { useLiveQuery } from 'dexie-react-hooks' import { useMemo } from 'react' -import { getMilestonesWithNames } from '../indexeddb/joins' -import { getExpression, getVariables, validateExpression } from '../utils' +import { db } from '../indexeddb/db' +import { validateExpression } from '../utils' const useValidateExpression = (expression?: number[]) => { - const milestones = useLiveQuery(() => getMilestonesWithNames(), [], []) - - const variables = useMemo(() => getVariables(milestones), [milestones]) - const newExpression = useMemo( - () => getExpression(expression || [], variables), - [expression, variables] - ) + const milestones = useLiveQuery(() => db.milestones.toArray(), [], []) const { isValid, error } = useMemo( - () => validateExpression(newExpression, variables), - [newExpression, variables] + () => validateExpression(expression || [], milestones), + [expression, milestones] ) return { isValid, error } diff --git a/frontend/src/editor/FileUploader/index.tsx b/frontend/src/editor/FileUploader/index.tsx index 93966ab009f4d5190faf0f6fa209b86b40ab65c0..bdcf8f5f9b32b530bbf61cfb36f6183f06a5fb30 100644 --- a/frontend/src/editor/FileUploader/index.tsx +++ b/frontend/src/editor/FileUploader/index.tsx @@ -12,6 +12,7 @@ import { notify } from '@inject/shared/notification/engine' import type { ChangeEvent, FC } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import TooltipLabel from '../Tooltips/TooltipLabel' +import { GENERIC_CONTENT } from '../assets/generalContent' import { FILE_UPLOAD_FORM } from '../assets/pageContent/files' import { addFile, updateFile } from '../indexeddb/operations' import type { ContentFile } from '../indexeddb/types' @@ -130,7 +131,7 @@ const FileUploader: FC<FileUploaderProps> = ({ } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -145,7 +146,7 @@ const FileUploader: FC<FileUploaderProps> = ({ } intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/FinalInformationForm/FinalMilestoneForm.tsx b/frontend/src/editor/FinalInformationForm/FinalMilestoneForm.tsx index 5348f3348fecd5636a1539f3dab78cd0f7cf3ef2..e5fc1e9fa4e6638d2d1a9a4f129212513fe1399b 100644 --- a/frontend/src/editor/FinalInformationForm/FinalMilestoneForm.tsx +++ b/frontend/src/editor/FinalInformationForm/FinalMilestoneForm.tsx @@ -1,7 +1,7 @@ import type { OptionProps } from '@blueprintjs/core' import { HTMLSelect } from '@blueprintjs/core' import { useLiveQuery } from 'dexie-react-hooks' -import { memo, useEffect, useMemo, type FC } from 'react' +import { memo, useMemo, type FC } from 'react' import TooltipLabel from '../Tooltips/TooltipLabel' import { FINAL_MILESTONE_FORM } from '../assets/pageContent/finalInformation' import { getMilestonesWithNames } from '../indexeddb/joins' @@ -29,15 +29,9 @@ const FinalMilestoneForm: FC<FinalMilestoneFormProps> = ({ ] } - return getVariables(milestones) + return [{ label: 'none', value: 0 }, ...getVariables(milestones)] }, [milestones]) - useEffect(() => { - if (!finalMilestone && milestones && milestones.length > 0) { - onFinalMilestoneChange(milestones[0].id) - } - }, [finalMilestone, milestones]) - return ( <TooltipLabel label={FINAL_MILESTONE_FORM.finalMilestone}> <HTMLSelect diff --git a/frontend/src/editor/InjectForm/index.tsx b/frontend/src/editor/InjectForm/index.tsx index 54edeb79fe43e56911f64836c1516be8dc3c0eeb..3b22a57359006cc523bede1c8e37055cd5d4ea39 100644 --- a/frontend/src/editor/InjectForm/index.tsx +++ b/frontend/src/editor/InjectForm/index.tsx @@ -12,6 +12,7 @@ import { notify } from '@inject/shared/notification/engine' import type { FC } from 'react' import { memo, useCallback, useEffect, useState } from 'react' import TooltipLabel from '../Tooltips/TooltipLabel' +import { GENERIC_CONTENT } from '../assets/generalContent' import { INJECT_FORM } from '../assets/pageContent/injects' import { addInjectInfo, updateInjectInfo } from '../indexeddb/operations' import type { InjectInfo } from '../indexeddb/types' @@ -124,7 +125,7 @@ const InjectForm: FC<InjectFormProps> = ({ inject, buttonProps }) => { } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -132,7 +133,7 @@ const InjectForm: FC<InjectFormProps> = ({ inject, buttonProps }) => { onClick={() => handleAddButton({ name, description, type })} intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx b/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx index 68ff975434df41496e2fab231cbbb22ba01a28da..961f526b24ddf267e0ff6b36249e925f97d8dc40 100644 --- a/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx +++ b/frontend/src/editor/InjectSpecification/ConnectionsForm.tsx @@ -1,9 +1,10 @@ -import { Button, NumericInput } from '@blueprintjs/core' +import { NumericInput } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' import ExpressionBuilder from '../ExpressionBuilder' import useValidateExpression from '../ExpressionBuilder/useValidateExpression' +import SaveButtonGroup from '../SaveButtonGroup' import TooltipLabel from '../Tooltips/TooltipLabel' import { INJECT_CONNECTIONS_FORM } from '../assets/pageContent/injectSpecification' import { @@ -17,11 +18,15 @@ import { InjectType } from '../indexeddb/types' interface ConnectionsFormProps { injectInfoId: number injectType: InjectType + changed: boolean + onChangedChange: (value: boolean) => void } const ConnectionsForm: FC<ConnectionsFormProps> = ({ injectInfoId, injectType, + changed, + onChangedChange, }) => { const injectControl = useLiveQuery( () => getInjectControlByInjectInfoId(injectInfoId), @@ -42,7 +47,7 @@ const ConnectionsForm: FC<ConnectionsFormProps> = ({ setMilestoneCondition(injectControl?.milestoneCondition || []) }, [injectControl, injectType]) - const handleUpdateButton = useCallback( + const update = useCallback( async (newInjectControl: InjectControl | Omit<InjectControl, 'id'>) => { try { if (injectControl) { @@ -62,6 +67,21 @@ const ConnectionsForm: FC<ConnectionsFormProps> = ({ [notify, injectControl] ) + const handleUpdate = useCallback(async () => { + if (!changed) onChangedChange(true) + await update({ + injectInfoId, + start, + delay, + milestoneCondition: isValid ? milestoneCondition : [], + }) + onChangedChange(false) + }, [changed, injectInfoId, start, delay, milestoneCondition, isValid, update]) + + useEffect(() => { + if (changed) handleUpdate() + }, [changed, handleUpdate]) + return ( <div> <TooltipLabel label={INJECT_CONNECTIONS_FORM.time}> @@ -87,19 +107,10 @@ const ConnectionsForm: FC<ConnectionsFormProps> = ({ initExpression={injectControl?.milestoneCondition} onExpressionChange={expression => setMilestoneCondition(expression)} /> - <Button - disabled={!isValid} - onClick={() => - handleUpdateButton({ - injectInfoId, - start, - delay, - milestoneCondition: isValid ? milestoneCondition : [], - }) - } - intent='primary' - icon='edit' - text='Save changes' + <SaveButtonGroup + isValid={isValid} + handleUpdate={() => handleUpdate()} + prevPath='/editor/create/inject-specification' /> </div> ) diff --git a/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx b/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx index 239ce31678f73f84a3e9b8e0bbb45f66439c8f8a..b92df38e1b85231d761fbdbe9ea3a42659689cb3 100644 --- a/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx +++ b/frontend/src/editor/InjectSpecification/EmailInjectForm.tsx @@ -1,9 +1,10 @@ -import { Button, InputGroup, NumericInput, TextArea } from '@blueprintjs/core' +import { InputGroup, NumericInput, TextArea } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' import EmailAddressSelector from '../EmailAddressSelector' import FileSelector from '../FileSelector' +import SaveButtonGroup from '../SaveButtonGroup' import TooltipLabel from '../Tooltips/TooltipLabel' import { EMAIL_ADDRESS_FORM } from '../assets/pageContent/emails' import { EMAIL_INJECT_FORM } from '../assets/pageContent/injectSpecification' @@ -16,9 +17,15 @@ import type { EmailInject } from '../indexeddb/types' interface EmailInjectFormProps { injectInfoId: number + changed: boolean + onChangedChange: (value: boolean) => void } -const EmailInjectForm: FC<EmailInjectFormProps> = ({ injectInfoId }) => { +const EmailInjectForm: FC<EmailInjectFormProps> = ({ + injectInfoId, + changed, + onChangedChange, +}) => { const emailInject = useLiveQuery( () => getEmailInjectByInjectInfoId(injectInfoId), [injectInfoId], @@ -39,7 +46,7 @@ const EmailInjectForm: FC<EmailInjectFormProps> = ({ injectInfoId }) => { setFileId(emailInject?.fileId || 0) }, [emailInject]) - const handleUpdateButton = useCallback( + const updateInject = useCallback( async (newEmailInject: EmailInject | Omit<EmailInject, 'id'>) => { try { if (emailInject) { @@ -56,6 +63,32 @@ const EmailInjectForm: FC<EmailInjectFormProps> = ({ injectInfoId }) => { [notify, emailInject] ) + const handleUpdate = useCallback(async () => { + if (!changed) onChangedChange(true) + await updateInject({ + injectInfoId, + subject, + content, + emailAddressId: selectedAddressId, + extraCopies, + fileId, + }) + onChangedChange(false) + }, [ + changed, + injectInfoId, + subject, + content, + selectedAddressId, + extraCopies, + fileId, + updateInject, + ]) + + useEffect(() => { + if (changed) handleUpdate() + }, [changed, handleUpdate]) + return ( <div> <EmailAddressSelector @@ -96,20 +129,10 @@ const EmailInjectForm: FC<EmailInjectFormProps> = ({ injectInfoId }) => { fileId={fileId} onChange={id => setFileId(id)} /> - <Button - onClick={() => - handleUpdateButton({ - injectInfoId, - subject, - content, - emailAddressId: selectedAddressId, - extraCopies, - fileId, - }) - } - intent='primary' - icon='edit' - text='Save changes' + <SaveButtonGroup + isValid + handleUpdate={() => handleUpdate()} + prevPath='/editor/create/inject-specification' /> </div> ) diff --git a/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx b/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx index e88a6f33f3b88ef263dfd67a436b16cbb4b9bcfc..d5c4cc85dd4f68966ce2b9dba35866f9d71a6eff 100644 --- a/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx +++ b/frontend/src/editor/InjectSpecification/InformationInjectForm.tsx @@ -1,8 +1,9 @@ -import { Button, TextArea } from '@blueprintjs/core' +import { TextArea } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' import FileSelector from '../FileSelector' +import SaveButtonGroup from '../SaveButtonGroup' import TooltipLabel from '../Tooltips/TooltipLabel' import { INFORMATION_INJECT_FORM } from '../assets/pageContent/injectSpecification' import { @@ -14,10 +15,14 @@ import type { InformationInject } from '../indexeddb/types' interface InformationInjectFormProps { injectInfoId: number + changed: boolean + onChangedChange: (value: boolean) => void } const InformationInjectForm: FC<InformationInjectFormProps> = ({ injectInfoId, + changed, + onChangedChange, }) => { const informationInject = useLiveQuery( () => getInformationInjectByInjectInfoId(injectInfoId), @@ -33,7 +38,7 @@ const InformationInjectForm: FC<InformationInjectFormProps> = ({ setFileId(informationInject?.fileId || 0) }, [informationInject]) - const handleUpdateButton = useCallback( + const updateInject = useCallback( async ( newInformationInject: InformationInject | Omit<InformationInject, 'id'> ) => { @@ -55,6 +60,20 @@ const InformationInjectForm: FC<InformationInjectFormProps> = ({ [notify, informationInject] ) + const handleUpdate = useCallback(async () => { + if (!changed) onChangedChange(true) + await updateInject({ + injectInfoId, + content, + fileId, + }) + onChangedChange(false) + }, [changed, injectInfoId, content, fileId, updateInject]) + + useEffect(() => { + if (changed) handleUpdate() + }, [changed, handleUpdate]) + return ( <div> <TooltipLabel label={INFORMATION_INJECT_FORM.content}> @@ -75,17 +94,10 @@ const InformationInjectForm: FC<InformationInjectFormProps> = ({ fileId={fileId} onChange={id => setFileId(id)} /> - <Button - onClick={() => - handleUpdateButton({ - injectInfoId, - content, - fileId, - }) - } - intent='primary' - icon='edit' - text='Save changes' + <SaveButtonGroup + isValid + handleUpdate={() => handleUpdate()} + prevPath='/editor/create/inject-specification' /> </div> ) diff --git a/frontend/src/editor/InjectSpecification/OverlayForm.tsx b/frontend/src/editor/InjectSpecification/OverlayForm.tsx index 9489237f2054df83028505d82f243c23576370eb..1ff87252014da8c657b15deca7d56927b311dfe9 100644 --- a/frontend/src/editor/InjectSpecification/OverlayForm.tsx +++ b/frontend/src/editor/InjectSpecification/OverlayForm.tsx @@ -1,7 +1,8 @@ -import { Button, NumericInput } from '@blueprintjs/core' +import { NumericInput } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' +import SaveButtonGroup from '../SaveButtonGroup' import TooltipCheckbox from '../Tooltips/TooltipCheckbox' import TooltipLabel from '../Tooltips/TooltipLabel' import { OVERLAY_FORM } from '../assets/pageContent/injectSpecification' @@ -15,9 +16,15 @@ import type { Overlay } from '../indexeddb/types' interface OverlayFormProps { injectInfoId: number + changed: boolean + onChangedChange: (value: boolean) => void } -const OverlayForm: FC<OverlayFormProps> = ({ injectInfoId }) => { +const OverlayForm: FC<OverlayFormProps> = ({ + injectInfoId, + changed, + onChangedChange, +}) => { const overlay = useLiveQuery( () => getOverlayByInjectInfoId(injectInfoId), [injectInfoId], @@ -32,7 +39,7 @@ const OverlayForm: FC<OverlayFormProps> = ({ injectInfoId }) => { setDuration(overlay?.duration || 1) }, [overlay]) - const handleUpdateButton = useCallback( + const update = useCallback( async (newOverlay: Overlay | Omit<Overlay, 'id'>) => { try { if (overlay) { @@ -59,6 +66,19 @@ const OverlayForm: FC<OverlayFormProps> = ({ injectInfoId }) => { [notify, overlay, enableOverlay] ) + const handleUpdate = useCallback(async () => { + if (!changed) onChangedChange(true) + await update({ + injectInfoId, + duration, + }) + onChangedChange(false) + }, [changed, injectInfoId, duration, update]) + + useEffect(() => { + if (changed) handleUpdate() + }, [changed, handleUpdate]) + return ( <div> <TooltipCheckbox @@ -78,16 +98,10 @@ const OverlayForm: FC<OverlayFormProps> = ({ injectInfoId }) => { /> </TooltipLabel> )} - <Button - onClick={() => - handleUpdateButton({ - injectInfoId, - duration, - }) - } - intent='primary' - icon='edit' - text='Save changes' + <SaveButtonGroup + isValid + handleUpdate={() => handleUpdate()} + prevPath='/editor/create/inject-specification' /> </div> ) diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestionForm.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestionForm.tsx index 31270ca1fff24af43e6fa9e4139de466cd789f7c..ddb92824efe588f652e8bf1b283e179ffa52c1ea 100644 --- a/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestionForm.tsx +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/QuestionnaireQuestionForm.tsx @@ -1,5 +1,6 @@ import TooltipLabel from '@/editor/Tooltips/TooltipLabel' import TooltipSwitch from '@/editor/Tooltips/TooltipSwitch' +import { GENERIC_CONTENT } from '@/editor/assets/generalContent' import { QUESTIONNAIRE_QUESTION_FORM } from '@/editor/assets/pageContent/injectSpecification' import { addQuestionnaireQuestion, @@ -173,7 +174,7 @@ const QuestionnaireQuestionForm: FC<QuestionnaireQuestionFormProps> = ({ } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -189,7 +190,7 @@ const QuestionnaireQuestionForm: FC<QuestionnaireQuestionFormProps> = ({ } intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/InjectSpecification/QuestionnaireForm/index.tsx b/frontend/src/editor/InjectSpecification/QuestionnaireForm/index.tsx index 57788542345fa39b222ebf5081c779f4b0ac5182..3f7573b9ed49710d7aab635d29b8f42b66eee4b2 100644 --- a/frontend/src/editor/InjectSpecification/QuestionnaireForm/index.tsx +++ b/frontend/src/editor/InjectSpecification/QuestionnaireForm/index.tsx @@ -1,6 +1,7 @@ +import SaveButtonGroup from '@/editor/SaveButtonGroup' import TooltipLabel from '@/editor/Tooltips/TooltipLabel' import { QUESTIONNAIRE_FORM } from '@/editor/assets/pageContent/injectSpecification' -import { Button, InputGroup } from '@blueprintjs/core' +import { InputGroup } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' @@ -15,9 +16,15 @@ import QuestionnaireQuestions from './QuestionnaireQuestions' interface QuestionnaireFormProps { injectInfoId: number + changed: boolean + onChangedChange: (value: boolean) => void } -const QuestionnaireForm: FC<QuestionnaireFormProps> = ({ injectInfoId }) => { +const QuestionnaireForm: FC<QuestionnaireFormProps> = ({ + injectInfoId, + changed, + onChangedChange, +}) => { const questionnaire = useLiveQuery( () => getQuestionnaireByInjectInfoId(injectInfoId), [injectInfoId], @@ -34,7 +41,7 @@ const QuestionnaireForm: FC<QuestionnaireFormProps> = ({ injectInfoId }) => { } }, [questionnaire, injectInfoId]) - const handleUpdateButton = useCallback( + const updateInject = useCallback( async (newQuestionnaire: Questionnaire | Omit<Questionnaire, 'id'>) => { try { if (questionnaire) { @@ -54,6 +61,19 @@ const QuestionnaireForm: FC<QuestionnaireFormProps> = ({ injectInfoId }) => { [notify, questionnaire] ) + const handleUpdate = useCallback(async () => { + if (!changed) onChangedChange(true) + await updateInject({ + injectInfoId, + title, + }) + onChangedChange(false) + }, [changed, injectInfoId, title, updateInject]) + + useEffect(() => { + if (changed) handleUpdate() + }, [changed, handleUpdate]) + return ( <div> <TooltipLabel label={QUESTIONNAIRE_FORM.title}> @@ -78,16 +98,10 @@ const QuestionnaireForm: FC<QuestionnaireFormProps> = ({ injectInfoId }) => { /> </> )} - <Button - onClick={() => - handleUpdateButton({ - injectInfoId, - title, - }) - } - intent='primary' - icon='edit' - text='Save changes' + <SaveButtonGroup + isValid + handleUpdate={() => handleUpdate()} + prevPath='/editor/create/inject-specification' /> </div> ) diff --git a/frontend/src/editor/InjectSpecification/index.tsx b/frontend/src/editor/InjectSpecification/index.tsx index cc1681a52ad7d3e2b0f00a6a22068a2a7c27b549..db50cf5549c0bda54d722fd0aebf58c8ef3ac088 100644 --- a/frontend/src/editor/InjectSpecification/index.tsx +++ b/frontend/src/editor/InjectSpecification/index.tsx @@ -1,6 +1,13 @@ -import { NonIdealState, Tab, Tabs, TabsExpander } from '@blueprintjs/core' +import { + Divider, + NonIdealState, + Tab, + Tabs, + TabsExpander, +} from '@blueprintjs/core' +import { css } from '@emotion/css' import { useLiveQuery } from 'dexie-react-hooks' -import { memo, type FC } from 'react' +import { memo, useState, type FC } from 'react' import InjectForm from '../InjectForm' import { getInjectInfoById } from '../indexeddb/operations' import type { InjectInfo } from '../indexeddb/types' @@ -11,6 +18,10 @@ import InformationInjectForm from './InformationInjectForm' import OverlayForm from './OverlayForm' import QuestionnaireForm from './QuestionnaireForm' +const divider = css` + margin: 1.5rem 0 1rem; +` + interface InjectSpecificationProps { injectInfoId: number } @@ -23,6 +34,7 @@ const InjectSpecification: FC<InjectSpecificationProps> = ({ [injectInfoId], null ) as InjectInfo + const [changed, setChanged] = useState(false) if (!injectInfo) { return ( @@ -37,9 +49,15 @@ const InjectSpecification: FC<InjectSpecificationProps> = ({ return ( <div> <div style={{ marginBottom: '1rem' }}> - <p>Name: {injectInfo.name}</p> - <p>Description: {injectInfo.description}</p> - <p>Type: {injectInfo.type}</p> + <p> + <b>Name:</b> {injectInfo.name} + </p> + <p> + <b>Description:</b> {injectInfo.description} + </p> + <p> + <b>Type:</b> {injectInfo.type} + </p> <InjectForm inject={injectInfo} buttonProps={{ @@ -49,6 +67,7 @@ const InjectSpecification: FC<InjectSpecificationProps> = ({ }} /> </div> + <Divider className={divider} /> <Tabs> <Tab id='parameters' @@ -56,13 +75,25 @@ const InjectSpecification: FC<InjectSpecificationProps> = ({ panel={ <> {injectInfo.type === InjectType.EMAIL && ( - <EmailInjectForm injectInfoId={injectInfoId} /> + <EmailInjectForm + injectInfoId={injectInfoId} + changed={changed} + onChangedChange={value => setChanged(value)} + /> )} {injectInfo.type === InjectType.INFORMATION && ( - <InformationInjectForm injectInfoId={injectInfoId} /> + <InformationInjectForm + injectInfoId={injectInfoId} + changed={changed} + onChangedChange={value => setChanged(value)} + /> )} {injectInfo.type === InjectType.QUESTIONNAIRE && ( - <QuestionnaireForm injectInfoId={injectInfoId} /> + <QuestionnaireForm + injectInfoId={injectInfoId} + changed={changed} + onChangedChange={value => setChanged(value)} + /> )} </> } @@ -74,13 +105,21 @@ const InjectSpecification: FC<InjectSpecificationProps> = ({ <ConnectionsForm injectInfoId={injectInfoId} injectType={injectInfo.type} + changed={changed} + onChangedChange={value => setChanged(value)} /> } /> <Tab id='overlay' title='Overlay' - panel={<OverlayForm injectInfoId={injectInfoId} />} + panel={ + <OverlayForm + injectInfoId={injectInfoId} + changed={changed} + onChangedChange={value => setChanged(value)} + /> + } /> <TabsExpander /> </Tabs> diff --git a/frontend/src/editor/InjectsOverview/Inject.tsx b/frontend/src/editor/InjectsOverview/Inject.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba65779e15ec7b37b432a96509944b0839e8c5b5 --- /dev/null +++ b/frontend/src/editor/InjectsOverview/Inject.tsx @@ -0,0 +1,54 @@ +import type { InjectInfo, Milestone } from '@/editor/indexeddb/types' +import { useNavigate } from '@/router' +import { useLiveQuery } from 'dexie-react-hooks' +import type { FC } from 'react' +import { memo, useMemo } from 'react' +import OverviewCard from '../OverviewCard' +import { + doesInjectHaveCorrectCondition, + isInjectSpecified, +} from '../indexeddb/operations' +import { getInjectIcon } from '../utils' + +interface InjectProps { + injectInfo: InjectInfo + milestones: Milestone[] +} + +const Inject: FC<InjectProps> = ({ injectInfo, milestones }) => { + const isFilled = useLiveQuery(() => isInjectSpecified(injectInfo), [], true) + const isValid = useLiveQuery( + () => doesInjectHaveCorrectCondition(injectInfo.id, milestones), + [injectInfo.id, milestones], + true + ) + const nav = useNavigate() + + const isSpecified = useMemo(() => isFilled && isValid, [isFilled, isValid]) + const tooltipContent = useMemo( + () => + [ + !isFilled && 'Missing mandatory parameters', + !isValid && 'Incorrect milestone condition', + ] + .filter(Boolean) + .join(', '), + [isFilled, isValid] + ) + + return ( + <OverviewCard + name={injectInfo.name} + icon={getInjectIcon(injectInfo)} + onClick={() => + nav(`/editor/create/inject-specification/:injectId`, { + params: { injectId: injectInfo.id.toString() }, + }) + } + isSpecified={isSpecified} + tooltipContent={tooltipContent} + /> + ) +} + +export default memo(Inject) diff --git a/frontend/src/editor/InjectsOverview/index.tsx b/frontend/src/editor/InjectsOverview/index.tsx index 5c24961f860cd3b6205987c97cb66d9f3d6b4da0..81ca06ba81f089b3926e72692f84373a0fa9fc5a 100644 --- a/frontend/src/editor/InjectsOverview/index.tsx +++ b/frontend/src/editor/InjectsOverview/index.tsx @@ -1,33 +1,22 @@ import { db } from '@/editor/indexeddb/db' import type { InjectInfo } from '@/editor/indexeddb/types' -import { useNavigate } from '@/router' -import { Card, CardList, Icon } from '@blueprintjs/core' +import { CardList } from '@blueprintjs/core' import { useLiveQuery } from 'dexie-react-hooks' import { memo } from 'react' -import { getInjectIcon } from '../utils' +import Inject from './Inject' const InjectsOverview = () => { const injectInfos = useLiveQuery(() => db.injectInfos.toArray(), [], []) - const nav = useNavigate() + const milestones = useLiveQuery(() => db.milestones.toArray(), [], []) return ( <CardList> {injectInfos?.map((injectInfo: InjectInfo) => ( - <Card - interactive + <Inject key={injectInfo.id} - onClick={() => - nav(`/editor/create/inject-specification/:injectId`, { - params: { injectId: injectInfo.id.toString() }, - }) - } - > - <Icon - icon={getInjectIcon(injectInfo)} - style={{ marginRight: '1rem' }} - /> - {injectInfo.name} - </Card> + injectInfo={injectInfo} + milestones={milestones} + /> ))} </CardList> ) diff --git a/frontend/src/editor/LandingPage/index.tsx b/frontend/src/editor/LandingPage/index.tsx index a9838bce27bd650b7401ed33228211f360003da4..2b384e04c6e1d96ecf1190c08d8f5d098b595039 100644 --- a/frontend/src/editor/LandingPage/index.tsx +++ b/frontend/src/editor/LandingPage/index.tsx @@ -7,22 +7,43 @@ import { isEmpty } from 'lodash' import { useEffect, useState } from 'react' import DataRemovalDialog from '../DataRemovalDialog' import DefinitionUploader from '../DefinitionUploader' +import { GENERIC_CONTENT } from '../assets/generalContent' import { LANDING_PAGE_ACTIONS } from '../assets/pageContent/landingPage' import { PAGE_INFORMATION } from '../assets/pageInformation' import { isDbEmpty } from '../indexeddb/operations' import { PageNames } from '../types' import useEditorStorage from '../useEditorStorage' +const logo = css` + width: 100%; + height: 200px; + margin: auto; +` + +const container = css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +` + const introduction = css` display: flex; flex-direction: column; + align-items: center; text-align: center; - gap: 1rem; - margin: 0 auto; - max-width: 200px; + gap: 0.5rem; + max-width: 400px; width: 100%; ` +const buttonGroup = css` + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 200px; +` + const LandingPage = () => { const nav = useNavigate() const [config] = useEditorStorage() @@ -38,51 +59,52 @@ const LandingPage = () => { }, [config]) return ( - <Container makeFullHeight> - <InjectLogo - style={{ - width: '100%', - height: '200px', - margin: 'auto', - }} - /> - <div className={introduction}> - <h1>{PAGE_INFORMATION[PageNames.LANDING_PAGE].title}</h1> - <p - dangerouslySetInnerHTML={{ - __html: PAGE_INFORMATION[PageNames.LANDING_PAGE].description, - }} - /> - <Button - type='button' - intent='primary' - icon={isDataEmpty ? 'plus' : 'arrow-right'} - onClick={() => nav('/editor/create/introduction')} - > - {isDataEmpty - ? LANDING_PAGE_ACTIONS.create - : LANDING_PAGE_ACTIONS.edit} - </Button> - {!isDataEmpty && ( - <DataRemovalDialog - openButtonProps={{ - icon: 'plus', - text: LANDING_PAGE_ACTIONS.create, - }} - confirmButtonProps={{ - intent: 'primary', - icon: 'tick', - text: 'Confirm', + <Container makeFullHeight className={container}> + <div className={container}> + <InjectLogo className={logo} /> + <div className={introduction}> + <h1 style={{ margin: '0' }}> + {PAGE_INFORMATION[PageNames.LANDING_PAGE].title} + </h1> + <p + dangerouslySetInnerHTML={{ + __html: PAGE_INFORMATION[PageNames.LANDING_PAGE].description, }} - redirectTo='/editor/create/introduction' + style={{ marginBottom: '1.5rem' }} /> - )} - <DefinitionUploader /> - <Button - text={LANDING_PAGE_ACTIONS.select} - icon='list-detail-view' - onClick={() => nav('/editor/definitions')} - /> + <div className={buttonGroup}> + <Button + type='button' + intent='primary' + icon={isDataEmpty ? 'plus' : 'arrow-right'} + onClick={() => nav('/editor/create/introduction')} + > + {isDataEmpty + ? LANDING_PAGE_ACTIONS.create + : LANDING_PAGE_ACTIONS.edit} + </Button> + {!isDataEmpty && ( + <DataRemovalDialog + openButtonProps={{ + icon: 'plus', + text: LANDING_PAGE_ACTIONS.create, + }} + confirmButtonProps={{ + intent: 'primary', + icon: 'tick', + text: GENERIC_CONTENT.buttons.confirm, + }} + redirectTo='/editor/create/introduction' + /> + )} + <DefinitionUploader /> + <Button + text={LANDING_PAGE_ACTIONS.select} + icon='list-detail-view' + onClick={() => nav('/editor/definitions')} + /> + </div> + </div> </div> </Container> ) diff --git a/frontend/src/editor/LearningActivitiesOverview/LearningActivity.tsx b/frontend/src/editor/LearningActivitiesOverview/LearningActivity.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7375533f7d7e64ddf1395017f327c5882da7cf5d --- /dev/null +++ b/frontend/src/editor/LearningActivitiesOverview/LearningActivity.tsx @@ -0,0 +1,61 @@ +import type { LearningActivityInfo, Milestone } from '@/editor/indexeddb/types' +import { useNavigate } from '@/router' +import { useLiveQuery } from 'dexie-react-hooks' +import type { FC } from 'react' +import { memo, useMemo } from 'react' +import OverviewCard from '../OverviewCard' +import { + doesLearningActivityHaveCorrectCondition, + isActivitySpecified, +} from '../indexeddb/operations' +import { getLearningActivityIcon } from '../utils' + +interface LearningActivityProps { + activity: LearningActivityInfo + milestones: Milestone[] +} + +const LearningActivity: FC<LearningActivityProps> = ({ + activity, + milestones, +}) => { + const isFilled = useLiveQuery( + () => isActivitySpecified(activity), + [activity], + true + ) + const isValid = useLiveQuery( + () => doesLearningActivityHaveCorrectCondition(activity, milestones), + [activity, milestones], + true + ) + const nav = useNavigate() + + const isSpecified = useMemo(() => isFilled && isValid, [isFilled, isValid]) + const tooltipContent = useMemo( + () => + [ + !isFilled && 'Missing mandatory parameters', + !isValid && 'Incorrect milestone condition', + ] + .filter(Boolean) + .join(', '), + [isFilled, isValid] + ) + + return ( + <OverviewCard + name={activity.name} + icon={getLearningActivityIcon(activity)} + onClick={() => + nav(`/editor/create/activity-specification/:activityId`, { + params: { activityId: activity.id.toString() }, + }) + } + isSpecified={isSpecified} + tooltipContent={tooltipContent} + /> + ) +} + +export default memo(LearningActivity) diff --git a/frontend/src/editor/LearningActivitiesOverview/index.tsx b/frontend/src/editor/LearningActivitiesOverview/index.tsx index c973fec920f7e44ce9485d98ca4e1927c5552bce..b031faee6a88604b37753ea893b2d5ed13c4a044 100644 --- a/frontend/src/editor/LearningActivitiesOverview/index.tsx +++ b/frontend/src/editor/LearningActivitiesOverview/index.tsx @@ -1,10 +1,9 @@ import { db } from '@/editor/indexeddb/db' import type { LearningActivityInfo } from '@/editor/indexeddb/types' -import { useNavigate } from '@/router' -import { Card, CardList, Icon } from '@blueprintjs/core' +import { CardList } from '@blueprintjs/core' import { useLiveQuery } from 'dexie-react-hooks' import { memo } from 'react' -import { getLearningActivityIcon } from '../utils' +import LearningActivity from './LearningActivity' const LearningActivitiesOverview = () => { const learningActivities = useLiveQuery( @@ -12,26 +11,16 @@ const LearningActivitiesOverview = () => { [], [] ) - const nav = useNavigate() + const milestones = useLiveQuery(() => db.milestones.toArray(), [], []) return ( <CardList> {learningActivities?.map((activity: LearningActivityInfo) => ( - <Card - interactive + <LearningActivity key={activity.id} - onClick={() => - nav(`/editor/create/activity-specification/:activityId`, { - params: { activityId: activity.id.toString() }, - }) - } - > - <Icon - icon={getLearningActivityIcon(activity)} - style={{ marginRight: '1rem' }} - /> - {activity.name} - </Card> + activity={activity} + milestones={milestones} + /> ))} </CardList> ) diff --git a/frontend/src/editor/LearningActivityForm/index.tsx b/frontend/src/editor/LearningActivityForm/index.tsx index b23f176d2177c0ba9e57fe0c0f36b10be621d720..c88284299d8e4d926adb37777813892cb1a9dc79 100644 --- a/frontend/src/editor/LearningActivityForm/index.tsx +++ b/frontend/src/editor/LearningActivityForm/index.tsx @@ -16,6 +16,7 @@ import { notify } from '@inject/shared/notification/engine' import type { FC } from 'react' import { memo, useCallback, useEffect, useState } from 'react' import TooltipLabel from '../Tooltips/TooltipLabel' +import { GENERIC_CONTENT } from '../assets/generalContent' import { LEARNING_ACTIVITY_FORM } from '../assets/pageContent/learningObjectives' import type { LearningActivityInfo } from '../indexeddb/types' import { LearningActivityType } from '../indexeddb/types' @@ -140,7 +141,7 @@ const LearningActivityForm: FC<LearningActivityFormProps> = ({ } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -155,7 +156,7 @@ const LearningActivityForm: FC<LearningActivityFormProps> = ({ } intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx index 1b186953890709eacdc3a663fe6dd18c889d189a..5c0b05a73465ad06ab9960a9b98dc0db11a630d8 100644 --- a/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/EmailTemplateForm.tsx @@ -1,8 +1,8 @@ -import { Button } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' import EmailTemplateFormContent from '../EmailTemplateFormContent' +import SaveButtonGroup from '../SaveButtonGroup' import { addEmailTemplate, getEmailTemplateByActivityId, @@ -64,8 +64,9 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ onEmailAddressIdChange={(value: number) => setSelectedAddressId(value)} onFileIdChange={(value: number) => setFileId(value)} /> - <Button - onClick={() => + <SaveButtonGroup + isValid + handleUpdate={() => handleUpdateButton({ learningActivityId, context, @@ -74,9 +75,7 @@ const EmailTemplateForm: FC<EmailTemplateFormProps> = ({ fileId, }) } - intent='primary' - icon='edit' - text='Save changes' + prevPath='/editor/create/activity-specification' /> </div> ) diff --git a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx index fcec134d939bee0262f8cb0148baad23730ab941..a67e69e22f6bab1487d3069705f73c943817db8c 100644 --- a/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx +++ b/frontend/src/editor/LearningActivitySpecification/ToolResponseForm.tsx @@ -1,8 +1,8 @@ -import { Button } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { useLiveQuery } from 'dexie-react-hooks' import { memo, useCallback, useEffect, useState, type FC } from 'react' import useValidateExpression from '../ExpressionBuilder/useValidateExpression' +import SaveButtonGroup from '../SaveButtonGroup' import ToolResponseFormContent from '../ToolResponseFormContent' import { addToolResponse, @@ -80,9 +80,9 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ setMilestoneCondition(value) } /> - <Button - disabled={!isValid} - onClick={() => + <SaveButtonGroup + isValid={isValid} + handleUpdate={() => handleUpdateButton({ learningActivityId, parameter, @@ -94,9 +94,7 @@ const ToolResponseForm: FC<ToolResponseFormProps> = ({ milestoneCondition: isValid ? milestoneCondition : [], }) } - intent='primary' - icon='edit' - text='Save changes' + prevPath='/editor/create/activity-specification' /> </div> ) diff --git a/frontend/src/editor/LearningActivitySpecification/index.tsx b/frontend/src/editor/LearningActivitySpecification/index.tsx index e99315e95c74247df99a0fb717fd90432999397a..b6b4562674f70d2d111e85915be2e2576b7edef7 100644 --- a/frontend/src/editor/LearningActivitySpecification/index.tsx +++ b/frontend/src/editor/LearningActivitySpecification/index.tsx @@ -1,4 +1,5 @@ import { Divider, NonIdealState } from '@blueprintjs/core' +import { css } from '@emotion/css' import { useLiveQuery } from 'dexie-react-hooks' import { memo, type FC } from 'react' import LearningActivityForm from '../LearningActivityForm' @@ -8,6 +9,10 @@ import { LearningActivityType } from '../indexeddb/types' import EmailTemplateForm from './EmailTemplateForm' import ToolResponseForm from './ToolResponseForm' +const divider = css` + margin: 1.5rem 0 1rem; +` + interface LearningActivitySpecificationProps { learningActivityId: number } @@ -34,9 +39,15 @@ const LearningActivitySpecification: FC<LearningActivitySpecificationProps> = ({ return ( <div> <div> - <p>Name: {activity.name}</p> - <p>Description: {activity.description}</p> - <p>Type: {activity.type}</p> + <p> + <b>Name:</b> {activity.name} + </p> + <p> + <b>Description:</b> {activity.description} + </p> + <p> + <b>Type:</b> {activity.type} + </p> <LearningActivityForm learningActivity={activity} learningObjectiveId={activity.learningObjectiveId} @@ -47,7 +58,7 @@ const LearningActivitySpecification: FC<LearningActivitySpecificationProps> = ({ }} /> </div> - <Divider style={{ margin: '1rem 0' }} /> + <Divider className={divider} /> {activity.type === LearningActivityType.TOOL && ( <ToolResponseForm learningActivityId={learningActivityId} /> )} diff --git a/frontend/src/editor/LearningObjectiveForm/index.tsx b/frontend/src/editor/LearningObjectiveForm/index.tsx index 6297d0ccd6e3e06e51da22d059d65d2796200a51..4a65cf7f138616961809f9e9aa6f9dc9d2e8ab21 100644 --- a/frontend/src/editor/LearningObjectiveForm/index.tsx +++ b/frontend/src/editor/LearningObjectiveForm/index.tsx @@ -14,6 +14,7 @@ import { notify } from '@inject/shared/notification/engine' import type { FC } from 'react' import { memo, useCallback, useEffect, useState } from 'react' import TooltipLabel from '../Tooltips/TooltipLabel' +import { GENERIC_CONTENT } from '../assets/generalContent' import { LEARNING_OBJECTIVE_FORM } from '../assets/pageContent/learningObjectives' import type { LearningObjectiveInfo } from '../indexeddb/types' @@ -104,7 +105,7 @@ const LearningObjectiveForm: FC<LearningObjectiveFormProps> = ({ } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -112,7 +113,7 @@ const LearningObjectiveForm: FC<LearningObjectiveFormProps> = ({ onClick={() => handleAddButton({ name })} intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/Navbar/NavbarButton.tsx b/frontend/src/editor/Navbar/NavbarButton.tsx index e2ba6d0d977b6f1c7870bea92cbeca3365e56bb7..6ec54c0ae4fee746371917db7a66dac46ddc7e06 100644 --- a/frontend/src/editor/Navbar/NavbarButton.tsx +++ b/frontend/src/editor/Navbar/NavbarButton.tsx @@ -1,25 +1,64 @@ import type { Path } from '@/router' import { useNavigate } from '@/router' -import { Button } from '@blueprintjs/core' -import type { FC } from 'react' +import { Button, Icon, Intent } from '@blueprintjs/core' +import { css } from '@emotion/css' +import { useMemo, type FC } from 'react' +import { useLocation } from 'react-router-dom' import { NAVIGATION_CONTENT } from '../assets/navigationContent' import type { NavigationLinkNames } from '../types' +const buttonContent = css` + display: flex; + justify-content: space-between; + align-items: center; +` + interface NavbarButtonProps { path: Path linkKey: NavigationLinkNames visible?: boolean + filled?: boolean } -const NavbarButton: FC<NavbarButtonProps> = ({ path, linkKey, visible }) => { +const NavbarButton: FC<NavbarButtonProps> = ({ + path, + linkKey, + visible, + filled, +}) => { const nav = useNavigate() + const { pathname } = useLocation() const name = NAVIGATION_CONTENT.links[linkKey] + const active = useMemo( + () => + path === pathname || + (path === '/editor/create/other' && + (pathname === '/editor/create/tools' || + pathname === '/editor/create/emails')) || + ((path === '/editor/create/activity-specification' || + path === '/editor/create/inject-specification') && + pathname.startsWith(path)), + [path, pathname] + ) + + const highlighted = useMemo(() => !filled && !active, [filled, active]) + if (!visible) return return ( - <Button type='button' onClick={() => nav(path)} alignText='left' minimal> - {name} + <Button + type='button' + onClick={() => nav(path)} + alignText='left' + minimal + active={active} + intent={highlighted ? Intent.WARNING : Intent.NONE} + > + <div className={buttonContent}> + <span>{name}</span> + {highlighted && <Icon icon='high-priority' />} + </div> </Button> ) } diff --git a/frontend/src/editor/Navbar/index.tsx b/frontend/src/editor/Navbar/index.tsx index b834ba35e7e1d8b10e87754f800ef2c56a1a10a0..24eed3f4a8f4bc47b3a0d5ac109d02e073b48d3d 100644 --- a/frontend/src/editor/Navbar/index.tsx +++ b/frontend/src/editor/Navbar/index.tsx @@ -1,4 +1,13 @@ +import { useLiveQuery } from 'dexie-react-hooks' import { memo } from 'react' +import { + areActivitiesSpecified, + areInjectsSpecified, + doInjectsHaveCorrectConditions, + doLearningActivitiesHaveCorrectConditions, + doToolResponsesHaveCorrectConditions, + doToolsHaveResponses, +} from '../indexeddb/operations' import { NavigationLinkNames } from '../types' import useEditorAccessStorage from '../useEditorAccessStorage' import NavbarButton from './NavbarButton' @@ -6,57 +15,96 @@ import NavbarButton from './NavbarButton' const Navbar = () => { const [access] = useEditorAccessStorage() + const activitiesSpecified = useLiveQuery( + () => areActivitiesSpecified(), + [], + false + ) + const activitiesHaveCorrectConditions = useLiveQuery( + () => doLearningActivitiesHaveCorrectConditions(), + [], + false + ) + + const injectsSpecified = useLiveQuery(() => areInjectsSpecified(), [], false) + const injectsHaveCorrectConditions = useLiveQuery( + () => doInjectsHaveCorrectConditions(), + [], + false + ) + + const toolsHaveResponses = useLiveQuery( + () => doToolsHaveResponses(), + [], + false + ) + const toolsHaveCorrectConditions = useLiveQuery( + () => doToolResponsesHaveCorrectConditions(), + [], + false + ) + return ( <div style={{ display: 'flex', flexDirection: 'column' }}> <NavbarButton path='/editor/create/introduction' linkKey={NavigationLinkNames.INTRODUCTION} visible + filled={access?.introductionFilled} /> <NavbarButton path='/editor/create/exercise-information' linkKey={NavigationLinkNames.EXERCISE_INFORMATION} visible={access?.introductionFilled} + filled={access?.exerciseInformationFilled} /> <NavbarButton path='/editor/create/learning-objectives' linkKey={NavigationLinkNames.LEARNING_OBJECTIVES} visible={access?.exerciseInformationFilled} + filled={access?.objectivesFilled} /> <NavbarButton path='/editor/create/injects' linkKey={NavigationLinkNames.INJECTS} visible={access?.objectivesFilled} + filled={access?.injectsFilled} /> <NavbarButton path='/editor/create/activity-specification' linkKey={NavigationLinkNames.ACTIVITY_SPECIFICATION_OVERVIEW} visible={access?.injectsFilled} + filled={activitiesSpecified && activitiesHaveCorrectConditions} /> <NavbarButton path='/editor/create/inject-specification' linkKey={NavigationLinkNames.INJECT_SPECIFICATION_OVERVIEW} visible={access?.injectsFilled} + filled={injectsSpecified && injectsHaveCorrectConditions} /> <NavbarButton path='/editor/create/other' linkKey={NavigationLinkNames.OTHER} visible={access?.injectsFilled} + filled={toolsHaveResponses && toolsHaveCorrectConditions} /> <NavbarButton path='/editor/create/final-information' linkKey={NavigationLinkNames.FINAL_INFORMATION} visible={access?.specificationsFilled} + filled={access?.finalInformationFilled} /> <NavbarButton path='/editor/create/conclusion' linkKey={NavigationLinkNames.CONCLUSION} visible={access?.finalInformationFilled} + filled={access?.conclusionFilled} /> <NavbarButton path='/editor/create/save' linkKey={NavigationLinkNames.DOWNLOAD} visible={access?.conclusionFilled} + filled /> </div> ) diff --git a/frontend/src/editor/OverviewCard/index.tsx b/frontend/src/editor/OverviewCard/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8679eb0a1e0975304b4df480bc4f2df6aacf07d --- /dev/null +++ b/frontend/src/editor/OverviewCard/index.tsx @@ -0,0 +1,40 @@ +import type { IconName } from '@blueprintjs/core' +import { Card, Icon, Tooltip } from '@blueprintjs/core' +import { css } from '@emotion/css' +import type { FC } from 'react' +import { memo } from 'react' + +const overviewCard = css` + display: flex; + justify-content: space-between; +` + +interface OverviewCardProps { + name: string + icon?: IconName + onClick: () => void + isSpecified?: boolean + tooltipContent: string +} + +const OverviewCard: FC<OverviewCardProps> = ({ + name, + icon, + onClick, + isSpecified, + tooltipContent, +}) => ( + <Card interactive onClick={onClick} className={overviewCard}> + <span> + {icon && <Icon icon={icon} style={{ marginRight: '1rem' }} />} + {name} + </span> + {!isSpecified && ( + <Tooltip content={tooltipContent}> + <Icon icon='high-priority' intent='warning' size={20} /> + </Tooltip> + )} + </Card> +) + +export default memo(OverviewCard) diff --git a/frontend/src/editor/SaveButtonGroup/index.tsx b/frontend/src/editor/SaveButtonGroup/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bc7fad86592f740f94ec3babf0161c65547c1f36 --- /dev/null +++ b/frontend/src/editor/SaveButtonGroup/index.tsx @@ -0,0 +1,43 @@ +import type { Path } from '@/router' +import { useNavigate } from '@/router' +import { Button, ButtonGroup } from '@blueprintjs/core' +import { memo, type FC } from 'react' +import { GENERIC_CONTENT } from '../assets/generalContent' + +interface SaveButtonGroupProps { + isValid: boolean + handleUpdate: () => void + prevPath: Path +} + +const SaveButtonGroup: FC<SaveButtonGroupProps> = ({ + isValid, + handleUpdate, + prevPath, +}) => { + const nav = useNavigate() + + return ( + <ButtonGroup> + <Button + disabled={!isValid} + onClick={() => { + handleUpdate() + nav(prevPath) + }} + intent='primary' + icon='arrow-left' + text={GENERIC_CONTENT.buttons.saveAndExit} + style={{ marginRight: '0.5rem' }} + /> + <Button + disabled={!isValid} + onClick={() => handleUpdate()} + icon='edit' + text={GENERIC_CONTENT.buttons.save} + /> + </ButtonGroup> + ) +} + +export default memo(SaveButtonGroup) diff --git a/frontend/src/editor/ToolForm/index.tsx b/frontend/src/editor/ToolForm/index.tsx index 30d4bb7b769facd0d0df2265d60aadf56162026d..5127389ca25760bc1d0b7d53997112929394c51e 100644 --- a/frontend/src/editor/ToolForm/index.tsx +++ b/frontend/src/editor/ToolForm/index.tsx @@ -12,6 +12,7 @@ import { notify } from '@inject/shared/notification/engine' import type { FC } from 'react' import { memo, useCallback, useEffect, useState } from 'react' import TooltipLabel from '../Tooltips/TooltipLabel' +import { GENERIC_CONTENT } from '../assets/generalContent' import { TOOL_FORM } from '../assets/pageContent/tools' import type { ToolInfo } from '../indexeddb/types' @@ -24,14 +25,12 @@ interface ToolFormProps { const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { const [isOpen, setIsOpen] = useState(false) const [name, setName] = useState<string>('') - const [category, setCategory] = useState<string>('') const [tooltipDescription, setTooltipDescription] = useState<string>('') const [hint, setHint] = useState<string>('') const [defaultResponse, setDefaultResponse] = useState<string>('') const clearInput = useCallback(() => { setName('') - setCategory('') setTooltipDescription('') setHint('') setDefaultResponse('') @@ -69,7 +68,6 @@ const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { useEffect(() => { setName(toolInfo?.name || '') - setCategory(toolInfo?.category || '') setTooltipDescription(toolInfo?.tooltipDescription || '') setHint(toolInfo?.hint || '') setDefaultResponse(toolInfo?.defaultResponse || '') @@ -92,13 +90,6 @@ const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { onChange={e => setName(e.target.value)} /> </TooltipLabel> - <TooltipLabel label={TOOL_FORM.category}> - <InputGroup - placeholder='Input text' - value={category} - onChange={e => setCategory(e.target.value)} - /> - </TooltipLabel> <TooltipLabel label={TOOL_FORM.tooltip}> <InputGroup placeholder='Input text' @@ -136,7 +127,6 @@ const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { handleUpdateButton({ id: toolInfo.id, name, - category, tooltipDescription, hint, defaultResponse, @@ -144,7 +134,7 @@ const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -152,7 +142,6 @@ const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { onClick={() => handleAddButton({ name, - category, tooltipDescription, hint, defaultResponse, @@ -160,7 +149,7 @@ const ToolForm: FC<ToolFormProps> = ({ toolInfo, buttonProps, onAdd }) => { } intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/ToolResponseFormDialog/index.tsx b/frontend/src/editor/ToolResponseFormDialog/index.tsx index 84086419cddc5d3bb688cc3ad1b9b8a7074d052b..b8607c574c6aa7f7ace4f8604cf4836bcd839849 100644 --- a/frontend/src/editor/ToolResponseFormDialog/index.tsx +++ b/frontend/src/editor/ToolResponseFormDialog/index.tsx @@ -4,6 +4,7 @@ import { notify } from '@inject/shared/notification/engine' import { memo, useCallback, useEffect, useState, type FC } from 'react' import useValidateExpression from '../ExpressionBuilder/useValidateExpression' import ToolResponseForm from '../ToolResponseFormContent' +import { GENERIC_CONTENT } from '../assets/generalContent' import { addToolResponse, updateToolResponse } from '../indexeddb/operations' import type { ToolResponse } from '../indexeddb/types' @@ -132,7 +133,7 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ } intent='primary' icon='edit' - text='Save changes' + text={GENERIC_CONTENT.buttons.save} /> ) : ( <Button @@ -150,7 +151,7 @@ const ToolResponseFormDialog: FC<ToolResponseFormDialogProps> = ({ } intent='primary' icon='plus' - text='Add' + text={GENERIC_CONTENT.buttons.add} /> ) } diff --git a/frontend/src/editor/ToolResponses/ToolResponse.tsx b/frontend/src/editor/ToolResponses/ToolResponse.tsx index 507f733e9fe2f9975d3a7fbc2cb0e5238481902d..344e377ea2908d86dd3c34d153abc14245784c0c 100644 --- a/frontend/src/editor/ToolResponses/ToolResponse.tsx +++ b/frontend/src/editor/ToolResponses/ToolResponse.tsx @@ -1,4 +1,4 @@ -import { Button, ButtonGroup, Card } from '@blueprintjs/core' +import { Button, ButtonGroup, Card, Icon, Tooltip } from '@blueprintjs/core' import { notify } from '@inject/shared/notification/engine' import { useLiveQuery } from 'dexie-react-hooks' import { isEmpty } from 'lodash' @@ -13,9 +13,10 @@ import type { LearningActivityInfo, ToolResponse } from '../indexeddb/types' interface ToolResponseProps { toolResponse: ToolResponse + isValid: boolean } -const ToolResponseItem: FC<ToolResponseProps> = ({ toolResponse }) => { +const ToolResponseItem: FC<ToolResponseProps> = ({ toolResponse, isValid }) => { const activity = useLiveQuery( () => getLearningActivityById(toolResponse.learningActivityId || 0), [toolResponse], @@ -44,6 +45,11 @@ const ToolResponseItem: FC<ToolResponseProps> = ({ toolResponse }) => { {isEmpty(toolResponse.parameter) ? `(${activity?.name})` : toolResponse.parameter} + {!isValid && ( + <Tooltip content='Incorrect milestone condition'> + <Icon icon='high-priority' intent='warning' size={20} /> + </Tooltip> + )} </span> <ButtonGroup> <ToolResponseFormDialog diff --git a/frontend/src/editor/ToolResponses/index.tsx b/frontend/src/editor/ToolResponses/index.tsx index 1c96632cc93a4b63eec6a6c5fb001626bf54e23d..14a2689ac9aaf3e83989a2aaf45bb2561716108b 100644 --- a/frontend/src/editor/ToolResponses/index.tsx +++ b/frontend/src/editor/ToolResponses/index.tsx @@ -4,6 +4,7 @@ import { CardList } from '@blueprintjs/core' import { useLiveQuery } from 'dexie-react-hooks' import type { FC } from 'react' import { memo } from 'react' +import { doesToolResponseHaveCorrectCondition } from '../indexeddb/operations' import ToolResponseItem from './ToolResponse' interface ToolResponsesProps { @@ -16,11 +17,19 @@ const ToolResponses: FC<ToolResponsesProps> = ({ toolId }) => { [toolId], [] ) + const milestones = useLiveQuery(() => db.milestones.toArray(), [], []) return ( <CardList> {toolResponses?.map((toolResponse: ToolResponse) => ( - <ToolResponseItem key={toolResponse.id} toolResponse={toolResponse} /> + <ToolResponseItem + key={toolResponse.id} + toolResponse={toolResponse} + isValid={doesToolResponseHaveCorrectCondition( + toolResponse, + milestones + )} + /> ))} </CardList> ) diff --git a/frontend/src/editor/Tools/Tool.tsx b/frontend/src/editor/Tools/Tool.tsx index f33e1070b19c9dff23aabde4efafeadc1dc4b152..58f0d0e5f9cb3f7e160aae7ea09de542ccbc4aab 100644 --- a/frontend/src/editor/Tools/Tool.tsx +++ b/frontend/src/editor/Tools/Tool.tsx @@ -1,18 +1,44 @@ -import { Button, ButtonGroup, Card } from '@blueprintjs/core' +import { Button, ButtonGroup, Card, Icon, Tooltip } from '@blueprintjs/core' +import { css } from '@emotion/css' import { notify } from '@inject/shared/notification/engine' +import { useLiveQuery } from 'dexie-react-hooks' import type { FC } from 'react' import { memo, useCallback } from 'react' import ToolForm from '../ToolForm' import ToolResponseFormDialog from '../ToolResponseFormDialog' import ToolResponses from '../ToolResponses' -import { deleteTool } from '../indexeddb/operations' +import { deleteTool, doesToolHaveResponse } from '../indexeddb/operations' import type { ToolInfo } from '../indexeddb/types' +const toolCard = css` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 0rem 1rem 1rem 0; +` + +const toolCardName = css` + height: 100%; + flex-grow: 1; +` + +const toolResponses = css` + width: 100%; + padding-left: 2rem; +` + interface ToolProps { tool: ToolInfo } const ToolItem: FC<ToolProps> = ({ tool }) => { + const toolHasResponses = useLiveQuery( + () => doesToolHaveResponse(tool.id), + [tool], + false + ) + const handleDeleteButton = useCallback( async (toolInfo: ToolInfo) => { try { @@ -28,16 +54,15 @@ const ToolItem: FC<ToolProps> = ({ tool }) => { return ( <Card style={{ flexDirection: 'column' }}> - <div - style={{ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - padding: '0rem 1rem 1rem 0', - }} - > - <span style={{ height: '100%', flexGrow: 1 }}>{tool.name}</span> + <div className={toolCard}> + <span className={toolCardName}> + {tool.name} + {!toolHasResponses && ( + <Tooltip content='Every tool has to have at least one tool response'> + <Icon icon='high-priority' intent='warning' size={20} /> + </Tooltip> + )} + </span> <ButtonGroup> <ToolForm toolInfo={tool} @@ -54,7 +79,7 @@ const ToolItem: FC<ToolProps> = ({ tool }) => { /> </ButtonGroup> </div> - <div style={{ width: '100%', paddingLeft: '2rem' }}> + <div className={toolResponses}> <ToolResponses toolId={tool.id} /> <ToolResponseFormDialog toolId={tool.id} diff --git a/frontend/src/editor/assets/README.md b/frontend/src/editor/assets/README.md index 69def44692c9c3b96470773bb8f1a1d9dd3affd1..b8449d5fa5d23f858f236b0c3476b1785970fca2 100644 --- a/frontend/src/editor/assets/README.md +++ b/frontend/src/editor/assets/README.md @@ -81,3 +81,13 @@ Each file in this directory contains checklist or form object(s) with following - label - required, plain string - tooltip - optional, plain string - optional - should not be changed, used as an indicator of whether the input field should be filled out + +### dialogContent +- contains plaintext descriptions of dialog actions + +## Other + +### Pictures in HTML +- the format of some values allows to use HTML string +- if the image is being inserted, the img tag needs to specify the whole path to the picture, e.g. +```<img src="/src/editor/assets/images/pic.jpg" alt="Picture">``` diff --git a/frontend/src/editor/assets/dialogContent.ts b/frontend/src/editor/assets/dialogContent.ts new file mode 100644 index 0000000000000000000000000000000000000000..6aa6752182f569d03e8822163733d099e9699d56 --- /dev/null +++ b/frontend/src/editor/assets/dialogContent.ts @@ -0,0 +1,10 @@ +export const DEFINITION_IMPORT_CONTENT = + 'This will permanently delete all data in the editor. Are you sure you want to upload a new definition?' + +export const DATA_REMOVAL_CONTENT = + 'This will permanently delete all data in the editor. Are you sure you want to clear the definition?' + +export const COMMIT_PROJECT_PANEL_CONTENT_1 = + 'The definition changes will be commited to the project:' +export const COMMIT_PROJECT_PANEL_CONTENT_2 = + 'To create a new project, click on the new project option and fill in the necessary information.' diff --git a/frontend/src/editor/assets/generalContent.tsx b/frontend/src/editor/assets/generalContent.ts similarity index 85% rename from frontend/src/editor/assets/generalContent.tsx rename to frontend/src/editor/assets/generalContent.ts index a7c91a2666c010ac3d55b066a1632967052f9179..a2008cf7690b8e70094dc92523e98b2aebcae02c 100644 --- a/frontend/src/editor/assets/generalContent.tsx +++ b/frontend/src/editor/assets/generalContent.ts @@ -3,6 +3,7 @@ export const GENERIC_CONTENT = { add: 'Add', edit: 'Edit', save: 'Save', + saveAndExit: 'Save & Exit', back: 'Back', next: 'Next', cancel: 'Cancel', diff --git a/frontend/src/editor/assets/navigationContent.tsx b/frontend/src/editor/assets/navigationContent.ts similarity index 100% rename from frontend/src/editor/assets/navigationContent.tsx rename to frontend/src/editor/assets/navigationContent.ts diff --git a/frontend/src/editor/assets/pageContent/conclusion.tsx b/frontend/src/editor/assets/pageContent/conclusion.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/conclusion.tsx rename to frontend/src/editor/assets/pageContent/conclusion.ts diff --git a/frontend/src/editor/assets/pageContent/definitions.tsx b/frontend/src/editor/assets/pageContent/definitions.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/definitions.tsx rename to frontend/src/editor/assets/pageContent/definitions.ts diff --git a/frontend/src/editor/assets/pageContent/emails.tsx b/frontend/src/editor/assets/pageContent/emails.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/emails.tsx rename to frontend/src/editor/assets/pageContent/emails.ts diff --git a/frontend/src/editor/assets/pageContent/exerciseInformation.tsx b/frontend/src/editor/assets/pageContent/exerciseInformation.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/exerciseInformation.tsx rename to frontend/src/editor/assets/pageContent/exerciseInformation.ts diff --git a/frontend/src/editor/assets/pageContent/files.tsx b/frontend/src/editor/assets/pageContent/files.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/files.tsx rename to frontend/src/editor/assets/pageContent/files.ts diff --git a/frontend/src/editor/assets/pageContent/finalInformation.tsx b/frontend/src/editor/assets/pageContent/finalInformation.ts similarity index 98% rename from frontend/src/editor/assets/pageContent/finalInformation.tsx rename to frontend/src/editor/assets/pageContent/finalInformation.ts index e44e40fe47a50d2a5af882ccd89e42435cc27bcd..489a344f1b059acc17644dc8b6b28a514a11ae22 100644 --- a/frontend/src/editor/assets/pageContent/finalInformation.tsx +++ b/frontend/src/editor/assets/pageContent/finalInformation.ts @@ -16,6 +16,7 @@ export const FINAL_MILESTONE_FORM: Form = { finalMilestone: { label: 'Final milestone', tooltip: '', + optional: true, }, } diff --git a/frontend/src/editor/assets/pageContent/gitlab.tsx b/frontend/src/editor/assets/pageContent/gitlab.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/gitlab.tsx rename to frontend/src/editor/assets/pageContent/gitlab.ts diff --git a/frontend/src/editor/assets/pageContent/injectSpecification.tsx b/frontend/src/editor/assets/pageContent/injectSpecification.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/injectSpecification.tsx rename to frontend/src/editor/assets/pageContent/injectSpecification.ts diff --git a/frontend/src/editor/assets/pageContent/injects.tsx b/frontend/src/editor/assets/pageContent/injects.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/injects.tsx rename to frontend/src/editor/assets/pageContent/injects.ts diff --git a/frontend/src/editor/assets/pageContent/introduction.tsx b/frontend/src/editor/assets/pageContent/introduction.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/introduction.tsx rename to frontend/src/editor/assets/pageContent/introduction.ts diff --git a/frontend/src/editor/assets/pageContent/landingPage.tsx b/frontend/src/editor/assets/pageContent/landingPage.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/landingPage.tsx rename to frontend/src/editor/assets/pageContent/landingPage.ts diff --git a/frontend/src/editor/assets/pageContent/learningObjectives.tsx b/frontend/src/editor/assets/pageContent/learningObjectives.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/learningObjectives.tsx rename to frontend/src/editor/assets/pageContent/learningObjectives.ts diff --git a/frontend/src/editor/assets/pageContent/tools.tsx b/frontend/src/editor/assets/pageContent/tools.ts similarity index 100% rename from frontend/src/editor/assets/pageContent/tools.tsx rename to frontend/src/editor/assets/pageContent/tools.ts diff --git a/frontend/src/editor/assets/pageInformation.tsx b/frontend/src/editor/assets/pageInformation.ts similarity index 100% rename from frontend/src/editor/assets/pageInformation.tsx rename to frontend/src/editor/assets/pageInformation.ts diff --git a/frontend/src/editor/gitlabAccess.tsx b/frontend/src/editor/gitlabAccess.ts similarity index 100% rename from frontend/src/editor/gitlabAccess.tsx rename to frontend/src/editor/gitlabAccess.ts diff --git a/frontend/src/editor/indexeddb/db.tsx b/frontend/src/editor/indexeddb/db.ts similarity index 99% rename from frontend/src/editor/indexeddb/db.tsx rename to frontend/src/editor/indexeddb/db.ts index 745f012bf0704963ebe1efbbf008e90655360cc5..9201b2dd3fe570cd065f67e23b95d3b1be374a64 100644 --- a/frontend/src/editor/indexeddb/db.tsx +++ b/frontend/src/editor/indexeddb/db.ts @@ -44,7 +44,7 @@ db.version(dbVersion).stores({ learningObjectives: '++id, &name', learningActivities: '++id, &name, description, type, learningObjectiveId', injectInfos: '++id, &name, description, type', - tools: '++id, &name, tooltipDescription, hint, defaultResponse, category', + tools: '++id, &name, tooltipDescription, hint, defaultResponse', toolResponses: '++id, &learningActivityId, toolId, parameter, isRegex, content, fileId', emailAddresses: '++id, &address, organization, description, teamVisible', diff --git a/frontend/src/editor/indexeddb/joins.tsx b/frontend/src/editor/indexeddb/joins.ts similarity index 96% rename from frontend/src/editor/indexeddb/joins.tsx rename to frontend/src/editor/indexeddb/joins.ts index 4939599fe23f18bbb72d183eeeb79d77a0ed221f..8a0d2d0bde15bb53aed42040bdd1042876b121c0 100644 --- a/frontend/src/editor/indexeddb/joins.tsx +++ b/frontend/src/editor/indexeddb/joins.ts @@ -1,6 +1,6 @@ import notEmpty from '@inject/shared/utils/notEmpty' import type { EditorConfig } from '../useEditorStorage' -import { getExpression, getVariables } from '../utils' +import { getExpression, getVariablesForOutput } from '../utils' import { db } from './db' import { getEmailAddressById, @@ -47,10 +47,10 @@ export const getMilestonesWithNames = async () => { const response = await getToolResponseById(milestone.referenceId) if (!response) return - const tool = await getToolById(response?.id) + const tool = await getToolById(response.toolId) return { ...milestone, - name: `${tool ? `${tool.name} ` : ''}${response.parameter}`, + name: `${tool ? `${tool.name} - ` : ''}${response.parameter}`, } } case MilestoneEventType.EMAIL: { @@ -60,7 +60,7 @@ export const getMilestonesWithNames = async () => { const address = await getEmailAddressById(template.emailAddressId) return { ...milestone, - name: `${address ? `${address.address} ` : ''}${template.context}`, + name: `${address ? `${address.address} - ` : ''}${template.context}`, } } default: @@ -87,7 +87,7 @@ export const getLearningObjectivesWithActivities = async () => { const getMilestoneCondition = async (events: number[]) => { const milestones = await getMilestonesWithNames() - const variables = getVariables(milestones) + const variables = getVariablesForOutput(milestones) return getExpression(events, variables) } diff --git a/frontend/src/editor/indexeddb/operations.tsx b/frontend/src/editor/indexeddb/operations.ts similarity index 78% rename from frontend/src/editor/indexeddb/operations.tsx rename to frontend/src/editor/indexeddb/operations.ts index e893cf1e6aa34fa93e2ca2d9a9a003b5c9e7c1ed..9b2da5e08aca94cc9c6e1e9c39412bd65df2203b 100644 --- a/frontend/src/editor/indexeddb/operations.tsx +++ b/frontend/src/editor/indexeddb/operations.ts @@ -1,4 +1,5 @@ import { isEmpty } from 'lodash' +import { validateExpression } from '../utils' import { db } from './db' import type { MarkdownContent } from './types' import { @@ -125,23 +126,34 @@ export const deleteLearningActivity = async (id: number) => } ) +const isToolActivitySpecified = async (activityId: number) => { + const response = await getToolResponseByActivityId(activityId) + const tool = response ? await getToolById(response?.toolId) : undefined + return response && tool && !isEmpty(response.parameter) +} + +const isEmailActivitySpecified = async (activityId: number) => { + const template = await getEmailTemplateByActivityId(activityId) + const sender = template + ? await getEmailAddressById(template.emailAddressId) + : undefined + return template && sender && !isEmpty(template.context) +} + +export const isActivitySpecified = async (activity: LearningActivityInfo) => { + switch (activity.type) { + case LearningActivityType.TOOL: + return await isToolActivitySpecified(activity.id) + case LearningActivityType.EMAIL: + return await isEmailActivitySpecified(activity.id) + } +} + export const areActivitiesSpecified = async () => { const activities = await db.learningActivities.toArray() const results = await Promise.all( - activities.map(async activity => { - if (activity.type === LearningActivityType.TOOL) { - const response = await getToolResponseByActivityId(activity.id) - const tool = response ? await getToolById(response?.toolId) : undefined - return response && tool && !isEmpty(response.parameter) - } else { - const template = await getEmailTemplateByActivityId(activity.id) - const sender = template - ? await getEmailAddressById(template.emailAddressId) - : undefined - return template && sender && !isEmpty(template.context) - } - }) + activities.map(async activity => await isActivitySpecified(activity)) ) return results.every(Boolean) @@ -188,33 +200,64 @@ export const deleteInjectInfo = async (id: number) => } ) +export const isEmailInjectSpecified = async (injectId: number) => { + const email = await getEmailInjectByInjectInfoId(injectId) + const sender = email + ? await getEmailAddressById(email?.emailAddressId) + : undefined + return email && sender && !isEmpty(email.subject) +} + +export const isQuestionnaireInjectSpecified = async (injectId: number) => { + const questionnaire = await getQuestionnaireByInjectInfoId(injectId) + const questionsCount = questionnaire + ? await db.questionnaireQuestions + .where({ questionnaireId: questionnaire?.id }) + .count() + : 0 + return questionnaire && !isEmpty(questionnaire.title) && questionsCount > 0 +} + +export const isInjectSpecified = async (inject: InjectInfo) => { + switch (inject.type) { + case InjectType.INFORMATION: + return true + case InjectType.EMAIL: + return await isEmailInjectSpecified(inject.id) + case InjectType.QUESTIONNAIRE: + return await isQuestionnaireInjectSpecified(inject.id) + } +} + export const areInjectsSpecified = async () => { const injects = await db.injectInfos.toArray() const results = await Promise.all( - injects.map(async inject => { - if (inject.type === InjectType.INFORMATION) { - const info = await getInformationInjectByInjectInfoId(inject.id) - return info - } - if (inject.type === InjectType.EMAIL) { - const email = await getEmailInjectByInjectInfoId(inject.id) - const sender = email - ? await getEmailAddressById(email?.emailAddressId) - : undefined - return email && sender && !isEmpty(email.subject) - } else { - const questionnaire = await getQuestionnaireByInjectInfoId(inject.id) - const questionsCount = questionnaire - ? await db.questionnaireQuestions - .where({ questionnaireId: questionnaire?.id }) - .count() - : 0 - return ( - questionnaire && !isEmpty(questionnaire.title) && questionsCount > 0 - ) - } - }) + injects.map(async inject => await isInjectSpecified(inject)) + ) + + return results.every(Boolean) +} + +export const doesInjectHaveCorrectCondition = async ( + injectId: number, + milestones: Milestone[] +) => { + const control = await getInjectControlByInjectInfoId(injectId) + + return validateExpression(control?.milestoneCondition || [], milestones) + .isValid +} + +export const doInjectsHaveCorrectConditions = async () => { + const injects = await db.injectInfos.toArray() + const milestones = await db.milestones.toArray() + + const results = await Promise.all( + injects.map( + async inject => + await doesInjectHaveCorrectCondition(inject.id, milestones) + ) ) return results.every(Boolean) @@ -277,15 +320,56 @@ export const deleteToolResponse = async (id: number) => .delete() }) +export const doesToolHaveResponse = async (toolId: number) => + (await db.toolResponses.where({ toolId }).count()) > 0 + export const doToolsHaveResponses = async () => { const tools = await db.tools.toArray() const results = await Promise.all( - tools.map( - async tool => - (await db.toolResponses.where({ toolId: tool.id }).count()) > 0 - ) + tools.map(async tool => await doesToolHaveResponse(tool.id)) + ) + return results.every(Boolean) +} + +export const doesToolResponseHaveCorrectCondition = ( + response: ToolResponse, + milestones: Milestone[] +) => validateExpression(response.milestoneCondition || [], milestones).isValid + +export const doToolResponsesHaveCorrectConditions = async () => { + const responses = await db.toolResponses.toArray() + const milestones = await db.milestones.toArray() + + const results = responses.map(response => + doesToolResponseHaveCorrectCondition(response, milestones) + ) + + return results.every(Boolean) +} + +export const doesLearningActivityHaveCorrectCondition = async ( + activity: LearningActivityInfo, + milestones: Milestone[] +) => { + if (activity.type !== LearningActivityType.TOOL) return true + + const response = await getToolResponseByActivityId(activity.id) + + return validateExpression(response?.milestoneCondition || [], milestones) + .isValid +} + +export const doLearningActivitiesHaveCorrectConditions = async () => { + const responses = await db.toolResponses.toArray() + const milestones = await db.milestones.toArray() + + const results = responses.map( + response => + !response.learningActivityId || + doesToolResponseHaveCorrectCondition(response, milestones) ) + return results.every(Boolean) } diff --git a/frontend/src/editor/indexeddb/types.tsx b/frontend/src/editor/indexeddb/types.ts similarity index 99% rename from frontend/src/editor/indexeddb/types.tsx rename to frontend/src/editor/indexeddb/types.ts index d14f9bcfea8d3012565f8c23dfe56219803c6676..eebbaa4136b47cd533cb1d4f48312f8491b88bc9 100644 --- a/frontend/src/editor/indexeddb/types.tsx +++ b/frontend/src/editor/indexeddb/types.ts @@ -41,7 +41,6 @@ export type InjectInfo = { export type ToolInfo = { id: number name: string - category?: string tooltipDescription?: string defaultResponse: string hint?: string diff --git a/frontend/src/editor/types.tsx b/frontend/src/editor/types.ts similarity index 100% rename from frontend/src/editor/types.tsx rename to frontend/src/editor/types.ts diff --git a/frontend/src/editor/utils.tsx b/frontend/src/editor/utils.ts similarity index 62% rename from frontend/src/editor/utils.tsx rename to frontend/src/editor/utils.ts index 40d47494d036987952ed37f3d08d38732a3d8535..4e756f01a6a682b3604a8d94c236c96719709a9a 100644 --- a/frontend/src/editor/utils.tsx +++ b/frontend/src/editor/utils.ts @@ -1,9 +1,9 @@ import type { IconName, OptionProps } from '@blueprintjs/core' import notEmpty from '@inject/shared/utils/notEmpty' -import { isEqual } from 'lodash' import type { InjectInfo, LearningActivityInfo, + Milestone, MilestoneName, } from './indexeddb/types' import { @@ -41,12 +41,12 @@ export const getInjectIcon = (inject: InjectInfo): IconName => { } // 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 AND: OptionProps = { value: '-4', label: 'and' } -export const OR: OptionProps = { value: '-5', label: 'or' } +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 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, @@ -56,18 +56,24 @@ export const ALL_OPERATORS: OptionProps[] = [ ] export const getBlockFromId = (id: number, variables: OptionProps[]) => - ALL_OPERATORS.find(operator => Number(operator.value) === id) || - variables.find(variable => Number(variable.value) === id) + ALL_OPERATORS.find(operator => operator.value === id) || + variables.find(variable => variable.value === id) export const getExpression = (expression: number[], variables: OptionProps[]) => expression.map(id => getBlockFromId(id, variables)).filter(notEmpty) export const getVariables = (milestones: MilestoneName[]) => milestones.map(milestone => ({ - value: milestone.id.toString(), + value: milestone.id, label: getMilestoneName(milestone), })) +export const getVariablesForOutput = (milestones: MilestoneName[]) => + milestones.map(milestone => ({ + value: milestone.id, + label: getMilestoneNameForOutput(milestone), + })) + export const getPrefixByMilestoneType = (type: MilestoneEventType) => { switch (type) { case MilestoneEventType.LEARNING_ACTIVITY: @@ -84,7 +90,10 @@ export const getPrefixByMilestoneType = (type: MilestoneEventType) => { } export const getMilestoneName = (milestone: MilestoneName) => - `${getPrefixByMilestoneType(milestone.type)}_${milestone.name.replace(/\s+/g, '_')}${milestone.type === MilestoneEventType.TOOL || milestone.type === MilestoneEventType.EMAIL ? `_${milestone.id}` : ''}` + `${getPrefixByMilestoneType(milestone.type)}: ${milestone.name}` + +export const getMilestoneNameForOutput = (milestone: MilestoneName) => + `${getPrefixByMilestoneType(milestone.type)}_${milestone.name.replace(/\s+/g, '_').replace(/[^A-Za-z0-9_]/g, '')}_${milestone.id}` type ValidationResult = { isValid: boolean @@ -92,78 +101,82 @@ type ValidationResult = { } export const validateExpression = ( - expression: OptionProps[], - variables: OptionProps[] + expression: number[], + variables: Milestone[] ): ValidationResult => { let balance = 0 - let lastBlock: OptionProps | undefined = undefined + let lastBlock: number | undefined = undefined for (const block of expression) { - if (isEqual(block, OPENING_BRACKET)) { + if (block === OPENING_BRACKET.value) { balance++ - if (variables.find(value => isEqual(value, lastBlock))) { + if (variables.find(value => value.id === lastBlock)) { return { isValid: false, error: 'Missing operator after variable' } } - if (isEqual(lastBlock, CLOSING_BRACKET)) { + if (lastBlock === CLOSING_BRACKET.value) { return { isValid: false, error: 'Missing operator after closing bracket', } } - } else if (isEqual(block, CLOSING_BRACKET)) { + } else if (block === CLOSING_BRACKET.value) { balance-- if (balance < 0) { return { isValid: false, error: 'More closing brackets' } } - if (isEqual(lastBlock, OPENING_BRACKET)) { + if (lastBlock === OPENING_BRACKET.value) { return { isValid: false, error: 'Empty brackets' } } if ( - OPERATORS.find(value => isEqual(value, lastBlock)) || - isEqual(lastBlock, NOT) + OPERATORS.find(operator => operator.value === lastBlock) || + lastBlock === NOT.value ) { return { isValid: false, error: 'Missing variable after operator' } } - } else if (OPERATORS.find(value => isEqual(value, block))) { - if (OPERATORS.find(value => isEqual(value, lastBlock))) { + } else if (OPERATORS.find(operator => operator.value === block)) { + if (!lastBlock) + return { isValid: false, error: 'Cannot start with an operator' } + if (OPERATORS.find(operator => operator.value === lastBlock)) { return { isValid: false, error: 'Two operators in a row' } } - if (isEqual(lastBlock, NOT)) { + if (lastBlock === NOT.value) { return { isValid: false, error: 'Missing variable after NOT' } } - if (isEqual(lastBlock, OPENING_BRACKET)) { + if (lastBlock === OPENING_BRACKET.value) { return { isValid: false, error: 'Missing variable after opening bracket', } } - } else if (variables.find(value => isEqual(value, block))) { - if (variables.find(value => isEqual(value, lastBlock))) { + } else if (variables.find(value => value.id === block)) { + if (variables.find(value => value.id === lastBlock)) { return { isValid: false, error: 'Two variables in a row' } } - if (isEqual(lastBlock, CLOSING_BRACKET)) { + if (lastBlock === CLOSING_BRACKET.value) { return { isValid: false, error: 'Missing operator after closing bracket', } } - } else if (isEqual(block, NOT)) { - if (isEqual(lastBlock, CLOSING_BRACKET)) { + } else if (block === NOT.value) { + if (lastBlock === CLOSING_BRACKET.value) { return { isValid: false, error: 'Missing operator after closing bracket', } } - if (variables.find(value => isEqual(value, lastBlock))) { + if (variables.find(value => value.id === lastBlock)) { return { isValid: false, error: 'Missing operator after variable' } } + } else { + return { isValid: false, error: 'Missing variable' } } lastBlock = block } if ( - OPERATORS.find(value => isEqual(value, lastBlock)) || - isEqual(lastBlock, NOT) + OPERATORS.find(operator => operator.value === lastBlock) || + lastBlock === NOT.value ) { return { isValid: false, error: 'Cannot end with operator' } } else if (balance > 0) { diff --git a/frontend/src/editor/yaml/generate/channels.tsx b/frontend/src/editor/yaml/generate/channels.ts similarity index 100% rename from frontend/src/editor/yaml/generate/channels.tsx rename to frontend/src/editor/yaml/generate/channels.ts diff --git a/frontend/src/editor/yaml/generate/config.tsx b/frontend/src/editor/yaml/generate/config.ts similarity index 100% rename from frontend/src/editor/yaml/generate/config.tsx rename to frontend/src/editor/yaml/generate/config.ts diff --git a/frontend/src/editor/yaml/generate/email.tsx b/frontend/src/editor/yaml/generate/email.ts similarity index 100% rename from frontend/src/editor/yaml/generate/email.tsx rename to frontend/src/editor/yaml/generate/email.ts diff --git a/frontend/src/editor/yaml/generate/injects.tsx b/frontend/src/editor/yaml/generate/injects.ts similarity index 100% rename from frontend/src/editor/yaml/generate/injects.tsx rename to frontend/src/editor/yaml/generate/injects.ts diff --git a/frontend/src/editor/yaml/generate/milestones.tsx b/frontend/src/editor/yaml/generate/milestones.ts similarity index 85% rename from frontend/src/editor/yaml/generate/milestones.tsx rename to frontend/src/editor/yaml/generate/milestones.ts index 3de321393d6fa70bc08415a88254c90568805cf7..d377c2117eeb45554056f7999450030ec6758539 100644 --- a/frontend/src/editor/yaml/generate/milestones.tsx +++ b/frontend/src/editor/yaml/generate/milestones.ts @@ -2,7 +2,7 @@ import { getUsedMilestones } from '@/editor/indexeddb/joins' import type { MilestoneName } from '@/editor/indexeddb/types' import { MilestoneEventType } from '@/editor/indexeddb/types' import type { EditorConfig } from '@/editor/useEditorStorage' -import { getMilestoneName } from '@/editor/utils' +import { getMilestoneNameForOutput } from '@/editor/utils' import { pickBy } from 'lodash' export const generateMilestones = async (config: EditorConfig) => { @@ -10,7 +10,7 @@ export const generateMilestones = async (config: EditorConfig) => { return milestones.map((milestone: MilestoneName) => pickBy({ - name: getMilestoneName(milestone), + name: getMilestoneNameForOutput(milestone), activity: milestone.type === MilestoneEventType.LEARNING_ACTIVITY ? milestone.name diff --git a/frontend/src/editor/yaml/generate/objectives.tsx b/frontend/src/editor/yaml/generate/objectives.ts similarity index 100% rename from frontend/src/editor/yaml/generate/objectives.tsx rename to frontend/src/editor/yaml/generate/objectives.ts diff --git a/frontend/src/editor/yaml/generate/questionnaires.tsx b/frontend/src/editor/yaml/generate/questionnaires.ts similarity index 100% rename from frontend/src/editor/yaml/generate/questionnaires.tsx rename to frontend/src/editor/yaml/generate/questionnaires.ts diff --git a/frontend/src/editor/yaml/generate/shared.tsx b/frontend/src/editor/yaml/generate/shared.ts similarity index 91% rename from frontend/src/editor/yaml/generate/shared.tsx rename to frontend/src/editor/yaml/generate/shared.ts index 819eb38f39681c7c69a4b05d89601de4975fab76..705efce6436eac749f1af8cf9f7d395c8f00bf09 100644 --- a/frontend/src/editor/yaml/generate/shared.tsx +++ b/frontend/src/editor/yaml/generate/shared.ts @@ -4,7 +4,7 @@ import type { MilestoneName, Overlay, } from '@/editor/indexeddb/types' -import { getMilestoneName } from '@/editor/utils' +import { getMilestoneNameForOutput } from '@/editor/utils' import type { OptionProps } from '@blueprintjs/core' import { isEmpty, pickBy } from 'lodash' @@ -29,7 +29,7 @@ export const generateControl = ( ) => { const generatedControl = pickBy({ activate_milestone: activateMilestone - ? getMilestoneName(activateMilestone) + ? getMilestoneNameForOutput(activateMilestone) : '', milestone_condition: milestoneCondition ?.map(block => block.label) diff --git a/frontend/src/editor/yaml/generate/tools.tsx b/frontend/src/editor/yaml/generate/tools.ts similarity index 100% rename from frontend/src/editor/yaml/generate/tools.tsx rename to frontend/src/editor/yaml/generate/tools.ts diff --git a/frontend/src/editor/yaml/parse/channels.tsx b/frontend/src/editor/yaml/parse/channels.ts similarity index 100% rename from frontend/src/editor/yaml/parse/channels.tsx rename to frontend/src/editor/yaml/parse/channels.ts diff --git a/frontend/src/editor/yaml/parse/config.tsx b/frontend/src/editor/yaml/parse/config.ts similarity index 100% rename from frontend/src/editor/yaml/parse/config.tsx rename to frontend/src/editor/yaml/parse/config.ts diff --git a/frontend/src/editor/yaml/parse/email.tsx b/frontend/src/editor/yaml/parse/email.ts similarity index 100% rename from frontend/src/editor/yaml/parse/email.tsx rename to frontend/src/editor/yaml/parse/email.ts diff --git a/frontend/src/editor/yaml/parse/injects.tsx b/frontend/src/editor/yaml/parse/injects.ts similarity index 100% rename from frontend/src/editor/yaml/parse/injects.tsx rename to frontend/src/editor/yaml/parse/injects.ts diff --git a/frontend/src/editor/yaml/parse/milestones.tsx b/frontend/src/editor/yaml/parse/milestones.ts similarity index 100% rename from frontend/src/editor/yaml/parse/milestones.tsx rename to frontend/src/editor/yaml/parse/milestones.ts diff --git a/frontend/src/editor/yaml/parse/objectives.tsx b/frontend/src/editor/yaml/parse/objectives.ts similarity index 100% rename from frontend/src/editor/yaml/parse/objectives.tsx rename to frontend/src/editor/yaml/parse/objectives.ts diff --git a/frontend/src/editor/yaml/parse/questionnaires.tsx b/frontend/src/editor/yaml/parse/questionnaires.ts similarity index 100% rename from frontend/src/editor/yaml/parse/questionnaires.tsx rename to frontend/src/editor/yaml/parse/questionnaires.ts diff --git a/frontend/src/editor/yaml/parse/shared.tsx b/frontend/src/editor/yaml/parse/shared.ts similarity index 94% rename from frontend/src/editor/yaml/parse/shared.tsx rename to frontend/src/editor/yaml/parse/shared.ts index 0ea75bf953335b59aa0dab4ea244c7a9d8da2f11..55d40dfcab98c2f223f9b972eda45370c8210d62 100644 --- a/frontend/src/editor/yaml/parse/shared.tsx +++ b/frontend/src/editor/yaml/parse/shared.ts @@ -33,8 +33,8 @@ export const getMilestoneCondition = ( milestonesWithIds: MappedMilestone[] ) => condition - .split(' ') - .map( + .match(/\bnot\b|\band\b|\bor\b|[a-zA-Z0-9_]+|[()]/g) + ?.map( value => Number( ALL_OPERATORS.find(operator => operator.label === value)?.value diff --git a/frontend/src/editor/yaml/parse/tools.tsx b/frontend/src/editor/yaml/parse/tools.ts similarity index 100% rename from frontend/src/editor/yaml/parse/tools.tsx rename to frontend/src/editor/yaml/parse/tools.ts diff --git a/frontend/src/editor/yaml/types.tsx b/frontend/src/editor/yaml/types.ts similarity index 100% rename from frontend/src/editor/yaml/types.tsx rename to frontend/src/editor/yaml/types.ts diff --git a/frontend/src/editor/zip/createDefinitionZip.tsx b/frontend/src/editor/zip/createDefinitionZip.ts similarity index 100% rename from frontend/src/editor/zip/createDefinitionZip.tsx rename to frontend/src/editor/zip/createDefinitionZip.ts diff --git a/frontend/src/editor/zip/loadDefinitionZip.tsx b/frontend/src/editor/zip/loadDefinitionZip.ts similarity index 100% rename from frontend/src/editor/zip/loadDefinitionZip.tsx rename to frontend/src/editor/zip/loadDefinitionZip.ts diff --git a/frontend/src/editor/zip/utils.tsx b/frontend/src/editor/zip/utils.ts similarity index 100% rename from frontend/src/editor/zip/utils.tsx rename to frontend/src/editor/zip/utils.ts diff --git a/frontend/src/logic/StaffSelector/index.tsx b/frontend/src/logic/StaffSelector/index.tsx index 3f25d22669ce891c3de52140afe55abbb591a6e8..3358f816c4d6aefa849a63fd1429c90d16463677 100644 --- a/frontend/src/logic/StaffSelector/index.tsx +++ b/frontend/src/logic/StaffSelector/index.tsx @@ -42,6 +42,10 @@ const StaffSelector: FC<StaffSelectorInterface> = ({ enableRefresh }) => { link={['/analyst']} button={{ icon: 'series-search', text: 'Analyst' }} /> + <LinkButton + link={['/editor']} + button={{ icon: 'annotation', text: 'Designer' }} + /> </ButtonGroup> {enableRefresh && <Reloader minimal onRefetch={refetch} />} </div> diff --git a/frontend/src/pages/editor/_layout.tsx b/frontend/src/pages/editor/_layout.tsx index 45e2df52a81bd91fb998fe4064412d2e907e94ca..ded2c09c1166f580d26afd1dec3be650944ea95f 100644 --- a/frontend/src/pages/editor/_layout.tsx +++ b/frontend/src/pages/editor/_layout.tsx @@ -1,7 +1,8 @@ -import { useSetPageTitle } from '@/utils' +import { useActiveBoundary, useSetPageTitle } from '@/utils' import { Outlet } from 'react-router-dom' const Layout = () => { + useActiveBoundary() useSetPageTitle('Editor') return <Outlet /> diff --git a/frontend/src/pages/editor/create/other.tsx b/frontend/src/pages/editor/create/other.tsx index b171943363d50ed982033ecc775186d70593d61e..e8c647db09c7e344f8b2722c9f19574373a9f32b 100644 --- a/frontend/src/pages/editor/create/other.tsx +++ b/frontend/src/pages/editor/create/other.tsx @@ -1,14 +1,47 @@ import EditorPage from '@/editor/EditorPage' +import OverviewCard from '@/editor/OverviewCard' +import { + doToolResponsesHaveCorrectConditions, + doToolsHaveResponses, +} from '@/editor/indexeddb/operations' import { PageNames } from '@/editor/types' import useEditorAccessStorage from '@/editor/useEditorAccessStorage' import { useNavigate } from '@/router' import { Card, CardList } from '@blueprintjs/core' -import { memo } from 'react' +import { useLiveQuery } from 'dexie-react-hooks' +import { memo, useMemo } from 'react' const OtherPage = () => { const nav = useNavigate() const [access] = useEditorAccessStorage() + const toolsHaveResponses = useLiveQuery( + () => doToolsHaveResponses(), + [], + false + ) + const toolsHaveCorrectConditions = useLiveQuery( + () => doToolResponsesHaveCorrectConditions(), + [], + false + ) + + const areSpecified = useMemo( + () => toolsHaveResponses && toolsHaveCorrectConditions, + [toolsHaveResponses, toolsHaveCorrectConditions] + ) + const tooltipContent = useMemo( + () => + [ + !toolsHaveResponses && + 'Every tool has to have at least one tool response', + !toolsHaveCorrectConditions && 'Incorrect milestone conditions', + ] + .filter(Boolean) + .join(', '), + [toolsHaveResponses, toolsHaveCorrectConditions] + ) + return ( <EditorPage pageKey={PageNames.OTHER} @@ -18,9 +51,12 @@ const OtherPage = () => { nextDisabled={!access?.specificationsFilled} > <CardList> - <Card interactive onClick={() => nav(`/editor/create/tools`)}> - Tools - </Card> + <OverviewCard + name='Tools' + onClick={() => nav(`/editor/create/tools`)} + isSpecified={areSpecified} + tooltipContent={tooltipContent} + /> <Card interactive onClick={() => nav(`/editor/create/emails`)}> Email addresses </Card> diff --git a/frontend/src/views/EditorView/index.tsx b/frontend/src/views/EditorView/index.tsx index a48ddec32eb74fe820107f1780e569f36833711c..a27955326cdafcee74c4b4fdbcd2e4d566426e66 100644 --- a/frontend/src/views/EditorView/index.tsx +++ b/frontend/src/views/EditorView/index.tsx @@ -8,8 +8,9 @@ import { db } from '@/editor/indexeddb/db' import { areActivitiesSpecified, areInjectsSpecified, + doInjectsHaveCorrectConditions, + doToolResponsesHaveCorrectConditions, doToolsHaveResponses, - getMilestoneById, } from '@/editor/indexeddb/operations' import useEditorAccessStorage from '@/editor/useEditorAccessStorage' import useEditorStorage from '@/editor/useEditorStorage' @@ -40,15 +41,20 @@ const EditorView: FC<PropsWithChildren> = ({ children }) => { false ) const injectsSpecified = useLiveQuery(() => areInjectsSpecified(), [], false) + const injectsHaveCorrectConditions = useLiveQuery( + () => doInjectsHaveCorrectConditions(), + [], + false + ) const toolsHaveResponses = useLiveQuery( () => doToolsHaveResponses(), [], false ) - const finalMilestoneSpecified = useLiveQuery( - () => getMilestoneById(config?.finalMilestone || 0), - [config?.finalMilestone], - undefined + const toolsHaveCorrectConditions = useLiveQuery( + () => doToolResponsesHaveCorrectConditions(), + [], + false ) const [allIntroChecked, setAllIntroChecked] = useState( @@ -92,12 +98,12 @@ const EditorView: FC<PropsWithChildren> = ({ children }) => { specificationsFilled: activitiesSpecified && injectsSpecified && + injectsHaveCorrectConditions && toolsHaveResponses && + toolsHaveCorrectConditions && prev?.injectsFilled, finalInformationFilled: - (config?.exerciseDuration || 0) > 0 && - finalMilestoneSpecified && - prev?.specificationsFilled, + (config?.exerciseDuration || 0) > 0 && prev?.specificationsFilled, conclusionFilled: allConclusionChecked && prev?.finalInformationFilled, })) }, [ @@ -114,7 +120,8 @@ const EditorView: FC<PropsWithChildren> = ({ children }) => { activitiesSpecified, toolsHaveResponses, injectsSpecified, - finalMilestoneSpecified, + injectsHaveCorrectConditions, + toolsHaveCorrectConditions, config?.exerciseDuration, allConclusionChecked, ]) @@ -168,7 +175,12 @@ const EditorView: FC<PropsWithChildren> = ({ children }) => { node: !hide && <Navbar />, }, ], - [hide] + [ + hide, + access?.exerciseInformationFilled, + config?.name, + gitlabConfig?.project, + ] ) return (