Loading frontend/src/components/OverlayEngine/ActionLogOverlay.tsx 0 → 100644 +118 −0 Original line number Diff line number Diff line import { Dialog, DialogBody, DialogFooter } from '@blueprintjs/core' import { css, cx } from '@emotion/css' import type { ActionLog, Overlay } from '@inject/graphql' import { getActionLogOverlay, SetClosed, useTypedMutation, } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' import { dialogBody, variableHeightMaximizedDialog } from '@inject/shared' import type { FC } from 'react' import { Content } from '../../actionlog/InjectMessage/Content' import { TraineeFilePageRoute } from '../../routes/_protected/trainee/$exerciseId/$teamId/file.$fileId' import { getIcon, getTitle } from '../../utils' import { Countdown } from './Countdown' const footerWrapper = css` display: flex; align-items: center; justify-content: center; ` const getSecondsLeft = (actionLog: ActionLog, overlay: Overlay): number => { const now = Date.now() const logTime = new Date(actionLog.timestamp).getTime() const elapsedMs = now - logTime const durationMs = overlay.duration * 60 * 1000 const secondsLeft = Math.max(0, Math.floor((durationMs - elapsedMs) / 1000)) return secondsLeft } interface ActionLogOverlayProps { actionLog: ActionLog } export const ActionLogOverlay: FC<ActionLogOverlayProps> = ({ actionLog }) => { const { t } = useTranslationFrontend() const [, setClosed] = useTypedMutation(SetClosed) const overlay = getActionLogOverlay(actionLog) if (!overlay) { return null } return ( <Dialog className={variableHeightMaximizedDialog} isOpen canEscapeKeyClose={false} canOutsideClickClose={false} isCloseButtonShown={false} title={getTitle(actionLog.details)} icon={getIcon(actionLog.logType)} onClose={() => {}} > <DialogBody className={dialogBody}> <Content actionLog={actionLog} exerciseId={actionLog.team.exercise.id} teamId={actionLog.team.id} inInstructor={false} getFileLink={fileId => ({ to: TraineeFilePageRoute.to, params: { exerciseId: actionLog.team.exercise.id, teamId: actionLog.team.id, fileId, }, })} onClose={() => setClosed({ actionLogId: actionLog.id, state: true, typename: actionLog.__typename, }) } inPopup /> </DialogBody> <DialogFooter> <div> {overlay.duration !== 0 && ( <Countdown secondsLeft={getSecondsLeft(actionLog, overlay)} onExpire={() => setClosed({ actionLogId: actionLog.id, state: true, typename: actionLog.__typename, }) } /> )} <span className={cx( css` padding-top: ${overlay.duration !== 0 ? '0.5rem' : 0}; `, footerWrapper )} > {t('overlay.access')} <b className={css` margin-left: 0.25rem; `} > {actionLog.channel.displayName} </b> . </span> </div> </DialogFooter> </Dialog> ) } frontend/src/components/OverlayEngine/Countdown.tsx 0 → 100644 +115 −0 Original line number Diff line number Diff line import { css } from '@emotion/css' import { useTranslationFrontend } from '@inject/locale' import type { FC } from 'react' import { useEffect, useRef, useState } from 'react' const countdownWrapper = css` width: 100%; display: flex; flex-direction: column; align-items: center; text-transform: uppercase; font-weight: 600; ` const countdownTitle = css` font-size: 0.75rem; letter-spacing: 0.12em; ` const countdownDisplay = css` display: flex; align-items: center; gap: 0.4rem; margin-top: 0.35rem; ` const countdownBlock = css` display: flex; flex-direction: column; align-items: center; min-width: 3rem; ` const countdownValue = css` font-size: 2rem; line-height: 1; ` const countdownLabel = css` margin-top: 0.25rem; font-size: 0.5rem; letter-spacing: 0.2em; ` const countdownColon = css` font-size: 1.5rem; align-self: flex-start; ` interface CountdownProps { secondsLeft: number onExpire: () => void } export const Countdown: FC<CountdownProps> = ({ secondsLeft, onExpire }) => { const { t } = useTranslationFrontend() const [timeLeft, setTimeLeft] = useState(secondsLeft) const hasClosedRef = useRef(false) useEffect(() => { if (secondsLeft === 0) { return } hasClosedRef.current = false setTimeLeft(secondsLeft) const interval = setInterval(() => { setTimeLeft(prev => { const next = prev - 1 if (next < 0 && !hasClosedRef.current) { hasClosedRef.current = true onExpire() } return Math.max(next, 0) }) }, 1000) return () => { clearInterval(interval) } }, [onExpire, secondsLeft]) if (secondsLeft === 0) { return null } const [hours, minutes, seconds] = new Date(timeLeft * 1000) .toISOString() .substring(11, 19) .split(':') return ( <div className={countdownWrapper}> <span className={countdownTitle}>{t('overlay.remainingTime')}</span> <div className={countdownDisplay}> <div className={countdownBlock}> <span className={countdownValue}>{hours}</span> <span className={countdownLabel}>{t('overlay.hoursLabel')}</span> </div> <span className={countdownColon}>:</span> <div className={countdownBlock}> <span className={countdownValue}>{minutes}</span> <span className={countdownLabel}>{t('overlay.minutesLabel')}</span> </div> <span className={countdownColon}>:</span> <div className={countdownBlock}> <span className={countdownValue}>{seconds}</span> <span className={countdownLabel}>{t('overlay.secondsLabel')}</span> </div> </div> </div> ) } frontend/src/components/OverlayEngine/index.tsx 0 → 100644 +90 −0 Original line number Diff line number Diff line import type { ActionLog } from '@inject/graphql' import { getActionLogOverlay, OverlayActionLogsQuery, useTypedQuery, } from '@inject/graphql' import { type FC } from 'react' import { ActionLogOverlay } from './ActionLogOverlay' const getActionLogDone = (actionLog: ActionLog): boolean => { switch (actionLog.details.__typename) { // if inject includes confirmation button and it is confirmed, consider it done case 'TInjectDetailsType': case 'IInjectDetailsType': { if (!actionLog.details.confirmation) { return false } const confirmationActionLog = actionLog.nextLogs.find( actionLog => actionLog?.logType === 'CONFIRMATION' ) return !!confirmationActionLog } // if questionnaire is submitted, consider it done case 'TTeamQuestionnaireStateType': case 'ITeamQuestionnaireStateType': switch (actionLog.details.status) { case 'ANSWERED': case 'REVIEWED': return true default: // UNSENT, DELAYED, SENT return false } default: return false } } const overlayActionLogsFilter = (actionLog: ActionLog) => { const overlay = getActionLogOverlay(actionLog) if (!overlay) { return false } if (actionLog.closed) { return false } if (getActionLogDone(actionLog)) { return false } const { duration } = overlay if (duration === 0) { // no duration return true } const now = Date.now() const logTime = new Date(actionLog.timestamp).getTime() if (now - logTime > duration * 60 * 1000) { // duration expired return false } return true } interface OverlayEngineProps { teamId: string exerciseId: string } export const OverlayEngine: FC<OverlayEngineProps> = ({ teamId, exerciseId, }) => { const [{ data }] = useTypedQuery({ query: OverlayActionLogsQuery, variables: { teamIds: [teamId], exerciseId, }, }) // latest, because newestFirst is not set in the query; // this make the OverlayEngine act as a queue of overlays // (first overlay must be closed to see the next one) const latestOverlayActionLog = data?.teamActionLogs.find( overlayActionLogsFilter ) return latestOverlayActionLog ? ( <ActionLogOverlay actionLog={latestOverlayActionLog} /> ) : undefined } frontend/src/routes/_protected/trainee/$exerciseId/$teamId/route.tsx +2 −69 Original line number Diff line number Diff line import type { ActionLog, Overlay } from '@inject/graphql' import { useActionLogSubscriptionTrainee } from '@inject/graphql' import { usePushPopup } from '@inject/shared' import { createFileRoute, Outlet } from '@tanstack/react-router' import { useEventListener } from 'ahooks' import { useCallback } from 'react' import { OnDemandStartAlert } from '../../../../../components/OnDemandStartAlert' import { getIcon, getTitle } from '../../../../../utils' import { TraineeFilePageRoute } from './file.$fileId' import { OverlayEngine } from '../../../../../components/OverlayEngine' const RouteComponent = () => { const { exerciseId, teamId } = TraineeTeamLayoutRoute.useParams() const pushPopup = usePushPopup() useActionLogSubscriptionTrainee({ exerciseId, teamId, }) const handleOverlay = useCallback( (actionLog: ActionLog, overlay: Overlay | null) => { if (!overlay) { return } pushPopup(`ActionLogId:${actionLog.id}`, { duration: overlay.duration, title: getTitle(actionLog.details), icon: getIcon(actionLog.logType), type: 'ActionLogPopup', // TODO: proper typing; this needs to be consistent with ContentProps marchedData: { actionLog: actionLog, exerciseId: actionLog.team.exercise.id, teamId: actionLog.team.id, inInstructor: false, getFileLinkString: { to: TraineeFilePageRoute.to, params: { exerciseId: actionLog.team.exercise.id, }, }, }, }) }, [pushPopup] ) useEventListener('actionLogEvent', event => { const { actionLog } = event.detail switch (actionLog.details.__typename) { case 'TInjectDetailsType': case 'IInjectDetailsType': case 'TCustomInjectDetailsType': case 'ICustomInjectDetailsType': case 'TEmailType': case 'IEmailType': handleOverlay(actionLog, actionLog.details.overlay) break case 'TTeamQuestionnaireStateType': case 'ITeamQuestionnaireStateType': handleOverlay(actionLog, actionLog.details.questionnaire.overlay) break case 'TConfirmationDetailsType': case 'IConfirmationDetailsType': case 'TQuestionnaireReviewDetailsType': case 'IQuestionnaireReviewDetailsType': case 'TQuestionnaireSubmissionType': case 'IQuestionnaireSubmissionType': case 'TToolDetailsType': case 'IToolDetailsType': case 'TFileDownloadDetailsType': case 'IFileDownloadDetailsType': case 'TMilestoneModificationDetailsType': case 'IMilestoneModificationDetailsType': case 'TSandboxLogDetailsType': case 'ISandboxLogDetailsType': break } }) return ( <> <OnDemandStartAlert exerciseId={exerciseId} teamId={teamId} /> <OverlayEngine teamId={teamId} exerciseId={exerciseId} /> <Outlet /> </> ) Loading frontend/src/routes/_protected/trainee/$exerciseId/route.tsx +2 −15 Original line number Diff line number Diff line import { CenteredSpinner, Keys, PopupEngine } from '@inject/shared' import { CenteredSpinner } from '@inject/shared' import { createFileRoute, Outlet, useParams } from '@tanstack/react-router' import { Content } from '../../../../actionlog/InjectMessage/Content' import { TraineeView } from '../../../../views/TraineeView' export const Pending = () => <CenteredSpinner /> Loading @@ -14,19 +13,7 @@ const RouteComponent = () => { return ( <TraineeView exerciseId={exerciseId} teamId={teamId}> {/* TODO: move PopupEngine to TraineeTeamLayoutRoute */} <PopupEngine syncKey={Keys.getPopupQueue(teamId || '')} renderClasses={{ ActionLogPopup: onClose => function ActionLogPopup(params) { //@ts-ignore return <Content {...params} onClose={onClose} inPopup /> }, }} > <Outlet /> </PopupEngine> </TraineeView> ) } Loading Loading
frontend/src/components/OverlayEngine/ActionLogOverlay.tsx 0 → 100644 +118 −0 Original line number Diff line number Diff line import { Dialog, DialogBody, DialogFooter } from '@blueprintjs/core' import { css, cx } from '@emotion/css' import type { ActionLog, Overlay } from '@inject/graphql' import { getActionLogOverlay, SetClosed, useTypedMutation, } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' import { dialogBody, variableHeightMaximizedDialog } from '@inject/shared' import type { FC } from 'react' import { Content } from '../../actionlog/InjectMessage/Content' import { TraineeFilePageRoute } from '../../routes/_protected/trainee/$exerciseId/$teamId/file.$fileId' import { getIcon, getTitle } from '../../utils' import { Countdown } from './Countdown' const footerWrapper = css` display: flex; align-items: center; justify-content: center; ` const getSecondsLeft = (actionLog: ActionLog, overlay: Overlay): number => { const now = Date.now() const logTime = new Date(actionLog.timestamp).getTime() const elapsedMs = now - logTime const durationMs = overlay.duration * 60 * 1000 const secondsLeft = Math.max(0, Math.floor((durationMs - elapsedMs) / 1000)) return secondsLeft } interface ActionLogOverlayProps { actionLog: ActionLog } export const ActionLogOverlay: FC<ActionLogOverlayProps> = ({ actionLog }) => { const { t } = useTranslationFrontend() const [, setClosed] = useTypedMutation(SetClosed) const overlay = getActionLogOverlay(actionLog) if (!overlay) { return null } return ( <Dialog className={variableHeightMaximizedDialog} isOpen canEscapeKeyClose={false} canOutsideClickClose={false} isCloseButtonShown={false} title={getTitle(actionLog.details)} icon={getIcon(actionLog.logType)} onClose={() => {}} > <DialogBody className={dialogBody}> <Content actionLog={actionLog} exerciseId={actionLog.team.exercise.id} teamId={actionLog.team.id} inInstructor={false} getFileLink={fileId => ({ to: TraineeFilePageRoute.to, params: { exerciseId: actionLog.team.exercise.id, teamId: actionLog.team.id, fileId, }, })} onClose={() => setClosed({ actionLogId: actionLog.id, state: true, typename: actionLog.__typename, }) } inPopup /> </DialogBody> <DialogFooter> <div> {overlay.duration !== 0 && ( <Countdown secondsLeft={getSecondsLeft(actionLog, overlay)} onExpire={() => setClosed({ actionLogId: actionLog.id, state: true, typename: actionLog.__typename, }) } /> )} <span className={cx( css` padding-top: ${overlay.duration !== 0 ? '0.5rem' : 0}; `, footerWrapper )} > {t('overlay.access')} <b className={css` margin-left: 0.25rem; `} > {actionLog.channel.displayName} </b> . </span> </div> </DialogFooter> </Dialog> ) }
frontend/src/components/OverlayEngine/Countdown.tsx 0 → 100644 +115 −0 Original line number Diff line number Diff line import { css } from '@emotion/css' import { useTranslationFrontend } from '@inject/locale' import type { FC } from 'react' import { useEffect, useRef, useState } from 'react' const countdownWrapper = css` width: 100%; display: flex; flex-direction: column; align-items: center; text-transform: uppercase; font-weight: 600; ` const countdownTitle = css` font-size: 0.75rem; letter-spacing: 0.12em; ` const countdownDisplay = css` display: flex; align-items: center; gap: 0.4rem; margin-top: 0.35rem; ` const countdownBlock = css` display: flex; flex-direction: column; align-items: center; min-width: 3rem; ` const countdownValue = css` font-size: 2rem; line-height: 1; ` const countdownLabel = css` margin-top: 0.25rem; font-size: 0.5rem; letter-spacing: 0.2em; ` const countdownColon = css` font-size: 1.5rem; align-self: flex-start; ` interface CountdownProps { secondsLeft: number onExpire: () => void } export const Countdown: FC<CountdownProps> = ({ secondsLeft, onExpire }) => { const { t } = useTranslationFrontend() const [timeLeft, setTimeLeft] = useState(secondsLeft) const hasClosedRef = useRef(false) useEffect(() => { if (secondsLeft === 0) { return } hasClosedRef.current = false setTimeLeft(secondsLeft) const interval = setInterval(() => { setTimeLeft(prev => { const next = prev - 1 if (next < 0 && !hasClosedRef.current) { hasClosedRef.current = true onExpire() } return Math.max(next, 0) }) }, 1000) return () => { clearInterval(interval) } }, [onExpire, secondsLeft]) if (secondsLeft === 0) { return null } const [hours, minutes, seconds] = new Date(timeLeft * 1000) .toISOString() .substring(11, 19) .split(':') return ( <div className={countdownWrapper}> <span className={countdownTitle}>{t('overlay.remainingTime')}</span> <div className={countdownDisplay}> <div className={countdownBlock}> <span className={countdownValue}>{hours}</span> <span className={countdownLabel}>{t('overlay.hoursLabel')}</span> </div> <span className={countdownColon}>:</span> <div className={countdownBlock}> <span className={countdownValue}>{minutes}</span> <span className={countdownLabel}>{t('overlay.minutesLabel')}</span> </div> <span className={countdownColon}>:</span> <div className={countdownBlock}> <span className={countdownValue}>{seconds}</span> <span className={countdownLabel}>{t('overlay.secondsLabel')}</span> </div> </div> </div> ) }
frontend/src/components/OverlayEngine/index.tsx 0 → 100644 +90 −0 Original line number Diff line number Diff line import type { ActionLog } from '@inject/graphql' import { getActionLogOverlay, OverlayActionLogsQuery, useTypedQuery, } from '@inject/graphql' import { type FC } from 'react' import { ActionLogOverlay } from './ActionLogOverlay' const getActionLogDone = (actionLog: ActionLog): boolean => { switch (actionLog.details.__typename) { // if inject includes confirmation button and it is confirmed, consider it done case 'TInjectDetailsType': case 'IInjectDetailsType': { if (!actionLog.details.confirmation) { return false } const confirmationActionLog = actionLog.nextLogs.find( actionLog => actionLog?.logType === 'CONFIRMATION' ) return !!confirmationActionLog } // if questionnaire is submitted, consider it done case 'TTeamQuestionnaireStateType': case 'ITeamQuestionnaireStateType': switch (actionLog.details.status) { case 'ANSWERED': case 'REVIEWED': return true default: // UNSENT, DELAYED, SENT return false } default: return false } } const overlayActionLogsFilter = (actionLog: ActionLog) => { const overlay = getActionLogOverlay(actionLog) if (!overlay) { return false } if (actionLog.closed) { return false } if (getActionLogDone(actionLog)) { return false } const { duration } = overlay if (duration === 0) { // no duration return true } const now = Date.now() const logTime = new Date(actionLog.timestamp).getTime() if (now - logTime > duration * 60 * 1000) { // duration expired return false } return true } interface OverlayEngineProps { teamId: string exerciseId: string } export const OverlayEngine: FC<OverlayEngineProps> = ({ teamId, exerciseId, }) => { const [{ data }] = useTypedQuery({ query: OverlayActionLogsQuery, variables: { teamIds: [teamId], exerciseId, }, }) // latest, because newestFirst is not set in the query; // this make the OverlayEngine act as a queue of overlays // (first overlay must be closed to see the next one) const latestOverlayActionLog = data?.teamActionLogs.find( overlayActionLogsFilter ) return latestOverlayActionLog ? ( <ActionLogOverlay actionLog={latestOverlayActionLog} /> ) : undefined }
frontend/src/routes/_protected/trainee/$exerciseId/$teamId/route.tsx +2 −69 Original line number Diff line number Diff line import type { ActionLog, Overlay } from '@inject/graphql' import { useActionLogSubscriptionTrainee } from '@inject/graphql' import { usePushPopup } from '@inject/shared' import { createFileRoute, Outlet } from '@tanstack/react-router' import { useEventListener } from 'ahooks' import { useCallback } from 'react' import { OnDemandStartAlert } from '../../../../../components/OnDemandStartAlert' import { getIcon, getTitle } from '../../../../../utils' import { TraineeFilePageRoute } from './file.$fileId' import { OverlayEngine } from '../../../../../components/OverlayEngine' const RouteComponent = () => { const { exerciseId, teamId } = TraineeTeamLayoutRoute.useParams() const pushPopup = usePushPopup() useActionLogSubscriptionTrainee({ exerciseId, teamId, }) const handleOverlay = useCallback( (actionLog: ActionLog, overlay: Overlay | null) => { if (!overlay) { return } pushPopup(`ActionLogId:${actionLog.id}`, { duration: overlay.duration, title: getTitle(actionLog.details), icon: getIcon(actionLog.logType), type: 'ActionLogPopup', // TODO: proper typing; this needs to be consistent with ContentProps marchedData: { actionLog: actionLog, exerciseId: actionLog.team.exercise.id, teamId: actionLog.team.id, inInstructor: false, getFileLinkString: { to: TraineeFilePageRoute.to, params: { exerciseId: actionLog.team.exercise.id, }, }, }, }) }, [pushPopup] ) useEventListener('actionLogEvent', event => { const { actionLog } = event.detail switch (actionLog.details.__typename) { case 'TInjectDetailsType': case 'IInjectDetailsType': case 'TCustomInjectDetailsType': case 'ICustomInjectDetailsType': case 'TEmailType': case 'IEmailType': handleOverlay(actionLog, actionLog.details.overlay) break case 'TTeamQuestionnaireStateType': case 'ITeamQuestionnaireStateType': handleOverlay(actionLog, actionLog.details.questionnaire.overlay) break case 'TConfirmationDetailsType': case 'IConfirmationDetailsType': case 'TQuestionnaireReviewDetailsType': case 'IQuestionnaireReviewDetailsType': case 'TQuestionnaireSubmissionType': case 'IQuestionnaireSubmissionType': case 'TToolDetailsType': case 'IToolDetailsType': case 'TFileDownloadDetailsType': case 'IFileDownloadDetailsType': case 'TMilestoneModificationDetailsType': case 'IMilestoneModificationDetailsType': case 'TSandboxLogDetailsType': case 'ISandboxLogDetailsType': break } }) return ( <> <OnDemandStartAlert exerciseId={exerciseId} teamId={teamId} /> <OverlayEngine teamId={teamId} exerciseId={exerciseId} /> <Outlet /> </> ) Loading
frontend/src/routes/_protected/trainee/$exerciseId/route.tsx +2 −15 Original line number Diff line number Diff line import { CenteredSpinner, Keys, PopupEngine } from '@inject/shared' import { CenteredSpinner } from '@inject/shared' import { createFileRoute, Outlet, useParams } from '@tanstack/react-router' import { Content } from '../../../../actionlog/InjectMessage/Content' import { TraineeView } from '../../../../views/TraineeView' export const Pending = () => <CenteredSpinner /> Loading @@ -14,19 +13,7 @@ const RouteComponent = () => { return ( <TraineeView exerciseId={exerciseId} teamId={teamId}> {/* TODO: move PopupEngine to TraineeTeamLayoutRoute */} <PopupEngine syncKey={Keys.getPopupQueue(teamId || '')} renderClasses={{ ActionLogPopup: onClose => function ActionLogPopup(params) { //@ts-ignore return <Content {...params} onClose={onClose} inPopup /> }, }} > <Outlet /> </PopupEngine> </TraineeView> ) } Loading