Loading frontend/src/instructor/InstructorTodoLog/LogItem.tsx +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' Loading @@ -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': Loading Loading @@ -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': Loading @@ -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; Loading Loading @@ -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': Loading @@ -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> Loading frontend/src/instructor/InstructorTodoLog/OverviewPillNav.tsx +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' Loading @@ -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({ Loading frontend/src/instructor/InstructorTodoLog/TodoTabs.tsx +1 −0 Original line number Diff line number Diff line Loading @@ -68,6 +68,7 @@ export const TodoTabs: FC<TodoTabsProps> = ({ teamIds }) => { } contextType='team' done={value === 'done'} teamIds={teamIds} /> </div> ) Loading frontend/src/instructor/InstructorTodoLog/index.tsx +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' Loading @@ -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) { Loading @@ -42,6 +53,7 @@ export const InstructorTodoLog: FC<{ key={actionLog.id} actionLog={actionLog} contextType={contextType} milestoneStates={states?.teamMilestones} /> ))} </div> Loading graphql/fragments.ts +11 −1 Original line number Diff line number Diff line Loading @@ -1200,6 +1200,16 @@ export const ITodoLogActionLog = graphql( participants { id address definitionAddress { id description templates { id control { ...Control } } } } } sender { Loading Loading @@ -1256,7 +1266,7 @@ export const ITodoLogActionLog = graphql( } } `, [] [Control] ) // just a wrapper around ITodoLogActionLog export const TodoLogActionLog = graphql( Loading Loading
frontend/src/instructor/InstructorTodoLog/LogItem.tsx +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' Loading @@ -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': Loading Loading @@ -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': Loading @@ -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; Loading Loading @@ -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': Loading @@ -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> Loading
frontend/src/instructor/InstructorTodoLog/OverviewPillNav.tsx +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' Loading @@ -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({ Loading
frontend/src/instructor/InstructorTodoLog/TodoTabs.tsx +1 −0 Original line number Diff line number Diff line Loading @@ -68,6 +68,7 @@ export const TodoTabs: FC<TodoTabsProps> = ({ teamIds }) => { } contextType='team' done={value === 'done'} teamIds={teamIds} /> </div> ) Loading
frontend/src/instructor/InstructorTodoLog/index.tsx +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' Loading @@ -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) { Loading @@ -42,6 +53,7 @@ export const InstructorTodoLog: FC<{ key={actionLog.id} actionLog={actionLog} contextType={contextType} milestoneStates={states?.teamMilestones} /> ))} </div> Loading
graphql/fragments.ts +11 −1 Original line number Diff line number Diff line Loading @@ -1200,6 +1200,16 @@ export const ITodoLogActionLog = graphql( participants { id address definitionAddress { id description templates { id control { ...Control } } } } } sender { Loading Loading @@ -1256,7 +1266,7 @@ export const ITodoLogActionLog = graphql( } } `, [] [Control] ) // just a wrapper around ITodoLogActionLog export const TodoLogActionLog = graphql( Loading