Loading editor/src/importExport.ts +7 −28 Original line number Diff line number Diff line Loading @@ -65,12 +65,9 @@ const generateFileContents = async () => { const fileMapping: Array<{ name: string; content: string | Blob }> = [] for (const [tableName, fileName] of Object.entries(TABLE_TO_FILE)) { // TODO: maybe there is a better way to type this? maybe dexie has something? // eslint-disable-next-line @typescript-eslint/no-explicit-any const table = (db as Record<string, any>)[tableName] const table = db.table(tableName) if (!table) { // TODO: can this happen? how to handle this case? continue throw new Error('Wrong TABLE_TO_FILE name.') } let records Loading @@ -90,7 +87,6 @@ const generateFileContents = async () => { } if (!records || (Array.isArray(records) && records.length === 0)) { // TODO: decide if we want to export empty files or not continue } Loading Loading @@ -123,13 +119,6 @@ const generateFileContents = async () => { }) }) // TODO: Export database blob for backup? // const expDb = await exportDB(db) // fileMapping.push({ // name: '_database.db', // content: expDb, // }) return fileMapping } Loading Loading @@ -393,12 +382,11 @@ export const loadDbData = async (zip: JSZip) => { for (const [fileName, tableName] of Object.entries(FILE_TO_TABLE)) { const records = await loadYamlData(zip, fileName) if (tableName === 'config') { if (!records || typeof records !== 'object' || Array.isArray(records)) { throw new Error('Config must be an object') } console.log(records) await db.config.clear() applyContentPathsDeep(records, contentMap, llmMap, false) const normalized = normalizeConfig(records) Loading @@ -406,19 +394,15 @@ export const loadDbData = async (zip: JSZip) => { continue } if (!Array.isArray(records)) { // TODO: handle empty or invalid files? throw new Error('YAML file does not contain array') if (!Array.isArray(records) || records.length === 0) { continue } applyContentPathsDeep(records, contentMap, llmMap, false) // TODO: maybe there is a better way to type this? maybe dexie has something? // eslint-disable-next-line @typescript-eslint/no-explicit-any const table = (db as Record<string, any>)[tableName] const table = db.table(tableName) if (!table || typeof table !== 'object') { // TODO: can this happen? how to handle this case? continue throw new Error('Wrong TABLE_TO_FILE name.') } if (tableName === 'inject') { Loading @@ -437,9 +421,4 @@ export const loadDbData = async (zip: JSZip) => { await importFiles(zip) await importDrive(zip) // TODO: optionally restore from database blob for full backup? // const dbFile = zip.file('_database.db') // if (dbFile) { // await importDB(await dbFile.async('blob')) // } } frontend/src/email/TeamEmails/EmailCard.tsx +91 −4 Original line number Diff line number Diff line import { Button, ButtonGroup, Classes, Colors, Divider, Icon, Section, SectionCard, } from '@blueprintjs/core' import { Shield } from '@blueprintjs/icons' import { css, cx } from '@emotion/css' import { SetTimestampRead, useTypedMutation } from '@inject/graphql' import { LLMEnabledQuery, SetTimestampRead, useTypedMutation, useTypedQuery, } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' import { breakWord, Timestamp } from '@inject/shared' import { breakWord, LinkButton, notifyNoncommmit, Timestamp, } from '@inject/shared' import type { NavigateOptions } from '@tanstack/react-router' import type { FC } from 'react' import { useEffect, useLayoutEffect, useState } from 'react' Loading @@ -20,6 +32,7 @@ import { Assessment } from '../../components/Assessment' import type { AssessmentProps } from '../../components/Assessment/types' import Description from '../../components/Description' import { FileViewRedirectButton } from '../../components/FileViewRedirectButton' import { useToggleDone } from '../../hooks/useToggleDone' import { canBeCommented } from '../../utils' import { OPEN_COMPOSE_EVENT_TYPE } from '../EmailFormOverlay/events' import type { ExtendedEmail } from '../typing' Loading @@ -45,6 +58,17 @@ const rightElement = css` align-items: center; ` const buttonGroupRow = css` display: flex; justify-content: flex-end; ` const buttonTextWithHelp = css` display: inline-flex; align-items: center; gap: 1rem; ` export const EmailCard: FC<{ exerciseId: string teamId?: string Loading @@ -54,6 +78,8 @@ export const EmailCard: FC<{ inInstructor?: boolean getFileLink: (fileId: string) => NavigateOptions assessmentProps?: AssessmentProps markDoneAvailable?: boolean overviewLink?: NavigateOptions }> = ({ exerciseId, teamId, Loading @@ -63,9 +89,25 @@ export const EmailCard: FC<{ inInstructor, getFileLink, assessmentProps, overviewLink, markDoneAvailable, }) => { const [, setTimestampRead] = useTypedMutation(SetTimestampRead) const [{ data: llmData, fetching: llmFetching }] = useTypedQuery({ query: LLMEnabledQuery, variables: { exerciseId }, pause: !assessmentProps, }) const { t } = useTranslationFrontend() const { toggleDone } = useToggleDone( email.__typename === 'IActionLogType' ? { __typename: email.__typename, id: email.id, done: email.done, } : undefined ) // this ensures the message is rendered as 'not read' the first time it's rendered const [initialTimestampRead, setInitialTimestampRead] = useState(false) Loading Loading @@ -94,6 +136,22 @@ export const EmailCard: FC<{ ]) const definitionAddress = email.details.sender.definitionAddress const llmEnabled = llmData?.exerciseId.llm ?? false const showAssessment = !!assessmentProps && llmEnabled const showDivider = !assessmentProps || (!llmFetching && !llmEnabled) const MarkAsDoneAndNotify = () => { toggleDone() notifyNoncommmit(t('overview.todoList.notification.description'), { intent: 'success', timeout: 2000, action: { icon: 'undo', text: t('overview.todoList.notification.undo'), onClick: () => toggleDone(false), }, }) } return ( <Section Loading @@ -105,7 +163,7 @@ export const EmailCard: FC<{ icon={ teamId && email.details.sender.team?.id === teamId ? ( <Icon className={Classes.TEXT_MUTED} className={cx(Classes.TEXT_MUTED)} icon='send-message' title={t('emails.senderTeam')} /> Loading Loading @@ -222,7 +280,36 @@ export const EmailCard: FC<{ /> ))} {assessmentProps && <Assessment {...assessmentProps} />} {showAssessment && assessmentProps && ( <Assessment {...assessmentProps} /> )} {markDoneAvailable && teamId && !email.done && ( <> {showDivider && <Divider />} <div className={buttonGroupRow}> <ButtonGroup> <Button icon='changes' onClick={MarkAsDoneAndNotify}> {t('overview.todoList.markAsDone')} </Button> {overviewLink && ( <LinkButton button={{ icon: 'changes', onClick: MarkAsDoneAndNotify, text: ( <span className={buttonTextWithHelp}> {t('overview.todoList.markAsDoneAndReturn')} </span> ), minimal: true, }} link={overviewLink} /> )} </ButtonGroup> </div> </> )} </SectionCard> </Section> ) Loading frontend/src/hooks/useToggleDone.ts 0 → 100644 +20 −0 Original line number Diff line number Diff line import type { ITodoLogActionLog } from '@inject/graphql' import { SetDone, useTypedMutation } from '@inject/graphql' type ToggleDoneLog = Pick<ITodoLogActionLog, '__typename' | 'id' | 'done'> export const useToggleDone = (actionLog?: ToggleDoneLog) => { const [, setDone] = useTypedMutation(SetDone) return { toggleDone: (nextState?: boolean) => { if (!actionLog) { return } setDone({ actionLogId: actionLog.id, state: nextState ?? !actionLog.done, typename: actionLog.__typename, }) }, } } frontend/src/instructor/InstructorTodoLog/LogItem.tsx +6 −4 Original line number Diff line number Diff line Loading @@ -393,6 +393,7 @@ const LogItemActions: React.FC<{ actionLog: ITodoLogActionLog contextType: 'exercise' | 'team' }> = ({ actionLog, contextType }) => { const { t } = useTranslationFrontend() const threadId = actionLog.details.__typename === 'IEmailType' ? actionLog.details.thread.id Loading @@ -404,14 +405,15 @@ const LogItemActions: React.FC<{ const { toggleDone } = useToggleDone(actionLog) return ( <ButtonGroup vertical> <Button small icon='changes' onClick={toggleDone}> {actionLog.done ? 'Mark as undone' : 'Mark as done'} <Button icon='changes' onClick={() => toggleDone()}> {actionLog.done ? t('overview.todoList.markAsUndone') : t('overview.todoList.markAsDone')} </Button> <LinkButton button={{ icon: 'zoom-in', text: 'Inspect', small: true, text: t('overview.todoList.inspect'), }} link={getNavigateOptions(actionLog)} /> Loading frontend/src/routes/_protected/instructor/$exerciseId/$teamId/email/$tab/$threadId.tsx +8 −0 Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ import { useTranslationFrontend } from '@inject/locale' import { EmailSelection } from '@inject/shared' import { createFileRoute } from '@tanstack/react-router' import { useMemo } from 'react' import { InstructorLandingPageRoute } from '../../..' import { OPEN_REPLY_EVENT_TYPE } from '../../../../../../../email/EmailFormOverlay/events' import { EmailCard } from '../../../../../../../email/TeamEmails/EmailCard' import { ThreadHeaderCard } from '../../../../../../../email/TeamEmails/ThreadHeaderCard' Loading Loading @@ -151,6 +152,13 @@ const RouteComponent = () => { } : undefined } markDoneAvailable overviewLink={{ to: InstructorLandingPageRoute.to, params: { exerciseId, }, }} /> ))} </div> Loading Loading
editor/src/importExport.ts +7 −28 Original line number Diff line number Diff line Loading @@ -65,12 +65,9 @@ const generateFileContents = async () => { const fileMapping: Array<{ name: string; content: string | Blob }> = [] for (const [tableName, fileName] of Object.entries(TABLE_TO_FILE)) { // TODO: maybe there is a better way to type this? maybe dexie has something? // eslint-disable-next-line @typescript-eslint/no-explicit-any const table = (db as Record<string, any>)[tableName] const table = db.table(tableName) if (!table) { // TODO: can this happen? how to handle this case? continue throw new Error('Wrong TABLE_TO_FILE name.') } let records Loading @@ -90,7 +87,6 @@ const generateFileContents = async () => { } if (!records || (Array.isArray(records) && records.length === 0)) { // TODO: decide if we want to export empty files or not continue } Loading Loading @@ -123,13 +119,6 @@ const generateFileContents = async () => { }) }) // TODO: Export database blob for backup? // const expDb = await exportDB(db) // fileMapping.push({ // name: '_database.db', // content: expDb, // }) return fileMapping } Loading Loading @@ -393,12 +382,11 @@ export const loadDbData = async (zip: JSZip) => { for (const [fileName, tableName] of Object.entries(FILE_TO_TABLE)) { const records = await loadYamlData(zip, fileName) if (tableName === 'config') { if (!records || typeof records !== 'object' || Array.isArray(records)) { throw new Error('Config must be an object') } console.log(records) await db.config.clear() applyContentPathsDeep(records, contentMap, llmMap, false) const normalized = normalizeConfig(records) Loading @@ -406,19 +394,15 @@ export const loadDbData = async (zip: JSZip) => { continue } if (!Array.isArray(records)) { // TODO: handle empty or invalid files? throw new Error('YAML file does not contain array') if (!Array.isArray(records) || records.length === 0) { continue } applyContentPathsDeep(records, contentMap, llmMap, false) // TODO: maybe there is a better way to type this? maybe dexie has something? // eslint-disable-next-line @typescript-eslint/no-explicit-any const table = (db as Record<string, any>)[tableName] const table = db.table(tableName) if (!table || typeof table !== 'object') { // TODO: can this happen? how to handle this case? continue throw new Error('Wrong TABLE_TO_FILE name.') } if (tableName === 'inject') { Loading @@ -437,9 +421,4 @@ export const loadDbData = async (zip: JSZip) => { await importFiles(zip) await importDrive(zip) // TODO: optionally restore from database blob for full backup? // const dbFile = zip.file('_database.db') // if (dbFile) { // await importDB(await dbFile.async('blob')) // } }
frontend/src/email/TeamEmails/EmailCard.tsx +91 −4 Original line number Diff line number Diff line import { Button, ButtonGroup, Classes, Colors, Divider, Icon, Section, SectionCard, } from '@blueprintjs/core' import { Shield } from '@blueprintjs/icons' import { css, cx } from '@emotion/css' import { SetTimestampRead, useTypedMutation } from '@inject/graphql' import { LLMEnabledQuery, SetTimestampRead, useTypedMutation, useTypedQuery, } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' import { breakWord, Timestamp } from '@inject/shared' import { breakWord, LinkButton, notifyNoncommmit, Timestamp, } from '@inject/shared' import type { NavigateOptions } from '@tanstack/react-router' import type { FC } from 'react' import { useEffect, useLayoutEffect, useState } from 'react' Loading @@ -20,6 +32,7 @@ import { Assessment } from '../../components/Assessment' import type { AssessmentProps } from '../../components/Assessment/types' import Description from '../../components/Description' import { FileViewRedirectButton } from '../../components/FileViewRedirectButton' import { useToggleDone } from '../../hooks/useToggleDone' import { canBeCommented } from '../../utils' import { OPEN_COMPOSE_EVENT_TYPE } from '../EmailFormOverlay/events' import type { ExtendedEmail } from '../typing' Loading @@ -45,6 +58,17 @@ const rightElement = css` align-items: center; ` const buttonGroupRow = css` display: flex; justify-content: flex-end; ` const buttonTextWithHelp = css` display: inline-flex; align-items: center; gap: 1rem; ` export const EmailCard: FC<{ exerciseId: string teamId?: string Loading @@ -54,6 +78,8 @@ export const EmailCard: FC<{ inInstructor?: boolean getFileLink: (fileId: string) => NavigateOptions assessmentProps?: AssessmentProps markDoneAvailable?: boolean overviewLink?: NavigateOptions }> = ({ exerciseId, teamId, Loading @@ -63,9 +89,25 @@ export const EmailCard: FC<{ inInstructor, getFileLink, assessmentProps, overviewLink, markDoneAvailable, }) => { const [, setTimestampRead] = useTypedMutation(SetTimestampRead) const [{ data: llmData, fetching: llmFetching }] = useTypedQuery({ query: LLMEnabledQuery, variables: { exerciseId }, pause: !assessmentProps, }) const { t } = useTranslationFrontend() const { toggleDone } = useToggleDone( email.__typename === 'IActionLogType' ? { __typename: email.__typename, id: email.id, done: email.done, } : undefined ) // this ensures the message is rendered as 'not read' the first time it's rendered const [initialTimestampRead, setInitialTimestampRead] = useState(false) Loading Loading @@ -94,6 +136,22 @@ export const EmailCard: FC<{ ]) const definitionAddress = email.details.sender.definitionAddress const llmEnabled = llmData?.exerciseId.llm ?? false const showAssessment = !!assessmentProps && llmEnabled const showDivider = !assessmentProps || (!llmFetching && !llmEnabled) const MarkAsDoneAndNotify = () => { toggleDone() notifyNoncommmit(t('overview.todoList.notification.description'), { intent: 'success', timeout: 2000, action: { icon: 'undo', text: t('overview.todoList.notification.undo'), onClick: () => toggleDone(false), }, }) } return ( <Section Loading @@ -105,7 +163,7 @@ export const EmailCard: FC<{ icon={ teamId && email.details.sender.team?.id === teamId ? ( <Icon className={Classes.TEXT_MUTED} className={cx(Classes.TEXT_MUTED)} icon='send-message' title={t('emails.senderTeam')} /> Loading Loading @@ -222,7 +280,36 @@ export const EmailCard: FC<{ /> ))} {assessmentProps && <Assessment {...assessmentProps} />} {showAssessment && assessmentProps && ( <Assessment {...assessmentProps} /> )} {markDoneAvailable && teamId && !email.done && ( <> {showDivider && <Divider />} <div className={buttonGroupRow}> <ButtonGroup> <Button icon='changes' onClick={MarkAsDoneAndNotify}> {t('overview.todoList.markAsDone')} </Button> {overviewLink && ( <LinkButton button={{ icon: 'changes', onClick: MarkAsDoneAndNotify, text: ( <span className={buttonTextWithHelp}> {t('overview.todoList.markAsDoneAndReturn')} </span> ), minimal: true, }} link={overviewLink} /> )} </ButtonGroup> </div> </> )} </SectionCard> </Section> ) Loading
frontend/src/hooks/useToggleDone.ts 0 → 100644 +20 −0 Original line number Diff line number Diff line import type { ITodoLogActionLog } from '@inject/graphql' import { SetDone, useTypedMutation } from '@inject/graphql' type ToggleDoneLog = Pick<ITodoLogActionLog, '__typename' | 'id' | 'done'> export const useToggleDone = (actionLog?: ToggleDoneLog) => { const [, setDone] = useTypedMutation(SetDone) return { toggleDone: (nextState?: boolean) => { if (!actionLog) { return } setDone({ actionLogId: actionLog.id, state: nextState ?? !actionLog.done, typename: actionLog.__typename, }) }, } }
frontend/src/instructor/InstructorTodoLog/LogItem.tsx +6 −4 Original line number Diff line number Diff line Loading @@ -393,6 +393,7 @@ const LogItemActions: React.FC<{ actionLog: ITodoLogActionLog contextType: 'exercise' | 'team' }> = ({ actionLog, contextType }) => { const { t } = useTranslationFrontend() const threadId = actionLog.details.__typename === 'IEmailType' ? actionLog.details.thread.id Loading @@ -404,14 +405,15 @@ const LogItemActions: React.FC<{ const { toggleDone } = useToggleDone(actionLog) return ( <ButtonGroup vertical> <Button small icon='changes' onClick={toggleDone}> {actionLog.done ? 'Mark as undone' : 'Mark as done'} <Button icon='changes' onClick={() => toggleDone()}> {actionLog.done ? t('overview.todoList.markAsUndone') : t('overview.todoList.markAsDone')} </Button> <LinkButton button={{ icon: 'zoom-in', text: 'Inspect', small: true, text: t('overview.todoList.inspect'), }} link={getNavigateOptions(actionLog)} /> Loading
frontend/src/routes/_protected/instructor/$exerciseId/$teamId/email/$tab/$threadId.tsx +8 −0 Original line number Diff line number Diff line Loading @@ -9,6 +9,7 @@ import { useTranslationFrontend } from '@inject/locale' import { EmailSelection } from '@inject/shared' import { createFileRoute } from '@tanstack/react-router' import { useMemo } from 'react' import { InstructorLandingPageRoute } from '../../..' import { OPEN_REPLY_EVENT_TYPE } from '../../../../../../../email/EmailFormOverlay/events' import { EmailCard } from '../../../../../../../email/TeamEmails/EmailCard' import { ThreadHeaderCard } from '../../../../../../../email/TeamEmails/ThreadHeaderCard' Loading Loading @@ -151,6 +152,13 @@ const RouteComponent = () => { } : undefined } markDoneAvailable overviewLink={{ to: InstructorLandingPageRoute.to, params: { exerciseId, }, }} /> ))} </div> Loading