Compare commits

...
45 Commits
Author SHA1 Message Date
Saara Satokangas 2713545eda Remove proptypes from Thesis Courses 2021-12-01 13:41:08 +02:00
Saara Satokangas fa619fed38 Fix Thesis Courses -tab breaking because of unselected language 2021-12-01 13:36:48 +02:00
Saara Satokangas 6d4f49879b Add graph and table for graduated and thesis-writers 2021-12-01 13:27:58 +02:00
Saara Satokangas 37aeb9ebe4 Add percentages to barchart tooltips 2021-12-01 09:34:30 +02:00
Saara Satokangas 5f5423971c Add colors to new overview charts 2021-12-01 09:34:21 +02:00
Otto Ahoniemi aa6d2b83f5 Revert hiding of programme filter 2021-11-29 15:08:12 +02:00
Otto Ahoniemi ff36a0e8f7 Add date to study guidance group filter set 2021-11-29 13:48:11 +02:00
Otto Ahoniemi 71c1ce703a Add better selector for datetime picker header hider 2021-11-29 13:42:27 +02:00
Otto Ahoniemi c94711234f Don't show tag filter when tags aren't available 2021-11-29 13:34:08 +02:00
Otto Ahoniemi 563d2cb4b0 Fix course tab in population student table 2021-11-29 12:59:50 +02:00
Otto Ahoniemi 3727bd03dd Set datepicker to start from 2017 + hide year header 2021-11-29 11:05:36 +02:00
Otto Ahoniemi efb0b98cdc Update README 2021-11-29 10:19:12 +02:00
Petri Palmu cdbbf06c18 added info options to the filters 2021-11-24 08:55:02 +02:00
Saara SatokangasandGitHub e471b1e73a Merge pull request #3310 from UniversityOfHelsinkiCS/new_studyprogramme
New studyprogramme overview -pukinkontti
2021-11-18 16:09:59 +02:00
Saara Satokangas 1d7f9d250a Remove waste 2021-11-18 16:01:54 +02:00
Saara Satokangas 0207e58fa3 Years in correct order for tables and graphs 2021-11-18 16:01:54 +02:00
Saara Satokangas aecca8426d Modify tooltips 2021-11-18 16:01:54 +02:00
Saara Satokangas ef0d0a5eb7 Refactor stats basis into its own function 2021-11-18 16:01:54 +02:00
Saara Satokangas 284c5b270d Rewrite how major and non-major students' credits are defined 2021-11-18 16:01:54 +02:00
Saara Satokangas 51f2a7573e Increase height of the containers 2021-11-18 16:01:54 +02:00
Saara Satokangas 0837e1d972 Add transferred credits and non-major credits to studyprogramme credits
And general betterments
2021-11-18 16:01:54 +02:00
Saara Satokangas e468186af2 Add major students credit stats to the data table 2021-11-18 16:01:54 +02:00
Saara Satokangas 3fcfe56bd0 Show credits for major students in the graph 2021-11-18 16:01:54 +02:00
Saara Satokangas 85ad68c97d Move also new studyprogramme-stuff in backend into its own file
Again, why did I not do this already last week?
2021-11-18 16:01:54 +02:00
Saara Satokangas ede3b8efac Move the new view into its own folder
Should have done this from the start
2021-11-18 16:01:54 +02:00
Saara Satokangas cd1d65b29b Add basis for fetching credit stats 2021-11-18 16:01:54 +02:00
Saara Satokangas 456fe927e7 Initial adding of credits stats to studyprogramme-view 2021-11-18 16:01:54 +02:00
Saara Satokangas df36d58288 Add datatable, clean up graph 2021-11-18 16:01:54 +02:00
Saara Satokangas 918869b307 Add also started to the initial graph 2021-11-18 16:01:54 +02:00
Saara Satokangas 2ba06ae787 Add also cancelled and transfers to the initial new graph 2021-11-18 16:01:54 +02:00
Saara Satokangas ac0dc90661 Very initial commit for new studyprogramme graphs 2021-11-18 16:01:54 +02:00
Otto AhoniemiandGitHub 8622bd8e6d Merge pull request #3307 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/url-parse-1.5.3
Bump url-parse from 1.5.1 to 1.5.3 in /services/frontend
2021-11-17 19:01:48 +02:00
Otto Ahoniemi 76988e3ec5 show admission type for study guidance group students as well 2021-11-17 18:59:50 +02:00
Otto Ahoniemi b0a9fb48d7 Fix some study guidance group filtering related bugs 2021-11-17 18:56:37 +02:00
Otto Ahoniemi 16d603538f Fix study guidance group layout failing when tags not set 2021-11-17 18:35:09 +02:00
Otto Ahoniemi 7dd810cad6 Allow users with study guidance group accesses to view student page 2021-11-17 18:28:59 +02:00
Otto Ahoniemi 42afea5e0c Show student statistics search if user has studyguidancegroup role 2021-11-17 17:30:32 +02:00
Otto Ahoniemi 6d0c5e552b Add study guidance group spesific stuff to generaltab 2021-11-17 16:54:00 +02:00
Otto Ahoniemi 897c2e4c88 Add variants to generaltab + render study guidance groups in a custom manner 2021-11-17 16:13:56 +02:00
Otto Ahoniemi 673ce9f9c0 Change from population booleans to variant in studenttable 2021-11-17 16:10:31 +02:00
Otto Ahoniemi 77054187a0 Refactor column if elses away from generaltab 2021-11-17 15:27:17 +02:00
Otto Ahoniemi a9c244b1f6 Remove hocs and passing unnecessary props to generaltab 2021-11-17 13:00:01 +02:00
Otto Ahoniemi ed5e2da875 Declare populationtable contents in container instead of using if elses 2021-11-17 11:48:54 +02:00
Otto Ahoniemi 25d5599466 Remove unused components 2021-11-17 11:18:09 +02:00
dependabot[bot]andGitHub 2a6653c700 Bump url-parse from 1.5.1 to 1.5.3 in /services/frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.1 to 1.5.3.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.1...1.5.3)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 13:47:08 +00:00
59 changed files with 1540 additions and 762 deletions
+3 -3
View File
@@ -43,8 +43,8 @@ What different CLI options do:
- Cleans up any previous real data databases
- Downloads needed database dumps from production servers.
- Creates real data databases and populates them with downloaded dumps.
3. Reset sis-importer data
- Like option 2, but only for sis-importer database. Useful when developing updater microservice.
3. Reset single database
- Like option 2, but only for single database. Leaves other databases untouched.
## ⌨️ Development
@@ -124,7 +124,7 @@ Continuous integration (CI) works with Github actions and is defined in workflow
### Modules are missing after updating package.json
You should always install the dependencies **inside** the container to have the application **inside** the container access them. This might be the case when someone else installs a new library and you only pull the changes in package.json. Use `docker exec -it <service> sh` to get inside the container and run `npm ci` to install modules.
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Use `docker exec <service> npm ci` to install modules inside the container.
### I get the "You are not authorized to use Oodikone" -message when I try to work with anon-data
+2
View File
@@ -26,6 +26,7 @@ const customPopulationSearch = require('./routes/customPopulationSearch')
const trends = require('./routes/trends')
const programmeModules = require('./routes/programmeModules')
const studyGuidanceGroups = require('./routes/studyGuidanceGroups')
const studyProgramme = require('./routes/studyProgramme')
module.exports = (app, url) => {
app.use(url, ping)
@@ -54,6 +55,7 @@ module.exports = (app, url) => {
app.use(url, semesters)
app.use(url, tags)
app.use(url, programmeModules)
app.use(url, studyProgramme)
app.use(`${url}/faculties`, faculties)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
+20 -2
View File
@@ -2,6 +2,7 @@ const router = require('express').Router()
const Student = require('../services/students')
const userService = require('../services/userService')
const Unit = require('../services/units')
const { getAllStudentsUserHasInGroups } = require('../services/studyGuidanceGroups')
const filterStudentTags = (student, userId) => {
return {
@@ -13,7 +14,7 @@ const filterStudentTags = (student, userId) => {
router.get('/students', async (req, res) => {
const {
roles,
decodedToken: { userId },
decodedToken: { userId, sisPersonId },
query: { searchTerm },
} = req
@@ -37,8 +38,14 @@ router.get('/students', async (req, res) => {
} else {
const unitsUserCanAccess = await userService.getUnitsFromElementDetails(userId)
const codes = unitsUserCanAccess.map(unit => unit.id)
let extraStudents = []
if (roles.includes('studyGuidanceGroups')) {
const matchingOnlyByTerm = await Student.bySearchTerm(trimmedSearchTerm)
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(sisPersonId)
extraStudents.push(...matchingOnlyByTerm.filter(s => studentsUserCanAccess.has(s.studentNumber)))
}
const matchingStudents = await Student.bySearchTermAndElements(trimmedSearchTerm, codes)
res.json(matchingStudents)
res.json([...matchingStudents, ...extraStudents])
}
})
@@ -53,6 +60,17 @@ router.get('/students/:id', async (req, res) => {
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
}
if (roles && roles.includes('studyGuidanceGroups')) {
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(decodedToken.sisPersonId)
if (!studentsUserCanAccess.has(studentId)) {
return res.status(400).json({ error: 'error finding student' }).end()
}
const results = await Student.withId(studentId)
return results.error
? res.status(400).json({ error: 'error finding student' }).end()
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
}
const uid = req.decodedToken.userId
const student = await Student.withId(studentId)
if (student.error) {
@@ -0,0 +1,121 @@
const router = require('express').Router()
const {
getBasicStatsForStudytrack,
getCreditStatsForStudytrack,
getGraduationStatsForStudytrack,
} = require('../services/newStudyprogramme')
const {
getBasicStats,
setBasicStats,
getCreditStats,
setCreditStats,
getGraduationStats,
setGraduationStats,
} = require('../services/analyticsService')
const logger = require('../util/logger')
router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
const code = req.params.id
if (code) {
let data = null
try {
data = await getBasicStats(code)
} catch (e) {
logger.error(`Failed to get code ${code} basic stats`)
}
if (!data) {
try {
let result
if (code.includes('MH') || code.includes('KH')) {
result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2017-01-01'),
})
} else {
result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2000-01-01'),
})
}
data = await setBasicStats(result)
} catch (e) {
logger.error(`Failed to update code ${code} basic stats`)
}
}
return res.json({ data })
} else {
res.status(422).end()
}
})
router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
const code = req.params.id
if (code) {
let data = null
try {
data = await getCreditStats(code)
} catch (e) {
logger.error(`Failed to get code ${code} credit stats`)
}
if (!data) {
try {
let result
if (code.includes('MH') || code.includes('KH')) {
result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2017-01-01'),
})
} else {
result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2000-01-01'),
})
}
data = await setCreditStats(result)
} catch (e) {
logger.error(`Failed to update code ${code} credit stats`)
}
}
return res.json({ data })
} else {
res.status(422).end()
}
})
router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
const code = req.params.id
if (code) {
let data = null
try {
data = await getGraduationStats(code)
} catch (e) {
logger.error(`Failed to get code ${code} graduation stats`)
}
if (!data) {
try {
let result
if (code.includes('MH') || code.includes('KH')) {
result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2017-01-01'),
})
} else {
result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2000-01-01'),
})
}
data = await setGraduationStats(result)
} catch (e) {
logger.error(`Failed to update code ${code} graduation stats`)
}
}
return res.json({ data })
} else {
res.status(422).end()
}
})
router.use('*', (req, res, next) => next())
module.exports = router
@@ -3,6 +3,9 @@ const moment = require('moment')
const createRedisKeyForProductivity = id => `PRODUCTIVITY_${id}`
const createRedisKeyForThroughput = id => `THROUGHPUT_${id}`
const createRedisKeyForBasicStats = id => `BASIC_STATS_${id}`
const createRedisKeyForCreditStats = id => `CREDIT_STATS_${id}`
const createRedisKeyForGraduationStats = id => `GRADUATION_STATS_${id}`
const getProductivity = async id => {
const redisKey = createRedisKeyForProductivity(id)
@@ -87,6 +90,66 @@ const patchThroughput = async data => {
}
}
const getBasicStats = async id => {
const redisKey = createRedisKeyForBasicStats(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setBasicStats = async data => {
const { id } = data
const redisKey = createRedisKeyForBasicStats(id)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
const getCreditStats = async id => {
const redisKey = createRedisKeyForCreditStats(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setCreditStats = async data => {
const { id } = data
const redisKey = createRedisKeyForCreditStats(id)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
const getGraduationStats = async id => {
const redisKey = createRedisKeyForGraduationStats(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setGraduationStats = async data => {
const { id } = data
const redisKey = createRedisKeyForGraduationStats(id)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
module.exports = {
getProductivity,
setProductivity,
@@ -94,4 +157,10 @@ module.exports = {
getThroughput,
setThroughput,
patchThroughput,
getBasicStats,
setBasicStats,
getCreditStats,
setCreditStats,
getGraduationStats,
setGraduationStats,
}
@@ -0,0 +1,484 @@
const sequelize = require('sequelize')
const { Op } = sequelize
const { indexOf } = require('lodash')
const {
Credit,
Course,
Organization,
Studyright,
StudyrightElement,
ElementDetail,
Transfer,
Student,
} = require('../models')
const { ThesisCourse } = require('../models/models_kone')
const { mapToProviders } = require('../util/utils')
const formatStudyright = studyright => {
const { studyrightid, studystartdate, enddate, graduated, prioritycode, extentcode, student } = studyright
return {
studyrightid,
studystartdate,
enddate,
graduated,
prioritycode,
extentcode,
studentnumber: student.studentnumber,
}
}
const getYearsArray = since => {
const years = []
for (let i = since; i <= new Date().getFullYear(); i++) {
years.push(i)
}
return years
}
const getYearsObject = years => {
let yearsObject = {}
for (const year of years) {
yearsObject = { ...yearsObject, [year]: 0 }
}
return yearsObject
}
const getStatsBasis = years => {
return {
graphStats: new Array(years.length).fill(0),
tableStats: getYearsObject(years),
}
}
const isMajorStudentCredit = (studyright, attainment_date) =>
studyright &&
(studyright.prioritycode === 1 || studyright.prioritycode === 30) && // Is studyright state = MAIN or state = GRADUATED
studyright.studystartdate <= attainment_date && // Has the credit been attained after studying in the programme started
studyright.enddate >= attainment_date && // Has the credit been attained before the studyright ended
(!studyright.canceldate || studyright.canceldate >= attainment_date) // If the studyright was cancelled, was the credit attained before it was cancelled
const getCreditsForStudyProgramme = async (provider, since) =>
await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: ['code'],
required: true,
where: {
is_study_module: false,
},
include: {
model: Organization,
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: {
[Op.notIn]: [10, 9, 7],
},
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.gte]: since,
},
},
})
const getProgrammesStudents = async studyprogramme =>
(
await Studyright.findAll({
attributes: ['studyrightid', 'studystartdate', 'enddate', 'graduated', 'prioritycode', 'extentcode'],
include: [
{
model: StudyrightElement,
required: true,
where: {
code: {
[Op.in]: [studyprogramme],
},
},
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
})
).map(formatStudyright)
const getTransferredCredits = async (provider, since) =>
await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'credittypecode', 'student_studentnumber'],
include: {
model: Course,
attributes: ['code'],
required: true,
where: {
is_study_module: false,
},
include: {
model: Organization,
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: {
[Op.eq]: [9],
},
attainment_date: {
[Op.gte]: since,
},
},
})
const getThesisCredits = async (studyprogramme, startDate) => {
const thesiscourses = await ThesisCourse.findAll({
where: {
programmeCode: studyprogramme,
},
})
return await Credit.findAll({
include: {
model: Course,
required: true,
distinct: true,
col: 'student_studentnumber',
where: {
code: {
[Op.in]: thesiscourses.map(tc => tc.courseCode),
},
},
},
where: {
credittypecode: {
[Op.ne]: 10,
},
attainment_date: {
[Op.gte]: startDate,
},
},
})
}
const startedStudyrights = async (studytrack, since) =>
await Studyright.findAll({
include: {
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
where: {
code: studytrack,
},
},
},
where: {
studystartdate: {
[Op.gte]: since,
},
},
})
const graduatedStudyRights = async (studytrack, since) =>
await Studyright.findAll({
include: {
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
where: {
code: studytrack,
},
},
},
where: {
graduated: 1,
enddate: {
[Op.gte]: since,
},
},
})
const cancelledStudyRights = async (studytrack, since) => {
return await Studyright.findAll({
include: {
model: StudyrightElement,
required: true,
where: {
code: {
[Op.eq]: studytrack,
},
},
},
where: {
canceldate: {
[Op.gte]: since,
},
},
})
}
const transfersAway = async (studytrack, startDate) => {
return await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: startDate,
},
sourcecode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
}
const transfersTo = async (studytrack, startDate) => {
return await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: startDate,
},
targetcode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
}
const getGraduatedStats = async (studytrack, startDate, years) => {
const studyrights = await graduatedStudyRights(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ enddate }) => {
const graduationYear = enddate.getFullYear()
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
})
return { graphStats, tableStats }
}
const getStartedStats = async (studytrack, startDate, years) => {
const studyrights = await startedStudyrights(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ studystartdate }) => {
const startYear = studystartdate.getFullYear()
graphStats[indexOf(years, startYear)] += 1
tableStats[startYear] += 1
})
return { graphStats, tableStats }
}
const getCancelledStats = async (studytrack, startDate, years) => {
const studyrights = await cancelledStudyRights(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ canceldate }) => {
const cancelYear = canceldate.getFullYear()
graphStats[indexOf(years, cancelYear)] += 1
tableStats[cancelYear] += 1
})
return { graphStats, tableStats }
}
const getTransferredAwayStats = async (studytrack, startDate, years) => {
const studyrights = await transfersAway(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ transferdate }) => {
const transferYear = transferdate.getFullYear()
graphStats[indexOf(years, transferYear)] += 1
tableStats[transferYear] += 1
})
return { graphStats, tableStats }
}
const getTransferredToStats = async (studytrack, startDate, years) => {
const studyrights = await transfersTo(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ transferdate }) => {
const transferYear = transferdate.getFullYear()
graphStats[indexOf(years, transferYear)] += 1
tableStats[transferYear] += 1
})
return { graphStats, tableStats }
}
const getRegularCreditStats = async (studytrack, startDate, years) => {
const providercode = mapToProviders([studytrack])[0]
const studyrights = await getProgrammesStudents(studytrack)
const credits = await getCreditsForStudyProgramme(providercode, startDate)
let majors = getStatsBasis(years)
let nonMajors = getStatsBasis(years)
credits.forEach(({ student_studentnumber, attainment_date, credits }) => {
const studyright = studyrights.find(studyright => studyright.studentnumber == student_studentnumber)
const attainmentYear = attainment_date.getFullYear()
// Map all credits for the studyprogramme and
// divide them into major students' and nonmajors' credits by year
if (isMajorStudentCredit(studyright, attainment_date)) {
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
majors.tableStats[attainmentYear] += credits || 0
} else {
nonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
nonMajors.tableStats[attainmentYear] += credits || 0
}
})
return { majors, nonMajors }
}
const getTransferredCreditStats = async (studytrack, startDate, years) => {
const providercode = mapToProviders([studytrack])[0]
const credits = await getTransferredCredits(providercode, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
credits.forEach(({ attainment_date, credits }) => {
const attainmentYear = attainment_date.getFullYear()
graphStats[indexOf(years, attainmentYear)] += credits || 0
tableStats[attainmentYear] += credits || 0
})
return { graphStats, tableStats }
}
const getThesisStats = async (studytrack, startDate, years) => {
const credits = await getThesisCredits(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
credits.forEach(({ attainment_date }) => {
const attainmentYear = attainment_date.getFullYear()
graphStats[indexOf(years, attainmentYear)] += 1
tableStats[attainmentYear] += 1
})
return { graphStats, tableStats }
}
const getBasicStatsForStudytrack = async ({ studyprogramme, startDate }) => {
const years = getYearsArray(startDate.getFullYear())
const started = await getStartedStats(studyprogramme, startDate, years)
const graduated = await getGraduatedStats(studyprogramme, startDate, years)
const cancelled = await getCancelledStats(studyprogramme, startDate, years)
const transferredAway = await getTransferredAwayStats(studyprogramme, startDate, years)
const transferredTo = await getTransferredToStats(studyprogramme, startDate, years)
const reversedYears = getYearsArray(startDate.getFullYear()).reverse()
const tableStats = reversedYears.map(year => [
year,
started.tableStats[year],
graduated.tableStats[year],
cancelled.tableStats[year],
transferredAway.tableStats[year],
transferredTo.tableStats[year],
])
return {
id: studyprogramme,
years,
graphStats: [
{
name: 'Started',
data: started.graphStats,
},
{
name: 'Graduated',
data: graduated.graphStats,
},
{
name: 'Cancelled',
data: cancelled.graphStats,
},
{
name: 'Transferred away',
data: transferredAway.graphStats,
},
{
name: 'Transferred To',
data: transferredTo.graphStats,
},
],
tableStats,
}
}
const getCreditStatsForStudytrack = async ({ studyprogramme, startDate }) => {
const years = getYearsArray(startDate.getFullYear())
const { majors, nonMajors } = await getRegularCreditStats(studyprogramme, startDate, years)
const transferred = await getTransferredCreditStats(studyprogramme, startDate, years)
const reversedYears = getYearsArray(startDate.getFullYear()).reverse()
const tableStats = reversedYears.map(year => [
year,
majors.tableStats[year],
nonMajors.tableStats[year],
transferred.tableStats[year],
])
return {
id: studyprogramme,
years,
tableStats,
graphStats: [
{
name: 'Major students credits',
data: majors.graphStats,
},
{
name: 'Non-major students credits',
data: nonMajors.graphStats,
},
{
name: 'Transferred credits',
data: transferred.graphStats,
},
],
}
}
const getGraduationStatsForStudytrack = async ({ studyprogramme, startDate }) => {
const years = getYearsArray(startDate.getFullYear())
const thesis = await getThesisStats(studyprogramme, startDate, years)
const graduated = await getGraduatedStats(studyprogramme, startDate, years)
const reversedYears = getYearsArray(startDate.getFullYear()).reverse()
const tableStats = reversedYears.map(year => [year, graduated.tableStats[year], thesis.tableStats[year]])
return {
id: studyprogramme,
years,
tableStats,
graphStats: [
{
name: 'Graduated students',
data: graduated.graphStats,
},
{
name: 'Wrote thesis',
data: thesis.graphStats,
},
],
}
}
module.exports = {
getBasicStatsForStudytrack,
getCreditStatsForStudytrack,
getGraduationStatsForStudytrack,
}
@@ -494,6 +494,7 @@ const studentnumbersWithAllStudyrightElements = async (
if (!newestStudytrack) return false
return studyRights.includes(newestStudytrack.code)
})
const studentnumberlist = filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
// fetch students that have transferred out of the programme and filter out these studentnumbers
+3 -3
View File
@@ -15611,9 +15611,9 @@
}
},
"url-parse": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz",
"integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==",
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -3,6 +3,7 @@ import PopulationOverview from './populationOverview'
import CoolDataScience from './coolDataScience'
import CourseStatistics from './courseStatistics'
import CoursePopulation from './coursePopulation'
import Studyprogramme from './studyprogramme'
export default {
PopulationStatistics,
@@ -10,4 +11,5 @@ export default {
CoolDataScience,
CourseStatistics,
CoursePopulation,
Studyprogramme,
}
@@ -0,0 +1,12 @@
export default {
StudentsOfTheStudyprogramme: `
**Started**: Sisältää kyseisenä vuonna aloitetut opinto-oikeudet riippumatta siitä, minä vuonna kyseinen opiskeluoikeus on myönnetty. Opiskelija on myös voinut keskeyttää opintonsa tai siirtyä pois ohjelmasta.\n
**Cancelled**: Sisältää kyseisenä vuonna perutut opiskeluoikeudet riippumatta siitä, onko kyseinen opiskelija ikinä edes alkanut opiskelemaan ohjelmassa tai opiskelun aloitusvuodesta.
**Transferred away**: Sisältää kyseisenä vuonna pois ohjelmasta siirtyneet opinto-oikeudet. Sama opiskelija voi esiintyä tilastoissa useana vuonna. Kyseiset opiskelijat ovat saattaneet aloittaa ohjelmassa tai eivät koskaan ole aloittaneet ohjelmassa.
**Transferred to**: Sisältää kyseisenä vuonna ohjelmaan siirtyneet opinto-oikeudet.
HUOM! Ohjelmaan siirtyneiden määrä on erityisen suuri vuonna 2020, jolloin vanhoista koulutusohjelmista luovuttiin ja opiskelijat siirtyivät uusiin ohjelmiin.
`,
}
@@ -3,7 +3,7 @@ import { connect } from 'react-redux'
import { shape, arrayOf, string, number } from 'prop-types'
import { Table, Tab } from 'semantic-ui-react'
import { maxBy } from 'lodash'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import infotooltips from '../../../common/InfoToolTips'
import { getNewestProgramme, getTextIn } from '../../../common'
import useLanguage from '../../LanguagePicker/useLanguage'
@@ -18,7 +18,7 @@ import ProgressBar from '../ProgressBar'
import { getStudentToTargetCourseDateMap, getUserIsAdmin, getTextIn } from '../../common'
import { useProgress, useTitle } from '../../common/hooks'
import infotooltips from '../../common/InfoToolTips'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import useProgrammeFilter from '../FilterTray/filters/Programmes/useProgrammeFilter'
@@ -258,9 +258,9 @@ const CoursePopulation = ({
content: (
<div ref={studentRef}>
<PopulationStudents
variant="coursePopulation"
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
filteredStudents={studentData.students.filter(stu => selectedStudents.includes(stu.studentNumber))}
coursePopulation
language={language}
coursecode={codes}
/>
@@ -6,7 +6,7 @@ import { dataSeriesType, viewModeNames } from './Panes/util'
import PassRate from './Panes/passRate'
import Distribution from './Panes/distribution'
import Tables from './Panes/tables'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import { useTabs } from '../../../common/hooks'
import './resultTabs.css'
@@ -12,7 +12,7 @@ import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from './CustomPopulationCourses'
import CustomPopulationProgrammeDist from './CustomPopulationProgrammeDist'
import ProgressBar from '../ProgressBar'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
@@ -152,6 +152,7 @@ const CustomPopulation = ({
content: (
<div ref={studentRef}>
<PopulationStudents
variant="customPopulation"
language={language}
filteredStudents={filteredStudents}
dataExport={<UnihowDataExport />}
@@ -60,15 +60,18 @@ export const CustomPopulationFilters = () => (
</Filters>
)
export const StudyGuidanceGroupFilters = () => (
export const StudyGuidanceGroupFilters = ({ group }) => (
<Filters name="Study Guidance Group">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<AdmissionType />
<GraduatedFromProgramme />
{group?.tags?.studyProgramme && group?.tags?.year >= 2020 && (
<AdmissionType overrideCode={group.tags.studyProgramme} />
)}
{group?.tags?.studyProgramme && <GraduatedFromProgramme overrideCode={group.tags.studyProgramme} />}
<Tags />
<Courses />
<Date />
</Filters>
)
@@ -1,13 +1,14 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import { Form, Dropdown } from 'semantic-ui-react'
import { connect } from 'react-redux'
import FilterCard from './common/FilterCard'
import ClearFilterButton from './common/ClearFilterButton'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
const AdmissionType = ({ code }) => {
const AdmissionType = ({ overrideCode = '' }) => {
const { query } = useSelector(({ populations }) => populations)
const code = overrideCode || query?.studyRights.programme || ''
const { addFilter, removeFilter, activeFilters, withoutFilter } = useFilters()
const analytics = useAnalytics()
// Using undefined as default, since students can be filtered with null value
@@ -81,12 +82,4 @@ const AdmissionType = ({ code }) => {
)
}
AdmissionType.propTypes = {
code: PropTypes.string.isRequired,
}
const mapStateToProps = ({ populations }) => ({
code: populations.query ? populations.query.studyRights.programme : '',
})
export default connect(mapStateToProps)(AdmissionType)
export default AdmissionType
@@ -76,8 +76,17 @@ const AgeFilter = () => {
const active = Object.values(names).some(name => Object.keys(activeFilters).includes(name))
const infoText = null
return (
<FilterCard title="Age" active={active} className="total-age-filter" contextKey={contextKey} name="ageFilter">
<FilterCard
title="Age"
active={active}
className="total-age-filter"
contextKey={contextKey}
name="ageFilter"
info={infoText}
>
<Form>
<div className="card-content">
{Object.keys(currentValue).map(key => (
@@ -41,6 +41,11 @@ export default () => {
const clearButtonDisabled = startDate === label && endDate === label
const infoText = {
label: 'Selected date range only.',
short: 'Include course credits from the selected date range only. Does not filter out students.',
}
return (
<FilterCard
title="Date of Course Credits"
@@ -48,10 +53,8 @@ export default () => {
footer={<ClearFilterButton disabled={clearButtonDisabled} onClick={reset} name={name} />}
active={!clearButtonDisabled}
name={name}
info={infoText}
>
<div className="description-text">
Include course credits from the selected date range only. Does not filter out students.
</div>
<div className="card-content" style={{ marginTop: '0.5rem' }}>
<Form>
<Form.Field>
@@ -67,3 +70,6 @@ export default () => {
</FilterCard>
)
}
// <div className="description-text"></div>
// Include course credits from the selected date range only. Does not filter out students.
@@ -59,6 +59,11 @@ const EnrollmentStatus = ({ allSemesters, language }) => {
setSemesters([])
}
const infoText = {
label: 'Filter students present or absent',
short: 'Filter students based on if they are present or absent in certain semester/semesters.',
}
return (
<FilterCard
title="Enrollment Status"
@@ -66,6 +71,7 @@ const EnrollmentStatus = ({ allSemesters, language }) => {
active={active}
footer={<ClearFilterButton disabled={!status && !semesters.length} onClick={clear} name={name} />}
name={name}
info={infoText}
>
<div className="card-content">
<Form>
@@ -1,13 +1,14 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Form, Radio } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { useSelector } from 'react-redux'
import FilterCard from './common/FilterCard'
import ClearFilterButton from './common/ClearFilterButton'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
const GraduatedFromProgramme = ({ code }) => {
const GraduatedFromProgramme = ({ overrideCode = '' }) => {
const { query } = useSelector(({ populations }) => populations)
const code = overrideCode || query?.studyRights.programme || ''
const { addFilter, removeFilter } = useFilters()
const analytics = useAnalytics()
const [value, setValue] = useState(null)
@@ -86,12 +87,4 @@ const GraduatedFromProgramme = ({ code }) => {
)
}
GraduatedFromProgramme.propTypes = {
code: PropTypes.string.isRequired,
}
const mapStateToProps = ({ populations }) => ({
code: populations.query ? populations.query.studyRights.programme : '',
})
export default connect(mapStateToProps)(GraduatedFromProgramme)
export default GraduatedFromProgramme
@@ -43,6 +43,8 @@ export default () => {
value: tag,
}))
if (Object.entries(countsByTag).length === 0) return null
return (
<FilterCard
title="Tags"
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'
import { Form, Radio } from 'semantic-ui-react'
import FilterCard from './common/FilterCard'
import ClearFilterButton from './common/ClearFilterButton'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
import { usePrevious } from '../../../common/hooks'
@@ -38,13 +37,20 @@ export default () => {
const toggle = buttonValue => () => setValue(prev => (prev === buttonValue ? null : buttonValue))
const infoText = {
label: 'Include and exclude students from this program',
short: 'Possibility to exclude or include students who have transferred into this program',
}
const rightMargin = { margin: '0 0.5rem 0' }
return (
<FilterCard
title="Transfer Status"
contextKey={name}
active={active}
footer={<ClearFilterButton disabled={!active} onClick={() => setValue(null)} name={name} />}
className="total-transfer-filter"
name={name}
info={infoText}
>
<Form>
<div className="card-content">
@@ -55,6 +61,7 @@ export default () => {
checked={value === null}
onChange={toggle(null)}
data-cy={`${name}-all`}
style={rightMargin}
/>
<Radio
label="Transferred"
@@ -62,7 +69,7 @@ export default () => {
checked={value === 1}
onChange={toggle(1)}
data-cy={`${name}-have`}
style={{ margin: '0.5rem 0' }}
style={rightMargin}
/>
<Radio
label="Not Transferred"
@@ -70,6 +77,7 @@ export default () => {
checked={value === 0}
onChange={toggle(0)}
data-cy={`${name}-havenot`}
style={rightMargin}
/>
</Form.Field>
</div>
@@ -1,10 +1,32 @@
import React from 'react'
import { Icon, Header } from 'semantic-ui-react'
import WithHelpTooltip from '../../../Info/InfoWithHelpTooltip'
import useFilterTray from '../../useFilterTray'
const FilterCard = ({ title, children, contextKey, name, active }) => {
const FilterCard = ({ title, children, contextKey, name, active, info }) => {
const [open, , toggleOpen] = useFilterTray(contextKey)
const renderWithHelp = info => {
if (info) {
return (
<WithHelpTooltip tooltip={info} data-cy="tooltip-div">
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
<Header size="tiny" style={{ marginTop: '0', marginBottom: 0 }}>
{title}
</Header>
</WithHelpTooltip>
)
}
return (
<>
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
<Header size="tiny" style={{ marginTop: '0', marginBottom: 0 }}>
{title}
</Header>
</>
)
}
return (
<div>
<div
@@ -12,10 +34,7 @@ const FilterCard = ({ title, children, contextKey, name, active }) => {
onClick={active ? undefined : toggleOpen}
data-cy={`${name}-header`}
>
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
<Header size="tiny" style={{ marginTop: '0' }}>
{title}
</Header>
{renderWithHelp(info)}
</div>
{(active || open) && children}
</div>
@@ -15,7 +15,7 @@ const FilterTray = ({ children, filterSet, visible = true }) => {
return (
<>
<div style={{ display: 'inline-flex', alignItems: 'flex-start', width: '100%' }}>
<Segment style={{ marginLeft: '0.5rem', width: '12rem', position: 'sticky', top: '10px' }}>
<Segment style={{ marginLeft: '0.5rem', width: '16rem', position: 'sticky', top: '10px' }}>
<Header size="small" style={{ textAlign: 'center' }} data-cy="filtered-students">
Filter students
</Header>
@@ -53,11 +53,11 @@ const FrontPage = () => {
useTitle()
const showItems = {
populations: userRoles.includes('admin') || rights.length !== 0,
studyProgramme: userRoles.includes('admin') || rights.length !== 0,
students: userRoles.includes('admin') || rights.length !== 0,
populations: userRoles.includes('admin') || rights.length > 0,
studyProgramme: userRoles.includes('admin') || rights.length > 0,
students: checkUserAccess(['studyGuidanceGroups', 'admin'], userRoles) || rights.length > 0,
courseStatistics: checkUserAccess(['courseStatistics', 'admin'], userRoles) || rights.length > 0,
teachers: userRoles.includes('admin') || userRoles.includes('teachers'),
teachers: checkUserAccess(['teachers', 'admin'], userRoles),
trends: true,
feedback: true,
}
@@ -0,0 +1,60 @@
import React, { useState, useRef } from 'react'
import { Popup, Icon } from 'semantic-ui-react'
import _ from 'lodash'
const InfoWithHelpTooltip = ({ children, tooltip, ...rest }) => {
const popupContext = useRef()
const [popupOpen, setPopupOpen] = useState(false)
const [detailsOpen, setDetailsOPen] = useState(false)
const trigger = (
<div style={{ display: 'flex' }} data-cy="tooltip-trigger">
{children}
<div ref={popupContext} style={{ display: 'inline-block', cursor: 'help' }} data-cy="popup-context">
<Icon
onClick={() => setPopupOpen(!popupOpen)}
ref={popupContext}
style={{ marginLeft: '0.5em', color: '#888' }}
name="question circle outline"
/>
</div>
</div>
)
const popupProps = _.defaults(rest, {
position: 'right center',
})
return (
<>
<Popup
wide={detailsOpen}
hoverable
size="tiny"
open={popupOpen}
onOpen={() => setPopupOpen(true)}
onClose={() => {
setPopupOpen(false)
setDetailsOPen(false)
}}
trigger={trigger}
context={popupContext}
{...popupProps}
on="hover"
mouseEnterDelay={2000}
>
{!detailsOpen && (
<>
<div>{tooltip.label}</div>
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={() => setDetailsOPen(!detailsOpen)}>
Lue lisää...
</span>
</>
)}
{detailsOpen && <span>{tooltip.short}</span>}
</Popup>
</>
)
}
export default InfoWithHelpTooltip
@@ -42,7 +42,7 @@ const NavigationBar = props => {
const visibleNavigationItems = {}
Object.keys(allNavigationItems).forEach(key => {
if (key === 'populations' || key === 'students') {
if (!userRoles.includes('admin') && rights.length === 0) {
if (!checkUserAccess(['admin', 'studyGuidanceGroups'], userRoles) && rights.length === 0) {
return
}
} else if (key === 'courseStatistics') {
@@ -5,7 +5,7 @@ import uuidv4 from 'uuid/v4'
import SegmentDimmer from '../SegmentDimmer'
import PopulationCourseStats from '../PopulationCourseStats'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import FilterDegreeCoursesModal from './FilterDegreeCoursesModal'
import useCourseFilter from '../FilterTray/filters/Courses/useCourseFilter'
import infotooltips from '../../common/InfoToolTips'
@@ -1,7 +1,7 @@
import React, { useCallback } from 'react'
import { Tab } from 'semantic-ui-react'
import { useTabChangeAnalytics } from '../../../common/hooks'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import StatisticsTab from './StatisticsTab'
import CreditsGainedTab from './CreditsGainedTab'
import infotooltips from '../../../common/InfoToolTips'
@@ -7,7 +7,7 @@ import { useLocalStorage } from '../../common/hooks'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import PopulationCourses from '../PopulationCourses'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import CreditGainStats from './CreditGainStats'
import AgeStats from './AgeStats'
import useFilters from '../FilterTray/useFilters'
@@ -169,7 +169,12 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, dataExport }
content: {
content: (
<div ref={studentTableRef}>
<PopulationStudents language={language} filteredStudents={filteredStudents} dataExport={dataExport} />
<PopulationStudents
variant="population"
language={language}
filteredStudents={filteredStudents}
dataExport={dataExport}
/>
</div>
),
},
@@ -12,7 +12,7 @@ import TSA from '../../common/tsa'
import './populationSearch.css'
import infotooltips from '../../common/InfoToolTips'
import { getTextIn } from '../../common'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import FilterActiveNote from './FilterActiveNote'
const PopulationsQueryTSA = ({ programmeCode, unitData }) => {
@@ -5,7 +5,7 @@ import { Link } from 'react-router-dom'
import PopulationSearchForm from './PopulationSearchForm'
import PopulationSearchHistory from './PopulationSearchHistory'
import ProgressBar from '../ProgressBar'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import { useProgress } from '../../common/hooks'
import infotoolTips from '../../common/InfoToolTips'
@@ -1,128 +0,0 @@
import React, { useState, useEffect } 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'
import './populationStudents.css'
const verticalTitle = title => {
// https://stackoverflow.com/a/41396815
return <div className="tableVerticalTitle">{title}</div>
}
const DIRECTIONS = {
ASC: 'ascending',
DESC: 'descending',
}
const FlippedCourseTable = ({
defaultdescending,
defaultsortkey,
columns,
data,
tableProps,
getRowKey,
chunkifyBy,
}) => {
const [direction, setDirection] = useState(defaultdescending ? DIRECTIONS.DESC : DIRECTIONS.ASC)
const [selected, setSelected] = useState(defaultsortkey == null ? columns[0].key : defaultsortkey)
const [sortedRows, setSortedRows] = useState(data)
const chunkedData = useChunk(data, chunkifyBy)
const handleSort = column => {
if (selected === column) {
setDirection(direction === DIRECTIONS.ASC ? DIRECTIONS.DESC : DIRECTIONS.ASC)
} else {
setSelected(column)
setDirection(DIRECTIONS.DESC)
}
}
useEffect(() => {
const column = columns.find(c => c.key === selected)
if (!column) {
setSortedRows(chunkedData)
return
}
const { getRowVal } = column
const sorted = sortBy(chunkedData, [getRowVal])
setSortedRows(direction === DIRECTIONS.ASC ? sorted : sorted.reverse())
}, [selected])
return (
<Table sortable {...tableProps} className="fixed-header" striped>
<Table.Header>
<Table.Row>
<Table.HeaderCell key="general" content={<b>Students:</b>} colSpan="2" style={{ textAlign: 'right' }} />
{sortedRows
.filter(row => row.studentNumber)
.map(row => (
<Table.HeaderCell key={getRowKey(row)} content={verticalTitle(row.studentNumber)} />
))}
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Course</Table.Cell>
<Table.Cell>Total</Table.Cell>
{sortedRows
.filter(row => row.studentNumber)
.map(row => (
<Table.Cell key={getRowKey(row)} content={columns[2].getRowVal(row)} />
))}
</Table.Row>
{columns
.filter(col => col.cellProps)
.filter(col => data[0][col.code] && data[0][col.code] > 0)
.map(col => (
<Table.Row key={col.key}>
<Table.Cell key="name" content={col.title} onClick={() => handleSort(col.key)} />
<Table.Cell key="totals" content={data[0][col.code]} />
{sortedRows
.filter(row => row.studentNumber)
.map(row => (
<Table.Cell key={getRowKey(row)} content={col.getRowContent(row)} />
))}
</Table.Row>
))}
</Table.Body>
</Table>
)
}
FlippedCourseTable.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,
}
FlippedCourseTable.defaultProps = {
tableProps: undefined,
getRowProps: undefined,
defaultdescending: false,
defaultsortkey: null,
collapsingHeaders: false,
showNames: undefined,
chunkifyBy: undefined,
}
export default FlippedCourseTable
@@ -1,207 +0,0 @@
import React, { useState, useEffect } 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'
import './populationStudents.css'
const verticalTitle = title => {
// https://stackoverflow.com/a/41396815
return <div className="tableVerticalTitle">{title}</div>
}
const intoCollapsing = column => ({
title: verticalTitle(column.headerProps.title),
headerProps: { ...column.headerProps, colSpan: 1, rowSpan: 2 },
key: column.key,
collapsed: true,
parent: column.parent,
})
const initialCollapsing = columns => {
const coll = {}
columns.forEach(column => {
if (!column.parent || column.key === 'general') return
coll[column.headerProps.title] = intoCollapsing(column)
})
return coll
}
const DIRECTIONS = {
ASC: 'ascending',
DESC: 'descending',
}
const PopulationCourseTable = ({
defaultdescending,
defaultsortkey,
columns,
data,
tableProps,
getRowProps,
getRowKey,
collapsingHeaders,
chunkifyBy,
}) => {
const [direction, setDirection] = useState(defaultdescending ? DIRECTIONS.DESC : DIRECTIONS.ASC)
const [selected, setSelected] = useState(defaultsortkey == null ? columns[0].key : defaultsortkey)
const [collapsed, setCollapsed] = useState(initialCollapsing(columns))
const [columnsWithCollapsedHeaders, setColumnsWithCollapsedHeaders] = useState(columns)
const [sortedRows, setSortedRows] = useState(data)
const chunkedData = useChunk(data, chunkifyBy)
const handleSort = column => () => {
if (selected === column) {
setDirection(direction === DIRECTIONS.ASC ? DIRECTIONS.DESC : DIRECTIONS.ASC)
} else {
setSelected(column)
setDirection(DIRECTIONS.DESC)
}
}
const handleCollapse = column => () => {
const { title } = column.headerProps
if (collapsed[title]) {
const { [title]: _, ...rest } = collapsed
setCollapsed(rest)
} else {
setCollapsed({ ...collapsed, [title]: column })
}
}
useEffect(() => {
const column = columns.find(c => c.key === selected)
if (!column) {
return chunkedData
}
const { getRowVal } = column
const sorted = sortBy(chunkedData, [getRowVal])
return setSortedRows(direction === DIRECTIONS.ASC ? sorted : sorted.reverse())
}, [columns, columnsWithCollapsedHeaders])
useEffect(() => {
setColumnsWithCollapsedHeaders(
collapsingHeaders
? columns
.filter(c => c.headerProps && !collapsed[c.headerProps.title] && !c.collapsed)
.sort((a, b) => a.headerProps.ordernumber - b.headerProps.ordernumber)
.concat(Object.values(collapsed).sort((a, b) => a.headerProps.ordernumber - b.headerProps.ordernumber))
: columns
)
}, [collapsed])
const sortDirection = name => (selected === name ? direction : null)
return (
<Table sortable {...tableProps} className="fixed-header" striped>
<Table.Header>
{columnsWithCollapsedHeaders.length > 0 && (
<Table.Row>
{columnsWithCollapsedHeaders
.filter(c => !c.child && !(c.title == null))
.map(c => (
<Table.HeaderCell
key={c.key}
content={c.title}
onClick={
c.parent && collapsingHeaders && c.key !== 'general'
? handleCollapse(intoCollapsing(c))
: handleSort(c.key)
}
sorted={c.parent ? undefined : sortDirection(c.key)}
{...c.headerProps}
/>
))}
</Table.Row>
)}
<Table.Row>
{columns
.filter(c => c.child && !(c.title == null) && !collapsed[c.childOf])
.map(c => (
<Table.HeaderCell
key={c.key}
content={c.title}
onClick={handleSort(c.key)}
sorted={sortDirection(c.key)}
{...c.headerProps}
/>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{sortedRows.map(row => (
<Table.Row key={getRowKey(row)} {...(getRowProps && getRowProps(row))}>
{columns
.filter(c => !c.parent)
.map(c => {
if (collapsed[c.childOf]) {
return null
}
if (c.key.includes('programmecode') || c.key.includes('programmename')) {
return (
<Table.Cell
width={c.key === 'programmecode' ? '1' : '16'}
key={c.key}
content={c.getRowContent ? c.getRowContent(row) : c.getRowVal(row)}
{...c.cellProps}
{...(c.getCellProps && c.getCellProps(row))}
/>
)
}
return (
<Table.Cell
key={c.key}
content={c.getRowContent ? c.getRowContent(row) : c.getRowVal(row)}
{...c.cellProps}
{...(c.getCellProps && c.getCellProps(row))}
/>
)
})}
{Object.values(collapsed).map(e => (
<Table.Cell className="tableCell" key={e.key} warning />
))}
</Table.Row>
))}
</Table.Body>
</Table>
)
}
PopulationCourseTable.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,
}
PopulationCourseTable.defaultProps = {
tableProps: undefined,
getRowProps: undefined,
defaultdescending: false,
defaultsortkey: null,
collapsingHeaders: false,
showNames: undefined,
chunkifyBy: undefined,
}
export default PopulationCourseTable
@@ -1,54 +1,33 @@
import React, { useState } from 'react'
import { shape, bool, string, arrayOf } from 'prop-types'
import { Item, Icon, Popup } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { flatten } from 'lodash'
import _ from 'lodash'
import moment from 'moment'
import { connect } from 'react-redux'
import SortableTable from '../../SortableTable'
import {
getStudentTotalCredits,
getTextIn,
getNewestProgramme,
reformatDate,
copyToClipboard,
getUserIsAdmin,
} from '../../../common'
import { useSelector } from 'react-redux'
import { useIsAdmin } from 'common/hooks'
import SortableTable from 'components/SortableTable'
import { getStudentTotalCredits, getTextIn, getNewestProgramme, reformatDate, copyToClipboard } from 'common'
import { useGetStudyGuidanceGroupPopulationQuery } from 'redux/studyGuidanceGroups'
import { PRIORITYCODE_TEXTS } from '../../../constants'
import sendEvent from '../../../common/sendEvent'
import useFilters from '../../FilterTray/useFilters'
import useLanguage from '../../LanguagePicker/useLanguage'
// TODO: Refactoring in process, contains lot of duplicate code.
const GeneralTab = ({
showNames,
coursePopulation,
customPopulation,
populationStatistics,
queryStudyrights,
isAdmin,
studentToTargetCourseDateMap,
coursecode,
queryYear,
}) => {
const GeneralTab = ({ group, populations, columnKeysToInclude, studentToTargetCourseDateMap, coursecode }) => {
const { language } = useLanguage()
const { filteredStudents } = useFilters()
const [popupStates, setPopupStates] = useState({})
const sendAnalytics = sendEvent.populationStudents
if (!populationStatistics) return null
const { data: populationStatistics, query } = populations
if (!populationStatistics || !populationStatistics.elementdetails) return null
// TODO: Refactor this away:
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
// TODO: This fixes crashing upon using back button. Find the root cause and fix it.
if (!populationStatistics.elementdetails) {
return null
}
const students = Object.fromEntries(filteredStudents.map(stu => [stu.studentNumber, stu]))
const queryStudyrights = query ? Object.values(query.studyRights) : []
const cleanedQueryStudyrights = queryStudyrights.filter(sr => !!sr)
const programmeCode = cleanedQueryStudyrights[0] || group?.tags?.studyProgramme
const popupTimeoutLength = 1000
let timeout = null
@@ -137,38 +116,32 @@ const GeneralTab = ({
return null
}
const studentToStudyrightStartMap = !(customPopulation || coursePopulation)
? selectedStudents.reduce((res, sn) => {
const targetStudyright = flatten(
students[sn].studyrights.reduce((acc, curr) => {
acc.push(curr.studyright_elements)
return acc
}, [])
).filter(e => e.code === cleanedQueryStudyrights[0])
res[sn] = targetStudyright[0] ? targetStudyright[0].startdate : null
return res
}, {})
: null
const studentToStudyrightStartMap = selectedStudents.reduce((res, sn) => {
const targetStudyright = _.flatten(
students[sn].studyrights.reduce((acc, curr) => {
acc.push(curr.studyright_elements)
return acc
}, [])
).filter(e => e.code === programmeCode)
res[sn] = targetStudyright[0] ? targetStudyright[0].startdate : null
return res
}, {})
const studentToStudyrightActualStartMap = !(customPopulation || coursePopulation)
? selectedStudents.reduce((res, sn) => {
const targetStudyright = students[sn].studyrights.find(studyright =>
studyright.studyright_elements.some(e => e.code === cleanedQueryStudyrights[0])
)
res[sn] = targetStudyright ? targetStudyright.studystartdate : null
return res
}, {})
: null
const studentToStudyrightActualStartMap = selectedStudents.reduce((res, sn) => {
const targetStudyright = students[sn].studyrights.find(studyright =>
studyright.studyright_elements.some(e => e.code === programmeCode)
)
res[sn] = targetStudyright ? targetStudyright.studystartdate : null
return res
}, {})
const studentToStudyrightEndMap = !(customPopulation || coursePopulation)
? selectedStudents.reduce((res, sn) => {
const targetStudyright = students[sn].studyrights.find(studyright =>
studyright.studyright_elements.some(e => e.code === cleanedQueryStudyrights[0])
)
res[sn] = targetStudyright && targetStudyright.graduated === 1 ? targetStudyright.enddate : null
return res
}, {})
: null
const studentToStudyrightEndMap = selectedStudents.reduce((res, sn) => {
const targetStudyright = students[sn].studyrights.find(studyright =>
studyright.studyright_elements.some(e => e.code === programmeCode)
)
res[sn] = targetStudyright && targetStudyright.graduated === 1 ? targetStudyright.enddate : null
return res
}, {})
const getActualStartDate = studentNumber => {
const studyRightStart = studentToStudyrightStartMap[studentNumber]
@@ -190,30 +163,30 @@ const GeneralTab = ({
sendAnalytics('Copy all student emails to clipboard', 'Copy all student emails to clipboard')
}
const containsStudyTracks = () => {
const allStudyrights = selectedStudents.map(sn => students[sn]).map(st => st.studyrights)
return allStudyrights
.map(
studyrights =>
studyrightCodes(studyrights, 'studyright_elements').reduce((acc, elemArr) => {
elemArr
.filter(el => populationStatistics.elementdetails.data[el.code].type === 30)
.forEach(el => acc.push(getTextIn(populationStatistics.elementdetails.data[el.code].name, language)))
return acc
}, []).length > 0
)
.some(el => el === true)
}
const columns = []
if (showNames) {
columns.push(
{ key: 'lastname', title: 'last name', getRowVal: s => s.lastname },
{ key: 'firstname', title: 'given names', getRowVal: s => s.firstnames }
// Filters to check data for whether to show certain columns
const containsStudyTracks = selectedStudents
.map(sn => students[sn])
.map(st => st.studyrights)
.map(
studyrights =>
studyrightCodes(studyrights, 'studyright_elements').reduce((acc, elemArr) => {
elemArr
.filter(el => populationStatistics.elementdetails.data[el.code].type === 30)
.forEach(el => acc.push(getTextIn(populationStatistics.elementdetails.data[el.code].name, language)))
return acc
}, []).length > 0
)
}
columns.push(
{
.some(el => el === true)
const containsOption = cleanedQueryStudyrights.some(code => code.startsWith('MH') || code.startsWith('KH'))
const shouldShowAdmissionType = parseInt(query?.year, 10) >= 2020 || parseInt(group?.tags?.year, 10) >= 2020
// All columns components user is able to use
const columnsAvailable = {
lastname: { key: 'lastname', title: 'last name', getRowVal: s => s.lastname },
firstname: { key: 'firstname', title: 'given names', getRowVal: s => s.firstnames },
studentnumber: {
key: 'studentnumber',
title: 'Student Number',
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
@@ -224,7 +197,7 @@ const GeneralTab = ({
),
headerProps: { colSpan: 2 },
},
{
icon: {
key: 'icon',
getRowVal: s =>
!s.obfuscated && (
@@ -239,22 +212,28 @@ const GeneralTab = ({
</Item>
),
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
}
)
if (!(coursePopulation || customPopulation)) {
columns.push({
key: 'credits since start',
},
creditsSinceStart: {
key: 'creditsSinceStart',
title: 'credits since start of studyright',
getRowVal: s => {
const credits = getStudentTotalCredits(s)
return credits
},
})
}
if (coursecode.length > 0) {
columns.push({
key: 'grade for single course',
},
creditsSinceStartByYear: {
key: 'creditsSinceStartByYear',
title: `credits since ${group?.tags?.year}`,
getRowVal: s => {
const credits = getStudentTotalCredits({
...s,
courses: s.courses.filter(c => new Date(c.date) > new Date(group?.tags?.year, 7, 1)),
})
return credits
},
},
gradeForSingleCourse: {
key: 'gradeForSingleCourse',
title: 'Grade',
getRowVal: s => {
const grade = s.courses.filter(c => coursecode.includes(c.course_code))
@@ -264,171 +243,148 @@ const GeneralTab = ({
}
return ''
},
})
}
columns.push({
key: 'all credits',
title: 'All Credits',
getRowVal: s => s.credits,
})
if (!(coursePopulation || customPopulation)) {
columns.push({
key: 'transferred from',
},
allCredits: {
key: 'allCredits',
title: 'All Credits',
getRowVal: s => s.credits,
},
transferredFrom: {
key: 'transferredFrom',
title: 'Transferred From',
getRowVal: s => (s.transferredStudyright ? transferFrom(s) : ''),
})
}
if (containsStudyTracks() && !(coursePopulation || customPopulation)) {
columns.push({
key: 'studytrack',
},
studyTrack: containsStudyTracks && {
key: 'studyTrack',
title: 'Study Track',
getRowVal: s => studytrack(s.studyrights).map(st => st.name)[0],
})
}
if (isAdmin && !(coursePopulation || customPopulation)) {
columns.push(
{
key: 'priority',
title: 'priority',
getRowVal: s => priorityText(s.studyrights),
},
{
key: 'extent',
title: 'extent',
getRowVal: s => extentCodes(s.studyrights),
}
)
}
columns.push({
key: 'tags',
title: 'Tags',
getRowVal: s => (!s.obfuscated ? tags(s.tags) : ''),
})
if (!(coursePopulation || customPopulation)) {
if (isAdmin) {
columns.push({
key: 'studystartdate',
title: 'start of studyright',
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
})
}
// potentially will replace the 'start of studyright' column - both present for validation
columns.push({
key: 'studystartdateactual',
},
priority: {
key: 'priority',
title: 'priority',
getRowVal: s => priorityText(s.studyrights),
},
extent: {
key: 'extent',
title: 'extent',
getRowVal: s => extentCodes(s.studyrights),
},
tags: {
key: 'tags',
title: 'Tags',
getRowVal: s => (!s.obfuscated ? tags(s.tags) : ''),
},
studyStartDate: {
key: 'studyStartDate',
title: 'start of studyright',
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
},
studyStartDateActual: {
key: 'studyStartDateActual',
title: 'started in studyright',
getRowVal: s => new Date(getActualStartDate(s.studentNumber)).getTime(),
getRowContent: s => reformatDate(getActualStartDate(s.studentNumber), 'YYYY-MM-DD'),
})
columns.push({
key: 'enddate',
},
endDate: {
key: 'endDate',
title: 'graduation date',
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]).getTime(),
getRowContent: s =>
studentToStudyrightEndMap[s.studentNumber]
? reformatDate(studentToStudyrightEndMap[s.studentNumber], 'YYYY-MM-DD')
: '',
})
}
if (!(coursePopulation || customPopulation) && parseInt(queryYear, 10) >= 2020) {
const code = queryStudyrights[0]
columns.push({
key: 'admission type',
},
admissionType: shouldShowAdmissionType && {
key: 'admissionType',
title: 'admission type',
getRowVal: s => {
const studyright = s.studyrights.find(sr => sr.studyright_elements.some(e => e.code === code))
const studyright = s.studyrights.find(sr => sr.studyright_elements.some(e => e.code === programmeCode))
return studyright && studyright.admission_type ? studyright.admission_type : 'Ei valintatapaa'
},
})
}
if (coursePopulation || customPopulation) {
columns.push(
{
key: 'programme',
title: 'Study Programme',
getRowVal: s => getTextIn(mainProgramme(s.studyrights, s.studentNumber), language) || 'No programme',
},
{
key: 'startyear',
title: 'Start Year at Uni',
getRowVal: s => (!s.obfuscated ? reformatDate(s.started, 'YYYY') : ''),
}
)
}
if (cleanedQueryStudyrights.some(code => code.startsWith('MH') || code.startsWith('KH')))
columns.push({
},
programme: {
key: 'programme',
title: 'Study Programme',
getRowVal: s => getTextIn(mainProgramme(s.studyrights, s.studentNumber), language) || 'No programme',
},
startYear: {
key: 'startYear',
title: 'Start Year at Uni',
getRowVal: s => (!s.obfuscated ? reformatDate(s.started, 'YYYY') : ''),
},
option: containsOption && {
key: 'option',
title: cleanedQueryStudyrights.some(code => code.startsWith('MH')) ? 'Bachelor' : 'Master',
getRowVal: s => (s.option ? getTextIn(s.option.name, language) : ''),
})
if (showNames) {
columns.push(
{
key: 'email',
title: (
<>
email
<Popup
trigger={<Icon link name="copy" onClick={copyToClipboardAll} style={{ float: 'right' }} />}
content="Copied email list!"
on="click"
open={popupStates['0']}
onClose={() => handlePopupClose('0')}
onOpen={() => handlePopupOpen('0')}
position="top right"
/>
</>
),
getRowVal: s => s.email,
headerProps: { colSpan: 2 },
},
{
key: 'copy email',
getRowVal: s =>
s.email && !s.obfuscated ? (
<Popup
trigger={
<Icon
link
name="copy outline"
onClick={() => {
copyToClipboard(s.email)
sendAnalytics("Copy student's email to clipboard", "Copy student's email to clipboard")
}}
style={{ float: 'right' }}
/>
}
content="Email copied!"
on="click"
open={popupStates[s.studentNumber]}
onClose={() => handlePopupClose(s.studentNumber)}
onOpen={() => handlePopupOpen(s.studentNumber)}
position="top right"
/>
) : null,
headerProps: { onClick: null, sorted: null },
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
}
)
}
if (isAdmin) {
columns.push({
},
email: {
key: 'email',
title: (
<>
email
<Popup
trigger={<Icon link name="copy" onClick={copyToClipboardAll} style={{ float: 'right' }} />}
content="Copied email list!"
on="click"
open={popupStates['0']}
onClose={() => handlePopupClose('0')}
onOpen={() => handlePopupOpen('0')}
position="top right"
/>
</>
),
getRowVal: s => s.email,
headerProps: { colSpan: 2 },
},
copyEmail: {
key: 'copyEmail',
getRowVal: s =>
s.email && !s.obfuscated ? (
<Popup
trigger={
<Icon
link
name="copy outline"
onClick={() => {
copyToClipboard(s.email)
sendAnalytics("Copy student's email to clipboard", "Copy student's email to clipboard")
}}
style={{ float: 'right' }}
/>
}
content="Email copied!"
on="click"
open={popupStates[s.studentNumber]}
onClose={() => handlePopupClose(s.studentNumber)}
onOpen={() => handlePopupOpen(s.studentNumber)}
position="top right"
/>
) : null,
headerProps: { onClick: null, sorted: null },
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
},
updatedAt: {
key: 'updatedAt',
title: 'Last Updated At',
getRowVal: s => reformatDate(s.updatedAt, 'YYYY-MM-DD HH:mm:ss'),
})
},
}
// Columns are shown in order they're declared above. JS guarantees this order of keys
// to stay for non-integer keys
const orderOfColumns = Object.keys(columnsAvailable).reduce(
(acc, curr, ind) => ({
...acc,
[curr]: ind,
}),
{}
)
const columns = _.chain(columnKeysToInclude)
.map(colKey => columnsAvailable[colKey])
.sortBy(col => orderOfColumns[col.key])
.filter(col => !!col)
.value()
return (
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
@@ -448,36 +404,69 @@ const GeneralTab = ({
)
}
GeneralTab.defaultProps = {
studentToTargetCourseDateMap: null,
customPopulation: false,
coursePopulation: false,
// study guidance groups -feature uses different population + rtk query, so it needs to
// be rendered differently. TODO: should rafactor this, maybe with using allStudents
// from useFilters and making sure that it contains same students than the population
// backend returns with population query below (so caching works)
const StudyGuidanceGroupGeneralTabContainer = ({ group, ...props }) => {
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const populations = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
return <GeneralTab populations={populations} group={group} {...props} />
}
GeneralTab.propTypes = {
showNames: bool.isRequired,
coursePopulation: bool,
customPopulation: bool,
populationStatistics: shape({}).isRequired,
queryStudyrights: arrayOf(string).isRequired,
isAdmin: bool.isRequired,
studentToTargetCourseDateMap: shape({}),
}
const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) => {
const populations = useSelector(({ populations }) => populations)
const { namesVisible } = useSelector(({ settings }) => settings)
const isAdmin = useIsAdmin()
const mapStateToProps = state => {
const {
populations,
auth: {
token: { roles },
},
} = state
return {
isAdmin: getUserIsAdmin(roles),
populationStatistics: populations.data,
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
queryYear: populations?.query?.year,
const getStudyGuidanceGroupColumns = () => {
const cols = ['programme', 'startYear']
if (studyGuidanceGroup?.tags?.year) cols.push('creditsSinceStartByYear')
if (studyGuidanceGroup?.tags?.studyProgramme) cols.push('studyStartDate', 'studyStartDateActual', 'endDate')
if (studyGuidanceGroup?.tags?.studyProgramme && studyGuidanceGroup?.tags?.year) {
cols.push('admissionType')
}
return cols
}
const columnsByVariant = {
customPopulation: ['programme', 'startYear'],
coursePopulation: ['gradeForSingleCourse', 'programme', 'startYear'],
population: [
'creditsSinceStart',
'transferredFrom',
'priority',
'extent',
'studyStartDate',
'studyStartDateActual',
'endDate',
'studyTrack',
'admissionType',
],
studyGuidanceGroupPopulation: getStudyGuidanceGroupColumns(),
}
const baseColumns = ['studentnumber', 'icon', 'allCredits', 'tags', 'updatedAt', 'option']
const nameColumnsToAdd = namesVisible ? ['email', 'copyEmail', 'lastname', 'firstname'] : []
const adminColumnsToFilter = isAdmin ? [] : ['priority', 'extent', 'studyStartDate', 'updatedAt']
const columnKeysToInclude = _.chain(baseColumns)
.union(columnsByVariant[variant])
.union(nameColumnsToAdd)
.difference(adminColumnsToFilter)
.value()
if (variant === 'studyGuidanceGroupPopulation') {
return (
<StudyGuidanceGroupGeneralTabContainer
group={studyGuidanceGroup}
columnKeysToInclude={columnKeysToInclude}
{...props}
/>
)
}
return <GeneralTab populations={populations} columnKeysToInclude={columnKeysToInclude} {...props} />
}
export default connect(mapStateToProps)(GeneralTab)
export default GeneralTabContainer
@@ -13,7 +13,7 @@ import { getStudentTagsByStudytrackAction } from '../../redux/tagstudent'
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
import '../PopulationCourseStats/populationCourseStats.css'
import SortableTable from '../SortableTable'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import CheckStudentList from './CheckStudentList'
import TagPopulation from '../TagPopulation'
import TagList from '../TagList'
@@ -29,9 +29,10 @@ const PopulationStudents = ({
filteredStudents,
studentToTargetCourseDateMap,
dataExport,
customPopulation = false,
coursePopulation = false,
contentToInclude,
coursecode = [],
variant,
studyGuidanceGroup,
}) => {
const [state, setState] = useState({})
const studentRef = useRef()
@@ -287,19 +288,18 @@ const PopulationStudents = ({
},
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
)
const mandatoryCourseData = [totals, filteredStudents]
const mandatoryCourseData = [totals, ...filteredStudents]
const panes = [
const panesAvailable = [
{
menuItem: 'General',
render: () => (
<Tab.Pane>
<GeneralTab
showNames={showNames}
coursePopulation={coursePopulation}
customPopulation={customPopulation}
variant={variant}
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
coursecode={coursecode}
studyGuidanceGroup={studyGuidanceGroup}
/>
</Tab.Pane>
),
@@ -380,12 +380,7 @@ const PopulationStudents = ({
},
]
const filteredPanes = panesToFilter => {
if (coursePopulation || customPopulation) {
return panesToFilter.slice(0, 1)
}
return panesToFilter
}
const panes = panesAvailable.filter(pane => contentToInclude.panesToInclude.includes(pane.menuItem))
return (
<>
@@ -401,7 +396,7 @@ const PopulationStudents = ({
</Grid.Column>
)}
</Grid>
<Tab onTabChange={handleTabChange} panes={filteredPanes(panes)} data-cy="student-table-tabs" />
<Tab onTabChange={handleTabChange} panes={panes} data-cy="student-table-tabs" />
</>
)
}
@@ -411,11 +406,7 @@ const PopulationStudents = ({
return (
<>
<span style={{ marginRight: '0.5rem' }} ref={studentRef}>
<InfoBox
content={
coursePopulation ? infotoolTips.CoursePopulation.Students : infotoolTips.PopulationStatistics.Students
}
/>
<InfoBox content={contentToInclude.infotoolTipContent} />
</span>
{admin ? <CheckStudentList students={filteredStudents.map(stu => stu.studentNumber)} /> : null}
{renderStudentTable()}
@@ -423,4 +414,33 @@ const PopulationStudents = ({
)
}
export default PopulationStudents
const PopulationStudentsContainer = ({ ...props }) => {
const { variant } = props
if (!['population', 'customPopulation', 'coursePopulation', 'studyGuidanceGroupPopulation'].includes(variant)) {
throw new Error(`${variant} is not a proper variant!`)
}
const contentByVariant = {
population: {
panesToInclude: ['General', 'Courses', 'Tags'],
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
},
coursePopulation: {
panesToInclude: ['General'],
infotoolTipContent: infotoolTips.CoursePopulation.Students,
},
customPopulation: {
panesToInclude: ['General'],
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
},
studyGuidanceGroupPopulation: {
panesToInclude: ['General'],
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
},
}
return <PopulationStudents contentToInclude={contentByVariant[variant]} {...props} />
}
export default PopulationStudentsContainer
@@ -25,6 +25,9 @@ const ProtectedRoute = ({ requiredRoles = [], requireUserHasRights = false, ...r
if (requiredRoles.includes('courseStatistics')) {
return hasRequiredRoles || hasRequiredRights
}
if (rest.path.includes('students')) {
return hasRequiredRoles || hasRequiredRights
}
return hasRequiredRoles && hasRequiredRights
}
@@ -66,7 +66,13 @@ const Routes = () => (
path="/study-programme/:studyProgrammeId?"
component={StudyProgramme}
/>
<ProtectedRoute requireUserHasRights exact path={routes.students} component={StudentStatistics} />
<ProtectedRoute
requiredRoles={['admin', 'studyGuidanceGroups']}
requireUserHasRights
exact
path={routes.students}
component={StudentStatistics}
/>
<ProtectedRoute
requiredRoles={['courseStatistics']}
requireUserHasRights
@@ -1,33 +1,20 @@
import React from 'react'
import { connect } from 'react-redux'
import { shape, string, arrayOf } from 'prop-types'
import { Segment, Header, Message } from 'semantic-ui-react'
import { Segment, Header } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'
import { findStudents, getStudent, selectStudent } from '../../redux/students'
import StudentSearch from './StudentSearch'
import StudentDetails from './StudentDetails'
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
import { useTitle } from '../../common/hooks'
import { getUserRoles, checkUserAccess } from '../../common'
import { getUserRoles } from '../../common'
import { toggleStudentNameVisibility } from '../../redux/settings'
const StudentStatistics = props => {
const { match, userRoles, rights } = props
const { match } = props
const { studentNumber } = match.params
useTitle('Student statistics')
checkUserAccess(['student', 'admin'], userRoles)
if (!checkUserAccess(['student', 'admin'], userRoles) && rights.length < 1)
return (
<div className="segmentContainer">
<Message
error
color="red"
header="You have no rights to access any data. If you should have access please contact grp-toska@helsinki.fi"
/>
</div>
)
return (
<div className="segmentContainer">
@@ -43,22 +30,6 @@ const StudentStatistics = props => {
)
}
StudentStatistics.propTypes = {
match: shape({
params: shape({
studentNumber: string,
}),
}),
userRoles: arrayOf(string).isRequired,
rights: arrayOf(string).isRequired,
}
StudentStatistics.defaultProps = {
match: {
params: { studentNumber: undefined },
},
}
const mapStateToProps = ({
students,
auth: {
@@ -154,7 +154,12 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
content: {
content: (
<div ref={refs[2]}>
<PopulationStudents customPopulation language={language} filteredStudents={filteredStudents} />
<PopulationStudents
variant="studyGuidanceGroupPopulation"
language={language}
filteredStudents={filteredStudents}
studyGuidanceGroup={group}
/>
</div>
),
},
@@ -162,7 +167,7 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
]
return (
<FilterTray filterSet={<StudyGuidanceGroupFilters />}>
<FilterTray filterSet={<StudyGuidanceGroupFilters group={group} />}>
<Segment className="contentSegment">
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
</Segment>
@@ -0,0 +1,4 @@
/* Hide year header from datetime picker in overview */
.studyguidancegroupoverview__yeartagselector .rdtSwitch {
display: none;
}
@@ -10,6 +10,7 @@ import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import { useToggle } from 'common/hooks'
import SortableTable from 'components/SortableTable'
import { startYearToAcademicYear, StyledMessage } from './common'
import './StudyGuidanceGroupOverview.css'
const LinkToGroup = ({ group, language }) => {
const history = useHistory()
@@ -69,12 +70,22 @@ const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
/>
) : (
<Datetime
className="studyguidancegroupoverview__yeartagselector"
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
inputProps={{ readOnly: true }}
renderYear={(props, selectableYear) => <td {...props}>{startYearToAcademicYear(selectableYear)}</td>}
renderYear={(props, year) => {
const shiftBy = 2 // fix to start from 2017 instead of 2019
const shiftedProps = {
...props,
key: props.key - shiftBy,
'data-value': props['data-value'] - shiftBy,
}
const formattedAndShiftedYear = startYearToAcademicYear(year - shiftBy)
return <td {...shiftedProps}> {formattedAndShiftedYear}</td>
}}
renderInput={({ value, ...rest }) => {
return (
<div>
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import InfoBox from 'components/InfoBox'
import InfoBox from 'components/Info/InfoBox'
import PopulationCourseStatsFlat from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
import PopulationCourseStats from 'components/PopulationCourseStats'
import useCourseFilter from 'components/FilterTray/filters/Courses/useCourseFilter'
@@ -0,0 +1,44 @@
import React from 'react'
const ReactHighcharts = require('react-highcharts')
const colors = ['#003E65', '#1392c2', '#036415']
const StackedBarChart = ({ data, categories }) => {
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
const defaultConfig = {
title: {
text: '',
},
series: dataWithColors,
credits: {
text: 'oodikone | TOSKA',
},
xAxis: {
categories,
},
chart: {
type: 'column',
width: 950,
height: 400,
},
plotOptions: {
column: {
dataLabels: {
enabled: true,
},
},
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: '',
},
}
return <ReactHighcharts config={defaultConfig} />
}
export default StackedBarChart
@@ -0,0 +1,32 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
const DataTable = ({ data, titles }) => {
if (!data || !titles) return null
return (
<div className="table-container">
<Table celled>
<Table.Header>
<Table.Row>
{titles?.map(title => (
<Table.HeaderCell>{title}</Table.HeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{data?.map(yearArray => (
<Table.Row>
{yearArray?.map(value => (
<Table.Cell>{value}</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
)
}
export default DataTable
@@ -0,0 +1,42 @@
import React from 'react'
import '../studyprogramme.css'
const ReactHighcharts = require('react-highcharts')
const colors = ['#003E65', '#1392c2', '#E68825', '#333737', '#036415']
const LineGraph = ({ categories, data }) => {
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
const defaultConfig = {
title: {
text: '',
},
series: dataWithColors,
credits: {
text: 'oodikone | TOSKA',
},
chart: {
height: '400px',
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: '',
},
}
if (!data || !categories) return <div>No data available for the selected studyprogramme</div>
return (
<div className="graph-container">
<ReactHighcharts config={defaultConfig} />
</div>
)
}
export default LineGraph
@@ -0,0 +1,48 @@
import React from 'react'
const ReactHighcharts = require('react-highcharts')
const colors = ['#003E65', '#1392c2', '#036415']
const StackedBarChart = ({ data, categories }) => {
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
const defaultConfig = {
title: {
text: '',
},
series: dataWithColors,
credits: {
text: 'oodikone | TOSKA',
},
xAxis: {
categories,
},
chart: {
type: 'column',
width: 950,
height: 400,
},
plotOptions: {
column: {
stacking: 'normal',
dataLabels: {
enabled: true,
},
},
},
tooltip: {
pointFormat: '<b>{series.name}: {point.percentage:.1f} %</b>',
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: '',
},
}
return <ReactHighcharts config={defaultConfig} />
}
export default StackedBarChart
@@ -0,0 +1,76 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { Divider } from 'semantic-ui-react'
import LineGraph from './LineGraph'
import StackedBarChart from './StackedBarChart'
import BarChart from './BarChart'
import DataTable from './DataTable'
import InfoBox from '../../Info/InfoBox'
import { getBasicStats, getCreditStats, getGraduationStats } from '../../../redux/studyProgramme'
import InfotoolTips from '../../../common/InfoToolTips'
import '../studyprogramme.css'
const basicStatsTitles = ['', 'Started', 'Graduated', 'Cancelled', 'Transferred away', 'Transferred to']
const creditStatsTitles = ['', 'Major students credits', 'Non major students credits', 'Transferred credits']
const graduationStatsTitles = ['', 'Graduated', 'Wrote thesis']
const Overview = props => {
const toolTips = InfotoolTips.Studyprogramme
const {
studyprogramme,
basicStats,
creditStats,
graduationStats,
dispatchGetBasicStats,
dispatchGetCreditStats,
dispatchGetGetGraduationStats,
} = props
useEffect(() => {
dispatchGetBasicStats(studyprogramme)
dispatchGetCreditStats(studyprogramme)
dispatchGetGetGraduationStats(studyprogramme)
}, [])
const getDivider = (title, toolTipText) => (
<>
<div className="divider">
<Divider horizontal>{title}</Divider>
</div>
<InfoBox content={toolTips[toolTipText]} />
</>
)
return (
<div className="studyprogramme-overview">
{getDivider('Students of the studyprogramme', 'StudentsOfTheStudyprogramme')}
<div className="section-container">
<LineGraph categories={basicStats?.data?.years} data={basicStats?.data?.graphStats} />
<DataTable titles={basicStatsTitles} data={basicStats?.data?.tableStats} />
</div>
{getDivider('Credits produced by the studyprogramme')}
<div className="section-container">
<StackedBarChart categories={creditStats?.data?.years} data={creditStats?.data?.graphStats} />
<DataTable titles={creditStatsTitles} data={creditStats?.data?.tableStats} />
</div>
{getDivider('Graduated and thesis writers of the programme')}
<div className="section-container">
<BarChart categories={graduationStats?.data?.years} data={graduationStats?.data?.graphStats} />
<DataTable titles={graduationStatsTitles} data={graduationStats?.data?.tableStats} />
</div>
</div>
)
}
const mapStateToProps = ({ studyProgramme }) => ({
basicStats: studyProgramme?.basicStats,
creditStats: studyProgramme?.creditStats,
graduationStats: studyProgramme?.graduationStats,
})
export default connect(mapStateToProps, {
dispatchGetBasicStats: getBasicStats,
dispatchGetCreditStats: getCreditStats,
dispatchGetGetGraduationStats: getGraduationStats,
})(Overview)
@@ -4,7 +4,7 @@ import { Table, Header, Grid, Label, Segment, Dropdown } from 'semantic-ui-react
import { connect } from 'react-redux'
import { shape, number, arrayOf, bool, string, oneOfType } from 'prop-types'
import { getProductivity } from '../../../redux/productivity'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import infotooltips from '../../../common/InfoToolTips'
import './productivityTable.css'
@@ -1,14 +1,16 @@
import React from 'react'
import { connect } from 'react-redux'
import { Segment } from 'semantic-ui-react'
import { arrayOf, shape, string, func } from 'prop-types'
import { getCourseSearchResults } from '../../../selectors/courses'
import useLanguage from '../../LanguagePicker/useLanguage'
import SortableTable from '../../SortableTable'
const SearchResult = ({ courses, getCourseActions }) =>
courses.length === 0 ? (
<Segment content="No results" />
) : (
const SearchResult = ({ courses, getCourseActions }) => {
const { language } = useLanguage()
if (!courses || courses.length === 0) return <Segment content="No results" />
return (
<SortableTable
getRowKey={c => c.code}
tableProps={{ celled: true }}
@@ -22,7 +24,7 @@ const SearchResult = ({ courses, getCourseActions }) =>
{
key: 'name',
title: 'Name',
getRowVal: c => c.name,
getRowVal: c => c.name[language],
headerProps: { width: 12, textAlign: 'left' },
},
{
@@ -35,15 +37,6 @@ const SearchResult = ({ courses, getCourseActions }) =>
data={courses}
/>
)
SearchResult.propTypes = {
courses: arrayOf(
shape({
code: string,
name: string,
})
),
getCourseActions: func.isRequired,
}
SearchResult.defaultProps = {
@@ -1,6 +1,5 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { string, func, bool, arrayOf, shape } from 'prop-types'
import { Dimmer, Loader, Segment, Button } from 'semantic-ui-react'
import { getThesisCourses, deleteThesisCourse } from '../../../redux/thesisCourses'
import SortableTable from '../../SortableTable'
@@ -53,19 +52,6 @@ const ThesisCourseList = ({ studyprogramme, getThesisCourses, pending, data, del
)
}
ThesisCourseList.propTypes = {
studyprogramme: string.isRequired,
pending: bool.isRequired,
getThesisCourses: func.isRequired,
deleteThesisCourse: func.isRequired,
data: arrayOf(
shape({
courseCode: string,
thesisType: string,
})
),
}
ThesisCourseList.defaultProps = {
data: [],
}
@@ -1,6 +1,5 @@
import React from 'react'
import { Button, Message } from 'semantic-ui-react'
import { string, func } from 'prop-types'
import { connect } from 'react-redux'
import CourseSearchForm from '../../CourseSearchForm'
import ThesisCourseList from './ThesisCourseList'
@@ -34,12 +33,6 @@ const ThesisCourses = ({ studyprogramme, createThesis, getTheses }) => {
)
}
ThesisCourses.propTypes = {
studyprogramme: string.isRequired,
createThesis: func.isRequired,
getTheses: func.isRequired,
}
export default connect(null, {
createThesis: createNewThesisCourse,
getTheses: getThesisCourses,
@@ -6,7 +6,7 @@ import { Link } from 'react-router-dom'
import { flatten, uniq, range } from 'lodash'
import { getUserIsAdmin } from '../../../common'
import { isDev } from '../../../conf'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import infotooltips from '../../../common/InfoToolTips'
const getMonths = year => {
@@ -1,16 +1,18 @@
import React, { useCallback, useEffect } from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import { connect, useSelector } from 'react-redux'
import { Header, Segment, Tab, Button } from 'semantic-ui-react'
import { isEqual, uniqBy } from 'lodash'
import DegreeCoursesTable from './DegreeCourses'
import StudyProgrammeSelector from './StudyProgrammeSelector'
import Overview from './Overview'
import NewOverview from './NewOverview'
import StudyTrackOverview from './StudyTrackOverview'
import ThesisCourses from './ThesisCourses'
import '../PopulationQueryCard/populationQueryCard.css'
import { getTextIn, getUserRoles, getUserIsAdmin } from '../../common'
import { useTabs, useTitle } from '../../common/hooks'
import { isDev } from '../../conf'
import TSA from '../../common/tsa'
import Tags from './Tags'
@@ -23,7 +25,9 @@ import useLanguage from '../LanguagePicker/useLanguage'
const StudyProgramme = props => {
const { language } = useLanguage()
const { roles } = useSelector(state => state.auth.token)
const [tab, setTab] = useTabs('p_tab', 0, props.history)
useTitle('Study programmes')
useEffect(() => {
@@ -57,6 +61,8 @@ const StudyProgramme = props => {
})
}
const SHOW_NEW_OVERVIEW = getUserIsAdmin(roles) && isDev
const getPanes = () => {
const { match, programmes } = props
const { studyProgrammeId } = match.params
@@ -71,6 +77,12 @@ const StudyProgramme = props => {
menuItem: 'Overview',
render: () => <Overview studyprogramme={studyProgrammeId} history={props.history} />,
})
if (SHOW_NEW_OVERVIEW) {
panes.push({
menuItem: 'New Overview',
render: () => <NewOverview studyprogramme={studyProgrammeId} history={props.history} />,
})
}
if (filteredStudytracks.length > 0) {
panes.push({
menuItem: 'Studytrack overview',
@@ -0,0 +1,20 @@
.studyprogramme-overview .divider {
margin-top: 4em;
}
.studyprogramme-overview .section-container {
display: flex;
justify-content: space-between;
}
.studyprogramme-overview .graph-container {
width: 60%;
height: 400px;
}
.studyprogramme-overview .table-container {
width: 35%;
height: 400px;
margin-top: 2em;
margin-left: 2em;
}
+2
View File
@@ -41,6 +41,7 @@ import singleCourseStats from './singleCourseStats'
import userAccessEmail from './userAccessEmail'
import customPopulationSearch from './customPopulationSearch'
import coolDataScience from './coolDataScience'
import studyProgramme from './studyProgramme'
const store = configureStore({
reducer: {
@@ -85,6 +86,7 @@ const store = configureStore({
customPopulationSearch,
coolDataScience,
[RTKApi.reducerPath]: RTKApi.reducer,
studyProgramme,
},
// oodikone is currently too heavy for other middlewares than thunk, but
// feel free to take use them at some point if possible
@@ -0,0 +1,42 @@
import { createReducer } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
import { initialState, matcherReducers, defaultReducer } from './common'
const baseUrl = '/v2/studyprogrammes'
const getBasicsPrefix = 'GET_STUDY_PROGRAMME_BASIC_STATS_'
const getBasics = {
...actionTypes(getBasicsPrefix),
}
const getCreditsPrefix = 'GET_STUDY_PROGRAMME_CREDIT_STATS_'
const getCredits = {
...actionTypes(getCreditsPrefix),
}
const getGraduationPrefix = 'GET_STUDY_PROGRAMME_GRADUATION_STATS_'
const getGraduation = {
...actionTypes(getGraduationPrefix),
}
export const getBasicStats = id => callController(`${baseUrl}/${id}/basicstats`, getBasicsPrefix)
export const getCreditStats = id => callController(`${baseUrl}/${id}/creditstats`, getCreditsPrefix)
export const getGraduationStats = id => callController(`${baseUrl}/${id}/graduationstats`, getGraduationPrefix)
const reducer = createReducer(
initialState,
{
[getBasics.success]: (state, action) => {
state.basicStats = action.response
},
[getCredits.success]: (state, action) => {
state.creditStats = action.response
},
[getGraduation.success]: (state, action) => {
state.graduationStats = action.response
},
},
matcherReducers,
defaultReducer
)
export default reducer