From 1abbf4e190a99f98ef0cdd67caa9d554aae18128 Mon Sep 17 00:00:00 2001
From: Marek Vesely <xvesely4@fi.muni.cz>
Date: Thu, 9 May 2024 17:14:00 +0200
Subject: [PATCH] refactor/fix/feat: unify team selectors and labels, add role
 and exercise info

---
 .../components/InstructorTeams.tsx            | 17 ++--
 frontend/src/clientsettings/vars/teams.ts     | 43 ++++-----
 frontend/src/components/Status/index.tsx      | 17 +++-
 frontend/src/components/TeamLabel/index.tsx   | 88 ++++++++++++++++---
 .../InstructorTeamSelector/LabelElement.tsx   | 36 --------
 .../InstructorTeamSelector/index.tsx          | 62 ++++++++-----
 .../useTeamStateValidator.ts                  | 73 ++++++++-------
 frontend/src/logic/StaffSelector/index.tsx    |  8 +-
 frontend/src/logic/TeamSelector/index.tsx     | 26 +++---
 frontend/src/views/InstructorView/index.tsx   |  2 +-
 10 files changed, 223 insertions(+), 149 deletions(-)
 delete mode 100644 frontend/src/instructor/InstructorTeamSelector/LabelElement.tsx

diff --git a/frontend/src/clientsettings/components/InstructorTeams.tsx b/frontend/src/clientsettings/components/InstructorTeams.tsx
index 338c5d689..3edb4b71c 100644
--- a/frontend/src/clientsettings/components/InstructorTeams.tsx
+++ b/frontend/src/clientsettings/components/InstructorTeams.tsx
@@ -6,17 +6,17 @@ import {
   Section,
   SectionCard,
 } from '@blueprintjs/core'
-import { toggleTeam, unsetTeams, useTeams } from '../vars/teams'
+import { toggleTeam, unsetTeams, useTeamStateMap } from '../vars/teams'
 
 const InstructorTeams = () => {
-  const teamsVar = useTeams()
+  const teamStateMap = useTeamStateMap()
   const validator = useTeamStateValidator()
 
   return (
     <Section
       title={'Instructor Subscribed Teams'}
       subtitle={`Current amount of synchronized teams: ${
-        Object.keys(teamsVar).length
+        Object.keys(teamStateMap).length
       }`}
       rightElement={
         <div
@@ -48,7 +48,7 @@ const InstructorTeams = () => {
       }
     >
       <SectionCard>
-        {Object.keys(teamsVar).length === 0 && (
+        {Object.keys(teamStateMap).length === 0 && (
           <div style={{ padding: '1rem' }}>
             <NonIdealState
               icon='low-voltage-pole'
@@ -56,18 +56,19 @@ const InstructorTeams = () => {
             />
           </div>
         )}
-        {Object.keys(teamsVar).length > 0 && (
+        {Object.keys(teamStateMap).length > 0 && (
           <div style={{ padding: '0.25rem' }}>
-            {Object.entries(teamsVar).map(([key, team]) => (
+            {Object.entries(teamStateMap).map(([key, value]) => (
               <CheckboxCard
                 key={key}
-                checked={team.show}
+                checked={value.show}
                 onClick={e => {
                   e.preventDefault()
                   toggleTeam(key)
                 }}
               >
-                <span>{team.teamRole || `Team ${team.teamId}`}</span>
+                {/* TODO: add role */}
+                <span>{value.team.name}</span>
               </CheckboxCard>
             ))}
           </div>
diff --git a/frontend/src/clientsettings/vars/teams.ts b/frontend/src/clientsettings/vars/teams.ts
index 9be9e5d1a..4dc092c5d 100644
--- a/frontend/src/clientsettings/vars/teams.ts
+++ b/frontend/src/clientsettings/vars/teams.ts
@@ -1,33 +1,34 @@
 import { makeVar, useReactiveVar } from '@inject/graphql/client/reactive'
+import type { Team } from '@inject/graphql/fragments/Team.generated'
 
-export interface Team {
-  exerciseId: string
-  teamRole: string
-  teamId: string
+export interface TeamState {
+  team: Team
   show: boolean
   inactive: boolean
 }
 
-interface TeamState {
-  [key: string]: Team
+export interface TeamStateMap {
+  [teamId: string]: TeamState
 }
 
-const key = 'team'
-const initialSettings: TeamState = JSON.parse(localStorage.getItem(key) || '{}')
+const key = 'teamStateMap'
+const initialSettings: TeamStateMap = JSON.parse(
+  localStorage.getItem(key) || '{}'
+)
 
-export const teams = makeVar<TeamState>(initialSettings)
-export const useTeams = () => useReactiveVar(teams) as TeamState
+export const teamStateMap = makeVar<TeamStateMap>(initialSettings)
+export const useTeamStateMap = () => useReactiveVar(teamStateMap)
 
-function change(value: TeamState) {
+function change(value: TeamStateMap) {
   localStorage.setItem(key, JSON.stringify(value))
 }
 
-export const toggleTeam = (team: string) => {
-  const prev = teams() as TeamState
-  const chosenTeam = prev[team]
-  teams({
+export const toggleTeam = (teamId: string) => {
+  const prev = teamStateMap() as TeamStateMap
+  const chosenTeam = prev[teamId]
+  teamStateMap({
     ...prev,
-    [team]: {
+    [teamId]: {
       ...chosenTeam,
       show: !chosenTeam.show,
     },
@@ -35,8 +36,8 @@ export const toggleTeam = (team: string) => {
 }
 
 export const unsetTeams = () => {
-  const prev = teams() as TeamState
-  teams(
+  const prev = teamStateMap() as TeamStateMap
+  teamStateMap(
     Object.fromEntries(
       Object.entries(prev).map(([key, value]) => [
         key,
@@ -46,7 +47,7 @@ export const unsetTeams = () => {
   )
 }
 
-teams.onNextChange(function onNext() {
-  change(teams())
-  teams.onNextChange(onNext)
+teamStateMap.onNextChange(function onNext() {
+  change(teamStateMap())
+  teamStateMap.onNextChange(onNext)
 })
diff --git a/frontend/src/components/Status/index.tsx b/frontend/src/components/Status/index.tsx
index ba4fa64e7..c515738e2 100644
--- a/frontend/src/components/Status/index.tsx
+++ b/frontend/src/components/Status/index.tsx
@@ -28,14 +28,27 @@ const Status: FC<StatusProps> = ({
   team,
   hideLabel,
 }) => (
-  <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
+  <div
+    style={{
+      display: 'flex',
+      flexDirection: 'column',
+      gap: '0.5rem',
+      alignItems: 'center',
+    }}
+  >
     <div className={header}>
       {!small && (!exerciseRunning || !showTime) && <span>Backend:</span>}
       <HealthCheck />
       {exerciseRunning && showTime && <TimeLeft />}
     </div>
     {team && (
-      <TeamLabel teamId={team.id} teamName={team.name} hideLabel={hideLabel} />
+      <TeamLabel
+        teamId={team.id}
+        teamName={team.name}
+        hideLabel={hideLabel}
+        exerciseName={team.exercise.name}
+        teamRole={team.role}
+      />
     )}
   </div>
 )
diff --git a/frontend/src/components/TeamLabel/index.tsx b/frontend/src/components/TeamLabel/index.tsx
index 07a011bf4..6557d8ab2 100644
--- a/frontend/src/components/TeamLabel/index.tsx
+++ b/frontend/src/components/TeamLabel/index.tsx
@@ -1,24 +1,84 @@
+import { Classes, Tag } from '@blueprintjs/core'
 import ColorBox from '@inject/shared/components/ColorBox'
-import type { FC } from 'react'
+import { useMemo, type FC } from 'react'
 
 interface TeamLabelProps {
   hideLabel?: boolean
   teamId: string
   teamName: string
+  teamRole?: string
+  exerciseName?: string
+  inactive?: boolean
 }
 
-const TeamLabel: FC<TeamLabelProps> = ({ hideLabel, teamId, teamName }) => (
-  <div
-    style={{
-      display: 'flex',
-      justifyContent: 'center',
-      alignItems: 'center',
-      gap: '0.5rem',
-    }}
-  >
-    {!hideLabel && <h3 style={{ fontSize: '1rem', margin: 0 }}>{teamName}</h3>}
-    <ColorBox id={Number(teamId)} title={teamName} />
-  </div>
-)
+const TeamLabel: FC<TeamLabelProps> = ({
+  hideLabel,
+  teamId,
+  teamName,
+  teamRole,
+  exerciseName,
+  inactive,
+}) => {
+  const label = useMemo(
+    () => (
+      <div>
+        <div>{teamName}</div>
+        {teamRole && (
+          <div className={Classes.TEXT_MUTED}>{`role: ${teamRole}`}</div>
+        )}
+        {exerciseName && (
+          <div
+            className={Classes.TEXT_MUTED}
+          >{`exercise: ${exerciseName}`}</div>
+        )}
+      </div>
+    ),
+    [exerciseName, teamName, teamRole]
+  )
+
+  const colorbox = useMemo(
+    () => (
+      <ColorBox
+        style={{
+          width: '16px',
+          height: '16px',
+          marginRight: hideLabel ? undefined : '7px',
+        }}
+        id={Number(teamId)}
+      />
+    ),
+    [hideLabel, teamId]
+  )
+
+  const content = useMemo(() => {
+    if (hideLabel) {
+      return colorbox
+    }
+
+    return (
+      <>
+        {colorbox}
+        {label}
+        {inactive && (
+          <Tag style={{ marginLeft: '1ch' }} minimal intent='warning'>
+            inactive
+          </Tag>
+        )}
+      </>
+    )
+  }, [colorbox, hideLabel, inactive, label])
+
+  return (
+    <div
+      style={{
+        display: 'flex',
+        justifyContent: hideLabel ? 'center' : 'flex-start',
+        alignItems: 'center',
+      }}
+    >
+      {content}
+    </div>
+  )
+}
 
 export default TeamLabel
diff --git a/frontend/src/instructor/InstructorTeamSelector/LabelElement.tsx b/frontend/src/instructor/InstructorTeamSelector/LabelElement.tsx
deleted file mode 100644
index 60b147e67..000000000
--- a/frontend/src/instructor/InstructorTeamSelector/LabelElement.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { Team } from '@/clientsettings/vars/teams'
-import { Tag } from '@blueprintjs/core'
-import ColorBox from '@inject/shared/components/ColorBox'
-import type { FC } from 'react'
-
-interface LabelElementProps {
-  team: Team
-  hideLabel?: boolean
-}
-
-const LabelElement: FC<LabelElementProps> = ({ team, hideLabel }) => (
-  <div
-    style={{
-      display: 'flex',
-      justifyContent: hideLabel ? 'center' : 'flex-start',
-      alignItems: 'center',
-    }}
-  >
-    <ColorBox
-      style={{
-        width: '16px',
-        height: '16px',
-        marginRight: hideLabel ? undefined : '7px',
-      }}
-      id={Number(team.teamId)}
-    />
-    {!hideLabel && (team.teamRole || `Team ${team.teamId}`)}
-    {team.inactive && (
-      <Tag style={{ marginLeft: '1ch' }} minimal intent='warning'>
-        inactive
-      </Tag>
-    )}
-  </div>
-)
-
-export default LabelElement
diff --git a/frontend/src/instructor/InstructorTeamSelector/index.tsx b/frontend/src/instructor/InstructorTeamSelector/index.tsx
index d56622c2a..5a249f6a8 100644
--- a/frontend/src/instructor/InstructorTeamSelector/index.tsx
+++ b/frontend/src/instructor/InstructorTeamSelector/index.tsx
@@ -1,6 +1,6 @@
-import { toggleTeam, useTeams } from '@/clientsettings/vars/teams'
+import { toggleTeam, useTeamStateMap } from '@/clientsettings/vars/teams'
 import LinkButton from '@/components/LinkButton'
-import { useParams } from '@/router'
+import TeamLabel from '@/components/TeamLabel'
 import {
   Button,
   Checkbox,
@@ -11,36 +11,35 @@ import {
 } from '@blueprintjs/core'
 import type { FC } from 'react'
 import { useEffect, useState } from 'react'
-import LabelElement from './LabelElement'
 import Reloader from './Reloader'
 import useTeamStateValidator from './useTeamStateValidator'
 
 interface InstructorTeamSelectorProps {
   hideLabel?: boolean
+  teamId: string | undefined
 }
 
 const InstructorTeamSelector: FC<InstructorTeamSelectorProps> = ({
   hideLabel,
+  teamId,
 }) => {
   const validator = useTeamStateValidator()
-  const state = useTeams()
-  const cleaned = state ?? {}
-  const { teamId } = useParams('/instructor/:exerciseId/:teamId')
+  const teamStateMap = useTeamStateMap()
 
   const [openSelector, setOpenSelector] = useState(false)
 
-  const teams = Object.values(cleaned) || []
-  const selectedTeams = teams.filter(x => x.show)
+  const teamStates = Object.values(teamStateMap ?? {}) || []
+  const selectedTeamStates = teamStates.filter(teamState => teamState.show)
 
   useEffect(() => {
     validator()
   }, [])
 
   useEffect(() => {
-    if (selectedTeams.length === 0) {
+    if (selectedTeamStates.length === 0) {
       setOpenSelector(true)
     }
-  }, [selectedTeams, setOpenSelector])
+  }, [selectedTeamStates.length])
 
   return (
     <>
@@ -54,25 +53,32 @@ const InstructorTeamSelector: FC<InstructorTeamSelectorProps> = ({
         title='Select teams'
         onClick={() => setOpenSelector(!openSelector)}
       />
-      {selectedTeams.map(team => (
+      {selectedTeamStates.map(teamState => (
         <LinkButton
-          key={team.teamId}
+          key={teamState.team.id}
           link={[
             '/instructor/:exerciseId/:teamId',
             {
               params: {
-                exerciseId: team.exerciseId,
-                teamId: team.teamId,
+                exerciseId: teamState.team.exercise.id,
+                teamId: teamState.team.id,
               },
             },
           ]}
           button={{
-            active: team.teamId === teamId,
+            active: teamState.team.id === teamId,
             alignText: 'left',
             fill: true,
             minimal: true,
-            title: team.teamRole || `Team ${team.teamId}`,
-            children: <LabelElement team={team} hideLabel={hideLabel} />,
+            title: teamState.team.name,
+            children: (
+              <TeamLabel
+                hideLabel={hideLabel}
+                teamId={teamState.team.id}
+                teamName={teamState.team.name}
+                inactive={teamState.inactive}
+              />
+            ),
           }}
         />
       ))}
@@ -86,20 +92,28 @@ const InstructorTeamSelector: FC<InstructorTeamSelectorProps> = ({
         title='Team Selection'
       >
         <DialogBody>
-          {teams.map((team, i) => (
-            <div key={team.teamId}>
+          {teamStates.map((teamState, i) => (
+            <div key={teamState.team.id}>
               <Checkbox
-                key={team.teamId}
+                key={teamState.team.id}
                 onChange={() => {
-                  toggleTeam(team.teamId)
+                  toggleTeam(teamState.team.id)
                 }}
-                checked={team.show}
+                checked={teamState.show}
                 alignIndicator='right'
                 inline={false}
-                labelElement={<LabelElement team={team} />}
+                labelElement={
+                  <TeamLabel
+                    teamId={teamState.team.id}
+                    teamName={teamState.team.name}
+                    teamRole={teamState.team.role}
+                    exerciseName={teamState.team.exercise.name}
+                    inactive={teamState.inactive}
+                  />
+                }
                 disabled={false}
               />
-              {i < teams.length - 1 && <Divider />}
+              {i < teamStates.length - 1 && <Divider />}
             </div>
           ))}
         </DialogBody>
diff --git a/frontend/src/instructor/InstructorTeamSelector/useTeamStateValidator.ts b/frontend/src/instructor/InstructorTeamSelector/useTeamStateValidator.ts
index 62c2f992e..eefe75aed 100644
--- a/frontend/src/instructor/InstructorTeamSelector/useTeamStateValidator.ts
+++ b/frontend/src/instructor/InstructorTeamSelector/useTeamStateValidator.ts
@@ -1,9 +1,10 @@
-import { teams, useTeams } from '@/clientsettings/vars/teams'
+import type { TeamStateMap } from '@/clientsettings/vars/teams'
+import { teamStateMap, useTeamStateMap } from '@/clientsettings/vars/teams'
 import { useGetRunningExercises } from '@inject/graphql/queries/GetRunningExercises.generated'
 import notEmpty from '@inject/shared/utils/notEmpty'
 
 const useTeamStateValidator = () => {
-  const teamsVar = useTeams()
+  const teamStateMapVar = useTeamStateMap()
 
   const { refetch } = useGetRunningExercises({
     fetchPolicy: 'network-only',
@@ -13,10 +14,17 @@ const useTeamStateValidator = () => {
     const { data } = await refetch()
     if (!data || !data.exercises) return
     const runningExercises = data.exercises.filter(notEmpty)
+
     if (runningExercises.length === 0 || !data.exercises[0]) {
-      teams(
+      /*
+       * if the teamStateMapVar is not empty, the viewed exercise finished
+       * or was paused
+       *
+       * keep the team state map but mark all teams as inactive
+       */
+      teamStateMap(
         Object.fromEntries(
-          Object.entries(teamsVar).map(([key, value]) => [
+          Object.entries(teamStateMapVar).map(([key, value]) => [
             key,
             { ...value, inactive: true },
           ])
@@ -24,40 +32,45 @@ const useTeamStateValidator = () => {
       )
       return
     }
-    const newState = data.exercises
-      .flatMap(x =>
-        x?.teams.map(team => [
+
+    const newTeamStateMap: TeamStateMap = Object.fromEntries(
+      data.exercises.filter(notEmpty).flatMap(exercise =>
+        exercise.teams.map(team => [
           team.id,
           {
-            exerciseId: x.id,
-            teamRole: team.role,
-            teamId: team.id,
+            team,
             show: false,
             inactive: false,
           },
         ])
       )
-      .filter(notEmpty)
-
-    teams({
-      ...Object.fromEntries(newState),
-      ...(teamsVar
-        ? Object.fromEntries(
-            Object.entries(teamsVar)
-              .filter(([, value]) =>
-                runningExercises.some(x => value.exerciseId === x.id)
-              )
-              .map(([key, value]) => [
-                key,
-                {
-                  ...value,
-                  inactive: runningExercises.some(
-                    x => value.exerciseId === x.id
-                  ),
-                },
-              ])
+    )
+    /*
+     * if the teamStateMapVar is not empty, a previously selected exercise was
+     * paused and is now running again
+     *
+     * keep the teams from the currently running exercises, but mark them
+     * as active
+     */
+    const oldTeamStateMap: TeamStateMap = Object.fromEntries(
+      Object.entries(teamStateMapVar)
+        .filter(([, value]) =>
+          runningExercises.some(
+            exercise => exercise.id === value.team.exercise.id
           )
-        : {}),
+        )
+        .map(([key, value]) => [
+          key,
+          {
+            ...value,
+            inactive: false,
+          },
+        ])
+    )
+
+    teamStateMap({
+      ...newTeamStateMap,
+      ...oldTeamStateMap,
     })
   }
 }
diff --git a/frontend/src/logic/StaffSelector/index.tsx b/frontend/src/logic/StaffSelector/index.tsx
index 9b739425f..bbc8b9dd2 100644
--- a/frontend/src/logic/StaffSelector/index.tsx
+++ b/frontend/src/logic/StaffSelector/index.tsx
@@ -1,4 +1,4 @@
-import { useTeams } from '@/clientsettings/vars/teams'
+import { useTeamStateMap } from '@/clientsettings/vars/teams'
 import { useNavigate } from '@/router'
 import { Button, ButtonGroup, Checkbox, Collapse } from '@blueprintjs/core'
 import { useGetRunningExercises } from '@inject/graphql/queries/GetRunningExercises.generated'
@@ -8,7 +8,7 @@ import TeamSelector from '../TeamSelector'
 
 const StaffSelector = () => {
   const { data } = useGetRunningExercises()
-  const teams = useTeams()
+  const teamStateMap = useTeamStateMap()
   const nav = useNavigate()
   const [enableTrainee, setEnableTrainee] = useState(false)
 
@@ -24,7 +24,9 @@ const StaffSelector = () => {
               ? 'Enter Instructor view'
               : 'Please wait for the administrator to start the exercises'
           }
-          disabled={!anyExerciseRunning && Object.keys(teams).length === 0}
+          disabled={
+            !anyExerciseRunning && Object.keys(teamStateMap).length === 0
+          }
           onClick={() => nav('/instructor')}
         >
           Instructor
diff --git a/frontend/src/logic/TeamSelector/index.tsx b/frontend/src/logic/TeamSelector/index.tsx
index 87f665932..c26ddef8e 100644
--- a/frontend/src/logic/TeamSelector/index.tsx
+++ b/frontend/src/logic/TeamSelector/index.tsx
@@ -1,4 +1,5 @@
 import ErrorMessage from '@/components/ErrorMessage'
+import TeamLabel from '@/components/TeamLabel'
 import { useNavigate } from '@/router'
 import {
   Card,
@@ -12,7 +13,6 @@ import { ChevronRight } from '@blueprintjs/icons'
 import { css } from '@emotion/css'
 import type { Team } from '@inject/graphql/fragments/Team.generated'
 import { useGetRunningExercises } from '@inject/graphql/queries/GetRunningExercises.generated'
-import ColorBox from '@inject/shared/components/ColorBox'
 import notEmpty from '@inject/shared/utils/notEmpty'
 import { useMemo } from 'react'
 import Reloader from '../Reloader'
@@ -60,26 +60,32 @@ const TeamSelector = () => {
             />
           </div>
         )}
-        {exercises.filter(notEmpty).map(x => (
-          <SectionCard padded key={x.id}>
-            <h3 style={{ paddingBottom: '1rem' }}>{`Exercise ${x.id}`}</h3>
+        {exercises.filter(notEmpty).map(exercise => (
+          <SectionCard padded key={exercise.id}>
+            <h3
+              style={{ paddingBottom: '1rem' }}
+            >{`Exercise ${exercise.name}`}</h3>
             <CardList bordered>
-              {x.teams.map((y: Team) => (
+              {exercise.teams.map((team: Team) => (
                 <Card
                   interactive
                   onClick={() =>
                     nav('/trainee/:exerciseId/:teamId', {
                       params: {
-                        exerciseId: x.id,
-                        teamId: y.id,
+                        exerciseId: exercise.id,
+                        teamId: team.id,
                       },
                     })
                   }
                   className={between}
-                  key={y.id}
+                  key={team.id}
                 >
-                  <h4>{y.role || y.name}</h4>
-                  <ColorBox id={Number(y.id)} style={{ marginRight: 'auto' }} />
+                  <TeamLabel
+                    teamId={team.id}
+                    teamName={team.name}
+                    teamRole={team.role}
+                    exerciseName={exercise.name}
+                  />
                   <ChevronRight className={Classes.TEXT_MUTED} />
                 </Card>
               ))}
diff --git a/frontend/src/views/InstructorView/index.tsx b/frontend/src/views/InstructorView/index.tsx
index 36df92c2a..be25175aa 100644
--- a/frontend/src/views/InstructorView/index.tsx
+++ b/frontend/src/views/InstructorView/index.tsx
@@ -97,7 +97,7 @@ const InstructorView: FC<InstructorViewProps> = ({
     },
     {
       name: 'Teams',
-      node: <InstructorTeamSelector hideLabel={hideLeftBar} />,
+      node: <InstructorTeamSelector hideLabel={hideLeftBar} teamId={teamId} />,
     },
     ...(exerciseId && teamId
       ? [
-- 
GitLab