Commit 8dfb294d authored by Adam Parák's avatar Adam Parák 💬
Browse files

Merge branch 'hang-action-logs' into 'main'

Refactor Channel fetching

Closes #354, #360, and #353

See merge request inject/frontend!272
parents 4cee8545 75f01b5a
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
query GetTeamChannelLogs($teamId: ID!, $channelId: ID!) {
    teamChannelLogs(teamId: $teamId, channelId: $channelId) {
        ...ActionLog
    }
}
 No newline at end of file
+0 −119
Original line number Diff line number Diff line
import InjectMessage from '@/actionlog/InjectMessage'
import { Button, NonIdealState } from '@blueprintjs/core'
import { css } from '@emotion/css'
import type { ActionLog } from '@inject/graphql/fragments/ActionLog.generated'
import {
  useEffect,
  useMemo,
  useRef,
  type FC,
  type MouseEventHandler,
} from 'react'

const view = css`
  height: 100%;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
`

const bottom = css`
  display: flex;
  justify-content: flex-end;
`

interface ActionLogTimelineProps {
  actionLogs: ActionLog[]
  exerciseId: string
  teamId: string
  getOnInspect: (actionLogId: string) => MouseEventHandler
  inInstructor: boolean
}

const ActionLogTimeline: FC<ActionLogTimelineProps> = ({
  actionLogs,
  exerciseId,
  teamId,
  getOnInspect,
  inInstructor,
}) => {
  const unreadLogsStartRef = useRef<HTMLDivElement | null>(null)
  const bottomRef = useRef<HTMLDivElement | null>(null)

  /**
   * this prevents the change of actionLogs from triggering a
   * re-calculation of the firstUnreadIndex
   *
   * actionLog.readReceipt will update as soon as the InjectMessage is
   * rendered, but the actionLog needs to still be rendered as unread
   */
  const firstUnreadIndex = useMemo(() => {
    const index = actionLogs.findIndex(actionLog => !actionLog.readReceipt)
    return index === -1 ? actionLogs.length : index
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
  // TODO: we should change it to a proper look-ahead algorithm and use actionLog's ID for indicating the read state, or use something better

  useEffect(() => {
    if (bottomRef.current) {
      bottomRef.current.scrollIntoView({ behavior: 'instant' })
    }
  }, [])
  useEffect(() => {
    if (bottomRef.current) {
      bottomRef.current.scrollIntoView({ behavior: 'smooth' })
    }
  }, [actionLogs])
  const handleTopOfUnread = () => {
    if (unreadLogsStartRef.current) {
      unreadLogsStartRef.current.scrollIntoView({ behavior: 'smooth' })
    }
  }

  return (
    <div className={view}>
      {actionLogs.length > 0 ? (
        <>
          <div>
            {actionLogs.map((actionLog, index) => (
              <div key={actionLog.id}>
                {index === firstUnreadIndex && <div ref={unreadLogsStartRef} />}
                {
                  <InjectMessage
                    key={actionLog.id}
                    exerciseId={exerciseId}
                    teamId={teamId}
                    actionLog={actionLog}
                    onInspect={getOnInspect(actionLog.id)}
                    inInstructor={inInstructor}
                    allowFileRedirect
                  />
                }
              </div>
            ))}
          </div>

          <div ref={bottomRef} className={bottom}>
            <Button
              icon='arrow-up'
              minimal
              onClick={handleTopOfUnread}
              disabled={firstUnreadIndex === actionLogs.length}
            >
              See all unread
            </Button>
          </div>
        </>
      ) : (
        <NonIdealState
          icon='low-voltage-pole'
          title='No notifications'
          description='Please wait for new notifications to come in'
        />
      )}
    </div>
  )
}

export default ActionLogTimeline
+109 −26
Original line number Diff line number Diff line
import ErrorMessage from '@/components/ErrorMessage'
import { Spinner } from '@blueprintjs/core'
import useGetChannelLogs from '@inject/graphql/custom/useGetVisibleActionLogs'
import InjectMessage from '@/actionlog/InjectMessage'
import { Button, NonIdealState } from '@blueprintjs/core'
import { css } from '@emotion/css'
import { useGetTeamChannelLogsSuspenseQuery } from '@inject/graphql/queries/GetTeamChannelLogs.generated'
import useInInstructor from '@inject/shared/hooks/useInInstructor'
import type { FC, MouseEventHandler } from 'react'
import { memo } from 'react'
import ActionLogTimeline from './components/ActionLogTimeline'
import notEmpty from '@inject/shared/utils/notEmpty'
import type { ReactNode } from 'react'
import {
  memo,
  useEffect,
  useMemo,
  useRef,
  type FC,
  type MouseEventHandler,
} from 'react'

const view = css`
  height: 100%;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
`

const bottom = css`
  display: flex;
  justify-content: flex-end;
`

interface ActionLogProps {
  teamId: string
  exerciseId: string
  channelId: string
  getOnInspect: (actionLogId: string) => MouseEventHandler
  bottomOverride?: ReactNode
}

/**
@@ -21,34 +43,95 @@ const ActionLog: FC<ActionLogProps> = ({
  exerciseId,
  channelId,
  getOnInspect,
  bottomOverride,
}) => {
  const inInstructor = useInInstructor()

  const { data, loading, error } = useGetChannelLogs(channelId, {
    variables: { teamId },
    fetchPolicy: 'cache-only',
  const { data } = useGetTeamChannelLogsSuspenseQuery({
    variables: { teamId, channelId },
    fetchPolicy: 'cache-first',
  })
  const actionLogs = (data.teamChannelLogs || [])?.filter(notEmpty)

  const unreadLogsStartRef = useRef<HTMLDivElement | null>(null)
  const bottomRef = useRef<HTMLDivElement | null>(null)

  /**
   * this prevents the change of actionLogs from triggering a
   * re-calculation of the firstUnreadIndex
   *
   * actionLog.readReceipt will update as soon as the InjectMessage is
   * rendered, but the actionLog needs to still be rendered as unread
   */
  const firstUnreadIndex = useMemo(() => {
    const index = actionLogs.findIndex(actionLog => !actionLog.readReceipt)
    return index === -1 ? actionLogs.length : index
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
  // TODO: we should change it to a proper look-ahead algorithm and use actionLog's ID for indicating the read state, or use something better

  if (loading) {
    return <Spinner />
  useEffect(() => {
    if (bottomRef.current) {
      bottomRef.current.scrollIntoView({ behavior: 'instant' })
    }
  }, [])
  useEffect(() => {
    if (bottomRef.current) {
      bottomRef.current.scrollIntoView({ behavior: 'smooth' })
    }
  }, [actionLogs])
  const handleTopOfUnread = () => {
    if (unreadLogsStartRef.current) {
      unreadLogsStartRef.current.scrollIntoView({ behavior: 'smooth' })
    }
  if (error) {
    return (
      <ErrorMessage>
        <h1>Error occurred!</h1>
        <p>{error.message}</p>
      </ErrorMessage>
    )
  }

  return (
    <ActionLogTimeline
      actionLogs={data.actionLogs}
      teamId={teamId}
    <div className={view}>
      {actionLogs.length > 0 && (
        <>
          <div>
            {actionLogs.map((actionLog, index) => (
              <div key={actionLog.id}>
                {index === firstUnreadIndex && <div ref={unreadLogsStartRef} />}
                {
                  <InjectMessage
                    key={actionLog.id}
                    exerciseId={exerciseId}
      getOnInspect={getOnInspect}
                    teamId={teamId}
                    actionLog={actionLog}
                    onInspect={getOnInspect(actionLog.id)}
                    inInstructor={inInstructor}
                    allowFileRedirect
                  />
                }
              </div>
            ))}
          </div>

          <div ref={bottomRef} className={bottom}>
            <Button
              icon='arrow-up'
              minimal
              onClick={handleTopOfUnread}
              disabled={firstUnreadIndex === actionLogs.length}
            >
              See all unread
            </Button>
          </div>
        </>
      )}
      {bottomOverride && (
        <div style={{ height: 'max-content' }}>{bottomOverride}</div>
      )}
      {actionLogs.length === 0 && !bottomOverride && (
        <NonIdealState
          icon='low-voltage-pole'
          title='No notifications'
          description='Please wait for new notifications to come in'
        />
      )}
    </div>
  )
}

+17 −10
Original line number Diff line number Diff line
import ActionLog from '@/actionlog/ActionLog'
import { useNavigate, useParams } from '@/router'
import { Spinner } from '@blueprintjs/core'
import Container from '@inject/shared/components/Container'
import { Suspense } from 'react'

const Page = () => {
  const { exerciseId, teamId, channelId } = useParams(
@@ -10,16 +12,21 @@ const Page = () => {

  return (
    <Container makeFullHeight>
      <Suspense fallback={<Spinner />}>
        <ActionLog
          exerciseId={exerciseId}
          teamId={teamId}
          channelId={channelId}
          getOnInspect={actionLogId => () =>
          nav('/instructor/:exerciseId/:teamId/:channelId/form/:actionLogId', {
            nav(
              '/instructor/:exerciseId/:teamId/:channelId/form/:actionLogId',
              {
                params: { exerciseId, teamId, channelId, actionLogId },
          })
              }
            )
          }
        />
      </Suspense>
    </Container>
  )
}
+17 −10
Original line number Diff line number Diff line
import ActionLog from '@/actionlog/ActionLog'
import { useNavigate, useParams } from '@/router'
import { Spinner } from '@blueprintjs/core'
import Container from '@inject/shared/components/Container'
import { Suspense } from 'react'

const Page = () => {
  const { exerciseId, teamId, channelId } = useParams(
@@ -10,16 +12,21 @@ const Page = () => {

  return (
    <Container makeFullHeight>
      <Suspense fallback={<Spinner />}>
        <ActionLog
          exerciseId={exerciseId}
          teamId={teamId}
          channelId={channelId}
          getOnInspect={actionLogId => () =>
          nav('/instructor/:exerciseId/:teamId/:channelId/info/:actionLogId', {
            nav(
              '/instructor/:exerciseId/:teamId/:channelId/info/:actionLogId',
              {
                params: { exerciseId, teamId, channelId, actionLogId },
          })
              }
            )
          }
        />
      </Suspense>
    </Container>
  )
}
Loading