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