Compare commits

...
56 Commits
Author SHA1 Message Date
Otto Ahoniemi 33c0aa71d9 Destructure props on function declaration 2021-11-17 11:02:59 +02:00
Otto Ahoniemi 2e4572c871 Drop use of hocs in populationstudents 2021-11-17 11:01:28 +02:00
Otto Ahoniemi f6b14777fe Combine tabs to studenttable 2021-11-17 10:37:13 +02:00
Otto Ahoniemi f251f9456e Make studyprogramme and associated year sortable 2021-11-17 10:25:59 +02:00
Petri Palmu f364eb4f67 few changes to the population statistics tests 2021-11-16 14:01:03 +02:00
Petri Palmu 4896f9e458 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-12 12:43:07 +02:00
Petri Palmu 096535e470 little change how updater handles open uni courses 2021-11-12 12:42:56 +02:00
Otto Ahoniemi 7c949b47a7 Initial refactoring of populationStudents from class component to functional component 2021-11-11 17:01:55 +02:00
Otto AhoniemiandGitHub c3b7d96b06 Merge pull request #3304 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/axios-0.21.2
Bump axios from 0.21.1 to 0.21.2 in /services/frontend
2021-11-11 15:46:17 +02:00
Otto AhoniemiandGitHub 9a3783bc45 Merge pull request #3305 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/tmpl-1.0.5
Bump tmpl from 1.0.4 to 1.0.5 in /services/frontend
2021-11-11 15:46:10 +02:00
Otto AhoniemiandGitHub 4882d1d9d3 Merge pull request #3306 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/tar-6.1.11
Bump tar from 6.1.0 to 6.1.11 in /services/frontend
2021-11-11 15:46:00 +02:00
dependabot[bot]andGitHub 9575d86d9c Bump tar from 6.1.0 to 6.1.11 in /services/frontend
Bumps [tar](https://github.com/npm/node-tar) from 6.1.0 to 6.1.11.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v6.1.0...v6.1.11)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 12:57:47 +00:00
dependabot[bot]andGitHub 85c7e86ed9 Bump tmpl from 1.0.4 to 1.0.5 in /services/frontend
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 12:57:46 +00:00
dependabot[bot]andGitHub d527c6ba5f Bump axios from 0.21.1 to 0.21.2 in /services/frontend
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 12:57:32 +00:00
Otto AhoniemiandGitHub 6d5b91782c Merge pull request #3287 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/backend/validator-13.7.0
Bump validator from 13.6.0 to 13.7.0 in /services/backend
2021-11-11 14:56:48 +02:00
Otto AhoniemiandGitHub b8762aae34 Merge pull request #3288 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/userservice/validator-13.7.0
Bump validator from 13.6.0 to 13.7.0 in /services/userservice
2021-11-11 14:56:41 +02:00
Otto Ahoniemi 4da78df1e6 Refactor studyguidancegroup logic 2021-11-11 14:12:39 +02:00
Otto Ahoniemi 93258ad12a Fix toggling between panel statuses throwing user away from current panel 2021-11-11 13:29:19 +02:00
Otto Ahoniemi 0028b69661 Revert tunkki solution for getting study guidance group population instead of generic population from redux 2021-11-11 12:52:27 +02:00
Otto Ahoniemi ef902b1c0c Fix population stats failing 2021-11-11 08:10:25 +02:00
Otto Ahoniemi d8e3ee092f Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-11 07:48:49 +02:00
Mitja Karhusaari 36e8c2f7f8 Fix lint 2021-11-11 05:01:21 +02:00
Otto Ahoniemi 8763b1868e Prevent manual typing to datetime field 2021-11-11 02:40:51 +02:00
Otto Ahoniemi 700d799b00 Add more filters to study guidance groups 2021-11-11 02:31:37 +02:00
Otto Ahoniemi b0ecc3d4f5 Fix showing students in study guidance groups, add better indicators for links 2021-11-11 02:27:43 +02:00
Otto Ahoniemi e681d5a94f Style betterments 2021-11-11 01:42:01 +02:00
Otto Ahoniemi 980cd8ecf0 Don't load mandatory programmes if already in state 2021-11-11 01:17:24 +02:00
Otto Ahoniemi 357c0b3f44 Make it possible to toggle showing programme structure / flat list in courses of population 2021-11-11 01:12:43 +02:00
Otto Ahoniemi 6467f5046e Make it possible to toggle showing credits from associated year 2021-11-10 23:09:57 +02:00
Otto Ahoniemi 21ff440ebd Cleanup unused props and proptypes 2021-11-10 23:07:23 +02:00
Otto Ahoniemi 73228fb26f Allow absolute imports 2021-11-10 21:46:43 +02:00
Otto Ahoniemi 2c1807fe10 Migrate studyguidancegroup redux logic to rtk query, fix some issues in overview 2021-11-10 21:02:30 +02:00
Otto Ahoniemi 50091a3098 Add RTK query baseapi and migrate studyguidancegroups to use it 2021-11-10 20:59:12 +02:00
Otto Ahoniemi 3ba7965402 Add original token to state and export dev headers in order to use them in rtk query 2021-11-10 20:32:04 +02:00
Otto Ahoniemi 067a53a072 Return copy instead of modifying course array 2021-11-10 16:41:39 +02:00
Otto Ahoniemi 9a3956832d Random fixes' 2021-11-10 00:40:28 +02:00
Otto Ahoniemi 831b638c99 Migrate populationcoursestatsflat from hoc to hooks, fix state updates on unmounted 2021-11-09 13:32:51 +02:00
Otto Ahoniemi 38c343b8d2 Add study guidance group filterset 2021-11-08 13:33:24 +02:00
Otto Ahoniemi c05346ad9a proptype cleaning 2021-11-08 13:25:33 +02:00
Petri Palmu dd12cd3626 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-08 10:58:22 +02:00
Petri Palmu 55eebe32a6 groomed course search method 2021-11-08 10:58:05 +02:00
Mitja Karhusaari 58592df2a0 Avoid accidental indirect dependency 2021-11-06 01:39:04 +02:00
Mitja Karhusaari dd110d21e3 🎉 Happy birthday to this one guy in the test dataset 2021-11-06 01:22:27 +02:00
Mitja Karhusaari 88443f0117 Scroll the instructions popup if neccessary when clicking a "See more..." info link 2021-11-06 00:37:10 +02:00
Mitja Karhusaari accd5c9120 Add a toggle for choosing between credit and student counts 2021-11-06 00:37:10 +02:00
Mitja Karhusaari c11f5d78ba Remove selectedStudents prop from the graph component
The component does not use the non-selected students for anything,
so we can just pass the pre-filtered set of students to the component.

The previous method of filtering students based on student numbers
in-component caused problems with the credit date filter. As this filter
not only filtered the students, but also modified them, the list of
filtered students needs to be passed to the components. Filtering the
whole population of students based on student numbers afterwards would
ignore these changes.
2021-11-06 00:09:38 +02:00
Mitja Karhusaari 9f4466d246 Convert PopulationStatistics into a functional component 2021-11-06 00:08:29 +02:00
Mitja Karhusaari 5504f02c31 Refoactor how credit graph data ranges are passed 2021-11-06 00:08:16 +02:00
Mitja Karhusaari 863e84e226 Add course credit date filter onto the population statistics page 2021-11-05 23:20:40 +02:00
Mitja Karhusaari 0ce965abe3 Rewrite the credit accumulation graph using a functional component 2021-11-05 23:20:40 +02:00
Petri Palmu e9242b3063 update frontend packages 2021-11-04 08:22:58 +02:00
Petri Palmu 6ccf320bb2 Fix to the open uni programme trends 2021-11-04 08:14:03 +02:00
Petri Palmu 4e9b579843 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-04 08:06:13 +02:00
Petri Palmu 985084864c Added open uni programme view to the trends 2021-11-04 08:05:57 +02:00
dependabot[bot]andGitHub 8099dff1b6 Bump validator from 13.6.0 to 13.7.0 in /services/userservice
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.6.0 to 13.7.0.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.6.0...13.7.0)

---
updated-dependencies:
- dependency-name: validator
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-03 19:43:02 +00:00
dependabot[bot]andGitHub 1528ff3d80 Bump validator from 13.6.0 to 13.7.0 in /services/backend
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.6.0 to 13.7.0.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.6.0...13.7.0)

---
updated-dependencies:
- dependency-name: validator
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-03 19:42:56 +00:00
46 changed files with 1258 additions and 1322 deletions
+2 -2
View File
@@ -89,7 +89,7 @@ describe('Population Statistics', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('40')
checkFilteringResult(43)
checkFilteringResult(42)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
})
@@ -190,7 +190,7 @@ describe('Course Statistics', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('40')
checkFilteringResult(33)
checkFilteringResult(32)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
})
+7 -3
View File
@@ -64,6 +64,7 @@ describe('Population Statistics tests', () => {
it('Population statistics is usable on general level', () => {
cy.visit(pathToCSBach2017)
cy.cs('filtered-students')
setPopStatsUntil('toukokuu 2020')
cy.get('.card').within(() => {
@@ -74,7 +75,8 @@ describe('Population Statistics tests', () => {
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
})
cy.contains('Courses of population').click({ force: true })
cy.cs('filtered-students')
cy.contains('Courses of population').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
// eslint-disable-next-line cypress/no-unnecessary-waiting
@@ -165,7 +167,9 @@ describe('Population Statistics tests', () => {
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
cy.visit(pathToCSMaster2019)
cy.contains('Credit statistics').click()
cy.cs('filtered-students')
cy.contains('Credit statistics')
cy.get("[data-cy='credit-statistics']").click()
cy.get('.credits-gained-divider').click()
cy.get("[data-cy='credits-gained-table-Avoin väylä']").should('exist')
cy.get("[data-cy='credits-gained-table-Yhteispisteet']").should('exist')
@@ -175,7 +179,7 @@ describe('Population Statistics tests', () => {
it('Credit Statistics, Statistics pane works', () => {
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Credit statistics').click()
cy.contains('Credit statistics').click({ force: true })
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 149 Students')
+3 -3
View File
@@ -3353,9 +3353,9 @@
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
},
"validator": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz",
"integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg=="
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
},
"vary": {
"version": "1.1.2",
+3 -33
View File
@@ -368,39 +368,9 @@ const byNameAndOrCodeLike = async (name, code) => {
})
const courses = rawCourses
.map(
({
id,
code,
name,
latest_instance_date,
is_study_module,
coursetypecode,
startdate,
max_attainment_date,
min_attainment_date,
createdAt,
updatedAt,
substitutions,
organizations,
}) => {
return {
id,
code,
name,
latest_instance_date,
is_study_module,
coursetypecode,
startdate,
max_attainment_date,
min_attainment_date,
createdAt,
updatedAt,
substitutions,
organizations: organizations.map(o => o.id),
}
}
)
.map(course => {
return { ...course.dataValues, organizations: course.organizations.map(o => o.id) }
})
.sort(a => (a.code.match(/^[A-Za-z]{3}[0-9]{1}/) ? -1 : 1))
let substitutionGroupIndex = 0
@@ -193,7 +193,7 @@ const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias
return sequelize.query(
`
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
o.code AS organizationcode, co.code, co.id
o.code AS organizationcode, co.code, co.id, co.name
FROM credit cr
INNER JOIN course co ON cr.course_code = co.code
INNER JOIN course_providers cp ON cp.coursecode = co.id
@@ -245,6 +245,27 @@ const getOrgStats = (stats, orgId) =>
},
}
const getOrgsOfOpenUniReqularCounterparts = async codes => {
const rows = await sequelize.query(
`
SELECT co.id, co.code, cp.organizationcode, org.code AS organization
FROM course co, course_providers cp, organization org
WHERE co.code IN(:codes)
AND co.id = cp.coursecode
AND org.id = cp.organizationcode;
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { codes },
}
)
return rows.reduce((acc, { code, organization }) => {
acc[code] = organization
return acc
}, {})
}
const calculateStatusStatistics = async (unixMillis, showByYear) => {
const YEAR_TO_MILLISECONDS = 31556952000
/* Memoize parses booleans into strings... */
@@ -314,6 +335,21 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
Organization.findAll(),
])
const isOpenUniCourseCode = code => code.match(/^AY?(.+?)(?:en|fi|sv)?$/)
const unifyOpenUniversity = code => {
const regexresult = isOpenUniCourseCode(code)
if (!regexresult) return code
return regexresult[1]
}
const fromAyToNormalCourses = [
...new Set(_.flatten(yearlyOpenUniAccCredits).map(c => unifyOpenUniversity(c.code))),
...new Set(_.flatten(yearlyOpenUniTotalCredits).map(c => unifyOpenUniversity(c.code))),
]
const orgsOfOpenUniReqularCounterparts = await getOrgsOfOpenUniReqularCounterparts(fromAyToNormalCourses)
const facultyProgrammes = facultiesAndProgrammesForTrends
const yearlyOrgStatPromises = yearRange.map(async year => {
@@ -380,7 +416,6 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
}, {})
programmeToFaculties['H906'] = ['H906']
programmeToFaculties['H930'] = ['H930']
const providerToProgramme = elementDetails.reduce((res, curr) => {
const [p] = mapToProviders([curr.code])
@@ -418,17 +453,64 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return _.mergeWith(a, b, sumMerge)
}
const refactoredOpenUniCredits = [..._.flatten(yearlyOpenUniAccCredits), ..._.flatten(yearlyOpenUniTotalCredits)].map(
ele => {
const org = orgsOfOpenUniReqularCounterparts[unifyOpenUniversity(ele.code)]
return {
...ele,
organizationcode: org ? org : 'H930',
}
}
)
/* Calculate course level stats and group by providers */
const coursesGroupedByProvider = Object.entries(
_.groupBy(
[
..._.flatten(yearlyAccCredits),
..._.flatten(yearlyTotalCredits),
..._.flatten(yearlyOpenUniAccCredits),
..._.flatten(yearlyOpenUniTotalCredits),
],
'organizationcode'
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'organizationcode')
).reduce((acc, [organizationcode, courseCredits]) => {
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
(acc, [courseCode, yearlyInstances]) => {
acc[courseCode] = {
yearly: {},
name: yearlyInstances[0].name,
type: 'course',
code: courseCode,
}
const array = _.range(2017, startYear + 1)
array.forEach(year => {
acc[courseCode]['yearly'][year] = {}
acc[courseCode]['yearly'][year]['acc'] = 0
acc[courseCode]['yearly'][year]['accStudents'] = 0
acc[courseCode]['yearly'][year]['total'] = undefined
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
})
yearlyInstances.forEach(instance => {
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
if (instance.acc !== undefined) {
acc[courseCode]['yearly'][instance.year]['acc'] = instance.acc
acc[courseCode]['yearly'][instance.year]['accStudents'] = Number(instance.students)
} else {
acc[courseCode]['yearly'][instance.year]['total'] = instance.total
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
}
})
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
acc[courseCode]['previousStudents'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'accStudents']) || 0
return acc
},
{}
)
return acc
}, {})
const openUniCoursesGroupedByProvider = Object.entries(
_.groupBy(refactoredOpenUniCredits, 'organizationcode')
).reduce((acc, [organizationcode, courseCredits]) => {
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
(acc, [courseCode, yearlyInstances]) => {
@@ -477,7 +559,7 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
const programme = providerToProgramme[organizationcode]
if (programme && programme.code) {
if (programme.code === 'H906' || programme.code === 'H930') {
if (programme.code === 'H906') {
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
@@ -519,6 +601,34 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return acc
}, {})
const openUniGroupedByProgramme = Object.entries(openUniCoursesGroupedByProvider).reduce(
(acc, [organizationcode, courses]) => {
const programme = providerToProgramme[organizationcode]
if (programme && programme.code) {
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
acc[programme.code] = {
type: 'programme',
code: programme.code,
name: programme.name,
drill: courses,
yearly: _.mergeWith({}, ...yearlyValues, mergele),
current: _.sumBy(courseValues, 'current') | undefined,
previous: _.sumBy(courseValues, 'previous') | undefined,
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
}
return acc
}
return acc
},
{}
)
/* Group programmes into faculties and calculate faculty level stats */
const groupedByFaculty = Object.entries(groupedByProgramme).reduce((acc, [programmeCode, programmeStats]) => {
const facultyCodes = programmeToFaculties[programmeCode]
@@ -569,7 +679,31 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return acc
}, {})
return groupedByFaculty
const openUniGroupedByFaculty = Object.entries(openUniGroupedByProgramme).reduce(
(acc, [programmeCode, programmeStats]) => {
const facultyCode = 'H930'
if (!acc[facultyCode]) {
acc[facultyCode] = {
type: 'faculty',
code: facultyCode,
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly: {},
current: 0,
previous: 0,
}
}
acc[facultyCode]['drill'][programmeCode] = programmeStats
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
acc[facultyCode]['current'] += programmeStats.current
acc[facultyCode]['previous'] += programmeStats.previous
return acc
},
{}
)
return { ...groupedByFaculty, ...openUniGroupedByFaculty }
}
const getStatus = async (unixMillis, showByYear, doRefresh = false) => {
+3 -2
View File
@@ -1,7 +1,8 @@
{
"root": true,
"env": {
"browser": true
"browser": true,
"node": true
},
"parser": "@babel/eslint-parser",
"parserOptions": {
@@ -30,7 +31,7 @@
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/prefer-stateless-function": "error",
"import/no-unresolved": "error",
"import/no-unresolved": "off",
"react/jsx-no-bind": "error",
"prefer-object-spread/prefer-object-spread": "error",
"babel/no-invalid-this": "error",
+6
View File
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": "src"
},
"include": ["src"]
}
+13 -13
View File
@@ -3160,11 +3160,11 @@
"integrity": "sha512-99FZt8qS/xukgxU/8daV8WT7wAakqBzt6lF3XCweO6pwcf50/NgxxBj6ZC7/ejR+F4PWeFpkb9lAMH3y2quBXA=="
},
"axios": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
"integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
"requires": {
"follow-redirects": "^1.10.0"
"follow-redirects": "^1.14.0"
}
},
"axobject-query": {
@@ -13256,9 +13256,9 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"reselect": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.1.tgz",
"integrity": "sha512-Jjt8Us6hAWJpjucyladHvUGR+q1mHHgWtGDXlhvvKyNyIeQ3bjuWLDX0bsTLhbm/gd4iXEACBlODUHBlLWiNnA=="
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.2.tgz",
"integrity": "sha512-wg60ebcPOtxcptIUfrr7Jt3h4BR86cCW3R7y4qt65lnNb4yz4QgrXcbSioVsIOYguyz42+XTHIyJ5TEruzkFgQ=="
},
"resolve": {
"version": "1.18.1",
@@ -14919,9 +14919,9 @@
"integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="
},
"tar": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
"integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
"version": "6.1.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
"integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
"requires": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -15143,9 +15143,9 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"tmpl": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE="
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
},
"to-arraybuffer": {
"version": "1.0.1",
+1 -1
View File
@@ -9,7 +9,7 @@
"@reduxjs/toolkit": "^1.6.2",
"@sentry/browser": "^6.9.0",
"@sentry/tracing": "^6.11.0",
"axios": "^0.21.1",
"axios": "^0.21.2",
"formik": "^2.2.9",
"highcharts": "^9.1.2",
"highcharts-custom-events": "^3.0.10",
+22 -1
View File
@@ -1,10 +1,11 @@
import axios from 'axios'
import * as Sentry from '@sentry/browser'
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { getMocked, setMocking } from '../common'
import { apiBasePath, isDev } from '../conf'
import { loginPrefix, logoutPrefix } from '../redux/auth'
const getHeaders = () => {
export const getHeaders = () => {
// Set up dev user for development environment, mimicking production admin user
const devUserHeaders = {
uid: 'mluukkai',
@@ -140,3 +141,23 @@ export const handleAuth = store => next => async action => {
}
}
}
// Redux-toolkit query based API
// Api can be extended with enhanceEndpoints method
// All tags used for invalidating cache must be defined here
export const RTKApi = createApi({
reducerPath: 'api',
tagTypes: ['StudyGuidanceGroups'],
baseQuery: fetchBaseQuery({
baseUrl: apiBasePath,
prepareHeaders: (headers, { getState }) => {
// Get token from state and add it to headers
const token = getState().auth.encodedToken
if (token) headers.set('x-access-token', token)
// Add possible default headers
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
return headers
},
}),
endpoints: () => ({}),
})
@@ -28,7 +28,7 @@ const mapValueToRange = (x, min1, max1, min2, max2) => {
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
}
const actionTooltips = {
const settingDefinitions = {
showByYear: {
label: 'Näytä kalenterivuosittain',
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
@@ -36,6 +36,7 @@ const actionTooltips = {
Kun tämä valinta on käytössä, vuosittaiset ajanjaksot lasketaan kalenterivuoden alusta sen loppuun.
Muulloin vuosittaiset ajanjaksot lasketaan lukukauden alusta seuraavan lukukauden alkuun.
`,
defaultValue: false,
},
showYearlyValues: {
@@ -49,6 +50,7 @@ const actionTooltips = {
tai lukuvuoden alusta "Näytä päivänä"-valintaa vastaavaan päivämäärään tuona vuotena. Luvut näytetään muodossa *<kerynyt>*/*<kokonais>*.
Kuluvalta vuodelta näytetään ainoastaan kertynyt tilasto.
`,
defaultValue: false,
},
showRelativeValues: {
@@ -60,6 +62,7 @@ const actionTooltips = {
Opiskelijoiden määrä perustuu ajanjaksolla kyseisen organisaation alaisista kursseista suoritusmerkintöjä saaneiden opiskelijoiden määrään.
Luku siis sisältää muutkin kuin kyseiseen ohjelman tai osaston opinto-oikeuden omaavat opiskelijat.
`,
defaultValue: false,
},
showCountingFrom: {
@@ -71,10 +74,31 @@ const actionTooltips = {
lasketaan kertyneet tilastot (vrt. lukuvuosien kokonaistilastot) kunkin lukuvuoden alusta
tätä päivämäärää vastaavaan päivään kyseisenä lukuvuonna.
`,
defaultValue: null,
},
showStudentCounts: {
label: 'Näytä kurssien opiskelijamäärät',
short: 'Näyttää suoritettujen opintopisteiden sijasta opiskelijoiden määrät kurssitason näkymässä.',
long: `
Oletuksena kurssitason näkymässä näytetään organisaatio- ja ohjelmatason näkymien tapaan suoritettujen opintopisteiden
kokonaismäärä. Kun tämä valinta on käytössä, tämän sijasta näytettävät luvut vastaavat kurssin suorittaneiden
*yksilöityjen* opiskelijoiden määrää. Kukin opiskelija lasketaan siis vain kerran tähän tilastoon.
`,
defaultValue: false,
},
}
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
const StatusContainer = ({
stats,
handleClick,
min1,
max1,
showYearlyValues,
showRelativeValues,
showByYear,
showStudentCounts,
}) => {
const title = getTextIn(stats.name)
const clickable = !!stats.drill
@@ -186,7 +210,7 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
{year}
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
</b>
{stats.type === 'course' ? (
{stats.type === 'course' && showStudentCounts ? (
<>
<span className="year-value">{yearStats.accStudents}</span>
{!!yearStats.totalStudents && (
@@ -218,7 +242,7 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
}
const StatusContent = ({ data, settings, onDrill }) => {
const { showByYear, showRelativeValues, showYearlyValues } = settings
const { showByYear, showRelativeValues, showYearlyValues, showStudentCounts } = settings
const orderedAbsDiffs = _.chain(data)
.map(({ current, currentStudents, previous, previousStudents }) => {
@@ -257,6 +281,7 @@ const StatusContent = ({ data, settings, onDrill }) => {
handleClick={handleClick}
showRelativeValues={showRelativeValues}
showYearlyValues={showYearlyValues}
showStudentCounts={showStudentCounts}
min1={-medianDiff * 2}
max1={medianDiff * 2}
showByYear={showByYear}
@@ -316,7 +341,7 @@ const WithHelpTooltip = ({ children, tooltip, onOpenDetails, ...rest }) => {
const isValidDate = d => moment().diff(moment(d)) > 0
const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
const { showYearlyValues, showByYear, showRelativeValues, selectedDate } = settings
const { selectedDate } = settings
const DATE_FORMAT = 'DD.MM.YYYY'
const changeSetting = (property, value) => {
@@ -334,46 +359,36 @@ const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
alignItems: 'center',
}
const createSettingToggle = key => (
<div style={itemStyles}>
<WithHelpTooltip tooltip={settingDefinitions[key].short} onOpenDetails={() => onOpenDetails(key)}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={settingDefinitions[key].label}
checked={settings[key]}
onChange={() => changeSetting(key, !settings[key])}
/>
</WithHelpTooltip>
</div>
)
return (
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, flexDirection: 'column' }}>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showYearlyValues.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showYearlyValues.label}
checked={showYearlyValues}
onChange={() => changeSetting('showYearlyValues', !showYearlyValues)}
/>
</WithHelpTooltip>
</div>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showByYear.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showByYear.label}
checked={showByYear}
onChange={() => changeSetting('showByYear', !showByYear)}
/>
</WithHelpTooltip>
</div>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showRelativeValues.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showRelativeValues.label}
checked={showRelativeValues}
onChange={() => changeSetting('showRelativeValues', !showRelativeValues)}
/>
</WithHelpTooltip>
</div>
{createSettingToggle('showYearlyValues')}
{createSettingToggle('showByYear')}
{createSettingToggle('showRelativeValues')}
{createSettingToggle('showStudentCounts')}
<div style={itemStyles}>
<Form>
<Form.Field
error={selectedDate !== null && !isValidDate(selectedDate)}
style={{ display: 'flex', alignItems: 'center' }}
>
<WithHelpTooltip tooltip={actionTooltips.showCountingFrom.short} onOpenDetails={onOpenDetails}>
<span style={{ fontSize: '0.9em' }}>{actionTooltips.showCountingFrom.label}</span>
<WithHelpTooltip
tooltip={settingDefinitions.showCountingFrom.short}
onOpenDetails={() => onOpenDetails('showCountingFrom')}
>
<span style={{ fontSize: '0.9em' }}>{settingDefinitions.showCountingFrom.label}</span>
</WithHelpTooltip>
<Datetime
className="status-date-time-input"
@@ -395,13 +410,12 @@ const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
)
}
const getDefaultSettings = () => ({
showYearlyValues: true,
showByYear: false,
showRelativeValues: false,
showSettings: true,
selectedDate: null,
})
const getDefaultSettings = () =>
_.chain(settingDefinitions)
.toPairs()
.map(([key, { defaultValue }]) => [key, defaultValue])
.fromPairs()
.value()
const Status = () => {
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
@@ -521,6 +535,23 @@ const Status = () => {
}
const [settingsOpen, setSettingsOpen] = useState(false)
const attentionSeekers = useRef({})
const scrollToAttentionSeeker = el => {
el.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
})
}
const attentionSeekerRef = key => ref => {
if (ref !== null && attentionSeekers.current[key] === true) {
scrollToAttentionSeeker(ref)
}
attentionSeekers.current[key] = ref
}
return (
<>
@@ -546,7 +577,7 @@ const Status = () => {
<StatusSettings
settings={settings}
onSettingsChange={setSettings}
onOpenDetails={() => {
onOpenDetails={key => {
if (moreDetailsRef.current) {
moreDetailsRef.current.scrollIntoView({
block: 'start',
@@ -555,6 +586,12 @@ const Status = () => {
})
}
if (attentionSeekers.current[key]) {
scrollToAttentionSeeker(attentionSeekers.current[key])
} else {
attentionSeekers.current[key] = true
}
setUsageDetailsOpen(true)
}}
/>
@@ -579,10 +616,10 @@ const Status = () => {
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
}
</div>
{_.toPairs(actionTooltips).map(([key, { label, long }]) => (
{_.toPairs(settingDefinitions).map(([key, { label, long }]) => (
<div key={key}>
<Divider />
<div style={{ padding: '0 1em' }}>
<div style={{ padding: '0 1em' }} ref={attentionSeekerRef(key)}>
<b>
Valinta "<i>{label}</i>"
</b>
@@ -1,18 +1,17 @@
import React, { Component } from 'react'
import React, { useState, useRef, useMemo } from 'react'
import { renderToString } from 'react-dom/server'
import { connect } from 'react-redux'
import moment from 'moment'
import { withRouter } from 'react-router-dom'
import { useHistory } from 'react-router-dom'
import Highcharts from 'highcharts/highstock'
import { Button } from 'semantic-ui-react'
import boostcanvas from 'highcharts/modules/boost-canvas'
import _ from 'lodash/fp'
import boost from 'highcharts/modules/boost'
import ReactHighstock from 'react-highcharts/ReactHighstock'
import './creditAccumulationGraphHC.css'
import CreditGraphTooltip from '../CreditGraphTooltip'
import { clearLoading } from '../../redux/graphSpinner'
import { setChartHeight } from '../../redux/settings'
import { reformatDate, getTextIn } from '../../common'
import useLanguage from '../LanguagePicker/useLanguage'
import { DISPLAY_DATE_FORMAT, API_DATE_FORMAT } from '../../constants'
// boost canvas needed because tests break with large population
@@ -22,294 +21,249 @@ boost(Highcharts)
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
class CreditAccumulationGraphHighCharts extends Component {
constructor(props) {
super(props)
this.state = {
options: [],
updateGraph: false,
}
const createGraphOptions = ({
singleStudent,
seriesData,
graphHeight,
tooltipFormatter,
onPointClicked,
startDate,
endDate,
}) => {
const tooltip = {
shared: false,
split: false,
}
componentDidMount() {
const { students, selectedStudents, currentGraphSize, render } = this.props
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
}
componentWillReceiveProps(nextProps) {
if (nextProps.students) {
const nextStudents = nextProps.students.map(student => student.studentNumber)
const oldStudents = this.props.students.map(student => student.studentNumber)
const changedStudentAmount = nextProps.selectedStudents.length !== this.props.selectedStudents.length
const changedGraphSize = nextProps.currentGraphSize !== this.props.currentGraphSize
const changed =
nextStudents.some(student => !oldStudents.includes(student)) ||
oldStudents.some(student => !nextStudents.includes(student))
if (changed || changedStudentAmount || changedGraphSize) {
this.createGraphOptions(
nextProps.students,
nextProps.selectedStudents,
nextProps.currentGraphSize,
nextProps.render
)
}
}
/**
* Trigger updating the chart after filter tray transition has finished.
* This hack remedies the problem that highcharts will not dynamically adapt to container width
* if a CSS transition is used. The resize must fire only after the transition has completed to
* ensure correct size.
*/
if (this.props.trayOpen !== nextProps.trayOpen) {
setTimeout(() => {
this.props.setChartHeight(this.props.currentGraphSize - 1)
}, 700)
}
}
componentDidUpdate() {
const { updateGraph } = this.state
const { students, selectedStudents, currentGraphSize, render } = this.props
if (updateGraph) {
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
}
}
getXAxisMonth = (date, startDate) =>
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
sortCoursesByDate = courses => courses.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
createTooltip = point => {
const { students, language } = this.props
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
return null
}
const targetCourse = this.sortCoursesByDate(students[0].courses).find(
c => point.key === c.course_code && point.x === new Date(c.date).getTime()
)
if (!targetCourse.course) return null
const payload = [
{
name: students[0].studentNumber,
payload: {
...targetCourse,
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
title: getTextIn(targetCourse.course.name, language),
},
if (singleStudent) {
Object.assign(tooltip, {
formatter() {
tooltipFormatter(this)
},
]
return renderToString(<CreditGraphTooltip payload={payload} />)
useHTML: true,
style: {
all: 'unset',
display: 'none',
},
})
}
createGraphOptions = (students, selectedStudents, graphSize, render) => {
const studentCreditLines = this.createStudentCreditLines(students)
const dataOfSelected = studentCreditLines.filter(line => selectedStudents.includes(line.name))
let lastCredits = null
if (this.isSingleStudentGraph() && render) {
const started = moment(students.minDate)
const lastDate = moment(students.maxDate)
const lastMonth = Math.ceil(this.getXAxisMonth(lastDate, started))
let totalAbsenceMonths = 0
const absencePoints = this.props.absences
.filter(a => a.startdate >= started || (a.startdate <= started && a.enddate >= started))
.reduce((res, { startdate, enddate }) => {
const targetCreditsBeforeAbsence =
(Math.ceil(this.getXAxisMonth(moment(startdate), started)) - totalAbsenceMonths) * (55 / 12)
if (enddate < students.maxDate) {
res.push([startdate, targetCreditsBeforeAbsence])
res.push([enddate, targetCreditsBeforeAbsence])
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
}
return res
}, [])
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
const zones = absencePoints.reduce(
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
zoneStart
)
lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
dataOfSelected.push({
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
data: [
...[[students.minDate, 0], ...absencePoints, [students.maxDate, lastCredits]].sort((a, b) => a[0] - b[0]),
],
seriesThreshold: 150,
color: '#96d7c3',
marker: {
enabled: false,
},
zones,
zoneAxis: 'x',
})
}
const self = this
const tooltipOptions = this.isSingleStudentGraph()
? {
formatter() {
return self.createTooltip(this)
},
shared: false,
useHTML: true,
style: {
all: 'unset',
display: 'none',
},
split: false,
}
: {
shared: false,
split: false,
}
const options = {
plotOptions: {
series: {
findNearestPointBy: 'xy',
point: {
events: {
click() {
if (!self.isSingleStudentGraph()) {
self.props.history.push(`/students/${this.series.name}`)
}
},
mouseOver() {
if (this.series.halo) {
this.series.halo
.attr({
class: 'highcharts-tracker',
})
.toFront()
}
},
hover: {
halo: {
size: 9,
attributes: {
fill: Highcharts.getOptions().colors[2],
'stroke-width': 2,
stroke: Highcharts.getOptions().colors[1],
},
return {
plotOptions: {
series: {
findNearestPointBy: 'xy',
point: {
events: {
click() {
onPointClicked(this)
},
mouseOver() {
if (this.series.halo) {
this.series.halo
.attr({
class: 'highcharts-tracker',
})
.toFront()
}
},
hover: {
halo: {
size: 9,
attributes: {
fill: Highcharts.getOptions().colors[2],
'stroke-width': 2,
stroke: Highcharts.getOptions().colors[1],
},
},
},
cursor: 'pointer',
},
cursor: 'pointer',
},
},
yAxis: {
max: lastCredits && lastCredits > students.maxCredits ? lastCredits : students.maxCredits,
title: { text: 'Credits' },
},
xAxis: {
max: students.maxDate,
min: students.minDateWithCredits || students.minDate || this.props.startYear,
ordinal: false,
},
series: dataOfSelected,
chart: {
height: graphSize,
},
tooltip: {
...tooltipOptions,
},
}
this.setState({ options, updateGraph: false })
}
isSingleStudentGraph = () => !!this.props.singleStudent
createStudentCreditLines = students =>
students.map(student => {
const { started, studyrightStart } = student
const startDate = this.props.selectedStudents.length === 1 ? started : studyrightStart
let credits = 0
let points = this.filterCoursesByDate(this.sortCoursesByDate(student.courses), startDate).map(course => {
if (course.passed && !course.isStudyModuleCredit) {
credits += course.credits
}
const defaultPointOptions = this.isSingleStudentGraph() ? { name: course.course_code } : {}
return {
...defaultPointOptions,
x: new Date(course.date).getTime(),
y: credits,
}
})
if (points.length < 2) {
points = [[students.minDate, 0], ...points]
}
return {
name: student.studentNumber,
data: points,
seriesThreshold: 150,
marker: {
enabled: this.isSingleStudentGraph(),
radius: 4,
},
}
})
resizeChart = size => {
if (this.chart) {
this.props.setChartHeight(size)
}
}
render() {
return (
<div>
<div className="graphContainer">
<div className="graphOptions">
<Button
active={this.props.currentGraphSize === 400}
onClick={() => this.resizeChart(400)}
content="Small"
/>
<Button
active={this.props.currentGraphSize === 600}
onClick={() => this.resizeChart(600)}
content="Medium"
/>
<Button
active={this.props.currentGraphSize === 1000}
onClick={() => this.resizeChart(1000)}
content="Large"
/>
</div>
<ReactHighstock
highcharts={Highcharts}
ref={HighchartsReact => {
this.chart = HighchartsReact
}}
constructorType="stockChart"
config={this.state.options}
/>
</div>
</div>
)
},
yAxis: {
title: { text: 'Credits' },
min: 0,
max: undefined,
},
xAxis: {
ordinal: false,
min: startDate,
max: endDate,
},
series: seriesData,
chart: {
height: graphHeight,
},
tooltip,
}
}
const mapStateToProps = state => ({
spinner: state.graphSpinner,
currentGraphSize: state.settings.chartHeight,
})
const sortCoursesByDate = courses =>
[...courses].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
export default connect(mapStateToProps, { clearLoading, setChartHeight })(withRouter(CreditAccumulationGraphHighCharts))
const filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
const getXAxisMonth = (date, startDate) =>
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
const singleStudentTooltipFormatter = (point, student, language) => {
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
return null
}
const targetCourse = sortCoursesByDate(student.courses).find(
({ course_code: courseCode, date }) => point.key === courseCode && point.x === new Date(date).getTime()
)
if (!targetCourse.course) {
return null
}
const payload = [
{
name: student.studentNumber,
payload: {
...targetCourse,
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
title: getTextIn(targetCourse.course.name, language),
},
},
]
return renderToString(<CreditGraphTooltip payload={payload} />)
}
const createGoalSeries = (starting, ending, absences) => {
const lastMonth = Math.ceil(getXAxisMonth(moment(ending), moment(starting)))
let totalAbsenceMonths = 0
const absencePoints = absences
.filter(a => a.startdate >= starting || (a.startdate <= starting && a.enddate >= starting))
.reduce((res, { startdate, enddate }) => {
const targetCreditsBeforeAbsence =
(Math.ceil(getXAxisMonth(moment(startdate), moment(starting))) - totalAbsenceMonths) * (55 / 12)
if (enddate < ending) {
res.push([startdate, targetCreditsBeforeAbsence])
res.push([enddate, targetCreditsBeforeAbsence])
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
}
return res
}, [])
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
const zones = absencePoints.reduce(
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
zoneStart
)
const lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
const data = [...[[starting, 0], ...absencePoints, [ending, lastCredits]].sort((a, b) => a[0] - b[0])]
return {
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
data,
seriesThreshold: 150,
color: '#96d7c3',
marker: {
enabled: false,
},
zones,
zoneAxis: 'x',
}
}
const createStudentCreditLines = (students, singleStudent) =>
students.map(student => {
const { started, studyrightStart } = student
const startDate = students.length === 1 ? started : studyrightStart
const { points } = _.flow(
sortCoursesByDate,
courses => filterCoursesByDate(courses, startDate),
_.reduce(
({ credits, points }, course) => {
const gainedCredits = course.passed && !course.isStudyModuleCredit ? course.credits : 0
const point = {
x: new Date(course.date).getTime(),
y: credits + gainedCredits,
}
if (singleStudent) {
Object.assign(point, {
name: course.course_code,
})
}
return {
points: [...points, point],
credits: credits + gainedCredits,
}
},
{ credits: 0, points: [] }
)
)(student.courses)
return {
name: student.studentNumber,
data: points,
seriesThreshold: 150,
marker: {
enabled: !!singleStudent,
radius: 4,
},
}
})
const CreditAccumulationGraphHighCharts = ({ students, singleStudent, absences, startDate, endDate }) => {
const history = useHistory()
const chartRef = useRef()
const language = useLanguage()
const [graphHeight, setGraphHeight] = useState(600)
const seriesData = useMemo(() => createStudentCreditLines(students, singleStudent), [students, singleStudent])
if (singleStudent) {
const starting = students[0].studyrights
.map(sr => new Date(sr.startdate))
.sort()[0]
.getTime()
const ending = endDate ? new Date(endDate).getTime() : new Date().getTime()
seriesData.push(createGoalSeries(starting, ending, absences))
}
const options = createGraphOptions({
singleStudent,
seriesData,
graphHeight,
startDate: startDate ? new Date(startDate).getTime() : undefined,
endDate,
tooltipFormatter: point => singleStudent && singleStudentTooltipFormatter(point, students[0], language),
onPointClicked: point => {
if (!singleStudent) {
history.push(`/students/${point.series.name}`)
}
},
})
const makeGraphSizeButton = (height, label) => (
<Button active={graphHeight === height} onClick={() => setGraphHeight(height)} content={label} />
)
return (
<div className="graphContainer">
<div className="graphOptions">
{makeGraphSizeButton(400, 'Small')}
{makeGraphSizeButton(600, 'Medium')}
{makeGraphSizeButton(1000, 'Large')}
</div>
<ReactHighstock highcharts={Highcharts} ref={chartRef} constructorType="stockChart" config={options} />
</div>
)
}
export default CreditAccumulationGraphHighCharts
@@ -1,11 +1,11 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { useSelector } from 'react-redux'
import PopulationCourseStatsFlat from '../../PopulationCourseStats/PopulationCourseStatsFlat'
import useCourseFilter from '../../FilterTray/filters/Courses/useCourseFilter'
const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, error, showFilter }) => {
const CustomPopulationCourses = ({ selectedStudents, showFilter = false }) => {
const { setCourses, resetCourses } = useCourseFilter()
const { data: courses, pending, error } = useSelector(({ populationCourses }) => populationCourses)
useEffect(() => {
if (!pending && !error && courses.coursestatistics) {
@@ -21,33 +21,10 @@ const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, er
return (
<PopulationCourseStatsFlat
courses={courses}
query={query}
pending={pending}
selectedStudents={selectedStudents}
customPopulation
showFilter={showFilter}
/>
)
}
CustomPopulationCourses.propTypes = {
pending: bool.isRequired,
courses: shape([]).isRequired,
selectedStudents: arrayOf(string).isRequired,
query: shape({}).isRequired,
error: bool.isRequired,
showFilter: bool,
}
CustomPopulationCourses.defaultProps = {
showFilter: false,
}
const mapStateToProps = ({ populationCourses }) => ({
courses: populationCourses.data,
pending: populationCourses.pending,
query: populationCourses.query,
error: populationCourses.error,
})
export default connect(mapStateToProps)(CustomPopulationCourses)
export default CustomPopulationCourses
@@ -16,7 +16,6 @@ import InfoBox from '../InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import useLanguage from '../LanguagePicker/useLanguage'
import CustomPopulationSearch from './CustomPopulationSearch'
import UnihowDataExport from './UnihowDataExport'
@@ -35,7 +34,6 @@ const CustomPopulation = ({
const [, setSelectedSearchId] = useState('')
const [newestIndex, setNewest] = useState(null)
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const { progress } = useProgress(loading)
@@ -98,13 +96,7 @@ const CustomPopulation = ({
content: {
content: (
<div ref={creditGainRef}>
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
/>
<CreditAccumulationGraphHighCharts students={filteredStudents} />
</div>
),
},
@@ -1,17 +1,24 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Popup, Button, Icon } from 'semantic-ui-react'
import { contextKey as filterTrayContextKey } from './index'
import useFilterTray from './useFilterTray'
import useAnalytics from './useAnalytics'
/**
* @param {func} applyFilter Function to apply the filter when toggled on
* @param {func} clearFilter Function to clear the filter when toggled off.
* @param {string} filterName Name to display in analytics.
* @param {active} bool Toggle state. Must be controlled in the parent.
* @param {funcs} popupContent Content component to show in the tooltip, if given. Tooltip is not shown if omitted.
* @param {string} filterPanelContextKey Used to open the filter's panel, if given. If not given, the filter tray will not be opened either.
*/
const ExternalFilterToggle = ({
filterPanelContextKey,
applyFilter,
clearFilter,
filterName,
active,
popupContent,
filterPanelContextKey,
}) => {
const [, setFilterTrayOpen] = useFilterTray(filterTrayContextKey)
const [, setCreditFilterOpen] = useFilterTray(filterPanelContextKey)
@@ -55,24 +62,4 @@ const ExternalFilterToggle = ({
)
}
ExternalFilterToggle.propTypes = {
/** Used to open the filter's panel, if given. If not given, the filter tray will not be opened either. */
filterPanelContextKey: PropTypes.string,
/** Function to apply the filter when toggled on. */
applyFilter: PropTypes.func.isRequired,
/** Function to clear the filter when toggled off. */
clearFilter: PropTypes.func.isRequired,
/** Name to display in analytics. */
filterName: PropTypes.string.isRequired,
/** Toggle state. Must be controlled in the parent. */
active: PropTypes.bool.isRequired,
/** Content to show in the tooltip, if given. Tooltip is not shown if omitted. */
popupContent: PropTypes.node,
}
ExternalFilterToggle.defaultProps = {
filterPanelContextKey: undefined,
popupContent: undefined,
}
export default ExternalFilterToggle
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { CourseFilterProvider } from './filters/Courses/useCourseFilter'
import { CreditFilterProvider } from './filters/CreditsEarned/useCreditFilter'
import { AgeFilterProvider } from './filters/Age/useAgeFilter'
@@ -23,8 +22,4 @@ const filterProviders = [
const FilterContextProvider = ({ children }) =>
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
FilterContextProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default FilterContextProvider
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react'
import useAnalytics from './useAnalytics'
import GraduatedFromProgramme from './filters/GraduatedFromProgramme'
import TransferredToProgramme from './filters/TransferredToProgramme'
import EnrollmentStatus from './filters/EnrollmentStatus'
@@ -7,69 +8,67 @@ import Gender from './filters/Gender'
import StartYearAtUni from './filters/StartYearAtUni'
import Courses from './filters/Courses'
import Programmes from './filters/Programmes'
import useAnalytics from './useAnalytics'
import Grade from './filters/Grade'
import Date from './filters/Date'
import Age from './filters/Age'
import AdmissionType from './filters/AdmissionType'
import Tags from './filters/Tags'
export const PopulationStatisticsFilters = ({ query }) => {
const Filters = ({ name, children }) => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Population Statistics')
analytics.setTarget(name)
})
return (
<>
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
</>
)
return React.Children.toArray(children)
}
export const CoursePopulationFilters = () => {
const analytics = useAnalytics()
export const PopulationStatisticsFilters = ({ query }) => (
<Filters name="Population Statistics">
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
<Date />
</Filters>
)
useEffect(() => {
analytics.setTarget('Course Population')
})
export const CoursePopulationFilters = () => (
<Filters name="Course Population">
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</Filters>
)
return (
<>
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</>
)
}
export const CustomPopulationFilters = () => (
<Filters name="Custom Population">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</Filters>
)
export const CustomPopulationFilters = () => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Custom Population')
})
return (
<>
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</>
)
}
export const StudyGuidanceGroupFilters = () => (
<Filters name="Study Guidance Group">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<AdmissionType />
<GraduatedFromProgramme />
<Tags />
<Courses />
</Filters>
)
@@ -1,5 +1,4 @@
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { getPopulationSelectedStudentCourses } from '../../../../redux/populationSelectedStudentCourses'
@@ -23,11 +22,6 @@ const CourseFilterProvider = ({ children, getPopulationSelectedStudentCourses })
return <CourseFilterContext.Provider value={[state, setState]}>{children}</CourseFilterContext.Provider>
}
CourseFilterProvider.propTypes = {
children: PropTypes.node.isRequired,
getPopulationSelectedStudentCourses: PropTypes.func.isRequired,
}
const ConnectedProvider = connect(null, { getPopulationSelectedStudentCourses })(CourseFilterProvider)
export { ConnectedProvider as CourseFilterProvider }
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Button, Icon } from 'semantic-ui-react'
const ClearFilterButton = ({ disabled, onClick, name }) => (
@@ -9,10 +8,4 @@ const ClearFilterButton = ({ disabled, onClick, name }) => (
</Button>
)
ClearFilterButton.propTypes = {
disabled: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
}
export default ClearFilterButton
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Icon, Header } from 'semantic-ui-react'
import useFilterTray from '../../useFilterTray'
@@ -23,12 +22,4 @@ const FilterCard = ({ title, children, contextKey, name, active }) => {
)
}
FilterCard.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
contextKey: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
active: PropTypes.bool.isRequired,
}
export default FilterCard
@@ -1,8 +1,16 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Input, Label, Popup, Button, Icon } from 'semantic-ui-react'
const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonDisabled, className, name }) => (
const NumericInput = ({
onChange,
onKeyDown = () => {},
onClear,
value,
label,
clearButtonDisabled,
className,
name,
}) => (
<Input
labelPosition="left"
size="mini"
@@ -30,20 +38,4 @@ const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonD
</Input>
)
NumericInput.propTypes = {
onChange: PropTypes.func.isRequired,
onKeyDown: PropTypes.func,
onClear: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
clearButtonDisabled: PropTypes.bool.isRequired,
className: PropTypes.string,
name: PropTypes.string.isRequired,
}
NumericInput.defaultProps = {
onKeyDown: () => {},
className: null,
}
export default NumericInput
@@ -1,12 +1,11 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Segment, Header } from 'semantic-ui-react'
import './filterTray.css'
import useFilters from './useFilters'
export const contextKey = 'filterTray'
const FilterTray = ({ children, filterSet, visible }) => {
const FilterTray = ({ children, filterSet, visible = true }) => {
const { filteredStudents, allStudents } = useFilters()
if (!visible || !allStudents.length) {
@@ -17,7 +16,7 @@ const FilterTray = ({ children, filterSet, visible }) => {
<>
<div style={{ display: 'inline-flex', alignItems: 'flex-start', width: '100%' }}>
<Segment style={{ marginLeft: '0.5rem', width: '12rem', position: 'sticky', top: '10px' }}>
<Header size="small" style={{ textAlign: 'center' }}>
<Header size="small" style={{ textAlign: 'center' }} data-cy="filtered-students">
Filter students
</Header>
<div style={{ textAlign: 'center' }}>
@@ -34,14 +33,4 @@ const FilterTray = ({ children, filterSet, visible }) => {
)
}
FilterTray.propTypes = {
children: PropTypes.node.isRequired,
filterSet: PropTypes.node.isRequired,
visible: PropTypes.bool,
}
FilterTray.defaultProps = {
visible: true,
}
export default FilterTray
@@ -1,5 +1,4 @@
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
import TSA from '../../common/tsa'
const FilterAnalyticsContext = createContext([[], () => {}])
@@ -10,10 +9,6 @@ export const FilterAnalyticsProvider = ({ children }) => {
return <FilterAnalyticsContext.Provider value={[state, setState]}>{children}</FilterAnalyticsContext.Provider>
}
FilterAnalyticsProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterAnalyticsContext)
const category = `Filters (${state})`
@@ -2,7 +2,6 @@
* Context for those expandable filter panels that need to be opened/closed remotely.
*/
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
const FilterTrayContext = createContext([[], () => {}])
FilterTrayContext.displayName = 'Filter Tray'
@@ -13,10 +12,6 @@ export const FilterTrayProvider = ({ children }) => {
return <FilterTrayContext.Provider value={[state, setState]}>{children}</FilterTrayContext.Provider>
}
FilterTrayProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default key => {
const [state, setState] = useContext(FilterTrayContext)
@@ -2,7 +2,6 @@
* Context for managing filtering.
*/
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import lodash from 'lodash'
const defaultState = {
@@ -37,10 +36,6 @@ export const FilterProvider = ({ children }) => {
return <FilterContext.Provider value={[state, setState]}>{children}</FilterContext.Provider>
}
FilterProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterContext)
@@ -1,9 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import { Table, Form, Input, Tab, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
import './populationCourseStats.css'
import { PopulationCourseContext } from './PopulationCourseContext'
@@ -43,7 +41,7 @@ const lodashSortOrderTypes = {
DESC: 'desc',
}
function updateCourseStatisticsCriteria(props, language, state) {
const updateCourseStatisticsCriteria = (props, language, state) => {
const { studentAmountLimit, sortCriteria, codeFilter, nameFilter, reversed } = state
const {
courses: { coursestatistics },
@@ -90,8 +88,21 @@ const initialState = props => ({
selectedStudentsLength: props.selectedStudentsLength || 0,
})
function PopulationCourseStats(props) {
const PopulationCourseStats = ({ courses, pending, selectedStudents, showFilter = true }) => {
const dispatch = useDispatch()
const { years } = useSelector(({ semesters }) => semesters.data)
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
const isAdmin = getUserIsAdmin(useSelector(({ auth }) => auth.token.roles))
const { language } = useLanguage()
const props = {
courses,
isAdmin,
pending,
populationCourses,
selectedStudents,
showFilter,
years,
}
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
const [courseStatistics, setCourseStatistics] = useState(
@@ -105,7 +116,7 @@ function PopulationCourseStats(props) {
const filterAnalytics = useAnalytics()
useEffect(() => {
if (state && props.courses) {
if (!pending && state && props.courses) {
const studentAmountLimit =
state.selectedStudentsLength !== props.selectedStudents.length
? Math.round(props.selectedStudents.length * 0.3)
@@ -119,44 +130,46 @@ function PopulationCourseStats(props) {
})
setCourseStatistics(updateCourseStatisticsCriteria(props, language, state))
}
}, [props.courses, props.selectedStudents])
}, [props.courses, props.selectedStudents, pending])
useEffect(() => {
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
const {
courses: { coursestatistics },
language,
} = props
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return studentAmountLimit === 0 || students >= studentAmountLimit
if (!pending) {
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
const {
courses: { coursestatistics },
language,
} = 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 courseNameFilter = ({ course }) => {
const { name } = course
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
}
const filteredCourses =
coursestatistics &&
coursestatistics
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
const sortedStatistics = orderBy(
filteredCourses,
[course => course.stats[sortCriteria], course => course.course.code],
[lodashSortOrder, lodashSortOrderTypes.ASC]
)
setCourseStatistics(sortedStatistics)
}
const courseCodeFilter = ({ course }) => {
const { code } = course
return code.toLowerCase().includes(codeFilter.toLowerCase())
}
const courseNameFilter = ({ course }) => {
const { name } = course
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
}
const filteredCourses =
coursestatistics &&
coursestatistics
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
const sortedStatistics = orderBy(
filteredCourses,
[course => course.stats[sortCriteria], course => course.course.code],
[lodashSortOrder, lodashSortOrderTypes.ASC]
)
setCourseStatistics(sortedStatistics)
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter])
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter, pending])
const onFilterChange = (e, field) => {
const {
@@ -174,8 +187,8 @@ function PopulationCourseStats(props) {
)
useEffect(() => {
setFilters(filterFields)
}, [filterFields])
if (!pending) setFilters(filterFields)
}, [filterFields, pending])
const handleCourseStatisticsCriteriaChange = () => {
// eslint-disable-next-line react/no-access-state-in-setstate
@@ -232,13 +245,14 @@ function PopulationCourseStats(props) {
}
const onGoToCourseStatisticsClick = courseCode => {
const { clearCourseStats: clearCourseStatsfn } = props
sendAnalytics('Courses of Population course stats button clicked', courseCode)
clearCourseStatsfn()
dispatch(clearCourseStats())
}
const onCourseNameCellClick = code => {
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
const courseStatistic =
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
if (courseStatistic) {
const isSelected = courseIsSelected(code)
const name = 'Course Filtername'
@@ -280,7 +294,6 @@ function PopulationCourseStats(props) {
)
}
const { courses, pending } = props
const { sortCriteria, reversed } = state
const contextValue = {
courseStatistics,
@@ -324,8 +337,6 @@ function PopulationCourseStats(props) {
return null
}
const { showFilter = true } = props
return (
<div>
{showFilter && (
@@ -343,37 +354,4 @@ function PopulationCourseStats(props) {
)
}
PopulationCourseStats.propTypes = {
courses: shape({
coursestatistics: arrayOf(object),
disciplines: shape({}),
}).isRequired,
populationCourses: shape({
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
}).isRequired,
clearCourseStats: func.isRequired,
pending: bool.isRequired,
selectedStudents: arrayOf(string).isRequired,
years: shape({}), // eslint-disable-line
showFilter: bool,
language: string.isRequired,
}
PopulationCourseStats.defaultProps = {
showFilter: false,
}
const mapStateToProps = state => {
const { years } = state.semesters.data
const isAdmin = getUserIsAdmin(state.auth.token.roles)
return {
years,
populationCourses: state.populationCourses,
isAdmin,
}
}
export default connect(mapStateToProps, {
clearCourseStats,
})(withRouter(PopulationCourseStats))
export default PopulationCourseStats
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect, useSelector } from 'react-redux'
import { Table, Input, Tab, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
@@ -92,7 +91,7 @@ const initialState = props => ({
selectedStudentsLength: props.selectedStudentsLength || 0,
})
function PopulationCourseStats(props) {
const PopulationCourseStats = props => {
const { language } = useLanguage()
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
@@ -268,7 +267,9 @@ function PopulationCourseStats(props) {
}
const onCourseNameCellClick = code => {
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
const courseStatistic =
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
if (courseStatistic) {
const isSelected = courseIsSelected(code)
const name = 'Course Filtername'
@@ -397,19 +398,6 @@ function PopulationCourseStats(props) {
)
}
PopulationCourseStats.propTypes = {
courses: shape({
coursestatistics: arrayOf(object),
}).isRequired,
populationCourses: shape({
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
}).isRequired,
clearCourseStats: func.isRequired,
pending: bool.isRequired,
selectedStudents: arrayOf(string).isRequired,
language: string.isRequired,
}
const mapStateToProps = ({ populationCourses }) => ({
populationCourses,
})
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import uuidv4 from 'uuid/v4'
import SegmentDimmer from '../SegmentDimmer'
@@ -11,15 +10,13 @@ import FilterDegreeCoursesModal from './FilterDegreeCoursesModal'
import useCourseFilter from '../FilterTray/filters/Courses/useCourseFilter'
import infotooltips from '../../common/InfoToolTips'
const PopulationCourses = ({
populationSelectedStudentCourses,
populationCourses,
selectedStudents,
query,
filteredStudents,
}) => {
const PopulationCourses = ({ selectedStudents, query = {}, filteredStudents }) => {
const { setCoursesOnce, resetCourses, runCourseQuery } = useCourseFilter()
const [showByStudytrack, setShowByStudytrack] = useState(true)
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
const populationSelectedStudentCourses = useSelector(
({ populationSelectedStudentCourses }) => populationSelectedStudentCourses
)
const selectedPopulationCourses = populationSelectedStudentCourses.data
? populationSelectedStudentCourses
@@ -89,7 +86,6 @@ const PopulationCourses = ({
<PopulationCourseStats
key={selectedPopulationCourses.query.uuid}
courses={selectedPopulationCourses.data}
query={selectedPopulationCourses.query}
pending={pending}
selectedStudents={selectedStudents}
/>
@@ -100,21 +96,4 @@ const PopulationCourses = ({
)
}
PopulationCourses.defaultPropTypes = {
query: {},
}
PopulationCourses.propTypes = {
populationSelectedStudentCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
populationCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
selectedStudents: arrayOf(string).isRequired,
query: shape({}).isRequired,
filteredStudents: arrayOf(shape({})).isRequired,
}
const mapStateToProps = ({ populationSelectedStudentCourses, populationCourses }) => ({
populationCourses,
populationSelectedStudentCourses,
})
export default connect(mapStateToProps)(PopulationCourses)
export default PopulationCourses
@@ -18,7 +18,7 @@ import infotooltips from '../../common/InfoToolTips'
const sendAnalytics = sendEvent.populationStatistics
const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStudentsByYear, dataExport }) => {
const PopulationDetails = ({ samples, queryIsSet, isLoading, query, dataExport }) => {
const { allStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray('filterTray')
const { language } = useLanguage()
@@ -58,11 +58,9 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
const graphs = (
<CreditAccumulationGraphHighCharts
students={samples}
students={filteredStudents}
title="Id"
label={samples.label}
maxCredits={samples.maxCredits}
selectedStudents={selectedStudents}
trayOpen={trayOpen}
language={language}
/>
@@ -102,7 +100,10 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
key: 1,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
<span
style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}
data-cy="credit-statistics"
>
Credit statistics
</span>
),
@@ -147,7 +148,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
<div ref={courseTableRef}>
<PopulationCourses
selectedStudents={selectedStudents}
selectedStudentsByYear={selectedStudentsByYear}
query={query}
allStudents={allStudents}
filteredStudents={filteredStudents}
@@ -187,7 +187,6 @@ PopulationDetails.propTypes = {
samples: arrayOf(object).isRequired,
queryIsSet: bool.isRequired,
isLoading: bool.isRequired,
selectedStudentsByYear: shape({}).isRequired,
query: shape({}).isRequired,
dataExport: node,
}
@@ -1,9 +1,9 @@
import React, { memo, useEffect } from 'react'
import { connect } from 'react-redux'
import React, { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from '@reduxjs/toolkit'
import { useLocation, useHistory } from 'react-router-dom'
import { Header, Segment } from 'semantic-ui-react'
import { flattenDeep } from 'lodash'
import PopulationDetails from '../PopulationDetails'
import { getTotalCreditsFromCourses } from '../../common'
import { useTitle } from '../../common/hooks'
import selectors from '../../selectors/populationDetails'
import FilterTray from '../FilterTray'
@@ -12,9 +12,23 @@ import { PopulationStatisticsFilters } from '../FilterTray/FilterSets'
import PopulationSearch from '../PopulationSearch'
import DataExport from './DataExport'
const PopulationStatistics = memo(props => {
const { queryIsSet, selectedStudentsByYear, query, samples, location, history, isLoading, students } = props
const selectPopulations = createSelector(
({ populations }) => populations,
populations => ({
query: populations.query ?? {},
queryIsSet: !!populations.query,
isLoading: populations.pending === true,
students: populations.data.students ?? [],
})
)
const PopulationStatistics = () => {
const location = useLocation()
const history = useHistory()
const { samples } = useSelector(selectors.makePopulationsToData)
const { query, queryIsSet, isLoading, students } = useSelector(selectPopulations)
const { setAllStudents } = useFilters()
useTitle('Population statistics')
// Pass students to filter context.
@@ -33,7 +47,6 @@ const PopulationStatistics = memo(props => {
{location.search !== '' ? (
<PopulationDetails
queryIsSet={queryIsSet}
selectedStudentsByYear={selectedStudentsByYear}
query={query}
samples={samples}
isLoading={isLoading}
@@ -44,41 +57,6 @@ const PopulationStatistics = memo(props => {
</div>
</FilterTray>
)
})
const mapStateToProps = state => {
// haha copied from other place :mintu:
const { samples, selectedStudentsByYear } = selectors.makePopulationsToData(state)
// REFACTOR YES, IF YOU SEE THIS COMMENT YOU ARE OBLIGATED TO FIX IT
if (samples.length > 0) {
const creditsAndDates = samples.map(s => {
const passedCourses = s.courses.filter(c => c.passed)
const passedCredits = getTotalCreditsFromCourses(passedCourses)
const dates = passedCourses.map(c => c.date)
const datesWithCredits = passedCourses.filter(c => c.credits > 0).map(c => c.date)
return { passedCredits, dates, datesWithCredits }
})
const credits = creditsAndDates.map(cd => cd.passedCredits)
const dates = flattenDeep(creditsAndDates.map(cd => cd.dates)).map(date => new Date(date).getTime())
const datesWithCredits = flattenDeep(creditsAndDates.map(cd => cd.datesWithCredits)).map(date =>
new Date(date).getTime()
)
samples.maxCredits = Math.max(...credits)
samples.maxDate = Math.max(...dates)
samples.minDate = Math.min(...dates)
samples.minDateWithCredits = Math.min(...datesWithCredits)
}
const { populations } = state
return {
query: populations.query ? populations.query : {},
queryIsSet: !!populations.query,
selectedStudentsByYear,
samples,
isLoading: populations.pending === true,
students: populations.data.students || [],
}
}
export default connect(mapStateToProps)(PopulationStatistics)
export default PopulationStatistics
@@ -37,6 +37,8 @@ const GeneralTab = ({
const [popupStates, setPopupStates] = useState({})
const sendAnalytics = sendEvent.populationStudents
if (!populationStatistics) return null
// TODO: Refactor this away:
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
@@ -1,14 +1,12 @@
import React, { Component, Fragment } from 'react'
import { connect } from 'react-redux'
import { string, arrayOf, object, func, bool, shape, node } from 'prop-types'
import { Icon, Tab, Grid, Ref, Item, Dropdown } from 'semantic-ui-react'
import { withRouter, Link } from 'react-router-dom'
import React, { useState, useEffect, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Icon, Tab, Grid, Item, Dropdown } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { orderBy, uniqBy, flatten, sortBy, isNumber } from 'lodash'
import scrollToComponent from 'react-scroll-to-component'
import { getTextIn, getUserRoles } from '../../common'
import { useTabChangeAnalytics } from '../../common/hooks'
import { getTextIn } from '../../common'
import { useTabChangeAnalytics, usePrevious, useIsAdmin } from '../../common/hooks'
import { toggleStudentListVisibility } from '../../redux/settings'
import { getTagsByStudytrackAction } from '../../redux/tags'
import { getStudentTagsByStudytrackAction } from '../../redux/tagstudent'
@@ -24,79 +22,95 @@ import GeneralTab from './StudentTable/GeneralTab'
import sendEvent, { ANALYTICS_CATEGORIES } from '../../common/sendEvent'
import infotoolTips from '../../common/InfoToolTips'
// TODO: Refactoring in process, contains lot of duplicate code.
const sendAnalytics = sendEvent.populationStudents
const StudentTableTabs = ({ panes, filterPanes }) => {
// only its own component really because I needed to use hooks and didn't want to refactor
// this megamillion line component :) /Joona
const PopulationStudents = ({
language,
filteredStudents,
studentToTargetCourseDateMap,
dataExport,
customPopulation = false,
coursePopulation = false,
coursecode = [],
}) => {
const [state, setState] = useState({})
const studentRef = useRef()
const dispatch = useDispatch()
const { namesVisible: showNames, studentlistVisible: showList } = useSelector(({ settings }) => settings)
const { data: tags } = useSelector(({ tags }) => tags)
const { query } = useSelector(({ populations }) => populations)
const queryStudyrights = query ? Object.values(query.studyRights) : []
const { data: mandatoryCourses } = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses)
const { data: populationCourses } = useSelector(({ populationCourses }) => populationCourses)
const prevShowList = usePrevious(showList)
const admin = useIsAdmin()
const { handleTabChange } = useTabChangeAnalytics(
ANALYTICS_CATEGORIES.populationStudents,
'Change students table tab'
)
return <Tab onTabChange={handleTabChange} panes={filterPanes(panes)} data-cy="student-table-tabs" />
}
useEffect(() => {
if (tags && tags.length > 0) return
const queryStudyright = queryStudyrights[0]
dispatch(getTagsByStudytrackAction(queryStudyright))
dispatch(getStudentTagsByStudytrackAction(queryStudyright))
setState({ ...state, admin })
}, [])
StudentTableTabs.propTypes = {
panes: arrayOf(object).isRequired,
filterPanes: func.isRequired,
}
class PopulationStudents extends Component {
constructor(props) {
super(props)
this.state = {}
this.studentsRef = React.createRef()
}
componentDidMount() {
const queryStudyright = this.props.queryStudyrights[0]
const admin = this.props.userRoles.includes('admin')
if (queryStudyright) {
this.props.getTagsByStudytrack(queryStudyright)
this.props.getStudentTagsStudyTrack(queryStudyright)
useEffect(() => {
if (!prevShowList && showList) {
scrollToComponent(studentRef.current, { align: 'bottom' })
}
this.setState({ admin })
}, [prevShowList])
const mandatoryCodes = mandatoryCourses.filter(course => course.visible && course.visible.visibility).map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
mandatoryCourses
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
componentDidUpdate = prevProps => {
if (!prevProps.showList && this.props.showList) {
scrollToComponent(this.studentsRef.current, { align: 'bottom' })
}
}
studyrightCodes = (studyrights, value) => {
const { queryStudyrights } = this.props
return studyrights
.filter(sr => {
const { studyright_elements: studyrightElements } = sr
return studyrightElements.filter(sre => queryStudyrights.includes(sre.code)).length >= queryStudyrights.length
})
.map(a => a[value])
}
handleRef = node => {
this.studentsRef.current = node
}
renderStudentTable() {
const { customPopulation, coursePopulation } = this.props
const renderStudentTable = () => {
const verticalTitle = title => <div className="verticalTitle">{title}</div>
const hasPassedMandatory = (studentNumber, code) =>
this.props.mandatoryPassed[code] && this.props.mandatoryPassed[code].includes(studentNumber)
mandatoryPassed[code] && mandatoryPassed[code].includes(studentNumber)
const totalMandatoryPassed = studentNumber => {
this.props.mandatoryCourses.reduce((acc, m) => {
mandatoryCourses.reduce((acc, m) => {
return hasPassedMandatory(studentNumber, m.code) ? acc + 1 : acc
}, 0)
}
const nameColumns = this.props.showNames
const nameColumns = showNames
? [
{
key: 'lastname',
@@ -164,7 +178,7 @@ class PopulationStudents extends Component {
const mandatoryCourseLabels = []
const labelToMandatoryCourses = this.props.mandatoryCourses.reduce((acc, e) => {
const labelToMandatoryCourses = mandatoryCourses.reduce((acc, e) => {
const label = e.label ? e.label.label : ''
acc[label] = acc[label] || []
if (acc[label].some(l => l.code === e.code)) return acc
@@ -183,14 +197,14 @@ class PopulationStudents extends Component {
const mandatoryTitle = m => {
return (
<>
{getTextIn(m.name, this.props.language)}
{getTextIn(m.name, language)}
<br />
{m.code}
</>
)
}
const { visibleLabels, visibleCourseCodes } = this.props.mandatoryCourses.reduce(
const { visibleLabels, visibleCourseCodes } = mandatoryCourses.reduce(
(acc, cur) => {
if (cur.visible && cur.visible.visibility) {
acc.visibleLabels.add(cur.label_code)
@@ -220,7 +234,11 @@ class PopulationStudents extends Component {
</div>
),
parent: true,
headerProps: { colSpan: labelToMandatoryCourses[e.label].length, title: e.label, ordernumber: e.orderNumber },
headerProps: {
colSpan: labelToMandatoryCourses[e.label].length,
title: e.label,
ordernumber: e.orderNumber,
},
}))
)
@@ -242,8 +260,8 @@ class PopulationStudents extends Component {
.map(m => ({
key: `${m.label ? m.label.label : 'fix'}-${m.code}`, // really quick and dirty fix
title: verticalTitle(mandatoryTitle(m)),
cellProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
headerProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
cellProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
headerProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
getRowVal: s => (s.total ? getTotalRowVal(s, m) : hasPassedMandatory(s.studentNumber, m.code)),
getRowContent: s => {
if (s.total) return getTotalRowVal(s, m)
@@ -257,22 +275,20 @@ class PopulationStudents extends Component {
),
]
const totals = this.props.filteredStudents.reduce(
const totals = filteredStudents.reduce(
(acc, s) => {
const passedCourses = new Set()
this.props.mandatoryCourses.forEach(m => {
mandatoryCourses.forEach(m => {
if (passedCourses.has(m.code)) return
passedCourses.add(m.code)
if (hasPassedMandatory(s.studentNumber, m.code)) ++acc[m.code]
})
return acc
},
this.props.mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
)
const mandatoryCourseData = [totals, ...this.props.filteredStudents]
const mandatoryCourseData = [totals, filteredStudents]
// FIXME: here only for refactorment
const { showNames, studentToTargetCourseDateMap, coursecode } = this.props
const panes = [
{
menuItem: 'General',
@@ -294,7 +310,7 @@ class PopulationStudents extends Component {
<Tab.Pane>
<div style={{ display: 'flex' }}>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
{this.props.mandatoryCourses.length > 0 && (
{mandatoryCourses.length > 0 && (
<SortableTable
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
tableProps={{
@@ -308,7 +324,7 @@ class PopulationStudents extends Component {
textAlign: 'center',
}}
collapsingHeaders
showNames={this.props.showNames}
showNames={showNames}
columns={mandatoryCourseColumns}
data={mandatoryCourseData}
/>
@@ -323,7 +339,7 @@ class PopulationStudents extends Component {
render: () => (
<Tab.Pane>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
{this.props.tags.length === 0 && (
{tags.length === 0 && (
<div
style={{
paddingLeft: '10px',
@@ -337,7 +353,7 @@ class PopulationStudents extends Component {
<h3>
No tags defined. You can define them{' '}
<Link
to={`/study-programme/${this.props.queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
to={`/study-programme/${queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
onClick={() => {
sendAnalytics('No tags defined button clicked', 'Tags tab')
}}
@@ -348,14 +364,14 @@ class PopulationStudents extends Component {
</h3>
</div>
)}
{this.props.tags.length > 0 && (
{tags.length > 0 && (
<>
<TagPopulation
tags={this.props.tags}
selectedStudents={this.props.filteredStudents.map(stu => stu.studentNumber)}
studytrack={this.props.queryStudyrights[0]}
tags={tags}
selectedStudents={filteredStudents.map(stu => stu.studentNumber)}
studytrack={queryStudyrights[0]}
/>
<TagList studytrack={this.props.queryStudyrights[0]} selectedStudents={this.props.filteredStudents} />
<TagList studytrack={queryStudyrights[0]} selectedStudents={filteredStudents} />
</>
)}
</div>
@@ -377,142 +393,34 @@ class PopulationStudents extends Component {
<Grid.Column>
<StudentNameVisibilityToggle />
</Grid.Column>
{this.props.dataExport && (
{dataExport && (
<Grid.Column textAlign="right">
<Dropdown text="Export Data" icon="save" button labeled className="icon" direction="left">
<Dropdown.Menu>{this.props.dataExport}</Dropdown.Menu>
<Dropdown.Menu>{dataExport}</Dropdown.Menu>
</Dropdown>
</Grid.Column>
)}
</Grid>
<StudentTableTabs panes={panes} filterPanes={filteredPanes} />
<Tab onTabChange={handleTabChange} panes={filteredPanes(panes)} data-cy="student-table-tabs" />
</>
)
}
render() {
if (this.props.filteredStudents.length === 0) {
return null
}
if (filteredStudents.length === 0) return null
return (
<Ref innerRef={this.handleRef}>
<>
<span style={{ marginRight: '0.5rem' }}>
<InfoBox
content={
this.props.coursePopulation
? infotoolTips.CoursePopulation.Students
: infotoolTips.PopulationStatistics.Students
}
/>
</span>
{this.state.admin ? (
<CheckStudentList students={this.props.filteredStudents.map(stu => stu.studentNumber)} />
) : null}
{this.renderStudentTable()}
</>
</Ref>
)
}
return (
<>
<span style={{ marginRight: '0.5rem' }} ref={studentRef}>
<InfoBox
content={
coursePopulation ? infotoolTips.CoursePopulation.Students : infotoolTips.PopulationStatistics.Students
}
/>
</span>
{admin ? <CheckStudentList students={filteredStudents.map(stu => stu.studentNumber)} /> : null}
{renderStudentTable()}
</>
)
}
PopulationStudents.defaultProps = {
studentToTargetCourseDateMap: null,
customPopulation: false,
coursePopulation: false,
dataExport: null,
coursecode: [],
}
PopulationStudents.propTypes = {
showNames: bool.isRequired,
showList: bool.isRequired,
language: string.isRequired,
queryStudyrights: arrayOf(string).isRequired,
mandatoryCourses: arrayOf(
shape({
name: shape({}).isRequired,
code: string.isRequired,
})
).isRequired,
mandatoryPassed: shape({}).isRequired,
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
getTagsByStudytrack: func.isRequired,
getStudentTagsStudyTrack: func.isRequired,
userRoles: arrayOf(string).isRequired,
studentToTargetCourseDateMap: shape({}),
coursePopulation: bool,
customPopulation: bool,
filteredStudents: arrayOf(shape({})).isRequired,
dataExport: node,
coursecode: arrayOf(string),
}
const mapStateToProps = state => {
const {
settings,
populations,
populationCourses,
populationMandatoryCourses,
tags,
auth: {
token: { roles },
},
} = state
const mandatoryCodes = populationMandatoryCourses.data
.filter(course => course.visible && course.visible.visibility)
.map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.data.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
populationMandatoryCourses.data
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.data.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
return {
showNames: settings.namesVisible,
showList: settings.studentlistVisible,
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
mandatoryCourses: populationMandatoryCourses.data,
mandatoryPassed,
tags: tags.data,
userRoles: getUserRoles(roles),
}
}
export default connect(mapStateToProps, {
toggleStudentListVisibility,
getTagsByStudytrack: getTagsByStudytrackAction,
getStudentTagsStudyTrack: getStudentTagsByStudytrackAction,
})(withRouter(PopulationStudents))
export default PopulationStudents
@@ -1,52 +1,31 @@
import React, { useState } from 'react'
import { shape, string, arrayOf } from 'prop-types'
import { Menu, Tab, Input, Message } from 'semantic-ui-react'
import { flattenDeep, cloneDeep } from 'lodash'
import { flattenDeep } from 'lodash'
import Highcharts from 'highcharts/highstock'
import ReactHighcharts from 'react-highcharts'
import CreditAccumulationGraphHighCharts from '../../CreditAccumulationGraphHighCharts'
import { byDateDesc, reformatDate, getTextIn } from '../../../common'
import TSA from '../../../common/tsa'
import useLanguage from '../../LanguagePicker/useLanguage'
const ANALYTICS_CATEGORY = 'Student stats'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const CreditsGraph = ({ graphYearStart, student, absences }) => {
const { language } = useLanguage()
const selectedStart = graphYearStart || student.started
const filteredCourses = student.courses.filter(c => new Date(c.date) > new Date(selectedStart))
const newStudent = cloneDeep(student)
newStudent.courses = filteredCourses
const sample = [newStudent]
const selectedStart = new Date(graphYearStart ?? student.started)
const dates = flattenDeep(student.courses.map(c => c.date)).map(d => new Date(d).getTime())
sample.maxCredits = newStudent.courses.reduce((a, c) => {
if (c.isStudyModuleCredit || !c.passed) return a + 0
return a + c.credits
}, 0)
sample.maxDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
sample.minDate = new Date(selectedStart).getTime()
const endDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
return (
<CreditAccumulationGraphHighCharts
singleStudent
students={sample}
selectedStudents={[student.studentNumber]}
title="Credit Accumulation"
maxCredits={sample.maxCredits}
students={[student]}
startDate={selectedStart}
endDate={endDate}
absences={absences}
language={language}
/>
)
}
CreditsGraph.propTypes = {
student: shape({}).isRequired,
absences: arrayOf(shape({})).isRequired,
graphYearStart: string.isRequired,
}
const chunkifyArray = (array, size = 1) => {
if (!array) return []
const firstChunk = array.slice(0, size) // create the first chunk of the given array
@@ -205,12 +184,6 @@ const GradeGraph = ({ student, semesters, language }) => {
)
}
GradeGraph.propTypes = {
student: shape({}).isRequired,
semesters: shape({}).isRequired,
language: string.isRequired,
}
const StudentGraphs = ({ student, absences, graphYearStart, semesters, language }) => {
const panes = [
{
@@ -233,12 +206,4 @@ const StudentGraphs = ({ student, absences, graphYearStart, semesters, language
return <Tab panes={panes} />
}
StudentGraphs.propTypes = {
student: shape({}).isRequired,
absences: arrayOf(shape({})).isRequired,
graphYearStart: string.isRequired,
semesters: shape({}).isRequired,
language: string.isRequired,
}
export default StudentGraphs
@@ -39,7 +39,7 @@ const StudentDetails = ({
}) => {
const dispatch = useDispatch()
const { language } = useLanguage()
const [graphYearStart, setGraphYear] = useState('')
const [graphYearStart, setGraphYear] = useState(null)
const [studyrightid, setStudyrightid] = useState('')
const [honoursCode, setHonoursCode] = useState(null)
@@ -49,7 +49,7 @@ const StudentDetails = ({
}, [])
useEffect(() => {
setGraphYear('')
setGraphYear(null)
if (studentNumber.length > 0) getStudent(studentNumber)
else {
resetStudent()
@@ -151,7 +151,7 @@ const StudentDetails = ({
const handleStartDateChange = (elements, id) => {
if (id === studyrightid) {
setGraphYear('')
setGraphYear(null)
setStudyrightid('')
return
}
@@ -1,63 +1,95 @@
/* eslint-disable react/prop-types */
// temp disable prop types
import React, { useEffect, useState, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Button, Loader, Header, Accordion, Divider, Segment } from 'semantic-ui-react'
import React, { useCallback, useEffect, useState, useRef } from 'react'
import { useSelector } from 'react-redux'
import { Button, Header, Accordion, Divider, Label, Segment } from 'semantic-ui-react'
import { useHistory } from 'react-router-dom'
import scrollToComponent from 'react-scroll-to-component'
import { getCustomPopulation, clearPopulations } from '../../redux/populations'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import StyledMessage from './StyledMessage'
import { getTextIn } from '../../common'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
import InfoBox from '../InfoBox'
import infotooltips from '../../common/InfoToolTips'
import { getTextIn } from 'common'
import { useToggle } from 'common/hooks'
import FilterTray from 'components/FilterTray'
import useFilters from 'components/FilterTray/useFilters'
import PopulationStudents from 'components/PopulationStudents'
import CreditAccumulationGraphHighCharts from 'components/CreditAccumulationGraphHighCharts'
import { StudyGuidanceGroupFilters } from 'components/FilterTray/FilterSets'
import { useGetStudyGuidanceGroupPopulationQuery } from 'redux/studyGuidanceGroups'
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import StudyGuidanceGroupPopulationCourses from './StudyGuidanceGroupPopulationCourses'
import { startYearToAcademicYear, Wrapper, StyledMessage } from './common'
const createAcademicYearStartDate = year => new Date(year, 7, 1)
const takeOnlyCoursesStartingFromGivenAcademicYear = ({ students, year }) => {
if (!year) return students
const academicYearStartDate = createAcademicYearStartDate(year)
return students.map(student => ({
...student,
courses: student.courses.filter(course => new Date(course.date) > academicYearStartDate),
}))
}
const useIsMounted = () => {
const isMounted = useRef(false)
useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
}, [])
return useCallback(() => isMounted.current, [])
}
const useToggleAndSetNewestIndex = ({ defaultValue, indexOfPanelToScroll, setNewestIndex, isMounted }) => {
const [state, toggle] = useToggle(defaultValue)
useEffect(() => {
if (isMounted()) setNewestIndex(indexOfPanelToScroll)
}, [state])
return [state, toggle]
}
const SingleStudyGroupContent = ({ population, group, language }) => {
const custompop = population?.students || []
const isMounted = useIsMounted()
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const creditGainRef = useRef()
const programmeRef = useRef()
const coursesRef = useRef()
const studentRef = useRef()
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
const [activeIndex, setIndex] = useState([])
const [newestIndex, setNewest] = useState(null)
const refs = [useRef(), useRef(), useRef(), useRef()]
const [activeIndex, setActiveIndex] = useState([])
const [newestIndex, setNewestIndex] = useState(null)
const [creditsStartingFromAssociatedYear, toggleCreditsStartingFromAssociatedYear] = useToggleAndSetNewestIndex({
defaultValue: !!group.tags?.year,
indexOfPanelToScroll: 0,
setNewestIndex,
isMounted,
})
const [coursesStructuredByProgramme, toggleCoursesStructuredByProgramme] = useToggleAndSetNewestIndex({
defaultValue: !!group.tags?.studyProgramme,
indexOfPanelToScroll: 1,
setNewestIndex,
isMounted,
})
const filteredStudentsWithFilteredCourses = takeOnlyCoursesStartingFromGivenAcademicYear({
students: filteredStudents,
year: group.tags?.year,
})
const handleClick = index => {
const indexes = [...activeIndex].sort()
if (indexes.includes(index)) {
indexes.splice(
indexes.findIndex(ind => ind === index),
1
)
const togglePanel = index => {
const currentActiveIndex = new Set(activeIndex)
if (currentActiveIndex.has(index)) {
currentActiveIndex.delete(index)
} else {
indexes.push(index)
currentActiveIndex.add(index)
setNewestIndex(index)
}
if (activeIndex.length < indexes.length) setNewest(index)
else setNewest(null)
setIndex(indexes)
setActiveIndex([...currentActiveIndex])
}
useEffect(() => {
setAllStudents(custompop)
handleClick(0)
}, [custompop])
if (newestIndex) scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}, [newestIndex])
useEffect(() => {
if (newestIndex) {
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}
}, [activeIndex])
setAllStudents(population?.students || [])
togglePanel(0)
}, [population])
const panels = [
{
@@ -65,21 +97,22 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Credit accumulation (for {selectedStudents.length} students)
Credit accumulation (for {filteredStudents.length} students)
</span>
),
},
onTitleClick: () => handleClick(0),
onTitleClick: () => togglePanel(0),
content: {
content: (
<div ref={creditGainRef}>
<div ref={refs[0]}>
{group.tags?.year && (
<Button primary onClick={() => toggleCreditsStartingFromAssociatedYear()}>
{creditsStartingFromAssociatedYear ? 'Show all credits' : 'Show starting from associated year'}
</Button>
)}
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
startYear={new Date(parseInt(group?.tags?.year, 10), 7, 1).getTime()}
students={creditsStartingFromAssociatedYear ? filteredStudentsWithFilteredCourses : filteredStudents}
startDate={creditsStartingFromAssociatedYear && createAcademicYearStartDate(group.tags?.year)}
/>
</div>
),
@@ -90,16 +123,20 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Programme distribution
Courses of population
</span>
),
},
onTitleClick: () => handleClick(1),
onTitleClick: () => togglePanel(1),
content: {
content: (
<div ref={programmeRef}>
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
<div ref={refs[1]}>
<StudyGuidanceGroupPopulationCourses
selectedStudents={filteredStudents.map(({ studentNumber }) => studentNumber)}
showStructured={coursesStructuredByProgramme}
toggleShowStructured={toggleCoursesStructuredByProgramme}
studyProgramme={group.tags?.studyProgramme}
/>
</div>
),
},
@@ -109,33 +146,15 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Courses of population
Students ({filteredStudents.length})
</span>
),
},
onTitleClick: () => handleClick(2),
onTitleClick: () => togglePanel(2),
content: {
content: (
<div ref={coursesRef}>
<CustomPopulationCourses selectedStudents={selectedStudents} />
</div>
),
},
},
{
key: 3,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({selectedStudents.length})
</span>
),
},
onTitleClick: () => handleClick(3),
content: {
content: (
<div ref={studentRef}>
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
<div ref={refs[2]}>
<PopulationStudents customPopulation language={language} filteredStudents={filteredStudents} />
</div>
),
},
@@ -143,78 +162,73 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
]
return (
<FilterTray filterSet={<CustomPopulationFilters />}>
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
<FilterTray filterSet={<StudyGuidanceGroupFilters />}>
<Segment className="contentSegment">
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
</Segment>
</FilterTray>
)
}
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
const dispatch = useDispatch()
const SingleStudyGroupViewWrapper = ({ group, language, isLoading, studyProgrammes, children }) => {
const history = useHistory()
const handleBack = () => {
dispatch(clearPopulations())
history.push('/studyguidancegroups')
}
return (
<>
<div className="segmentContainer">
<Segment className="contentSegment">
<Button icon="arrow circle left" content="Back" onClick={handleBack} />
<Divider />
<Header size="medium">{getTextIn(groupName, language)}</Header>
</Segment>
</div>
<Wrapper isLoading={isLoading}>
<Button icon="arrow circle left" content="Back" onClick={handleBack} />
<Divider />
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
<Header size="medium" style={{ marginRight: 'auto' }}>
{group.name && language && getTextIn(group.name, language)}
</Header>
{group.tags?.studyProgramme && (
<Label tag content={studyProgrammes.find(p => p.value === group.tags.studyProgramme)?.text} color="blue" />
)}
{group.tags?.year && <Label tag content={startYearToAcademicYear(group.tags.year)} color="blue" />}
</div>
</Wrapper>
{children}
</>
)
}
const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
const dispatch = useDispatch()
const history = useHistory()
const SingleStudyGuidanceGroupContainer = ({ group }) => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const group = groups.find(g => g.id === groupid)
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const { data: population, pending } = useSelector(({ populations }) => populations)
useEffect(() => {
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
if (groupStudentNumbers.length === 0 || population?.students?.length > 0) return
dispatch(getCustomPopulation({ studentnumberlist: groupStudentNumbers, usingStudyGuidanceGroups: true }))
dispatch(
getCustomPopulationCoursesByStudentnumbers({
studentnumberlist: groupStudentNumbers,
usingStudyGuidanceGroups: true,
})
)
}, [])
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const { data, isLoading } = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
if (!group) {
return (
<StyledMessage>
Couldn't find group with id {groupid}! Please check that id is correct and you have rights to this study
guidance group.
</StyledMessage>
<SingleStudyGroupViewWrapper>
<StyledMessage>
Couldn't find group with this id! Please check that id is correct and you have rights to this study guidance
group.
</StyledMessage>
</SingleStudyGroupViewWrapper>
)
}
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false && Object.keys(population).length > 0
const renderMessage = groupStudentNumbers.length === 0
const renderLoader = groupStudentNumbers.length > 0 && isLoading
const renderGroup = groupStudentNumbers.length > 0 && isLoaded
if (groupStudentNumbers.length === 0) {
return (
<SingleStudyGroupViewWrapper>
<StyledMessage>This study guidance group doesn't contain any students.</StyledMessage>
</SingleStudyGroupViewWrapper>
)
}
return (
<SingleStudyGroupViewWrapper history={history} groupName={group.name} language={language}>
{renderMessage ? <StyledMessage>This study guidance group doesn't contain any students.</StyledMessage> : null}
{renderLoader ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{renderGroup ? <SingleStudyGroupContent population={population} group={group} language={language} /> : null}
<SingleStudyGroupViewWrapper
group={group}
language={language}
isLoading={isLoading}
studyProgrammes={studyProgrammes}
>
{!isLoading && <SingleStudyGroupContent population={data} group={group} language={language} />}
</SingleStudyGroupViewWrapper>
)
}
@@ -1,100 +1,109 @@
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Link } from 'react-router-dom'
import React from 'react'
import { useSelector } from 'react-redux'
import { Link, useHistory } from 'react-router-dom'
import { Formik } from 'formik'
import { Form, Button, Icon } from 'semantic-ui-react'
import Datetime from 'react-datetime'
import SortableTable from '../SortableTable'
import { getTextIn, textAndDescriptionSearch } from '../../common'
import StyledMessage from './StyledMessage'
import { changeStudyGuidanceGroupTags } from '../../redux/studyGuidanceGroups'
import { getElementDetails } from '../../redux/elementdetails'
import { useToggle } from '../../common/hooks'
import { getTextIn, textAndDescriptionSearch } from 'common'
import { useChangeStudyGuidanceGroupTagsMutation } from 'redux/studyGuidanceGroups'
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import { useToggle } from 'common/hooks'
import SortableTable from 'components/SortableTable'
import { startYearToAcademicYear, StyledMessage } from './common'
const LinkToGroup = ({ group, language }) => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/studyguidancegroups/${group.id}`}
>
{getTextIn(group.name, language)}
</Link>
)
const LinkToGroup = ({ group, language }) => {
const history = useHistory()
const dest = `/studyguidancegroups/${group.id}`
return (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={dest}
>
{getTextIn(group.name, language)}
<Icon color="blue" name="level up alternate" onClick={() => history.push(dest)} />
</Link>
)
}
const prettifyCamelCase = str => {
const splitted = str.match(/[A-Za-z][a-z]*/g) || []
return splitted.map(w => w.charAt(0).toLowerCase() + w.substring(1)).join(' ')
}
const startYearToAcademicYear = year => {
return year === '' || Number.isNaN(year) ? '' : `${year} - ${parseInt(year, 10) + 1}`
}
const cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
const cellContent = { flex: '0 1 50%' }
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const dispatch = useDispatch()
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
return (
<div style={{ display: 'flex', gap: '8px' }}>
<div>
<Formik
initialValues={{ [tagName]: '' }}
onSubmit={values => {
dispatch(changeStudyGuidanceGroupTags(group.id, values))
toggleEdit()
changeStudyGuidanceGroupTags({ groupId: group.id, tags: values })
if (group.tags?.[tagName]) toggleEdit()
}}
validate={values => (!values[tagName] ? { [tagName]: `${tagName} is required` } : {})}
>
{formik => (
<Form onSubmit={formik.handleSubmit} style={{ display: 'flex', gap: '8px' }}>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
search={textAndDescriptionSearch}
fluid
placeholder={
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
/>
) : (
<Datetime
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
renderYear={(props, selectableYear) => <td {...props}>{startYearToAcademicYear(selectableYear)}</td>}
renderInput={({ value, ...rest }) => {
return (
<div>
<input value={startYearToAcademicYear(value)} placeholder="Select year" {...rest} />
</div>
)
}}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
/>
<Form onSubmit={formik.handleSubmit} style={{ ...cellWrapper, alignItems: 'center' }}>
<div style={cellContent}>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
search={textAndDescriptionSearch}
fluid
placeholder={
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
/>
) : (
<Datetime
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
inputProps={{ readOnly: true }}
renderYear={(props, selectableYear) => <td {...props}>{startYearToAcademicYear(selectableYear)}</td>}
renderInput={({ value, ...rest }) => {
return (
<div>
<input value={startYearToAcademicYear(value)} placeholder="Select year" {...rest} />
</div>
)
}}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
/>
)}
</div>
<div style={cellContent}>
<Button type="submit" style={{ margin: '0' }} disabled={isLoading}>
Add {prettifyCamelCase(tagName)}
</Button>
</div>
{group.tags?.[tagName] && (
<div style={cellContent}>
<Button icon type="button" style={{ margin: '0' }} onClick={() => toggleEdit()}>
Close
<Icon name="close" />
</Button>
</div>
)}
<Button type="submit" style={{ margin: '0 0 1em 0' }}>
Add {prettifyCamelCase(tagName)}
</Button>
</Form>
)}
</Formik>
{group.tags?.[tagName] ? (
<Button icon style={{ margin: '0 0 1em 0' }} onClick={() => toggleEdit()}>
Close
<Icon name="close" />
</Button>
) : null}
</div>
)
}
@@ -111,40 +120,23 @@ const TagCell = ({ tagName, group, studyProgrammes }) => {
throw Error(`Wrong tagname: ${tagName}`)
}
}
const text = getText()
return group.tags?.[tagName] && !showEdit ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
<p>{text}</p>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
<div style={{ ...cellWrapper, alignItems: 'baseline' }}>
<p style={{ ...cellContent, textAlign: 'center' }}>{getText()}</p>
<div style={cellContent}>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
</div>
</div>
) : (
<AssociateTagForm group={group} tagName={tagName} toggleEdit={toggleEdit} selectFieldItems={studyProgrammes} />
)
}
const StudyGuidanceGroupOverview = () => {
const dispatch = useDispatch()
const StudyGuidanceGroupOverview = ({ groups }) => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const { data: elementDetails } = useSelector(({ elementdetails }) => elementdetails)
useEffect(() => {
if (elementDetails && elementDetails.length > 0) return
dispatch(getElementDetails())
}, [dispatch])
const studyProgrammesFilteredForDropdown =
elementDetails
?.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
.map(elem => ({
key: elem.code,
value: elem.code,
description: elem.code,
text: getTextIn(elem.name, language),
})) || []
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const headers = [
{
@@ -156,6 +148,7 @@ const StudyGuidanceGroupOverview = () => {
style: {
padding: '0',
},
className: 'iconCellNoPointer',
},
},
{
@@ -174,16 +167,12 @@ const StudyGuidanceGroupOverview = () => {
key: 'studyProgramme',
title: 'Study programme',
getRowVal: group => group.tags?.studyProgramme,
headerProps: { onClick: null, sorted: null },
getRowContent: group => (
<TagCell tagName="studyProgramme" studyProgrammes={studyProgrammesFilteredForDropdown} group={group} />
),
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
},
{
key: 'associatedyear',
title: 'Associated starting academic year',
getRowVal: group => group.tags?.year,
headerProps: { onClick: null, sorted: null },
getRowContent: group => <TagCell tagName="year" group={group} />,
},
]
@@ -0,0 +1,62 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import InfoBox from 'components/InfoBox'
import PopulationCourseStatsFlat from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
import PopulationCourseStats from 'components/PopulationCourseStats'
import useCourseFilter from 'components/FilterTray/filters/Courses/useCourseFilter'
import SegmentDimmer from 'components/SegmentDimmer'
import { useGetStudyGuidanceGroupPopulationCoursesQuery } from 'redux/studyGuidanceGroups'
import { getMandatoryCourses, getMandatoryCourseModules } from 'redux/populationMandatoryCourses'
import infotooltips from 'common/InfoToolTips'
const StudyGuidanceGroupPopulationCourses = ({
selectedStudents,
showStructured,
toggleShowStructured,
studyProgramme,
}) => {
const { setCourses, resetCourses } = useCourseFilter()
const dispatch = useDispatch()
const mandatoryCourses = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses.data)
const { data: courses, isLoading } = useGetStudyGuidanceGroupPopulationCoursesQuery(selectedStudents)
useEffect(() => {
// ensure mandatory courses are available for course stats structured
if (showStructured && studyProgramme && mandatoryCourses.length === 0) {
dispatch(getMandatoryCourses(studyProgramme))
dispatch(getMandatoryCourseModules(studyProgramme))
}
}, [showStructured])
useEffect(() => {
if (!isLoading) setCourses(courses.coursestatistics)
}, [courses])
useEffect(() => {
return resetCourses
}, [])
if (isLoading) return <SegmentDimmer isLoading={isLoading} />
return (
<Segment basic>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
{studyProgramme && (
<Button primary onClick={() => toggleShowStructured()} style={{ marginLeft: '1em' }}>
{showStructured ? 'Show courses as flat list' : 'Show by programme structure'}
</Button>
)}
{showStructured ? (
<PopulationCourseStats courses={courses} pending={isLoading} selectedStudents={selectedStudents} />
) : (
<PopulationCourseStatsFlat
courses={courses}
pending={isLoading}
selectedStudents={selectedStudents}
showFilter={false}
/>
)}
</Segment>
)
}
export default StudyGuidanceGroupPopulationCourses
@@ -1,4 +0,0 @@
import React from 'react'
import { Message } from 'semantic-ui-react'
export default ({ children }) => <Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
@@ -0,0 +1,18 @@
import React from 'react'
import { Message, Segment } from 'semantic-ui-react'
export const startYearToAcademicYear = year => {
return year === '' || Number.isNaN(year) ? '' : `${year} - ${parseInt(year, 10) + 1}`
}
export const StyledMessage = ({ children }) => (
<Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
)
export const Wrapper = ({ isLoading, children }) => (
<div className="segmentContainer">
<Segment loading={isLoading} className="contentSegment">
{children}
</Segment>
</div>
)
@@ -1,26 +1,16 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useRouteMatch } from 'react-router-dom'
import { Header, Loader, Segment } from 'semantic-ui-react'
import { useTitle } from '../../common/hooks'
import { getStudyGuidanceGroups } from '../../redux/studyGuidanceGroups'
import React from 'react'
import { useParams } from 'react-router-dom'
import { Header } from 'semantic-ui-react'
import { useGetAllStudyGuidanceGroupsQuery } from 'redux/studyGuidanceGroups'
import { useTitle } from 'common/hooks'
import StudyGuidanceGroupOverview from './StudyGuidanceGroupOverview'
import SingleStudyGuidanceGroup from './SingleStudyGuidanceGroup'
import { Wrapper } from './common'
const StudyGuidanceGroups = () => {
useTitle('Study guidance groups')
const dispatch = useDispatch()
const { pending, data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const match = useRouteMatch('/studyguidancegroups/:groupid')
const groupid = match?.params?.groupid
useEffect(() => {
if (groups && groups.length > 0) return
dispatch(getStudyGuidanceGroups())
}, [dispatch])
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false
const { groupid } = useParams()
const { data, isLoading } = useGetAllStudyGuidanceGroupsQuery()
return (
<>
@@ -28,16 +18,9 @@ const StudyGuidanceGroups = () => {
<Header className="segmentTitle" size="large">
Study guidance groups
</Header>
<Segment className="contentSegment">
{isLoading ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
</Segment>
</div>
<div>{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}</div>
<Wrapper isLoading={isLoading}>{!isLoading && !groupid && <StudyGuidanceGroupOverview groups={data} />}</Wrapper>
{!isLoading && groupid && <SingleStudyGuidanceGroup group={data.find(group => group.id === groupid)} />}
</>
)
}
+4 -1
View File
@@ -9,7 +9,7 @@ export const login = () => ({
export const logout = () => ({ type: 'LOGOUT_ATTEMPT' })
const reducer = (state = { pending: false, error: false, token: null }, action) => {
const reducer = (state = { pending: false, error: false, token: null, encodedToken: null }, action) => {
switch (action.type) {
case 'LOGIN_ATTEMPT':
return {
@@ -31,6 +31,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: false,
token: decodeToken(action.token),
encodedToken: action.token,
}
case 'LOGOUT_ATTEMPT':
@@ -46,6 +47,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: true,
token: null,
encodedToken: null,
}
case 'LOGOUT_SUCCESS':
@@ -54,6 +56,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: false,
token: null,
encodedToken: null,
}
default:
@@ -1,5 +1,8 @@
import { RTKApi } from 'apiConnection'
import { getTextIn } from 'common'
import { callController } from '../apiConnection'
// original elementdetail stuff
export const getElementDetails = () => {
const route = '/elementdetails/all'
const prefix = 'GET_ELEMENTDETAILS_'
@@ -31,4 +34,38 @@ const reducer = (state = { data: [] }, action) => {
}
}
// RTK query based stuff
const elementDetailsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getAllElementDetails: builder.query({
query: () => '/elementdetails/all',
providesTags: result => [
...result?.map(({ id }) => ({ type: 'StudyGuidanceGroups', id })),
{ type: 'StudyGuidanceGroups', id: 'LIST' },
],
}),
}),
overrideExisting: false,
})
export const { useGetAllElementDetailsQuery } = elementDetailsApi
// Returns only newest studyprogrammes and formats them to be used in semantic ui dropdowns
export const useFilteredAndFormattedElementDetails = language => {
const { data, isLoading } = useGetAllElementDetailsQuery()
const filteredAndFormatted = isLoading
? []
: data
.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
.map(elem => ({
key: elem.code,
value: elem.code,
description: elem.code,
text: getTextIn(elem.name, language),
}))
return filteredAndFormatted
}
export default reducer
+7 -4
View File
@@ -1,5 +1,6 @@
import { configureStore } from '@reduxjs/toolkit'
import { handleRequest, handleAuth } from '../apiConnection'
import { setupListeners } from '@reduxjs/toolkit/query/react'
import { handleRequest, handleAuth, RTKApi } from 'apiConnection'
import actionHistory from './actionHistory'
import users from './users'
import populations from './populations'
@@ -40,9 +41,8 @@ import singleCourseStats from './singleCourseStats'
import userAccessEmail from './userAccessEmail'
import customPopulationSearch from './customPopulationSearch'
import coolDataScience from './coolDataScience'
import studyGuidanceGroups from './studyGuidanceGroups'
export default configureStore({
const store = configureStore({
reducer: {
actionHistory,
users,
@@ -84,7 +84,7 @@ export default configureStore({
userAccessEmail,
customPopulationSearch,
coolDataScience,
studyGuidanceGroups,
[RTKApi.reducerPath]: RTKApi.reducer,
},
// oodikone is currently too heavy for other middlewares than thunk, but
// feel free to take use them at some point if possible
@@ -92,5 +92,8 @@ export default configureStore({
...getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }),
handleRequest,
handleAuth,
RTKApi.middleware,
],
})
setupListeners(store.dispatch)
export default store
@@ -1,37 +1,43 @@
import { createReducer } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
import { initialState, matcherReducers, defaultReducer } from './common'
import { RTKApi } from 'apiConnection'
const baseUrl = '/studyguidancegroups'
const getPrefix = 'GET_STUDY_GUIDANCE_GROUPS_'
const getTypes = {
...actionTypes(getPrefix),
}
const changeTagsPrefix = 'CHANGE_STUDY_GUIDANCE_GROUP_TAGS_'
const changeTagsTypes = {
...actionTypes(changeTagsPrefix),
}
const studyGuidanceGroupsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getAllStudyGuidanceGroups: builder.query({
query: () => '/studyguidancegroups',
providesTags: result => [
...result?.map(({ id }) => ({ type: 'StudyGuidanceGroups', id })),
{ type: 'StudyGuidanceGroups', id: 'LIST' },
],
}),
changeStudyGuidanceGroupTags: builder.mutation({
query: ({ groupId, tags }) => ({
url: `/studyguidancegroups/${groupId}/tags`,
method: 'PUT',
body: tags,
}),
invalidatesTags: ({ studyGuidanceGroupId }) => [{ type: 'StudyGuidanceGroups', id: studyGuidanceGroupId }],
}),
getStudyGuidanceGroupPopulation: builder.query({
query: studentnumberlist => ({
url: '/v3/populationstatisticsbystudentnumbers',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
}),
}),
getStudyGuidanceGroupPopulationCourses: builder.query({
query: studentnumberlist => ({
url: '/v2/populationstatistics/coursesbystudentnumberlist',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
}),
}),
}),
overrideExisting: false,
})
export const getStudyGuidanceGroups = () => callController(baseUrl, getPrefix)
export const changeStudyGuidanceGroupTags = (groupId, tags) =>
callController(`${baseUrl}/${groupId}/tags`, changeTagsPrefix, tags, 'put')
const reducer = createReducer(
initialState,
{
[getTypes.success]: (state, action) => {
state.data = action.response
},
[changeTagsTypes.success]: (state, action) => {
state.data.find(group => group.id === action.response.studyGuidanceGroupId).tags = (({
studyGuidanceGroupId,
...rest
}) => rest)(action.response)
},
},
matcherReducers,
defaultReducer
)
export default reducer
export const {
useGetAllStudyGuidanceGroupsQuery,
useChangeStudyGuidanceGroupTagsMutation,
useGetStudyGuidanceGroupPopulationQuery,
useGetStudyGuidanceGroupPopulationCoursesQuery,
} = studyGuidanceGroupsApi
+3 -3
View File
@@ -6596,9 +6596,9 @@
}
},
"validator": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz",
"integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg=="
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
},
"vary": {
"version": "1.1.2",
@@ -358,20 +358,27 @@ const updateAttainments = async (attainments, personIdToStudentNumber, attainmen
} else {
coursesToBeCreated.set('AY'.concat(course.code), {
...course.dataValues,
name: course.name,
id: course.id.concat('-ay'),
code: 'AY'.concat(course.code),
substitutions: [course.id],
})
const subs = [...course.substitutions, course.id.concat('-ay')]
coursesToBeUpdated.set(course.code, {
...course.dataValues,
substitutions: subs,
})
courseCodeToAyCodelessId.set('AY'.concat(course.code), course.id.concat('-ay'))
const courseProvider = await CourseProvider.findOne({
where: {
coursecode: course ? course.id : 'AY'.concat(course.code),
coursecode: `${course.id}-ay`,
},
})
if (!courseProvider) {
const mapCourseProvider = courseProviderMapper('AY'.concat(course.code))
courseProvidersToBeCreated.push(mapCourseProvider('hy-org-48645785'))
const mapCourseProvider = courseProviderMapper(course.id.concat('-ay'))
courseProvidersToBeCreated.push(mapCourseProvider({ organisationId: 'hy-org-48645785' }))
}
}
}