Skip to content

Commit

Permalink
Merge pull request #1055 from UniversityOfHelsinkiCS/summary-v2
Browse files Browse the repository at this point in the history
Summary v2
  • Loading branch information
Veikkosuhonen authored Aug 24, 2023
2 parents b176d5e + 27c217c commit dd5d36e
Show file tree
Hide file tree
Showing 17 changed files with 1,164 additions and 13 deletions.
3 changes: 2 additions & 1 deletion config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const config = {
TAGS_ENABLED: [],

/**
* The id of a LIKERT-type question that is considered the university level workload question.
* The id of a SINGLE_CHOICE-type question that is considered the university level workload question.
* The workload question has some assumptions about it, mainly that it MUST NOT have a "no answer (EOS)"-option.
* Future ideas: get rid of this and add a new question type for it instead.
*/
WORKLOAD_QUESTION_ID: 1042,
Expand Down
29 changes: 19 additions & 10 deletions src/client/pages/CourseSummary/CourseSummary.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,26 @@ import { Route, Switch } from 'react-router-dom'

import OrganisationSummary from './OrganisationSummary'
import CourseRealisationSummary from './CourseRealisationSummary'
import SummaryV2 from './SummaryV2/SummaryV2'
import useAuthorizedUser from '../../hooks/useAuthorizedUser'
import ProtectedRoute from '../../components/common/ProtectedRoute'

const CourseSummary = () => (
<Switch>
<Route path="/course-summary" exact>
<OrganisationSummary />
</Route>
const CourseSummary = () => {
const { authorizedUser: user } = useAuthorizedUser()

<Route path="/course-summary/:code" exact>
<CourseRealisationSummary />
</Route>
</Switch>
)
return (
<Switch>
<Route path="/course-summary" exact>
<OrganisationSummary />
</Route>

<ProtectedRoute path="/course-summary/v2" component={SummaryV2} hasAccess={user?.isAdmin} />

<Route path="/course-summary/:code" exact>
<CourseRealisationSummary />
</Route>
</Switch>
)
}

export default CourseSummary
2 changes: 2 additions & 0 deletions src/client/pages/CourseSummary/OrganisationSummary.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import ErrorView from '../../components/common/ErrorView'
import OrganisationTable from './OrganisationTable'
import ExportCourses from './ExportCourses'
import { TAGS_ENABLED } from '../../util/common'
import LinkButton from '../../components/common/LinkButton'

const safelyParseDateRange = dateRange =>
dateRange?.startDate && dateRange?.endDate
Expand Down Expand Up @@ -134,6 +135,7 @@ const OrganisationSummary = () => {
questions={questions || []}
componentRef={componentRef}
/>
{courseSummaryAccessInfo?.adminAccess && <LinkButton to="/course-summary/v2" title="MINTUfy" />}
</Box>
<Box mt={1} />
<Typography variant="body1" component="h2">
Expand Down
3 changes: 2 additions & 1 deletion src/client/pages/CourseSummary/PercentageCell.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const styles = {
* @param {{ string, number }} { label, percent }
* @returns
*/
const PercentageCell = ({ label, percent }) => {
const PercentageCell = ({ label, percent, sx }) => {
let hex = Number(percent * 2.55).toString(16)
const indexOfDot = hex.indexOf('.')
hex = indexOfDot === -1 ? hex : hex.substring(0, indexOfDot)
Expand All @@ -25,6 +25,7 @@ const PercentageCell = ({ label, percent }) => {
label={label}
sx={{
background: theme => `${theme.palette.info.light}${hex}`,
...(sx ?? {}),
}}
/>
</Box>
Expand Down
315 changes: 315 additions & 0 deletions src/client/pages/CourseSummary/SummaryV2/SummaryRow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronRight } from '@mui/icons-material'
import { Link as RouterLink } from 'react-router-dom'
import { grey } from '@mui/material/colors'
import _ from 'lodash'
import { useSummaries } from './api'
import { getLanguageValue } from '../../../util/languageUtils'
import SummaryResultItem from '../../../components/SummaryResultItem/SummaryResultItem'
import { LoadingProgress } from '../../../components/common/LoadingProgress'
import { CourseUnitLabel, OrganisationLabel } from '../Labels'
import PercentageCell from '../PercentageCell'

const { Box, ButtonBase, Typography } = require('@mui/material')

const styles = {
resultCell: {
whiteSpace: 'nowrap',
textAlign: 'center',
minWidth: '3.5rem',
display: 'flex',
aspectRatio: 1, // Make them square
alignItems: 'center',
justifyContent: 'center',
},
countCell: {
whiteSpace: 'nowrap',
textAlign: 'center',
flex: '0.1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
percentCell: {
whiteSpace: 'nowrap',
textAlign: 'right',
minWidth: '60px',
},
labelCell: theme => ({
[theme.breakpoints.down('md')]: {
width: '100px',
height: '74px', // Sets a good height for the entire row
},
[theme.breakpoints.up('md')]: {
width: '450px',
},
[theme.breakpoints.up('lg')]: {
width: '500px',
},
paddingRight: '1rem',
}),
innerLabelCell: {
paddingLeft: '1.3rem',
},
accordionButton: {
width: '100%',
height: '100%',
minHeight: '48px',
maxHeight: '74px',
paddingLeft: '0.5rem',
paddingRight: '2.5rem',
display: 'flex',
justifyContent: 'space-between',
borderRadius: '10px',
textAlign: 'left',
textTransform: 'none',
'&:hover': {
background: theme => theme.palette.action.hover,
},
},
link: {
color: theme => theme.palette.primary.main,
},
arrowContainer: {
position: 'absolute',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'row-reverse',
alignItems: 'center',
paddingRight: '0.7rem',
'&:hover': {
color: theme => theme.palette.text.primary,
},
color: theme => theme.palette.info.main,
},
arrow: {
transition: 'transform 0.2s ease-out',
},
arrowOpen: {
transform: 'rotate(90deg)',
},
given: {
color: theme => theme.palette.success.main,
'&:hover': {
color: theme => theme.palette.success.light,
},
},
notGiven: {
color: theme => theme.palette.error.main,
'&:hover': {
color: theme => theme.palette.error.light,
},
},
feedbackOpen: {
color: theme => theme.palette.primary.main,
'&:hover': {
color: theme => theme.palette.primary.light,
},
},
}

const useAccordionState = (id, enabled, forceOpen) => {
const key = `accordions-v2`

const initial = React.useMemo(() => {
if (!enabled) return false
if (forceOpen) return true

const str = localStorage.getItem(key)
if (typeof str === 'string') {
const ids = JSON.parse(str)
if (Array.isArray(ids)) {
return ids.includes(id)
}
}
return false
}, [key])

const [open, setOpen] = React.useState(initial)

React.useEffect(() => {
if (!enabled || forceOpen) return

let ids = []
const str = localStorage.getItem(key)
if (typeof str === 'string') {
ids = JSON.parse(str)
if (Array.isArray(ids)) {
if (open && !ids.includes(id)) {
ids.push(id)
} else if (!open) {
ids = ids.filter(aid => aid !== id)
}
}
}
localStorage.setItem(key, JSON.stringify(ids))
}, [open])

return [open, setOpen]
}

const RowHeader = ({ openable = false, isOpen = false, handleOpenRow, label, link }) => (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{openable ? (
<ButtonBase onClick={handleOpenRow} sx={styles.accordionButton} variant="contained" disableRipple>
{label}
<Box sx={styles.arrowContainer}>
<ChevronRight sx={{ ...styles.arrow, ...(isOpen ? styles.arrowOpen : {}) }} />
</Box>
</ButtonBase>
) : (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{link ? (
<ButtonBase
to={link}
component={RouterLink}
sx={{ ...styles.accordionButton, ...styles.link }}
variant="contained"
>
{label}
</ButtonBase>
) : (
label
)}
</>
)}
</>
)

const CourseUnitSummaryRow = ({ courseUnit, questions }) => {
const { i18n } = useTranslation()
const label = <CourseUnitLabel name={getLanguageValue(courseUnit.name, i18n.language)} code={courseUnit.courseCode} />
const link = `/course-summary/${courseUnit.courseCode}`
const { summary } = courseUnit
const percent = ((summary.data.feedbackCount / summary.data.studentCount) * 100).toFixed()
const feedbackResponsePercentage = (summary.data.feedbackResponsePercentage * 100).toFixed()

return (
<Box display="flex" flexDirection="column" alignItems="stretch">
<Box display="flex" alignItems="stretch" gap="0.2rem">
<Box flex={0.4} pr="1rem">
<RowHeader label={label} link={link} />
</Box>
{questions.map(q => (
<SummaryResultItem
key={q.id}
question={q}
mean={summary.data.result[q.id]?.mean}
distribution={summary.data.result[q.id]?.distribution}
sx={styles.resultCell}
component="div"
/>
))}
<Typography variant="body2" sx={styles.countCell}>
{summary.data.feedbackCount} / {summary.data.studentCount}
</Typography>
<PercentageCell label={`${percent}%`} percent={percent} sx={styles.percentCell} />
<PercentageCell
label={`${feedbackResponsePercentage}%`}
percent={feedbackResponsePercentage}
sx={styles.percentCell}
/>
</Box>
</Box>
)
}

const OrganisationSummaryRow = ({
isInitiallyOpen = false,
startDate,
endDate,
organisation: initialOrganisation,
questions,
}) => {
const [isTransitioning, startTransition] = React.useTransition()
const [isOpen, setIsOpen] = useAccordionState(initialOrganisation.id, true, isInitiallyOpen)

const { i18n } = useTranslation()

const { organisation: fetchedOrganisation } = useSummaries({
entityId: initialOrganisation.id,
enabled: isOpen,
startDate,
endDate,
})

const organisation = fetchedOrganisation ?? initialOrganisation

const { childOrganisations, courseUnits, summary } = organisation

const label = <OrganisationLabel name={getLanguageValue(organisation.name, i18n.language)} code={organisation.code} /> //getLanguageValue(rootSummary.organisation?.name, i18n.language)

const handleOpenRow = () => {
startTransition(() => setIsOpen(!isOpen))
}

const link = null

const percent = ((summary.data.feedbackCount / summary.data.studentCount) * 100).toFixed()
const feedbackResponsePercentage = (summary.data.feedbackResponsePercentage * 100).toFixed()

return (
<Box display="flex" flexDirection="column" alignItems="stretch" gap="0.4rem" pt={isOpen ? '0.5rem' : 0}>
<Box display="flex" alignItems="stretch" gap="0.2rem">
<Box flex={0.4} pr="1rem">
<RowHeader openable label={label} isOpen={isOpen} handleOpenRow={handleOpenRow} link={link} />
</Box>
{questions.map(q => (
<SummaryResultItem
key={q.id}
question={q}
mean={summary.data.result[q.id]?.mean}
distribution={summary.data.result[q.id]?.distribution}
sx={styles.resultCell}
component="div"
/>
))}
<Typography variant="body2" sx={styles.countCell}>
{summary.data.feedbackCount} / {summary.data.studentCount}
</Typography>
<PercentageCell label={`${percent}%`} percent={percent} sx={styles.percentCell} />
<PercentageCell
label={`${feedbackResponsePercentage}%`}
percent={feedbackResponsePercentage}
sx={styles.percentCell}
/>
</Box>
{(isTransitioning || isOpen) && (
// eslint-disable-next-line react/jsx-no-useless-fragment
<Box
sx={{ pl: '2rem', borderLeft: `solid 2px ${grey[300]}`, pb: '0.5rem' }}
display="flex"
flexDirection="column"
alignItems="stretch"
gap="0.4rem"
>
{!childOrganisations ? (
<LoadingProgress />
) : (
_.orderBy(childOrganisations, 'code')
.map(org => (
<OrganisationSummaryRow
key={org.id}
startDate={startDate}
endDate={endDate}
organisation={org}
questions={questions}
/>
))
.concat(
_.orderBy(courseUnits, 'courseCode').map(cu => (
<CourseUnitSummaryRow key={cu.id} courseUnit={cu} questions={questions} />
))
)
)}
</Box>
)}
</Box>
)
}

export default OrganisationSummaryRow
Loading

0 comments on commit dd5d36e

Please sign in to comment.