Commit aee4e6da authored by Patrik Kotucek's avatar Patrik Kotucek
Browse files

feat: adding auto highlighting

parent 596670f1
Loading
Loading
Loading
Loading
+24 −6
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@ import { css, cx } from '@emotion/css'
import type { Milestone } from '@inject/graphql'
import { useTranslationFrontend } from '@inject/locale'
import { breakWord } from '@inject/shared'
import type { FC, ReactNode } from 'react'
import { useMemo, type FC, type ReactNode } from 'react'
import {
  HIGHLIGHT_MILESTONE_EVENT,
  type HighlightInstructorMilestoneActivityEvent,
@@ -69,6 +69,23 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({
}) => {
  const { t } = useTranslationFrontend()
  const isSmallScreen = useMediaQuery('(max-width: 60rem)')
  const milestoneActivities = useMemo(
    () =>
      milestones.reduce<NonNullable<Milestone['activity']>[]>(
        (acc, milestone) => {
          if (
            milestone.activity &&
            !acc.some(activity => activity.id === milestone.activity?.id)
          ) {
            acc.push(milestone.activity)
          }

          return acc
        },
        []
      ),
    [milestones]
  )
  const replyText = (
    <span className={replyButtonLabel}>{t('emails.reply')}</span>
  )
@@ -132,11 +149,11 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({
          )}
        </ButtonGroup>
      </div>
      {milestones.length > 0 && (
      {milestoneActivities.length > 0 && (
        <div className={tagsRow}>
          {milestones.map(milestone => (
          {milestoneActivities.map(activity => (
            <Tag
              key={`${milestone.activity?.id}:${milestone.activity?.name}`}
              key={activity.id}
              minimal
              interactive
              intent='primary'
@@ -144,13 +161,14 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({
                window.dispatchEvent(
                  new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, {
                    detail: {
                      activityId: milestone.activity?.id ?? '0',
                      activityIds: milestoneActivities.map(item => item.id),
                      scrollToActivityId: activity.id,
                    } satisfies HighlightInstructorMilestoneActivityEvent,
                  })
                )
              }
            >
              {milestone.activity?.name}
              {activity.name}
            </Tag>
          ))}
        </div>
+2 −1
Original line number Diff line number Diff line
export const HIGHLIGHT_MILESTONE_EVENT = 'highlightInstructorMilestoneActivity'

export interface HighlightInstructorMilestoneActivityEvent {
  activityId: string
  activityIds: string[]
  scrollToActivityId?: string | null
}

declare global {
+25 −40
Original line number Diff line number Diff line
@@ -61,9 +61,16 @@ interface InstructorMilestonesProps {
const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => {
  const [isOpen, setIsOpen] = useState(true)
  const [isUncategorizedOpen, setIsUncategorizedOpen] = useState(false)
  const [highlightedActivityId, setHighlightedActivityId] = useState<
    string | null
  >(null)
  const [highlightedActivityIds, setHighlightedActivityIds] = useState<
    string[]
  >([])
  const [scrollRequest, setScrollRequest] = useState<{
    activityId: string | null
    requestId: number
  }>({
    activityId: null,
    requestId: 0,
  })
  const [{ data: learningObjectivesData }] = useTypedQuery({
    query: TeamLearningObjectivesQuery,
    variables: { teamIds: teamIds },
@@ -128,8 +135,19 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => {
    const handleHighlight = (
      event: WindowEventMap[typeof HIGHLIGHT_MILESTONE_EVENT]
    ) => {
      const activityIds = [...new Set(event.detail.activityIds.filter(Boolean))]
      const scrollToActivityId = event.detail.scrollToActivityId ?? null

      if (scrollToActivityId) {
        setIsOpen(true)
      setHighlightedActivityId(event.detail.activityId)
      }
      setHighlightedActivityIds(activityIds)
      if (scrollToActivityId) {
        setScrollRequest(current => ({
          activityId: scrollToActivityId,
          requestId: current.requestId + 1,
        }))
      }
    }

    window.addEventListener(HIGHLIGHT_MILESTONE_EVENT, handleHighlight)
@@ -138,37 +156,6 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => {
      window.removeEventListener(HIGHLIGHT_MILESTONE_EVENT, handleHighlight)
  }, [])

  useEffect(() => {
    if (!highlightedActivityId || !isOpen) {
      return
    }

    requestAnimationFrame(() => {
      document
        .querySelector<HTMLElement>(
          `[data-activity-id="${highlightedActivityId}"]`
        )
        ?.scrollIntoView({
          block: 'nearest',
          behavior: 'smooth',
        })
    })
  }, [highlightedActivityId, isOpen])

  useEffect(() => {
    if (!highlightedActivityId) {
      return
    }

    const timeout = window.setTimeout(() => {
      setHighlightedActivityId(current =>
        current === highlightedActivityId ? null : current
      )
    }, 10000)

    return () => window.clearTimeout(timeout)
  }, [highlightedActivityId])

  return (
    <div
      className={cx(container, {
@@ -209,10 +196,8 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => {
              teamId={teamIds.length === 1 ? teamIds[0] : undefined}
              teamIds={teamIds}
              states={data.milestoneStates}
              highlighted={objective.activities.some(
                activity => activity.activity.id === highlightedActivityId
              )}
              highlightedActivityId={highlightedActivityId}
              highlightedActivityIds={highlightedActivityIds}
              scrollRequest={scrollRequest}
            />
          ))}
          {uncategorizedMilestones.length > 0 && (
+113 −54
Original line number Diff line number Diff line
import { Section, SectionCard } from '@blueprintjs/core'
import { Colors, Section, SectionCard } from '@blueprintjs/core'
import { css, cx } from '@emotion/css'
import type { MilestoneState, TeamLearningObjective } from '@inject/graphql'
import { useTranslationFrontend } from '@inject/locale'
@@ -10,6 +10,7 @@ import { reached, subtitle, title } from './classes'
import LearningActivity from './LearningActivity'

const objectiveClass = css`
  position: relative;
  flex-shrink: 0;
  margin-bottom: 1px;

@@ -30,13 +31,30 @@ const activities = css`
  margin: 1rem;
`

const highlightedCount = css`
  position: absolute;
  top: 0.35rem;
  right: 0.6rem;
  z-index: 1;
  background-color: ${Colors.ORANGE3} !important;
  color: ${Colors.WHITE} !important;
  font-size: 0.7rem !important;
  line-height: 1 !important;
  min-height: 1.125rem !important;
  padding: 0 0.45rem !important;
  pointer-events: none;
`

interface LearningObjectiveProps {
  objective: TeamLearningObjective
  teamId?: string
  teamIds?: string[]
  states?: MilestoneState[]
  highlighted?: boolean
  highlightedActivityId?: string | null
  highlightedActivityIds?: string[]
  scrollRequest?: {
    activityId: string | null
    requestId: number
  }
  disabled?: boolean
}

@@ -45,27 +63,65 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({
  teamId,
  teamIds,
  states,
  highlighted = false,
  highlightedActivityId,
  highlightedActivityIds = [],
  scrollRequest,
  disabled,
}) => {
  const [expanded, setExpanded] = useState(false)

  const { t } = useTranslationFrontend()
  const didAutoExpandForHighlight = useRef(false)
  const handledScrollRequestId = useRef<number | null>(null)
  const activitiesWithMilestones = objective.activities.filter(
    iTeamLearningActivityCheck
  )
  const highlightedActivitiesCount = activitiesWithMilestones.filter(activity =>
    highlightedActivityIds.includes(activity.activity.id)
  ).length
  const containsScrollTarget = scrollRequest?.activityId
    ? activitiesWithMilestones.some(
        activity => activity.activity.id === scrollRequest.activityId
      )
    : false

  useEffect(() => {
    if (highlighted && !didAutoExpandForHighlight.current) {
      didAutoExpandForHighlight.current = true
      setExpanded(true)
    if (!containsScrollTarget || !scrollRequest?.activityId) {
      return
    }

    if (handledScrollRequestId.current === scrollRequest.requestId) {
      return
    }

    if (!highlighted) {
      didAutoExpandForHighlight.current = false
    handledScrollRequestId.current = scrollRequest.requestId

    if (!expanded) {
      setExpanded(true)
    }
  }, [highlighted])

    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        document
          .querySelector<HTMLElement>(
            `[data-activity-id="${scrollRequest.activityId}"]`
          )
          ?.scrollIntoView({
            block: 'nearest',
            behavior: 'smooth',
          })
      })
    })
  }, [containsScrollTarget, expanded, scrollRequest])

  return (
    <div
      className={cx({ [objectiveClass]: true, [reached]: objective.reached })}
    >
      {highlightedActivitiesCount > 0 && (
        <StyledTag
          content={`${highlightedActivitiesCount}`}
          className={highlightedCount}
        />
      )}
      <Section
        title={<div className={title}>{objective.objective.name}</div>}
        subtitle={
@@ -92,21 +148,21 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({
          onToggle: () => setExpanded(!expanded),
        }}
        compact
      className={cx({ [objectiveClass]: true, [reached]: objective.reached })}
        rightElement={
          objective.reached ? (
            <StyledTag content={t('milestones.reached')} isAchieved />
          ) : (
          <StyledTag content={t('milestones.notReached')} isAchieved={false} />
            <StyledTag
              content={t('milestones.notReached')}
              isAchieved={false}
            />
          )
        }
      >
        <SectionCard padded className={activities}>
          {/* currently, this component is only rendered in instructor view */}
          {/* TODO: change this when implementing trainee view summary */}
        {objective.activities
          .filter(iTeamLearningActivityCheck)
          .map(activity => (
          {activitiesWithMilestones.map(activity => (
            <LearningActivity
              key={activity.id}
              activity={activity}
@@ -114,10 +170,13 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({
              teamIds={teamIds}
              states={states}
              disabled={disabled}
              highlighted={highlightedActivityId === activity.activity.id}
              highlighted={highlightedActivityIds.includes(
                activity.activity.id
              )}
            />
          ))}
        </SectionCard>
      </Section>
    </div>
  )
}
+65 −16
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@ import {
import { useTranslationFrontend } from '@inject/locale'
import { EmailSelection } from '@inject/shared'
import { createFileRoute } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { InstructorLandingPageRoute } from '../../..'
import { OPEN_REPLY_EVENT_TYPE } from '../../../../../../../email/EmailFormOverlay/events'
import { EmailCard } from '../../../../../../../email/TeamEmails/EmailCard'
@@ -19,6 +19,10 @@ import type {
  EmailThreadChecked,
  ExtendedEmail,
} from '../../../../../../../email/typing'
import {
  HIGHLIGHT_MILESTONE_EVENT,
  type HighlightInstructorMilestoneActivityEvent,
} from '../../../../../../../instructor/InstructorMilestones/events'
import { InstructorFilePageRoute } from '../../../file.$fileId'

const emailThread = css`
@@ -86,30 +90,75 @@ const RouteComponent = () => {
    ],
    [selectedEmailThread]
  )

  if (
  const invalidSelectedThread =
    !selectedEmailThread ||
    selectedEmailThread.archived !== (tab === EmailSelection.ARCHIVED)
  ) {
    return <NonIdealState title={t('emails.selectThread')} />

  const { matchingMilestones } = useMemo(() => {
    if (invalidSelectedThread) {
      return {
        matchingMilestones: [],
      }
    }

  const replyDisabled =
    selectedEmailThread !== undefined &&
    !selectedEmailThread.participants.some(
      participant => participant.definitionAddress
    const participantAddressSet = new Set(
      selectedEmailThread.participants.map(
        participant => participant.definitionAddress?.address
      )
    )

  const participantAddresses = selectedEmailThread.participants
    .map(participant => participant.definitionAddress?.address)
    .filter((address): address is string => Boolean(address))

    const matchingMilestones = (milestones?.milestones ?? []).filter(
      milestone =>
      milestone.activity &&
        !!milestone.activity &&
        milestone.activity.addresses.some(address =>
        participantAddresses.includes(address.address)
          participantAddressSet.has(address.address)
        )
    )

    return {
      matchingMilestones,
    }
  }, [invalidSelectedThread, milestones?.milestones, selectedEmailThread])

  useEffect(() => {
    if (invalidSelectedThread) {
      return
    }

    const activityIds = [
      ...new Set(
        matchingMilestones
          .map(milestone => milestone.activity?.id)
          .filter((activityId): activityId is string => Boolean(activityId))
      ),
    ]

    window.dispatchEvent(
      new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, {
        detail: {
          activityIds,
        } satisfies HighlightInstructorMilestoneActivityEvent,
      })
    )

    return () => {
      window.dispatchEvent(
        new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, {
          detail: {
            activityIds: [],
          } satisfies HighlightInstructorMilestoneActivityEvent,
        })
      )
    }
  }, [invalidSelectedThread, matchingMilestones])

  if (invalidSelectedThread) {
    return <NonIdealState title={t('emails.selectThread')} />
  }

  const replyDisabled =
    selectedEmailThread !== undefined &&
    !selectedEmailThread.participants.some(
      participant => participant.definitionAddress
    )

  return (
Loading