mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0aaad7f95 | ||
|
|
13cf71923d | ||
|
|
a83e7cf2cf | ||
|
|
17dcc22224 | ||
|
|
77a1efe1f9 | ||
|
|
3109fd146b | ||
|
|
467bb5827f | ||
|
|
c43593423c | ||
|
|
c6da893027 | ||
|
|
30cb47f680 | ||
|
|
440a8ddd02 | ||
|
|
a231f7e01b | ||
|
|
b6aee55e7a | ||
|
|
ec9707e694 | ||
|
|
45b9cfa554 | ||
|
|
45a1f79f20 | ||
|
|
0a961034b2 | ||
|
|
0b1fba1856 | ||
|
|
c8ca0c37be | ||
|
|
ee8e8ec995 | ||
|
|
f721161b8d | ||
|
|
388c95748b | ||
|
|
86cbf05690 | ||
|
|
31cc762187 | ||
|
|
a7d9c2f567 | ||
|
|
32be98c428 | ||
|
|
8f71965070 | ||
|
|
d348e3b930 | ||
|
|
819350d58e | ||
|
|
62caf3e014 | ||
|
|
c7396f2096 | ||
|
|
de8b08ab8a | ||
|
|
8f0558e2b7 | ||
|
|
fbe3a6863f | ||
|
|
8a0cfb86f2 | ||
|
|
fe38a89e97 | ||
|
|
f90fb14f30 | ||
|
|
b7c54d9686 | ||
|
|
cc66fa20f5 | ||
|
|
c4d9a6652f | ||
|
|
11c0ceed15 | ||
|
|
58844c9999 | ||
|
|
80bb30b715 | ||
|
|
824f2e8ece | ||
|
|
ed1d67a241 | ||
|
|
5a587b69ae | ||
|
|
7ae55b63ca | ||
|
|
6c3bc2bedb | ||
|
|
d1eb3f093a | ||
|
|
cad37de836 | ||
|
|
517dad91e6 | ||
|
|
e7ff1bd760 | ||
|
|
94f4cdb5d0 | ||
|
|
a89b7f1240 | ||
|
|
443f6d6eb1 | ||
|
|
015ea209aa | ||
|
|
0f29fec96b | ||
|
|
ef7ba0d44d | ||
|
|
6218c8535f | ||
|
|
f32f106f7c | ||
|
|
c1624c9a51 | ||
|
|
32de98d1b7 | ||
|
|
9c6f4915cc | ||
|
|
0feeb74929 | ||
|
|
8e5c4f2ec3 | ||
|
|
a52ee5da00 | ||
|
|
2fe2707f18 | ||
|
|
76734c3149 | ||
|
|
c00f738a1d | ||
|
|
dd812de4f3 | ||
|
|
1963e742e9 | ||
|
|
dbe8170185 | ||
|
|
180c0fe854 | ||
|
|
fe16b3e6f5 | ||
|
|
d890c166f0 | ||
|
|
4025b0518a | ||
|
|
d9e59fd6ae | ||
|
|
c0ef721111 | ||
|
|
1801351d64 | ||
|
|
7aa447c571 | ||
|
|
7660f83b26 | ||
|
|
0c9e6a3ee4 | ||
|
|
a82af6fd30 | ||
|
|
db94757c79 | ||
|
|
a5c4d7031e | ||
|
|
69c498a070 | ||
|
|
bfb599617d | ||
|
|
1bc5a3528a | ||
|
|
ffa60d5234 | ||
|
|
9644d6ec23 | ||
|
|
adef0032c3 | ||
|
|
1456b662d7 | ||
|
|
69dc4f61c9 | ||
|
|
b0fb665d6b | ||
|
|
34ccf7f38e | ||
|
|
4dc486e1bc | ||
|
|
ef6ecad149 | ||
|
|
543e9de6d5 | ||
|
|
fa61460388 |
@@ -0,0 +1,2 @@
|
||||
node_modules/*
|
||||
dist/*
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
dist/
|
||||
config/
|
||||
config/
|
||||
+4
-1
@@ -17,6 +17,7 @@
|
||||
}
|
||||
],
|
||||
"import/no-unresolved": 2,
|
||||
"max-len": [1, 120],
|
||||
"react/jsx-no-bind": 2,
|
||||
"react/no-did-mount-set-state": 0,
|
||||
"react/prefer-stateless-function": 2,
|
||||
@@ -28,6 +29,8 @@
|
||||
"prefer-object-spread/prefer-object-spread": 2,
|
||||
"prefer-arrow-callback": 2,
|
||||
"babel/no-invalid-this": 2,
|
||||
"lines-between-class-members": [2, "always"],
|
||||
"no-plusplus": 0,
|
||||
"babel/semi": [
|
||||
2,
|
||||
"never"
|
||||
@@ -43,4 +46,4 @@
|
||||
"prefer-object-spread",
|
||||
"babel"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4337
-3625
File diff suppressed because it is too large
Load Diff
+42
-37
@@ -15,7 +15,6 @@
|
||||
"lint:js": "eslint server ./ --ext .jsx,.js",
|
||||
"lintfix": "eslint server ./ --ext .jsx,.js --fix",
|
||||
"stats": "webpack --json --config webpack.config.production.js > stats.json",
|
||||
"prepush": "npm run lint",
|
||||
"pretest": "npm run lint",
|
||||
"test": "",
|
||||
"notest": "echo"
|
||||
@@ -24,79 +23,85 @@
|
||||
"main": "src/app.jsx",
|
||||
"devDependencies": {
|
||||
"babel-core": "6.26.0",
|
||||
"babel-eslint": "8.2.1",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-loader": "7.1.4",
|
||||
"babel-plugin-inline-json-import": "^0.2.1",
|
||||
"babel-plugin-inline-json-import": "^0.3.1",
|
||||
"babel-plugin-transform-async-to-generator": "^6.24.1",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-preset-latest": "^6.24.1",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"babel-runtime": "6.26.0",
|
||||
"case-sensitive-paths-webpack-plugin": "2.1.1",
|
||||
"concurrently": "^3.5.1",
|
||||
"css-loader": "0.28.7",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.1.2",
|
||||
"concurrently": "^4.1.0",
|
||||
"css-loader": "^1.0.1",
|
||||
"eslint": "^4.19.1",
|
||||
"eslint-config-airbnb": "^16.1.0",
|
||||
"eslint-loader": "1.9.0",
|
||||
"eslint-plugin-babel": "^4.1.2",
|
||||
"eslint-plugin-flowtype": "2.39.1",
|
||||
"eslint-plugin-import": "^2.10.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-loader": "^2.1.1",
|
||||
"eslint-plugin-babel": "^5.3.0",
|
||||
"eslint-plugin-flowtype": "^3.2.0",
|
||||
"eslint-plugin-import": "^2.14.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.1.2",
|
||||
"eslint-plugin-prefer-object-spread": "^1.2.1",
|
||||
"eslint-plugin-react": "^7.7.0",
|
||||
"eslint-plugin-react": "^7.11.1",
|
||||
"extract-text-webpack-plugin": "^v4.0.0-beta.0",
|
||||
"file-loader": "1.1.11",
|
||||
"glob": "^7.1.2",
|
||||
"file-loader": "^2.0.0",
|
||||
"glob": "^7.1.3",
|
||||
"html-webpack-plugin": "3.2.0",
|
||||
"html-webpack-template": "^6.2.0",
|
||||
"husky": "^0.14.3",
|
||||
"mini-css-extract-plugin": "^0.4.0",
|
||||
"optimize-css-assets-webpack-plugin": "^3.2.0",
|
||||
"postcss-cssnext": "^3.0.2",
|
||||
"postcss-flexbugs-fixes": "3.2.0",
|
||||
"postcss-loader": "^2.1.2",
|
||||
"style-loader": "^0.19.1",
|
||||
"stylelint": "^8.2.0",
|
||||
"husky": "^1.2.0",
|
||||
"mini-css-extract-plugin": "^0.4.5",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.1",
|
||||
"postcss-cssnext": "^3.1.0",
|
||||
"postcss-flexbugs-fixes": "^4.1.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"style-loader": "^0.23.1",
|
||||
"stylelint": "^9.9.0",
|
||||
"stylelint-config-standard": "^18.2.0",
|
||||
"sw-precache-webpack-plugin": "0.11.5",
|
||||
"uglifyjs-webpack-plugin": "^1.2.4",
|
||||
"webpack": "^4.4.1",
|
||||
"uglifyjs-webpack-plugin": "^2.0.1",
|
||||
"webpack": "^4.26.1",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-dev-server": "^3.1.10",
|
||||
"webpack-manifest-plugin": "^2.0.0-rc.2"
|
||||
"webpack-manifest-plugin": "^2.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "^4.1.1",
|
||||
"axios": "^0.18.0",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"dotenv": "4.0.0",
|
||||
"dotenv": "^6.1.0",
|
||||
"highcharts": "^6.1.0",
|
||||
"highcharts-react-official": "^1.1.0",
|
||||
"jStat": "^1.7.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash": "^4.17.5",
|
||||
"moment": "^2.22.0",
|
||||
"prop-types": "^15.6.1",
|
||||
"react": "^16.4.0",
|
||||
"react-datetime": "^2.14.0",
|
||||
"react-dom": "^16.4.0",
|
||||
"lodash": "^4.17.11",
|
||||
"moment": "^2.22.2",
|
||||
"path-to-regexp": "^2.4.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^16.6.3",
|
||||
"react-datetime": "^2.16.2",
|
||||
"react-dom": "^16.6.3",
|
||||
"react-highcharts": "^16.0.2",
|
||||
"react-hot-loader": "^4.3.4",
|
||||
"react-hot-loader": "^4.3.12",
|
||||
"react-jsx-highcharts": "^3.3.0",
|
||||
"react-localize-redux": "^2.16.0",
|
||||
"react-redux": "^5.0.7",
|
||||
"react-router": "^4.2.0",
|
||||
"react-router-dom": "^4.2.2",
|
||||
"react-router": "^4.3.1",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-scroll-to-component": "^1.0.2",
|
||||
"recharts": "^1.0.0-beta.10",
|
||||
"redux": "^3.7.2",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^3.0.1",
|
||||
"rimraf": "^2.6.2",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^0.83.0",
|
||||
"uuid": "^3.2.1"
|
||||
"uuid": "^3.3.2"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-push": "npm run lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import moment from 'moment'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import Datetime from 'react-datetime'
|
||||
import _ from 'lodash'
|
||||
import pathToRegexp from 'path-to-regexp'
|
||||
import { API_DATE_FORMAT, DISPLAY_DATE_FORMAT, TOKEN_NAME } from '../constants'
|
||||
import toskaLogo from '../assets/toska.png'
|
||||
import irtomikko from '../assets/irtomikko.png'
|
||||
@@ -143,3 +144,8 @@ export const copyToClipboard = (text) => {
|
||||
document.execCommand('copy')
|
||||
textField.remove()
|
||||
}
|
||||
|
||||
export const getCompiledPath = (template, parameters) => {
|
||||
const toPath = pathToRegexp.compile(template)
|
||||
return toPath(parameters)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ class CourseCodeMapper extends Component {
|
||||
handleCodeFilterChange = (e) => {
|
||||
this.setState({ codeFilter: e.target.value })
|
||||
}
|
||||
|
||||
handleNameFilterChange = (e) => {
|
||||
this.setState({ nameFilter: e.target.value })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Header, Segment, Table } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router'
|
||||
import { func, shape } from 'prop-types'
|
||||
|
||||
import { getCompiledPath } from '../../../common'
|
||||
import { routes } from '../../../constants'
|
||||
|
||||
import { callApi } from '../../../apiConnection'
|
||||
|
||||
const getCourseGroupPath = courseGroupId => getCompiledPath(routes.courseGroups.route, { courseGroupId })
|
||||
|
||||
class AggregateView extends Component {
|
||||
state = {
|
||||
isLoading: true,
|
||||
courseGroups: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
callApi('/courseGroups')
|
||||
.then((res) => {
|
||||
this.setState({
|
||||
courseGroups: res.data,
|
||||
isLoading: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
handleNavigation = (e, courseGroupId) => {
|
||||
e.preventDefault()
|
||||
this.props.history.push(getCourseGroupPath(courseGroupId))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, courseGroups } = this.state
|
||||
|
||||
return (
|
||||
<Segment loading={isLoading}>
|
||||
<Header size="medium">Group statistics</Header>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell content="Course group" />
|
||||
<Table.HeaderCell content="Credits" />
|
||||
<Table.HeaderCell content="Students" />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{courseGroups.map(courseGroup => (
|
||||
<Table.Row key={courseGroup.id}>
|
||||
<Table.Cell>
|
||||
<a
|
||||
href={getCourseGroupPath(courseGroup.id)}
|
||||
onClick={e => this.handleNavigation(e, courseGroup.id)}
|
||||
>
|
||||
{courseGroup.name}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell content={courseGroup.credits} />
|
||||
<Table.Cell content={courseGroup.students} />
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AggregateView.propTypes = {
|
||||
history: shape({
|
||||
push: func.isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
export default withRouter(AggregateView)
|
||||
@@ -0,0 +1,129 @@
|
||||
.headerWithControl {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1em !important;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
font-size: 1em !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.activeIconButton {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.groupStatistics {
|
||||
display: flex;
|
||||
justify-content: space-around !important;
|
||||
align-items: flex-end !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.groupStatistic {
|
||||
flex: 1 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.teacherActiveItem {
|
||||
background: var(--lightSilver);
|
||||
}
|
||||
|
||||
.courseInfoContent,
|
||||
.teacherItemStatistics,
|
||||
.courseHeaderContent {
|
||||
padding-top: 0.3em;
|
||||
padding-bottom: 0.3em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.courseHeaderContent {
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.courseHeaderContent > div {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.teacherName {
|
||||
font-weight: 700;
|
||||
font-size: 1.075em;
|
||||
}
|
||||
|
||||
.teacherCount {
|
||||
padding-left: 0.25em;
|
||||
}
|
||||
|
||||
.teacherCount,
|
||||
.teacherCode {
|
||||
color: var(--darkSilver);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.teacherItemBasicInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 33%;
|
||||
}
|
||||
|
||||
.courseInfoItem,
|
||||
.courseInfoCode,
|
||||
.courseHeaderCode,
|
||||
.courseHeaderItem {
|
||||
flex: 0 0 10%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.courseHeaderCode,
|
||||
.courseInfoCode {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.courseInfoName,
|
||||
.courseInfoTeacher,
|
||||
.courseHeaderTeacher,
|
||||
.courseHeaderName,
|
||||
.teacherHeaderName {
|
||||
flex: 0 0 33%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.teacherHeaderFilter {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.statisticControlItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.teacherStatisticNumber,
|
||||
.teacherHeaderItem {
|
||||
flex: 0 0 20%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.teacherStatisticNumber {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
flex: 0 0 20%;
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.activeToggleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.activeToggleContainer > label {
|
||||
color: var(--darkSilver);
|
||||
font-size: 0.75em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { Fragment, Component } from 'react'
|
||||
import { Header, List, Loader, Placeholder, Icon } from 'semantic-ui-react'
|
||||
import sortBy from 'lodash/sortBy'
|
||||
import { callApi } from '../../../apiConnection'
|
||||
import styles from './courseGroup.css'
|
||||
|
||||
const courseColumnTypes = {
|
||||
TEACHER: 'teachername',
|
||||
CODE: 'coursecode',
|
||||
NAME: 'coursenames',
|
||||
CREDITS: 'credits',
|
||||
STUDENTS: 'students'
|
||||
}
|
||||
|
||||
const CourseItem = ({ course }) => { // eslint-disable-line react/prop-types
|
||||
const { coursecode, teachername, credits, students, coursenames } = course
|
||||
const coursename = coursenames.fi
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Content className={styles.courseInfoContent}>
|
||||
<div className={styles.courseInfoName}>{coursename}</div>
|
||||
<div className={styles.courseInfoCode}>{coursecode}</div>
|
||||
<div className={styles.courseInfoTeacher}>{teachername}</div>
|
||||
<div className={styles.courseInfoItem}>{credits}</div>
|
||||
<div className={styles.courseInfoItem}>{students}</div>
|
||||
</List.Content>
|
||||
</List.Item>
|
||||
)
|
||||
}
|
||||
|
||||
class Courses extends Component {
|
||||
state = {
|
||||
isLoading: true,
|
||||
courses: [],
|
||||
sortColumn: courseColumnTypes.TEACHER,
|
||||
sortReverse: false
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.fetchCourses()
|
||||
}
|
||||
|
||||
async componentDidUpdate(prevProps) {
|
||||
const { teacherIds } = this.props
|
||||
if (prevProps.teacherIds.length !== teacherIds.length) {
|
||||
await this.fetchCourses()
|
||||
}
|
||||
}
|
||||
|
||||
onCourseHeaderClick = (columnName) => {
|
||||
const { sortColumn, sortReverse } = this.state
|
||||
const isSortColumn = columnName === sortColumn
|
||||
|
||||
this.setState({
|
||||
sortColumn: columnName,
|
||||
sortReverse: isSortColumn ? !sortReverse : sortReverse
|
||||
})
|
||||
}
|
||||
|
||||
fetchCourses = async () => {
|
||||
const { teacherIds } = this.props
|
||||
|
||||
this.setState(
|
||||
{ isLoading: true, courses: [] },
|
||||
() => callApi(`courseGroups/teachers/?teacherIds=${JSON.stringify(teacherIds)}`)
|
||||
.then(({ data: courses }) => this.setState({ isLoading: false, courses }))
|
||||
)
|
||||
}
|
||||
|
||||
renderPlaceholder = () => {
|
||||
const placeholderKeys = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
return (
|
||||
<Placeholder fluid>
|
||||
{placeholderKeys.map(k => <Placeholder.Line key={k} length="full" />)}
|
||||
</Placeholder>
|
||||
)
|
||||
}
|
||||
|
||||
renderListHeader = () => {
|
||||
const { sortColumn, sortReverse } = this.state
|
||||
|
||||
const getHeader = (className, label, columnName) => {
|
||||
const isActive = columnName === sortColumn
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={() => this.onCourseHeaderClick(columnName)}
|
||||
>
|
||||
{label}
|
||||
{isActive
|
||||
? <Icon name={`caret ${sortReverse ? 'down' : 'up'}`} />
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Header>
|
||||
<List.Content className={styles.courseHeaderContent}>
|
||||
{getHeader(styles.courseHeaderName, 'Name', courseColumnTypes.NAME)}
|
||||
{getHeader(styles.courseHeaderCode, 'Code', courseColumnTypes.CODE)}
|
||||
{getHeader(styles.courseHeaderTeacher, 'Teacher', courseColumnTypes.TEACHER)}
|
||||
{getHeader(styles.courseHeaderItem, 'Credits', courseColumnTypes.CREDITS)}
|
||||
{getHeader(styles.courseHeaderItem, 'Students', courseColumnTypes.STUDENTS)}
|
||||
</List.Content>
|
||||
</List.Header>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, courses, sortColumn, sortReverse } = this.state
|
||||
const sortedCourses = sortBy(courses, sortColumn)
|
||||
|
||||
if (sortReverse) {
|
||||
sortedCourses.reverse()
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Header size="medium" content="Courses" />
|
||||
<Loader active={isLoading} inline="centered" />
|
||||
{isLoading
|
||||
? this.renderPlaceholder()
|
||||
:
|
||||
<List celled>
|
||||
{this.renderListHeader()}
|
||||
{sortedCourses.map(c =>
|
||||
<CourseItem key={`${c.coursecode}-${c.teachercode}`} course={c} />)}
|
||||
</List>
|
||||
}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Courses
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { Component, Fragment } from 'react'
|
||||
import { Segment, Header, Button, Statistic } from 'semantic-ui-react'
|
||||
import { string, func, shape } from 'prop-types'
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
import { getCompiledPath } from '../../../common'
|
||||
import { routes } from '../../../constants'
|
||||
import { callApi } from '../../../apiConnection'
|
||||
import Teachers from './teachers'
|
||||
import Courses from './courses'
|
||||
|
||||
import styles from './courseGroup.css'
|
||||
|
||||
class CourseGroup extends Component {
|
||||
static propTypes = {
|
||||
groupId: string.isRequired,
|
||||
history: shape({
|
||||
push: func.isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
isLoading: true
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { groupId } = this.props
|
||||
|
||||
const response = await callApi(`courseGroups/${groupId}`)
|
||||
|
||||
const { name, totalCredits, totalStudents, totalCourses, teachers } = response.data
|
||||
this.setState({
|
||||
name,
|
||||
totalCredits,
|
||||
totalStudents,
|
||||
totalCourses,
|
||||
teachers,
|
||||
isLoading: false
|
||||
})
|
||||
}
|
||||
|
||||
onTeacherFilterClick = (teacherId) => {
|
||||
const { teachers } = this.state
|
||||
const newTeachers = [...teachers]
|
||||
const index = newTeachers.findIndex(t => t.id === teacherId)
|
||||
newTeachers[index].isActive = !newTeachers[index].isActive
|
||||
this.setState({ teachers: newTeachers })
|
||||
}
|
||||
|
||||
renderStatistics = () => {
|
||||
const { totalCredits, totalStudents, totalCourses, teachers, isLoading } = this.state
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalTeachers = teachers.length
|
||||
|
||||
const getStatistic = (label, value) => (
|
||||
<Statistic className={styles.groupStatistic}>
|
||||
<Statistic.Label>{label}</Statistic.Label>
|
||||
<Statistic.Value>{value}</Statistic.Value>
|
||||
</Statistic>
|
||||
)
|
||||
|
||||
return (
|
||||
<Statistic.Group className={styles.groupStatistics}>
|
||||
{getStatistic('Total teachers', totalTeachers)}
|
||||
{getStatistic('Total students', totalStudents)}
|
||||
{getStatistic('Total courses', totalCourses)}
|
||||
{getStatistic('Total credits', totalCredits)}
|
||||
</Statistic.Group>
|
||||
)
|
||||
}
|
||||
|
||||
renderTeachersAndCourses = () => {
|
||||
const { teachers, isLoading } = this.state
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getTeacherIds = teacers => teacers.map(t => t.id)
|
||||
|
||||
const activeTeachers = teachers.filter(t => t.isActive)
|
||||
const hasActiveTeachers = activeTeachers.length > 0
|
||||
const teacherIds = hasActiveTeachers
|
||||
? getTeacherIds(activeTeachers)
|
||||
: getTeacherIds(teachers)
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Teachers teachers={teachers} onFilterClickFn={this.onTeacherFilterClick} />
|
||||
<Courses teacherIds={teacherIds} />
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { history } = this.props
|
||||
const { isLoading, name } = this.state
|
||||
|
||||
const navigateTo = route => history.push(getCompiledPath(route, {}))
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Segment loading={isLoading}>
|
||||
<Header size="medium" className={styles.headerWithControl}>
|
||||
{name}
|
||||
<Button
|
||||
icon="reply"
|
||||
onClick={() => navigateTo(routes.courseGroups.route)}
|
||||
className={styles.iconButton}
|
||||
/>
|
||||
</Header>
|
||||
{this.renderStatistics()}
|
||||
</Segment>
|
||||
{this.renderTeachersAndCourses()}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(CourseGroup)
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { Fragment, Component } from 'react'
|
||||
import { Header, List, Button, Radio, Icon } from 'semantic-ui-react'
|
||||
import sortBy from 'lodash/sortBy'
|
||||
import { bool, func, shape, string, number } from 'prop-types'
|
||||
|
||||
import styles from './courseGroup.css'
|
||||
|
||||
const teacherColumnTypes = {
|
||||
NAME: 'name',
|
||||
CODE: 'code',
|
||||
COURSES: 'courses',
|
||||
CREDITS: 'credits'
|
||||
}
|
||||
|
||||
const TeacherItem = ({ teacher, onFilterClickFn }) => {
|
||||
const { name, code, id, isActive, courses, credits } = teacher
|
||||
|
||||
return (
|
||||
<List.Item className={`${isActive ? styles.teacherActiveItem : ''}`}>
|
||||
<List.Content className={styles.teacherItemStatistics}>
|
||||
<div className={styles.teacherItemBasicInfo}>
|
||||
<div className={styles.teacherName}>{name}</div>
|
||||
<div className={styles.teacherCode}>{`(${code})`}</div>
|
||||
</div>
|
||||
<div className={styles.teacherStatisticNumber}>{courses}</div>
|
||||
<div className={styles.teacherStatisticNumber}>{credits}</div>
|
||||
<div className={styles.statisticControlItem}>
|
||||
<Button
|
||||
icon="filter"
|
||||
className={`${isActive ? styles.activeIconButton : styles.iconButton}`}
|
||||
onClick={() => onFilterClickFn(id)}
|
||||
circular
|
||||
/>
|
||||
</div>
|
||||
</List.Content>
|
||||
</List.Item>
|
||||
)
|
||||
}
|
||||
|
||||
TeacherItem.propTypes = {
|
||||
onFilterClickFn: func.isRequired,
|
||||
teacher: shape({
|
||||
name: string,
|
||||
code: string,
|
||||
id: string,
|
||||
isActive: bool,
|
||||
courses: number,
|
||||
credits: number
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
class Teachers extends Component {
|
||||
state = {
|
||||
showOnlyActive: false,
|
||||
activeTeacherCount: 0,
|
||||
viewableTeachers: [],
|
||||
sortColumn: teacherColumnTypes.NAME,
|
||||
sortReverse: false
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
const { teachers } = props
|
||||
const { showOnlyActive } = state
|
||||
const activeTeachers = teachers.filter(t => t.isActive)
|
||||
const activeTeacherCount = activeTeachers.length
|
||||
const resetShowOnlyActive = showOnlyActive && activeTeacherCount === 0
|
||||
|
||||
if (!showOnlyActive || resetShowOnlyActive) {
|
||||
return {
|
||||
showOnlyActive: false,
|
||||
activeTeacherCount,
|
||||
viewableTeachers: teachers
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTeacherCount,
|
||||
viewableTeachers: activeTeachers
|
||||
}
|
||||
}
|
||||
|
||||
onActiveToggleChange = () => {
|
||||
const { showOnlyActive } = this.state
|
||||
this.setState({ showOnlyActive: !showOnlyActive })
|
||||
}
|
||||
|
||||
onTeacherHeaderClick = (columnName) => {
|
||||
const { sortColumn, sortReverse } = this.state
|
||||
const isSortColumn = columnName === sortColumn
|
||||
this.setState({
|
||||
sortColumn: columnName,
|
||||
sortReverse: isSortColumn ? !sortReverse : sortReverse
|
||||
})
|
||||
}
|
||||
|
||||
renderListHeader = () => {
|
||||
const { sortColumn, sortReverse } = this.state
|
||||
|
||||
const getHeader = (className, label, columnName) => {
|
||||
const isActive = columnName === sortColumn
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={() => this.onTeacherHeaderClick(columnName)}
|
||||
>
|
||||
{label}
|
||||
{isActive
|
||||
? <Icon name={`caret ${sortReverse ? 'down' : 'up'}`} />
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Header>
|
||||
<List.Content className={styles.courseHeaderContent}>
|
||||
{getHeader(styles.teacherHeaderName, 'Name', teacherColumnTypes.NAME)}
|
||||
{getHeader(styles.teacherHeaderItem, 'Courses', teacherColumnTypes.COURSES)}
|
||||
{getHeader(styles.teacherHeaderItem, 'Credits', teacherColumnTypes.CREDITS)}
|
||||
<div className={styles.teacherHeaderFilter}>Filter</div>
|
||||
</List.Content>
|
||||
</List.Header>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onFilterClickFn } = this.props
|
||||
const {
|
||||
viewableTeachers,
|
||||
activeTeacherCount,
|
||||
showOnlyActive,
|
||||
sortColumn,
|
||||
sortReverse
|
||||
} = this.state
|
||||
const toggleId = 'toggle'
|
||||
const sortedTeachers = sortBy(viewableTeachers, sortColumn)
|
||||
|
||||
if (sortReverse) {
|
||||
sortedTeachers.reverse()
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Header size="medium" className={styles.headerWithControl}>
|
||||
Teachers
|
||||
<div className={styles.activeToggleContainer}>
|
||||
<label htmlFor={toggleId}>Show only active</label>
|
||||
<Radio
|
||||
id={toggleId}
|
||||
toggle
|
||||
checked={showOnlyActive}
|
||||
onChange={this.onActiveToggleChange}
|
||||
disabled={activeTeacherCount === 0}
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<List celled>
|
||||
{this.renderListHeader()}
|
||||
{sortedTeachers.map(t =>
|
||||
<TeacherItem key={t.id} teacher={t} onFilterClickFn={onFilterClickFn} />)
|
||||
}
|
||||
</List>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Teachers.propTypes = {
|
||||
onFilterClickFn: func.isRequired
|
||||
}
|
||||
|
||||
export default Teachers
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import { Header, Segment } from 'semantic-ui-react'
|
||||
import { shape, string } from 'prop-types'
|
||||
|
||||
import AggregateView from './AggregateView'
|
||||
import CourseGroup from './CourseGroup'
|
||||
import sharedStyles from '../../styles/shared'
|
||||
|
||||
|
||||
const CourseGroups = ({ match }) => {
|
||||
const { params: { courseGroupId } } = match
|
||||
|
||||
return (
|
||||
<div className={sharedStyles.segmentContainer}>
|
||||
<Header className={sharedStyles.segmentTitle} size="large" content="Course groups" />
|
||||
<Segment className={sharedStyles.contentSegment}>
|
||||
{ courseGroupId
|
||||
? <CourseGroup groupId={courseGroupId} />
|
||||
: <AggregateView />
|
||||
}
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CourseGroups.propTypes = {
|
||||
match: shape({
|
||||
params: shape({
|
||||
courseGroupId: string
|
||||
}).isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
export default CourseGroups
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Header } from 'semantic-ui-react'
|
||||
import { shape, string, number, oneOfType, arrayOf } from 'prop-types'
|
||||
import SortableTable from '../SortableTable'
|
||||
import SortableTable from '../../../SortableTable'
|
||||
|
||||
const CumulativeTable = ({ stats, name }) => (
|
||||
<div>
|
||||
@@ -16,7 +16,8 @@ const CumulativeTable = ({ stats, name }) => (
|
||||
{
|
||||
key: 'PASSRATE',
|
||||
title: 'Pass rate',
|
||||
getRowVal: s => s.cumulative.categories.passed / (s.cumulative.categories.failed + s.cumulative.categories.passed),
|
||||
getRowVal: s => s.cumulative.categories.passed /
|
||||
(s.cumulative.categories.failed + s.cumulative.categories.passed),
|
||||
getRowContent: stat => `${Number((100 * stat.cumulative.categories.passed) /
|
||||
(stat.cumulative.categories.failed + stat.cumulative.categories.passed)).toFixed(2)} %`,
|
||||
cellProps: { width: 4 }
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import { arrayOf, number, oneOfType, shape, string } from 'prop-types'
|
||||
import { Header } from 'semantic-ui-react'
|
||||
import SortableTable from '../../../SortableTable'
|
||||
import { getGradeSpread } from '../util'
|
||||
|
||||
const getSortableColumn = (key, title, getRowVal, getRowContent) => (
|
||||
{
|
||||
key,
|
||||
title,
|
||||
getRowVal,
|
||||
getRowContent
|
||||
})
|
||||
|
||||
const getTableData = stats => stats.map(((stat) => {
|
||||
const { name, code, cumulative: { grades } } = stat
|
||||
const spread = getGradeSpread([grades])
|
||||
const attempts = Object.values(grades)
|
||||
.reduce((cur, acc) => (acc + cur), 0)
|
||||
return {
|
||||
name,
|
||||
code,
|
||||
attempts,
|
||||
...spread
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
const GradesTable = ({ stats, name }) => {
|
||||
const columns = [
|
||||
getSortableColumn('TIME', 'Time', s => s.code, s => s.name),
|
||||
getSortableColumn('ATTEMPTS', 'Attempts', s => s.attempts),
|
||||
getSortableColumn('0', '0', s => s['0']),
|
||||
getSortableColumn('1', '1', s => s['1']),
|
||||
getSortableColumn('2', '2', s => s['2']),
|
||||
getSortableColumn('3', '3', s => s['3']),
|
||||
getSortableColumn('4', '4', s => s['4']),
|
||||
getSortableColumn('5', '5', s => s['5']),
|
||||
getSortableColumn('OTHER_PASSED', 'Other passed', s => s['Hyv.'])
|
||||
]
|
||||
|
||||
const data = getTableData(stats)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header as="h3" content={name} textAlign="center" />
|
||||
<SortableTable
|
||||
getRowKey={s => s.code}
|
||||
tableProps={{ celled: true, structured: true }}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
GradesTable.propTypes = {
|
||||
stats: arrayOf(shape({})).isRequired,
|
||||
name: oneOfType([number, string]).isRequired
|
||||
}
|
||||
export default GradesTable
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Header } from 'semantic-ui-react'
|
||||
import { shape, string, number, oneOfType, arrayOf } from 'prop-types'
|
||||
import SortableTable from '../SortableTable'
|
||||
import SortableTable from '../../../SortableTable'
|
||||
|
||||
const formatPercentage = p => `${p.toFixed(2) * 100} %`
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import { Grid } from 'semantic-ui-react'
|
||||
import { gradeGraphOptions } from '../../../../constants'
|
||||
import {
|
||||
viewModeNames,
|
||||
graphSeriesTypes,
|
||||
getDataObject,
|
||||
getMaxValueOfSeries,
|
||||
dataSeriesType,
|
||||
viewModeType,
|
||||
getGradeSpread
|
||||
} from './util'
|
||||
import StackedBarChart from '../../../StackedBarChart'
|
||||
|
||||
|
||||
const getGradeSeries = (series, seriesType) => {
|
||||
const { name, multiplier } = seriesType
|
||||
const newSeries = getGradeSpread(series, multiplier)
|
||||
|
||||
return [
|
||||
getDataObject(`${name} 0`, newSeries[0], 'a'),
|
||||
getDataObject(`${name} 1`, newSeries[1], 'b'),
|
||||
getDataObject(`${name} 2`, newSeries[2], 'c'),
|
||||
getDataObject(`${name} 3`, newSeries[3], 'd'),
|
||||
getDataObject(`${name} 4`, newSeries[4], 'e'),
|
||||
getDataObject(`${name} 5`, newSeries[5], 'f'),
|
||||
getDataObject(`${name} Hyv.`, newSeries['Hyv.'], 'g')
|
||||
]
|
||||
}
|
||||
|
||||
const getGradeCumSeriesFromStats = (stats, seriesType) => {
|
||||
const series = stats.flatMap(s => s.cumulative.grades)
|
||||
return getGradeSeries(series, seriesType)
|
||||
}
|
||||
|
||||
const getGradeStudSeriesFromStats = (stats, seriesType) => {
|
||||
const series = stats.flatMap(s => s.students.grades)
|
||||
return getGradeSeries(series, seriesType)
|
||||
}
|
||||
|
||||
const Distribution = ({ primary, comparison, viewMode }) => {
|
||||
const isCumulativeMode = viewMode === viewModeNames.CUMULATIVE
|
||||
|
||||
const primaryStats = primary.stats
|
||||
const statYears = primaryStats.map(year => year.name)
|
||||
const comparisonStats = comparison ? comparison.stats : []
|
||||
|
||||
const gradeGraphSerieFn = isCumulativeMode ? getGradeCumSeriesFromStats : getGradeStudSeriesFromStats
|
||||
|
||||
|
||||
const gradeGraphSerie = [
|
||||
...gradeGraphSerieFn(primaryStats, graphSeriesTypes.PRIMARY),
|
||||
...gradeGraphSerieFn(comparisonStats, graphSeriesTypes.COMPARISON)
|
||||
]
|
||||
|
||||
const maxGradeValue = getMaxValueOfSeries(gradeGraphSerie)
|
||||
|
||||
const distributionOptions = gradeGraphOptions(statYears, maxGradeValue)
|
||||
|
||||
return (
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={distributionOptions}
|
||||
series={gradeGraphSerie}
|
||||
/>
|
||||
</Grid.Column>
|
||||
)
|
||||
}
|
||||
|
||||
Distribution.propTypes = {
|
||||
primary: dataSeriesType.isRequired,
|
||||
comparison: dataSeriesType,
|
||||
viewMode: viewModeType.isRequired
|
||||
}
|
||||
|
||||
Distribution.defaultProps = {
|
||||
comparison: undefined
|
||||
}
|
||||
|
||||
export default Distribution
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react'
|
||||
import { Grid } from 'semantic-ui-react'
|
||||
import StackedBarChart from '../../../StackedBarChart'
|
||||
import { passRateCumGraphOptions, passRateStudGraphOptions } from '../../../../constants'
|
||||
import {
|
||||
viewModeNames,
|
||||
graphSeriesTypes,
|
||||
getDataObject,
|
||||
getMaxValueOfSeries,
|
||||
dataSeriesType,
|
||||
viewModeType
|
||||
} from './util'
|
||||
|
||||
|
||||
const getPassRateCumSeriesFromStats = (stats, seriesType) => {
|
||||
const { name, multiplier } = seriesType
|
||||
const all = []
|
||||
const passed = []
|
||||
const failed = []
|
||||
|
||||
stats.forEach((year) => {
|
||||
const { passed: p, failed: f } = year.cumulative.categories
|
||||
all.push((p * multiplier) + (f * multiplier))
|
||||
passed.push(p * multiplier)
|
||||
failed.push(f * multiplier)
|
||||
})
|
||||
|
||||
return [
|
||||
getDataObject(`${name} all`, all, 'a'),
|
||||
getDataObject(`${name} passed`, passed, 'b'),
|
||||
getDataObject(`${name} failed`, failed, 'c')
|
||||
]
|
||||
}
|
||||
|
||||
const getPassRateStudSeriesFromStats = (stats, seriesType) => {
|
||||
const { name, multiplier } = seriesType
|
||||
|
||||
const all = []
|
||||
const failedFirst = []
|
||||
const failedRetry = []
|
||||
const passedFirst = []
|
||||
const passedRetry = []
|
||||
|
||||
stats.forEach((year) => {
|
||||
const {
|
||||
failedFirst: ff,
|
||||
failedRetry: fr,
|
||||
passedFirst: pf,
|
||||
passedRetry: pr
|
||||
} = year.students.categories
|
||||
|
||||
all.push((ff * multiplier) + (fr * multiplier) + (pf * multiplier) + (pr * multiplier))
|
||||
failedFirst.push(ff * multiplier)
|
||||
failedRetry.push(fr * multiplier)
|
||||
passedFirst.push(pf * multiplier)
|
||||
passedRetry.push(pr * multiplier)
|
||||
})
|
||||
|
||||
return [
|
||||
getDataObject(`${name} all`, all, 'a'),
|
||||
getDataObject(`${name} passed on first try`, passedFirst, 'b'),
|
||||
getDataObject(`${name} passed after retry`, passedRetry, 'b'),
|
||||
getDataObject(`${name} failed on first try`, failedFirst, 'c'),
|
||||
getDataObject(`${name} failed after retry`, failedRetry, 'c')
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
const PassRate = ({ primary, comparison, viewMode }) => {
|
||||
const isCumulativeMode = viewMode === viewModeNames.CUMULATIVE
|
||||
|
||||
const primaryStats = primary.stats
|
||||
const statYears = primaryStats.map(year => year.name)
|
||||
const comparisonStats = comparison ? comparison.stats : []
|
||||
const passGraphSerieFn = isCumulativeMode ? getPassRateCumSeriesFromStats : getPassRateStudSeriesFromStats
|
||||
|
||||
const passGraphSerie = [
|
||||
...passGraphSerieFn(primaryStats, graphSeriesTypes.PRIMARY),
|
||||
...passGraphSerieFn(comparisonStats, graphSeriesTypes.COMPARISON)
|
||||
]
|
||||
|
||||
const maxPassRateVal = getMaxValueOfSeries(passGraphSerie)
|
||||
const graphOptionsFn = isCumulativeMode ? passRateCumGraphOptions : passRateStudGraphOptions
|
||||
const graphOptions = graphOptionsFn(statYears, maxPassRateVal)
|
||||
|
||||
return (
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={graphOptions}
|
||||
series={passGraphSerie}
|
||||
/>
|
||||
</Grid.Column>
|
||||
)
|
||||
}
|
||||
|
||||
PassRate.propTypes = {
|
||||
primary: dataSeriesType.isRequired,
|
||||
comparison: dataSeriesType,
|
||||
viewMode: viewModeType.isRequired
|
||||
}
|
||||
|
||||
PassRate.defaultProps = {
|
||||
comparison: undefined
|
||||
}
|
||||
|
||||
export default PassRate
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import { Grid } from 'semantic-ui-react'
|
||||
import { dataSeriesType, viewModeNames, viewModeType } from './util'
|
||||
import CumulativeTable from './Tables/cumulative'
|
||||
import StudentTable from './Tables/student'
|
||||
import GradesTable from './Tables/grades'
|
||||
|
||||
const Tables = ({ primary, comparison, viewMode }) => {
|
||||
const getViewMode = (name, stats) => {
|
||||
switch (viewMode) {
|
||||
case viewModeNames.CUMULATIVE:
|
||||
return <CumulativeTable name={name} stats={stats} />
|
||||
case viewModeNames.STUDENT:
|
||||
return <StudentTable name={name} stats={stats} />
|
||||
case viewModeNames.GRADES:
|
||||
return <GradesTable name={name} stats={stats} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getTables = (series) => {
|
||||
const { name, stats } = series
|
||||
return (
|
||||
<Grid.Column>
|
||||
{getViewMode(name, stats)}
|
||||
</Grid.Column>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{primary && getTables(primary)}
|
||||
{comparison && getTables(comparison)}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
Tables.propTypes = {
|
||||
primary: dataSeriesType.isRequired,
|
||||
comparison: dataSeriesType,
|
||||
viewMode: viewModeType.isRequired
|
||||
}
|
||||
|
||||
Tables.defaultProps = {
|
||||
comparison: null
|
||||
}
|
||||
|
||||
export default Tables
|
||||
@@ -0,0 +1,63 @@
|
||||
import { arrayOf, number, oneOfType, shape, string, oneOf } from 'prop-types'
|
||||
|
||||
export const viewModeNames = {
|
||||
CUMULATIVE: 'Cumulative',
|
||||
STUDENT: 'Student',
|
||||
GRADES: 'Grades'
|
||||
}
|
||||
|
||||
export const graphSeriesTypes = {
|
||||
PRIMARY: { name: 'primary', multiplier: 1 },
|
||||
COMPARISON: { name: 'comparison', multiplier: -1 }
|
||||
}
|
||||
|
||||
export const getDataObject = (name, data, stack) => ({ name, data, stack })
|
||||
|
||||
export const getMaxValueOfSeries = series => Object.values(series).reduce((acc, cur) => {
|
||||
const curMax = Math.max(...cur.data.map(Math.abs))
|
||||
return curMax >= acc ? curMax : acc
|
||||
}, 0)
|
||||
|
||||
export const dataSeriesType = shape({
|
||||
name: string,
|
||||
code: oneOfType([string, number]),
|
||||
stats: arrayOf(shape({}))
|
||||
})
|
||||
|
||||
export const viewModeType = oneOf(Object.values(viewModeNames))
|
||||
|
||||
export const getGradeSpread = (series, multiplier = 1) => {
|
||||
const failedKeys = ['Eisa', 'Hyl.', '0', 'Luop']
|
||||
|
||||
const baseAccumalator = {
|
||||
0: [],
|
||||
1: [],
|
||||
2: [],
|
||||
3: [],
|
||||
4: [],
|
||||
5: [],
|
||||
'Hyv.': []
|
||||
}
|
||||
|
||||
return series.reduce((acc, cur, i) => {
|
||||
const currentEntries = Object.entries(cur)
|
||||
let failed = 0
|
||||
currentEntries.forEach(([k, v]) => {
|
||||
if (failedKeys.includes(k)) {
|
||||
failed += v
|
||||
} else {
|
||||
acc[k].push(v * multiplier)
|
||||
}
|
||||
})
|
||||
acc[0].push(failed * multiplier)
|
||||
Object.entries(acc)
|
||||
.forEach(([k, v]) => {
|
||||
if (v.length < i + 1) {
|
||||
acc[k].push(0)
|
||||
}
|
||||
})
|
||||
|
||||
return acc
|
||||
}, { ...baseAccumalator })
|
||||
}
|
||||
|
||||
@@ -1,350 +1,146 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Tab, Grid, Radio, Form } from 'semantic-ui-react'
|
||||
import { shape, string, number, oneOfType, arrayOf, func, bool } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import StackedBarChart from '../../StackedBarChart'
|
||||
import { passRateCumGraphOptions, passRateStudGraphOptions, gradeGraphOptions } from '../../../constants'
|
||||
import { userIsAdmin } from '../../../common'
|
||||
import CumulativeTable from './CumulativeTable'
|
||||
import StudentTable from './StudentTable'
|
||||
import { Tab, Grid, Radio, Menu } from 'semantic-ui-react'
|
||||
import { dataSeriesType, viewModeNames } from './Panes/util'
|
||||
import PassRate from './Panes/passRate'
|
||||
import Distribution from './Panes/distribution'
|
||||
import Tables from './Panes/tables'
|
||||
|
||||
const getPassRateCumSeriesFromStats = (stats, multiplier = 1, name = '') => {
|
||||
const all = []
|
||||
const passed = []
|
||||
const failed = []
|
||||
stats.forEach((year) => {
|
||||
const { passed: p, failed: f } = year.cumulative.categories
|
||||
all.push((p * multiplier) + (f * multiplier))
|
||||
passed.push(p * multiplier)
|
||||
failed.push(f * multiplier)
|
||||
})
|
||||
const series = [
|
||||
{
|
||||
name: `${name} all`,
|
||||
data: all,
|
||||
stack: 'a'
|
||||
},
|
||||
{
|
||||
name: `${name} passed`,
|
||||
data: passed,
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} failed`,
|
||||
data: failed,
|
||||
stack: 'c'
|
||||
}
|
||||
]
|
||||
return series
|
||||
import styles from './resultTabs.css'
|
||||
|
||||
|
||||
const paneViewIndex = {
|
||||
TABLE: 0,
|
||||
PASS_RAGE: 1,
|
||||
GRADE_DISTRIBUTION: 2
|
||||
}
|
||||
|
||||
const getPassRateStudSeriesFromStats = (stats, multiplier = 1, name = '') => {
|
||||
const all = []
|
||||
const failedFirst = []
|
||||
const failedRetry = []
|
||||
const passedFirst = []
|
||||
const passedRetry = []
|
||||
stats.forEach((year) => {
|
||||
const {
|
||||
failedFirst: ff,
|
||||
failedRetry: fr,
|
||||
passedFirst: pf,
|
||||
passedRetry: pr
|
||||
} = year.students.categories
|
||||
|
||||
all.push((ff * multiplier) + (fr * multiplier) + (pf * multiplier) + (pr * multiplier))
|
||||
failedFirst.push(ff * multiplier)
|
||||
failedRetry.push(fr * multiplier)
|
||||
passedFirst.push(pf * multiplier)
|
||||
passedRetry.push(pr * multiplier)
|
||||
})
|
||||
const series = [
|
||||
{
|
||||
name: `${name} all`,
|
||||
data: all,
|
||||
stack: 'a'
|
||||
},
|
||||
{
|
||||
name: `${name} passed on first try`,
|
||||
data: passedFirst,
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} passed after retry`,
|
||||
data: passedRetry,
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} failed on first try`,
|
||||
data: failedFirst,
|
||||
stack: 'c'
|
||||
},
|
||||
{
|
||||
name: `${name} failed after retry`,
|
||||
data: failedRetry,
|
||||
stack: 'c'
|
||||
}
|
||||
]
|
||||
return series
|
||||
}
|
||||
const getGradeCumSeriesFromStats = (stats, multiplier = 1, name = '') => {
|
||||
const zeros = []
|
||||
const grades = {}
|
||||
|
||||
stats.forEach((year) => {
|
||||
const fails = []
|
||||
Object.entries(year.cumulative.grades).forEach(([key, value]) => {
|
||||
if (['Eisa', 'Hyl.', '0', 'Luop'].includes(key)) {
|
||||
fails.push(value)
|
||||
}
|
||||
if (!grades[key]) {
|
||||
grades[key] = []
|
||||
}
|
||||
grades[key].push(value * multiplier)
|
||||
})
|
||||
zeros.push(_.sum(fails) * multiplier)
|
||||
})
|
||||
const seriesData = { 0: zeros, ...grades }
|
||||
const series = [
|
||||
{
|
||||
name: `${name} 0`,
|
||||
data: seriesData['0'],
|
||||
stack: 'a'
|
||||
},
|
||||
{
|
||||
name: `${name} 1`,
|
||||
data: seriesData['1'],
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} 2`,
|
||||
data: seriesData['2'],
|
||||
stack: 'c'
|
||||
},
|
||||
{
|
||||
name: `${name} 3`,
|
||||
data: seriesData['3'],
|
||||
stack: 'd'
|
||||
},
|
||||
{
|
||||
name: `${name} 4`,
|
||||
data: seriesData['4'],
|
||||
stack: 'e'
|
||||
},
|
||||
{
|
||||
name: `${name} 5`,
|
||||
data: seriesData['5'],
|
||||
stack: 'f'
|
||||
},
|
||||
{
|
||||
name: `${name} Hyv.`,
|
||||
data: seriesData['Hyv.'],
|
||||
stack: 'g'
|
||||
}
|
||||
]
|
||||
return series
|
||||
}
|
||||
|
||||
const getGradeStudSeriesFromStats = (stats, multiplier = 1, name = '') => {
|
||||
const zeros = []
|
||||
const grades = {}
|
||||
|
||||
stats.forEach((year) => {
|
||||
const fails = []
|
||||
Object.entries(year.students.grades).forEach(([key, value]) => {
|
||||
if (['Eisa', 'Hyl.', '0', 'Luop'].includes(key)) {
|
||||
fails.push(value)
|
||||
}
|
||||
if (!grades[key]) {
|
||||
grades[key] = []
|
||||
}
|
||||
grades[key].push(value * multiplier)
|
||||
})
|
||||
zeros.push(_.sum(fails) * multiplier)
|
||||
})
|
||||
const seriesData = { 0: zeros, ...grades }
|
||||
const series = [
|
||||
{
|
||||
name: `${name} 0`,
|
||||
data: seriesData['0'],
|
||||
stack: 'a'
|
||||
},
|
||||
{
|
||||
name: `${name} 1`,
|
||||
data: seriesData['1'],
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} 2`,
|
||||
data: seriesData['2'],
|
||||
stack: 'c'
|
||||
},
|
||||
{
|
||||
name: `${name} 3`,
|
||||
data: seriesData['3'],
|
||||
stack: 'd'
|
||||
},
|
||||
{
|
||||
name: `${name} 4`,
|
||||
data: seriesData['4'],
|
||||
stack: 'e'
|
||||
},
|
||||
{
|
||||
name: `${name} 5`,
|
||||
data: seriesData['5'],
|
||||
stack: 'f'
|
||||
},
|
||||
{
|
||||
name: `${name} Hyv.`,
|
||||
data: seriesData['Hyv.'],
|
||||
stack: 'g'
|
||||
}
|
||||
]
|
||||
return series
|
||||
}
|
||||
|
||||
class ResultTabs extends Component {
|
||||
state = {}
|
||||
|
||||
|
||||
async componentDidMount() {
|
||||
const adminRights = await userIsAdmin()
|
||||
|
||||
this.setState({ adminRights })
|
||||
state = {
|
||||
activeIndex: paneViewIndex.TABLE,
|
||||
viewMode: viewModeNames.CUMULATIVE
|
||||
}
|
||||
|
||||
getPanes = () => {
|
||||
const { primary, comparison } = this.props
|
||||
const { viewMode } = this.state
|
||||
|
||||
render() {
|
||||
const { max, primary, comparison, changeMode, cumMode } = this.props
|
||||
const { maxPassRateVal, maxGradeVal } = max
|
||||
const passGraphSerie = cumMode ?
|
||||
getPassRateCumSeriesFromStats(primary.stats, 1, 'primary').concat(getPassRateCumSeriesFromStats(comparison ? comparison.stats : [], -1, 'comparison'))
|
||||
:
|
||||
getPassRateStudSeriesFromStats(primary.stats, 1, 'primary').concat(getPassRateStudSeriesFromStats(comparison ? comparison.stats : [], -1, 'comparison'))
|
||||
const graphOptions = cumMode ?
|
||||
passRateCumGraphOptions(primary.stats.map(year =>
|
||||
year.name), maxPassRateVal)
|
||||
:
|
||||
passRateStudGraphOptions(primary.stats.map(year =>
|
||||
year.name), maxPassRateVal)
|
||||
|
||||
const gradeGraphSerie = cumMode ?
|
||||
getGradeCumSeriesFromStats(primary.stats, 1, 'primary').concat(getGradeCumSeriesFromStats(comparison ? comparison.stats : [], -1, 'comparison'))
|
||||
:
|
||||
getGradeStudSeriesFromStats(primary.stats, 1, 'primary').concat(getGradeCumSeriesFromStats(comparison ? comparison.stats : [], -1, 'comparison'))
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: { key: 'Table', icon: 'table', content: 'Table' },
|
||||
render: () => (
|
||||
<Grid padded="vertically" columns="equal">
|
||||
<Grid.Row>
|
||||
{primary && (
|
||||
<Grid.Column>
|
||||
{cumMode ?
|
||||
<CumulativeTable name={primary.name} stats={primary.stats} />
|
||||
:
|
||||
<StudentTable name={primary.name} stats={primary.stats} />
|
||||
}
|
||||
</Grid.Column>
|
||||
)}
|
||||
{
|
||||
comparison && (
|
||||
<Grid.Column>
|
||||
{cumMode ?
|
||||
<CumulativeTable name={comparison.name} stats={comparison.stats} />
|
||||
:
|
||||
<StudentTable name={comparison.name} stats={comparison.stats} />
|
||||
}
|
||||
</Grid.Column>
|
||||
)
|
||||
}
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
)
|
||||
const paneMenuItems = [
|
||||
{ menuItem: { key: 'Table', icon: 'table', content: 'Table' },
|
||||
renderFn: () =>
|
||||
(<Tables
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
viewMode={viewMode}
|
||||
/>)
|
||||
},
|
||||
{
|
||||
menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
|
||||
render: () => (
|
||||
<Grid padded="vertically" columns="equal">
|
||||
<Grid.Row>
|
||||
{primary && (
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={graphOptions}
|
||||
series={passGraphSerie}
|
||||
/>
|
||||
</Grid.Column>
|
||||
)}
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
)
|
||||
{ menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
|
||||
renderFn: () =>
|
||||
(<PassRate
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
viewMode={viewMode}
|
||||
/>)
|
||||
},
|
||||
{ menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
|
||||
renderFn: () =>
|
||||
(<Distribution
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
viewMode={viewMode}
|
||||
/>)
|
||||
}
|
||||
]
|
||||
|
||||
if (this.state.adminRights) {
|
||||
panes.push({
|
||||
menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
|
||||
return paneMenuItems.map((p) => {
|
||||
const { menuItem, renderFn } = p
|
||||
return {
|
||||
menuItem,
|
||||
render: () => (
|
||||
<Grid padded="vertically" columns="equal">
|
||||
<Grid.Row className={styles.modeSelectorRow}>
|
||||
{this.renderViewModeSelector()}
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
{primary && (
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={gradeGraphOptions(primary.stats.map(year =>
|
||||
year.name), maxGradeVal)}
|
||||
series={gradeGraphSerie}
|
||||
/>
|
||||
</Grid.Column>
|
||||
)}
|
||||
{renderFn()}
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleTabChange = (e, { activeIndex }) => {
|
||||
const { viewMode, activeIndex: oldIndex } = this.state
|
||||
const resetViewMode = oldIndex === paneViewIndex.TABLE
|
||||
&& viewMode === viewModeNames.GRADES
|
||||
|
||||
this.setState({
|
||||
activeIndex,
|
||||
viewMode: resetViewMode ? viewModeNames.CUMULATIVE : viewMode
|
||||
})
|
||||
}
|
||||
|
||||
handleModeChange = (viewMode) => {
|
||||
this.setState({ viewMode })
|
||||
}
|
||||
|
||||
renderViewModeSelector = () => {
|
||||
const { activeIndex, viewMode } = this.state
|
||||
|
||||
const isTogglePane = activeIndex !== 0
|
||||
|
||||
const getButtonMenu = () => (
|
||||
<Menu secondary>
|
||||
{Object.values(viewModeNames).map(name => (
|
||||
<Menu.Item
|
||||
key={name}
|
||||
name={name}
|
||||
active={viewMode === name}
|
||||
onClick={() => this.handleModeChange(name)}
|
||||
/>))}
|
||||
</Menu>
|
||||
)
|
||||
|
||||
const getToggle = () => {
|
||||
const isToggleChecked = viewMode === viewModeNames.STUDENT
|
||||
const newMode = isToggleChecked ? viewModeNames.CUMULATIVE : viewModeNames.STUDENT
|
||||
const toggleId = 'viewModeToggle'
|
||||
return (
|
||||
<div className={styles.toggleContainer}>
|
||||
<label className={styles.toggleLabel} htmlFor={toggleId}>{viewModeNames.CUMULATIVE}</label>
|
||||
<Radio
|
||||
id={toggleId}
|
||||
checked={isToggleChecked}
|
||||
toggle
|
||||
onChange={() => this.handleModeChange(newMode)}
|
||||
/>
|
||||
<label className={styles.toggleLabel} htmlFor={toggleId}>{viewModeNames.STUDENT}</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.modeSelectorContainer}>
|
||||
{isTogglePane ? getToggle() : getButtonMenu()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
<Form.Group inline >
|
||||
<Form.Field>
|
||||
<label>Cumulative</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
toggle
|
||||
onChange={changeMode}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Student</label>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<Tab
|
||||
panes={panes}
|
||||
panes={this.getPanes()}
|
||||
onTabChange={this.handleTabChange}
|
||||
/>
|
||||
</div>)
|
||||
}
|
||||
}
|
||||
|
||||
ResultTabs.propTypes = {
|
||||
primary: shape({
|
||||
name: string,
|
||||
code: oneOfType([string, number]),
|
||||
stats: arrayOf(shape({}))
|
||||
}).isRequired,
|
||||
comparison: shape({
|
||||
name: string,
|
||||
code: oneOfType([string, number]),
|
||||
stats: arrayOf(shape({}))
|
||||
}),
|
||||
max: shape({
|
||||
maxPassRateVal: number,
|
||||
maxGradeVal: number
|
||||
}).isRequired,
|
||||
changeMode: func.isRequired,
|
||||
cumMode: bool.isRequired
|
||||
primary: dataSeriesType.isRequired,
|
||||
comparison: dataSeriesType
|
||||
}
|
||||
|
||||
ResultTabs.defaultProps = {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.modeSelectorRow {
|
||||
margin-top: 1em !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.modeSelectorContainer {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
cursor: pointer;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
@@ -18,20 +18,51 @@ const INITIAL = {
|
||||
toYear: undefined,
|
||||
separate: false,
|
||||
discipline: undefined,
|
||||
type: undefined
|
||||
type: undefined,
|
||||
prefilled: false
|
||||
}
|
||||
|
||||
|
||||
class SearchForm extends Component {
|
||||
state = {
|
||||
...INITIAL
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
const isFirstLoad = !state.prefilled && !props.pending && props.preselectedCourse
|
||||
if (isFirstLoad) {
|
||||
const { code, start, end } = props.preselectedCourse
|
||||
|
||||
const getMatchingYearSelection = (year) => {
|
||||
const matchingYear = year && props.years.find(y => y.text.startsWith(year.toString()))
|
||||
return matchingYear ? matchingYear.value : undefined
|
||||
}
|
||||
const fromYear = getMatchingYearSelection(start)
|
||||
const toYear = getMatchingYearSelection(end)
|
||||
|
||||
return {
|
||||
coursecode: code,
|
||||
fromYear,
|
||||
toYear
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.getSemesters()
|
||||
this.props.clearCourses()
|
||||
}
|
||||
|
||||
toggleCourse = (course) => {
|
||||
componentDidUpdate() {
|
||||
const { prefilled } = this.state
|
||||
const { preselectedCourse } = this.props
|
||||
if (!prefilled && preselectedCourse) {
|
||||
this.handlePrefilledLoad()
|
||||
}
|
||||
}
|
||||
|
||||
onSelectCourse = (course) => {
|
||||
course.selected = !course.selected
|
||||
const { selectedcourses } = this.state
|
||||
const isSelected = !!selectedcourses[course.code]
|
||||
@@ -48,16 +79,23 @@ class SearchForm extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (e, target) => {
|
||||
const { name, value } = target
|
||||
this.setState({ [name]: value })
|
||||
}
|
||||
|
||||
toggleCheckbox = (e, target) => {
|
||||
onToggleCheckbox = (e, target) => {
|
||||
const { name } = target
|
||||
this.setState({ [name]: !this.state[name] })
|
||||
}
|
||||
|
||||
onSubmitFormClick = async () => {
|
||||
const { fromYear, toYear, selectedcourses, separate } = this.state
|
||||
const params = {
|
||||
fromYear,
|
||||
toYear,
|
||||
courseCodes: Object.keys(selectedcourses),
|
||||
separate
|
||||
}
|
||||
|
||||
await this.props.getCourseStats(params)
|
||||
}
|
||||
|
||||
fetchCourses = () => {
|
||||
const { coursename: name, coursecode: code } = this.state
|
||||
|
||||
@@ -74,29 +112,38 @@ class SearchForm extends Component {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
submitForm = async () => {
|
||||
const { fromYear, toYear, selectedcourses, separate } = this.state
|
||||
const params = {
|
||||
fromYear,
|
||||
toYear,
|
||||
courseCodes: Object.keys(selectedcourses),
|
||||
separate
|
||||
}
|
||||
await this.props.getCourseStats(params)
|
||||
handlePrefilledLoad = () => {
|
||||
this.setState(
|
||||
{ prefilled: true },
|
||||
() => this.fetchCourses()
|
||||
)
|
||||
}
|
||||
|
||||
handleChange = (e, target) => {
|
||||
const { name, value } = target
|
||||
this.setState({ [name]: value })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { years, loading, matchingCourses } = this.props
|
||||
const { selectedcourses, fromYear, toYear, separate, coursename, coursecode } = this.state
|
||||
const { years, isLoading, matchingCourses } = this.props
|
||||
const {
|
||||
selectedcourses,
|
||||
fromYear,
|
||||
toYear,
|
||||
separate,
|
||||
coursename,
|
||||
coursecode
|
||||
} = this.state
|
||||
|
||||
const courses = matchingCourses.filter(c => !selectedcourses[c.code])
|
||||
|
||||
const disabled = (!fromYear || Object.keys(selectedcourses).length === 0)
|
||||
const disabled = (!fromYear || Object.keys(selectedcourses).length === 0) || isLoading
|
||||
const selected = Object.values(selectedcourses).map(course => ({ ...course, selected: true }))
|
||||
const noSelectedCourses = selected.length === 0
|
||||
const noQueryStrings = !coursename && !coursecode
|
||||
|
||||
return (
|
||||
<Segment loading={loading}>
|
||||
<Segment loading={isLoading}>
|
||||
<Form>
|
||||
<Header content="Search parameters" as="h3" />
|
||||
<Form.Group widths="equal">
|
||||
@@ -122,14 +169,14 @@ class SearchForm extends Component {
|
||||
<Form.Checkbox
|
||||
label="Separate statistics for Spring and Fall semesters"
|
||||
name="separate"
|
||||
onChange={this.toggleCheckbox}
|
||||
onChange={this.onToggleCheckbox}
|
||||
checked={separate}
|
||||
/>
|
||||
<CourseTable
|
||||
title="Selected courses"
|
||||
hidden={noSelectedCourses}
|
||||
courses={selected}
|
||||
onSelectCourse={this.toggleCourse}
|
||||
onSelectCourse={this.onSelectCourse}
|
||||
controlIcon="remove"
|
||||
/>
|
||||
<Form.Button
|
||||
@@ -139,7 +186,7 @@ class SearchForm extends Component {
|
||||
basic
|
||||
positive
|
||||
content="Fetch statistics"
|
||||
onClick={this.submitForm}
|
||||
onClick={this.onSubmitFormClick}
|
||||
/>
|
||||
<Header content="Search for courses" />
|
||||
<div
|
||||
@@ -172,10 +219,10 @@ class SearchForm extends Component {
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
<CourseTable
|
||||
hidden={noQueryStrings}
|
||||
hidden={noQueryStrings || isLoading}
|
||||
courses={courses}
|
||||
title="Searched courses"
|
||||
onSelectCourse={this.toggleCourse}
|
||||
onSelectCourse={this.onSelectCourse}
|
||||
controlIcon="plus"
|
||||
/>
|
||||
</div>
|
||||
@@ -192,13 +239,20 @@ SearchForm.propTypes = {
|
||||
clearCourses: func.isRequired,
|
||||
matchingCourses: arrayOf(shape({})).isRequired,
|
||||
years: arrayOf(shape({})).isRequired,
|
||||
loading: bool.isRequired,
|
||||
coursesLoading: bool.isRequired
|
||||
isLoading: bool.isRequired,
|
||||
coursesLoading: bool.isRequired,
|
||||
preselectedCourse: shape({})
|
||||
}
|
||||
|
||||
SearchForm.defaultProps = {
|
||||
preselectedCourse: null
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const { years = [] } = state.semesters.data
|
||||
const { pending } = state.courseStats
|
||||
const { pending, data } = state.courseStatistics
|
||||
const preselectedCourse = data[0]
|
||||
const { pending: courseStatsPending } = state.courseStats
|
||||
return {
|
||||
matchingCourses: getCourseSearchResults(state),
|
||||
years: Object.values(years).map(({ yearcode, yearname }) => ({
|
||||
@@ -206,7 +260,8 @@ const mapStateToProps = (state) => {
|
||||
text: yearname,
|
||||
value: yearcode
|
||||
})).reverse(),
|
||||
loading: pending,
|
||||
isLoading: pending || courseStatsPending,
|
||||
preselectedCourse,
|
||||
coursesLoading: state.courseSearch.pending
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Segment, Header, Form } from 'semantic-ui-react'
|
||||
import { shape, string, arrayOf, objectOf, oneOfType, number } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import { connect } from 'react-redux'
|
||||
import ResultTabs from '../ResultTabs'
|
||||
import ProgrammeDropdown from '../ProgrammeDropdown'
|
||||
@@ -18,22 +17,9 @@ const countFilteredStudents = (stat, filter) => Object.entries(stat).reduce((acc
|
||||
class SingleCourseStats extends Component {
|
||||
state = {
|
||||
primary: ALL.value,
|
||||
comparison: null,
|
||||
cumMode: true
|
||||
comparison: null
|
||||
}
|
||||
|
||||
getMax = stats => ({
|
||||
maxPassRateVal: _.max(stats.map(year =>
|
||||
year.attempts.classes.passed.concat(year.attempts.classes.failed).length)),
|
||||
maxGradeVal: _.max(_.max(stats.map(year =>
|
||||
Object.values(Object.entries(year.attempts.grades).reduce((acc, [key, value]) => {
|
||||
if (['Eisa', 'Hyl.', '0', 'Luop'].includes(key)) {
|
||||
return { ...acc, 0: acc['0'] + value.length }
|
||||
}
|
||||
return { ...acc, [key]: value.length }
|
||||
}, { 0: 0 })))))
|
||||
})
|
||||
|
||||
getProgrammeName = (progcode) => {
|
||||
if (progcode === ALL.value) {
|
||||
return 'All'
|
||||
@@ -104,15 +90,11 @@ class SingleCourseStats extends Component {
|
||||
const comparison = !programmes[c] ? undefined : c
|
||||
return { primary, comparison }
|
||||
}
|
||||
changeMode = () => {
|
||||
this.setState({ cumMode: !this.state.cumMode })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { stats, programmes } = this.props
|
||||
const { programmes } = this.props
|
||||
const { primary, comparison } = this.selectedProgrammes()
|
||||
const statistics = this.filteredProgrammeStatistics()
|
||||
const max = this.getMax(stats.statistics)
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
@@ -140,11 +122,8 @@ class SingleCourseStats extends Component {
|
||||
</Form>
|
||||
</Segment>
|
||||
<ResultTabs
|
||||
max={max}
|
||||
primary={statistics.primary}
|
||||
comparison={statistics.comparison}
|
||||
changeMode={this.changeMode}
|
||||
cumMode={this.state.cumMode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ class OodikoneLine extends Line {
|
||||
constructor(props) { // eslint-disable-line
|
||||
super(props)
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return this.props.hide !== nextProps.hide
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ class CreditAccumulationGraph extends Component {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
xAxisFormatter = (startDate, month) => (moment(startDate).add(month, 'months').format('MMM YY'))
|
||||
|
||||
render() {
|
||||
|
||||
@@ -23,6 +23,7 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
options: []
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { students } = this.props
|
||||
await this.getMoreCreditLines(students)
|
||||
@@ -126,6 +127,7 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getStudentCourseData = (student) => {
|
||||
const { studentNumber, courses, studyrightStart, started } = student
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { Component } from 'react'
|
||||
import { withRouter, Redirect } from 'react-router-dom'
|
||||
import { Button, Icon, Card, Header, Segment, Confirm } from 'semantic-ui-react'
|
||||
import { Button, Icon, Header, Segment, Confirm } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, shape, string, bool, arrayOf } from 'prop-types'
|
||||
import { getTranslate, getActiveLanguage } from 'react-localize-redux'
|
||||
@@ -10,6 +10,7 @@ import { makeSortUsers } from '../../selectors/users'
|
||||
import { copyToClipboard } from '../../common'
|
||||
import sharedStyles from '../../styles/shared'
|
||||
import UserPageNew from '../UserPage'
|
||||
import SortableTable from '../CourseStatistics/SortableTable'
|
||||
|
||||
class EnableUsers extends Component {
|
||||
state = {
|
||||
@@ -63,14 +64,14 @@ class EnableUsers extends Component {
|
||||
renderUserSearchList = () => {
|
||||
const { users, error } = this.props
|
||||
return error ? null : (
|
||||
<Card.Group itemsPerRow={4}>
|
||||
<div>
|
||||
{this.state.confirm ? <Confirm
|
||||
style={{
|
||||
marginTop: 'auto !important',
|
||||
display: 'inline-block !important',
|
||||
position: 'relative',
|
||||
top: '20%',
|
||||
left: '33%'
|
||||
position: 'relative'
|
||||
// top: '20%',
|
||||
// left: '33%'
|
||||
}}
|
||||
open={this.state.confirm}
|
||||
cancelButton="no"
|
||||
@@ -83,35 +84,89 @@ class EnableUsers extends Component {
|
||||
}}
|
||||
size="small"
|
||||
/> : null}
|
||||
{users.map(user => (
|
||||
<Card raised key={user.id} color={user.is_enabled ? 'green' : 'red'}>
|
||||
<Card.Content>
|
||||
<Card.Header content={user.full_name} />
|
||||
<Card.Meta content={user.username} />
|
||||
<Card.Meta content={user.email} />
|
||||
<Card.Description>
|
||||
{`Access to oodikone: ${user.is_enabled ? 'En' : 'Dis'}abled`}
|
||||
</Card.Description>
|
||||
</Card.Content>
|
||||
<Card.Content extra>
|
||||
<Button.Group compact widths={8}>
|
||||
<Button animated basic size="mini" disabled={!user.is_enabled} onClick={this.openEditUserPage(user.id)}>
|
||||
<Button.Content hidden>Edit</Button.Content>
|
||||
<Button.Content visible>
|
||||
<Icon name="wrench" />
|
||||
</Button.Content>
|
||||
</Button>
|
||||
<Button animated basic onClick={this.enableUser(user)} size="mini">
|
||||
<Button.Content hidden>{user.is_enabled ? 'Disable' : 'Enable'}</Button.Content>
|
||||
<Button.Content visible>
|
||||
<Icon color={user.is_enabled ? 'green' : 'red'} name={user.is_enabled ? 'check' : 'remove'} />
|
||||
</Button.Content>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
))}
|
||||
</Card.Group>
|
||||
<SortableTable
|
||||
getRowKey={user => user.id}
|
||||
tableProps={{ celled: true, structured: true }}
|
||||
columns={[
|
||||
{
|
||||
key: 'NAME',
|
||||
title: 'Name',
|
||||
getRowVal: (user) => {
|
||||
const nameparts = user.full_name.split(' ')
|
||||
return nameparts[nameparts.length - 1]
|
||||
},
|
||||
getRowContent: user => user.full_name,
|
||||
cellProps: { singleLine: true }
|
||||
}, {
|
||||
key: 'USERNAME',
|
||||
title: 'Username',
|
||||
getRowVal: user => user.username,
|
||||
cellProps: { singleLine: true }
|
||||
}, {
|
||||
key: 'ROLE',
|
||||
title: 'Role',
|
||||
getRowVal: (user) => {
|
||||
// TODO: visual representation
|
||||
if (user.admin && user.czar) return 'admin czar'
|
||||
if (user.admin) return 'admin'
|
||||
if (user.czar) return 'czar'
|
||||
return 'user'
|
||||
},
|
||||
cellProps: { singleLine: true }
|
||||
}, {
|
||||
key: 'STUDYTRACKS',
|
||||
title: 'Studytracks',
|
||||
getRowVal: (user) => {
|
||||
// TODO: language handling
|
||||
// const { currentLanguage } = this.props
|
||||
const language = 0
|
||||
const nameInLanguage = element =>
|
||||
element.name[language]
|
||||
|| element.name.fi
|
||||
|| element.name.en
|
||||
|| element.name.sv
|
||||
|
||||
if (!user.elementdetails || user.elementdetails.length === 0) return null
|
||||
if (user.elementdetails.length >= 2) {
|
||||
return `${nameInLanguage(user.elementdetails[0])} +${user.elementdetails.length - 1} others`
|
||||
}
|
||||
return nameInLanguage(user.elementdetails[0])
|
||||
},
|
||||
cellProps: { singleLine: true }
|
||||
}, {
|
||||
key: 'OODIACCESS',
|
||||
title: 'Access to oodikone',
|
||||
getRowVal: user => user.is_enabled,
|
||||
getRowContent: user => (
|
||||
<Button.Group compact widths={2}>
|
||||
<Button animated basic onClick={this.enableUser(user)} size="mini">
|
||||
<Button.Content hidden>{user.is_enabled ? 'Disable' : 'Enable'}</Button.Content>
|
||||
<Button.Content visible>
|
||||
<Icon color={user.is_enabled ? 'green' : 'red'} name={user.is_enabled ? 'check' : 'remove'} />
|
||||
</Button.Content>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
),
|
||||
cellProps: { singleLine: true }
|
||||
}, {
|
||||
key: 'EDIT',
|
||||
getRowVal: user => (
|
||||
<Button.Group compact widths={2}>
|
||||
<Button animated basic size="mini" disabled={!user.is_enabled} onClick={this.openEditUserPage(user.id)}>
|
||||
<Button.Content hidden>Edit</Button.Content>
|
||||
<Button.Content visible>
|
||||
<Icon name="wrench" />
|
||||
</Button.Content>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
),
|
||||
cellProps: { singleLine: true },
|
||||
headerProps: { onClick: null, sorted: null }
|
||||
}
|
||||
]}
|
||||
data={users}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class LanguageChooser extends Component {
|
||||
this.props.switchLanguage(name, value)
|
||||
await setUserLanguage(value)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Dropdown
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
// import { connect } from 'react-redux'
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
|
||||
import { localize } from 'react-localize-redux'
|
||||
import { Loader, Image, Transition } from 'semantic-ui-react'
|
||||
@@ -11,6 +11,7 @@ import WelcomePage from '../WelcomePage'
|
||||
import StudentStatistics from '../StudentStatistics'
|
||||
import CourseStatistics from '../CourseStatistics'
|
||||
import EnableUsers from '../EnableUsers'
|
||||
import CourseGroups from '../CourseGroups'
|
||||
import Settings from '../Settings'
|
||||
import ErrorContainer from '../ErrorContainer'
|
||||
import { routes, BASE_PATH, hiddenRoutes } from '../../constants'
|
||||
@@ -49,6 +50,19 @@ class Main extends Component {
|
||||
this.ping()
|
||||
}
|
||||
|
||||
componentDidCatch = async (e) => {
|
||||
// const { reduxState } = this.props
|
||||
const name = await getUserName()
|
||||
Sentry.configureScope((scope) => {
|
||||
// Object.keys(reduxState).forEach((key) => {
|
||||
// scope.setExtra(key, JSON.stringify(reduxState[key]))
|
||||
// })
|
||||
scope.setUser({ username: name })
|
||||
})
|
||||
Sentry.captureException(e)
|
||||
this.setState({ hasError: true, loaded: true })
|
||||
}
|
||||
|
||||
setNetworkError = () => this.setState({ guide: 'Oodikone is unable to connect. Double check that you\'re in eduroam or have pulse security on. Refresh by pressing F5.', networkError: true })
|
||||
|
||||
ping = async () => {
|
||||
@@ -65,19 +79,6 @@ class Main extends Component {
|
||||
setTimeout(this.ping, this.state.networkError ? 2000 : 30000)
|
||||
}
|
||||
|
||||
componentDidCatch = async (e) => {
|
||||
const { reduxState } = this.props
|
||||
const name = await getUserName()
|
||||
Sentry.configureScope(scope => {
|
||||
Object.keys(reduxState).forEach(key => {
|
||||
scope.setExtra(key, JSON.stringify(reduxState[key]))
|
||||
})
|
||||
scope.setUser({ "username": name })
|
||||
})
|
||||
Sentry.captureException(e)
|
||||
this.setState({ hasError: true, loaded: true })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.loaded) {
|
||||
return <Loader active inline="centered" />
|
||||
@@ -107,6 +108,7 @@ class Main extends Component {
|
||||
<Route exact path={routes.populations.route} component={Populations} />
|
||||
<Route exact path={routes.students.route} component={StudentStatistics} />
|
||||
<Route exact path={routes.courseStatistics.route} component={CourseStatistics} />
|
||||
<Route exact path={routes.courseGroups.route} component={CourseGroups} />
|
||||
<Route exact path={routes.settings.route} component={Settings} />
|
||||
<Route exact path={routes.users.route} component={EnableUsers} />
|
||||
<Route exact path={routes.teachers.route} component={Teachers} />
|
||||
@@ -121,10 +123,10 @@ class Main extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
reduxState: state
|
||||
})
|
||||
// const mapStateToProps = state => ({
|
||||
// reduxState: state
|
||||
// })
|
||||
|
||||
|
||||
export default connect(mapStateToProps)(localize(Main, 'locale'))
|
||||
|
||||
// export default connect(mapStateToProps)(localize(Main, 'locale'))
|
||||
export default localize(Main, 'locale')
|
||||
|
||||
@@ -9,4 +9,3 @@
|
||||
.logoText {
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint eqeqeq: 1, react/prop-types: 1, quote-props: 0, prefer-const: 0 */
|
||||
import React, { PureComponent } from 'react'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import Highcharts from 'highcharts'
|
||||
@@ -23,9 +24,9 @@ class ClusterGraph extends PureComponent {
|
||||
maxX: 0,
|
||||
maxY: 0
|
||||
}
|
||||
componentDidMount() {
|
||||
|
||||
const { name, points, students, grades } = this.props.data.course
|
||||
componentDidMount() {
|
||||
const { name, points, students, grades } = this.props.data.course // eslint-disable-line
|
||||
const minY = _.min(points.map(p => p[1]))
|
||||
const maxY = _.max(points.map(p => p[1]))
|
||||
const minX = _.min(points.map(p => p[0]))
|
||||
@@ -44,10 +45,12 @@ class ClusterGraph extends PureComponent {
|
||||
setActive = (series) => {
|
||||
console.log(series)
|
||||
}
|
||||
|
||||
setName = (series) => {
|
||||
console.log(series.target)
|
||||
this.setState({ name: this.state.series[series.target.name].data.name[series.target.index] })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { series, minX, minY, maxX, maxY } = this.state
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import Course from './Course'
|
||||
|
||||
class ContentTabs extends Component {
|
||||
state={}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Tab
|
||||
|
||||
@@ -4,22 +4,20 @@ import CoursePage from './CoursePage'
|
||||
|
||||
class Course extends Component {
|
||||
state={
|
||||
selected: undefined
|
||||
selected: undefined
|
||||
}
|
||||
|
||||
setSelected = (coursecode) => {
|
||||
this.setState({
|
||||
selected: coursecode
|
||||
})
|
||||
}
|
||||
|
||||
this.setState({
|
||||
selected: coursecode
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.selected
|
||||
? <CoursePage course={this.state.selected} goBack={() => this.setSelected(undefined)}/>
|
||||
: <CourseSearch onClickResult={this.setSelected}/>
|
||||
return this.state.selected
|
||||
? <CoursePage course={this.state.selected} goBack={() => this.setSelected(undefined)} />
|
||||
: <CourseSearch onClickResult={this.setSelected} />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Course
|
||||
export default Course
|
||||
|
||||
@@ -1,15 +1,89 @@
|
||||
import React from 'react'
|
||||
import { shape } from 'prop-types'
|
||||
import React, { Component } from 'react'
|
||||
import { string, shape, arrayOf, number, func } from 'prop-types'
|
||||
import {
|
||||
HighchartsChart, withHighcharts, XAxis, YAxis, SplineSeries, Legend, Tooltip
|
||||
} from 'react-jsx-highcharts'
|
||||
import Highcharts from 'highcharts'
|
||||
|
||||
const CourseGradeGraph = ({ data }) => {
|
||||
console.log(data)
|
||||
const FONTSIZE = 20
|
||||
|
||||
class CourseGradeGraph extends Component {
|
||||
state={
|
||||
focused: undefined
|
||||
}
|
||||
|
||||
handleLegendClick = (event) => {
|
||||
const { name } = event.target
|
||||
event.preventDefault()
|
||||
this.setState({
|
||||
focused: (this.state.focused !== name) ? name : undefined
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { height, categories, series, onClickCategory } = this.props
|
||||
return (
|
||||
<p>hello</p>
|
||||
<div style={{ minHeight: height }}>
|
||||
<HighchartsChart>
|
||||
<XAxis
|
||||
categories={categories}
|
||||
title={{
|
||||
text: 'Grades',
|
||||
style: { fontSize: FONTSIZE }
|
||||
}}
|
||||
labels={{
|
||||
style: {
|
||||
fontSize: 15
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
min={0}
|
||||
max={5}
|
||||
title={{
|
||||
text: 'Profile dimensions',
|
||||
style: { fontSize: FONTSIZE }
|
||||
}}
|
||||
>
|
||||
{series.map(({ name, data }) => {
|
||||
const formatted = data.map((val, i) => ({
|
||||
y: val,
|
||||
selected: (this.props.selected === categories[i]),
|
||||
events: {
|
||||
mouseOver: () => onClickCategory(categories[i])
|
||||
}
|
||||
}))
|
||||
return (
|
||||
<SplineSeries
|
||||
key={name}
|
||||
name={name}
|
||||
data={formatted}
|
||||
visible={!this.state.focused || (name === this.state.focused)}
|
||||
events={{
|
||||
legendItemClick: this.handleLegendClick
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</YAxis>
|
||||
<Legend />
|
||||
<Tooltip />
|
||||
</HighchartsChart>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CourseGradeGraph.propTypes = {
|
||||
data: shape({}).isRequired
|
||||
categories: arrayOf(string).isRequired,
|
||||
series: arrayOf(shape({})).isRequired,
|
||||
selected: string.isRequired,
|
||||
height: number,
|
||||
onClickCategory: func.isRequired
|
||||
}
|
||||
|
||||
export default CourseGradeGraph
|
||||
CourseGradeGraph.defaultProps = {
|
||||
height: 400
|
||||
}
|
||||
|
||||
export default withHighcharts(CourseGradeGraph, Highcharts)
|
||||
|
||||
@@ -1,50 +1,104 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Grid, Card, Header, Table, Form, Dropdown } from 'semantic-ui-react'
|
||||
import { shape } from 'prop-types'
|
||||
import { Grid, Message, Form, Dropdown } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, string } from 'prop-types'
|
||||
import SpiderGraph from './ProfileSpiderGraph'
|
||||
import ProfileTable from './ProfileTable'
|
||||
import CourseGradeGraph from './CourseGradeGraph'
|
||||
import selector from '../../selectors/oodilearn'
|
||||
|
||||
const DESCRIPTION = `
|
||||
Interactively explore how the profiles of students who have
|
||||
received a specific grade from this course average across the
|
||||
different dimensions of their learner profiles.
|
||||
`
|
||||
|
||||
class CourseGradeSpiders extends Component {
|
||||
state={
|
||||
selected: Object.keys(this.props.data)[0]
|
||||
}
|
||||
render() {
|
||||
const { selected } = this.state
|
||||
const profile = this.props.data[selected]
|
||||
const options = Object.keys(this.props.data)
|
||||
.map(grade => ({
|
||||
value: grade,
|
||||
text: grade.slice(0, -2)
|
||||
}))
|
||||
return (
|
||||
<Grid centered textAlign="center">
|
||||
<Grid.Row>
|
||||
<Grid.Column width={6}>
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Grade</label>
|
||||
<Dropdown
|
||||
selection
|
||||
onChange={(e, { value: selected }) => this.setState({ selected })}
|
||||
options={options}
|
||||
value={selected}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<ProfileTable profile={profile} />
|
||||
</Grid.Column>
|
||||
<Grid.Column width={10}>
|
||||
<SpiderGraph profile={profile} />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
)
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
selected: this.props.categories[0],
|
||||
profile: this.props.profiles[this.props.categories[0]]
|
||||
}
|
||||
}
|
||||
|
||||
setGradeCategory = (selected) => {
|
||||
const { profiles } = this.props
|
||||
this.setState({ selected, profile: profiles[selected] })
|
||||
}
|
||||
|
||||
handleChange = (event, { value }) => {
|
||||
event.preventDefault()
|
||||
this.setGradeCategory(value)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { categories, series, options } = this.props
|
||||
const { selected, profile } = this.state
|
||||
return (
|
||||
<Grid centered textAlign="center">
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<Message
|
||||
header="Average profile dimensions per course grade"
|
||||
content={DESCRIPTION}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={6}>
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Grade</label>
|
||||
<Dropdown
|
||||
selection
|
||||
onChange={this.handleChange}
|
||||
options={options}
|
||||
value={selected}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<ProfileTable
|
||||
series={series}
|
||||
categories={categories}
|
||||
selected={selected}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={10}>
|
||||
<SpiderGraph profile={profile} title={null} />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<CourseGradeGraph
|
||||
categories={categories}
|
||||
series={series}
|
||||
selected={selected}
|
||||
onClickCategory={this.setGradeCategory}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
}
|
||||
CourseGradeSpiders.propTypes = {
|
||||
data: shape({}).isRequired
|
||||
categories: arrayOf(string).isRequired,
|
||||
series: arrayOf(shape({})).isRequired,
|
||||
profiles: shape({}).isRequired,
|
||||
options: arrayOf(shape({})).isRequired
|
||||
}
|
||||
|
||||
export default CourseGradeSpiders
|
||||
const mapStateToProps = (state) => {
|
||||
const { categories, series, profiles } = selector.courseProfileSeries(state)
|
||||
return {
|
||||
categories,
|
||||
series,
|
||||
profiles,
|
||||
options: categories.map(c => ({ text: c, value: c }))
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(CourseGradeSpiders)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Card, Button, Divider, Menu } from 'semantic-ui-react'
|
||||
import { string, func, shape, bool, arrayOf } from 'prop-types'
|
||||
import { Segment, Card, Divider, Menu, Placeholder } from 'semantic-ui-react'
|
||||
import { func, shape, bool, string, arrayOf } from 'prop-types'
|
||||
import ClusterGraph from './ClusterGraph'
|
||||
import { getOodiLearnCourse } from '../../redux/oodilearnCourse'
|
||||
import { getOodiLearnCluster } from '../../redux/oodilearnCluster'
|
||||
@@ -12,15 +12,25 @@ const KEYS = {
|
||||
CLUSTER: 'cluster'
|
||||
}
|
||||
|
||||
const OlPlaceholder = () => (
|
||||
<Placeholder>
|
||||
<Placeholder.Header>
|
||||
<Placeholder.Line />
|
||||
<Placeholder.Line />
|
||||
<Placeholder.Line />
|
||||
</Placeholder.Header>
|
||||
</Placeholder>
|
||||
)
|
||||
|
||||
class CoursePage extends Component {
|
||||
state={
|
||||
selected: KEYS.PROFILE
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { course, getOodiLearnCourse, getOodiLearnCluster } = this.props
|
||||
getOodiLearnCourse(course)
|
||||
getOodiLearnCluster(course)
|
||||
const { course } = this.props
|
||||
this.props.getOodiLearnCourse(course)
|
||||
this.props.getOodiLearnCluster(course)
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -34,7 +44,7 @@ class CoursePage extends Component {
|
||||
header={course}
|
||||
/>
|
||||
<Menu
|
||||
onItemClick={(e, { name: selected }) => this.setState({ selected })}
|
||||
onItemClick={(e, { name }) => this.setState({ selected: name })}
|
||||
items={[{
|
||||
icon: 'arrow circle left',
|
||||
key: 'back',
|
||||
@@ -48,12 +58,14 @@ class CoursePage extends Component {
|
||||
key: KEYS.CLUSTER,
|
||||
name: KEYS.CLUSTER,
|
||||
active: selected === KEYS.CLUSTER,
|
||||
disabled: true,
|
||||
content: 'Clusters'
|
||||
}]}
|
||||
/>
|
||||
<Divider />
|
||||
<Segment loading={loading}>
|
||||
{ finishedLoading && selected === KEYS.PROFILE && <CourseGradeSpiders data={data} /> }
|
||||
{ !finishedLoading && <OlPlaceholder /> }
|
||||
{ finishedLoading && selected === KEYS.PROFILE && <CourseGradeSpiders /> }
|
||||
{ finishedLoading && selected === KEYS.CLUSTER && <ClusterGraph data={clusterData} /> }
|
||||
</Segment>
|
||||
</Segment>
|
||||
@@ -64,22 +76,26 @@ class CoursePage extends Component {
|
||||
CoursePage.propTypes = {
|
||||
goBack: func.isRequired,
|
||||
getOodiLearnCourse: func.isRequired,
|
||||
getOodiLearnCluster: func.isRequired,
|
||||
loading: bool.isRequired,
|
||||
course: string.isRequired,
|
||||
data: shape({}),
|
||||
loading: bool.isRequired
|
||||
clusterData: arrayOf(shape({}))
|
||||
}
|
||||
|
||||
CoursePage.defaultProps = {
|
||||
data: undefined
|
||||
data: undefined,
|
||||
clusterData: undefined
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const { pending: loading, data } = state.oodilearnCourse
|
||||
const { pending: clusterLoading, data: clusterData} = state.oodilearnCluster
|
||||
const { pending: clusterLoading, data: clusterData } = state.oodilearnCluster
|
||||
return {
|
||||
loading,
|
||||
clusterLoading,
|
||||
data,
|
||||
clusterData
|
||||
clusterData,
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Message } from 'semantic-ui-react'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import { func, arrayOf, shape } from 'prop-types'
|
||||
import AutoSubmitSearchInput from '../AutoSubmitSearchInput'
|
||||
import { getCourses } from '../../redux/oodilearnCourses'
|
||||
@@ -13,9 +13,11 @@ class StudentSearch extends Component {
|
||||
state={
|
||||
searchterm: ''
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.getCourses()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { courses } = this.props
|
||||
const { searchterm } = this.state
|
||||
@@ -28,9 +30,9 @@ class StudentSearch extends Component {
|
||||
onChange={val => this.setState({ searchterm: val })}
|
||||
loading={false}
|
||||
minSearchLength={MIN_SEARCH_LENGTH}
|
||||
disabled={true}
|
||||
disabled
|
||||
/>
|
||||
<ResultTable
|
||||
<ResultTable
|
||||
results={courses.map(({ code: id, name }) => ({
|
||||
id,
|
||||
name,
|
||||
@@ -49,7 +51,7 @@ StudentSearch.propTypes = {
|
||||
onClickResult: func.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
const mapStateToProps = state => ({
|
||||
courses: oodilearnSelector.getSearchedCourses(state)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import { shape, number } from 'prop-types'
|
||||
import { shape, number, string } from 'prop-types'
|
||||
import Highcharts from 'highcharts'
|
||||
import addHighchartsMore from 'highcharts/highcharts-more'
|
||||
import {
|
||||
HighchartsChart, withHighcharts, XAxis, YAxis, Pane, AreaSeries, Title
|
||||
HighchartsChart, withHighcharts, XAxis, YAxis, Pane, AreaSeries, Title, Tooltip
|
||||
} from 'react-jsx-highcharts'
|
||||
|
||||
addHighchartsMore(Highcharts)
|
||||
@@ -17,17 +17,18 @@ const CATEGORIES = [
|
||||
'Self-efficacy'
|
||||
]
|
||||
|
||||
const ProfileSpiderGraph = ({ profile: p }) => {
|
||||
const ProfileSpiderGraph = ({ profile: p, title }) => {
|
||||
const data = [p.Deep, p.SBI, p.Surface, p.Organised, p.SE]
|
||||
return (
|
||||
<Segment basic>
|
||||
<HighchartsChart polar>
|
||||
<Title>Learner Profile</Title>
|
||||
<Title>{title}</Title>
|
||||
<Pane startAngle={0} endAngle={360} />
|
||||
<XAxis min={0} tickmarkPlacement="on" categories={CATEGORIES} lineWidth={0} />
|
||||
<YAxis min={0} max={5} gridLineInterpolation="polygon" lineWidth={0}>
|
||||
<AreaSeries name="Original" data={data} />
|
||||
</YAxis>
|
||||
<Tooltip />
|
||||
</HighchartsChart>
|
||||
</Segment>
|
||||
)
|
||||
@@ -40,7 +41,12 @@ ProfileSpiderGraph.propTypes = {
|
||||
Surface: number,
|
||||
Organised: number,
|
||||
SBI: number
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
title: string
|
||||
}
|
||||
|
||||
ProfileSpiderGraph.defaultProps = {
|
||||
title: 'Learner Profile'
|
||||
}
|
||||
|
||||
export default withHighcharts(ProfileSpiderGraph, Highcharts)
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { shape } from 'prop-types'
|
||||
import { shape, arrayOf, string } from 'prop-types'
|
||||
|
||||
const ProfileTable = ({ profile }) => (
|
||||
<Table definition size="small">
|
||||
<Table.Body>
|
||||
{ Object.entries(profile).map(([ key, value ]) => (
|
||||
<Table.Row key={key}>
|
||||
<Table.Cell content={key} width={3} textAlign="center" />
|
||||
<Table.Cell content={value} />
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
const ProfileTable = ({ series, categories, selected }) => (
|
||||
<Table definition celled size="small">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell content={null} />
|
||||
{ categories.map(c => (
|
||||
<Table.HeaderCell
|
||||
key={c}
|
||||
content={c}
|
||||
textAlign="center"
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{ series.map(({ name, data }) => (
|
||||
<Table.Row key={name}>
|
||||
<Table.Cell content={name} width={3} textAlign="center" />
|
||||
{ data.map((value, i) => (
|
||||
<Table.Cell
|
||||
key={categories[i]}
|
||||
content={value}
|
||||
textAlign="center"
|
||||
active={selected === categories[i]}
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
|
||||
ProfileTable.propTypes = {
|
||||
profile: shape({}).isRequired
|
||||
series: arrayOf(shape({})).isRequired,
|
||||
categories: arrayOf(string).isRequired,
|
||||
selected: string.isRequired
|
||||
}
|
||||
|
||||
export default ProfileTable
|
||||
export default ProfileTable
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Table, Button } from 'semantic-ui-react'
|
||||
import { arrayOf, shape, func, oneOfType, string, number } from 'prop-types'
|
||||
|
||||
const ResultTable = ({ results, idTitle, nameTitle }) => (results.length > 0) && (
|
||||
<Table>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell content={idTitle} />
|
||||
@@ -28,7 +28,7 @@ ResultTable.propTypes = {
|
||||
idTitle: string,
|
||||
nameTitle: string,
|
||||
results: arrayOf(shape({
|
||||
id: oneOfType([ number, string ]),
|
||||
id: oneOfType([number, string]),
|
||||
name: string,
|
||||
handleClick: func
|
||||
})).isRequired
|
||||
@@ -39,4 +39,4 @@ ResultTable.defaultProps = {
|
||||
nameTitle: 'Name'
|
||||
}
|
||||
|
||||
export default ResultTable
|
||||
export default ResultTable
|
||||
|
||||
@@ -12,6 +12,7 @@ class StudentSearch extends Component {
|
||||
state={
|
||||
searchterm: ''
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, loading, hasData } = this.props
|
||||
const { searchterm } = this.state
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
|
||||
import styles from '../populationCourseStats.css'
|
||||
|
||||
const getYearCount = (year, passingSemesters) => passingSemesters[`${year}-FALL`] + passingSemesters[`${year}-SPRING`]
|
||||
const getCumulativeYearCount = (year, passingSemesters) => {
|
||||
if (passingSemesters[`${year}-FALL`] === passingSemesters[`${year}-SPRING`]) {
|
||||
return passingSemesters[`${year}-FALL`]
|
||||
}
|
||||
|
||||
const diff = passingSemesters[`${year}-SPRING`] - passingSemesters[`${year}-FALL`]
|
||||
return passingSemesters[`${year}-FALL`] + diff
|
||||
}
|
||||
|
||||
const renderYear = (year, passingSemesters) => (
|
||||
<Fragment>
|
||||
<Table.Cell>{passingSemesters[`${year}-FALL`] || ''}</Table.Cell>
|
||||
<Table.Cell>{passingSemesters[`${year}-SPRING`] || ''}</Table.Cell>
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
const renderCompactYear = (year, passingSemesters) => (
|
||||
<Fragment>
|
||||
<Table.Cell>{getYearCount(year, passingSemesters) || ''}</Table.Cell>
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
const renderCompactCumulativeYear = (year, passingSemesters) => (
|
||||
<Fragment>
|
||||
<Table.Cell>{getCumulativeYearCount(year, passingSemesters) || ''}</Table.Cell>
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
const renderStatistics = passingSemesters => (
|
||||
<Fragment>
|
||||
<Table.Cell>{passingSemesters.BEFORE || ''}</Table.Cell>
|
||||
{renderYear(0, passingSemesters)}
|
||||
{renderYear(1, passingSemesters)}
|
||||
{renderYear(2, passingSemesters)}
|
||||
{renderYear(3, passingSemesters)}
|
||||
{renderCompactYear(4, passingSemesters)}
|
||||
{renderCompactYear(5, passingSemesters)}
|
||||
<Table.Cell>{passingSemesters.LATER || ''}</Table.Cell>
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
const renderCumulativeStatistics = passingSemesters => (
|
||||
<Fragment>
|
||||
<Table.Cell>{passingSemesters.BEFORE || ''}</Table.Cell>
|
||||
{renderYear(0, passingSemesters)}
|
||||
{renderYear(1, passingSemesters)}
|
||||
{renderYear(2, passingSemesters)}
|
||||
{renderYear(3, passingSemesters)}
|
||||
{renderCompactCumulativeYear(4, passingSemesters)}
|
||||
{renderCompactCumulativeYear(5, passingSemesters)}
|
||||
<Table.Cell>{passingSemesters.LATER || ''}</Table.Cell>
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
export default ({ statistics, cumulative, onCourseNameClickFn, isActiveCourseFn }) => {
|
||||
const { stats, course } = statistics
|
||||
const passingSemesters = cumulative ? stats.passingSemestersCumulative : stats.passingSemesters
|
||||
const isActive = isActiveCourseFn(course)
|
||||
|
||||
return (
|
||||
<Table.Row key={course.code} active={isActive}>
|
||||
<Table.Cell onClick={() => onCourseNameClickFn(statistics)} className={styles.clickableCell}>
|
||||
{course.name.fi}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.code}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{stats.students}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{stats.passed}
|
||||
</Table.Cell>
|
||||
|
||||
{cumulative
|
||||
? renderCumulativeStatistics(passingSemesters)
|
||||
: renderStatistics(passingSemesters)}
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Table, Checkbox } from 'semantic-ui-react'
|
||||
import CourseRow from './CourseRow'
|
||||
|
||||
export default class PassingSemesters extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
cumulativeStats: false
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = () => {
|
||||
this.setState({ cumulativeStats: !this.state.cumulativeStats })
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const { courseStatistics, onCourseNameClickFn, isActiveCourseFn } = this.props
|
||||
return (
|
||||
<div>
|
||||
<Checkbox
|
||||
toggle
|
||||
checked={this.state.cumulativeStats}
|
||||
onChange={this.handleChange}
|
||||
label="Show cumulative stats"
|
||||
/>
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Course name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Course code</Table.HeaderCell>
|
||||
<Table.HeaderCell>Students</Table.HeaderCell>
|
||||
<Table.HeaderCell>Passed</Table.HeaderCell>
|
||||
|
||||
<Table.HeaderCell>Before 1st year</Table.HeaderCell>
|
||||
<Table.HeaderCell>1st fall</Table.HeaderCell>
|
||||
<Table.HeaderCell>1st spring</Table.HeaderCell>
|
||||
<Table.HeaderCell>2nd fall</Table.HeaderCell>
|
||||
<Table.HeaderCell>2nd spring</Table.HeaderCell>
|
||||
<Table.HeaderCell>3rd fall</Table.HeaderCell>
|
||||
<Table.HeaderCell>3rd spring</Table.HeaderCell>
|
||||
<Table.HeaderCell>4th fall</Table.HeaderCell>
|
||||
<Table.HeaderCell>4th spring</Table.HeaderCell>
|
||||
<Table.HeaderCell>5th year</Table.HeaderCell>
|
||||
<Table.HeaderCell>6th year</Table.HeaderCell>
|
||||
<Table.HeaderCell>Later</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{courseStatistics.map(stats => (
|
||||
<CourseRow
|
||||
key={stats.course.code}
|
||||
statistics={stats}
|
||||
isActiveCourseFn={isActiveCourseFn}
|
||||
onCourseNameClickFn={onCourseNameClickFn}
|
||||
cumulative={this.state.cumulativeStats}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Table, Form, Input, Popup, Button, Icon } from 'semantic-ui-react'
|
||||
import { func, arrayOf, object, number, shape, string } from 'prop-types'
|
||||
import { Table, Form, Input, Popup, Button } from 'semantic-ui-react'
|
||||
import { func, arrayOf, object, number, shape, string, oneOf, bool } from 'prop-types'
|
||||
import { getTranslate } from 'react-localize-redux'
|
||||
import _ from 'lodash'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
@@ -10,8 +10,69 @@ import moment from 'moment'
|
||||
import { setPopulationFilter, removePopulationFilterOfCourse } from '../../redux/populationFilters'
|
||||
import { getMultipleCourseStatistics } from '../../redux/courseStatistics'
|
||||
import { courseParticipation } from '../../populationFilters'
|
||||
import PassingSemesters from './PassingSemesters'
|
||||
|
||||
const formatGradeDistribution = grades => _.replace(JSON.stringify(_.sortBy(Object.entries(grades).map(([key, value]) => ({ [key]: value.count })), o => -Object.keys(o)), null, 1), /\[\n|{\n*|{\s|}|\s*}|]|"|,/g, '')
|
||||
import styles from './populationCourseStats.css'
|
||||
|
||||
export const tableColumnNames = {
|
||||
STUDENTS: 'students',
|
||||
PASSED: 'passed',
|
||||
RETRY_PASSED: 'retryPassed',
|
||||
PERCENTAGE: 'percentage',
|
||||
FAILED: 'failed',
|
||||
FAILED_MANY: 'failedMany',
|
||||
ATTEMPTS: 'attempts',
|
||||
PER_STUDENT: 'perStudent',
|
||||
PASSED_OF_POPULATION: 'passedOfPopulation',
|
||||
TRIED_OF_POPULATION: 'triedOfPopulation'
|
||||
}
|
||||
|
||||
export const sortOrderTypes = {
|
||||
ASC: 'ascending',
|
||||
DESC: 'descending'
|
||||
}
|
||||
|
||||
const lodashSortOrderTypes = {
|
||||
ASC: 'asc',
|
||||
DESC: 'desc'
|
||||
}
|
||||
|
||||
const SortableHeaderCell =
|
||||
({ content, columnName, onClickFn, activeSortColumn, reversed, rowSpan }) => {
|
||||
const isTableSortedBy = activeSortColumn === columnName
|
||||
const direction = reversed ? sortOrderTypes.DESC : sortOrderTypes.ASC
|
||||
return (
|
||||
<Table.HeaderCell
|
||||
rowSpan={`${rowSpan}`}
|
||||
sorted={isTableSortedBy ? direction : null}
|
||||
onClick={() => onClickFn(columnName)}
|
||||
className={isTableSortedBy ? styles.activeSortHeader : ''}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tableColumnType = oneOf(Object.values(tableColumnNames))
|
||||
|
||||
SortableHeaderCell.propTypes = {
|
||||
content: string.isRequired,
|
||||
columnName: tableColumnType.isRequired,
|
||||
activeSortColumn: tableColumnType.isRequired,
|
||||
reversed: bool.isRequired,
|
||||
onClickFn: func.isRequired,
|
||||
rowSpan: number
|
||||
}
|
||||
|
||||
SortableHeaderCell.defaultProps = {
|
||||
rowSpan: 1
|
||||
}
|
||||
|
||||
const formatGradeDistribution = grades =>
|
||||
_.replace(JSON.stringify(
|
||||
_.sortBy(Object.entries(grades)
|
||||
.map(([key, value]) => ({ [key]: value.count })), o => -Object.keys(o)),
|
||||
null, 1
|
||||
), /\[\n|{\n*|{\s|}|\s*}|]|"|,/g, '')
|
||||
|
||||
class PopulationCourseStats extends Component {
|
||||
static propTypes = {
|
||||
@@ -31,379 +92,381 @@ class PopulationCourseStats extends Component {
|
||||
query: shape({}).isRequired
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (state && !state.initialSortReady && props.courses.coursestatistics) {
|
||||
state.courseStatistics = PopulationCourseStats.updateCourseStatisticsCriteria(props, state)
|
||||
state.initialSortReady = true
|
||||
}
|
||||
|
||||
this.state = {
|
||||
sortBy: 'students',
|
||||
reversed: false,
|
||||
limit: parseInt(this.props.populationSize * 0.15, 10),
|
||||
codeFilter: '',
|
||||
showGradeDistribution: false
|
||||
return state
|
||||
}
|
||||
|
||||
static updateCourseStatisticsCriteria(props, state) {
|
||||
const { studentAmountLimit, sortCriteria, codeFilter, reversed } = state
|
||||
const { courses: { coursestatistics } } = props
|
||||
|
||||
const studentAmountFilter = ({ stats }) => {
|
||||
const { students } = stats
|
||||
return studentAmountLimit === 0 || students >= studentAmountLimit
|
||||
}
|
||||
const courseCodeFilter = ({ course }) => {
|
||||
const { code } = course
|
||||
return code.toLowerCase().includes(codeFilter.toLowerCase())
|
||||
}
|
||||
|
||||
const filteredCourses = coursestatistics
|
||||
.filter(studentAmountFilter)
|
||||
.filter(c => !codeFilter || courseCodeFilter(c))
|
||||
|
||||
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
|
||||
|
||||
const courseStatistics = _.orderBy(
|
||||
filteredCourses,
|
||||
[course => course.stats[sortCriteria], course => course.course.code],
|
||||
[lodashSortOrder, lodashSortOrderTypes.ASC]
|
||||
)
|
||||
|
||||
return courseStatistics
|
||||
}
|
||||
|
||||
state = {
|
||||
sortCriteria: tableColumnNames.STUDENTS,
|
||||
reversed: true,
|
||||
studentAmountLimit: parseInt(this.props.populationSize * 0.15, 10),
|
||||
codeFilter: '',
|
||||
activeView: null
|
||||
}
|
||||
|
||||
onCodeFilterChange = (e) => {
|
||||
const { target: { value } } = e
|
||||
this.setState({ codeFilter: value })
|
||||
}
|
||||
|
||||
onSetCodeFilterKeyPress = (e) => {
|
||||
const { key } = e
|
||||
const enterKey = 'Enter'
|
||||
const isEnterKeyPress = key === enterKey
|
||||
if (isEnterKeyPress) {
|
||||
this.handleCourseStatisticsCriteriaChange()
|
||||
}
|
||||
}
|
||||
|
||||
getSelectionStyle = criteria => (this.state.sortBy === criteria ? ({ background: 'darkgray' }) : ({ background: '#f9fafb' }))
|
||||
|
||||
criteria = () => (c1, c2) => {
|
||||
const orderByCode = (code1, code2) =>
|
||||
(code1.course.code < code2.course.code ? -1 : 1)
|
||||
|
||||
if (this.state.reversed) {
|
||||
const val = c1.stats[this.state.sortBy] - c2.stats[this.state.sortBy]
|
||||
return (val !== 0 ? val : orderByCode(c1, c2))
|
||||
}
|
||||
const val = c2.stats[this.state.sortBy] - c1.stats[this.state.sortBy]
|
||||
return (val !== 0 ? val : orderByCode(c1, c2))
|
||||
onStudentAmountLimitChange = (e) => {
|
||||
const { target: { value } } = e
|
||||
this.setState(
|
||||
{ studentAmountLimit: value },
|
||||
() => this.handleCourseStatisticsCriteriaChange()
|
||||
)
|
||||
}
|
||||
|
||||
limit = () => (course) => {
|
||||
if (this.state.limit === 0) {
|
||||
return true
|
||||
}
|
||||
return course.stats.students >= this.state.limit
|
||||
onSortableColumnHeaderClick = (criteria) => {
|
||||
const { reversed, sortCriteria } = this.state
|
||||
const isActiveSortCriteria = sortCriteria === criteria
|
||||
const isReversed = isActiveSortCriteria ? !reversed : reversed
|
||||
|
||||
this.setState({
|
||||
sortCriteria: criteria,
|
||||
reversed: isReversed
|
||||
}, () => this.handleCourseStatisticsCriteriaChange())
|
||||
}
|
||||
|
||||
codeFilter = () => ({ course }) =>
|
||||
course.code.toLowerCase().includes(this.state.codeFilter.toLowerCase())
|
||||
|
||||
sortBy(criteria) {
|
||||
return () => {
|
||||
let { reversed } = this.state
|
||||
if (this.state.sortBy === criteria) {
|
||||
reversed = !reversed
|
||||
}
|
||||
this.setState({ sortBy: criteria, reversed })
|
||||
}
|
||||
onGoToCourseStatisticsClick = (code) => {
|
||||
const { history, query, getMultipleCourseStatistics: getStatsFn, language } = this.props
|
||||
const { year, months } = query
|
||||
history.push('/coursestatistics/')
|
||||
getStatsFn({
|
||||
codes: [code],
|
||||
start: Number(year),
|
||||
end: Number(moment(moment(year, 'YYYY').add(months, 'months')).format('YYYY')),
|
||||
separate: false,
|
||||
language
|
||||
})
|
||||
}
|
||||
|
||||
limitPopulationToCourse = course => () => {
|
||||
if (!this.active(course.course)) {
|
||||
const params = { course, field: 'all' }
|
||||
onCourseNameCellClick = (courseStats) => {
|
||||
if (!this.isActiveCourse(courseStats.course)) {
|
||||
const params = { course: courseStats, field: 'all' }
|
||||
this.props.setPopulationFilter(courseParticipation(params))
|
||||
} else {
|
||||
this.props.removePopulationFilterOfCourse(course.course)
|
||||
this.props.removePopulationFilterOfCourse(courseStats.course)
|
||||
}
|
||||
}
|
||||
|
||||
active = course =>
|
||||
this.props.selectedCourses
|
||||
setActiveView = activeView => this.setState({ activeView })
|
||||
|
||||
handleCourseStatisticsCriteriaChange = () => {
|
||||
const courseStatistics = PopulationCourseStats.updateCourseStatisticsCriteria(this.props, this.state)
|
||||
this.setState({ courseStatistics })
|
||||
}
|
||||
|
||||
isActiveCourse = (course) => {
|
||||
const { selectedCourses } = this.props
|
||||
return selectedCourses.length > 0 && selectedCourses
|
||||
.find(c => course.name === c.name && course.code === c.code) !== undefined
|
||||
|
||||
renderSortArrow = (criteria) => {
|
||||
if (this.state.sortBy === criteria) {
|
||||
if (this.state.reversed) {
|
||||
return (<Icon name="angle up" />)
|
||||
}
|
||||
return (<Icon name="angle down" />)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
renderCodeFilterInputHeaderCell = () => {
|
||||
const { translate } = this.props
|
||||
const { codeFilter } = this.state
|
||||
return (
|
||||
<Table.HeaderCell>
|
||||
{translate('populationCourses.code')}
|
||||
<Input
|
||||
className={styles.courseCodeInput}
|
||||
transparent
|
||||
value={codeFilter}
|
||||
placeholder="(filter here)"
|
||||
onChange={this.onCodeFilterChange}
|
||||
onKeyPress={this.onSetCodeFilterKeyPress}
|
||||
/>
|
||||
</Table.HeaderCell>)
|
||||
}
|
||||
|
||||
renderGradeDistributionTable = ({ translate, sortBy, courses, language }) => (
|
||||
<Table celled sortable>
|
||||
renderActiveView() {
|
||||
const { courses } = this.props
|
||||
const { courseStatistics } = this.state
|
||||
const courseStats = courseStatistics || courses.coursestatistics
|
||||
|
||||
switch (this.state.activeView) {
|
||||
case 'showGradeDistribution':
|
||||
return this.renderGradeDistributionTable(courseStats)
|
||||
case 'passingSemester':
|
||||
return (<PassingSemesters
|
||||
courseStatistics={courseStats}
|
||||
onCourseNameClickFn={this.onCourseNameCellClick}
|
||||
isActiveCourseFn={this.isActiveCourse}
|
||||
/>)
|
||||
default:
|
||||
return this.renderBasicTable(courseStats)
|
||||
}
|
||||
}
|
||||
|
||||
renderGradeDistributionTable = (courseStatistics) => {
|
||||
const { translate, language } = this.props
|
||||
const { sortCriteria, reversed } = this.state
|
||||
|
||||
const courseGradesTypes = [1, 2, 3, 4, 5]
|
||||
|
||||
const getTableHeader = () => (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="2" >
|
||||
{translate('populationCourses.course')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
{translate('populationCourses.code')}
|
||||
<Input style={{ marginLeft: 10, width: '6em', textDecoration: 'underline' }} transparent placeholder="(filter here)" onKeyPress={e => e.key === 'Enter' && this.setState({ codeFilter: e.target.value })} />
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'students' ? 'descending' : null}
|
||||
onClick={this.sortBy('students')}
|
||||
style={this.getSelectionStyle('students')}
|
||||
>
|
||||
Attempts
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
0
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
1
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
2
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
3
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
4
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
5
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Other passed
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="2" content={translate('populationCourses.course')} />
|
||||
{this.renderCodeFilterInputHeaderCell()}
|
||||
<SortableHeaderCell
|
||||
content="Attempts"
|
||||
columnName={tableColumnNames.STUDENTS}
|
||||
onClickFn={this.onSortableColumnHeaderClick}
|
||||
activeSortColumn={sortCriteria}
|
||||
reversed={reversed}
|
||||
/>
|
||||
<Table.HeaderCell content={0} />
|
||||
{courseGradesTypes.map(g => <Table.HeaderCell content={g} />)}
|
||||
<Table.HeaderCell content="Other passed" />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{courses.coursestatistics
|
||||
.sort(this.criteria()).filter(this.limit() && this.codeFilter()).map(course => (
|
||||
<Popup
|
||||
key={course.course.code}
|
||||
trigger={
|
||||
<Table.Row active={this.active(course.course)}>
|
||||
<Table.Cell onClick={this.limitPopulationToCourse(course)}>
|
||||
{course.course.name[language]}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
icon="level up alternate"
|
||||
onClick={() => {
|
||||
this.props.history.push('/coursestatistics/')
|
||||
this.props.getMultipleCourseStatistics({
|
||||
codes: [course.course.code],
|
||||
start: Number(this.props.query.year),
|
||||
end: Number(moment(moment(this.props.query.year, 'YYYY').add(this.props.query.months, 'months')).format('YYYY')),
|
||||
separate: false,
|
||||
language: this.props.language
|
||||
})
|
||||
}}
|
||||
style={{ borderLeft: '0px !important' }}
|
||||
/>
|
||||
<Table.Cell>{course.course.code}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades ? _.sum(Object.values(course.grades).map(g => g.count)) || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades ?
|
||||
_.sum(Object.values(course.grades).filter(g =>
|
||||
g.status.failingGrade).map(g => g.count))
|
||||
|| 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[1] ? course.grades[1].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[2] ? course.grades[2].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[3] ? course.grades[3].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[4] ? course.grades[4].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[5] ? course.grades[5].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades ?
|
||||
_.sum(Object.values(_.omit(course.grades, [1, 2, 3, 4, 5])).filter(g =>
|
||||
g.status.passingGrade || g.status.improvedGrade).map(g => g.count)) : 0}
|
||||
</Table.Cell>
|
||||
</Table.Row>}
|
||||
flowing
|
||||
hoverable
|
||||
inverted
|
||||
position="top right"
|
||||
hideOnScroll
|
||||
content={course.grades ? <pre>{formatGradeDistribution(course.grades)}</pre> : 'Nothing to see here'}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
renderBasicTable = ({ translate, sortBy, direction, courses, language }) => (
|
||||
<Table celled sortable>
|
||||
)
|
||||
|
||||
const getCourseRow = (courseStats) => {
|
||||
const { course, grades } = courseStats
|
||||
const { name, code } = course
|
||||
|
||||
let attempts = 0
|
||||
let failedGrades = 0
|
||||
let otherPassed = 0
|
||||
|
||||
if (grades) {
|
||||
const countSumReducer = (acc, cur) => acc + cur.count
|
||||
const gradeValues = grades ? Object.values(grades) : null
|
||||
attempts = gradeValues.reduce(countSumReducer, 0)
|
||||
failedGrades = gradeValues.filter(g => g.status.failed).reduce(countSumReducer, 0)
|
||||
otherPassed = Object.values(_.omit(grades, courseGradesTypes))
|
||||
.filter(g => g.status.passingGrade || g.status.improvedGrade)
|
||||
.reduce(countSumReducer, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.Row active={this.isActiveCourse(course)}>
|
||||
<Table.Cell
|
||||
onClick={() => this.onCourseNameCellClick(courseStats)}
|
||||
content={name[language]}
|
||||
className={styles.clickableCell}
|
||||
/>
|
||||
<Table.Cell
|
||||
icon="level up alternate"
|
||||
onClick={() => this.onGoToCourseStatisticsClick(code)}
|
||||
className={styles.iconCell}
|
||||
/>
|
||||
<Table.Cell content={code} />
|
||||
<Table.Cell content={attempts} />
|
||||
<Table.Cell content={failedGrades} />
|
||||
{courseGradesTypes.map(g =>
|
||||
<Table.Cell content={grades[g] ? grades[g].count || 0 : 0} />)
|
||||
}
|
||||
<Table.Cell content={otherPassed} />
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
const getCoursePopUpRow = (courseStats) => {
|
||||
const { course, grades } = courseStats
|
||||
const { code } = course
|
||||
return (
|
||||
<Popup
|
||||
key={code}
|
||||
flowing
|
||||
hoverable
|
||||
inverted
|
||||
position="top right"
|
||||
hideOnScroll
|
||||
content={grades ? <pre>{formatGradeDistribution(grades)}</pre> : 'Nothing to see here'}
|
||||
trigger={getCourseRow(courseStats)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Table celled sortable>
|
||||
{getTableHeader()}
|
||||
<Table.Body>
|
||||
{courseStatistics.map(getCoursePopUpRow)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
renderBasicTable = (courseStatistics) => {
|
||||
const { translate, language } = this.props
|
||||
const { sortCriteria, reversed } = this.state
|
||||
|
||||
const getSortableHeaderCell = (label, columnName, rowSpan = 1) =>
|
||||
(<SortableHeaderCell
|
||||
content={label}
|
||||
columnName={columnName}
|
||||
onClickFn={this.onSortableColumnHeaderClick}
|
||||
activeSortColumn={sortCriteria}
|
||||
reversed={reversed}
|
||||
rowSpan={rowSpan}
|
||||
/>)
|
||||
|
||||
const getTableHeader = () => (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="2">
|
||||
{translate('populationCourses.course')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
rowSpan="2"
|
||||
sorted={sortBy === 'students' ? 'descending' : null}
|
||||
onClick={this.sortBy('students')}
|
||||
style={this.getSelectionStyle('students')}
|
||||
>
|
||||
{translate('populationCourses.students')}
|
||||
{this.renderSortArrow('students')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
colSpan="3"
|
||||
>
|
||||
{translate('populationCourses.passed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
colSpan="2"
|
||||
>
|
||||
{translate('populationCourses.failed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="2">{translate('populationCourses.attempts')}</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="2">{translate('populationCourses.percentageOfPopulation')}</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="3" content={translate('populationCourses.course')} />
|
||||
{getSortableHeaderCell(translate('populationCourses.students'), tableColumnNames.STUDENTS, 2)}
|
||||
<Table.HeaderCell colSpan="3" content={translate('populationCourses.passed')} />
|
||||
<Table.HeaderCell colSpan="2" content={translate('populationCourses.failed')} />
|
||||
<Table.HeaderCell colSpan="2" content={translate('populationCourses.attempts')} />
|
||||
<Table.HeaderCell colSpan="2" content={translate('populationCourses.percentageOfPopulation')} />
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="1" >{translate('populationCourses.name')}</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
{translate('populationCourses.code')}
|
||||
<Input style={{ marginLeft: 10, width: '6em' }} transparent placeholder="(filter here)" onKeyPress={e => e.key === 'Enter' && this.setState({ codeFilter: e.target.value })} />
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'passed' ? 'descending' : null}
|
||||
onClick={this.sortBy('passed')}
|
||||
style={this.getSelectionStyle('passed')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.number')}
|
||||
{this.renderSortArrow('passed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'retryPassed' ? 'descending' : null}
|
||||
onClick={this.sortBy('retryPassed')}
|
||||
style={this.getSelectionStyle('retryPassed')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.passedAfterRetry')}
|
||||
{this.renderSortArrow('retryPassed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'percentage' ? direction : null}
|
||||
onClick={this.sortBy('percentage')}
|
||||
style={this.getSelectionStyle('percentage')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.percentage')}
|
||||
{this.renderSortArrow('percentage')}
|
||||
</Table.HeaderCell>
|
||||
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'failed' ? 'descending' : null}
|
||||
onClick={this.sortBy('failed')}
|
||||
style={this.getSelectionStyle('failed')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.number')}
|
||||
{this.renderSortArrow('failed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
rowSpan="2"
|
||||
sorted={sortBy === 'failedMany' ? 'descending' : null}
|
||||
onClick={this.sortBy('failedMany')}
|
||||
style={this.getSelectionStyle('failedMany')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.failedManyTimes')}
|
||||
{this.renderSortArrow('failedMany')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'attempts' ? 'descending' : null}
|
||||
onClick={this.sortBy('attempts')}
|
||||
style={this.getSelectionStyle('attempts')}
|
||||
>
|
||||
{translate('populationCourses.number')}
|
||||
{this.renderSortArrow('attempts')}
|
||||
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'perStudent' ? 'descending' : null}
|
||||
onClick={this.sortBy('perStudent')}
|
||||
style={this.getSelectionStyle('perStudent')}
|
||||
>
|
||||
{translate('populationCourses.perStudent')}
|
||||
{this.renderSortArrow('perStudent')}
|
||||
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'passedOfPopulation' ? direction : null}
|
||||
onClick={this.sortBy('passedOfPopulation')}
|
||||
style={this.getSelectionStyle('passedOfPopulation')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.passed')}
|
||||
{this.renderSortArrow('passedOfPopulation')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'triedOfPopulation' ? direction : null}
|
||||
onClick={this.sortBy('triedOfPopulation')}
|
||||
style={this.getSelectionStyle('triedOfPopulation')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.attempted')}
|
||||
{this.renderSortArrow('triedOfPopulation')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="2" content={translate('populationCourses.name')} />
|
||||
{this.renderCodeFilterInputHeaderCell()}
|
||||
{getSortableHeaderCell(translate('populationCourses.number'), tableColumnNames.PASSED)}
|
||||
{getSortableHeaderCell(translate('populationCourses.passedAfterRetry'), tableColumnNames.RETRY_PASSED)}
|
||||
{getSortableHeaderCell(translate('populationCourses.percentage'), tableColumnNames.PERCENTAGE)}
|
||||
{getSortableHeaderCell(translate('populationCourses.number'), tableColumnNames.FAILED)}
|
||||
{getSortableHeaderCell(translate('populationCourses.failedManyTimes'), tableColumnNames.FAILED_MANY)}
|
||||
{getSortableHeaderCell(translate('populationCourses.number'), tableColumnNames.ATTEMPTS)}
|
||||
{getSortableHeaderCell(translate('populationCourses.perStudent'), tableColumnNames.PER_STUDENT)}
|
||||
{getSortableHeaderCell(translate('populationCourses.passed'), tableColumnNames.PASSED_OF_POPULATION)}
|
||||
{getSortableHeaderCell(translate('populationCourses.attempted'), tableColumnNames.TRIED_OF_POPULATION)}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{courses.coursestatistics
|
||||
.sort(this.criteria()).filter(this.limit() && this.codeFilter()).map(course => (
|
||||
<Table.Row key={course.course.code} active={this.active(course.course)}>
|
||||
<Table.Cell onClick={this.limitPopulationToCourse(course)}>
|
||||
{course.course.name[language]}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
icon="level up alternate"
|
||||
onClick={() => {
|
||||
this.props.history.push('/coursestatistics/')
|
||||
this.props.getMultipleCourseStatistics({
|
||||
codes: [course.course.code],
|
||||
start: Number(this.props.query.year),
|
||||
end: Number(moment(moment(this.props.query.year, 'YYYY').add(this.props.query.months, 'months')).format('YYYY')),
|
||||
separate: false,
|
||||
language: this.props.language
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
borderLeft: '0px !important',
|
||||
display: 'none'
|
||||
}}
|
||||
/>
|
||||
<Table.Cell>{course.course.code}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.passed + course.stats.failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.passed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.retryPassed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.percentage} %</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.failedMany}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.attempts}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.perStudent.toFixed(2)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.passedOfPopulation} %</Table.Cell>
|
||||
<Table.Cell>{course.stats.triedOfPopulation} %</Table.Cell>
|
||||
</Table.Row>))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
)
|
||||
|
||||
const getCourseRow = (courseStats) => {
|
||||
const { course, stats } = courseStats
|
||||
const { code, name } = course
|
||||
const {
|
||||
failed,
|
||||
passed,
|
||||
retryPassed,
|
||||
failedMany,
|
||||
attempts,
|
||||
percentage,
|
||||
perStudent,
|
||||
passedOfPopulation,
|
||||
triedOfPopulation
|
||||
} = stats
|
||||
return ((
|
||||
<Table.Row key={code} active={this.isActiveCourse(course)}>
|
||||
<Table.Cell
|
||||
onClick={() => this.onCourseNameCellClick(courseStats)}
|
||||
content={name[language]}
|
||||
className={styles.clickableCell}
|
||||
/>
|
||||
<Table.Cell
|
||||
icon="level up alternate"
|
||||
onClick={() => this.onGoToCourseStatisticsClick(code)}
|
||||
className={styles.iconCell}
|
||||
/>
|
||||
<Table.Cell content={code} />
|
||||
<Table.Cell content={passed + failed} />
|
||||
<Table.Cell content={passed} />
|
||||
<Table.Cell content={retryPassed} />
|
||||
<Table.Cell content={`${percentage} %`} />
|
||||
<Table.Cell content={failed} />
|
||||
<Table.Cell content={failedMany} />
|
||||
<Table.Cell content={attempts} />
|
||||
<Table.Cell content={perStudent.toFixed(2)} />
|
||||
<Table.Cell content={`${passedOfPopulation} %`} />
|
||||
<Table.Cell content={`${triedOfPopulation} %`} />
|
||||
</Table.Row>)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Table celled sortable>
|
||||
{getTableHeader()}
|
||||
<Table.Body>
|
||||
{courseStatistics.map(getCourseRow)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { courses, translate } = this.props
|
||||
const { reversed } = this.state
|
||||
const direction = reversed ? 'descending' : 'ascending'
|
||||
if (courses.length === 0) return null
|
||||
const { studentAmountLimit } = this.state
|
||||
|
||||
if (courses.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
<Form.Field inline>
|
||||
<label>{translate('populationCourses.limit')}</label>
|
||||
<Input
|
||||
value={this.state.limit}
|
||||
onChange={e => this.setState({ limit: e.target.value })}
|
||||
value={studentAmountLimit}
|
||||
onChange={this.onStudentAmountLimitChange}
|
||||
/>
|
||||
<Button icon floated="right" onClick={() => this.setState({ showGradeDistribution: !this.state.showGradeDistribution })}>
|
||||
<Icon color="black" size="big" name="chart bar" />
|
||||
<Button
|
||||
active={this.state.activeView === 'passingSemester'}
|
||||
floated="right"
|
||||
onClick={() => this.setActiveView('passingSemester')}
|
||||
>
|
||||
when passed
|
||||
</Button>
|
||||
<Button
|
||||
active={this.state.activeView === 'showGradeDistribution'}
|
||||
floated="right"
|
||||
onClick={() => this.setActiveView('showGradeDistribution')}
|
||||
>
|
||||
grades
|
||||
</Button>
|
||||
<Button
|
||||
active={this.state.activeView === null}
|
||||
floated="right"
|
||||
onClick={() => this.setActiveView(null)}
|
||||
>
|
||||
pass/fail
|
||||
</Button>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
{this.state.showGradeDistribution ?
|
||||
this.renderGradeDistributionTable(this.props, this.state)
|
||||
:
|
||||
this.renderBasicTable(this.props, this.state, direction)
|
||||
}
|
||||
|
||||
{this.renderActiveView()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.courseCodeInput {
|
||||
margin-left: 0.25em !important;
|
||||
width: 6em;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.activeSortHeader {
|
||||
background-color: darkgray;
|
||||
}
|
||||
|
||||
.iconCell {
|
||||
border-left: 0 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickableCell {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, arrayOf, object } from 'prop-types'
|
||||
import { Segment, Header, Popup } from 'semantic-ui-react'
|
||||
@@ -6,36 +6,32 @@ import { getTranslate } from 'react-localize-redux'
|
||||
import SegmentDimmer from '../SegmentDimmer'
|
||||
import PopulationCourseStats from '../PopulationCourseStats'
|
||||
|
||||
class PopulationCourses extends Component {
|
||||
isSamplesRenderable = () => {
|
||||
const { samples } = this.props
|
||||
return samples && samples.length > 0
|
||||
const PopulationCourses = ({ samples, translate }) => {
|
||||
const isValidSample = samples && samples.length > 0
|
||||
|
||||
if (!isValidSample) {
|
||||
return null
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isSamplesRenderable()) {
|
||||
return null
|
||||
}
|
||||
const { samples, translate } = this.props
|
||||
const { pending } = this.props.samples[0]
|
||||
return (
|
||||
<Segment>
|
||||
<Popup
|
||||
trigger={<Header size="medium" dividing>{translate('populationCourses.header')}</Header>}
|
||||
content="Sort by clicking columns. Click course name to limit observed population to students who participated to the course."
|
||||
wide
|
||||
position="top left"
|
||||
/>
|
||||
<SegmentDimmer translate={translate} isLoading={pending} />
|
||||
{samples.map(sample => (
|
||||
<PopulationCourseStats
|
||||
key={sample.query.uuid}
|
||||
courses={sample.data || {}}
|
||||
query={sample.query}
|
||||
/>))}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
const { pending } = samples[0]
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Popup
|
||||
trigger={<Header size="medium" dividing>{translate('populationCourses.header')}</Header>}
|
||||
content="Sort by clicking columns. Click course name to limit observed population to students who participated to the course."
|
||||
wide
|
||||
position="top left"
|
||||
/>
|
||||
<SegmentDimmer translate={translate} isLoading={pending} />
|
||||
{samples.map(sample => (
|
||||
<PopulationCourseStats
|
||||
key={sample.query.uuid}
|
||||
courses={sample.data || {}}
|
||||
query={sample.query}
|
||||
/>))}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
PopulationCourses.propTypes = {
|
||||
@@ -43,9 +39,9 @@ PopulationCourses.propTypes = {
|
||||
translate: func.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
samples: state.populationCourses,
|
||||
translate: getTranslate(state.locale)
|
||||
const mapStateToProps = ({ populationCourses, locale }) => ({
|
||||
samples: populationCourses,
|
||||
translate: getTranslate(locale)
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(PopulationCourses)
|
||||
|
||||
@@ -25,6 +25,7 @@ class PopulationDetails extends Component {
|
||||
queryIsSet: bool.isRequired,
|
||||
isLoading: bool.isRequired
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.chart = React.createRef()
|
||||
@@ -102,6 +103,7 @@ class PopulationDetails extends Component {
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
renderNavigationPanel = () => (
|
||||
<div>
|
||||
<Segment className="navigationpanel" style={{ position: 'fixed', right: '2%', bottom: '2%' }}>
|
||||
|
||||
@@ -23,6 +23,7 @@ class CourseParticipation extends Component {
|
||||
this.props.alterPopulationCourseFilter(this.props.filter.id, field)
|
||||
}
|
||||
}
|
||||
|
||||
renderFilterSegment = (type, text, active) =>
|
||||
(
|
||||
<Segment
|
||||
|
||||
@@ -15,11 +15,13 @@ class ExtentGraduated extends Component {
|
||||
setPopulationFilter: func.isRequired,
|
||||
extents: arrayOf(object).isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
extentcode: undefined,
|
||||
graduated: undefined,
|
||||
complemented: 'false'
|
||||
}
|
||||
|
||||
graduationOptions = [
|
||||
{ value: 'grad', text: 'graduated' },
|
||||
{ value: 'either', text: 'studying' }
|
||||
@@ -30,6 +32,7 @@ class ExtentGraduated extends Component {
|
||||
handleChange = (e, data) => {
|
||||
this.setState({ [data.name]: data.value })
|
||||
}
|
||||
|
||||
handleLimit = () => {
|
||||
const { extentcode, graduated, complemented } = this.state
|
||||
this.props.setPopulationFilter(extentGraduated({ extentcode, graduated, complemented }))
|
||||
@@ -38,6 +41,7 @@ class ExtentGraduated extends Component {
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
renderSetText = (extents, filter) => {
|
||||
const { language } = this.props
|
||||
const { extentcode, graduated, complemented } = filter.params
|
||||
|
||||
@@ -14,12 +14,14 @@ class Preset extends Component {
|
||||
deletePopulationFilter: func.isRequired,
|
||||
destroy: func.isRequired
|
||||
}
|
||||
|
||||
state = { open: false }
|
||||
|
||||
handleSetFilter = (filter) => {
|
||||
this.props.filter.notSet = false
|
||||
this.props.setPopulationFilter(presetFilter(filter))
|
||||
}
|
||||
|
||||
clearFilter = (destroy = false) => {
|
||||
this.props.filter.notSet = true
|
||||
if (destroy) {
|
||||
@@ -27,6 +29,7 @@ class Preset extends Component {
|
||||
}
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter } = this.props
|
||||
if (filter.notSet) {
|
||||
|
||||
@@ -14,11 +14,13 @@ class PriorityStudyright extends Component {
|
||||
setPopulationFilter: func.isRequired,
|
||||
studyrights: arrayOf({}).isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
prioritycode: undefined,
|
||||
degree: undefined,
|
||||
programme: undefined
|
||||
}
|
||||
|
||||
graduationOptions = [
|
||||
{ value: 'grad', text: 'graduated' },
|
||||
{ value: 'either', text: 'studying' }
|
||||
@@ -29,6 +31,7 @@ class PriorityStudyright extends Component {
|
||||
handleChange = (e, data) => {
|
||||
this.setState({ [data.name]: data.value })
|
||||
}
|
||||
|
||||
handleLimit = () => {
|
||||
const { prioritycode, degree, programme } = this.state
|
||||
this.props.setPopulationFilter(priorityStudyright({
|
||||
@@ -39,6 +42,7 @@ class PriorityStudyright extends Component {
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
renderSetText = (filter, studyrights) => {
|
||||
const { language } = this.props
|
||||
const { prioritycode, degree, programme } = filter.params
|
||||
|
||||
@@ -21,6 +21,7 @@ class TransferFilter extends Component {
|
||||
selectedSource: '',
|
||||
selectedTarget: ''
|
||||
}
|
||||
|
||||
getName = (code) => {
|
||||
const { transfers } = this.props
|
||||
const mergedTransfers = { ...Object.values(transfers)[0], ...Object.values(transfers)[1] }
|
||||
@@ -42,6 +43,7 @@ class TransferFilter extends Component {
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
renderFilterText = (filter) => {
|
||||
const sourceName = this.getName(filter.params.source)
|
||||
const targetName = this.getName(filter.params.target)
|
||||
|
||||
@@ -70,6 +70,7 @@ class PopulationFilters extends Component {
|
||||
advancedUser: false,
|
||||
modalOpen: false
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.state.firstRenderKludge || (this.props.populationCourses.pending === false
|
||||
&& prevProps.populationCourses.pending === true)
|
||||
@@ -81,6 +82,7 @@ class PopulationFilters extends Component {
|
||||
but note that it must be wrapped in a condition */
|
||||
}
|
||||
}
|
||||
|
||||
formatFilter = (filter) => {
|
||||
let filterToSave = {}
|
||||
if (filter.type === 'Preset') {
|
||||
@@ -107,6 +109,7 @@ class PopulationFilters extends Component {
|
||||
}
|
||||
return filterToSave
|
||||
}
|
||||
|
||||
handleSavePopulationFilters = () => {
|
||||
const preset = {
|
||||
id: uuidv4(),
|
||||
|
||||
@@ -22,4 +22,3 @@
|
||||
.dateItem {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class PopulationSearchForm extends Component {
|
||||
this.props.getDegreesAndProgrammes()
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { studyProgrammes } = this.props
|
||||
if (studyProgrammes
|
||||
@@ -175,6 +176,7 @@ class PopulationSearchForm extends Component {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleProgrammeChange = (e, { value }) => {
|
||||
const { query } = this.state
|
||||
const programme = value
|
||||
@@ -188,6 +190,7 @@ class PopulationSearchForm extends Component {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleStudyTrackChange = (e, { value }) => {
|
||||
const { query } = this.state
|
||||
const studyTrack = value
|
||||
@@ -310,7 +313,7 @@ class PopulationSearchForm extends Component {
|
||||
<label>Statistics until</label>
|
||||
<Datetime
|
||||
dateFormat="MMMM YYYY"
|
||||
value={this.getMonthValue(this.state.query.year, this.state.floatMonths)}
|
||||
defaultValue={this.getMonthValue(this.state.query.year, this.state.floatMonths)}
|
||||
onChange={value => this.handleMonthsChange(value)}
|
||||
isValidDate={current => current.isBefore(moment()) &&
|
||||
current.isAfter(this.getMinSelection(year, semesters[1] || semesters[0]))}
|
||||
@@ -319,6 +322,7 @@ class PopulationSearchForm extends Component {
|
||||
</Form.Group>
|
||||
)
|
||||
}
|
||||
|
||||
renderStudyProgrammeDropdown = (studyRights, programmesToRender) => (
|
||||
<Form.Field
|
||||
width={6}
|
||||
|
||||
@@ -29,4 +29,3 @@
|
||||
.yearControlButton {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Input, Message, Button, Table } from 'semantic-ui-react'
|
||||
import { Input, Button, Table } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { doGet } from '../../redux/postman'
|
||||
|
||||
@@ -9,72 +9,74 @@ class Postman extends Component {
|
||||
}
|
||||
|
||||
doRequest = () => {
|
||||
/* eslint-disable-next-line react/prop-types */
|
||||
this.props.doGet(this.state.route)
|
||||
}
|
||||
|
||||
dataToConsole = () => {
|
||||
console.log(this.props.postman.data)
|
||||
/* eslint-disable-next-line react/prop-types */
|
||||
console.log(this.props.postman.data)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { postman } = this.props
|
||||
const tableData = Object.entries(postman).map(([ key, value ]) => ({ key, value }))
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
fluid
|
||||
value={this.state.route}
|
||||
onChange={(_, { value: route }) => this.setState({ route })}
|
||||
placeholder="Make GET request to API"
|
||||
action={
|
||||
<Button
|
||||
content="GET"
|
||||
icon="send"
|
||||
onClick={this.doRequest}
|
||||
/>
|
||||
const { postman } = this.props
|
||||
const tableData = Object.entries(postman).map(([key, value]) => ({ key, value }))
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
fluid
|
||||
value={this.state.route}
|
||||
onChange={(_, { value: route }) => this.setState({ route })}
|
||||
placeholder="Make GET request to API"
|
||||
action={
|
||||
<Button
|
||||
content="GET"
|
||||
icon="send"
|
||||
onClick={this.doRequest}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
celled
|
||||
tableData={tableData}
|
||||
renderBodyRow={({ key, value }, i) => ({
|
||||
/>
|
||||
<Table
|
||||
celled
|
||||
tableData={tableData}
|
||||
renderBodyRow={({ key, value }, i) => ({
|
||||
key: i,
|
||||
cells: [
|
||||
{
|
||||
width: 2,
|
||||
textAlign: 'center',
|
||||
content: key,
|
||||
key: i,
|
||||
key: i
|
||||
}, {
|
||||
content: (
|
||||
<pre
|
||||
style={{
|
||||
<pre
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
maxHeight: '25em',
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
>{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
key: i + 1,
|
||||
width: 14
|
||||
}
|
||||
]})
|
||||
] })
|
||||
}
|
||||
headerRow={[{
|
||||
headerRow={[{
|
||||
content: 'Key',
|
||||
textAlign: 'center',
|
||||
key: 'key'
|
||||
}, {
|
||||
content: 'Value',
|
||||
textAlign: 'left',
|
||||
key:'value'
|
||||
key: 'value'
|
||||
}]}
|
||||
/>
|
||||
<Button fluid primary content="Data2Console" onClick={this.dataToConsole} />
|
||||
</div>
|
||||
)
|
||||
/>
|
||||
<Button fluid primary content="Data2Console" onClick={this.dataToConsole} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,4 +84,4 @@ const mapStateToProps = ({ postman }) => ({ postman })
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
doGet
|
||||
})(Postman)
|
||||
})(Postman)
|
||||
|
||||
@@ -7,16 +7,19 @@ class StackedBarChart extends Component {
|
||||
state = {
|
||||
graphOptions: {}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { options, series } = this.props
|
||||
this.setState({ graphOptions: { ...options, series } })
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.series !== prevProps.series) {
|
||||
const { options, series } = this.props
|
||||
this.setState({ graphOptions: { ...options, series } }) //eslint-disable-line
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -21,6 +21,7 @@ class StudentDetails extends Component {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (isEmpty(this.props.student) && this.props.studentNumber) {
|
||||
this.props.getStudent(this.props.studentNumber)
|
||||
|
||||
@@ -53,6 +53,7 @@ class CoursesTab extends Component {
|
||||
}
|
||||
|
||||
setCourse = selectedCourse => this.setState({ selectedCourse })
|
||||
|
||||
setSemester = selectedSemester => this.setState({ selectedSemester })
|
||||
|
||||
getCourseStats(courseid) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Container, Header, Table, Icon } from 'semantic-ui-react'
|
||||
import { Container, Header, Table, Icon, Tab } from 'semantic-ui-react'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
|
||||
import { callApi } from '../../apiConnection'
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export const routes = {
|
||||
populations: { route: '/populations', translateId: 'populations' },
|
||||
students: { route: '/students/:studentNumber?', translateId: 'students' },
|
||||
courseStatistics: { route: '/coursestatistics', translateId: 'courseStatistics' },
|
||||
courseGroups: { route: '/course-groups/:courseGroupId?', translateId: 'courseGroups', admin: true },
|
||||
teachers: { route: '/teachers/:teacherid?', translateId: 'teachers', czar: true },
|
||||
users: { route: '/users/:userid?', translateId: 'users', admin: true },
|
||||
settings: { route: '/settings', translateId: 'settings', admin: true },
|
||||
@@ -153,6 +154,10 @@ export const gradeGraphOptions = (categories, max) => ({
|
||||
text: 'Grades'
|
||||
},
|
||||
|
||||
legend: {
|
||||
enabled: false
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
categories
|
||||
},
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
],
|
||||
"oodilearn": [
|
||||
"OodiLearn"
|
||||
],
|
||||
"courseGroups": [
|
||||
"Course groups"
|
||||
]
|
||||
},
|
||||
"populationCourses": {
|
||||
@@ -276,4 +279,4 @@
|
||||
"Search for teachers"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@ export const getOodiLearnCluster = (code) => {
|
||||
|
||||
const reducer = itemreducer(prefix)
|
||||
|
||||
export default reducer
|
||||
export default reducer
|
||||
|
||||
@@ -10,4 +10,4 @@ export const getOodiLearnCourse = (code) => {
|
||||
|
||||
const reducer = itemreducer(prefix, { data: undefined })
|
||||
|
||||
export default reducer
|
||||
export default reducer
|
||||
|
||||
@@ -3,11 +3,11 @@ import { callController } from '../apiConnection/index'
|
||||
|
||||
const prefix = 'OODILEARN_COURSE_SEARCH_'
|
||||
|
||||
export const getCourses = (searchTerm) => {
|
||||
export const getCourses = () => {
|
||||
const route = '/oodilearn/courses'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
const reducer = itemreducer(prefix)
|
||||
|
||||
export default reducer
|
||||
export default reducer
|
||||
|
||||
@@ -5,7 +5,7 @@ const prefix = 'POSTMAN_GETTER_'
|
||||
|
||||
export const doGet = (route, params) => {
|
||||
if (!params) {
|
||||
return callController(route, prefix)
|
||||
return callController(route, prefix)
|
||||
}
|
||||
return callController(route, prefix, [], 'get', params, params)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,61 @@
|
||||
import { createSelector } from 'reselect'
|
||||
|
||||
const notNumeric = str => !Number(str)
|
||||
|
||||
const formatGradeName = name => name.slice(0, -2)
|
||||
|
||||
const getSearchedCourses = (state) => {
|
||||
const { settings, oodilearnCourses } = state
|
||||
const { language } = settings
|
||||
return oodilearnCourses.data.map(({ code, name }) => ({
|
||||
code,
|
||||
name: name[language]
|
||||
}))
|
||||
const { settings, oodilearnCourses } = state
|
||||
const { language } = settings
|
||||
return oodilearnCourses.data.map(({ code, name }) => ({
|
||||
code,
|
||||
name: name[language]
|
||||
}))
|
||||
}
|
||||
|
||||
const getGradeProfiles = (state) => {
|
||||
const { data } = state.oodilearnCourse
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
const result = Object.entries(data).reduce((acc, next) => {
|
||||
const [key, profile] = next
|
||||
const grade = formatGradeName(key)
|
||||
const entry = { grade, profile: {} }
|
||||
Object.entries(profile).forEach(([dimension, value]) => {
|
||||
entry.profile[dimension] = parseFloat(value.toFixed(2))
|
||||
})
|
||||
return notNumeric(grade) ? [entry, ...acc] : [...acc, entry]
|
||||
}, [])
|
||||
return result
|
||||
}
|
||||
|
||||
const courseProfileSeries = createSelector(
|
||||
[getGradeProfiles],
|
||||
(gradeProfiles) => {
|
||||
if (!gradeProfiles) {
|
||||
return null
|
||||
}
|
||||
const categories = []
|
||||
const series = {}
|
||||
const profiles = {}
|
||||
gradeProfiles.forEach(({ grade, profile }) => {
|
||||
categories.push(grade)
|
||||
profiles[grade] = profile
|
||||
Object.entries(profile).forEach(([dimension, value]) => {
|
||||
const category = series[dimension] || (series[dimension] = { name: dimension, type: 'spline', data: [] })
|
||||
category.data.push(value)
|
||||
})
|
||||
})
|
||||
return {
|
||||
categories,
|
||||
series: Object.values(series),
|
||||
profiles
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default {
|
||||
getSearchedCourses
|
||||
}
|
||||
getSearchedCourses,
|
||||
courseProfileSeries
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user