Commit 5cd5bed5 authored by Marek Veselý's avatar Marek Veselý
Browse files

Merge branch '1027-rework-overlays' into 'main'

feat: implement client-side stateful overlays

Closes #1027 and #1026

See merge request inject/frontend!875
parents 241f04ad 7edb8b7d
Loading
Loading
Loading
Loading
+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>
  )
}
+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>
  )
}
+115 −0
Original line number Diff line number Diff line
import type { ActionLog, TeamQuestionnaireState } from '@inject/graphql'
import {
  getActionLogOverlay,
  OverlayActionLogsQuery,
  useTypedQuery,
} from '@inject/graphql'
import { type FC } from 'react'
import { ActionLogOverlay } from './ActionLogOverlay'

const getQuestionnaireStatusDone = (
  status: TeamQuestionnaireState['status']
): boolean => {
  switch (status) {
    case 'ANSWERED':
    case 'REVIEWED':
      return true
    case 'DELAYED':
    case 'SENT':
    case 'UNSENT':
      return false
  }
}

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':
      return getQuestionnaireStatusDone(actionLog.details.status)
    case 'TEmailType':
    case 'IEmailType':
    case 'TToolDetailsType':
    case 'IToolDetailsType':
    case 'TQuestionnaireReviewDetailsType':
    case 'IQuestionnaireReviewDetailsType':
    case 'TQuestionnaireSubmissionType':
    case 'IQuestionnaireSubmissionType':
    case 'TFileDownloadDetailsType':
    case 'IFileDownloadDetailsType':
    case 'TConfirmationDetailsType':
    case 'IConfirmationDetailsType':
    case 'TCustomInjectDetailsType':
    case 'ICustomInjectDetailsType':
    case 'TMilestoneModificationDetailsType':
    case 'IMilestoneModificationDetailsType':
    case 'TSandboxLogDetailsType':
    case 'ISandboxLogDetailsType':
      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
}
+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 />
    </>
  )
+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 />
@@ -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