mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33c0aa71d9 | ||
|
|
2e4572c871 | ||
|
|
f6b14777fe | ||
|
|
f251f9456e | ||
|
|
f364eb4f67 | ||
|
|
4896f9e458 | ||
|
|
096535e470 | ||
|
|
7c949b47a7 | ||
|
|
c3b7d96b06 | ||
|
|
9a3783bc45 | ||
|
|
4882d1d9d3 | ||
|
|
9575d86d9c | ||
|
|
85c7e86ed9 | ||
|
|
d527c6ba5f | ||
|
|
6d5b91782c | ||
|
|
b8762aae34 | ||
|
|
4da78df1e6 | ||
|
|
93258ad12a | ||
|
|
0028b69661 | ||
|
|
ef902b1c0c | ||
|
|
d8e3ee092f | ||
|
|
36e8c2f7f8 | ||
|
|
8763b1868e | ||
|
|
700d799b00 | ||
|
|
b0ecc3d4f5 | ||
|
|
e681d5a94f | ||
|
|
980cd8ecf0 | ||
|
|
357c0b3f44 | ||
|
|
6467f5046e | ||
|
|
21ff440ebd | ||
|
|
73228fb26f | ||
|
|
2c1807fe10 | ||
|
|
50091a3098 | ||
|
|
3ba7965402 | ||
|
|
067a53a072 | ||
|
|
9a3956832d | ||
|
|
831b638c99 | ||
|
|
38c343b8d2 | ||
|
|
c05346ad9a | ||
|
|
dd12cd3626 | ||
|
|
55eebe32a6 | ||
|
|
58592df2a0 | ||
|
|
dd110d21e3 | ||
|
|
88443f0117 | ||
|
|
accd5c9120 | ||
|
|
c11f5d78ba | ||
|
|
9f4466d246 | ||
|
|
5504f02c31 | ||
|
|
863e84e226 | ||
|
|
0ce965abe3 | ||
|
|
e9242b3063 | ||
|
|
6ccf320bb2 | ||
|
|
4e9b579843 | ||
|
|
985084864c | ||
|
|
8099dff1b6 | ||
|
|
1528ff3d80 | ||
|
|
71c61c3393 | ||
|
|
f3b1f639b1 | ||
|
|
e599e620c0 | ||
|
|
f5b081656f | ||
|
|
7af8e1a4df | ||
|
|
8c092c8d9f | ||
|
|
e0789186ba | ||
|
|
c798a4747f | ||
|
|
8f396a9913 | ||
|
|
148a5acc25 | ||
|
|
9cf1b87b3f | ||
|
|
82ed55e1f9 | ||
|
|
82041e673d | ||
|
|
1e4405aac9 | ||
|
|
dcefdd12cb | ||
|
|
fd775252b8 | ||
|
|
acd7dc65f6 | ||
|
|
a82d272a27 | ||
|
|
1bb5ddba96 | ||
|
|
ae14a55885 | ||
|
|
9308016cf3 | ||
|
|
b5252a01f8 | ||
|
|
4b22902fa4 | ||
|
|
267ebce457 | ||
|
|
ee8013cba2 | ||
|
|
4ce07eaf31 | ||
|
|
e0d4e4072d | ||
|
|
d4aeece78e | ||
|
|
02adcbc4f8 | ||
|
|
a9d44b3d76 | ||
|
|
3d310ee184 | ||
|
|
0adf1eae76 | ||
|
|
e10d3dd3c7 | ||
|
|
fce0e482d3 | ||
|
|
4d57d00ddb | ||
|
|
7cc90a4378 | ||
|
|
ff0f1a545f | ||
|
|
6eb69a036a | ||
|
|
3c8a47fc76 | ||
|
|
735ec7efa0 | ||
|
|
c33f842c9a | ||
|
|
2340e39fb4 | ||
|
|
0221d1f70e | ||
|
|
c6fe105b46 | ||
|
|
6ec1bc118d | ||
|
|
098dd5425e | ||
|
|
9eeed5094f | ||
|
|
ada4f66105 | ||
|
|
b25c90e338 | ||
|
|
47995056cb | ||
|
|
46ee5b7764 | ||
|
|
c8f0e421fe | ||
|
|
3db6a4443e | ||
|
|
77d20ad189 | ||
|
|
bd6b2adef0 | ||
|
|
7457ada25a | ||
|
|
ab94a0b27c | ||
|
|
41e86af663 | ||
|
|
19e752b7ca | ||
|
|
3d4f71dbad | ||
|
|
ff4d858efc | ||
|
|
59271c9e38 | ||
|
|
d87fb6d0fa | ||
|
|
646d87ab9e | ||
|
|
9e687f27a3 | ||
|
|
b699322e6f | ||
|
|
3c01e3b7b1 | ||
|
|
cb256976d7 | ||
|
|
1424461e26 | ||
|
|
86bd472c41 | ||
|
|
8984ea14f1 | ||
|
|
1af42bf012 | ||
|
|
f606a7e8b7 | ||
|
|
6d938a34af | ||
|
|
77c5721dff | ||
|
|
c5561ab6dc | ||
|
|
0b647e1b32 | ||
|
|
1437bb9d0e | ||
|
|
66249aabd8 | ||
|
|
02c63d1d9f | ||
|
|
00b7dc933e | ||
|
|
9f089fb6db | ||
|
|
542267042e | ||
|
|
9e282ae634 | ||
|
|
db66880e25 |
@@ -1,4 +1,3 @@
|
||||
# Populate and change this to .env
|
||||
IMPORTER_DB_API_TOKEN=
|
||||
IMPORTER_DB_API_URL=https://importer.cs.helsinki.fi
|
||||
PATE_TOKEN=
|
||||
|
||||
@@ -44,20 +44,22 @@ will take care of setting up Oodikone for you. See README for more details."
|
||||
msg ""
|
||||
}
|
||||
|
||||
# Define custom shell prompt for the interactive select loop
|
||||
PS3="Please enter your choice: "
|
||||
set_custom_select_prompt() {
|
||||
PS3="Please enter your choice: "
|
||||
|
||||
options=(
|
||||
"Set up oodikone from scratch."
|
||||
"Reset all real data."
|
||||
"Reset sis-importer data."
|
||||
"Quit."
|
||||
)
|
||||
options=(
|
||||
"Set up oodikone from scratch."
|
||||
"Reset all real data."
|
||||
"Reset single database."
|
||||
"Quit."
|
||||
)
|
||||
}
|
||||
|
||||
# Run scripts before showing interactive prompt
|
||||
# Run scripts and set the prompt
|
||||
show_welcome
|
||||
init_dirs
|
||||
get_username
|
||||
set_custom_select_prompt
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
@@ -66,8 +68,10 @@ while true; do
|
||||
set_up_oodikone_from_scratch;;
|
||||
"Reset all real data.")
|
||||
reset_all_real_data;;
|
||||
"Reset sis-importer data.")
|
||||
reset_sis_importer_data;;
|
||||
"Reset single database.")
|
||||
reset_single_database
|
||||
set_custom_select_prompt # Set prompt back to initial values
|
||||
;;
|
||||
"Quit.")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
const moment = require('moment')
|
||||
const _ = require('lodash')
|
||||
|
||||
const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
cy.contains('Advanced settings').siblings().get('[data-cy=advanced-toggle]').click()
|
||||
includeSettings.forEach(setting => {
|
||||
@@ -13,6 +16,8 @@ const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
describe('Population Statistics tests', () => {
|
||||
const pathToCSBach2017 =
|
||||
'/populations?months=36&semesters=FALL&semesters=SPRING&studyRights={%22programme%22%3A%22KH50_005%22}&tag&year=2017'
|
||||
const pathToCSMaster2019 =
|
||||
'/populations?months=27&semesters=FALL&semesters=SPRING&studyRights=%7B%22programme%22%3A%22MH50_009%22%7D&tag&year=2019'
|
||||
describe('when using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
@@ -59,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(() => {
|
||||
@@ -69,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
|
||||
@@ -123,18 +130,62 @@ describe('Population Statistics tests', () => {
|
||||
cy.contains('Credit accumulation (for 170 students)')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Statistics pane works', () => {
|
||||
it('Credit Statistics, Credits Gained tab works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the population')
|
||||
|
||||
const months = Math.ceil(moment.duration(moment().diff(moment('2017-08-1'))).asMonths())
|
||||
|
||||
const limits = [1, ..._.range(1, 4).map(p => Math.ceil(months * ((p * 15) / 12))), null]
|
||||
const ranges = _.range(1, limits.length).map(i => _.slice(limits, i - 1, i + 1))
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', '(n=149)')
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
let value = 'credits'
|
||||
|
||||
if (start !== null) {
|
||||
value = `${start} ≤ ${value}`
|
||||
}
|
||||
|
||||
if (end !== null) {
|
||||
value = `${value} < ${end}`
|
||||
}
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', value)
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-body'] td:nth-child(3)").then($els => {
|
||||
const sum = [...$els].map($el => parseInt($el.innerText)).reduce((a, b) => a + b, 0)
|
||||
|
||||
expect(sum).to.equal(149)
|
||||
})
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('not.exist')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
|
||||
cy.visit(pathToCSMaster2019)
|
||||
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')
|
||||
cy.get("[data-cy='credits-gained-table-Muu']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('exist')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Statistics pane works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
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')
|
||||
cy.get("[data-cy='credit-stats-mean']").should('contain', '123.89')
|
||||
cy.get("[data-cy='credit-stats-stdev']").should('contain', '61.32')
|
||||
cy.get("[data-cy='credit-stats-min']").should('contain', '11')
|
||||
cy.get("[data-cy='credit-stats-q1']").should('contain', '70')
|
||||
cy.get("[data-cy='credit-stats-q2']").should('contain', '129')
|
||||
cy.get("[data-cy='credit-stats-q3']").should('contain', '168')
|
||||
cy.get("[data-cy='credit-stats-max']").should('contain', '314')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,16 +4,16 @@ describe('Users tests', () => {
|
||||
describe('Using user with just grp-oodikone-user, no other rights', () => {
|
||||
it('shows only frontpage, trends feedback', () => {
|
||||
cy.init('', 'norights') // login with norights user
|
||||
cy.get('.navBar').contains('Trends').should('exist')
|
||||
cy.get('.navBar').contains('Give feedback').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Give feedback').should('exist')
|
||||
|
||||
cy.get('.navBar').contains('Study programme').should('not.exist')
|
||||
cy.get('.navBar').contains('Student statistics').should('not.exist')
|
||||
cy.get('.navBar').contains('Course statistics').should('not.exist')
|
||||
cy.get('.navBar').contains('Teachers').should('not.exist')
|
||||
cy.get('.navBar').contains('Users').should('not.exist')
|
||||
cy.get('.navBar').contains('Faculty').should('not.exist')
|
||||
cy.get('.navBar').contains('Updater').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Study programme').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Student statistics').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Course statistics').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Teachers').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Users').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Faculty').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Updater').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,12 +29,12 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it('should see more stuff than others', () => {
|
||||
cy.get('.navBar').contains('Study programme').should('exist')
|
||||
cy.get('.navBar').contains('Student statistics').should('exist')
|
||||
cy.get('.navBar').contains('Course statistics').should('exist')
|
||||
// TODO: admin should probably see this as well, since can access this page: cy.get('.navBar').contains('Teachers').should('exist')
|
||||
cy.get('.navBar').contains('Users').should('exist')
|
||||
cy.get('.navBar').contains('Trends').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Study programme').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Student statistics').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Course statistics').should('exist')
|
||||
// TODO: admin should probably see this as well, since can access this page: cy.get('[data-cy="navBar"]').contains('Teachers').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Users').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
|
||||
})
|
||||
|
||||
it("mocking normal user shows only the mocked user's programmes", () => {
|
||||
|
||||
Generated
+34
-6593
File diff suppressed because it is too large
Load Diff
@@ -49,5 +49,9 @@
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
"stylelint-config-standard": "^22.0.0",
|
||||
"stylelint-prettier": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,12 +110,54 @@ reset_all_real_data() {
|
||||
reset_databases "${DATABASES[@]}"
|
||||
}
|
||||
|
||||
reset_sis_importer_data() {
|
||||
infomsg "Downloading sis-importer-db dump"
|
||||
local database=$SIS_IMPORTER_DB_NAME
|
||||
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
|
||||
download_real_dump $database $url
|
||||
reset_databases $database
|
||||
reset_single_database() {
|
||||
# Define custom shell prompt for the nested interactive select loop
|
||||
PS3="Please enter which database to reset: "
|
||||
|
||||
options=(
|
||||
"kone-db"
|
||||
"sis-db"
|
||||
"sis-importer-db"
|
||||
"user-db"
|
||||
"Return to main menu."
|
||||
)
|
||||
|
||||
local database=""
|
||||
local url=""
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
case $opt in
|
||||
"kone-db")
|
||||
local database=$KONE_DB_NAME
|
||||
local url=$KONE_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"sis-db")
|
||||
local database=$SIS_DB_NAME
|
||||
local url=$SIS_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"sis-importer-db")
|
||||
local database=$SIS_IMPORTER_DB_NAME
|
||||
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"user-db")
|
||||
local database=$USER_DB_NAME
|
||||
local url=$USER_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"Return to main menu.")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
";;
|
||||
esac
|
||||
break
|
||||
done
|
||||
done
|
||||
|
||||
if [ -n "$database" ]; then
|
||||
infomsg "Downloading $database dump"
|
||||
download_real_dump $database $url
|
||||
reset_databases $database
|
||||
fi
|
||||
}
|
||||
|
||||
set_up_oodikone_from_scratch() {
|
||||
|
||||
Generated
+3
-3
@@ -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",
|
||||
|
||||
@@ -18,7 +18,6 @@ const pateToken = process.env.PATE_API_TOKEN || ''
|
||||
|
||||
// Importer client
|
||||
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
|
||||
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
|
||||
|
||||
// Networking: Urls & ports
|
||||
const baseUrl = isDev ? '/api' : ''
|
||||
@@ -62,8 +61,6 @@ const addSlashToEnd = url => (url.endsWith('/') ? url : url + '/')
|
||||
|
||||
const ACCESS_TOKEN_HEADER_KEY = 'x-access-token'
|
||||
|
||||
const PWD = process.env.PWD || '/usr/src/app'
|
||||
|
||||
module.exports = {
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
@@ -91,6 +88,4 @@ module.exports = {
|
||||
frontUrl,
|
||||
backendPort,
|
||||
importerToken,
|
||||
importerUrl,
|
||||
PWD,
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ const initializeDatabaseConnection = async () => {
|
||||
const migrations = await migrator.up()
|
||||
logger.info({ message: 'Kone Migrations up to date', meta: migrations })
|
||||
} catch (e) {
|
||||
logger.error('Kone Migration error')
|
||||
logger.error({ message: 'Kone Migration error: ', meta: e })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
const { BIGINT, STRING, DATE } = require('sequelize')
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.createTable(
|
||||
'study_guidance_group_tags',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: BIGINT,
|
||||
autoIncrement: true,
|
||||
},
|
||||
study_guidance_group_id: {
|
||||
type: STRING,
|
||||
unique: true,
|
||||
},
|
||||
study_programme: {
|
||||
type: STRING,
|
||||
},
|
||||
year: {
|
||||
type: STRING,
|
||||
},
|
||||
created_at: {
|
||||
type: DATE,
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE,
|
||||
},
|
||||
},
|
||||
{ underscored: true, timestamps: true }
|
||||
)
|
||||
},
|
||||
down: async () => {},
|
||||
}
|
||||
@@ -135,6 +135,28 @@ const CustomPopulationSearch = sequelizeKone.define('custom_population_searches'
|
||||
},
|
||||
})
|
||||
|
||||
const StudyGuidanceGroupTag = sequelizeKone.define(
|
||||
'study_guidance_group_tags',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.BIGINT,
|
||||
autoIncrement: true,
|
||||
},
|
||||
studyGuidanceGroupId: {
|
||||
type: Sequelize.STRING,
|
||||
unique: true,
|
||||
},
|
||||
studyProgramme: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
year: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
},
|
||||
{ underscored: true, timestamps: true }
|
||||
)
|
||||
|
||||
MandatoryCourse.belongsTo(MandatoryCourseLabels, {
|
||||
foreignKey: 'label',
|
||||
sourceKey: 'id',
|
||||
@@ -157,4 +179,5 @@ module.exports = {
|
||||
CustomPopulationSearch,
|
||||
sequelizeKone,
|
||||
ExcludedCourse,
|
||||
StudyGuidanceGroupTag,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ const router = require('express').Router()
|
||||
const Student = require('../services/students')
|
||||
const userService = require('../services/userService')
|
||||
const Unit = require('../services/units')
|
||||
const { mismatchedStudents } = require('../services/student_credit_total_mismatches')
|
||||
|
||||
const filterStudentTags = (student, userId) => {
|
||||
return {
|
||||
@@ -11,13 +10,6 @@ const filterStudentTags = (student, userId) => {
|
||||
}
|
||||
}
|
||||
|
||||
const creditTotalMismatch = student => {
|
||||
return {
|
||||
...student,
|
||||
mismatch: mismatchedStudents.has(student.studentNumber),
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/students', async (req, res) => {
|
||||
const {
|
||||
roles,
|
||||
@@ -58,10 +50,7 @@ router.get('/students/:id', async (req, res) => {
|
||||
const results = await Student.withId(studentId)
|
||||
return results.error
|
||||
? res.status(400).json({ error: 'error finding student' }).end()
|
||||
: res
|
||||
.status(200)
|
||||
.json(creditTotalMismatch(filterStudentTags(results, decodedToken.id)))
|
||||
.end()
|
||||
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
|
||||
}
|
||||
|
||||
const uid = req.decodedToken.userId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const router = require('express').Router()
|
||||
const { logger } = require('../util/logger')
|
||||
const { getAllGroupsAndStudents } = require('../services/studyGuidanceGroups')
|
||||
const { getAllGroupsAndStudents, changeGroupTags } = require('../services/studyGuidanceGroups')
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const {
|
||||
@@ -13,4 +13,27 @@ router.get('/', async (req, res) => {
|
||||
return res.json(await getAllGroupsAndStudents(sisPersonId))
|
||||
})
|
||||
|
||||
router.put('/:id/tags', async (req, res) => {
|
||||
const {
|
||||
decodedToken: { uid, sisPersonId },
|
||||
body: tags,
|
||||
params: { id: groupId },
|
||||
} = req
|
||||
|
||||
if (!sisPersonId) {
|
||||
logger.error(`User ${uid} tried to change study guidance group tags but personId was ${sisPersonId} in header`)
|
||||
return res.status(400).json({ error: 'Not possible to change groups without personId' })
|
||||
}
|
||||
|
||||
const groupsUserHasAccessTo = (await getAllGroupsAndStudents(sisPersonId)).map(group => group.id)
|
||||
|
||||
if (!groupsUserHasAccessTo.includes(groupId)) {
|
||||
logger.error(
|
||||
`User ${uid} tried to change study guidance group tags, but didn't have permission for the group ${groupId}`
|
||||
)
|
||||
return res.status(400).json({ error: 'Access denied' })
|
||||
}
|
||||
return res.status(200).json(await changeGroupTags({ groupId, tags }))
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
const { getImporterClient } = require('../util/importerClient')
|
||||
const importerClient = getImporterClient()
|
||||
const { StudyGuidanceGroupTag } = require('../models/models_kone')
|
||||
|
||||
const getAllStudentsUserHasInGroups = async sisPersonId => {
|
||||
if (!importerClient) {
|
||||
@@ -19,10 +20,33 @@ const getAllGroupsAndStudents = async sisPersonId => {
|
||||
return []
|
||||
}
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return Object.values(data)
|
||||
const tagsByGroupId = (await StudyGuidanceGroupTag.findAll()).reduce((acc, curr) => {
|
||||
const { studyProgramme, year } = curr
|
||||
return { ...acc, [curr.studyGuidanceGroupId]: { studyProgramme, year } }
|
||||
}, {})
|
||||
return Object.values(data).map(group => ({ ...group, tags: tagsByGroupId[group.id] }))
|
||||
}
|
||||
|
||||
const changeGroupTags = async ({ groupId, tags }) => {
|
||||
const { studyProgramme, year } = tags
|
||||
const tagToUpdate = studyProgramme ? { studyProgramme } : { year }
|
||||
|
||||
const [result] = await StudyGuidanceGroupTag.upsert(
|
||||
{
|
||||
studyGuidanceGroupId: groupId,
|
||||
...tagToUpdate,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
studyGuidanceGroupId: groupId,
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllStudentsUserHasInGroups,
|
||||
getAllGroupsAndStudents,
|
||||
changeGroupTags,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const { ElementDetail, Organization } = require('../../models')
|
||||
const { getRedisCDS, saveToRedis } = require('./shared')
|
||||
const { facultiesAndProgrammesForTrends } = require('../../services/organisations')
|
||||
|
||||
const REDIS_KEY_STATUS = 'STATUS_DATA_V3'
|
||||
const REDIS_KEY_STATUS = 'STATUS_DATA_V5'
|
||||
|
||||
const getCurrentStudyYearStartDate = _.memoize(
|
||||
async unixMillis =>
|
||||
@@ -118,13 +118,13 @@ const getOrganizationStatistics = async (organizations, start, end) => {
|
||||
if (organizationStats[orgId] === undefined) {
|
||||
organizationStats[orgId] = {
|
||||
direct: { credits: 0, students: new Set() },
|
||||
accumulated: { credits: 0, students: new Set() },
|
||||
aggregated: { credits: 0, students: new Set() },
|
||||
}
|
||||
}
|
||||
|
||||
const orgStats = organizationStats[orgId]
|
||||
|
||||
const statsRefs = isDirect ? [orgStats.direct, orgStats.accumulated] : [orgStats.accumulated]
|
||||
const statsRefs = isDirect ? [orgStats.direct, orgStats.aggregated] : [orgStats.aggregated]
|
||||
|
||||
statsRefs.forEach(statsRef => {
|
||||
statsRef.credits += stats.credits
|
||||
@@ -143,20 +143,20 @@ const getOrganizationStatistics = async (organizations, start, end) => {
|
||||
updateOrgStats(orgId, { credits, students }, true)
|
||||
|
||||
if (parentOrgId) {
|
||||
updateOrgStats(parentOrgId, organizationStats[orgId].accumulated, false)
|
||||
updateOrgStats(parentOrgId, organizationStats[orgId].aggregated, false)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(organizationStats).reduce((acc, [id, { direct, accumulated }]) => {
|
||||
return Object.entries(organizationStats).reduce((acc, [id, { direct, aggregated }]) => {
|
||||
acc[id] = {
|
||||
id,
|
||||
direct: {
|
||||
students: direct.students.size,
|
||||
credits: direct.credits,
|
||||
},
|
||||
accumulated: {
|
||||
students: accumulated.students.size,
|
||||
credits: accumulated.credits,
|
||||
aggregated: {
|
||||
students: aggregated.students.size,
|
||||
credits: aggregated.credits,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
|
||||
WHERE
|
||||
cr.attainment_date BETWEEN :a AND :b
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode IN (4, 9)
|
||||
AND cr.credittypecode IN (4, 9)
|
||||
GROUP BY co.id, o.code
|
||||
-- HAVING SUM(cr.credits) > 0
|
||||
`,
|
||||
@@ -189,13 +189,40 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
|
||||
)
|
||||
}
|
||||
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2') => {
|
||||
const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
|
||||
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
|
||||
INNER JOIN organization o ON o.id = cp.organizationcode
|
||||
AND cr.attainment_date BETWEEN :a AND :b
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode IN (4, 9) AND cp.organizationcode = 'hy-org-48645785'
|
||||
AND cr.student_studentnumber NOT IN
|
||||
(SELECT student_studentnumber FROM studyright
|
||||
WHERE studyright.student_studentnumber = cr.student_studentnumber
|
||||
AND cr.attainment_date BETWEEN studyright.startdate AND studyright.enddate)
|
||||
GROUP BY co.id, o.code -- HAVING SUM(cr.credits) > 0
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { a, b },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2', avoin) => {
|
||||
return years.map(
|
||||
year =>
|
||||
new Promise(async res => {
|
||||
const diff = currentYear - year
|
||||
const { from, to } = getRange(diff)
|
||||
const creditsByCourse = await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
const creditsByCourse = avoin
|
||||
? await getTotalOpenUniCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
: await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
res(
|
||||
creditsByCourse.map(c => {
|
||||
c['year'] = year
|
||||
@@ -208,10 +235,37 @@ const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum',
|
||||
|
||||
const getOrgStats = (stats, orgId) =>
|
||||
stats[orgId] ?? {
|
||||
accumulated: { students: 0, credits: 0 },
|
||||
direct: { students: 0, credits: 0 },
|
||||
accumulated: {
|
||||
aggregated: { students: 0, credits: 0 },
|
||||
direct: { students: 0, credits: 0 },
|
||||
},
|
||||
total: {
|
||||
aggregated: { students: 0, credits: 0 },
|
||||
direct: { students: 0, credits: 0 },
|
||||
},
|
||||
}
|
||||
|
||||
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... */
|
||||
@@ -219,66 +273,135 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const startYear = startDate.getFullYear()
|
||||
const startTime = startDate.getTime()
|
||||
const yearRange = _.range(2017, startYear + 1)
|
||||
const getRangeAcc = diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to:
|
||||
showByYear === 'true'
|
||||
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
|
||||
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
|
||||
})
|
||||
const getRangeTotal = diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
|
||||
})
|
||||
const yearlyAccCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange,
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to:
|
||||
showByYear === 'true'
|
||||
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
|
||||
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
|
||||
}),
|
||||
getRangeAcc,
|
||||
'acc',
|
||||
'students'
|
||||
'students',
|
||||
false
|
||||
)
|
||||
const yearlyOpenUniAccCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange,
|
||||
getRangeAcc,
|
||||
'acc',
|
||||
'students',
|
||||
true
|
||||
)
|
||||
|
||||
const yearlyTotalCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange.slice(0, -1),
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
|
||||
}),
|
||||
getRangeTotal,
|
||||
'total',
|
||||
'students'
|
||||
'students',
|
||||
false
|
||||
)
|
||||
|
||||
const yearlyOpenUniTotalCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange.slice(0, -1),
|
||||
getRangeTotal,
|
||||
'total',
|
||||
'students',
|
||||
true
|
||||
)
|
||||
|
||||
/* Gather all required data */
|
||||
const [yearlyAccCredits, yearlyTotalCredits, elementDetails, faculties] = await Promise.all([
|
||||
const [
|
||||
yearlyAccCredits,
|
||||
yearlyTotalCredits,
|
||||
yearlyOpenUniAccCredits,
|
||||
yearlyOpenUniTotalCredits,
|
||||
elementDetails,
|
||||
faculties,
|
||||
] = await Promise.all([
|
||||
Promise.all(yearlyAccCreditsPromises),
|
||||
Promise.all(yearlyTotalCreditsPromises),
|
||||
Promise.all(yearlyOpenUniAccCreditsPromises),
|
||||
Promise.all(yearlyOpenUniTotalCreditsPromises),
|
||||
ElementDetail.findAll(),
|
||||
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 => [
|
||||
year,
|
||||
await getOrganizationStatistics(
|
||||
const yearlyOrgStatPromises = yearRange.map(async year => {
|
||||
const accumulatedStats = await getOrganizationStatistics(
|
||||
faculties,
|
||||
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
|
||||
new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
|
||||
),
|
||||
])
|
||||
showByYear === 'true'
|
||||
? new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
|
||||
: new Date(unixMillis - (startYear - year) * YEAR_TO_MILLISECONDS)
|
||||
)
|
||||
|
||||
const totalStats =
|
||||
year !== startYear &&
|
||||
(await getOrganizationStatistics(
|
||||
faculties,
|
||||
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
|
||||
new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
|
||||
))
|
||||
|
||||
return [
|
||||
year,
|
||||
_.merge(
|
||||
_.mapValues(totalStats, total => ({ total })),
|
||||
_.mapValues(accumulatedStats, accumulated => ({ accumulated }))
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const orgStats = await Promise.all(yearlyOrgStatPromises)
|
||||
|
||||
const yearlyOrgStats = Object.fromEntries(orgStats)
|
||||
const [[, currentOrgStats], [, prevOrgStats]] = orgStats.reverse()
|
||||
|
||||
const defaultYearStats = _.chain(_.range(2017, startYear + 1))
|
||||
.map(year => [year, { acc: 0, accStudents: 0 }])
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
const getOrgYearlyStats = orgId =>
|
||||
Object.entries(yearlyOrgStats).reduce((acc, [year, stats]) => {
|
||||
const { credits, students } = getOrgStats(stats, orgId).accumulated
|
||||
_.chain(yearlyOrgStats)
|
||||
.mapValues(stats => {
|
||||
const orgStats = getOrgStats(stats, orgId)
|
||||
|
||||
acc[year] = {
|
||||
acc: credits,
|
||||
accStudents: students,
|
||||
total: credits,
|
||||
totalStudents: students,
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
return {
|
||||
acc: orgStats.accumulated.aggregated.credits,
|
||||
accStudents: orgStats.accumulated.aggregated.students,
|
||||
total: orgStats.total?.aggregated?.credits,
|
||||
totalStudents: orgStats.total?.aggregated?.students,
|
||||
}
|
||||
})
|
||||
.defaults(defaultYearStats)
|
||||
.value()
|
||||
|
||||
/* Construct some helper maps */
|
||||
const facultyCodeToFaculty = faculties.reduce((res, curr) => {
|
||||
@@ -292,6 +415,8 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return res
|
||||
}, {})
|
||||
|
||||
programmeToFaculties['H906'] = ['H906']
|
||||
|
||||
const providerToProgramme = elementDetails.reduce((res, curr) => {
|
||||
const [p] = mapToProviders([curr.code])
|
||||
res[p] = {
|
||||
@@ -301,26 +426,66 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return res
|
||||
}, {})
|
||||
|
||||
providerToProgramme['H906'] = {
|
||||
code: 'H906',
|
||||
name: { en: 'Language Centre', fi: 'Kielikeskus', sv: 'Språkcentrum' },
|
||||
}
|
||||
|
||||
providerToProgramme['H930'] = {
|
||||
code: 'H930',
|
||||
name: { en: 'Open University', fi: 'Avoin yliopisto', sv: 'Öppna universitetet' },
|
||||
}
|
||||
|
||||
const organizationCodeToOrganization = faculties.reduce((acc, org) => {
|
||||
acc[org.code] = org
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const sumMerge = (a, b) => {
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a + b
|
||||
}
|
||||
|
||||
const mergele = (a, b) => {
|
||||
if (!a) return _.clone(b)
|
||||
if (!b) return _.clone(a)
|
||||
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)], 'organizationcode')
|
||||
).reduce((acc, [organizationcode, courseCredits]) => {
|
||||
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
|
||||
(acc, [courseCode, yearlyInstances]) => {
|
||||
acc[courseCode] = { yearly: {}, name: yearlyInstances[0].name }
|
||||
const array = [2020, 2019, 2018, 2017]
|
||||
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'] = 0
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = 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] = {}
|
||||
|
||||
@@ -332,6 +497,52 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
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]) => {
|
||||
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
|
||||
@@ -348,44 +559,111 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const programme = providerToProgramme[organizationcode]
|
||||
|
||||
if (programme && programme.code) {
|
||||
if (programme.code === 'H906') {
|
||||
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
|
||||
}
|
||||
|
||||
const orgId = organizationCodeToOrganization[organizationcode]?.id
|
||||
|
||||
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
|
||||
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
|
||||
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated.aggregated
|
||||
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated.aggregated
|
||||
|
||||
const yearly = getOrgYearlyStats(orgId)
|
||||
|
||||
acc[programme.code] = {
|
||||
type: 'programme',
|
||||
code: programme.code,
|
||||
name: programme.name,
|
||||
drill: courses,
|
||||
yearly,
|
||||
current,
|
||||
previous,
|
||||
currentStudents,
|
||||
previousStudents,
|
||||
currentStudents: currentStudents,
|
||||
previousStudents: previousStudents,
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
if (!facultyCodes) return acc
|
||||
facultyCodes.forEach(facultyCode => {
|
||||
if (!facultyCode) return
|
||||
if (facultyCode === 'H906' || facultyCode === 'H930') {
|
||||
if (!acc[facultyCode]) {
|
||||
acc[facultyCode] = {
|
||||
type: 'faculty',
|
||||
code: facultyCode,
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly: programmeStats.yearly,
|
||||
current: programmeStats.current,
|
||||
previous: programmeStats.previous,
|
||||
currentStudents: programmeStats.currentStudents,
|
||||
previousStudents: programmeStats.previousStudents,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!acc[facultyCode]) {
|
||||
const orgId = organizationCodeToOrganization[facultyCode].id
|
||||
|
||||
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
|
||||
.aggregated
|
||||
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
|
||||
.aggregated
|
||||
|
||||
const yearly = getOrgYearlyStats(orgId)
|
||||
|
||||
acc[facultyCode] = {
|
||||
type: 'faculty',
|
||||
code: facultyCode,
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly,
|
||||
@@ -401,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) => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
const axios = require('axios')
|
||||
const { importerToken, importerUrl } = require('../conf-backend')
|
||||
const { importerToken } = require('../conf-backend')
|
||||
const logger = require('./logger')
|
||||
|
||||
const importerClient = axios.create({
|
||||
headers: {
|
||||
token: importerToken,
|
||||
},
|
||||
baseURL: importerUrl,
|
||||
baseURL: 'https://importer.cs.helsinki.fi',
|
||||
})
|
||||
|
||||
const getImporterClient = () => {
|
||||
if (!importerToken || !importerUrl) {
|
||||
logger.error("Importer token or importer url not set, can't return client!")
|
||||
if (!importerToken) {
|
||||
logger.error("Importer token not set, can't return client!")
|
||||
return null
|
||||
}
|
||||
return importerClient
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
const winston = require('winston')
|
||||
const path = require('path')
|
||||
const { isProduction, PWD } = require('../conf-backend')
|
||||
|
||||
const { isDev } = require('../conf-backend')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
// Write logfile to docker containers pwd
|
||||
const filename = path.join(PWD, 'debug.log')
|
||||
transports.push(new winston.transports.File({ filename, level: 'debug' }))
|
||||
let transports = []
|
||||
|
||||
if (!isProduction) {
|
||||
if (isDev) {
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
@@ -20,9 +15,7 @@ if (!isProduction) {
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (isProduction) {
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
@@ -40,9 +33,11 @@ if (isProduction) {
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat, level: 'info' }))
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
|
||||
|
||||
module.exports = logger
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+113
-22562
File diff suppressed because it is too large
Load Diff
@@ -6,13 +6,14 @@
|
||||
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"history": "^4.10.1",
|
||||
"jstat": "^1.9.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
@@ -22,18 +23,14 @@
|
||||
"qs": "^6.10.1",
|
||||
"query-string": "^6.14.1",
|
||||
"react": "^16.14.0",
|
||||
"react-context-devtool": "^2.0.3",
|
||||
"react-datetime": "^3.0.4",
|
||||
"react-dom": "^16.14.0",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^6.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-scroll-to-component": "^1.0.2",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^2.0.3",
|
||||
"unfuck-spa-shibboleth-session": "^3.1.3",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
|
||||
import { LanguageProvider } from './components/LanguagePicker/useLanguage'
|
||||
|
||||
@@ -9,8 +8,4 @@ const CommonContext = ({ children }) => (
|
||||
</LanguageProvider>
|
||||
)
|
||||
|
||||
CommonContext.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default CommonContext
|
||||
|
||||
@@ -1,9 +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',
|
||||
@@ -19,16 +21,16 @@ const getHeaders = () => {
|
||||
|
||||
export const api = axios.create({ baseURL: apiBasePath, headers: getHeaders() })
|
||||
|
||||
const types = {
|
||||
attempt: prefix => `${prefix}ATTEMPT`,
|
||||
failure: prefix => `${prefix}FAILURE`,
|
||||
success: prefix => `${prefix}SUCCESS`,
|
||||
export const actionSuffixes = {
|
||||
attempt: `ATTEMPT`,
|
||||
failure: `FAILURE`,
|
||||
success: `SUCCESS`,
|
||||
}
|
||||
|
||||
export const actionTypes = prefix => ({
|
||||
attempt: types.attempt(prefix),
|
||||
failure: types.failure(prefix),
|
||||
success: types.success(prefix),
|
||||
attempt: `${prefix}${actionSuffixes.attempt}`,
|
||||
failure: `${prefix}${actionSuffixes.failure}`,
|
||||
success: `${prefix}${actionSuffixes.success}`,
|
||||
})
|
||||
|
||||
export const logout = async () => {
|
||||
@@ -69,7 +71,8 @@ export const callController = (route, prefix, data, method = 'get', query, param
|
||||
params,
|
||||
onProgress,
|
||||
}
|
||||
return { type: `${prefix}ATTEMPT`, requestSettings }
|
||||
const { attempt } = actionTypes(prefix)
|
||||
return { type: attempt, requestSettings }
|
||||
}
|
||||
|
||||
const handleError = (err, actionHistory = []) => {
|
||||
@@ -90,20 +93,24 @@ export const handleRequest = store => next => async action => {
|
||||
const { requestSettings } = action
|
||||
if (requestSettings) {
|
||||
const { route, method, data, prefix, query, params, onProgress } = requestSettings
|
||||
const { success, failure } = actionTypes(prefix)
|
||||
try {
|
||||
const res = await callApi(route, method, data, params, 0, onProgress)
|
||||
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
|
||||
store.dispatch({ type: success, response: res.data, query })
|
||||
} catch (e) {
|
||||
store.dispatch({ type: `${prefix}FAILURE`, response: e, query })
|
||||
store.dispatch({ type: failure, response: e, query })
|
||||
handleError(e, store.getState().actionHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const handleAuth = store => next => async action => {
|
||||
const loginTypes = actionTypes(loginPrefix)
|
||||
const logoutTypes = actionTypes(logoutPrefix)
|
||||
next(action)
|
||||
const { type } = action
|
||||
if (type === 'LOGIN_ATTEMPT') {
|
||||
if (type === loginTypes.attempt) {
|
||||
const { success, failure } = loginTypes
|
||||
try {
|
||||
let token
|
||||
const mocked = getMocked()
|
||||
@@ -118,18 +125,39 @@ export const handleAuth = store => next => async action => {
|
||||
...api.defaults.headers.common,
|
||||
'x-access-token': token,
|
||||
}
|
||||
store.dispatch({ type: 'LOGIN_SUCCESS', token })
|
||||
store.dispatch({ type: success, token })
|
||||
} catch (err) {
|
||||
store.dispatch({ type: 'LOGIN_FAILURE' })
|
||||
store.dispatch({ type: failure })
|
||||
handleError(err, store.getState().actionHistory)
|
||||
}
|
||||
} else if (type === 'LOGOUT_ATTEMPT') {
|
||||
} else if (type === loginTypes.attempt) {
|
||||
const { success, failure } = logoutTypes
|
||||
try {
|
||||
await logout()
|
||||
store.dispatch({ type: 'LOGOUT_SUCCESSFUL' })
|
||||
store.dispatch({ type: success })
|
||||
} catch (err) {
|
||||
store.dispatch({ type: 'LOGOUT_FAILURE' })
|
||||
store.dispatch({ type: failure })
|
||||
handleError(err, store.getState().actionHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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: () => ({}),
|
||||
})
|
||||
|
||||
@@ -57,17 +57,7 @@ export default {
|
||||
Nuoli kuvaa muutosta, paljonko opintopisteitä on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
|
||||
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna. Vain suoritetut ja hyväksiluetut opintopisteet huomioidaan.
|
||||
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
|
||||
|
||||
Valinta "Näytä edelliset vuodet" näyttää vuosittaisen opintopiste-kertymän tähän päivään
|
||||
mennessä vuonna X sekä koko lukuvuoden X opintopisteet muodossa "kerääntymä vuonna X / koko lukuvuoden X opintopisteet".
|
||||
|
||||
Valinta "Näytä kalenterivuosittain" laskee kertymän kalenterivuoden alusta,
|
||||
eikä lukuvuoden alusta.
|
||||
|
||||
Kohdasta "Näytä päivänä" voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
|
||||
|
||||
Avoimen yliopiston suorituksia ei vielä lasketa mukaan.
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä. Mahdolliset valinnat ovat selitettyinä alla.
|
||||
`,
|
||||
statusGraduated: `
|
||||
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
|
||||
|
||||
@@ -19,16 +19,28 @@ export default {
|
||||
|
||||
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
|
||||
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”`,
|
||||
CreditStatistics: `**Opintopistekertymä**
|
||||
|
||||
Taulukon rivit skaalautuvat valitun tarkastelujakson mukaan.
|
||||
Jos tarkastelujaksoksi valitsee 12 kuukautta, on ylin kategoria 55 opintopistettä tai yli, eli opiskelija etenee tavoiteajassa.
|
||||
Taulukosta näkee silmäyksellä tavoiteajassa etenevät (ylin kategoria) ja esim. nollasuorittajat.
|
||||
|
||||
**Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.**
|
||||
CreditStatistics: `
|
||||
**Credits gained**
|
||||
|
||||
Taulukko kertoo opintopistekertymän valitulle opiskelijapopulaatiolle. Opintopisteluokat on suhteutettu tarkasteltavan populaation aloitusvuoteen.
|
||||
Ylin luokka kertoo siksi aina tavoiteajassa etenevien määrän ja suhteellisen osuuden.
|
||||
|
||||
Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
|
||||
|
||||
Kategorioita voi myös itsessään käyttää suodattimina. Klikkaamalla rivin vasemmassa reunassa olevaa suodatinta jäävät näkyviin vain sen kategorian opiskelijat.
|
||||
Suodatin aukeaa samalla ikkunan vasempaan laitaan, josta sitä voi käyttää vapaavalintaisilla arvoilla.
|
||||
|
||||
**Credit statistics**
|
||||
|
||||
Taulukko kertoo kaikkien tarkasteluun valittujen opiskelijoiden opintopistemääristä tähän päivään mennessä. Statistiikka on suodatettu käytettyjen filttereiden mukaan.
|
||||
Esimerkiksi, jos mukaan on valittu vain naisopiskelijat, statistiikat koskevat vain heitä.
|
||||
|
||||
+ **Total** on kokonaisopintopistemäärä kyseiselle populaatiolle.
|
||||
+ **Mean** kuvaa opintopistemäärän opiskelijakohtaista keskiarvoa.
|
||||
+ **Keskihajonta** kuvaa vaihtelua opintopistemäärissä. Karkeasti, mitä suurempi keskihajonta, sitä enemmän opintopistemäärissä on vaihtelua opiskelijoiden kesken.
|
||||
+ **Min ja Max** kertovat pienimmän ja suurimman opintopistemäärän, jotka jollain opiskelijoilla kyseisessä populaatiossa on.
|
||||
|
||||
Mikäli valintatapatieto kyseiselle populaatiolle on saatavissa, näytetään myös samat statistiikat per valintatapa.
|
||||
`,
|
||||
CoursesOfPopulation: `**Courses of population**
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
|
||||
|
||||
export const isNewHYStudyProgramme = code => !!(code && code.match(/^[A-Z]*[0-9]*_[0-9]*$/))
|
||||
|
||||
// These are the new Bachelor's programmes in Matlu, that have BH possibility
|
||||
export const bachelorHonoursProgrammes = [
|
||||
'KH50_001',
|
||||
'KH50_002',
|
||||
@@ -235,6 +236,8 @@ export const bachelorHonoursProgrammes = [
|
||||
'KH50_008',
|
||||
]
|
||||
|
||||
// Basic modules from Sisu programme structures. All modules that can be found for the past years' structures
|
||||
// taken into account
|
||||
export const bachelorHonoursBasicModules = {
|
||||
KH50_001: ['MAT110', 'MAT120', 'MAT130', 'MAT120', 'TKT1'],
|
||||
KH50_002: ['FYS1100', 'FYS1200'],
|
||||
@@ -246,6 +249,8 @@ export const bachelorHonoursBasicModules = {
|
||||
KH50_008: ['BSCH1000', 'BSCS1000', 'BSMA1000', 'BSMS1000', 'BSPH1000'],
|
||||
}
|
||||
|
||||
// Similarly intermediate modules from Sisu programme structures. All modules that can be found for the past years' structures
|
||||
// taken into account
|
||||
export const bachelorHonoursIntermediateModules = {
|
||||
KH50_001: ['mat217', 'MAT210', 'MAT220', 'MAT240', 'MAT213', 'MAT230', 'MAT218', 'MAT011'],
|
||||
KH50_002: ['FYS2100', 'FYS2200', 'FYS2300', 'FYS2400', 'FYS2500', 'FYS2600'],
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Divider, Segment, Button, Popup, Loader, Icon, Checkbox, Form, Breadcrumb } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Datetime from 'react-datetime'
|
||||
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import TSA from '../../common/tsa'
|
||||
import { getTextIn } from '../../common'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
@@ -27,7 +28,77 @@ const mapValueToRange = (x, min1, max1, min2, max2) => {
|
||||
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
|
||||
}
|
||||
|
||||
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
|
||||
const settingDefinitions = {
|
||||
showByYear: {
|
||||
label: 'Näytä kalenterivuosittain',
|
||||
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
|
||||
long: `
|
||||
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: {
|
||||
label: 'Näytä edelliset vuodet',
|
||||
short: 'Näytä tilastot vuosittain, alkaen vuodesta 2017.',
|
||||
long: `
|
||||
Näyttää tilastot vuodesta 2017 eteenpäin.
|
||||
|
||||
Jokaiselta vuodelta näytetään kaksi tilastoa: ajanjakson kokonaistilasto ja ns. tähän mennessä "*kertynyt*" tilasto.
|
||||
Kokonaistilasto vastaa nimensä mukaisesti koko ajanjaksoa, kun taas *kertynyt* tilasto kattaa ajanjakson vuoden
|
||||
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: {
|
||||
label: 'Näytä suhteutettuna opiskelijoiden määrään',
|
||||
short:
|
||||
'Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.',
|
||||
long: `
|
||||
Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.
|
||||
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: {
|
||||
label: 'Näytä päivänä',
|
||||
short: 'Valitse päivä johon asti kertyneet tilastot näytetään.',
|
||||
long: `
|
||||
Tämä valinta määrittää päivämäärän, jota käyttäen kertyneet tilastot lasketaan.
|
||||
Esimerkiksi "Näytä kalenterivuosittain" valinnan ollessa pois päältä,
|
||||
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,
|
||||
showStudentCounts,
|
||||
}) => {
|
||||
const title = getTextIn(stats.name)
|
||||
const clickable = !!stats.drill
|
||||
|
||||
@@ -64,10 +135,22 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
signDisplay: 'always',
|
||||
})
|
||||
|
||||
const roundToPrecision = (value, precision) => Math.round(value * 10 ** precision) / 10 ** precision
|
||||
const getDisplayValue = (value, denominator) => {
|
||||
let displayValue
|
||||
|
||||
const getDisplayValue = (value, denominator) =>
|
||||
showRelativeValues ? roundToPrecision(denominator === 0 ? 0 : value / denominator, 2) : value
|
||||
if (denominator === 0) {
|
||||
displayValue = value === 0 ? 0 : Infinity
|
||||
} else if (showRelativeValues) {
|
||||
displayValue = value / denominator
|
||||
} else {
|
||||
displayValue = value
|
||||
}
|
||||
|
||||
return displayValue.toLocaleString('fi', {
|
||||
minimumFractionDigits: showRelativeValues ? 2 : 0,
|
||||
maximumFractionDigits: showRelativeValues ? 2 : 0,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment
|
||||
@@ -94,12 +177,16 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
<span className="status-title">{title}</span>
|
||||
<Popup position="bottom center" size="tiny" trigger={<span className="status-title">{title}</span>}>
|
||||
{stats.code}
|
||||
</Popup>
|
||||
<div style={{ margin: '10px 0' }}>
|
||||
<Icon
|
||||
style={{
|
||||
color: getColor(change),
|
||||
transform: `rotateZ(${-mapValueToRange(change * 100, min1, max1, -45, 45)}deg)`,
|
||||
transitionProperty: 'transform',
|
||||
transitionDuration: '200ms',
|
||||
}}
|
||||
size="huge"
|
||||
name="arrow right"
|
||||
@@ -115,31 +202,34 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
</div>
|
||||
</div>
|
||||
{showYearlyValues && (
|
||||
<div className="years">
|
||||
<div className={`years item-type-${stats.type}`}>
|
||||
{_.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => {
|
||||
return (
|
||||
<span style={{ display: 'contents' }} className="year-row">
|
||||
<b>
|
||||
<span className="year-row">
|
||||
<b className="year-label">
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>
|
||||
{stats.type === 'course' ? (
|
||||
{stats.type === 'course' && showStudentCounts ? (
|
||||
<>
|
||||
<span className="year-value">{yearStats.accStudents}</span>
|
||||
{!!yearStats.totalStudents && (
|
||||
<>
|
||||
<span className="separator">{yearStats.totalStudents !== undefined && '/'}</span>
|
||||
<span className="year-value">{yearStats.totalStudents}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="unit">students</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="year-value"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `${getDisplayValue(yearStats.acc, yearStats.accStudents)
|
||||
.toLocaleString('fi', { minimumFractionDigits: 2 })
|
||||
.replace(/,?0*$/, match => `<span class="decimals">${match}</span>`)}`,
|
||||
}}
|
||||
/>
|
||||
<span className="unit">credits{showRelativeValues ? '/student' : ''}</span>
|
||||
<span className="year-value accumulated">
|
||||
{getDisplayValue(yearStats.acc, yearStats.accStudents)}
|
||||
</span>
|
||||
<span className="separator">{yearStats.total !== undefined && '/'}</span>
|
||||
<span className="year-value total">
|
||||
{yearStats.total !== undefined && getDisplayValue(yearStats.total, yearStats.totalStudents)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -151,185 +241,22 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
)
|
||||
}
|
||||
|
||||
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
|
||||
const StatusContent = ({ data, settings, onDrill }) => {
|
||||
const { showByYear, showRelativeValues, showYearlyValues, showStudentCounts } = settings
|
||||
|
||||
const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
|
||||
const [showByYear, setShowByYear] = useLocalStorage('showByYear', false)
|
||||
const [showRelativeValues, setShowRelativeValues] = useLocalStorage('showRelativeValues', false)
|
||||
const [drillStack, setDrillStack] = useState([])
|
||||
const [showSettings, setShowSettings] = useState(true)
|
||||
const [selectedDate, setSelectedDate] = useState(moment())
|
||||
const [codes, setCodes] = useState([])
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && isValidDate(selectedDate)) {
|
||||
getStatusDispatch({ date: selectedDate.valueOf(), showByYear })
|
||||
}
|
||||
}, [selectedDate, showByYear])
|
||||
|
||||
useEffect(() => {
|
||||
if (codes.length > 0) {
|
||||
const updatedDrillStack = codes.reduce((acc, code) => {
|
||||
const drilled = data[code]
|
||||
// check if the code is on first level of drilldown
|
||||
if (drilled) acc.push(drilled.drill)
|
||||
// if not on first level then use the previous object in array
|
||||
// this might be source of bugs but its the best I could come up with
|
||||
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
|
||||
return acc
|
||||
}, [])
|
||||
setDrillStack(updatedDrillStack)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const handleShowYearlyValuesToggled = () => {
|
||||
const yearlyValues = showYearlyValues
|
||||
setShowYearlyValues(!showYearlyValues)
|
||||
sendAnalytics(`S Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowByYearToggled = () => {
|
||||
const byYear = showByYear
|
||||
setShowByYear(!showByYear)
|
||||
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowRelativeValuesChanged = () => {
|
||||
sendAnalytics(`S Show relative values toggle ${!showRelativeValues ? 'on' : 'off'}`)
|
||||
setShowRelativeValues(!showRelativeValues)
|
||||
}
|
||||
|
||||
const pushToDrillStack = (values, code) => {
|
||||
const updatedCodes = [...codes].concat(code)
|
||||
const updatedDrillStack = [...drillStack].concat(values)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drilldown clicked', 'Status')
|
||||
}
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const updatedDrillStack = _.dropRight([...drillStack], 1)
|
||||
const updatedCodes = _.dropRight([...codes], 1)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drillup clicked', 'Status')
|
||||
}
|
||||
|
||||
const renderSettings = () => {
|
||||
return (
|
||||
<Accordion style={{ padding: 0, paddingTop: '10px', flex: 1 }}>
|
||||
<Accordion.Title style={{ padding: 0, cursor: 'default' }} active={showSettings}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => setShowSettings(!showSettings)}>
|
||||
<Icon name="setting" />
|
||||
<span>Asetukset</span>
|
||||
</span>
|
||||
</Accordion.Title>
|
||||
<Accordion.Content style={{ padding: 0, marginTop: '10px' }} active={showSettings}>
|
||||
<Segment style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: 'red',
|
||||
transform: 'rotateY(0deg) rotate(45deg)',
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
left: '35px',
|
||||
border: '1px solid #dededf',
|
||||
borderRight: 'none',
|
||||
borderBottom: 'none',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä edelliset vuodet"
|
||||
onChange={handleShowYearlyValuesToggled}
|
||||
checked={showYearlyValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä kalenterivuosittain"
|
||||
onChange={handleShowByYearToggled}
|
||||
checked={showByYear}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä suhteutettuna opiskelijoiden määrään"
|
||||
onChange={handleShowRelativeValuesChanged}
|
||||
checked={showRelativeValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Form>
|
||||
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={selectedDate}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
onChange={setSelectedDate}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message
|
||||
color="blue"
|
||||
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle ja ohjelmaa klikkaamalla pystyt porautumaan kurssitasolle.
|
||||
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
|
||||
/>
|
||||
)
|
||||
|
||||
if (!data || loading)
|
||||
return (
|
||||
<Segment style={{ padding: '40px' }} textAlign="center">
|
||||
<Dimmer inverted active />
|
||||
<Loader active={loading} />
|
||||
</Segment>
|
||||
)
|
||||
|
||||
const orderedAbsDiffs = _.orderBy(
|
||||
Object.values(_.last(drillStack) || data).map(({ current, currentStudents, previous, previousStudents }) => {
|
||||
return Math.abs(
|
||||
Math.floor(
|
||||
(getP(
|
||||
showRelativeValues ? current / currentStudents : current,
|
||||
showRelativeValues ? previous / previousStudents : previous
|
||||
) -
|
||||
1) *
|
||||
1000
|
||||
) / 10
|
||||
)
|
||||
const orderedAbsDiffs = _.chain(data)
|
||||
.map(({ current, currentStudents, previous, previousStudents }) => {
|
||||
const currentValue = showRelativeValues ? current / currentStudents : current
|
||||
const previousValue = showRelativeValues ? previous / previousStudents : previous
|
||||
return Math.abs(Math.floor((getP(currentValue, previousValue) - 1) * 1000) / 10)
|
||||
})
|
||||
)
|
||||
.orderBy()
|
||||
.value()
|
||||
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -340,20 +267,21 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
}}
|
||||
>
|
||||
{_.orderBy(
|
||||
Object.entries(_.last(drillStack) || data),
|
||||
([, { current, previous, currentStudents, previousStudents }]) =>
|
||||
data,
|
||||
({ current, previous, currentStudents, previousStudents }) =>
|
||||
getP(current || currentStudents, previous || previousStudents), // oh god<r
|
||||
['desc']
|
||||
).map(([code, stats]) => {
|
||||
const handleClick = () => pushToDrillStack(stats.drill, code)
|
||||
).map(stats => {
|
||||
const handleClick = () => onDrill(stats.code)
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
key={stats.code}
|
||||
stats={stats}
|
||||
handleClick={handleClick}
|
||||
showRelativeValues={showRelativeValues}
|
||||
showYearlyValues={showYearlyValues}
|
||||
showStudentCounts={showStudentCounts}
|
||||
min1={-medianDiff * 2}
|
||||
max1={medianDiff * 2}
|
||||
showByYear={showByYear}
|
||||
@@ -361,20 +289,367 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{renderSettings()}
|
||||
<Message>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
|
||||
}
|
||||
</Message>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.status,
|
||||
loading: coolDataScience.pending.status,
|
||||
})
|
||||
const WithHelpTooltip = ({ children, tooltip, onOpenDetails, ...rest }) => {
|
||||
const popupContext = useRef()
|
||||
const [popupOpen, setPopupOpen] = useState(false)
|
||||
|
||||
export default connect(mapStateToProps, { getStatusDispatch: getStatus })(Status)
|
||||
const trigger = (
|
||||
<div>
|
||||
{children}
|
||||
<div ref={popupContext} style={{ display: 'inline-block', paddingTop: '0.2em', cursor: 'help' }}>
|
||||
<Icon
|
||||
onClick={() => setPopupOpen(!popupOpen)}
|
||||
ref={popupContext}
|
||||
style={{ marginLeft: '0.3em', color: '#888' }}
|
||||
name="question circle outline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const popupProps = _.defaults(rest, {
|
||||
position: 'right center',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
hoverable
|
||||
size="tiny"
|
||||
open={popupOpen}
|
||||
onOpen={() => setPopupOpen(true)}
|
||||
onClose={() => setPopupOpen(false)}
|
||||
trigger={trigger}
|
||||
context={popupContext}
|
||||
{...popupProps}
|
||||
on="hover"
|
||||
mouseEnterDelay={1000}
|
||||
>
|
||||
<div>{tooltip}</div>
|
||||
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={onOpenDetails}>
|
||||
Lue lisää...
|
||||
</span>
|
||||
</Popup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const isValidDate = d => moment().diff(moment(d)) > 0
|
||||
|
||||
const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
|
||||
const { selectedDate } = settings
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
|
||||
const changeSetting = (property, value) => {
|
||||
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
|
||||
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
[property]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const itemStyles = {
|
||||
margin: '0.5rem 1rem',
|
||||
display: 'flex',
|
||||
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' }}>
|
||||
{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={settingDefinitions.showCountingFrom.short}
|
||||
onOpenDetails={() => onOpenDetails('showCountingFrom')}
|
||||
>
|
||||
<span style={{ fontSize: '0.9em' }}>{settingDefinitions.showCountingFrom.label}</span>
|
||||
</WithHelpTooltip>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={moment(selectedDate)}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
inputProps={{ placeholder: 'Valise päivämäärä' }}
|
||||
onChange={value => {
|
||||
changeSetting('selectedDate', value.format(DATE_FORMAT) === moment().format(DATE_FORMAT) ? null : value)
|
||||
}}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultSettings = () =>
|
||||
_.chain(settingDefinitions)
|
||||
.toPairs()
|
||||
.map(([key, { defaultValue }]) => [key, defaultValue])
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
const Status = () => {
|
||||
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
|
||||
const [usageDetailsOpen, setUsageDetailsOpen] = useState(false)
|
||||
const moreDetailsRef = useRef(null)
|
||||
const history = useHistory()
|
||||
const location = useLocation()
|
||||
|
||||
const drillStack = location.state?.drillStack ?? []
|
||||
|
||||
const data = useSelector(state => state.coolDataScience.data.status)
|
||||
const loading = useSelector(state => state.coolDataScience.pending.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const settings = useMemo(() => _.defaults(explicitSettings, getDefaultSettings()), [explicitSettings])
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
useEffect(() => {
|
||||
const { selectedDate, showByYear } = settings
|
||||
|
||||
let date
|
||||
|
||||
if (!selectedDate) {
|
||||
date = moment().valueOf()
|
||||
} else if (isValidDate(selectedDate)) {
|
||||
date = moment(selectedDate).valueOf()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(
|
||||
getStatus({
|
||||
date,
|
||||
showByYear,
|
||||
})
|
||||
)
|
||||
}, [settings.selectedDate, settings.showByYear])
|
||||
|
||||
const pushToDrillStack = code => {
|
||||
history.push('/trends/status', {
|
||||
drillStack: [...drillStack, code],
|
||||
})
|
||||
|
||||
sendAnalytics('S Drilldown clicked', 'Status')
|
||||
}
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
const stack = _.chain(drillStack)
|
||||
.reduce(
|
||||
([stack, data], code) => {
|
||||
let content = code
|
||||
let next = null
|
||||
|
||||
if (data) {
|
||||
const entry = data[code]
|
||||
|
||||
if (entry) {
|
||||
content = getTextIn(entry.name)
|
||||
next = entry.drill
|
||||
}
|
||||
}
|
||||
|
||||
const item = {
|
||||
key: code,
|
||||
content,
|
||||
link: !!next,
|
||||
active: !next,
|
||||
onClick: () => {
|
||||
history.push('/trends/status', {
|
||||
drillStack: [...stack.map(({ key }) => key).splice(1), code],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return [[...stack, item], next]
|
||||
},
|
||||
[[], data]
|
||||
)
|
||||
.value()[0]
|
||||
|
||||
const hy = {
|
||||
key: '',
|
||||
content: getTextIn({
|
||||
fi: 'Helsingin Yliopisto',
|
||||
en: 'University of Helsinki',
|
||||
sv: 'Helsingfors universitet',
|
||||
}),
|
||||
link: true,
|
||||
onClick: () =>
|
||||
history.push('/trends/status', {
|
||||
drillStack: [],
|
||||
}),
|
||||
}
|
||||
|
||||
return [hy, ...stack]
|
||||
}, [data, drillStack])
|
||||
|
||||
const drilledData = useMemo(
|
||||
() =>
|
||||
_.chain(drillStack)
|
||||
.reduce((data, code) => data && _.values(data).find(item => item.code === code)?.drill, data)
|
||||
.defaultTo({})
|
||||
.values()
|
||||
.value(),
|
||||
[data, drillStack]
|
||||
)
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const newDrillStack = [...drillStack]
|
||||
newDrillStack.pop()
|
||||
history.push('/trends/status', {
|
||||
drillStack: newDrillStack,
|
||||
})
|
||||
|
||||
sendAnalytics('S Drillup clicked', '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 (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }} ref={moreDetailsRef}>
|
||||
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button>
|
||||
<Icon name="settings" /> Asetukset
|
||||
</Button>
|
||||
}
|
||||
position="bottom right"
|
||||
on="click"
|
||||
wide="very"
|
||||
open={settingsOpen}
|
||||
onOpen={() => setSettingsOpen(true)}
|
||||
onClose={() => {
|
||||
// Close the popup only after the event has had a chance to propagate.
|
||||
setTimeout(() => setSettingsOpen(false), 0)
|
||||
}}
|
||||
style={{ padding: '0.25em 0em', maxHeight: '80vh' }}
|
||||
>
|
||||
<StatusSettings
|
||||
settings={settings}
|
||||
onSettingsChange={setSettings}
|
||||
onOpenDetails={key => {
|
||||
if (moreDetailsRef.current) {
|
||||
moreDetailsRef.current.scrollIntoView({
|
||||
block: 'start',
|
||||
inline: 'end',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
if (attentionSeekers.current[key]) {
|
||||
scrollToAttentionSeeker(attentionSeekers.current[key])
|
||||
} else {
|
||||
attentionSeekers.current[key] = true
|
||||
}
|
||||
|
||||
setUsageDetailsOpen(true)
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button>
|
||||
<Icon name="question circle" /> Käyttöohje
|
||||
</Button>
|
||||
}
|
||||
position="bottom right"
|
||||
on="click"
|
||||
wide="very"
|
||||
open={usageDetailsOpen}
|
||||
onOpen={() => setUsageDetailsOpen(true)}
|
||||
onClose={() => setUsageDetailsOpen(false)}
|
||||
style={{ padding: '0', paddingBottom: '0.5em', maxHeight: '80vh', overflowY: 'auto' }}
|
||||
>
|
||||
<div style={{ padding: '1em' }}>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
|
||||
}
|
||||
</div>
|
||||
{_.toPairs(settingDefinitions).map(([key, { label, long }]) => (
|
||||
<div key={key}>
|
||||
<Divider />
|
||||
<div style={{ padding: '0 1em' }} ref={attentionSeekerRef(key)}>
|
||||
<b>
|
||||
Valinta "<i>{label}</i>"
|
||||
</b>
|
||||
<div style={{ margin: '0.5em', fontSize: '0.9em' }}>
|
||||
<ReactMarkdown escapeHtml={false}>{long.replace(/(^|\n)[ \t]+/g, '\n')}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Popup>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px', alignItems: 'center' }}>
|
||||
<Breadcrumb icon="right angle" sections={breadcrumb} size="large" />
|
||||
{drillStack.length > 0 && (
|
||||
<Icon
|
||||
onClick={popFromDrillStack}
|
||||
style={{ cursor: 'pointer', color: 'rgba(0,0,0,.28)', marginLeft: '0.4em', marginTop: '-0.17em' }}
|
||||
name="arrow left"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && !loading ? (
|
||||
<StatusContent settings={settings} onDrill={pushToDrillStack} data={drilledData} />
|
||||
) : (
|
||||
<Loader active={loading} inline="centered" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Status
|
||||
|
||||
@@ -5,8 +5,22 @@ import Graduated from './StatusGraduated'
|
||||
|
||||
const StatusPanel = () => {
|
||||
const panes = [
|
||||
{ menuItem: 'Attainments', render: () => <Status /> },
|
||||
{ menuItem: 'Graduations', render: () => <Graduated /> },
|
||||
{
|
||||
menuItem: 'Attainments',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Status />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'Graduations',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Graduated />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
]
|
||||
return <Tab panes={panes} />
|
||||
}
|
||||
|
||||
@@ -10,41 +10,45 @@
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
}
|
||||
|
||||
.unit,
|
||||
.decimals {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.years {
|
||||
margin-top: 10px;
|
||||
text-align: start;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: grid;
|
||||
grid-template-columns: fit-content(0) fit-content(0) auto;
|
||||
grid-template-columns: repeat(4, fit-content(0));
|
||||
}
|
||||
|
||||
.year-row .unit {
|
||||
padding-left: 0.5em;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
font-size: 0.9em;
|
||||
.subtitle {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
white-space: nowrap;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.year-value {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
.year-row {
|
||||
position: relative;
|
||||
padding-left: 0.5em;
|
||||
display: inline-block;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.status-card .years .year-row:hover .unit,
|
||||
.status-card .years .year-row:hover .decimals,
|
||||
.status-card:hover .years:not(:hover) .year-row:first-child .unit,
|
||||
.status-card:hover .years:not(:hover) .year-row:first-child .decimals {
|
||||
opacity: 1;
|
||||
.year-row .unit {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
.year-row .separator {
|
||||
color: #aaa;
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
|
||||
.status-date-time-input {
|
||||
|
||||
@@ -1,19 +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 { arrayOf, bool, object, string, func, number, shape } from 'prop-types'
|
||||
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
|
||||
@@ -23,313 +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,
|
||||
ordinal: false,
|
||||
},
|
||||
series: dataOfSelected,
|
||||
chart: {
|
||||
height: graphSize,
|
||||
},
|
||||
tooltip: {
|
||||
...tooltipOptions,
|
||||
},
|
||||
}
|
||||
this.setState({ options, updateGraph: false })
|
||||
},
|
||||
yAxis: {
|
||||
title: { text: 'Credits' },
|
||||
min: 0,
|
||||
max: undefined,
|
||||
},
|
||||
xAxis: {
|
||||
ordinal: false,
|
||||
min: startDate,
|
||||
max: endDate,
|
||||
},
|
||||
series: seriesData,
|
||||
chart: {
|
||||
height: graphHeight,
|
||||
},
|
||||
tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
const sortCoursesByDate = courses =>
|
||||
[...courses].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
isSingleStudentGraph = () => !!this.props.singleStudent
|
||||
const targetCourse = sortCoursesByDate(student.courses).find(
|
||||
({ course_code: courseCode, date }) => point.key === courseCode && point.x === new Date(date).getTime()
|
||||
)
|
||||
|
||||
createStudentCreditLines = students =>
|
||||
students.map(student => {
|
||||
const { started, studyrightStart } = student
|
||||
if (!targetCourse.course) {
|
||||
return null
|
||||
}
|
||||
|
||||
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]
|
||||
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 {
|
||||
name: student.studentNumber,
|
||||
data: points,
|
||||
seriesThreshold: 150,
|
||||
marker: {
|
||||
enabled: this.isSingleStudentGraph(),
|
||||
radius: 4,
|
||||
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)
|
||||
|
||||
resizeChart = size => {
|
||||
if (this.chart) {
|
||||
this.props.setChartHeight(size)
|
||||
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))
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
CreditAccumulationGraphHighCharts.defaultProps = {
|
||||
absences: [],
|
||||
singleStudent: false,
|
||||
render: true,
|
||||
trayOpen: false,
|
||||
}
|
||||
|
||||
CreditAccumulationGraphHighCharts.propTypes = {
|
||||
singleStudent: bool,
|
||||
students: arrayOf(object).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
setChartHeight: func.isRequired,
|
||||
currentGraphSize: number.isRequired,
|
||||
language: string.isRequired,
|
||||
absences: arrayOf(shape({})),
|
||||
render: bool,
|
||||
trayOpen: bool,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
spinner: state.graphSpinner,
|
||||
currentGraphSize: state.settings.chartHeight,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { clearLoading, setChartHeight })(withRouter(CreditAccumulationGraphHighCharts))
|
||||
export default CreditAccumulationGraphHighCharts
|
||||
|
||||
+4
-27
@@ -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, { useEffect, useState } from 'react'
|
||||
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import { connect } from 'react-redux'
|
||||
import propTypes from 'prop-types'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { images, getUserRoles, checkUserAccess } from '../common'
|
||||
import { useTitle } from '../common/hooks'
|
||||
import { builtAt } from '../conf'
|
||||
@@ -49,10 +47,11 @@ const OodiToOodikone = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const FrontPage = props => {
|
||||
const { userRoles, rights } = props
|
||||
|
||||
const FrontPage = () => {
|
||||
const { roles, rights } = useSelector(state => state.auth.token)
|
||||
const userRoles = getUserRoles(roles)
|
||||
useTitle()
|
||||
|
||||
const showItems = {
|
||||
populations: userRoles.includes('admin') || rights.length !== 0,
|
||||
studyProgramme: userRoles.includes('admin') || rights.length !== 0,
|
||||
@@ -122,25 +121,4 @@ const FrontPage = props => {
|
||||
)
|
||||
}
|
||||
|
||||
FrontPage.propTypes = {
|
||||
userRoles: propTypes.arrayOf(propTypes.string),
|
||||
rights: propTypes.arrayOf(propTypes.string),
|
||||
}
|
||||
|
||||
FrontPage.defaultProps = {
|
||||
userRoles: [],
|
||||
rights: [],
|
||||
}
|
||||
|
||||
const mapStateToProps = ({
|
||||
auth: {
|
||||
token: { roles, rights },
|
||||
},
|
||||
}) => ({
|
||||
userRoles: getUserRoles(roles),
|
||||
rights,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, null, null, {
|
||||
areStatePropsEqual: isEqual,
|
||||
})(FrontPage)
|
||||
export default FrontPage
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import './navigationBar.css'
|
||||
import { Menu, Dropdown, Button, Label } from 'semantic-ui-react'
|
||||
import { NavLink, Link } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { isEqual } from 'lodash'
|
||||
import { getUserRoles, checkUserAccess } from '../../common'
|
||||
import { logout as logoutAction } from '../../redux/auth'
|
||||
import './navigationBar.css'
|
||||
import LanguagePicker from '../LanguagePicker'
|
||||
import { isDev, adminerUrls } from '../../conf'
|
||||
import { useShowAsUser } from '../../common/hooks'
|
||||
@@ -134,7 +134,7 @@ const NavigationBar = props => {
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu stackable fluid className="navBar">
|
||||
<Menu data-cy="navBar" stackable fluid className="navBar">
|
||||
{renderHome()}
|
||||
{renderNavigationRoutes()}
|
||||
{renderUserMenu()}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* stylelint-disable */
|
||||
|
||||
.navBar {
|
||||
display: grid !important;
|
||||
grid-auto-flow: column !important;
|
||||
@@ -25,56 +23,3 @@
|
||||
.logoText {
|
||||
color: var(--black) ;;
|
||||
}
|
||||
|
||||
.sis-destruction-siren {
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
animation: 0.5s infinite running sis-siren;
|
||||
}
|
||||
|
||||
@keyframes sis-siren {
|
||||
from {
|
||||
transform: rotateZ(0);
|
||||
}
|
||||
to {
|
||||
transform: rotateZ(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sis-danger-zone-button {
|
||||
background-color: #d01919;
|
||||
animation: 2s ease-in-out infinite running sis-danger-zone-button-heartbeat;
|
||||
}
|
||||
|
||||
@keyframes sis-danger-zone-button-heartbeat {
|
||||
from {
|
||||
background-color: #d01919;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
to {
|
||||
background-color: rgba(230, 107, 107, 0.76);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sis-danger-zone-button::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 0.28571429rem;
|
||||
box-shadow: 0 0 25px 5px rgba(224, 114, 40, 0.75);
|
||||
opacity: 1;
|
||||
animation: 2s ease-in-out infinite running sis-danger-zone-opacity;
|
||||
}
|
||||
|
||||
@keyframes sis-danger-zone-opacity {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
+61
-83
@@ -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
|
||||
|
||||
+1
-14
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table, Progress, Icon } from 'semantic-ui-react'
|
||||
import { number, func } from 'prop-types'
|
||||
|
||||
import ExternalCreditFilterToggle from './ExternalCreditFilterToggle'
|
||||
|
||||
const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }) => {
|
||||
@@ -76,17 +76,4 @@ const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }
|
||||
)
|
||||
}
|
||||
|
||||
CollapsibleCreditRow.propTypes = {
|
||||
min: number,
|
||||
max: number,
|
||||
studentCount: func.isRequired,
|
||||
filteredLength: number.isRequired,
|
||||
months: number.isRequired,
|
||||
}
|
||||
|
||||
CollapsibleCreditRow.defaultProps = {
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}
|
||||
|
||||
export default CollapsibleCreditRow
|
||||
|
||||
+50
-53
@@ -1,62 +1,59 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getMonths } from '../../../common/query'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import CollapsibleCreditRow from './CollapsibleCreditRow'
|
||||
import React, { useState } from 'react'
|
||||
import { Divider, Icon, Grid, Label } from 'semantic-ui-react'
|
||||
|
||||
const CreditsGainedTab = ({ filteredStudents }) => {
|
||||
const months = getMonths(useLocation())
|
||||
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
|
||||
import CreditsGainedTable from './CreditsGainedTable'
|
||||
|
||||
const studentCount = (min, max = Infinity) =>
|
||||
max === 0
|
||||
? creditList.filter(credits => credits === 0).length
|
||||
: creditList.filter(credits => credits < max && credits >= min).length
|
||||
const admissionTypes = [
|
||||
'Todistusvalinta',
|
||||
'Koepisteet',
|
||||
'Yhteispisteet',
|
||||
'Avoin väylä',
|
||||
'Kilpailumenestys',
|
||||
'Muu',
|
||||
null,
|
||||
]
|
||||
|
||||
const limits = [
|
||||
[Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
|
||||
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
|
||||
[1, Math.ceil(months * (15 / 12))],
|
||||
[null, 0],
|
||||
]
|
||||
const CreditsGainedTab = ({ allStudents, query }) => {
|
||||
const [show, setShow] = useState(false)
|
||||
if (!allStudents || !allStudents.length || !query) return null
|
||||
|
||||
const { studyRights } = query
|
||||
|
||||
const filterFunction = (student, type) =>
|
||||
student.studyrights.some(
|
||||
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
|
||||
)
|
||||
|
||||
const getCreditsGainedTable = type => {
|
||||
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
|
||||
return <CreditsGainedTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
|
||||
}
|
||||
|
||||
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
|
||||
|
||||
return (
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell collapsing />
|
||||
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Number of Students
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{limits.map(([min, max]) => (
|
||||
<CollapsibleCreditRow
|
||||
key={`table-row-${min}-${max}`}
|
||||
min={min}
|
||||
max={max}
|
||||
studentCount={studentCount}
|
||||
filteredLength={filteredStudents.length}
|
||||
months={Number(months)}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={16} data-cy="credits-gained-main-table">
|
||||
<CreditsGainedTable type="All students of the population" filteredStudents={allStudents} />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
{admissionTypesAvailable && (
|
||||
<Divider
|
||||
className="credits-gained-divider"
|
||||
horizontal
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
By admission type <Icon name={`angle ${show ? 'down' : 'right'}`} />
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</Divider>
|
||||
)}
|
||||
{show && <Grid.Row>{admissionTypes.map(type => getCreditsGainedTable(type))}</Grid.Row>}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
CreditsGainedTab.propTypes = {
|
||||
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
}
|
||||
|
||||
export default CreditsGainedTab
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getMonths } from '../../../common/query'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import CollapsibleCreditRow from './CollapsibleCreditRow'
|
||||
|
||||
const CreditsGainedTable = ({ filteredStudents, type }) => {
|
||||
const months = getMonths(useLocation())
|
||||
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
|
||||
|
||||
if (!filteredStudents || !filteredStudents.length || !type) return null
|
||||
|
||||
const studentCount = (min, max = Infinity) =>
|
||||
max === 0
|
||||
? creditList.filter(credits => credits === 0).length
|
||||
: creditList.filter(credits => credits < max && credits >= min).length
|
||||
|
||||
const limits = [
|
||||
[Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
|
||||
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
|
||||
[1, Math.ceil(months * (15 / 12))],
|
||||
[null, 0],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="credits-gained-table" data-cy={`credits-gained-table-${type}`}>
|
||||
<h3>{type}</h3>
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell collapsing />
|
||||
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Number of Students
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body data-cy="credits-gained-table-body">
|
||||
{limits.map(([min, max]) => (
|
||||
<CollapsibleCreditRow
|
||||
key={`table-row-${min}-${max}`}
|
||||
min={min}
|
||||
max={max}
|
||||
studentCount={studentCount}
|
||||
filteredLength={filteredStudents.length}
|
||||
months={Number(months)}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditsGainedTable
|
||||
+45
-48
@@ -1,55 +1,52 @@
|
||||
import React from 'react'
|
||||
import { jStat } from 'jstat'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import { Divider, Grid, Label } from 'semantic-ui-react'
|
||||
|
||||
const StatisticsTab = ({ filteredStudents }) => {
|
||||
const credits = filteredStudents.map(student => getStudentTotalCredits(student))
|
||||
const formatNumber = (x, decimals) => (Number.isNaN(x) || !x ? 0 : x).toFixed(decimals)
|
||||
const quartiles = jStat.quartiles(credits)
|
||||
import StatisticsTable from './StatisticsTable'
|
||||
|
||||
const admissionTypes = [
|
||||
'Todistusvalinta',
|
||||
'Koepisteet',
|
||||
'Yhteispisteet',
|
||||
'Avoin väylä',
|
||||
'Kilpailumenestys',
|
||||
'Muu',
|
||||
null,
|
||||
]
|
||||
|
||||
const StatisticsTab = ({ allStudents, query }) => {
|
||||
if (!allStudents || !allStudents.length || !query) return null
|
||||
|
||||
const { studyRights } = query
|
||||
|
||||
const filterFunction = (student, type) =>
|
||||
student.studyrights.some(
|
||||
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
|
||||
)
|
||||
|
||||
const getStatisticsTable = type => {
|
||||
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
|
||||
return <StatisticsTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
|
||||
}
|
||||
|
||||
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
|
||||
|
||||
return (
|
||||
<Table celled collapsing className="statistics-table">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell data-cy="credit-stats-table-name-header">
|
||||
{`Statistic for n = ${credits.length} Students`}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Credits Earned</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell>Average</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-mean">{formatNumber(jStat.mean(credits), 2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Standard Deviation</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-stdev">{formatNumber(jStat.stdev(credits), 2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Min</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-min">{formatNumber(jStat.min(credits), 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>1st Quartile (25 %)</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-q1">{formatNumber(quartiles[0], 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>2nd Quartile (50 %)</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-q2">{formatNumber(quartiles[1], 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>3rd Quartile (75 %)</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-q3">{formatNumber(quartiles[2], 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Max</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-max">{formatNumber(jStat.max(credits), 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Grid padded centered>
|
||||
<Grid.Row>
|
||||
<StatisticsTable type="All students of the population" filteredStudents={allStudents} />
|
||||
</Grid.Row>
|
||||
{admissionTypesAvailable && (
|
||||
<>
|
||||
<Divider horizontal>
|
||||
By admission type
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</Divider>
|
||||
<Grid.Row>{admissionTypes.map(type => getStatisticsTable(type))}</Grid.Row>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import * as _ from 'lodash'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
|
||||
const StatisticsTable = ({ filteredStudents, type }) => {
|
||||
if (!filteredStudents || !filteredStudents.length) return null
|
||||
const credits = filteredStudents.map(student => getStudentTotalCredits(student))
|
||||
const formatNumber = (x, decimals) => (Number.isNaN(x) || !x ? 0 : x).toFixed(decimals)
|
||||
const mean = _.mean(credits)
|
||||
const stdev = Math.sqrt(_.sum(_.map(credits, c => (c - mean) * (c - mean))) / credits.length)
|
||||
|
||||
return (
|
||||
<div className="statistics-table">
|
||||
<h3>{type}</h3>
|
||||
<Table celled collapsing>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell data-cy="credit-stats-table-name-header">
|
||||
{`Statistic for n = ${credits.length} Students`}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Credits Earned</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell>Total</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-total">{formatNumber(_.sum(credits))}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Average</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-mean">{formatNumber(mean, 2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Standard Deviation</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-stdev">{formatNumber(stdev, 2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Min</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-min">{formatNumber(_.min(credits), 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Max</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-max">{formatNumber(_.max(credits), 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatisticsTable
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
#credit-gain-stats .statistics-table {
|
||||
margin: 0 auto;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
#credit-gain-stats .credits-gained-table {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
#credit-gain-stats .credits-gained-divider:hover {
|
||||
color: gray;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#credit-gain-stats .collapsing + th,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
import { useTabChangeAnalytics } from '../../../common/hooks'
|
||||
import InfoBox from '../../InfoBox'
|
||||
@@ -8,11 +7,11 @@ import CreditsGainedTab from './CreditsGainedTab'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
import './creditGainStats.css'
|
||||
|
||||
const CreditGainStats = ({ filteredStudents }) => {
|
||||
const CreditGainStats = ({ filteredStudents, query }) => {
|
||||
const renderCreditsGainTab = useCallback(() => {
|
||||
return (
|
||||
<Tab.Pane attached={false}>
|
||||
<CreditsGainedTab filteredStudents={filteredStudents} />
|
||||
<CreditsGainedTab allStudents={filteredStudents} query={query} />
|
||||
</Tab.Pane>
|
||||
)
|
||||
}, [filteredStudents])
|
||||
@@ -20,7 +19,7 @@ const CreditGainStats = ({ filteredStudents }) => {
|
||||
const renderQuartersTab = useCallback(() => {
|
||||
return (
|
||||
<Tab.Pane attached={false}>
|
||||
<StatisticsTab filteredStudents={filteredStudents} />
|
||||
<StatisticsTab allStudents={filteredStudents} query={query} />
|
||||
</Tab.Pane>
|
||||
)
|
||||
}, [filteredStudents])
|
||||
@@ -51,8 +50,4 @@ const CreditGainStats = ({ filteredStudents }) => {
|
||||
)
|
||||
}
|
||||
|
||||
CreditGainStats.propTypes = {
|
||||
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
}
|
||||
|
||||
export default CreditGainStats
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { object, arrayOf, bool, shape, node } from 'prop-types'
|
||||
import { Message, Accordion, Label } from 'semantic-ui-react'
|
||||
import { Message, Accordion } from 'semantic-ui-react'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
@@ -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>
|
||||
),
|
||||
@@ -111,7 +112,7 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
content: {
|
||||
content: !query.years ? (
|
||||
<div ref={creditGainRef}>
|
||||
<CreditGainStats filteredStudents={filteredStudents} />
|
||||
<CreditGainStats query={query} filteredStudents={filteredStudents} />
|
||||
</div>
|
||||
) : (
|
||||
<div>This table is omitted when searching population of multiple years</div>
|
||||
@@ -124,9 +125,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Age distribution
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -150,7 +148,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
<div ref={courseTableRef}>
|
||||
<PopulationCourses
|
||||
selectedStudents={selectedStudents}
|
||||
selectedStudentsByYear={selectedStudentsByYear}
|
||||
query={query}
|
||||
allStudents={allStudents}
|
||||
filteredStudents={filteredStudents}
|
||||
@@ -190,7 +187,6 @@ PopulationDetails.propTypes = {
|
||||
samples: arrayOf(object).isRequired,
|
||||
queryIsSet: bool.isRequired,
|
||||
isLoading: bool.isRequired,
|
||||
selectedStudentsByYear: shape({}).isRequired,
|
||||
query: shape({}).isRequired,
|
||||
dataExport: node,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import qs from 'query-string'
|
||||
import { func, arrayOf, shape, bool, string, object, oneOfType } from 'prop-types'
|
||||
import { Form, Button, Message, Icon, Grid } from 'semantic-ui-react'
|
||||
import uuidv4 from 'uuid/v4'
|
||||
import Datetime from 'react-datetime'
|
||||
@@ -21,7 +20,6 @@ import { momentFromFormat, reformatDate, textAndDescriptionSearch, getTextIn, ca
|
||||
import { useSearchHistory } from '../../common/hooks'
|
||||
import { setLoading } from '../../redux/graphSpinner'
|
||||
import './populationSearch.css'
|
||||
import { dropdownType } from '../../constants/types'
|
||||
import SearchHistory from '../SearchHistory'
|
||||
|
||||
const YEAR_DATE_FORMAT = 'YYYY'
|
||||
@@ -568,28 +566,6 @@ const PopulationSearchForm = props => {
|
||||
)
|
||||
}
|
||||
|
||||
PopulationSearchForm.propTypes = {
|
||||
language: string.isRequired,
|
||||
getProgrammes: func.isRequired,
|
||||
getPopulationStatistics: func.isRequired,
|
||||
getPopulationCourses: func.isRequired,
|
||||
getMandatoryCourses: func.isRequired,
|
||||
queries: shape({}).isRequired,
|
||||
studyProgrammes: shape({}), //eslint-disable-line
|
||||
studyTracks: arrayOf(dropdownType), //eslint-disable-line
|
||||
setLoading: func.isRequired,
|
||||
pending: bool, //eslint-disable-line
|
||||
getSemesters: func.isRequired,
|
||||
semesters: shape({}).isRequired,
|
||||
history: shape({}).isRequired,
|
||||
location: shape({}).isRequired,
|
||||
getTagsByStudytrackAction: func.isRequired,
|
||||
tags: oneOfType([arrayOf(shape({ tag_id: string, tagname: string })), object]).isRequired,
|
||||
onProgress: func.isRequired,
|
||||
clearSelected: func.isRequired,
|
||||
clearPopulations: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ semesters, settings, populations, populationProgrammes, tags }) => {
|
||||
const { language } = settings
|
||||
const { pending } = populationProgrammes
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Segment, Header, Divider, Form, Button, Icon } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
@@ -41,19 +40,6 @@ const PopulationSearch = ({ populationFound, history, location, loading }) => {
|
||||
)
|
||||
}
|
||||
|
||||
PopulationSearch.propTypes = {
|
||||
history: PropTypes.shape({
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
populationFound: PropTypes.bool.isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations }) => ({
|
||||
populationFound: populations.data.students !== undefined,
|
||||
loading: !!populations.pending,
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
|
||||
import { sortBy } from 'lodash'
|
||||
import { useChunk } from '../../common/hooks'
|
||||
|
||||
@@ -166,39 +165,4 @@ const SortableTable = ({
|
||||
)
|
||||
}
|
||||
|
||||
SortableTable.propTypes = {
|
||||
tableProps: shape({}),
|
||||
getRowKey: func.isRequired,
|
||||
getRowProps: func,
|
||||
columns: arrayOf(
|
||||
shape({
|
||||
key: string.isRequired,
|
||||
title: oneOfType([element, string]),
|
||||
headerProps: shape({}),
|
||||
getRowVal: func,
|
||||
getRowContent: func,
|
||||
getCellProps: func,
|
||||
cellProps: shape({}),
|
||||
group: bool,
|
||||
children: arrayOf(),
|
||||
})
|
||||
).isRequired,
|
||||
data: arrayOf(shape({})).isRequired,
|
||||
defaultdescending: bool,
|
||||
defaultsortkey: string,
|
||||
collapsingHeaders: bool,
|
||||
showNames: bool,
|
||||
chunkifyBy: string,
|
||||
}
|
||||
|
||||
SortableTable.defaultProps = {
|
||||
tableProps: undefined,
|
||||
getRowProps: undefined,
|
||||
defaultdescending: false,
|
||||
defaultsortkey: null,
|
||||
collapsingHeaders: false,
|
||||
showNames: undefined,
|
||||
chunkifyBy: undefined,
|
||||
}
|
||||
|
||||
export default SortableTable
|
||||
|
||||
+6
-41
@@ -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
|
||||
}
|
||||
|
||||
@@ -45,15 +45,7 @@ const StudentInfoCard = ({ student }) => {
|
||||
<div className="startDate">{`Started: ${reformatDate(student.started, DISPLAY_DATE_FORMAT)}`}</div>
|
||||
<div className="startDate">{email}</div>
|
||||
</Card.Meta>
|
||||
<Card.Description>
|
||||
{`Credits: ${student.credits || 0}`}
|
||||
{student.mismatch && '*'}
|
||||
<p>
|
||||
<i>
|
||||
{student.mismatch && '*There is difference between the total credits of this student in Oodi and Sisu'}
|
||||
</i>
|
||||
</p>
|
||||
</Card.Description>
|
||||
<Card.Description>{`Credits: ${student.credits || 0}`}</Card.Description>
|
||||
<div style={{ paddingTop: '4px' }}>
|
||||
<EnrollmentAccordion semesterEnrollments={student.semesterenrollments} />
|
||||
<p style={{ fontSize: 14 }}>{`Updated at ${formattedTimestamp}`}</p>
|
||||
|
||||
+151
-131
@@ -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 } 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 SingleStudyGroupContent = ({ population, language }) => {
|
||||
const custompop = population?.students || []
|
||||
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 createAcademicYearStartDate = year => new Date(year, 7, 1)
|
||||
|
||||
const handleClick = index => {
|
||||
const indexes = [...activeIndex].sort()
|
||||
if (indexes.includes(index)) {
|
||||
indexes.splice(
|
||||
indexes.findIndex(ind => ind === index),
|
||||
1
|
||||
)
|
||||
} else {
|
||||
indexes.push(index)
|
||||
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
|
||||
}
|
||||
if (activeIndex.length < indexes.length) setNewest(index)
|
||||
else setNewest(null)
|
||||
setIndex(indexes)
|
||||
}, [])
|
||||
|
||||
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 isMounted = useIsMounted()
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
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 togglePanel = index => {
|
||||
const currentActiveIndex = new Set(activeIndex)
|
||||
if (currentActiveIndex.has(index)) {
|
||||
currentActiveIndex.delete(index)
|
||||
} else {
|
||||
currentActiveIndex.add(index)
|
||||
setNewestIndex(index)
|
||||
}
|
||||
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,20 +97,22 @@ const SingleStudyGroupContent = ({ population, 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}
|
||||
students={creditsStartingFromAssociatedYear ? filteredStudentsWithFilteredCourses : filteredStudents}
|
||||
startDate={creditsStartingFromAssociatedYear && createAcademicYearStartDate(group.tags?.year)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -89,16 +123,20 @@ const SingleStudyGroupContent = ({ population, 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>
|
||||
),
|
||||
},
|
||||
@@ -108,33 +146,15 @@ const SingleStudyGroupContent = ({ population, 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>
|
||||
),
|
||||
},
|
||||
@@ -142,73 +162,73 @@ const SingleStudyGroupContent = ({ population, 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 SingleStudyGroupViewWrapper = ({ group, language, isLoading, studyProgrammes, children }) => {
|
||||
const history = useHistory()
|
||||
const handleBack = () => {
|
||||
history.push('/studyguidancegroups')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
|
||||
<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(() => {
|
||||
if (!group) return
|
||||
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
|
||||
if (groupStudentNumbers.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} language={language} /> : null}
|
||||
<SingleStudyGroupViewWrapper
|
||||
group={group}
|
||||
language={language}
|
||||
isLoading={isLoading}
|
||||
studyProgrammes={studyProgrammes}
|
||||
>
|
||||
{!isLoading && <SingleStudyGroupContent population={data} group={group} language={language} />}
|
||||
</SingleStudyGroupViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
+167
-21
@@ -1,38 +1,180 @@
|
||||
import React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import SortableTable from '../SortableTable'
|
||||
import { getTextIn } from '../../common'
|
||||
import StyledMessage from './StyledMessage'
|
||||
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 { 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 StudyGuidanceGroupOverview = () => {
|
||||
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 cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
|
||||
const cellContent = { flex: '0 1 50%' }
|
||||
|
||||
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
|
||||
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
|
||||
return (
|
||||
<div>
|
||||
<Formik
|
||||
initialValues={{ [tagName]: '' }}
|
||||
onSubmit={values => {
|
||||
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={{ ...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>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TagCell = ({ tagName, group, studyProgrammes }) => {
|
||||
const [showEdit, toggleEdit] = useToggle()
|
||||
const getText = () => {
|
||||
switch (tagName) {
|
||||
case 'studyProgramme':
|
||||
return studyProgrammes.find(p => p.value === group.tags?.[tagName])?.text
|
||||
case 'year':
|
||||
return startYearToAcademicYear(group.tags?.[tagName])
|
||||
default:
|
||||
throw Error(`Wrong tagname: ${tagName}`)
|
||||
}
|
||||
}
|
||||
return group.tags?.[tagName] && !showEdit ? (
|
||||
<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 = ({ groups }) => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
|
||||
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
|
||||
|
||||
const headers = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'name',
|
||||
getRowVal: group => getTextIn(group.name, language),
|
||||
getRowContent: group => (
|
||||
<Link
|
||||
style={{
|
||||
color: 'black',
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '.78571429em .78571429em',
|
||||
}}
|
||||
to={`/studyguidancegroups/${group.id}`}
|
||||
>
|
||||
{getTextIn(group.name, language)}
|
||||
</Link>
|
||||
),
|
||||
getRowContent: group => <LinkToGroup group={group} language={language} />,
|
||||
cellProps: {
|
||||
style: {
|
||||
padding: '0',
|
||||
},
|
||||
className: 'iconCellNoPointer',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
title: 'students',
|
||||
getRowVal: group => group.members?.length || 0,
|
||||
getRowContent: group => group.members?.length || 0,
|
||||
cellProps: {
|
||||
style: {
|
||||
padding: '0',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'studyProgramme',
|
||||
title: 'Study programme',
|
||||
getRowVal: group => group.tags?.studyProgramme,
|
||||
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
|
||||
},
|
||||
{
|
||||
key: 'associatedyear',
|
||||
title: 'Associated starting academic year',
|
||||
getRowVal: group => group.tags?.year,
|
||||
getRowContent: group => <TagCell tagName="year" group={group} />,
|
||||
},
|
||||
]
|
||||
|
||||
if (groups.length === 0) {
|
||||
@@ -41,7 +183,11 @@ const StudyGuidanceGroupOverview = () => {
|
||||
return (
|
||||
<>
|
||||
<StyledMessage>
|
||||
<p>Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin.</p>
|
||||
<p>
|
||||
Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin. Voit halutessasi
|
||||
lisätä ohjausryhmään aloitusvuoden ja koulutusohjelman, jolloin yksittäisen ohjausryhmän näkymään avautuu
|
||||
lisäominaisuuksia.{' '}
|
||||
</p>
|
||||
</StyledMessage>
|
||||
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
</>
|
||||
|
||||
+62
@@ -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,18 +4,14 @@ import { Provider } from 'react-redux'
|
||||
import 'semantic-ui-css/semantic.min.css'
|
||||
import 'react-datetime/css/react-datetime.css'
|
||||
import './styles/custom.css'
|
||||
import { debugContextDevtool } from 'react-context-devtool'
|
||||
import store from './store'
|
||||
import store from './redux'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import Main from './components/Main'
|
||||
import CommonContext from './CommonContext'
|
||||
import initializeSentry from './util/sentry'
|
||||
import { isProduction } from './conf'
|
||||
|
||||
initializeSentry()
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<CommonContext>
|
||||
@@ -24,8 +20,5 @@ ReactDOM.render(
|
||||
</ErrorBoundary>
|
||||
</CommonContext>
|
||||
</Provider>,
|
||||
container
|
||||
document.getElementById('root')
|
||||
)
|
||||
|
||||
// Best not to use this in production as the performance impact appears to be quite high.
|
||||
debugContextDevtool(container, { disable: isProduction })
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { decodeToken } from '../common'
|
||||
|
||||
export const loginPrefix = 'LOGIN_'
|
||||
export const logoutPrefix = 'LOGOUT_'
|
||||
|
||||
export const login = () => ({
|
||||
type: 'LOGIN_ATTEMPT',
|
||||
})
|
||||
|
||||
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 {
|
||||
@@ -28,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':
|
||||
@@ -43,6 +47,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
|
||||
pending: false,
|
||||
error: true,
|
||||
token: null,
|
||||
encodedToken: null,
|
||||
}
|
||||
|
||||
case 'LOGOUT_SUCCESS':
|
||||
@@ -51,6 +56,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
|
||||
pending: false,
|
||||
error: false,
|
||||
token: null,
|
||||
encodedToken: null,
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { actionTypes } from '../../apiConnection'
|
||||
|
||||
const additionalTypes = prefix => ({
|
||||
reset: `${prefix}_CLEAR`,
|
||||
})
|
||||
|
||||
const createReducer = (
|
||||
prefix,
|
||||
{
|
||||
reduceGET = (_, action) => action.response,
|
||||
reducePOST = (data, action) => data.concat(action.response),
|
||||
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
|
||||
defaults = undefined,
|
||||
}
|
||||
) => {
|
||||
const prefixGET = `${prefix}GET_`
|
||||
const prefixPOST = `${prefix}POST_`
|
||||
const prefixDELETE = `${prefix}DELETE_`
|
||||
const read = actionTypes(prefixGET)
|
||||
const create = actionTypes(prefixPOST)
|
||||
const destroy = actionTypes(prefixDELETE)
|
||||
const other = additionalTypes(prefix)
|
||||
const initialState = {
|
||||
pending: false,
|
||||
error: false,
|
||||
data: [],
|
||||
...defaults,
|
||||
}
|
||||
const reducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case destroy.attempt:
|
||||
case create.attempt:
|
||||
case read.attempt:
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
pending: true,
|
||||
}
|
||||
case destroy.failure:
|
||||
case create.failure:
|
||||
case read.failure:
|
||||
return {
|
||||
...state,
|
||||
error: true,
|
||||
pending: false,
|
||||
}
|
||||
case read.success:
|
||||
return {
|
||||
...state,
|
||||
data: reduceGET(state.data, action),
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
case create.success:
|
||||
return {
|
||||
...state,
|
||||
data: reducePOST(state.data, action),
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
case destroy.success:
|
||||
return {
|
||||
...state,
|
||||
data: reduceDELETE(state.data, action),
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
case other.reset: {
|
||||
return {
|
||||
...initialState,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
return {
|
||||
reducer,
|
||||
prefixGET,
|
||||
prefixPOST,
|
||||
prefixDELETE,
|
||||
}
|
||||
}
|
||||
|
||||
export default createReducer
|
||||
@@ -0,0 +1,33 @@
|
||||
import { actionSuffixes } from '../../apiConnection'
|
||||
|
||||
export const matcherReducers = [
|
||||
{
|
||||
matcher: action => action.type.endsWith(actionSuffixes.attempt),
|
||||
reducer(state) {
|
||||
state.error = false
|
||||
state.pending = true
|
||||
},
|
||||
},
|
||||
{
|
||||
matcher: action => action.type.endsWith(actionSuffixes.failure),
|
||||
reducer(state) {
|
||||
state.error = true
|
||||
state.pending = false
|
||||
},
|
||||
},
|
||||
{
|
||||
matcher: action => action.type.endsWith(actionSuffixes.success),
|
||||
reducer(state) {
|
||||
state.error = false
|
||||
state.pending = false
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const defaultReducer = () => {}
|
||||
|
||||
export const initialState = {
|
||||
pending: false,
|
||||
error: false,
|
||||
data: [],
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { combineReducers } from 'redux'
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
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'
|
||||
@@ -39,48 +41,59 @@ import singleCourseStats from './singleCourseStats'
|
||||
import userAccessEmail from './userAccessEmail'
|
||||
import customPopulationSearch from './customPopulationSearch'
|
||||
import coolDataScience from './coolDataScience'
|
||||
import studyGuidanceGroups from './studyGuidanceGroups'
|
||||
|
||||
export default combineReducers({
|
||||
actionHistory,
|
||||
users,
|
||||
mandatoryCourseLabels,
|
||||
populations,
|
||||
populationCourses,
|
||||
populationSelectedStudentCourses,
|
||||
populationProgrammes,
|
||||
populationProgrammesUnfiltered,
|
||||
populationMandatoryCourses,
|
||||
studyProgrammeProductivity,
|
||||
studyProgrammeThroughput,
|
||||
studyProgrammeBachelors,
|
||||
faculties,
|
||||
units,
|
||||
students,
|
||||
errors,
|
||||
courses,
|
||||
courseInstances,
|
||||
graphSpinner,
|
||||
settings,
|
||||
teachers,
|
||||
providers,
|
||||
semesters,
|
||||
teacherStatistics,
|
||||
teachersTop,
|
||||
teachersTopCategories,
|
||||
courseSearch,
|
||||
courseStats,
|
||||
courseSummaryForm,
|
||||
thesisCourses,
|
||||
accessGroups,
|
||||
elementdetails,
|
||||
feedback,
|
||||
tags,
|
||||
tagstudent,
|
||||
auth,
|
||||
singleCourseStats,
|
||||
userAccessEmail,
|
||||
customPopulationSearch,
|
||||
coolDataScience,
|
||||
studyGuidanceGroups,
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
actionHistory,
|
||||
users,
|
||||
mandatoryCourseLabels,
|
||||
populations,
|
||||
populationCourses,
|
||||
populationSelectedStudentCourses,
|
||||
populationProgrammes,
|
||||
populationProgrammesUnfiltered,
|
||||
populationMandatoryCourses,
|
||||
studyProgrammeProductivity,
|
||||
studyProgrammeThroughput,
|
||||
studyProgrammeBachelors,
|
||||
faculties,
|
||||
units,
|
||||
students,
|
||||
errors,
|
||||
courses,
|
||||
courseInstances,
|
||||
graphSpinner,
|
||||
settings,
|
||||
teachers,
|
||||
providers,
|
||||
semesters,
|
||||
teacherStatistics,
|
||||
teachersTop,
|
||||
teachersTopCategories,
|
||||
courseSearch,
|
||||
courseStats,
|
||||
courseSummaryForm,
|
||||
thesisCourses,
|
||||
accessGroups,
|
||||
elementdetails,
|
||||
feedback,
|
||||
tags,
|
||||
tagstudent,
|
||||
auth,
|
||||
singleCourseStats,
|
||||
userAccessEmail,
|
||||
customPopulationSearch,
|
||||
coolDataScience,
|
||||
[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
|
||||
middleware: getDefaultMiddleware => [
|
||||
...getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }),
|
||||
handleRequest,
|
||||
handleAuth,
|
||||
RTKApi.middleware,
|
||||
],
|
||||
})
|
||||
setupListeners(store.dispatch)
|
||||
export default store
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
import { callController } from '../apiConnection'
|
||||
import { RTKApi } from 'apiConnection'
|
||||
|
||||
const prefix = 'GET_STUDY_GUIDANCE_GROUPS_'
|
||||
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 = () => {
|
||||
const route = '/studyguidancegroups'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
const reducer = (state = { data: [] }, action) => {
|
||||
switch (action.type) {
|
||||
case `${prefix}ATTEMPT`:
|
||||
return {
|
||||
...state,
|
||||
pending: true,
|
||||
}
|
||||
case `${prefix}FAILURE`:
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: true,
|
||||
}
|
||||
case `${prefix}SUCCESS`:
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: false,
|
||||
data: action.response,
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer
|
||||
export const {
|
||||
useGetAllStudyGuidanceGroupsQuery,
|
||||
useChangeStudyGuidanceGroupTagsMutation,
|
||||
useGetStudyGuidanceGroupPopulationQuery,
|
||||
useGetStudyGuidanceGroupPopulationCoursesQuery,
|
||||
} = studyGuidanceGroupsApi
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
import createReducer from './common/crudreducer'
|
||||
import { callController } from '../apiConnection/index'
|
||||
import { callController, actionTypes } from '../apiConnection/index'
|
||||
|
||||
const additionalTypes = prefix => ({
|
||||
reset: `${prefix}_CLEAR`,
|
||||
})
|
||||
|
||||
const createReducer = (
|
||||
prefix,
|
||||
{
|
||||
reduceGET = (_, action) => action.response,
|
||||
reducePOST = (data, action) => data.concat(action.response),
|
||||
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
|
||||
defaults = undefined,
|
||||
}
|
||||
) => {
|
||||
const prefixGET = `${prefix}GET_`
|
||||
const prefixPOST = `${prefix}POST_`
|
||||
const prefixDELETE = `${prefix}DELETE_`
|
||||
const read = actionTypes(prefixGET)
|
||||
const create = actionTypes(prefixPOST)
|
||||
const destroy = actionTypes(prefixDELETE)
|
||||
const other = additionalTypes(prefix)
|
||||
const initialState = {
|
||||
pending: false,
|
||||
error: false,
|
||||
data: [],
|
||||
...defaults,
|
||||
}
|
||||
const reducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case destroy.attempt:
|
||||
case create.attempt:
|
||||
case read.attempt:
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
pending: true,
|
||||
}
|
||||
case destroy.failure:
|
||||
case create.failure:
|
||||
case read.failure:
|
||||
return {
|
||||
...state,
|
||||
error: true,
|
||||
pending: false,
|
||||
}
|
||||
case read.success:
|
||||
return {
|
||||
...state,
|
||||
data: reduceGET(state.data, action),
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
case create.success:
|
||||
return {
|
||||
...state,
|
||||
data: reducePOST(state.data, action),
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
case destroy.success:
|
||||
return {
|
||||
...state,
|
||||
data: reduceDELETE(state.data, action),
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
case other.reset: {
|
||||
return {
|
||||
...initialState,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
return {
|
||||
reducer,
|
||||
prefixGET,
|
||||
prefixPOST,
|
||||
prefixDELETE,
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = 'PROGRAMME_THESIS_'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { combineReducers } from 'redux'
|
||||
import { combineReducers } from '@reduxjs/toolkit'
|
||||
import { callController, actionTypes } from '../apiConnection'
|
||||
|
||||
const CLEAR_USER_ACCESS_ERRORS = 'CLEAR_USER_ACCESS_ERRORS'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSelector } from 'reselect'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { sortBy, flatten } from 'lodash'
|
||||
|
||||
const courseStatsSelector = state => state.courseStats.data
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSelector } from 'reselect'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { byDateDesc, reformatDate } from '../common'
|
||||
|
||||
const getData = obj => obj
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSelector } from 'reselect'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
|
||||
const getPopulations = state => state.populations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSelector } from 'reselect'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
|
||||
const associationsSelector = state => state.populationProgrammesUnfiltered.data
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSelector } from 'reselect'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { reformatDate } from '../common'
|
||||
|
||||
const getStudents = students => students.data
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createStore, applyMiddleware, compose } from 'redux'
|
||||
import thunk from 'redux-thunk'
|
||||
import reducers from './redux'
|
||||
import { handleRequest, handleAuth } from './apiConnection'
|
||||
|
||||
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
|
||||
|
||||
const store = createStore(reducers, composeEnhancers(applyMiddleware(thunk, handleRequest, handleAuth)))
|
||||
|
||||
export default store
|
||||
@@ -1,3 +1,4 @@
|
||||
require('express-async-errors')
|
||||
const express = require('express')
|
||||
const Sentry = require('@sentry/node')
|
||||
|
||||
@@ -5,6 +6,7 @@ const User = require('./src/services/users')
|
||||
const AccessGroup = require('./src/services/accessgroups')
|
||||
const { initializeDatabaseConnection } = require('./src/database/connection')
|
||||
const initializeSentry = require('./src/util/sentry')
|
||||
const errorMiddleware = require('./src/middlewares/errorMiddleware')
|
||||
|
||||
initializeDatabaseConnection()
|
||||
.then(() => {
|
||||
@@ -131,6 +133,8 @@ initializeDatabaseConnection()
|
||||
}
|
||||
})
|
||||
|
||||
app.use(errorMiddleware)
|
||||
|
||||
const server = app.listen(port, () => console.log(`Userservice listening on port ${port}!`))
|
||||
process.on('SIGTERM', () => {
|
||||
server.close(() => {
|
||||
|
||||
Generated
+8
-3
@@ -2302,6 +2302,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"express-async-errors": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
|
||||
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng=="
|
||||
},
|
||||
"extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -6591,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",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@sentry/node": "^6.11.0",
|
||||
"@sentry/tracing": "^6.11.0",
|
||||
"express": "^4.17.1",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"moment": "^2.24.0",
|
||||
"pg": "^8.7.1",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const Sentry = require('@sentry/node')
|
||||
const { ApplicationError } = require('../util/customErrors')
|
||||
|
||||
const errorHandler = (error, req, res, next) => {
|
||||
console.error(`${error.message} ${error.name} ${error.stack}`)
|
||||
console.error(`Body of failing request: ${JSON.stringify(req.body)}`)
|
||||
|
||||
// enrich with user details from req body
|
||||
Sentry.captureException({
|
||||
...error,
|
||||
reqBody: req.body,
|
||||
})
|
||||
|
||||
if (res.headersSent) {
|
||||
return next(error)
|
||||
}
|
||||
|
||||
const normalizedError = error instanceof ApplicationError ? error : new ApplicationError(error.message)
|
||||
|
||||
return res.status(normalizedError.status).json(normalizedError)
|
||||
}
|
||||
|
||||
module.exports = errorHandler
|
||||
@@ -36,7 +36,6 @@ const createMissingGroups = async (group, service) => {
|
||||
const savedGroups = await service.findAll()
|
||||
group.forEach(async code => {
|
||||
if (!savedGroups.map(sg => sg.code).includes(code)) {
|
||||
console.log(`Creating grp ${code}`)
|
||||
await service.create(code)
|
||||
}
|
||||
})
|
||||
@@ -87,7 +86,6 @@ const login = async (uid, full_name, hyGroups, affiliations, mail, hyPersonSisuI
|
||||
await createMissingGroups(affiliations, AffiliationService)
|
||||
|
||||
if (!user) {
|
||||
console.log('New user')
|
||||
user = await createUser(uid, full_name, mail, hyPersonSisuId, lastLogin)
|
||||
|
||||
const userHyGroups = await HyGroupService.byCodes(hyGroups)
|
||||
@@ -99,7 +97,6 @@ const login = async (uid, full_name, hyGroups, affiliations, mail, hyPersonSisuI
|
||||
isNew = true
|
||||
} else {
|
||||
user = await updateUser(user, { full_name, email: mail, sisu_person_id: hyPersonSisuId, last_login: lastLogin })
|
||||
user = await updateUser(user, { full_name, email: mail })
|
||||
await updateGroups(user, affiliations, hyGroups)
|
||||
}
|
||||
|
||||
@@ -295,9 +292,9 @@ const determineAccessToCourseStats = async (user, hyGroups) => {
|
||||
const accessGroups = (user && user.accessgroup) || []
|
||||
const alreadyAccess = accessGroups.some(({ group_code }) => group_code === 'courseStatistics')
|
||||
if (hyGroups.includes(courseStatisticsGroup) && !alreadyAccess) {
|
||||
await modifyRights(user.id, { courseStatistics: true })
|
||||
await modifyRights(user.username, { courseStatistics: true })
|
||||
} else if (!hyGroups.includes(courseStatisticsGroup) && alreadyAccess) {
|
||||
await modifyRights(user.id, { courseStatistics: false })
|
||||
await modifyRights(user.username, { courseStatistics: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +302,7 @@ const determineAccessToTeachersForOne = async (user, hyGroups) => {
|
||||
const accessGroups = (user && user.accessgroup) || []
|
||||
const alreadyAccess = accessGroups.some(({ group_code }) => group_code === 'teachers')
|
||||
if (hyGroups.includes(hyOneGroup) && !alreadyAccess) {
|
||||
await modifyRights(user.id, { teachers: true })
|
||||
await modifyRights(user.username, { teachers: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,9 +310,9 @@ const determineAccessToStudyGuidanceGroups = async (user, hasStudyGuidanceGroupA
|
||||
const accessGroups = (user && user.accessgroup) || []
|
||||
const alreadyAccess = accessGroups.some(({ group_code }) => group_code === 'studyGuidanceGroups')
|
||||
if (hasStudyGuidanceGroupAccess && !alreadyAccess) {
|
||||
await modifyRights(user.id, { studyGuidanceGroups: true })
|
||||
await modifyRights(user.username, { studyGuidanceGroups: true })
|
||||
} else if (!hasStudyGuidanceGroupAccess && alreadyAccess) {
|
||||
await modifyRights(user.id, { studyGuidanceGroups: false })
|
||||
await modifyRights(user.username, { studyGuidanceGroups: false })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class ApplicationError extends Error {
|
||||
constructor(message, status, extra) {
|
||||
super()
|
||||
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
|
||||
this.name = this.constructor.name
|
||||
|
||||
this.message = message || 'Something went wrong. Please try again.'
|
||||
|
||||
this.status = status || 500
|
||||
|
||||
this.extra = extra || {}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ApplicationError,
|
||||
}
|
||||
@@ -2,14 +2,12 @@ const winston = require('winston')
|
||||
const { isDev } = require('../config')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
|
||||
transports.push(new winston.transports.File({ filename: 'debug.log' }))
|
||||
let transports = []
|
||||
|
||||
if (isDev) {
|
||||
const devFormat = printf(({ level, message, timestamp, ...rest }) => {
|
||||
return `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
})
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
@@ -17,9 +15,7 @@ if (isDev) {
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDev) {
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
@@ -36,12 +32,13 @@ if (!isDev) {
|
||||
...rest,
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
|
||||
|
||||
module.exports = {
|
||||
logger,
|
||||
}
|
||||
|
||||
@@ -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' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,12 @@ const winston = require('winston')
|
||||
const { isDev } = require('../config')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
transports.push(new winston.transports.File({ filename: 'debug.log' }))
|
||||
}
|
||||
let transports = []
|
||||
|
||||
if (isDev) {
|
||||
const devFormat = printf(({ level, message, timestamp, ...rest }) => {
|
||||
return `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
})
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
@@ -19,9 +15,7 @@ if (isDev) {
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDev) {
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
@@ -38,12 +32,13 @@ if (!isDev) {
|
||||
...rest,
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
|
||||
|
||||
module.exports = {
|
||||
logger,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user