Compare commits

...
99 Commits
Author SHA1 Message Date
Rochet2andGitHub e0aaad7f95 Merge pull request #583 from UniversityOfHelsinkiCS/trunk
Improve users listing
2018-12-04 12:51:19 +02:00
Rochet2andGitHub 13cf71923d Merge branch 'master' into trunk 2018-12-04 12:35:30 +02:00
Rochet2 a83e7cf2cf Implement Users listing as a sortable table with additional information 2018-12-04 12:25:13 +02:00
Juuso LehtinenandGitHub 17dcc22224 Merge pull request #582 from UniversityOfHelsinkiCS/impovement/course-statistics-grade-mode-to-table
Improvement/course statistics grade mode to table
2018-12-04 12:11:29 +02:00
eero3 77a1efe1f9 fixed ugly imports 2018-12-04 12:03:24 +02:00
eero3 3109fd146b added grades table for resulttab panes 2018-12-04 10:55:56 +02:00
eero3 467bb5827f Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone2-frontend into impovement/course-statistics-grade-mode-to-table 2018-12-04 08:59:25 +02:00
eero3 c43593423c reorganized the whole result tabs by splitting it to smaller components 2018-12-04 08:59:15 +02:00
Juuso Lehtinen c6da893027 Don't copy dist & node_modules to docker image 2018-12-03 14:21:07 +02:00
Rochet2andGitHub 30cb47f680 Merge pull request #575 from UniversityOfHelsinkiCS/trunk
Fix statistics inputs
2018-12-03 14:16:33 +02:00
Juuso Lehtinen 440a8ddd02 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone2-frontend into trunk 2018-12-03 14:04:43 +02:00
Juuso Lehtinen a231f7e01b Updated deps 2018-12-03 14:04:33 +02:00
Rochet2 b6aee55e7a Fix statistics until changing with entrollment in population statistics 2018-12-03 13:59:20 +02:00
eero3 ec9707e694 reorganized pane creation 2018-12-03 13:31:49 +02:00
Juuso Lehtinen 45b9cfa554 Lintfix & ignore 2018-12-03 11:54:03 +02:00
Matti LuukkainenandGitHub 45a1f79f20 Merge pull request #572 from UniversityOfHelsinkiCS/course-groups
Course groups
2018-12-02 17:27:07 +02:00
Matti LuukkainenandGitHub 0a961034b2 Merge branch 'master' into course-groups 2018-12-02 15:50:41 +02:00
Matti LuukkainenandGitHub 0b1fba1856 Merge pull request #574 from UniversityOfHelsinkiCS/trunk
Course graph fixes
2018-12-02 15:48:23 +02:00
Matti LuukkainenandGitHub c8ca0c37be Merge branch 'master' into course-groups 2018-12-02 15:48:19 +02:00
eero3 ee8e8ec995 fixed negative multiplier bug on failed 2018-11-30 14:03:20 +02:00
eero3andGitHub f721161b8d Merge pull request #571 from UniversityOfHelsinkiCS/bugfix/course-graph-extra-columns
fixed failed course count
2018-11-30 13:56:38 +02:00
eero3 388c95748b fixed failed course count 2018-11-30 13:50:18 +02:00
eero3andGitHub 86cbf05690 Merge pull request #570 from UniversityOfHelsinkiCS/course-group-teachers
Course group teachers
2018-11-30 13:36:40 +02:00
eero3 31cc762187 removed unnecessary catch blocks 2018-11-30 13:34:14 +02:00
eero3 a7d9c2f567 fixed sort icon direction 2018-11-30 13:11:03 +02:00
eero3 32be98c428 added size to teacher statistic numbers 2018-11-30 10:46:31 +02:00
eero3andGitHub 8f71965070 Merge pull request #569 from UniversityOfHelsinkiCS/course-group-teachers
Course group teachers
2018-11-30 10:41:45 +02:00
eero3 d348e3b930 fixed merge conflict 2018-11-30 10:39:50 +02:00
eero3 819350d58e Merge branches 'course-group-teachers' and 'course-groups' of https://github.com/UniversityOfHelsinkiCS/oodikone2-frontend into course-group-teachers
# Conflicts:
#	src/components/CourseGroups/CourseGroup/courseGroup.css
#	src/components/CourseGroups/CourseGroup/teachers.jsx
2018-11-30 10:38:40 +02:00
eero3 62caf3e014 pleased css lint 2018-11-29 16:56:15 +02:00
eero3 c7396f2096 added sort for teachers 2018-11-29 16:48:29 +02:00
Juuso Lehtinen de8b08ab8a Add sorting 2018-11-29 15:41:05 +02:00
Juuso Lehtinen 8f0558e2b7 Changed column ordering & fix lint errors 2018-11-29 15:06:57 +02:00
eero3andGitHub fbe3a6863f Merge pull request #568 from UniversityOfHelsinkiCS/course-group-teachers
Course group teachers
2018-11-29 13:33:33 +02:00
eero3 8a0cfb86f2 added sort for courses on frontend 2018-11-29 10:58:44 +02:00
eero3 fe38a89e97 Merge branch 'course-groups' of https://github.com/UniversityOfHelsinkiCS/oodikone2-backend into course-group-teachers
# Conflicts:
#	src/services/courseGroups.js
2018-11-28 18:03:14 +02:00
Juuso Lehtinen f90fb14f30 Fix lint error 2018-11-28 17:29:18 +02:00
Juuso Lehtinen b7c54d9686 Show aggregated data for course groups 2018-11-28 17:19:50 +02:00
eero3 cc66fa20f5 polished course fetching and showing 2018-11-28 16:43:03 +02:00
Juuso Lehtinen c4d9a6652f Credits & students 2018-11-28 16:33:10 +02:00
eero3 11c0ceed15 added show only active toggle to teachers 2018-11-28 13:58:56 +02:00
eero3 58844c9999 added teachers and courses components 2018-11-27 17:52:23 +02:00
ikuisma 80bb30b715 'Flipped' legend-click functionality to make only that line visible. 2018-11-27 16:23:57 +02:00
ikuisma 824f2e8ece Fixed grade type checking 2018-11-27 12:44:43 +02:00
ikuisma ed1d67a241 Fix unique key prop in ProfileTable 2018-11-27 11:55:24 +02:00
ikuisma 5a587b69ae Fixed prop-types 2018-11-27 11:55:10 +02:00
ikuisma 7ae55b63ca Enable lint in OodiLearn 2018-11-27 11:46:04 +02:00
ikuisma 6c3bc2bedb New and improved OodiLearn course mega-view 2018-11-27 11:45:40 +02:00
eero3 d1eb3f093a added navigation and sensible way of doing it with routes 2018-11-27 09:08:19 +02:00
Matti LuukkainenandGitHub cad37de836 Merge pull request #565 from UniversityOfHelsinkiCS/trunk
enable when passed to all users
2018-11-27 09:04:27 +02:00
Matti Luukkainen 517dad91e6 enable when passed to all users 2018-11-27 08:58:20 +02:00
eero3 e7ff1bd760 Merge branches 'course-groups' and 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone2-frontend into course-groups 2018-11-27 08:22:14 +02:00
Matti LuukkainenandGitHub 94f4cdb5d0 Merge pull request #564 from UniversityOfHelsinkiCS/trunk
fixed filtering on population course stats
2018-11-27 08:07:42 +02:00
Matti LuukkainenandGitHub a89b7f1240 Merge pull request #563 from UniversityOfHelsinkiCS/bug-fix/population-filters-and-course-stat-chart
Bug fix/population filters and course stat chart
2018-11-27 07:58:45 +02:00
eero3 443f6d6eb1 moved chart max value count to SingleCourseStats and fixed it 2018-11-27 07:53:29 +02:00
eero3 015ea209aa added loader to fetch courses 2018-11-27 07:52:17 +02:00
eero3 0f29fec96b removed confusing legend from grade chart, 2018-11-27 07:51:24 +02:00
eero3 ef7ba0d44d fixed filtering on population course stats 2018-11-27 07:32:39 +02:00
Matti LuukkainenandGitHub 6218c8535f Merge pull request #562 from UniversityOfHelsinkiCS/trunk
toggle graphs off from nonadmin
2018-11-26 20:17:17 +02:00
Matti Luukkainen f32f106f7c toggle graphs off from nonadmin 2018-11-26 20:06:41 +02:00
Matti LuukkainenandGitHub c1624c9a51 Merge pull request #558 from UniversityOfHelsinkiCS/trunk
toggle when course taken -tab off from nonadmins
2018-11-26 19:41:02 +02:00
Matti Luukkainen 32de98d1b7 toggle when course taken -tab off from nonadmins 2018-11-26 19:31:44 +02:00
eero3 9c6f4915cc initial commit for course groups feature 2018-11-26 17:35:20 +02:00
Matti LuukkainenandGitHub 0feeb74929 Merge pull request #557 from UniversityOfHelsinkiCS/trunk
population course page improvements
2018-11-26 16:34:42 +02:00
eero3andGitHub 8e5c4f2ec3 Merge pull request #555 from UniversityOfHelsinkiCS/course-passed
Show semesters when courses are passed
2018-11-26 16:28:09 +02:00
Juuso Lehtinen a52ee5da00 max-len -> warn 2018-11-26 16:21:54 +02:00
Juuso LehtinenandGitHub 2fe2707f18 Merge pull request #554 from UniversityOfHelsinkiCS/bugfix/course-statistics-re-enable-grade-graphs
Bugfix/course statistics re enable grade graphs
2018-11-26 16:17:36 +02:00
Juuso Lehtinen 76734c3149 fixed sorting 2018-11-26 16:16:49 +02:00
eero3 c00f738a1d changed max-len to warn after facing multiple problems 2018-11-26 16:12:40 +02:00
eero3 dd812de4f3 changed max-len lint rule 2018-11-26 16:08:01 +02:00
eero3 1963e742e9 fixed course statistics graph and enabled it for non-admin users 2018-11-26 16:06:50 +02:00
Uotila Terho dbe8170185 quickfix for studentpage 2018-11-26 15:55:43 +02:00
Juuso Lehtinen 180c0fe854 Data should be initially sorted/filtered 2018-11-26 14:59:31 +02:00
Juuso Lehtinen fe16b3e6f5 Fix after merge 2018-11-26 12:59:53 +02:00
Juuso Lehtinen d890c166f0 Merge branch 'trunk' into course-passed 2018-11-26 12:50:46 +02:00
Juuso Lehtinen 4025b0518a Cumulative passing statistics 2018-11-26 12:38:03 +02:00
eero3andGitHub d9e59fd6ae Merge pull request #553 from UniversityOfHelsinkiCS/improvement/add-lines-between-class-members-to-lint
Improvement/add lines between class members to lint
2018-11-26 12:24:42 +02:00
eero3 c0ef721111 fixed lint problems caused by the rule 2018-11-26 12:21:29 +02:00
eero3 1801351d64 added lines-between-class-members rule 2018-11-26 12:21:07 +02:00
Juuso LehtinenandGitHub 7aa447c571 Merge pull request #552 from UniversityOfHelsinkiCS/improvement/course-list-link-from-course-to-course-search
Improvement/course list link from course to course search
2018-11-26 10:52:42 +02:00
eero3 7660f83b26 reverted to version before merge 2018-11-26 10:48:59 +02:00
eero3 0c9e6a3ee4 fixed lint errors 2018-11-26 10:46:56 +02:00
eero3 a82af6fd30 reverted to version before merge 2018-11-26 10:46:21 +02:00
eero3 db94757c79 Merge branches 'improvement/course-list-link-from-course-to-course-search' and 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone2-frontend into improvement/course-list-link-from-course-to-course-search 2018-11-26 10:45:07 +02:00
eero3 a5c4d7031e fixed minor lint errors 2018-11-26 10:41:02 +02:00
eero3 69c498a070 added prefill to courseStatistics search 2018-11-26 10:38:06 +02:00
Juuso Lehtinen bfb599617d Refactor buttons 2018-11-23 16:57:33 +02:00
Juuso Lehtinen 1bc5a3528a Refactor & rename 2018-11-23 16:23:51 +02:00
eero3 ffa60d5234 changed sums to reduces 2018-11-23 16:07:14 +02:00
eero3 9644d6ec23 cleaned table body creation 2018-11-23 15:39:39 +02:00
Juuso Lehtinen adef0032c3 Merge branch 'trunk' into course-passed 2018-11-23 14:47:02 +02:00
Juuso Lehtinen 1456b662d7 Add new table which shows when course is passed 2018-11-23 14:40:15 +02:00
eero3 69dc4f61c9 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone2-frontend into improvement/course-list-link-from-course-to-course-search
# Conflicts:
#	src/components/CourseStatistics/SortableTable/index.jsx
2018-11-23 14:37:51 +02:00
eero3 b0fb665d6b more cleaning 2018-11-23 14:36:33 +02:00
eero3 34ccf7f38e cleaned rendering slowness 2018-11-23 14:06:59 +02:00
eero3 4dc486e1bc fixed simple lint errors 2018-11-23 12:26:30 +02:00
eero3 ef6ecad149 updated es-lint-plugin-react and added oodilearn to eslintignore 2018-11-23 12:25:04 +02:00
eero3 543e9de6d5 created constants for table column names & sort orders 2018-11-22 11:38:31 +02:00
eero3 fa61460388 Refactored PopulationCourses as a functional component since it has no state 2018-11-21 16:08:53 +02:00
70 changed files with 6767 additions and 4609 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/*
dist/*
+1 -1
View File
@@ -1,2 +1,2 @@
dist/
config/
config/
+4 -1
View File
@@ -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"
]
}
}
+4337 -3625
View File
File diff suppressed because it is too large Load Diff
+42 -37
View File
@@ -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"
}
}
}
+6
View File
@@ -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
+34
View File
@@ -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
@@ -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,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
+89 -34
View File
@@ -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>
)
}
+1
View File
@@ -25,6 +25,7 @@ class LanguageChooser extends Component {
this.props.switchLanguage(name, value)
await setUserLanguage(value)
}
render() {
return (
<Dropdown
+21 -19
View File
@@ -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);
}
+5 -2
View File
@@ -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
+1
View File
@@ -5,6 +5,7 @@ import Course from './Course'
class ContentTabs extends Component {
state={}
render() {
return (
<Tab
+9 -11
View File
@@ -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
+81 -7
View File
@@ -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)
+94 -40
View File
@@ -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)
+28 -12
View File
@@ -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
}
}
+6 -4
View File
@@ -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)
+35 -14
View File
@@ -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 -3
View File
@@ -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>
)
}
}
+397 -334
View File
@@ -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;
}
+28 -32
View File
@@ -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;
}
+37 -35
View File
@@ -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)
+3
View File
@@ -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>
+1
View File
@@ -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 -2
View File
@@ -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'
+5
View File
@@ -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
},
+4 -1
View File
@@ -89,6 +89,9 @@
],
"oodilearn": [
"OodiLearn"
],
"courseGroups": [
"Course groups"
]
},
"populationCourses": {
@@ -276,4 +279,4 @@
"Search for teachers"
]
}
}
}
+1 -1
View File
@@ -10,4 +10,4 @@ export const getOodiLearnCluster = (code) => {
const reducer = itemreducer(prefix)
export default reducer
export default reducer
+1 -1
View File
@@ -10,4 +10,4 @@ export const getOodiLearnCourse = (code) => {
const reducer = itemreducer(prefix, { data: undefined })
export default reducer
export default reducer
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)
}
+55 -8
View File
@@ -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
}