Commit 627a6142 authored by Marek Veselý's avatar Marek Veselý
Browse files

Merge branch '310-instructor-todo-list-improve-email-items' into 'main'

Add more info to Instroctor Todo emails

See merge request inject/frontend!902
parents 72acf030 27dfd80c
Loading
Loading
Loading
Loading
+257 −29
Original line number Diff line number Diff line
import { Button, ButtonGroup, Classes, Icon } from '@blueprintjs/core'
import { Button, ButtonGroup, Classes, Icon, Tag } from '@blueprintjs/core'
import { css, cx } from '@emotion/css'
import type { ITodoLogActionLog } from '@inject/graphql'
import { GetEmailThread, useTypedQuery } from '@inject/graphql'
import type { ITodoLogActionLog, MilestoneState } from '@inject/graphql'
import {
  GetEmailThread,
  SetDone,
  useTypedMutation,
  useTypedQuery,
} from '@inject/graphql'
import { useTranslationFrontend } from '@inject/locale'
import type { LinkButtonProps } from '@inject/shared'
import { EmailSelection, LinkButton, Timestamp } from '@inject/shared'
import type { NavigateOptions } from '@tanstack/react-router'
import type { FC } from 'react'
import { useToggleDone } from '../../hooks/useToggleDone'
import { InstructorLandingPageRoute } from '../../routes/_protected/instructor/$exerciseId'
import { InstructorTeamLandingPageRoute } from '../../routes/_protected/instructor/$exerciseId/$teamId'
import { InstructorFormChannelActionLogRoute } from '../../routes/_protected/instructor/$exerciseId/$teamId/$channelId/form/$actionLogId'
@@ -18,6 +22,43 @@ import { canBeReviewed, getIcon } from '../../utils'
import { QuestionnaireStatus } from '../InstructorQuestionnaire/QuestionnaireStatus'
import { OverviewPillNav } from './OverviewPillNav'

const recipientRow = css`
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 0.25rem 0.5rem;
  align-items: center;
  padding: 0.2rem 0.35rem;
  border-radius: 3px;
  background: rgba(255, 255, 255, 0.03);
`

const recipientAddress = css`
  font-weight: 600;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
`

const recipientMeta = css`
  display: inline-flex;
  gap: 0.25rem;
  flex-wrap: wrap;
  justify-content: flex-end;
`

const recipientDescription = css`
  grid-column: 1 / -1;
  font-size: 12px;
`

const topMetaRow = css`
  display: flex;
  align-items: center;
  gap: 0.3rem;
  flex-wrap: wrap;
`

const getTitle = (actionLog: ITodoLogActionLog): string => {
  switch (actionLog.details.__typename) {
    case 'IEmailType':
@@ -161,14 +202,159 @@ const useActionLogNavigate =
    }
  }

const useToggleDone = (actionLog: ITodoLogActionLog) => {
  const [, setDone] = useTypedMutation(SetDone)
  return {
    toggleDone: () =>
      setDone({
        actionLogId: actionLog.id,
        state: !actionLog.done,
        typename: actionLog.__typename,
      }),
  }
}

type EmailDetails = Extract<
  ITodoLogActionLog['details'],
  { __typename: 'IEmailType' }
>
type EmailParticipant = EmailDetails['thread']['participants'][number]

interface RecipientInfo {
  address: string
  description?: string
  templateCount: number
  pendingMilestonesCount?: number
  totalMilestonesCount?: number
}

const getRecipientInfo = ({
  recipients,
  milestoneStates,
  teamId,
}: {
  recipients: EmailParticipant[]
  milestoneStates: MilestoneState[]
  teamId: string
}): RecipientInfo[] =>
  recipients.map(participant => {
    const description = participant.definitionAddress?.description
    const templateCount = participant.definitionAddress?.templates.length ?? 0
    const possibleAffectedMilestones = new Set(
      (participant.definitionAddress?.templates ?? []).flatMap(template => [
        ...(template.control.activateMilestone ?? []),
        ...(template.control.deactivateMilestone ?? []),
      ])
    )
    const activePossibleMilestoneCount = Array.from(
      possibleAffectedMilestones
    ).filter(milestoneName =>
      milestoneStates.some(
        state =>
          state.milestone.name === milestoneName &&
          state.teamIds.includes(teamId) &&
          state.reached
      )
    ).length
    const possibleAffectedMilestonesSummary =
      possibleAffectedMilestones.size > 0
        ? {
            pendingMilestonesCount:
              possibleAffectedMilestones.size - activePossibleMilestoneCount,
            totalMilestonesCount: possibleAffectedMilestones.size,
          }
        : {}

    return {
      address: participant.address,
      description: description ?? undefined,
      templateCount,
      ...possibleAffectedMilestonesSummary,
    }
  })

const emailContent = css`
  margin-top: 0.4rem;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
`

const recipientList = css`
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.35rem;

  @media (max-width: 1100px) {
    grid-template-columns: minmax(0, 1fr);
  }
`

const EmailLogItemContent: FC<{
  actionLog: ITodoLogActionLog
  milestoneStates: MilestoneState[]
}> = ({ actionLog, milestoneStates }) => {
  const { t } = useTranslationFrontend()
  if (actionLog.details.__typename !== 'IEmailType') {
    return null
  }

  const { thread, sender } = actionLog.details
  const recipients = thread.participants.filter(
    participant => participant.address !== sender.address
  )
  const recipientInfo = getRecipientInfo({
    recipients,
    milestoneStates,
    teamId: actionLog.team.id,
  })

  return (
    <div className={emailContent}>
      <div className={recipientList}>
        {recipientInfo.map(recipient => (
          <div key={recipient.address} className={recipientRow}>
            <div className={recipientAddress}>{recipient.address}</div>
            <div className={recipientMeta}>
              <Tag minimal>
                {t(
                  recipient.templateCount === 0
                    ? 'overview.todoList.email.noTemplates'
                    : recipient.templateCount === 1
                      ? 'overview.todoList.email.template'
                      : 'overview.todoList.email.templates',
                  {
                    count: recipient.templateCount,
                  }
                )}
              </Tag>
              {recipient.totalMilestonesCount !== undefined && (
                <Tag minimal intent='primary'>{`${t(
                  recipient.totalMilestonesCount === 1
                    ? 'overview.todoList.email.milestone'
                    : 'overview.todoList.email.milestones',
                  {
                    total: recipient.totalMilestonesCount,
                  }
                )}`}</Tag>
              )}
            </div>
            {recipient.description && (
              <div className={cx(Classes.TEXT_MUTED, recipientDescription)}>
                {recipient.description}
              </div>
            )}
          </div>
        ))}
      </div>
    </div>
  )
}

const getContentInfo = (actionLog: ITodoLogActionLog): string => {
  switch (actionLog.details.__typename) {
    case 'IEmailType': {
      const { thread, sender } = actionLog.details
      const recipients = thread.participants
        .filter(participant => participant.address !== sender.address)
        .map(participant => participant.address)
      return `${recipients.length > 1 ? 'Recipients:' : 'Recipient:'} ${recipients.join(', ')}`
      return ''
    }
    case 'IConfirmationDetailsType':
    case 'IInjectDetailsType':
@@ -186,7 +372,14 @@ const getContentInfo = (actionLog: ITodoLogActionLog): string => {

const LogItemContent: FC<{
  actionLog: ITodoLogActionLog
}> = ({ actionLog }) => (
  milestoneStates: MilestoneState[]
}> = ({ actionLog, milestoneStates }) =>
  actionLog.details.__typename === 'IEmailType' ? (
    <EmailLogItemContent
      actionLog={actionLog}
      milestoneStates={milestoneStates}
    />
  ) : (
    <p
      className={css`
        white-space: pre-line;
@@ -231,9 +424,14 @@ const LogItemActions: React.FC<{
interface LogItemProps {
  actionLog: ITodoLogActionLog
  contextType: 'exercise' | 'team'
  milestoneStates?: MilestoneState[]
}

const LogItem: FC<LogItemProps> = ({ actionLog, contextType }) => {
const LogItem: FC<LogItemProps> = ({
  actionLog,
  contextType,
  milestoneStates,
}) => {
  const questionnaireStatus = (() => {
    switch (actionLog.details.__typename) {
      case 'IQuestionnaireSubmissionType':
@@ -253,32 +451,62 @@ const LogItem: FC<LogItemProps> = ({ actionLog, contextType }) => {
        css`
          display: flex;
          justify-content: space-between;
          align-items: flex-start;
          gap: 0.65rem;
          width: 100%;
        `
      )}
    >
      <Icon icon={getIcon(actionLog.logType)} />
      <div style={{ flexGrow: 0 }}>
        <h5 className={Classes.HEADING} style={{ width: 'max-content' }}>
      <div
        className={css`
          flex: 1;
          min-width: 0;
        `}
      >
        <h5
          className={cx(
            Classes.HEADING,
            css`
              margin-bottom: 0.15rem;
              overflow: hidden;
              text-overflow: ellipsis;
              white-space: nowrap;
            `
          )}
        >
          {getTitle(actionLog)}
        </h5>
        <div className={topMetaRow}>
          <OverviewPillNav actionLog={actionLog} />
          <Timestamp
            formatTimestampProps={{
              timestamp: actionLog.timestamp,
              inExerciseTime: actionLog.inExerciseTime,
            }}
          />
        </div>
        {questionnaireStatus && (
          <QuestionnaireStatus
            teamStateStatus={questionnaireStatus}
            canBeReviewed={canBeReviewed(actionLog.details)}
          />
        )}
        <Timestamp
          formatTimestampProps={{
            timestamp: actionLog.timestamp,
            inExerciseTime: actionLog.inExerciseTime,
          }}
        <LogItemContent
          actionLog={actionLog}
          milestoneStates={milestoneStates ?? []}
        />
        <LogItemContent actionLog={actionLog} />
      </div>

      <div style={{ width: 'max-content' }}>
      <div
        className={css`
          display: flex;
          align-self: stretch;
          align-items: center;
          justify-content: center;
          flex-shrink: 0;
        `}
      >
        <LogItemActions actionLog={actionLog} contextType={contextType} />
      </div>
    </div>
+12 −8
Original line number Diff line number Diff line
import { Tag } from '@blueprintjs/core'
import { css } from '@emotion/css'
import { css, cx } from '@emotion/css'
import type { ITodoLogActionLog } from '@inject/graphql'
import { getColorScheme } from '@inject/shared'
import { useNavigate } from '@tanstack/react-router'
@@ -8,16 +8,20 @@ import { InstructorTeamLandingPageRoute } from '../../routes/_protected/instruct

export const OverviewPillNav: FC<{
  actionLog: ITodoLogActionLog
}> = ({ actionLog }) => {
  className?: string
}> = ({ actionLog, className }) => {
  const nav = useNavigate()
  return (
    <Tag
      className={css`
      className={cx(
        css`
          margin-right: 0.25rem;
          &:hover {
            cursor: pointer;
          }
      `}
        `,
        className
      )}
      title='Click to enter team overview'
      onClick={() => {
        nav({
+1 −0
Original line number Diff line number Diff line
@@ -68,6 +68,7 @@ export const TodoTabs: FC<TodoTabsProps> = ({ teamIds }) => {
        }
        contextType='team'
        done={value === 'done'}
        teamIds={teamIds}
      />
    </div>
  )
+14 −2
Original line number Diff line number Diff line
import { NonIdealState } from '@blueprintjs/core'
import { css } from '@emotion/css'
import type { ITodoLogActionLog } from '@inject/graphql'
import {
  TeamMilestonesQuery,
  useTypedQuery,
  type ITodoLogActionLog,
} from '@inject/graphql'
import { useTranslationFrontend } from '@inject/locale'
import type { FC } from 'react'
import LogItem from './LogItem'
@@ -18,11 +22,18 @@ export const InstructorTodoLog: FC<{
  done?: boolean
  contextType: 'exercise' | 'team'
  actionLogs: ITodoLogActionLog[]
}> = ({ actionLogs, contextType, done }) => {
  teamIds: string[]
}> = ({ actionLogs, contextType, done, teamIds }) => {
  const filteredActionLogs = actionLogs.filter(actionLog =>
    done ? actionLog.done : !actionLog.done
  )

  const [{ data: states }] = useTypedQuery({
    query: TeamMilestonesQuery,
    variables: { teamIds },
    pause: !teamIds.length,
  })

  const { t } = useTranslationFrontend()

  if (!filteredActionLogs.length) {
@@ -42,6 +53,7 @@ export const InstructorTodoLog: FC<{
          key={actionLog.id}
          actionLog={actionLog}
          contextType={contextType}
          milestoneStates={states?.teamMilestones}
        />
      ))}
    </div>
+11 −1
Original line number Diff line number Diff line
@@ -1200,6 +1200,16 @@ export const ITodoLogActionLog = graphql(
            participants {
              id
              address
              definitionAddress {
                id
                description
                templates {
                  id
                  control {
                    ...Control
                  }
                }
              }
            }
          }
          sender {
@@ -1256,7 +1266,7 @@ export const ITodoLogActionLog = graphql(
      }
    }
  `,
  []
  [Control]
)
// just a wrapper around ITodoLogActionLog
export const TodoLogActionLog = graphql(
Loading