Compare commits

...
102 Commits
Author SHA1 Message Date
Saara Satokangas 6d174d0275 Adjust the updates to match the new options 2022-03-07 12:51:01 +02:00
Saara Satokangas 2e882ff8a0 Clean up the options for studyprogramme overviews 2022-03-07 11:03:19 +02:00
Saara Satokangas f8a6f89199 Clean up the way years are defined for studyprogramme stats 2022-03-07 10:36:33 +02:00
Saara Satokangas b272f9e94a Clean up the toggle container 2022-03-07 09:21:05 +02:00
Saara Satokangas 4cc73d5fd8 Clean up settings for studytrack-overview 2022-03-07 09:12:31 +02:00
Saara Satokangas c19810fca8 Add "years combined"-selection to front and redis 2022-03-07 08:35:22 +02:00
Saara Satokangas 298c9e9b4b Add possibility to choose all students in the studytrack-view 2022-03-07 08:11:43 +02:00
Mitja Karhusaari 58ed0ac417 Fix course filter toggle button in Courses of Population
Resolves #3458
2022-03-06 19:12:31 +02:00
Mitja Karhusaari cb40fd970c Fix reseting table filters 2022-03-04 10:55:00 +02:00
Mitja Karhusaari cdc7331667 Fix rest of the tests 2022-03-04 10:17:45 +02:00
Mitja Karhusaari 2ffc222bcf Fix at at least some of the tests 2022-03-04 09:46:50 +02:00
Mitja Karhusaari 2fcb5fd91d Remove dead files 2022-03-03 08:43:19 +02:00
Mitja Karhusaari f4be37a3b8 Use "fully qualified" column name in exports to avoid name conflicts 2022-03-03 08:41:43 +02:00
Mitja Karhusaari 67d7b05cc6 Remove use of collapsing-property in SortableTable cells 2022-03-03 08:36:06 +02:00
Mitja Karhusaari 88ad8db7bb UI fix 2022-03-03 08:32:50 +02:00
Mitja Karhusaari dda612f284 Handle property not being present 2022-03-03 08:30:49 +02:00
Mitja Karhusaari 6a9cf26f4b Use the new SortableTable in the flat versions of PopulationCourseStats tables 2022-03-03 08:29:26 +02:00
Mitja Karhusaari 2d7b679b04 Add options to fold/unfold all groups 2022-03-03 08:28:18 +02:00
Mitja Karhusaari 5aa4c5d146 Misc. UI/UX improvements to SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari ff9aaa1119 Add course filter toggles and links to all Population Courses tables 2022-03-03 07:09:18 +02:00
Mitja Karhusaari f443f3b7db Add exclude/include all buttons to export modal 2022-03-03 07:09:18 +02:00
Mitja Karhusaari a6a47e5ec8 Add Excel export to SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari b414fd3501 Go through and tweak components using SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari 88ce7bd2a5 Implement Courses of Population tables using SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari eb35ea503d Polish the row grouping functionality 2022-03-03 07:09:18 +02:00
Mitja Karhusaari 20de79a26f Implement row grouping in sortable table 2022-03-03 07:09:18 +02:00
Mitja Karhusaari dab21ab390 Revamp sortable table 2022-03-03 07:09:18 +02:00
Leo Varis 6069dbc328 fix broken grade distribution (#3456) 2022-03-02 13:40:02 +02:00
Saara SatokangasandGitHub 7cb8041ca0 Update Pate-url 2022-03-02 09:13:14 +02:00
Roni Lindholm 729192cddc Fix enrollment updater when missing education 2022-03-01 15:14:27 +02:00
SNurmivaara 4b3b10d033 Teacher leaderboard tests go to the shadow realm 2022-03-01 14:48:50 +02:00
SNurmivaara 9c6812d7cf Return the magic wait and reload to teacher leaderboard test 2022-03-01 13:56:08 +02:00
SNurmivaara 58ce626d97 Acually fix teacher leadearboard tests 2022-03-01 13:44:58 +02:00
SNurmivaara 0834b33778 Fix teacher leaderboard tests 2022-03-01 12:54:13 +02:00
SNurmivaara cbce1d7dbd Remove statistics update button from teacher leaderboards
The button is obsolete, the update is run daily as a cronjob
2022-03-01 12:34:27 +02:00
SNurmivaara b8e214f8cd Filter faculties out of top teachers leaderboard 2022-03-01 12:00:37 +02:00
SNurmivaara 69d920015c Added required props to teacherStatisticsTable in teacher leaderboards 2022-03-01 12:00:37 +02:00
Roni Lindholm cf64249d4f Add enrollments to updater 2022-02-28 17:13:33 +02:00
Leo VarisandGitHub f372e4f362 Change required version 2022-02-28 13:57:37 +02:00
Saara SatokangasandGitHub dd619d375d Merge pull request #3421 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/updater/sis-updater-scheduler/follow-redirects-1.14.8
Bump follow-redirects from 1.14.3 to 1.14.8 in /updater/sis-updater-scheduler
2022-02-28 12:31:15 +02:00
Saara SatokangasandGitHub 0888eaf547 Merge pull request #3445 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/url-parse-1.5.10
Bump url-parse from 1.5.3 to 1.5.10 in /services/frontend
2022-02-28 12:15:19 +02:00
Saara Satokangas 14275a3e6f Fix faculty statistics 2022-02-28 11:54:13 +02:00
Saara Satokangas 956b9302d2 Rename newStudyprogramme.js to studyprogramme.js 2022-02-28 11:12:00 +02:00
dependabot[bot]andGitHub 51f462ce7b Bump url-parse from 1.5.3 to 1.5.10 in /services/frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-27 20:54:50 +00:00
Saara Satokangas a38bd1a3b0 Enable custom populations for studyguidance-group users too 2022-02-24 16:01:50 +02:00
Saara Satokangas 316f3511c2 Fix to toolTip-texts 2022-02-24 15:00:03 +02:00
Saara Satokangas e36c7aa6ba Fix semesters showing invalid dates 2022-02-24 14:35:14 +02:00
Saara Satokangas ffda9af909 Use common component also for percentage-toggle 2022-02-24 14:33:49 +02:00
Saara Satokangas 7b56978e7c Improve the appearance of toggles and move them into one file 2022-02-24 14:29:37 +02:00
Saara Satokangas b95d439836 Fix coursepopulation bugging with academic terms 2022-02-24 13:15:58 +02:00
SNurmivaara 1f7573a0ba Added oodikone architecture picture to README. Closes issue #3110 2022-02-24 08:55:12 +02:00
Petri Palmu 0204b4890f little fix to course stats tests 2022-02-23 14:20:54 +02:00
Petri Palmu 5a19e48ff9 Moved provider organization in own line because not resoponsive 2022-02-22 10:19:53 +02:00
Petri Palmu 790386a394 more course statistics course provider organization test 2022-02-22 10:19:53 +02:00
Petri Palmu e86045e8e6 few more chenges to course stats tests 2022-02-22 10:19:53 +02:00
Petri Palmu b1fb0a7cc7 fix to the Population of course header 2022-02-22 10:19:53 +02:00
Petri Palmu 0086ae69d3 removed or changed few proptypes 2022-02-22 10:19:53 +02:00
Petri Palmu 10d9731fab removed unifyOpenCourses from redux and refactored SearchForm to use redux hooks 2022-02-22 10:19:53 +02:00
Petri Palmu 83ffd5389e fixed course statistics test and cleaned console logs from the code 2022-02-22 10:19:53 +02:00
Petri Palmu fcb4328dba different course stats datas seems to work 2022-02-22 10:19:53 +02:00
Petri Palmu f196a0939d change open/unify radio renders correct data 2022-02-22 10:19:53 +02:00
Petri Palmu 52a480d6fb unify radio value is in redux state 2022-02-22 10:19:53 +02:00
Petri Palmu 0618a17830 unify radio added to the course stats 2022-02-22 10:19:53 +02:00
Saara Satokangas d54b3c0782 Fix one test 2022-02-22 08:53:16 +02:00
Saara Satokangas 28fa4e5310 Increase the viewport-size for cypress tests to see what's wrong 2022-02-22 08:37:00 +02:00
Saara Satokangas df402c4823 Try fixing some of the tests 2022-02-22 08:20:03 +02:00
Saara Satokangas 9136d41bdd Adjust tooltips to match the new table 2022-02-21 14:39:21 +02:00
Saara Satokangas e1f63c2441 Filter our transfers also from master's 2022-02-21 14:34:15 +02:00
Saara Satokangas d797b766b1 Refactor and clean-up 2022-02-21 14:30:41 +02:00
Saara Satokangas 03bed43077 Unify following masters programme -table with the bachelor one 2022-02-21 13:53:22 +02:00
Saara Satokangas 6763905b2a Adjust texts in frontend and variable names to match the new table 2022-02-21 13:23:40 +02:00
Saara Satokangas 994101732d Define studystartdate in the programme similarly as in populations 2022-02-21 13:04:19 +02:00
Saara Satokangas b1c071a507 Add table for bachelor's programmes before the chosen masters 2022-02-21 13:03:55 +02:00
Mitja Karhusaari 0e2bdc8d2d Potential fix for problems with Courses of Population -components 2022-02-16 13:26:42 +02:00
dependabot[bot]andGitHub be0f14326a Bump follow-redirects in /updater/sis-updater-scheduler
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.3 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.3...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-16 07:49:06 +00:00
Saara SatokangasandGitHub 1cd6e2f435 Merge pull request #3425 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/backend/follow-redirects-1.14.8
Bump follow-redirects from 1.14.3 to 1.14.8 in /services/backend
2022-02-16 09:48:12 +02:00
Saara Satokangas 0790003ae8 Fix trends-updating erroring at night 2022-02-16 08:25:30 +02:00
Saara Satokangas a77bf8b6a9 Remove old logic studyprogramme-service 2022-02-15 15:50:58 +02:00
dependabot[bot]andGitHub 00cd57dc13 Bump follow-redirects from 1.14.3 to 1.14.8 in /services/backend
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.3 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.3...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-15 12:35:10 +00:00
Saara SatokangasandGitHub 44a8f39cc1 Merge pull request #3426 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/follow-redirects-1.14.8
Bump follow-redirects from 1.14.1 to 1.14.8 in /services/frontend
2022-02-15 14:34:17 +02:00
Saara Satokangas 4ecdb3237d Clarify the test-structure 2022-02-15 13:37:37 +02:00
Saara Satokangas 6b544179ef Add more tests for studytrack-overview 2022-02-15 13:16:26 +02:00
Saara Satokangas 9e29afedad Fix tests 2022-02-15 11:53:34 +02:00
Saara Satokangas 8610bf1084 Add tests for new studyprogramme overview 2022-02-15 11:35:01 +02:00
Saara Satokangas fe49a63408 Remove old reducer-for previous bachelor programmes 2022-02-15 08:19:28 +02:00
dependabot[bot]andGitHub a9100b78ec Bump follow-redirects from 1.14.1 to 1.14.8 in /services/frontend
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.1 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.1...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 20:34:45 +00:00
Saara Satokangas e3fb31ef8f Remove old productivity and progress functions from redis 2022-02-14 14:48:04 +02:00
Saara Satokangas f865796771 Remove old routes for getting option-data 2022-02-14 08:58:28 +02:00
Saara Satokangas 2532b5367e Remove routes for updating and getting old studyprogramme view's data 2022-02-14 08:58:28 +02:00
Saara Satokangas eeca5c52fc Remove old studyprogramme-reducers 2022-02-14 08:58:28 +02:00
Saara Satokangas 1e940fde73 Remove old infotooltips 2022-02-14 08:58:28 +02:00
Saara Satokangas 38a2f0a9a8 Remove propTypes from studyprogramme-overview, start using useSelector 2022-02-14 08:58:20 +02:00
Saara Satokangas 991203e08e Remove old studytrackoverview, rename new views 2022-02-14 08:13:49 +02:00
Saara Satokangas 0c8698ab04 Remove old overview 2022-02-14 08:10:46 +02:00
Saara Satokangas 57e04434b0 Remove old throughput-table and producitivity-tables 2022-02-14 08:09:52 +02:00
Saara Satokangas f1fcf9661d Remove old thesis-courses-list 2022-02-14 08:09:23 +02:00
Saara Satokangas 61ab3d9849 Add some comments 2022-02-14 08:09:09 +02:00
Saara Satokangas 52db993e1f Remove old bachelor's table 2022-02-14 08:08:28 +02:00
Saara Satokangas 66d4989fee Remove mentions of old views 2022-02-14 08:07:41 +02:00
Saara Satokangas 0439efddbe Make toggle-choices permanent between tabs 2022-02-14 07:35:46 +02:00
Saara Satokangas 453511fe5d Fix test 2022-02-06 11:02:48 +02:00
Saara Satokangas bcaa0086e5 Remove column "All" from studyprogramme-overview for now
It can be a bit misleading
2022-02-06 10:47:47 +02:00
119 changed files with 4090 additions and 4443 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+5 -1
View File
@@ -8,7 +8,7 @@ To run Oodikone locally, you will need the following:
1. Applications:
- [Docker](https://docs.docker.com/install/) (version 20.10+),
- [Docker Compose](https://docs.docker.com/compose/install/)(version 0.29+) and
- [Docker Compose](https://docs.docker.com/compose/install/)(version 1.29+) and
- [npm](https://docs.npmjs.com/cli/v7) (version 7+)
2. Access to Toska Docker Hub,
3. For real data setup, access to Toskas production servers.
@@ -48,6 +48,10 @@ What different CLI options do:
## ⌨️ Development
### Architecture
![Oodikone architecture](Oodikone.png)
### Basics
The development environment is entirely configured in the `docker-compose.yml` file located in this repository. The file defines the services for oodikone's two main components: **oodikone** and **updater**.
+67 -29
View File
@@ -96,7 +96,7 @@ describe('Course Statistics tests', () => {
cy.get("input[placeholder='Search by entering a course name']").type('tietokantojen perusteet')
cy.contains('Tietokantojen perusteet')
cy.contains('Avoin yo: Tietokantojen perusteet')
cy.contains('Tietokantojen perusteet')
cy.contains('A581328')
cy.contains('AYTKT10004')
cy.contains('TKT10004, 581328')
@@ -105,46 +105,39 @@ describe('Course Statistics tests', () => {
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10004, 581328 Tietokantojen perusteet')
cy.contains('TKT10004, 581328, AYTKT10004, A581328 Tietokantojen perusteet')
cy.get('.right').click()
cy.contains('No results')
})
it('Provider organization toggle works', () => {
cy.url().should('include', '/coursestatistics')
cy.contains('Search for courses')
cy.get("input[placeholder='Search by entering a course name']").type('tietokantojen perusteet')
cy.contains('td', /^AYTKT10004/).click()
cy.contains('td', /^TKT10004/).click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('Search for courses').should('not.exist')
cy.contains('AYTKT10004 Avoin yo: Tietokantojen perusteet')
cy.contains('TKT10004, 581328, AYTKT10004, A581328 Tietokantojen perusteet')
cy.get('[data-cy=unify_radio_reqular]').click()
cy.contains('TKT10004, 581328 Tietokantojen perusteet')
})
it('Searching course by name displays right courses, 10 credit courses', () => {
cy.url().should('include', '/coursestatistics')
cy.contains('Search for courses')
cy.get("input[placeholder='Search by entering a course name']").type('tietorakenteet ja algoritmit')
cy.contains('Tietorakenteet ja algoritmit')
cy.contains('Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001')
cy.contains('TKT20001, 58131')
cy.contains('td', /^TKT20001/).click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT20001, 58131 Tietorakenteet ja algoritmit')
cy.contains('TKT20001, 58131, AYTKT20001 Tietorakenteet ja algoritmit')
cy.get('.right').click()
cy.contains('No results')
cy.get("input[placeholder='Search by entering a course name']").type('tietorakenteet ja algoritmit')
cy.contains('td', /^AYTKT20001/).click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('Search for courses').should('not.exist')
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
cy.get('.right').click()
cy.contains('No results')
cy.get("input[name='unifyOpenUniCourses']").parent().click()
cy.get("input[placeholder='Search by entering a course name']").type('tietorakenteet ja algoritmit')
cy.contains('td', 'TKT20001, 58131, AYTKT20001').click()
@@ -159,12 +152,11 @@ describe('Course Statistics tests', () => {
cy.get("input[placeholder='Search by a course code']").type('TKT20003')
cy.contains('tr', 'TKT20003').click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('Käyttöjärjestelmät')
cy.contains('TKT20003')
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
cy.contains('Population of course Käyttöjärjestelmät 2019-2020')
cy.contains('Population of course Unified with open uni: Käyttöjärjestelmät 2019-2020')
cy.contains('TKT20003')
cy.contains('Students (127)').click()
@@ -174,12 +166,12 @@ describe('Course Statistics tests', () => {
it('Population of course shows grades for each student', () => {
cy.get("input[placeholder='Search by a course code']").type('TKT20001')
cy.contains(/^TKT20001$/).click()
cy.contains(/^TKT20001, AYTKT20001$/).click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('TKT20001, 58131 Tietorakenteet ja algoritmit')
cy.contains('TKT20001, 58131, AYTKT20001 Tietorakenteet ja algoritmit')
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
cy.contains('Population of course Tietorakenteet ja algoritmit 2018-2019')
cy.contains('Students (197)').click()
cy.contains('Population of course Unified with open uni: Tietorakenteet ja algoritmit 2018-2019')
cy.contains('Students (204)').click()
cy.contains('td', '010262566').siblings().contains('4')
cy.contains('td', '010674989').siblings().contains('1')
})
@@ -189,10 +181,11 @@ describe('Course Statistics tests', () => {
cy.contains(/^TKT20003$/).click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('TKT20003, 582219 Käyttöjärjestelmät')
cy.get('[data-cy=unify_radio_reqular]').click()
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
cy.contains('Population of course Käyttöjärjestelmät 2019-2020')
cy.contains('Language distribution').click()
cy.contains('td', 'finnish').siblings().contains('107')
cy.contains('td', 'finnish').siblings().contains('122')
cy.contains('td', 'english').siblings().contains('5')
})
@@ -200,7 +193,6 @@ describe('Course Statistics tests', () => {
beforeEach(() => {
cy.url().should('include', '/coursestatistics')
cy.contains('Search for courses')
cy.get("input[name='unifyOpenUniCourses']").parent().click()
cy.get("input[placeholder='Search by a course code']").type('TKT10002')
cy.contains('td', 'TKT10002, AYTKT10002').click()
cy.contains('Fetch statistics').should('be.enabled').click()
@@ -215,6 +207,16 @@ describe('Course Statistics tests', () => {
['Total', 511, 486, 25],
]
const attemptsTableContentsOpen = [
// [time, passed, failed, passrate]
['Total', 175, 175, 0],
]
const attemptsTableContentsReqular = [
// [time, passed, failed, passrate]
['Total', 312, 298, 14],
]
const gradesTableContents = [
// [time, attempts, 0, 1, 2, 3, 4, 5, other passed]
['Total', 511, 25, 11, 17, 21, 59, 365, 13],
@@ -271,6 +273,42 @@ describe('Course Statistics tests', () => {
})
})
it('Toggling course provider changes stats correctly', () => {
cy.get('[data-cy=unify_radio_reqular]').click()
cy.get("div[name='toYear']").within(() => {
cy.get("div[role='option']").first().should('have.text', '2020-2021')
cy.contains("div[role='option']", yearRange.to).should('have.class', 'selected')
cy.get("div[role='option']").last().should('have.text', yearRange.from)
cy.get("div[role='option']").should('have.length', 21)
})
cy.contains('#CourseStatPanes a.item', 'Table').click()
cy.contains('#CourseStatPanes a.item', 'Attempts').click()
cy.get('#CourseStatPanes table>tbody').within(() => {
attemptsTableContentsReqular.forEach((values, trIndex) => {
cy.get('tr')
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
cy.get('td').eq(tdIndex).contains(value)
})
})
})
})
cy.get('[data-cy=unify_radio_open]').click()
cy.get('#CourseStatPanes table>tbody').within(() => {
attemptsTableContentsOpen.forEach((values, trIndex) => {
cy.get('tr')
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
cy.get('td').eq(tdIndex).contains(value)
})
})
})
})
cy.get('[data-cy=unify_radio_unify]').click()
})
it('after changing time range shows same stats', () => {
const newYearRange = { from: '2016-2017', to: '2019-2020' }
cy.get("div[name='fromYear']")
@@ -316,10 +354,10 @@ describe('Course Statistics tests', () => {
const attemptsTableContents = [
// [time, passed, failed, passrate]
['Total*', 288, 213, 75, '73.96 %'],
['Total', 288, 213, 75, '73.96 %'],
// ['Total', 295, 216, 79, '73.22 %'],
['2020-2021', '5 or less students', 'NA', 'NA', 'NA'],
//['2020-2021', 5, 2, 3, '40.00 %'],
// ['2020-2021', 5, 2, 3, '40.00 %'],
['2019-2020', 164, 121, 43, '73.78 %'],
['2018-2019', 85, 60, 25, '70.59 %'],
['2017-2018', 39, 32, 7, '82.05 %'],
+3 -3
View File
@@ -81,11 +81,11 @@ describe('Population Statistics tests', () => {
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(15000) // a bit hacky way, wait until ui is ready
cy.get('[data-cy=expand-TKT1]').click()
cy.get('[data-cy=coursestats-link-TKT10002]').click()
cy.get('[data-cy=toggle-group-module-TKT1]').click()
cy.contains('td', 'TKT10002').siblings().find('i.level.up').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('TKT10002, 581325 Ohjelmoinnin perusteet')
cy.contains('TKT10002, 581325, AYTKT10002, A581325 Ohjelmoinnin perusteet')
})
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
+4 -4
View File
@@ -37,7 +37,7 @@ describe('Student Statistics tests', () => {
it('Can get student specific page by clicking student', () => {
cy.url().should('include', '/students')
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.contains('td a', student.studentnumber).click()
cy.contains('Started: 01.08.2017')
cy.contains('Tietojenkäsittelytieteen kandiohjelma (01.08.2017 - 01.08.2025')
cy.contains(student.lastname).should('not.exist')
@@ -54,7 +54,7 @@ describe('Student Statistics tests', () => {
it('Can get back to search menu', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.contains('td a', student.studentnumber).click()
cy.get('.remove').click()
cy.contains('Student number').should('not.exist')
cy.contains('Credits').should('not.exist')
@@ -62,7 +62,7 @@ describe('Student Statistics tests', () => {
it('Can jump to course', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.contains('td a', student.studentnumber).click()
cy.contains('Introduction to Machine Learning (DATA11002)')
.siblings()
.within(() => {
@@ -82,7 +82,7 @@ describe('Student Statistics tests', () => {
it('Can jump to population page', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.contains('td a', student.studentnumber).click()
cy.get('i.level.up.alternate.icon').eq(0).click()
cy.contains('Population statistics')
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
+290 -30
View File
@@ -1,62 +1,61 @@
/// <reference types="Cypress" />
const deleteTag = name => {
cy.contains(name).siblings().contains('Delete').click()
cy.contains('td', name).siblings().contains('Delete').click()
cy.contains('Are you sure you want to delete tag')
cy.contains('Confirm').click()
cy.contains(name).should('not.exist')
cy.contains('td', name).should('not.exist')
}
const getEmptyYears = () => {
const getEmptyYears = isAcademicYear => {
const thisYear = new Date().getFullYear()
const years = []
for (let year = thisYear; year > 2021; year--) {
years.push(year)
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
return years
}
describe('Studyprogramme overview', () => {
describe('when opening programme page with basic user', () => {
/* Basic information overview -tests*/
describe('Basic information -view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme/KH50_005')
cy.init('/study-programme')
cy.viewport(1536, 960)
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Basic information -tab loads', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('[data-cy=Section-StudentsOfTheStudyprogramme]')
cy.get('[data-cy=Section-CreditsProducedByTheStudyprogramme]')
cy.get('[data-cy=Section-GraduatedAndThesisWritersOfTheProgramme]')
cy.get('[data-cy=Section-ProgrammesAfterGraduation]')
cy.get('[data-cy=Section-AverageGraduationTimes]')
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Studytracks and student populations -tab loads', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
cy.get('[data-cy=Section-StudytrackOverview]')
cy.get('[data-cy=Section-StudytrackProgress]')
cy.get('[data-cy=Section-ProgrammesBeforeOrAfter]')
cy.get('[data-cy=Section-AverageGraduationTimes]')
})
it('Basic information contains correct students', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, All, Started, Graduated, Cancelled, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
[2021, 0, 0, 0, 0, 1, 0],
[2020, 12, 10, 36, 16, 3, 2],
[2019, 87, 86, 17, 14, 0, 1],
[2018, 161, 162, 1, 3, 0, 0],
[2017, 171, 173, 0, 0, 0, 0],
// [Year, Started, Graduated, Cancelled, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2021, 0, 0, 0, 1, 0],
[2020, 12, 36, 16, 3, 2],
[2019, 87, 17, 14, 0, 1],
[2018, 161, 1, 3, 0, 0],
[2017, 171, 0, 0, 0, 0],
]
cy.checkTableStats(tableContents, 'StudentsOfTheStudyprogramme')
})
it('Basic information contains correct credits', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Total, Major credits, Non-major credits, Transferred credits]
@@ -72,6 +71,7 @@ describe('Studyprogramme overview', () => {
})
it('Basic information contains correct thesis writers and graduates', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Graduated, Wrote thesis]
@@ -86,9 +86,269 @@ describe('Studyprogramme overview', () => {
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
})
it.skip('Special studyrights can be excluded and basic data changes accordingly', () => {
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
cy.get('[data-cy=StudentToggle]').click()
const years = getEmptyYears()
const studentTableContents = [
// [Year, Started, Graduated]
...years.map(year => [year, 0, 0]),
[2021, 0, 0],
[2020, 22, 0],
[2019, 12, 0],
[2018, 0, 0],
[2017, 0, 0],
]
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
const creditTableContents = [
// [Year, Total, Major students credits, Transferred credits]
...years.map(year => [year, 0, 0, 0]),
[2021, 0, 0, 0],
[2020, 117.5, 0, 7.5],
[2019, 67, 0, 0],
[2018, 0, 0, 0],
[2017, 0, 0, 0],
]
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
const graduatedTableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2021, 0, 0],
[2020, 0, 0],
[2019, 0, 0],
[2018, 0, 0],
[2017, 0, 0],
]
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
cy.get('[data-cy=StudentToggle]').click()
})
it('Year can be changed to academic year, and data changes accordingly', () => {
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
cy.get('[data-cy=YearToggle]').click()
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const studentTableContents = [
// [Year, Started, Graduated, Cancelled, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2021 - 2022', 0, 0, 0, 0, 0],
['2020 - 2021', 5, 0, 1, 0, 0],
['2019 - 2020', 27, 0, 1, 0, 0],
['2018 - 2019', 4, 0, 0, 0, 0],
['2017 - 2018', 0, 0, 0, 0, 0],
]
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
const creditTableContents = [
// [Year, Total, Major students credits, Non-major students credits, Transferred credits]
...years.map(year => [year, 0, 0, 0, 0]),
['2021 - 2022', 0, 0, 0, 0],
['2020 - 2021', 0, 0, 0, 0],
['2019 - 2020', 338.5, 0, 331, 7.5],
['2018 - 2019', 173, 0, 173, 0],
['2017 - 2018', 42, 0, 42, 0],
]
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
cy.get('[data-cy=YearToggle]').click()
})
it('Basic information graphs render', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('[data-cy=Graph-StudentsOfTheStudyprogramme')
.should('contain', 'Started')
.should('contain', 'Graduated')
.should('contain', 'Transferred away')
cy.get('[data-cy=Graph-CreditsProducedByTheStudyprogramme')
.should('contain', 'Major students credits')
.should('contain', 'Non-major students credits')
.should('contain', 'Transferred credits')
.should('contain', 788)
.should('contain', 1820)
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
.should('contain', 'Graduated students')
.should('contain', 'Wrote thesis')
.should('contain', 1)
.should('contain', 5)
.should('contain', 17)
.should('contain', 25)
.should('contain', 36)
.should('contain', 35)
cy.get('[data-cy=Graph-2019-AverageGraduationTimes]')
.should('contain', '2019')
.should('contain', 'n = 17')
.should('contain', '22 kk')
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
.should('contain', 'Datatieteen maisteriohjelma')
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
.should('contain', 1)
.should('contain', 4)
.should('contain', 13)
})
})
/* Studytrack overview -tests*/
describe('Basic information -view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Studytracks and student populations -tab loads', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
cy.get('[data-cy=Section-StudytrackOverview]')
cy.get('[data-cy=Section-StudytrackProgress]')
cy.get('[data-cy=Section-AverageGraduationTimes]')
})
it('Students of the studyprogramme are shown correctly', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
const tableContents = [
// [Year, All, Started studying, Currently enrolled, Absent, Cancelled, Graduated, Men, Women, Finnish]
[
'2020 - 2021',
12,
'100.0 %',
10,
'83.3 %',
0,
'0 %',
0,
'0 %',
0,
'0 %',
0,
'0 %',
10,
'83.3 %',
2,
'16.7 %',
12,
'100.0 %',
],
[
'2019 - 2020',
86,
'100.0 %',
86,
'100.0 %',
0,
'0 %',
0,
'0 %',
1,
'1.2 %',
1,
'1.2 %',
65,
'75.6 %',
21,
'24.4 %',
86,
'100.0 %',
],
[
'2018 - 2019',
162,
'100.0 %',
161,
'99.4 %',
0,
'0 %',
0,
'0 %',
11,
'6.8 %',
11,
'6.8 %',
118,
'72.8 %',
44,
'27.2 %',
161,
'99.4 %',
],
[
'2017 - 2018',
171,
'100.0 %',
171,
'100.0 %',
0,
'0 %',
0,
'0 %',
21,
'12.3 %',
42,
'24.6 %',
135,
'78.9 %',
36,
'21.1 %',
166,
'97.1 %',
],
]
cy.checkTableStats(tableContents, 'StudytrackOverview')
})
it('Student progress data is shown correctly', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
const tableContents = [
['2020 - 2021', 12, 5, 2, 2, 0, 2, 1],
['2019 - 2020', 86, 13, 15, 32, 11, 2, 13],
['2018 - 2019', 162, 7, 16, 35, 33, 27, 44],
['2017 - 2018', 171, 10, 22, 18, 22, 24, 75],
]
cy.checkTableStats(tableContents, 'StudytrackProgress')
})
it('Studytrack overview graphs render', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
cy.get('[data-cy=Graph-StudytrackProgress]')
.should('contain', 'Less than 30 credits')
.should('contain', '30-59 credits')
.should('contain', 'More than 150 credits')
.should('contain', '5.8%')
.should('contain', '4.3%')
.should('contain', '15.1%')
cy.get('[data-cy=Graph-2018-AverageGraduationTimes]')
.should('contain', '2018 - 2019')
.should('contain', 'n = 11 / 162')
.should('contain', '22 kk')
})
})
/* Tag-tests*/
describe('Basic information -view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
})
it('can create and delete tags for population', () => {
const name = `tag-${new Date().getTime()}`
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Tags').click()
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2018')
@@ -100,7 +360,7 @@ describe('Studyprogramme overview', () => {
it('can create personal tags', () => {
const name = `tag-${new Date().getTime()}`
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Tags').click()
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2018')
@@ -116,7 +376,7 @@ describe('Studyprogramme overview', () => {
const student = '010113437'
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Tags').click()
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2018')
@@ -124,7 +384,7 @@ describe('Studyprogramme overview', () => {
cy.contains(name)
cy.contains('Add tags to students').click()
cy.get('.form > .field > .dropdown').click().get('.ui > .search').type(name).click()
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
@@ -133,7 +393,7 @@ describe('Studyprogramme overview', () => {
cy.contains('Student statistics').click()
cy.get('.prompt').type(student)
cy.contains(student).click()
cy.contains('a', student).click()
cy.contains(name)
cy.go('back')
@@ -143,7 +403,7 @@ describe('Studyprogramme overview', () => {
cy.contains('Student statistics').click()
cy.get('.prompt').type(student)
cy.contains(student).click()
cy.contains('a', student).click()
cy.contains(name).should('not.exist')
})
})
+3 -11
View File
@@ -16,7 +16,7 @@ describe('Teachers page tests', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.cs('course-providers').children('.icon').click()
cy.get('.form > .fluid').click()
cy.contains('Name')
cy.contains('Teacher')
cy.contains(teacher1)
cy.contains(teacher2)
})
@@ -32,21 +32,13 @@ describe('Teachers page tests', () => {
cy.url().should('include', '/teachers')
cy.get('.borderless > :nth-child(3)').click()
cy.get('.prompt').type(teacher1.split()[0])
cy.contains(teacher1).click()
cy.contains('td', teacher1).click()
cy.contains('Aineopintojen harjoitustyö: Tietokantasovellus')
})
it('Check leaderboad works', () => {
cy.get('.borderless > :nth-child(2)').click({ force: true })
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
cy.contains('2019-2020').click({ force: true })
cy.contains('Recalculate this year').click({ force: true })
cy.wait(5000)
cy.reload()
cy.contains('2019-2020').click({ force: true })
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
cy.contains('Passed')
cy.contains('Ahokas Jarmo Ilkka')
cy.contains('2020-2021').click({ force: true })
})
})
+1 -3
View File
@@ -39,9 +39,7 @@ describe('Users tests', () => {
it("mocking normal user shows only the mocked user's programmes", () => {
cy.contains('mocking').should('not.exist')
cy.contains('tr', 'basic').within(() => {
cy.contains('.button', 'Edit').click()
})
cy.cs('user-edit-button-basic').click()
cy.get('i.spy').click()
cy.contains('mocking as basic')
cy.wait(1000)
+1 -1
View File
@@ -106,7 +106,7 @@ Cypress.Commands.add('selectStudyProgramme', name => {
})
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
cy.get(`[data-cy=${tableName}]>tbody`).within(() => {
cy.get(`[data-cy=Table-${tableName}]>tbody`).within(() => {
correctStats.forEach((values, trIndex) => {
cy.get('tr')
.eq(trIndex)
+3 -3
View File
@@ -1003,9 +1003,9 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
},
"follow-redirects": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz",
"integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw=="
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
},
"for-in": {
"version": "1.0.2",
@@ -1,23 +1,7 @@
const router = require('express').Router()
const MandatoryCourses = require('../services/mandatoryCourses')
const {
productivityStatsForStudytrack,
throughputStatsForStudytrack,
optionData,
} = require('../services/studyprogramme')
const { findProgrammeTheses, createThesisCourse, deleteThesisCourse } = require('../services/thesis')
const {
getProductivity,
setProductivity,
getThroughput,
setThroughput,
patchProductivity,
patchThroughput,
} = require('../services/analyticsService')
const { getAllProgrammes, getAllElementDetails } = require('../services/studyrights')
const logger = require('../util/logger')
const programmeStatsSince = new Date('2017-07-31')
router.get('/elementdetails/all', async (req, res) => {
const elementdetails = await getAllElementDetails()
@@ -29,22 +13,6 @@ router.get('/studyprogrammes', async (req, res) => {
res.json(studyrights)
})
router.get('/v2/studyprogrammes/:id/optiondata', async (req, res) => {
const code = req.params.id
let level
if (code.includes('MH')) {
level = 'MSC'
} else if (code.includes('KH')) {
level = 'BSC'
} else {
return res.json([])
}
const data = await optionData(new Date('2017-07-31'), new Date(), code, level)
res.json(data)
})
router.get('/v2/studyprogrammes/:id/mandatory_courses', async (req, res) => {
if (req.params.id) {
const codes = await MandatoryCourses.byStudyprogramme(req.params.id)
@@ -54,109 +22,6 @@ router.get('/v2/studyprogrammes/:id/mandatory_courses', async (req, res) => {
}
})
router.get('/v2/studyprogrammes/:id/productivity', async (req, res) => {
const code = req.params.id
if (code) {
let data = null
try {
data = await getProductivity(code)
} catch (e) {
logger.error(`Failed to get code ${code} productivity`)
}
if (!data) {
try {
const stats = await productivityStatsForStudytrack(code, programmeStatsSince)
data = await setProductivity(stats)
} catch (e) {
logger.error(`Failed to update code ${code} patching status to recalculation erroring`)
}
}
return res.json(data)
} else {
res.status(422).end()
}
})
router.get('/v2/studyprogrammes/productivity/recalculate', async (req, res) => {
const code = req.query.code
await patchThroughput({ [code]: { status: 'RECALCULATING' } })
res.status(200).end()
try {
if (code.includes('MH') || code.includes('KH')) {
const data = await productivityStatsForStudytrack(code, new Date('2017-07-31'))
await setProductivity(data)
} else {
const data = await productivityStatsForStudytrack(code, new Date('2000-07-31'))
await setProductivity(data)
}
} catch (e) {
try {
await patchProductivity({
[code]: { status: 'RECALCULATION ERRORED' },
})
} catch (e) {
logger.error(`Failed to update code ${code} patching status to recalculation erroring`)
return
}
logger.error(`Failed to update productivity stats for code: ${code}, reason: ${e.message}`)
}
logger.info(`Produdtiviy stats recalculation for studyprogramme ${code} done`)
})
router.get('/v2/studyprogrammes/:id/throughput', async (req, res) => {
const code = req.params.id
if (code) {
const thesisPromise = findProgrammeTheses(req.params.id)
let data = null
try {
data = await getThroughput(code)
} catch (e) {
logger.error(`Failed to get code ${code} throughput`)
}
if (!data) {
try {
const result = await throughputStatsForStudytrack(req.params.id, programmeStatsSince.getFullYear())
data = await setThroughput(result)
} catch (e) {
logger.error(`Failed to update code ${code} patching status to recalculation erroring`)
}
}
return res.json({ ...data, thesis: await thesisPromise })
} else {
res.status(422).end()
}
})
router.get('/v2/studyprogrammes/throughput/recalculate', async (req, res) => {
const code = req.query.code
await patchThroughput({ [code]: { status: 'RECALCULATING' } })
res.status(200).end()
try {
if (code.includes('MH') || code.includes('KH')) {
const data = await throughputStatsForStudytrack(code, 2017)
await setThroughput(data)
} else {
const data = await throughputStatsForStudytrack(code, 2000)
await setThroughput(data)
}
} catch (e) {
try {
await patchThroughput({ [code]: { status: 'RECALCULATION ERRORED' } })
} catch (e) {
logger.error(`Failed to update code ${code} patching status to recalculation erroring`)
return
}
logger.error(`Failed to update throughput stats for code: ${code}, reason: ${e.message}`)
}
logger.info(`Throughput stats recalculation for studyprogramme ${code} done`)
})
router.get('/v2/studyprogrammes/:id/thesis', async (req, res) => {
const { id } = req.params
if (id) {
+23 -9
View File
@@ -32,8 +32,10 @@ router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
try {
const result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setBasicStats(result, yearType, specialGroups)
} catch (e) {
@@ -62,8 +64,10 @@ router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
try {
const result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setCreditStats(result, yearType, specialGroups)
} catch (e) {
@@ -92,8 +96,10 @@ router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
try {
const result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setGraduationStats(result, yearType, specialGroups)
} catch (e) {
@@ -110,18 +116,26 @@ router.get('/v2/studyprogrammes/:id/studytrackstats', async (req, res) => {
const code = req.params.id
const graduated = req.query?.graduated
const specialGroups = req.query?.special_groups
const yearsCombined = req.query?.years_combined
if (code) {
let data = null
try {
data = await getStudytrackStats(code, graduated, specialGroups)
data = await getStudytrackStats(code, graduated, specialGroups, yearsCombined)
} catch (e) {
logger.error(`Failed to get code ${code} studytrack stats: ${e}`)
}
if (!data) {
try {
const result = await getStudytrackStatsForStudyprogramme({ studyprogramme: code, specialGroups, graduated })
data = await setStudytrackStats(result, graduated, specialGroups)
const result = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
settings: {
graduated: graduated === 'GRADUATED_INCLUDED',
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
yearsCombined: yearsCombined === 'YEARS_COMBINED',
},
})
data = await setStudytrackStats(result, graduated, specialGroups, yearsCombined)
} catch (e) {
logger.error(`Failed to update code ${code} studytrack stats: ${e}`)
}
@@ -1,97 +1,12 @@
const { redisClient } = require('../services/redis')
const moment = require('moment')
const createRedisKeyForProductivity = id => `PRODUCTIVITY_${id}`
const createRedisKeyForThroughput = id => `THROUGHPUT_${id}`
const createRedisKeyForBasicStats = (id, yearType, specialGroups) => `BASIC_STATS_${id}_${yearType}_${specialGroups}`
const createRedisKeyForCreditStats = (id, yearType, specialGroups) => `CREDIT_STATS_${id}_${yearType}_${specialGroups}`
const createRedisKeyForGraduationStats = (id, yearType, specialGroups) =>
`GRADUATION_STATS_${id}_${yearType}_${specialGroups}`
const createRedisKeyForStudytrackStats = (id, graduated, specialGroups) =>
`STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}`
const getProductivity = async id => {
const redisKey = createRedisKeyForProductivity(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return {
[id]: JSON.parse(dataFromRedis),
}
}
const setProductivity = async data => {
const { id } = data
const redisKey = createRedisKeyForProductivity(id)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return {
[id]: dataToRedis,
}
}
const patchProductivity = async data => {
const { id } = data
const redisKey = createRedisKeyForProductivity(id)
const dataFromRedis = JSON.parse(await redisClient.getAsync(redisKey))
const patchedData = {
...dataFromRedis,
...data,
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(patchedData))
if (setOperationStatus !== 'OK') return null
return {
[id]: patchedData,
}
}
const getThroughput = async id => {
const redisKey = createRedisKeyForThroughput(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return {
[id]: JSON.parse(dataFromRedis),
}
}
const setThroughput = async data => {
const { id, data: dataToSet } = data
const redisKey = createRedisKeyForThroughput(id)
const dataToRedis = {
data: dataToSet.years,
totals: dataToSet.totals,
stTotals: dataToSet.stTotals,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return {
[id]: dataToRedis,
}
}
const patchThroughput = async data => {
const [id, dataToPatch] = Object.entries(data)[0]
const redisKey = createRedisKeyForThroughput(id)
const { status } = dataToPatch
const dataFromRedis = JSON.parse(await redisClient.getAsync(redisKey))
const patchedData = {
...dataFromRedis,
status,
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(patchedData))
if (setOperationStatus !== 'OK') return null
return {
[id]: patchedData,
}
}
const createRedisKeyForStudytrackStats = (id, graduated, specialGroups, yearsCombined) =>
`STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}_${yearsCombined}`
const getBasicStats = async (id, yearType, specialGroups) => {
const redisKey = createRedisKeyForBasicStats(id, yearType, specialGroups)
@@ -153,16 +68,16 @@ const setGraduationStats = async (data, yearType, specialGroups) => {
return dataToRedis
}
const getStudytrackStats = async (id, graduated, specialGroups) => {
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups)
const getStudytrackStats = async (id, graduated, specialGroups, yearsCombined) => {
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups, yearsCombined)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setStudytrackStats = async (data, graduated, specialGroups) => {
const setStudytrackStats = async (data, graduated, specialGroups, yearsCombined) => {
const { id } = data
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups)
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups, yearsCombined)
const dataToRedis = {
...data,
status: 'DONE',
@@ -174,12 +89,6 @@ const setStudytrackStats = async (data, graduated, specialGroups) => {
}
module.exports = {
getProductivity,
setProductivity,
patchProductivity,
getThroughput,
setThroughput,
patchThroughput,
getBasicStats,
setBasicStats,
getCreditStats,
+21 -13
View File
@@ -59,10 +59,12 @@ const findOneByCode = code => {
})
}
const creditsForCourses = async (codes, anonymizationSalt, unifyOpenUniCourses) => {
const creditsForCourses = async (codes, anonymizationSalt, unification) => {
let is_open = false
if (unifyOpenUniCourses) {
if (unification === 'open') is_open = true
if (unification === 'unify') {
is_open = {
[Op.in]: [false, true],
}
@@ -162,23 +164,19 @@ const allCodeAlternatives = async code => {
return sortMainCode([...course.substitutions, code])
}
const yearlyStatsOfNew = async (coursecode, separate, unifyOpenUniCourses, anonymizationSalt) => {
const yearlyStatsOfNew = async (coursecode, separate, unification, anonymizationSalt) => {
const courseForSubs = await Course.findOne({
where: { code: coursecode },
})
let codes = courseForSubs.substitutions ? sortMainCode([...courseForSubs.substitutions, coursecode]) : [coursecode]
if (isOpenUniCourseCode(coursecode) && !unifyOpenUniCourses) {
codes = [coursecode]
}
if (!isOpenUniCourseCode(coursecode) && !unifyOpenUniCourses) {
codes = codes.filter(code => !code.match(/^[A][0-9]|^AY/))
if (unification === 'reqular') {
codes = codes.filter(course => !isOpenUniCourseCode(course))
}
const [credits, course] = await Promise.all([
creditsForCourses(codes, anonymizationSalt, unifyOpenUniCourses),
creditsForCourses(codes, anonymizationSalt, unification),
Course.findOne({
where: {
code: coursecode,
@@ -234,6 +232,7 @@ const yearlyStatsOfNew = async (coursecode, separate, unifyOpenUniCourses, anony
counter.markCreditToStudentCategories(studentnumber, passed, attainment_date, groupcode)
}
const statistics = counter.getFinalStatistics(anonymizationSalt)
return {
...statistics,
coursecode,
@@ -292,10 +291,19 @@ const maxYearsToCreatePopulationFrom = async coursecodes => {
}
const courseYearlyStats = async (coursecodes, separate, unifyOpenUniCourses, anonymizationSalt) => {
const stats = await Promise.all(
coursecodes.map(code => yearlyStatsOfNew(code, separate, unifyOpenUniCourses, anonymizationSalt))
const [unify, reqular, open] = ['unify', 'reqular', 'open']
const statsReqular = await Promise.all(
coursecodes.map(async code => {
const unifyStats = await yearlyStatsOfNew(code, separate, unify, anonymizationSalt)
const reqularStats = await yearlyStatsOfNew(code, separate, reqular, anonymizationSalt)
const openStats = await yearlyStatsOfNew(code, separate, open, anonymizationSalt)
return { unifyStats, reqularStats, openStats }
})
)
return stats
return statsReqular
}
const nameLikeTerm = name => {
+1 -1
View File
@@ -3,7 +3,7 @@ const { isProduction, pateToken } = require('../conf-backend')
const { ApplicationError } = require('../util/customErrors')
const pateClient = axios.create({
baseURL: 'https://pate.toska.cs.helsinki.fi',
baseURL: 'https://importer.cs.helsinki.fi/api/pate/',
params: {
token: pateToken,
},
@@ -1,402 +0,0 @@
const sequelize = require('sequelize')
const { Op } = sequelize
const {
Credit,
Course,
Organization,
Studyright,
StudyrightElement,
ElementDetail,
Transfer,
Student,
SemesterEnrollment,
Semester,
} = require('../models')
const { formatStudyright, formatStudent, formatTransfer } = require('./studyprogrammeHelpers')
const { getCurrentSemester } = require('./semesters')
const whereStudents = studentnumbers => {
return studentnumbers ? studentnumbers : { [Op.not]: null }
}
const sinceDate = since => {
return since ? { [Op.gte]: since } : { [Op.not]: null }
}
const studytrackStudents = async studentnumbers =>
(
await Student.findAll({
where: {
studentnumber: {
[Op.in]: studentnumbers,
},
},
})
).map(formatStudent)
const enrolledStudents = async (studytrack, since, studentnumbers) => {
const currentSemester = await getCurrentSemester()
const students = await Student.findAll({
attributes: ['studentnumber'],
include: [
{
model: Studyright,
include: [
{
model: StudyrightElement,
required: true,
where: {
code: studytrack,
},
},
],
attributes: ['studyrightid'],
},
{
model: SemesterEnrollment,
attributes: ['semestercode'],
include: [
{
model: Semester,
required: true,
where: {
semestercode: currentSemester.semestercode,
},
},
],
where: {
enrollmenttype: 1,
},
},
],
where: {
studentnumber: {
[Op.in]: studentnumbers,
},
},
})
return students.filter(s => s.semester_enrollments?.length)
}
const absentStudents = async (studytrack, studentnumbers) => {
const currentSemester = await getCurrentSemester()
const students = await Student.findAll({
attributes: ['studentnumber'],
include: [
{
model: Studyright,
include: [
{
model: StudyrightElement,
required: true,
where: {
code: studytrack,
},
},
],
attributes: ['studyrightid'],
},
{
model: SemesterEnrollment,
attributes: ['semestercode'],
where: {
enrollmenttype: 2,
semestercode: currentSemester.semestercode,
},
},
],
where: {
studentnumber: {
[Op.in]: studentnumbers,
},
},
})
return students
}
const allStudyrights = async (studytrack, since, studentnumbers) =>
(
await Studyright.findAll({
include: [
{
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
where: {
code: studytrack,
},
},
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
where: {
student_studentnumber: whereStudents(studentnumbers),
},
})
).map(formatStudyright)
const startedStudyrights = async (studytrack, since, studentnumbers) =>
(
await Studyright.findAll({
include: [
{
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
where: {
code: studytrack,
},
},
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
where: {
studystartdate: {
[Op.gte]: since,
},
student_studentnumber: whereStudents(studentnumbers),
},
})
).map(formatStudyright)
const graduatedStudyRights = async (studytrack, since, studentnumbers) =>
(
await Studyright.findAll({
include: [
{
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
where: {
code: studytrack,
},
},
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
where: {
graduated: 1,
enddate: sinceDate(since),
student_studentnumber: whereStudents(studentnumbers),
},
})
).map(formatStudyright)
const followingStudyrights = async (since, studentnumbers) =>
(
await Studyright.findAll({
include: [
{
model: StudyrightElement,
required: true,
include: [
{
model: ElementDetail,
attributes: ['name'],
},
],
attributes: ['code'],
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
where: {
studystartdate: {
[Op.gte]: since,
},
extentcode: 2,
student_studentnumber: whereStudents(studentnumbers),
},
})
).map(formatStudyright)
const transfersAway = async (studytrack, since) =>
(
await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
sourcecode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
).map(formatTransfer)
const transfersTo = async (studytrack, since) =>
(
await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
targetcode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
).map(formatTransfer)
const allTransfers = async (studytrack, since) => {
const transferredTo = await transfersTo(studytrack, since)
const transferredAway = await transfersAway(studytrack, since)
return [...transferredTo, ...transferredAway]
}
const getProgrammesStudyrights = 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 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 getTransferredCredits = async (provider, since) =>
await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date'],
include: {
model: Course,
attributes: ['code'],
required: true,
include: {
model: Organization,
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: {
[Op.eq]: [9],
},
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.gte]: since,
},
},
})
const getThesisCredits = async (provider, since, thesisType, studentnumbers) =>
await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: ['code'],
required: true,
where: {
course_unit_type: {
[Op.in]: thesisType,
},
},
include: {
model: Organization,
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: {
[Op.notIn]: [10, 9, 7],
},
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.gte]: since,
},
student_studentnumber: whereStudents(studentnumbers),
},
})
module.exports = {
studytrackStudents,
enrolledStudents,
absentStudents,
allStudyrights,
startedStudyrights,
graduatedStudyRights,
followingStudyrights,
transfersAway,
transfersTo,
allTransfers,
getProgrammesStudyrights,
getCreditsForStudyProgramme,
getTransferredCredits,
getThesisCredits,
}
+3 -2
View File
@@ -1,3 +1,4 @@
const moment = require('moment')
const sequelize = require('sequelize')
const { Semester } = require('../models')
const Op = sequelize.Op
@@ -28,8 +29,8 @@ const getSemestersAndYears = async () => {
acc.years[yearcode] = {
yearcode,
yearname,
startdate: Math.min(acc.years[yearcode].startdate, startdate),
enddate: Math.max(acc.years[yearcode].enddate, enddate),
startdate: moment.min(moment(acc.years[yearcode].startdate), moment(startdate)),
enddate: moment.max(moment(acc.years[yearcode].enddate), moment(enddate)),
}
}
return acc
File diff suppressed because it is too large Load Diff
@@ -13,30 +13,9 @@ const {
startedStudyrights,
transfersAway,
transfersTo,
} = require('./newStudyprogramme')
} = require('./studyprogramme')
const { getYearStartAndEndDates } = require('../util/semester')
const getAllStats = async ({ studyprogramme, years, isAcademicYear, includeAllSpecials }) => {
const { graphStats, tableStats } = getStatsBasis(years)
await Promise.all(
years.map(async year => {
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate,
endDate,
includeAllSpecials,
})
const all = await allStudyrights(studyprogramme, null, studentnumbersOfTheYear)
graphStats[indexOf(years, year)] = all.length
tableStats[year] += all.length
})
)
return { graphStats, tableStats }
}
const getStartedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const { graphStats, tableStats } = getStatsBasis(years)
@@ -126,14 +105,12 @@ const getTransferredToStats = async ({ studyprogramme, since, years, isAcademicY
return { graphStats, tableStats }
}
const getBasicStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
const getBasicStatsForStudytrack = async ({ studyprogramme, settings }) => {
const { includeAllSpecials, isAcademicYear } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const queryParameters = { studyprogramme, since, years, isAcademicYear, includeAllSpecials }
const all = await getAllStats(queryParameters)
const started = await getStartedStats(queryParameters)
const graduated = await getGraduatedStats(queryParameters)
const cancelled = await getCancelledStats(queryParameters)
@@ -141,19 +118,18 @@ const getBasicStatsForStudytrack = async ({ studyprogramme, yearType, specialGro
const transferredTo = await getTransferredToStats(queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles = tableTitles['basics'][specialGroups]
const titles = tableTitles['basics'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
const tableStats = reversedYears.map(year =>
includeAllSpecials
? [
year,
all.tableStats[year],
started.tableStats[year],
graduated.tableStats[year],
cancelled.tableStats[year],
transferredAway.tableStats[year],
transferredTo.tableStats[year],
]
: [year, all.tableStats[year], started.tableStats[year], graduated.tableStats[year]]
: [year, started.tableStats[year], graduated.tableStats[year]]
)
return {
@@ -15,7 +15,7 @@ const {
getCreditsForStudyProgramme,
getTransferredCredits,
allTransfers,
} = require('./newStudyprogramme')
} = require('./studyprogramme')
// Fetches all the credits for the studyprogramme and divides them into major-students and non-major students credits
// Division is done on the basis that whether the student had a primary studyright to the programme on the attainment_date
@@ -69,9 +69,10 @@ const getTransferredCreditStats = async ({ studyprogramme, since, years, isAcade
return { graphStats, tableStats }
}
const getCreditStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
// Fetches all credits for the studytrack and combines them into the statistics for the table
// and graph in the studyprogramme overview
const getCreditStatsForStudytrack = async ({ studyprogramme, settings }) => {
const { isAcademicYear, includeAllSpecials } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
@@ -80,7 +81,7 @@ const getCreditStatsForStudytrack = async ({ studyprogramme, yearType, specialGr
const transferred = await getTransferredCreditStats(queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles = tableTitles['credits'][specialGroups]
const titles = tableTitles['credits'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
const tableStats = reversedYears.map(year =>
includeAllSpecials
@@ -16,12 +16,14 @@ const {
} = require('./studyprogrammeHelpers')
const {
graduatedStudyRights,
transfersAway,
transfersTo,
getThesisCredits,
followingStudyrights,
} = require('./newStudyprogramme')
previousStudyrights,
allStudyrights,
allTransfers,
} = require('./studyprogramme')
const { getYearStartAndEndDates } = require('../util/semester')
const { getAllProgrammes } = require('./studyrights')
const getGraduatedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const { graphStats, tableStats } = getStatsBasis(years)
@@ -76,9 +78,9 @@ const getThesisStats = async ({ studyprogramme, since, years, isAcademicYear, in
}
const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
let graduationAmounts = getYearsObject(years)
let graduationTimes = getYearsObject(years, true)
let totalAmounts = isAcademicYear ? getAcademicYearsObject(years) : getYearsObject(years)
let graduationAmounts = getYearsObject({ years })
let graduationTimes = getYearsObject({ years, emptyArrays: true })
let totalAmounts = isAcademicYear ? getAcademicYearsObject({ years }) : getYearsObject({ years })
await Promise.all(
years.map(async year => {
@@ -104,8 +106,8 @@ const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademic
// The maximum amount of months in the graph depends on the studyprogramme intended graduation time
const comparisonValue = studyprogramme.includes('KH') ? 72 : 48
const medians = getYearsObject(years, true)
const means = getYearsObject(years, true)
const medians = getYearsObject({ years, emptyArrays: true })
const means = getYearsObject({ years, emptyArrays: true })
// HighCharts graph require the data to have this format (ie. actual value, "empty value")
years.forEach(year => {
@@ -123,43 +125,101 @@ const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademic
return { medians, means, graduationAmounts, totalAmounts }
}
const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const graduated = await graduatedStudyRights(studyprogramme, since)
const graduatedStudents = graduated.map(s => s.studentnumber)
let studyrightsAfterThisProgramme = await followingStudyrights(since, graduatedStudents)
const studyprogrammeCodes = uniqBy(studyrightsAfterThisProgramme, 'code').map(s => ({ code: s.code, name: s.name }))
const stats = {}
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject(years) }))
if (!includeAllSpecials) {
const transferredAway = await transfersAway(studyprogramme, since)
const transferredTo = await transfersTo(studyprogramme, since)
const transfers = [...transferredAway, ...transferredTo].map(s => s.studyrightid)
studyrightsAfterThisProgramme.filter(s => !transfers.includes(s.studyrightid) && !s.canceldate)
}
studyrightsAfterThisProgramme.forEach(({ studystartdate, code }) => {
const startYear = defineYear(studystartdate, isAcademicYear)
if (years.includes(startYear) && code) {
stats[code][startYear] += 1
}
})
// Creates (studyrightid, transfer)-map out of programmes transfers
const getTransferStudyrightMap = async (studyprogramme, since) => {
const transfers = await allTransfers(studyprogramme, since)
const studyrightTransferMap = new Map()
transfers.forEach(t => studyrightTransferMap.set(t.studyrightid, t))
return studyrightTransferMap
}
const formatStats = (stats, years) => {
const tableStats = Object.values(stats)
.filter(p => years.map(year => p[year]).find(started => started !== 0)) // Filter out programmes with no-one started between the selected years
.map(p => [p.code, p.name['fi'], ...years.map(year => p[year])])
.filter(p => p[0].includes('MH'))
const graphStats = Object.values(stats)
.filter(p => years.map(year => p[year]).find(started => started !== 0)) // Filter out programmes with no-one started between the selected years
.map(p => ({ name: p.name['fi'], code: p.code, data: years.map(year => p[year]) }))
.filter(p => p.code.includes('MH'))
return { tableStats, graphStats }
}
const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
const getProgrammesBeforeStarting = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const all = await allStudyrights(studyprogramme)
const allStudents = all.map(s => s.studentnumber)
// Get studyrights for bachelor studyprogrammes. This excludes studytracks.
const programmes = await getAllProgrammes()
let studyrightsBeforeMasters = await previousStudyrights(since, programmes, allStudents)
const studyprogrammeCodes = uniqBy(studyrightsBeforeMasters, 'code').map(s => ({ code: s.code, name: s.name }))
const stats = {}
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
// Map transfers to the Master's programme
const transfers = await getTransferStudyrightMap(studyprogramme, since)
studyrightsBeforeMasters.forEach(({ studentnumber, code, givendate }) => {
const masterStudyright = all.find(s => s.studentnumber === studentnumber)
const transfer = transfers.get(masterStudyright.studyrightid)
// If special studyrights are excluded, transfers to the master's programme should be excluded
if (!includeAllSpecials && transfers.get(masterStudyright.studyrightid)) {
return
}
// If all studyrights are included, and transfer exists, define the start year in the programme, based on when the student transferred to it
// Otherwise, the year is defined based on when the student started in the chosen master's programme
const programmeStartDate = includeAllSpecials && transfer ? transfer.transferdate : masterStudyright?.studystartdate
const startYear = defineYear(programmeStartDate, isAcademicYear)
// The master studyright should have been given on the same date as the bachelor's
if (years.includes(startYear) && code && masterStudyright.givendate.getTime() === givendate.getTime()) {
stats[code][startYear] += 1
}
})
const graphAndTableStats = formatStats(stats, years)
return graphAndTableStats
}
const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const graduated = await graduatedStudyRights(studyprogramme, since)
const graduatedStudents = graduated.map(s => s.studentnumber)
// Get studyrights for masters studyprogramme. This excludes studytracks.
const programmes = await getAllProgrammes()
let studyrightsAfterThisProgramme = await followingStudyrights(since, programmes, graduatedStudents)
const studyprogrammeCodes = uniqBy(studyrightsAfterThisProgramme, 'code').map(s => ({ code: s.code, name: s.name }))
const stats = {}
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
const transfers = await getTransferStudyrightMap(studyprogramme, since)
studyrightsAfterThisProgramme.forEach(({ studentnumber, studystartdate, code, givendate }) => {
const bachelorStudyright = graduated.find(s => s.studentnumber === studentnumber)
// If special studyrights are excluded, transfers to the bachelor programme should be excluded
if (!includeAllSpecials && transfers.get(bachelorStudyright.studyrightid)) {
return
}
// If all studyrights are included, and transfer exists, define the start year in the programme, based on when the student transferred to it
// Otherwise, the year is defined based on when the student started in the chosen master's programme
const startYear = defineYear(studystartdate, isAcademicYear)
// The master studyright should have been given on the same date as the bachelor's
if (years.includes(startYear) && code && bachelorStudyright.givendate.getTime() === givendate.getTime()) {
stats[code][startYear] += 1
}
})
const graphAndTableStats = formatStats(stats, years)
return graphAndTableStats
}
const getGraduationStatsForStudytrack = async ({ studyprogramme, settings }) => {
const { isAcademicYear, includeAllSpecials } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
@@ -167,12 +227,14 @@ const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, speci
const thesis = await getThesisStats(queryParameters)
const graduated = await getGraduatedStats(queryParameters)
const graduationTimeStats = await getGraduationTimeStats(queryParameters)
const programmesAfterGraduation = await getProgrammesAfterGraduation(queryParameters)
const programmesBeforeOrAfter = studyprogramme.includes('KH')
? await getProgrammesAfterGraduation(queryParameters)
: await getProgrammesBeforeStarting(queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles = ['', 'Graduated', 'Wrote thesis']
const programmesAfterTitles = ['Code', 'Programme', ...years]
const programmesBeforeOrAfterTitles = ['Code', 'Programme', ...years]
const tableStats = reversedYears.map(year => [year, graduated.tableStats[year], thesis.tableStats[year]])
return {
@@ -194,9 +256,9 @@ const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, speci
graduationMeanTime: graduationTimeStats.means,
graduationAmounts: graduationTimeStats.graduationAmounts,
totalAmounts: graduationTimeStats.totalAmounts,
programmesAfterTableStats: programmesAfterGraduation.tableStats,
programmesAfterGraphStats: programmesAfterGraduation.graphStats,
programmesAfterTitles,
programmesBeforeOrAfterTableStats: programmesBeforeOrAfter.tableStats,
programmesBeforeOrAfterGraphStats: programmesBeforeOrAfter.graphStats,
programmesBeforeOrAfterTitles,
}
}
@@ -28,11 +28,19 @@ const getCorrectStudentnumbers = async ({ codes, startDate, endDate, includeAllS
return studentnumbers
}
const defineStartDate = (studyright_elements, studystartdate) => {
if (studyright_elements?.length && studyright_elements[0].startdate > studystartdate) {
return studyright_elements[0].startdate
}
return studystartdate
}
const formatStudyright = studyright => {
const {
studyrightid,
studystartdate,
enddate,
givendate,
canceldate,
graduated,
prioritycode,
@@ -43,8 +51,9 @@ const formatStudyright = studyright => {
return {
studyrightid,
studystartdate,
studystartdate: defineStartDate(studyright_elements, studystartdate),
enddate,
givendate,
canceldate,
graduated,
prioritycode,
@@ -69,17 +78,18 @@ const formatStudent = student => {
}
const formatTransfer = transfer => {
const { sourcecode, transfercode, transferdate, studyrightid } = transfer
const { sourcecode, targetcode, transferdate, studyrightid } = transfer
return {
sourcecode,
transfercode,
targetcode,
transferdate,
studyrightid,
}
}
const getYearsArray = (since, isAcademicYear) => {
const getYearsArray = (since, isAcademicYear, yearsCombined) => {
const years = []
if (yearsCombined) return [`${since} - ${new Date().getFullYear()}`]
for (let i = since; i <= new Date().getFullYear(); i++) {
const year = isAcademicYear ? `${i} - ${i + 1}` : i
years.push(year)
@@ -87,7 +97,7 @@ const getYearsArray = (since, isAcademicYear) => {
return years
}
const getYearsObject = (years, emptyArrays = false) => {
const getYearsObject = ({ years, emptyArrays = false }) => {
let yearsObject = {}
for (const year of years) {
yearsObject = { ...yearsObject, [year]: emptyArrays ? [] : 0 }
@@ -95,7 +105,7 @@ const getYearsObject = (years, emptyArrays = false) => {
return yearsObject
}
const getAcademicYearsObject = (years, emptyArrays = false) => {
const getAcademicYearsObject = ({ years, emptyArrays = false }) => {
let yearsObject = {}
for (const year of years) {
yearsObject = { ...yearsObject, [`${year} - ${year + 1}`]: emptyArrays ? [] : 0 }
@@ -106,7 +116,7 @@ const getAcademicYearsObject = (years, emptyArrays = false) => {
const getStatsBasis = years => {
return {
graphStats: new Array(years.length).fill(0),
tableStats: getYearsObject(years),
tableStats: getYearsObject({ years }),
}
}
@@ -258,8 +268,8 @@ const getCreditThresholds = studyprogramme => {
const tableTitles = {
basics: {
SPECIAL_EXCLUDED: ['', 'All', 'Started studying', 'Graduated'],
SPECIAL_INCLUDED: ['', 'All', 'Started studying', 'Graduated', 'Cancelled', 'Transferred away', 'Transferred to'],
SPECIAL_EXCLUDED: ['', 'Started studying', 'Graduated'],
SPECIAL_INCLUDED: ['', 'Started studying', 'Graduated', 'Cancelled', 'Transferred away', 'Transferred to'],
},
credits: {
SPECIAL_EXCLUDED: ['', 'Total', 'Major students credits', 'Transferred credits'],
@@ -8,72 +8,132 @@ const {
setGraduationStats,
setStudytrackStats,
} = require('../services/analyticsService')
const logger = require('../util/logger')
const updateBasicView = async code => {
const yearTypes = ['ACADEMIC_YEAR', 'CALENDAR_YEAR']
const special = ['SPECIAL_INCLUDED', 'SPECIAL_EXCLUDED']
const specialCalendar = {
yearType: 'CALENDAR_YEAR',
specialGroups: 'SPECIAL_INCLUDED',
}
const specialAcademic = {
yearType: 'ACADEMIC_YEAR',
specialGroups: 'SPECIAL_INCLUDED',
}
const specialExcludedCalendar = {
yearType: 'CALENDAR_YEAR',
specialGroups: 'SPECIAL_EXCLUDED',
}
yearTypes.forEach(async yearType => {
special.forEach(async specialGroups => {
const specialExcludedAcademic = {
yearType: 'ACADEMIC_YEAR',
specialGroups: 'SPECIAL_EXCLUDED',
}
const options = [specialCalendar, specialAcademic, specialExcludedCalendar, specialExcludedAcademic]
for (const option of options) {
try {
const basicStats = await getBasicStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setBasicStats(basicStats, yearType, specialGroups)
await setBasicStats(basicStats, option.yearType, option.specialGroups)
const creditStats = await getCreditStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setCreditStats(creditStats, yearType, specialGroups)
await setCreditStats(creditStats, option.yearType, option.specialGroups)
const graduationStats = await getGraduationStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setGraduationStats(graduationStats, yearType, specialGroups)
})
})
return 'OK'
await setGraduationStats(graduationStats, option.yearType, option.specialGroups)
} catch (e) {
logger.error(e)
}
return 'OK'
}
}
const updateStudytrackView = async code => {
const specialIncluded = 'SPECIAL_INCLUDED'
const specialExcluded = 'SPECIAL_EXCLUDED'
const graduatedIncluded = 'GRADUATED_INCLUDED'
const graduatedExcluded = 'GRADUATED_EXCLUDED'
const specialGraduatedCombined = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
yearsCombined: 'YEARS_COMBINED',
}
const specialExcludedGraduatedCombined = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
yearsCombined: 'YEARS_SEPARATED',
}
const specialGraduatedExcludedCombined = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
yearsCombined: 'YEARS_COMBINED',
}
const specialExcludedGraduatedExcludedCombined = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
yearsCombined: 'YEARS_COMBINED',
}
const specialGraduatedSeparated = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
yearsCombined: 'YEARS_COMBINED',
}
const specialExcludedGraduatedSeparated = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
yearsCombined: 'YEARS_SEPARATED',
}
const specialGraduatedExcludedSeparated = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
yearsCombined: 'YEARS_COMBINED',
}
const specialExcludedGraduatedExcludedSeparated = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
yearsCombined: 'YEARS_COMBINED',
}
const statsSpecialAndGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialIncluded,
graduated: graduatedIncluded,
})
await setStudytrackStats(statsSpecialAndGraduated, graduatedIncluded, specialIncluded)
const options = [
specialGraduatedCombined,
specialExcludedGraduatedCombined,
specialGraduatedExcludedCombined,
specialExcludedGraduatedExcludedCombined,
specialGraduatedSeparated,
specialExcludedGraduatedSeparated,
specialGraduatedExcludedSeparated,
specialExcludedGraduatedExcludedSeparated,
]
const statsSpecialNotGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialIncluded,
graduated: graduatedExcluded,
})
await setStudytrackStats(statsSpecialNotGraduated, graduatedExcluded, specialIncluded)
const statsNotSpecialAndGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialExcluded,
graduated: graduatedIncluded,
})
await setStudytrackStats(statsNotSpecialAndGraduated, graduatedIncluded, specialExcluded)
const statsNotSpecialNotGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialExcluded,
graduated: graduatedExcluded,
})
await setStudytrackStats(statsNotSpecialNotGraduated, graduatedExcluded, specialExcluded)
for (const option of options) {
try {
const stats = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
settings: {
specialGroups: option.specialGroups === 'SPECIAL_INCLUDED',
graduated: option.graduated === 'GRADUATED_INCLUDED',
yearsCombined: option.yearsCombined === 'YEARS_COMBINED',
},
})
await setStudytrackStats(stats, option.graduated, option.specialGroups, option.yearsCombined)
} catch (e) {
logger.error(e)
}
}
return 'OK'
}
@@ -9,7 +9,6 @@ const {
getYearsArray,
getPercentage,
getYearsObject,
getAcademicYearsObject,
getCorrectStudentnumbers,
getCreditThresholds,
getCreditGraphStats,
@@ -22,7 +21,7 @@ const {
enrolledStudents,
absentStudents,
graduatedStudyRights,
} = require('./newStudyprogramme')
} = require('./studyprogramme')
const { getAcademicYearDates } = require('../util/semester')
const getStudentData = (students, thresholdKeys, thresholdAmounts) => {
@@ -79,8 +78,7 @@ const getGraduationTimeStats = async ({
// Goes through the programme and all its studytracks for the said year and adds the wanted stats to the data objects
const getStudytrackDataForTheYear = async ({
studyprogramme,
special,
includeGraduated,
settings,
studytracks,
studytrackNames,
year,
@@ -89,6 +87,8 @@ const getStudytrackDataForTheYear = async ({
creditThresholdKeys,
creditThresholdAmounts,
}) => {
const { specialGroups: includeAllSpecials, graduated: includeGraduated, yearsCombined } = settings
const { startDate, endDate } = getAcademicYearDates(year, yearsCombined)
const {
mainStatsByYear,
mainStatsByTrack,
@@ -100,7 +100,6 @@ const getStudytrackDataForTheYear = async ({
totalAmounts,
emptyTracks,
} = data
const { startDate, endDate } = getAcademicYearDates(year)
await Promise.all(
studytracks.map(async track => {
@@ -110,7 +109,7 @@ const getStudytrackDataForTheYear = async ({
codes,
startDate,
endDate,
includeAllSpecials: special,
includeAllSpecials,
includeGraduated,
})
@@ -122,7 +121,7 @@ const getStudytrackDataForTheYear = async ({
const enrolled = await enrolledStudents(track, startDate, studentnumbers)
const absent = await absentStudents(track, studentnumbers)
const cancelled = all.filter(s => s.canceldate !== null && s.canceldate !== undefined)
const graduated = await graduatedStudyRights(track, null, studentnumbers)
const graduated = await graduatedStudyRights(track, startDate, studentnumbers)
// If the track has no stats for that year, it should be removed from the table and dropdown options
if (all.length === 0) {
@@ -137,14 +136,14 @@ const getStudytrackDataForTheYear = async ({
// Count stats for the credit progress table for the year per track
creditTableStats[track] = [
...creditTableStats[track],
[`${year} - ${year + 1}`, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
[year, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
]
// Count stats for the main studytrack table grouped by tracks
mainStatsByTrack[track] = [
...mainStatsByTrack[track],
[
`${year} - ${year + 1}`,
year,
all.length,
getPercentage(all.length, all.length),
started.length,
@@ -170,9 +169,7 @@ const getStudytrackDataForTheYear = async ({
mainStatsByYear[year] = [
...mainStatsByYear[year],
[
studytrackNames[track]?.name['fi']
? `${studytrackNames[track]?.name['fi']}, ${track}`
: `${year} - ${year + 1}`,
studytrackNames[track]?.name['fi'] ? `${studytrackNames[track]?.name['fi']}, ${track}` : year,
all.length,
getPercentage(all.length, all.length),
started.length,
@@ -195,9 +192,9 @@ const getStudytrackDataForTheYear = async ({
]
// Count stats for the graduation time charts grouped by year
totalAmounts[track][`${year} - ${year + 1}`] = await all.length
totalAmounts[track][year] = await all.length
await getGraduationTimeStats({
year: `${year} - ${year + 1}`,
year,
graduated,
studyprogramme,
track,
@@ -224,7 +221,7 @@ const getStudytrackOptions = (studyprogramme, studytrackNames, studytracks, empt
// Creates empty objects for each statistic type, which are then updated with the studytrack data
const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
const mainStatsByYear = getYearsObject(years, true)
const mainStatsByYear = getYearsObject({ years, emptyArrays: true })
const mainStatsByTrack = {}
const creditGraphStats = {}
const creditTableStats = {}
@@ -238,10 +235,10 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
mainStatsByTrack[track] = []
creditGraphStats[track] = getCreditGraphStats(studyprogramme, years)
creditTableStats[track] = []
graduationMedianTime[track] = getAcademicYearsObject(years, true)
graduationMeanTime[track] = getAcademicYearsObject(years, true)
graduationAmounts[track] = getAcademicYearsObject(years)
totalAmounts[track] = getAcademicYearsObject(years)
graduationMedianTime[track] = getYearsObject({ years, emptyArrays: true })
graduationMeanTime[track] = getYearsObject({ years, emptyArrays: true })
graduationAmounts[track] = getYearsObject({ years })
totalAmounts[track] = getYearsObject({ years })
})
return {
@@ -258,12 +255,10 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
}
// Combines all the data for the Populations and Studytracks -view
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated, specialGroups }) => {
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, settings }) => {
const isAcademicYear = true
const special = specialGroups === 'SPECIAL_INCLUDED'
const includeGraduated = graduated === 'GRADUATED_INCLUDED'
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear())
const years = getYearsArray(since.getFullYear(), isAcademicYear, settings.yearsCombined)
const associations = await getAssociations()
const studytracks = associations.programmes[studyprogramme]
@@ -279,8 +274,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
years.map(async year => {
return await getStudytrackDataForTheYear({
studyprogramme,
special,
includeGraduated,
settings,
studytracks,
studytrackNames,
year,
@@ -296,7 +290,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
return {
id: studyprogramme,
years: getYearsArray(since.getFullYear(), isAcademicYear),
years: getYearsArray(since.getFullYear(), isAcademicYear, settings.yearsCombined),
mainStatsByTrack: data.mainStatsByTrack,
mainStatsByYear: data.mainStatsByYear,
creditTableStats: data.creditTableStats,
@@ -306,7 +300,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
graduationAmounts: data.graduationAmounts,
totalAmounts: data.totalAmounts,
studytrackOptions,
includeGraduated,
includeGraduated: settings.graduated,
populationTitles: tableTitles['studytracks'],
creditTableTitles: tableTitles['creditProgress'][studyprogramme.includes('KH') ? 'bachelor' : 'master'],
}
+3 -1
View File
@@ -107,7 +107,9 @@ const findTopTeachers = async yearcode => {
.filter(isRegularCourse)
.map(credit => {
const { credits, course, credittypecode } = credit
const teachers = credit.teachers.map(({ id, name }) => ({ id, name }))
const teachers = credit.teachers
.filter(({ id }) => !id.includes('hy-hlo-org')) // Remove faculties from the leaderboards
.map(({ id, name }) => ({ id, name }))
const passed = Credit.passed(credit) || Credit.improved(credit)
const failed = Credit.failed(credit)
const transferred = credittypecode === 9
@@ -93,7 +93,9 @@ const calculateStatusGraduated = async (unixMillis, showByYear) => {
// init faculty
if (!acc[curr.faculty_code]) {
const org = orgs.find(o => o.code === curr.faculty_code)
acc[curr.faculty_code] = { name: org.name, yearly: {}, drill: {} }
if (org) {
acc[curr.faculty_code] = { name: org.name, yearly: {}, drill: {} }
}
}
if (!acc[curr.faculty_code]['yearly'][data.year])
acc[curr.faculty_code]['yearly'][data.year] = { acc: 0, total: 0 }
+5 -3
View File
@@ -29,10 +29,12 @@ const getPassingSemester = (startYear, date) => {
return yearCount < 6 ? `${yearCount}-${semester}` : 'LATER'
}
const getAcademicYearDates = year => {
const getAcademicYearDates = (year, yearsCombined) => {
const startYear = typeof year === 'number' ? year : Number(year.slice(0, 4))
const correctEndYear = yearsCombined ? new Date().getFullYear() : startYear
return {
startDate: `${year}-${semesterStart['FALL']}`,
endDate: `${moment(year, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`,
startDate: `${startYear}-${semesterStart['FALL']}`,
endDate: `${moment(correctEndYear, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`,
}
}
+6 -6
View File
@@ -6837,9 +6837,9 @@
}
},
"follow-redirects": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
"integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg=="
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
},
"for-in": {
"version": "1.0.2",
@@ -15616,9 +15616,9 @@
}
},
"url-parse": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -1,5 +1,4 @@
import PopulationStatistics from './populationStatistics'
import PopulationOverview from './populationOverview'
import CoolDataScience from './coolDataScience'
import CourseStatistics from './courseStatistics'
import CoursePopulation from './coursePopulation'
@@ -7,7 +6,6 @@ import Studyprogramme from './studyprogramme'
export default {
PopulationStatistics,
PopulationOverview,
CoolDataScience,
CourseStatistics,
CoursePopulation,
@@ -1,24 +0,0 @@
export default {
PopulationProgress: `Tämä koontisivu sisältää koulutusohjelman kaikkien opiskelijoiden perustiedot lukuvuosittain. Luvut perustuvat opinto-oikeuksiin kyseisessä ohjelmassa.
Population progress:
Vuosittain ohjelmassa aloittaneet, ohjelman keskeyttäneet / peruneet sekä ohjelmaan siirtyneet opiskelijat.
Huomattavaa on, että sama opiskelija voi olla kahdessa luokassa, jos hän on esimerkiksi aloittanut opinnot jo, mutta keskeyttänyt ne lukuvuoden aikana.
Tarkemmat tiedot saat esille klikkaamalla nuolta kunkin lukuvuoden kohdalla.`,
YearlyProductivity: `
Yearly productivity:
Koulutusohjelman kunakin kalenterivuonna tuottamat opintopisteet, valmistuneiden määrän, ohjelman pääaineopiskelijoiden
opintopisteet sekä muiden kuin pääaineopiskelijoiden opintopisteet. Mukana on myös kuhunkin koulutusohjelmaan hyväksiluetut opintopisteet.
Vanhojen koulutusohjelmien lakkauttaminen ja opiskelijoiden siirtäminen uusiin ohjelmiin voi aiheuttaa tavallista suuremmat opintopistemäärät lukuvuodelle 2020-2021.`,
Year: `Lukuvuosi`,
Students: `Opiskelijat, jotka ovat koulutusohjelmassa aloittaneet, opinto-oikeutensa peruneet sekä koulutusohjelmaan tai siitä pois siirtyneet.`,
Started: `Koulutusohjelmassa kunakin lukuvuonna aloittaneet opiskelijat. EI sisällä ohjelmaan siirtyneitä opiskelijoita.`,
Cancelled: `Koulutusohjelmaan oikeuttavan opinto-oikeutensa kunakin lukuvuonna peruttaneet`,
Graduated: `Tiettynä lukuvuonna aloittaneista opiskelijoista on valmistunut`,
Transferred: `Koulutusohjelmaan kyseisenä lukuvuonna siirtyneet opiskelijat.
Vanhojen koulutusohjelmien lakkauttaminen aiheuttaa tavallista suuremmat siirtoluvut lukuvuodelle 2020-2021.`,
Credits: `Kunakin vuonna aloittaneista opiskelijoista ne, jotka ovat ylittäneet tietyn opintopisterajapyykin`,
Thesis: `Loppututkielman kirjoittaneiden määrä`,
}
@@ -4,11 +4,11 @@ export default {
Calendar year: Näyttää tilastot kalenterivuosille jaoteltuna, esimerkiksi 1.1.2019-31.12.2019
`,
StudentToggle: `
All studyrights included: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat, opintonsa keskeyttäneet opiskelijat, ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
All studyrights: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat, opintonsa keskeyttäneet opiskelijat, ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
Special studyrights excluded: Valinnasta ja kaikista luvuista on poissuljettu kaikki edellä mainitut erikoisryhmät.
`,
GradToggle: `
GraduatedToggle: `
Graduated included: Valinnassa ovat mukana ohjelmasta jo valmistuneet opiskelijat.
Graduated excluded: Valinnasta on poistettu ohjelmasta jo valmistuneet opiskelijat.
@@ -48,10 +48,12 @@ export default {
**Mean time**: Näyttää kyseisenä vuonna valmistuneiden opiskelijoiden opintojen keston **keskiarvon** kuukausina. Luku sisältää mahdolliset poissaolokuukaudet.
n = **valmistuneet opiskelijat / kaikki ohjelmassa kyseisenä vuonna alkaneet opinto-oikeudet**. Kandiohjelmissa tavoiteaika on 36 kk, maisteriohjelmissa 24 kk`,
ProgrammesAfterGraduation: `
Sisältää maisteriohjelmat, joissa tämän kandiohjelman opiskelijat ovat aloittaneet valmistuttuaan kandeiksi. Vuosijaottelu on tehty maisteriohjelmassa aloittamisen perusteella.
ProgrammesBeforeOrAfter: `
**Mikäli valittuna on kandiohjelma:** Sisältää maisteriohjelmat, joissa tämän kandiohjelman opiskelijat ovat aloittaneet valmistuttuaan kandeiksi. Vuosijaottelu on tehty maisteriohjelmassa aloittamisen perusteella.
Mukana ovat **vain valmistuneet opiskelijat ja vain maisteriohjelmat**.
Mukana ovat **vain valmistuneet opiskelijat ja vain maisteriohjelmat.**
**Mikäli valittuna on maisteriohjelma:** Sisältää kandiohjelmat, joissa tämän ohjelman opiskelijat ovat opiskelleet ennen tuloaan maisteriohjelmaan. Vuosijaottelu on tehty maisteriohjelmassa aloittamisen perusteella.
`,
StudytrackOverview: `
Kuvaa koulutusohjelman kyseiseen ohjelmaan opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna. Esimerkiksi 2020-2021 aloittaneiden opintopistejakaumaa.
+16
View File
@@ -101,6 +101,22 @@ export const getTextIn = (texts, language) => {
return null
}
export const getUnifyTextIn = unify => {
switch (unify) {
case 'reqularStats':
return ''
case 'openStats':
return 'Open University: '
case 'unifyStats':
return 'Unified with open uni: '
default:
return ''
}
}
export const cancelablePromise = promise => {
let hasCanceled = false
@@ -10,7 +10,7 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
const [courseGrades, setCourseGrades] = useState([])
useEffect(() => {
if (samples && singleCourseStats.alternatives) {
if (samples && singleCourseStats?.unifyStats?.alternatives) {
const filteredGradeArray = []
const grades = {}
@@ -19,7 +19,7 @@ import CoursePopulationCreditGainTable from './CoursePopulationCreditGainTable'
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import ProgressBar from '../ProgressBar'
import { getStudentToTargetCourseDateMap, getTextIn } from '../../common'
import { getStudentToTargetCourseDateMap, getTextIn, getUnifyTextIn } from '../../common'
import { useProgress, useTitle } from '../../common/hooks'
import infotooltips from '../../common/InfoToolTips'
import {
@@ -47,9 +47,9 @@ const CoursePopulation = ({
getSemestersDispatch,
semesters,
getFacultiesDispatch,
unifyOpen,
}) => {
const { language } = useLanguage()
const [codes, setCodes] = useState([])
const [headerYears, setYears] = useState('')
const [dateFrom, setDateFrom] = useState(null)
@@ -124,7 +124,8 @@ const CoursePopulation = ({
}
}, [semesters])
const header = courseData ? `${getTextIn(courseData.name, language)} ${headerYears}` : null
const avoin = getUnifyTextIn(unifyOpen)
const header = courseData ? `${avoin} ${getTextIn(courseData.name, language)} ${headerYears}` : null
const subHeader = codes.join(', ')
@@ -364,11 +365,12 @@ const CustomPopulationCoursesWrapper = props => {
return <CustomPopulationCourses {...props} courses={courseStatistics} />
}
const mapStateToProps = ({ singleCourseStats, populations, semesters }) => {
const mapStateToProps = ({ singleCourseStats, populations, semesters, courseSearch }) => {
return {
studentData: populations.data,
pending: populations.pending,
courseData: singleCourseStats.stats || {},
courseData: singleCourseStats.stats?.[courseSearch.openOrReqular],
unifyOpen: courseSearch.openOrReqular,
semesters: semesters.data,
elementDetails: populations?.data?.elementdetails?.data,
}
@@ -84,11 +84,13 @@ const CourseTable = ({ course, courseInstance, language, selectedYear }) => {
const FacultyLevelStatistics = () => {
const { language: activeLanguage } = useLanguage()
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
const { courseStats, language } = useSelector(({ courseStats }) => ({
courseStats: courseStats.data,
language: activeLanguage,
}))
const yearcodes = uniq(flatten(Object.values(courseStats).map(c => Object.keys(c.facultyStats))))
const yearcodes = uniq(flatten(Object.values(courseStats).map(c => Object.keys(c[openOrReqular].facultyStats))))
.sort()
.reverse()
@@ -103,13 +105,13 @@ const FacultyLevelStatistics = () => {
const yearsCourseStats = Object.values(courseStats)
.map(course => ({
course,
courseInstance: course.facultyStats[selectedYear],
courseInstance: course[openOrReqular].facultyStats[selectedYear],
}))
.sort((a, b) => (a.courseInstance === undefined) - (b.courseInstance === undefined))
const courseTables = yearsCourseStats.map(({ course, courseInstance }) => (
<CourseTable
course={course}
course={course[openOrReqular]}
courseInstance={courseInstance}
language={language}
key={course.coursecode}
@@ -70,9 +70,8 @@ const getGradeColumns = (notThesisGrades, addHTAndTT) => {
const AttemptsTable = ({
data: { stats, name },
settings: { showGrades },
settings: { showGrades, separate },
alternatives,
separate,
isRelative,
userHasAccessToAllStats,
headerVisible = false,
@@ -144,9 +143,8 @@ const AttemptsTable = ({
</Header>
)}
<SortableTable
defaultdescending
getRowKey={s => s.code}
tableProps={{ celled: true, style: { width: 'auto' } }}
title={`Yearly attempt statistics for group ${name}`}
defaultSort={['TIME', 'desc']}
columns={columns}
data={data}
/>
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'
import { Header, Icon, Item } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { uniq } from 'lodash'
import { shape, string, number, oneOfType, arrayOf, bool } from 'prop-types'
import { string, object, arrayOf, bool } from 'prop-types'
import SortableTable from '../../../../SortableTable'
import { defineCellColor } from '../util'
@@ -28,6 +28,7 @@ const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate
key: 'TIME',
title: 'Time',
getRowVal: s => s.code,
getRowExportVal: s => s.name,
getRowContent: s => (
<div style={{ whiteSpace: 'nowrap' }}>
{s.name}
@@ -92,9 +93,8 @@ const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate
const StudentTable = ({
data: { name, stats },
settings: { showDetails },
settings: { showDetails, separate },
alternatives,
separate,
userHasAccessToAllStats,
headerVisible = false,
}) => {
@@ -111,9 +111,11 @@ const StudentTable = ({
</Header>
)}
<SortableTable
title={`Student statistics for group ${name}`}
defaultSort={['TIME', 'desc']}
defaultdescending
getRowKey={s => s.code}
tableProps={{ celled: true, fixed: true, style: { width: 'auto' } }}
tableProps={{ celled: true, fixed: true }}
columns={columns}
data={stats}
/>
@@ -125,12 +127,10 @@ const StudentTable = ({
}
StudentTable.propTypes = {
stats: arrayOf(shape({})).isRequired,
name: oneOfType([number, string]).isRequired,
data: object.isRequired,
alternatives: arrayOf(string).isRequired,
separate: bool,
userHasAccessToAllStats: bool.isRequired,
headerVisible: bool.isRequired,
}
StudentTable.defaultProps = {
@@ -1,60 +1,109 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { Menu, Radio } from 'semantic-ui-react'
import { useSelector, useDispatch } from 'react-redux'
import { Menu, Radio, Form } from 'semantic-ui-react'
import { viewModeNames } from './util'
import HelpButton from '../HelpButton'
import StudentTable from './Tables/student'
import AttemptsTable from './Tables/attempts'
import { toggleOpenAndReqularCourses } from '../../../../redux/coursesearch'
import selectors from '../../../../selectors/courseStats'
export const TablesSettings = ({ value, onChange }) => {
const { viewMode, showDetails, showGrades } = value
const dispatch = useDispatch()
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
const toggleUnifyRadioValue = (event, { value }) => {
dispatch(toggleOpenAndReqularCourses(value))
}
return (
<Menu secondary style={{ marginBottom: 0 }}>
{Object.entries(viewModeNames).map(([key, name]) => (
<Menu.Item
active={viewMode === key}
data-cy={`viewMode-${name}`}
name={name}
onClick={() =>
onChange({
...value,
viewMode: key,
})
}
/>
))}
<Menu.Item>
<Radio
toggle
label="Show details"
disabled={viewMode !== 'STUDENT'}
checked={showDetails}
onChange={() => onChange({ ...value, showDetails: !showDetails })}
/>
</Menu.Item>
<Menu.Item>
<Radio
toggle
label="Show grades"
data-cy="gradeToggle"
disabled={viewMode !== 'ATTEMPTS'}
checked={showGrades}
onChange={() => onChange({ ...value, showGrades: !showGrades })}
/>
</Menu.Item>
<Menu.Item>
<HelpButton tab="Tables" viewMode={viewMode} />
</Menu.Item>
</Menu>
<div>
<Menu secondary>
{Object.entries(viewModeNames).map(([key, name]) => (
<Menu.Item
key={key}
active={viewMode === key}
data-cy={`viewMode-${name}`}
name={name}
onClick={() =>
onChange({
...value,
viewMode: key,
})
}
/>
))}
<Menu.Item>
<Radio
toggle
label="Show details"
disabled={viewMode !== 'STUDENT'}
checked={showDetails}
onChange={() => onChange({ ...value, showDetails: !showDetails })}
/>
</Menu.Item>
<Menu.Item>
<Radio
toggle
label="Show grades"
data-cy="gradeToggle"
disabled={viewMode !== 'ATTEMPTS'}
checked={showGrades}
onChange={() => onChange({ ...value, showGrades: !showGrades })}
/>
</Menu.Item>
<Menu.Item>
<HelpButton tab="Tables" viewMode={viewMode} />
</Menu.Item>
</Menu>
<Menu secondary style={{ marginLeft: '0.5rem' }}>
<Form>
<Form.Group>
<Form.Field>
<b>Provider organization:</b>
</Form.Field>
<Form.Field>
<Radio
label="university"
name="radioGroup"
value="reqularStats"
checked={openOrReqular === 'reqularStats'}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_reqular"
/>
</Form.Field>
<Form.Field>
<Radio
label="open university"
name="radioGroup"
value="openStats"
checked={openOrReqular === 'openStats'}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_open"
/>
</Form.Field>
<Form.Field>
<Radio
label="unify"
name="radioGroup"
value="unifyStats"
checked={openOrReqular === 'unifyStats'}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_unify"
/>
</Form.Field>
</Form.Group>
</Form>
</Menu>
</div>
)
}
export const Tables = props => {
const alternatives = useSelector(state => state.courseStats.data[state.singleCourseStats.selectedCourse].alternatives)
const alternatives = useSelector(selectors.getCourseAlternatives)
const viewModes = { ATTEMPTS: AttemptsTable, STUDENT: StudentTable }
const Content = viewModes[props.settings.viewMode]
return <Content {...props} alternatives={alternatives} />
@@ -51,137 +51,29 @@ const PaneContent = ({ component: Component, settings: SettingsComponent, initia
)
}
const ResultTabs = ({ primary, comparison, history }) => {
const ResultTabs = ({ primary, comparison, history, separate }) => {
const [tab, setTab] = useTabs('cs_tab', 0, history)
// const [viewMode, setViewMode] = useState(viewModeNames.STUDENT)
// const [selectedView, setSelectedView] = useState(false)
// const [isRelative, setIsRelative] = useState(false)
// const [showGrades, setShowGrades] = useState(false)
const { userHasAccessToAllStats } = primary
/* const handleModeChange = newViewMode => {
sendAnalytics(`Current view mode '${newViewMode}'`, 'Course statistics')
setViewMode(newViewMode)
} */
/* useEffect(() => {
const newViewMode = selectedView ? viewModeNames.ATTEMPTS : viewModeNames.STUDENT
handleModeChange(newViewMode)
}, [selectedView]) */
const handleTabChange = (...params) => {
const { activeIndex } = params[1]
const currentTab = params[1].panes[activeIndex]
sendAnalytics(`Current tab '${currentTab.menuItem.content}'`, 'Course statistics')
setTab(...params)
// setViewMode(resetViewMode ? viewMode : viewModeNames.ATTEMPTS)
}
/* const getRadioButton = (firstLabel, secondLabel, value, setValue) => (
<div className="toggleContainer">
<label className="toggleLabel">{firstLabel}</label>
<Radio toggle data-cy="gradeToggle" checked={value} onChange={() => setValue(!value)} />
<label className="toggleLabel">{secondLabel}</label>
</div>
) */
/* const renderViewModeSelector = () => {
const isTogglePane = tab !== 0
const getButtonMenu = () => (
<Menu secondary>
{Object.values(viewModeNames).map(name => (
<Menu.Item key={name} name={name} active={viewMode === name} onClick={() => handleModeChange(name)} />
))}
{viewMode === 'Attempts' && getRadioButton('Totals', 'Grade distribution', showGrades, setShowGrades)}
{viewMode === 'Attempts' && showGrades && getRadioButton('Absolute', 'Relative', isRelative, setIsRelative)}
</Menu>
)
const getToggle = () => {
return (
<div className="chartToggleContainer">
{tab === 1 && getRadioButton('Student', 'Attempts', selectedView, setSelectedView)}
{(tab === 2 || comparison) && getRadioButton('Absolute', 'Relative', isRelative, setIsRelative)}
</div>
)
}
// Remove "false" and activate infoboxes when the texts for them are ready
return (
<div className="modeSelectorContainer">
{isTogglePane ? getToggle() : getButtonMenu()}
<InfoBox content={infotooltips.CourseStatistics[tab][viewMode]} />
</div>
)
} */
/* const getPanes = () => {
return [
{
menuItem: { key: 'Table', icon: 'table', content: 'Table' },
render: () => (
<Tables
separate={separate}
comparison={comparison}
primary={primary}
viewMode={viewMode}
isRelative={isRelative}
showGrades={showGrades}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
},
{
menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
render: () => (
<PassRate
comparison={comparison}
primary={primary}
viewMode={viewMode}
isRelative={isRelative && !!comparison}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
},
{
menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
render: () => (
<Distribution
comparison={comparison}
primary={primary}
isRelative={isRelative}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
},
];
return paneMenuItems.map(p => {
const { menuItem, renderFn } = p
return {
menuItem,
render: () => (
<Grid padded="vertically" columns="equal">
<Grid.Row className="modeSelectorRow">{renderViewModeSelector()}</Grid.Row>
{renderFn()}
</Grid>
),
}
})
} */
const paneTypes = [
{
label: 'Tables',
icon: 'table',
initialSettings: { showDetails: false, viewMode: 'STUDENT' },
initialSettings: { showDetails: false, viewMode: 'STUDENT', separate },
settings: TablesSettings,
component: Tables,
},
{
label: 'Pass rate chart',
icon: 'balance',
initialSettings: { viewMode: 'STUDENT' },
initialSettings: { viewMode: 'STUDENT', separate },
settings: PassRateSettings,
component: PassRate,
},
@@ -1,11 +1,11 @@
import React, { useState, useEffect, Fragment } from 'react'
import React, { useState, useEffect } from 'react'
import { Segment, Header, Form } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { useSelector, useDispatch } from 'react-redux'
import { withRouter } from 'react-router-dom'
import qs from 'query-string'
import { sortBy } from 'lodash'
import { func, arrayOf, shape, bool } from 'prop-types'
import { clearCourses, findCoursesV2, toggleUnifyOpenUniCourses } from '../../../redux/coursesearch'
import { func, shape } from 'prop-types'
import { clearCourses, findCoursesV2 } from '../../../redux/coursesearch'
import { getCourseStats, clearCourseStats } from '../../../redux/coursestats'
import { getCourseSearchResults } from '../../../selectors/courses'
import { useSearchHistory, usePrevious } from '../../../common/hooks'
@@ -15,7 +15,6 @@ import AutoSubmitSearchInput from '../../AutoSubmitSearchInput'
import CourseTable from '../CourseTable'
import SearchHistory from '../../SearchHistory'
import useLanguage from '../../LanguagePicker/useLanguage'
import filterCourseSearchResults from './searchFormUtils'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent('Course statistics search', action, name, value)
@@ -55,13 +54,18 @@ const useTSASearchResultsHook = (coursesLoading, courseName, courseCode, matchin
const SearchForm = props => {
const { language } = useLanguage()
const dispatch = useDispatch()
const isLoading = useSelector(state => state.courseStats.pending)
const coursesLoading = useSelector(state => state.courseSearch.pending)
const matchingCourses = useSelector(getCourseSearchResults)
const [state, setState] = useState({
...INITIAL,
})
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('courseSearch', 6)
const { courseName, courseCode, selectedCourses, separate } = state
const { coursesLoading, isLoading, matchingCourses, unifyOpenUniCourses } = props
// const { coursesLoading, isLoading, matchingCourses /* unifyOpenUniCourses */ } = props
const parseQueryFromUrl = () => {
const { location } = props
@@ -71,7 +75,7 @@ const SearchForm = props => {
...rest,
courseCodes: JSON.parse(courseCodes),
separate: JSON.parse(separate),
unifyOpenUniCourses: JSON.parse(unifyOpenUniCourses || false),
// unifyOpenUniCourses: JSON.parse(unifyOpenUniCourses || false),
}
return query
}
@@ -79,14 +83,14 @@ const SearchForm = props => {
const fetchStatisticsFromUrlParams = () => {
const query = parseQueryFromUrl()
setState({ ...state, ...query })
props.getCourseStats(query, props.onProgress)
dispatch(getCourseStats(query, props.onProgress))
}
useEffect(() => {
const { location } = props
if (!location.search) {
props.clearCourses()
props.clearCourseStats()
dispatch(clearCourses())
dispatch(clearCourseStats())
}
}, [])
@@ -143,7 +147,6 @@ const SearchForm = props => {
const params = {
courseCodes: codes,
separate,
unifyOpenUniCourses: props.unifyOpenUniCourses,
}
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name, language)} ${code}`)
addItemToSearchHistory({
@@ -158,10 +161,10 @@ const SearchForm = props => {
const isValidCode = validateInputLength(courseCode, 2)
if (isValidName || isValidCode) {
return props.findCoursesV2({ name: courseName, code: courseCode })
return dispatch(findCoursesV2({ name: courseName, code: courseCode }))
}
if (courseName.length < 5 && courseCode.length < 2) {
props.clearCourses()
dispatch(clearCourses())
}
return Promise.resolve()
}
@@ -172,14 +175,6 @@ const SearchForm = props => {
sendAnalytics('Toggle Separate stats for spring & fall', `${newValue}`)
}
const onToggleUnifyOpenUniCoursesCheckbox = () => {
const newValue = !unifyOpenUniCourses
sendAnalytics('Toggle Unify open university courses', `${newValue}`)
setState({ ...state, selectedCourses: {} })
props.toggleUnifyOpenUniCourses()
}
const courses = matchingCourses.filter(c => !selectedCourses[c.code])
const disabled = isLoading || Object.keys(selectedCourses).length === 0
@@ -235,16 +230,6 @@ const SearchForm = props => {
data-cy="course-code-input"
/>
</Form.Field>
<Form.Field width={4} style={{ display: 'flex', flexDirection: 'column' }}>
<label>Unify open university courses:</label>
<span style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<Form.Checkbox
name="unifyOpenUniCourses"
onChange={onToggleUnifyOpenUniCoursesCheckbox}
checked={unifyOpenUniCourses}
/>
</span>
</Form.Field>
</Form.Group>
<div style={!noSelectedCourses ? searchBoxStyle : null}>
<CourseTable
@@ -314,38 +299,9 @@ SearchForm.defaultProps = {
}
SearchForm.propTypes = {
findCoursesV2: func.isRequired,
getCourseStats: func.isRequired,
clearCourses: func.isRequired,
clearCourseStats: func.isRequired,
matchingCourses: arrayOf(shape({})).isRequired,
isLoading: bool.isRequired,
coursesLoading: bool.isRequired,
history: shape({}).isRequired,
location: shape({}).isRequired,
unifyOpenUniCourses: bool.isRequired,
toggleUnifyOpenUniCourses: func.isRequired,
onProgress: func,
}
const mapStateToProps = state => {
const { courses } = getCourseSearchResults(state)
const { pending: courseStatsPending } = state.courseStats
const { unifyOpenUniCourses } = state.courseSearch
return {
matchingCourses: filterCourseSearchResults(courses, unifyOpenUniCourses),
isLoading: courseStatsPending,
coursesLoading: state.courseSearch.pending,
unifyOpenUniCourses,
}
}
export default withRouter(
connect(mapStateToProps, {
getCourseStats,
clearCourses,
findCoursesV2,
clearCourseStats,
toggleUnifyOpenUniCourses,
})(SearchForm)
)
export default withRouter(SearchForm)
@@ -1,9 +1,9 @@
import { orderBy } from 'lodash'
// import { orderBy } from 'lodash'
// handle special case where the course's actual code starts with "A" so it is mistakenly taken as Open Uni course
const isAvoin = course => !!course.code.match(/^[A][0-9]|^AY/)
// const isAvoin = course => !!course.code.match(/^[A][0-9]|^AY/)
const sortAlternatives = alternatives => {
/* const sortAlternatives = alternatives => {
const result = orderBy(
alternatives,
[
@@ -20,17 +20,18 @@ const sortAlternatives = alternatives => {
)
return result
}
} */
// If special case of Open Uni course that starts with A let's just sort them length, that
// should be good enough in this case.
const getAlternatives = course => sortAlternatives(course.alternatives)
const filterCourseSearchResults = (courses, unifyOpenUniCourses) => {
/* const getAlternatives = course => sortAlternatives(course.alternatives)
const filterCourseSearchResults = courses => {
const mergedCourses = {}
const sortedCourses = sortAlternatives(courses)
sortedCourses.forEach(course => {
const groupId = isAvoin(course) && !unifyOpenUniCourses ? course.code : course.subsId
const groupId = course.subsId
if (!(course.max_attainment_date && course.min_attainment_date)) {
return
@@ -64,6 +65,4 @@ const filterCourseSearchResults = (courses, unifyOpenUniCourses) => {
}))
return result
}
export default filterCourseSearchResults
} */
@@ -1,15 +1,22 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'react-redux'
import { useSelector } from 'react-redux'
import { Segment, Label, Header, Divider, Form } from 'semantic-ui-react'
import { shape, arrayOf, oneOfType, number, string, bool } from 'prop-types'
import { oneOfType, number, string, bool } from 'prop-types'
import SingleCourseStats from '../SingleCourseStats'
import useLanguage from '../../LanguagePicker/useLanguage'
import selectors from '../../../selectors/courseStats'
import { getTextIn } from '../../../common'
const SingleCourseTab = ({ selected, stats, courses, userHasAccessToAllStats }) => {
const SingleCourseTab = ({ selected, userHasAccessToAllStats }) => {
const [selection, setSelection] = useState(selected)
const { language } = useLanguage()
const stats = useSelector(selectors.getCourseStats)
const courses = useSelector(selectors.getCourses).map(({ code, name }) => ({
key: code,
value: code,
text: <Header content={<>{name}</>} />,
content: name,
}))
useEffect(() => {
setSelection(selected)
@@ -50,20 +57,8 @@ const SingleCourseTab = ({ selected, stats, courses, userHasAccessToAllStats })
}
SingleCourseTab.propTypes = {
stats: shape({}).isRequired,
courses: arrayOf(shape({})).isRequired,
selected: oneOfType([number, string]).isRequired,
userHasAccessToAllStats: bool.isRequired,
}
const mapStateToProps = state => ({
stats: selectors.getCourseStats(state),
courses: selectors.getCourses(state).map(({ code, name }) => ({
key: code,
value: code,
text: <Header content={<>{name}</>} />,
content: name,
})),
})
export default connect(mapStateToProps, {})(SingleCourseTab)
export default SingleCourseTab
@@ -0,0 +1,105 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import { Icon, Card, Dropdown, Portal, Popup } from 'semantic-ui-react'
import './styles.css'
const FigureContext = createContext(null)
const Header = ({ children, actions, contextItems }) => {
const { isFullscreen, setFullscreen } = useContext(FigureContext)
return (
<Card.Content style={{ flexGrow: 0, display: 'flex', alignItems: 'center', padding: '1em', height: '3.25em' }}>
<div style={{ fontWeight: 'bold', fontSize: '1.1rem' }}>{children}</div>
<div style={{ flexGrow: 1 }} />
<div style={{ marginRight: '1em' }}>{actions}</div>
<Popup
trigger={
<Icon
name={isFullscreen ? 'compress' : 'expand'}
onClick={() => setFullscreen(!isFullscreen)}
style={{ cursor: 'pointer', fontSize: '1.1rem', marginRight: '0.5em', position: 'relative', top: '-1px' }}
/>
}
on="hover"
position="bottom"
>
Toggle Fullscreen
</Popup>
{contextItems && (
<div>
<Dropdown position="bottom center" direction="left" icon="ellipsis vertical">
<Dropdown.Menu>
{contextItems.map(({ label, onClick }) => (
<Dropdown.Item onClick={onClick}>{label}</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</div>
)}
</Card.Content>
)
}
const Content = ({ children, ...rest }) => {
const { isFullscreen } = useContext(FigureContext)
const style = {
...rest.style,
overflow: 'auto',
}
if (isFullscreen) {
style.maxHeight = 'initial'
}
return (
<Card.Content {...rest} style={style}>
{children}
</Card.Content>
)
}
const FigureContainer = ({ children, style }) => {
const [isFullscreen, setFullscreen] = useState(false)
useEffect(() => {
if (isFullscreen) {
document.body.classList.add('figure-fullscreen')
} else {
document.body.classList.remove('figure-fullscreen')
}
return () => document.body.classList.remove('figure-fullscreen')
}, [isFullscreen])
return (
<FigureContext.Provider value={{ isFullscreen, setFullscreen }}>
<Card fluid style={{ overflow: 'hidden', maxHeight: '100%', ...style }}>
{children}
</Card>
<Portal
open={isFullscreen}
onClose={() => setFullscreen(false)}
style={{
width: '100% !important',
height: '100% !important',
}}
>
<div style={{ inset: 0, position: 'absolute', zIndex: 910, height: '100000vh' }}>
<Card
fluid
style={{ position: 'sticky', margin: 0, inset: 0, height: '100vh', border: 'none', borderRadius: 0 }}
>
{isFullscreen && children}
</Card>
</div>
</Portal>
</FigureContext.Provider>
)
}
FigureContainer.Header = Header
FigureContainer.Content = Content
export default FigureContainer
@@ -0,0 +1,3 @@
body.figure-fullscreen {
overflow: hidden;
}
@@ -4,7 +4,7 @@ import { Icon } from 'semantic-ui-react'
import './filterToggleIcon.css'
const FilterToggleIcon = ({ onClick, isActive }) => (
<span className={`flexIcon ${isActive ? 'active' : ''}`}>
<span className={`flexIcon ${isActive ? 'active' : ''}`} style={{ cursor: 'pointer' }}>
<Icon onClick={onClick} name="filter" />
<Icon onClick={onClick} name={isActive ? 'remove' : 'add'} size="tiny" />
</span>
@@ -0,0 +1,40 @@
import React from 'react'
import { Popup } from 'semantic-ui-react'
import { useSelector } from 'react-redux'
import { isCourseSelected, toggleCourseSelection } from 'components/FilterView/filters/courses'
import FilterToggleIcon from 'components/FilterToggleIcon'
import useFilters from 'components/FilterView/useFilters'
import { getTextIn } from '../../common'
const CourseFilterToggle = ({ course }) => {
const { language } = useSelector(({ settings }) => settings)
const { useFilterSelector, filterDispatch } = useFilters()
const isActive = useFilterSelector(isCourseSelected(course.code))
return (
<Popup
trigger={
<FilterToggleIcon
style={{ cursor: 'pointer' }}
isActive={isActive}
onClick={() => filterDispatch(toggleCourseSelection(course.code))}
/>
}
content={
isActive ? (
<span>
Poista rajaus kurssin <b>{getTextIn(course.name, language)}</b> perusteella
</span>
) : (
<span>
Rajaa opiskelijat kurssin <b>{getTextIn(course.name, language)}</b> perusteella
</span>
)
}
position="top right"
/>
)
}
export default CourseFilterToggle
@@ -1,24 +0,0 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import GradeDistributionHeader from './GradeDistributionHeader'
import GradeDistributionRow from './GradeDistributionRow'
const gradeTypes = [1, 2, 3, 4, 5]
const GradeDistributionFlat = () => {
const { courseStatistics } = UsePopulationCourseContext()
return (
<Table celled sortable className="fixed-header">
<GradeDistributionHeader gradeTypes={gradeTypes} />
<Table.Body>
{courseStatistics.map(stats => (
<GradeDistributionRow key={stats.course.code} courseStatistics={stats} gradeTypes={gradeTypes} />
))}
</Table.Body>
</Table>
)
}
export default GradeDistributionFlat
@@ -1,39 +0,0 @@
import React from 'react'
import { arrayOf, number } from 'prop-types'
import { Table } from 'semantic-ui-react'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import SortableHeaderCell from '../SortableHeaderCell'
const GradeDistributionHeader = ({ gradeTypes }) => {
const { onSortableColumnHeaderClick, filterInput, reversed, sortCriteria, tableColumnNames } =
UsePopulationCourseContext()
return (
<Table.Header>
<Table.Row>
{filterInput('nameFilter', 'Name', '3')}
{filterInput('codeFilter', 'Code')}
<SortableHeaderCell
content="Attempts"
columnName={tableColumnNames.STUDENTS}
onClickFn={onSortableColumnHeaderClick}
activeSortColumn={sortCriteria}
reversed={reversed}
/>
<Table.HeaderCell>0</Table.HeaderCell>
{gradeTypes.map(g => (
<Table.HeaderCell content={g} key={g} />
))}
<Table.HeaderCell content="Other passed" />
</Table.Row>
</Table.Header>
)
}
GradeDistributionHeader.propTypes = {
gradeTypes: arrayOf(number).isRequired,
}
export default GradeDistributionHeader
@@ -1,122 +0,0 @@
import React from 'react'
import { Table, Popup, Item, Icon } from 'semantic-ui-react'
import { replace, sortBy, omit } from 'lodash'
import { Link } from 'react-router-dom'
import { shape, string, number, arrayOf } from 'prop-types'
import { getTextIn } from '../../../common'
import FilterToggleIcon from '../../FilterToggleIcon'
import useFilters from '../../FilterView/useFilters'
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import { useLanguage } from '../../../common/hooks'
const formatGradeDistribution = grades =>
replace(
JSON.stringify(
sortBy(
Object.entries(grades).map(([key, value]) => ({ [key]: value.count })),
o => -Object.keys(o)
),
null,
1
),
/\[\n|{\n*|{\s|}|\s*}|]|"|,/g,
''
)
const CourseRow = ({ courseStatistics, gradeTypes }) => {
const language = useLanguage()
const { useFilterSelector, filterDispatch } = useFilters()
const { onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { course, grades } = courseStatistics
const { name, code } = course
const isActive = useFilterSelector(isCourseSelected(course.code))
let attempts = 0
let failedGrades = 0
let otherPassed = 0
if (grades) {
const countSumReducer = (acc, cur) => acc + cur.count
const gradeValues = grades ? Object.values(grades) : null
attempts = gradeValues.reduce(countSumReducer, 0)
failedGrades = gradeValues.filter(g => g.status.failingGrade).reduce(countSumReducer, 0)
otherPassed = Object.values(omit(grades, gradeTypes))
.filter(g => g.status.passingGrade || g.status.improvedGrade)
.reduce(countSumReducer, 0)
}
return (
<Table.Row active={isActive}>
<Popup
trigger={
<Table.Cell className="filterCell clickableCell">
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
</Table.Cell>
}
content={
isActive ? (
<span>
Poista rajaus kurssin <b>{getTextIn(name, language)}</b> perusteella
</span>
) : (
<span>
Rajaa opiskelijat kurssin <b>{getTextIn(name, language)}</b> perusteella
</span>
)
}
position="top right"
/>
<Table.Cell content={getTextIn(name, language)} className="nameCell" />
<Table.Cell className="iconCell">
<p>
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(code)} />
</Item>
</p>
</Table.Cell>
<Table.Cell content={code} />
<Table.Cell content={attempts} />
<Table.Cell content={failedGrades} />
{gradeTypes.map(g => (
<Table.Cell content={grades[g] ? grades[g].count || 0 : 0} key={code + g} />
))}
<Table.Cell content={otherPassed} />
</Table.Row>
)
}
const GradeDistributionRow = ({ courseStatistics, gradeTypes }) => {
const { course, grades } = courseStatistics
const { code } = course
return (
<Popup
key={code}
flowing
hoverable
inverted
position="top right"
hideOnScroll
content={grades ? <pre>{formatGradeDistribution(grades)}</pre> : 'Nothing to see here'}
trigger={<CourseRow courseStatistics={courseStatistics} gradeTypes={gradeTypes} />}
/>
)
}
CourseRow.propTypes = {
courseStatistics: shape({ course: shape({ code: string }) }).isRequired,
gradeTypes: arrayOf(number).isRequired,
}
GradeDistributionRow.propTypes = {
courseStatistics: shape({ course: shape({ code: string }) }).isRequired,
gradeTypes: arrayOf(number).isRequired,
}
export default GradeDistributionRow
@@ -1,49 +1,139 @@
import React from 'react'
import { Table, Icon } from 'semantic-ui-react'
import { instanceOf, func } from 'prop-types'
import { useSelector } from 'react-redux'
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { Icon, Item } from 'semantic-ui-react'
import SortableTable, { group } from 'components/SortableTable'
import _ from 'lodash'
import { getTextIn } from '../../../common'
import CourseFilterToggle from '../CourseFilterToggle'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import GradeDistributionHeader from './GradeDistributionHeader'
import GradeDistributionRow from './GradeDistributionRow'
const gradeTypes = [1, 2, 3, 4, 5]
const mapCourseData = course => ({
name: course.course.name,
code: course.course.code,
attempts: _.chain(course.grades).values().map('count').sum().value(),
otherPassed: _.chain(course.grades)
.omit(_.range(0, 6))
.filter(g => g.status.passingGrade || g.status.improvedGrade)
.map('count')
.sum()
.value(),
grades: {
...course.grades,
0: {
count: _.chain(course.grades)
.filter(g => g.status.failingGrade)
.map('count')
.sum()
.value(),
},
},
})
const GradeDistribution = ({ expandedGroups, toggleGroupExpansion }) => {
const { modules } = UsePopulationCourseContext()
const GradeDistribution = ({ flat }) => {
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { language } = useSelector(({ settings }) => settings)
const columns = useMemo(
() => [
{
key: 'name-parent',
mergeHeader: true,
merge: true,
children: [
{
key: 'name',
title: 'Name',
getRowVal: row => getTextIn(row.name),
},
{
key: 'filter-toggle',
export: false,
getRowContent: (row, isGroup) => {
if (isGroup) return null
return <CourseFilterToggle course={row} />
},
},
{
key: 'go-to-course',
export: false,
getRowContent: (row, isGroup) =>
!isGroup && (
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
row.code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
</Item>
),
},
],
},
{
key: 'code',
title: 'Code',
getRowVal: row => row.code,
},
{
key: 'stats',
noHeader: true,
getRowVal: (__, isGroup) => (isGroup ? ' ' : null),
children: [
{
key: 'attempts',
title: 'Attempts',
getRowVal: row => row.attempts,
},
..._.range(0, 6).map(grade => ({
key: `grade-${grade}`,
title: `${grade}`,
getRowVal: row => row.grades[grade]?.count ?? 0,
})),
{
key: 'other-passed',
title: 'Other Passed',
getRowVal: row => row.otherPassed,
},
],
},
],
[onGoToCourseStatisticsClick]
)
const data = useMemo(() => {
if (flat) {
return courseStatistics.map(mapCourseData)
}
return modules.map(({ module, courses }) =>
group(
{
key: `module-${module.code}`,
module,
headerRowData: ({ definition: { module }, children }) => ({
name: module.name,
code: module.code,
attempts: _.chain(children).map('attempts').sum().value(),
otherPassed: _.chain(children).map('otherPassed').sum().value(),
grades: _.range(0, 6).map(grade => ({
count: _.chain(children)
.map(course => course.grades[grade]?.count ?? 0)
.sum()
.value(),
})),
}),
},
courses.map(mapCourseData)
)
)
}, [modules])
return (
<Table celled sortable className="fixed-header">
<GradeDistributionHeader gradeTypes={gradeTypes} />
<Table.Body>
{modules.map(({ module, courses }) => (
<React.Fragment key={module.code}>
<Table.Row>
<Table.Cell style={{ cursor: 'pointer' }} colSpan="3" onClick={() => toggleGroupExpansion(module.code)}>
<Icon name={expandedGroups.has(module.code) ? 'angle down' : 'angle right'} />
<b>{getTextIn(module.name, language)}</b>
</Table.Cell>
<Table.Cell>
<b>{module.code}</b>
</Table.Cell>
<Table.Cell colSpan="8" />
</Table.Row>
{expandedGroups.has(module.code) &&
courses.map(course => (
<GradeDistributionRow key={course.course.code} courseStatistics={course} gradeTypes={gradeTypes} />
))}
</React.Fragment>
))}
</Table.Body>
</Table>
<>
<SortableTable title="Grade distribution of courses" data={data} columns={columns} />
</>
)
}
GradeDistribution.propTypes = {
expandedGroups: instanceOf(Set).isRequired,
toggleGroupExpansion: func.isRequired,
}
export default GradeDistribution
@@ -1,22 +0,0 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import PassFailHeader from './PassFailHeader'
import PassFailRow from './PassFailRow'
const PassFail = () => {
const { courseStatistics } = UsePopulationCourseContext()
return (
<Table className="fixed-header" celled sortable>
<PassFailHeader />
<Table.Body>
{courseStatistics.map(stats => (
<PassFailRow key={stats.course.code} courseStats={stats} />
))}
</Table.Body>
</Table>
)
}
export default PassFail
@@ -1,48 +0,0 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
import SortableHeaderCell from '../SortableHeaderCell'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
const PassFailHeader = () => {
const { onSortableColumnHeaderClick, filterInput, tableColumnNames, sortCriteria, reversed } =
UsePopulationCourseContext()
const getSortableHeaderCell = (label, columnName, rowSpan = 1) => (
<SortableHeaderCell
content={label}
columnName={columnName}
onClickFn={onSortableColumnHeaderClick}
activeSortColumn={sortCriteria}
reversed={reversed}
rowSpan={rowSpan}
/>
)
return (
<Table.Header>
<Table.Row>
<Table.HeaderCell colSpan="4" content="Course" />
{getSortableHeaderCell('Students', tableColumnNames.STUDENTS, 2)}
<Table.HeaderCell colSpan="3" content="Passed" />
<Table.HeaderCell colSpan="2" content="Failed" />
<Table.HeaderCell colSpan="2" content="Attempts" />
<Table.HeaderCell colSpan="2" content="percentage of population" />
</Table.Row>
<Table.Row>
{filterInput('nameFilter', 'Name', '3')}
{filterInput('codeFilter', 'Code')}
{getSortableHeaderCell('n', tableColumnNames.PASSED)}
{getSortableHeaderCell('after retry', tableColumnNames.RETRY_PASSED)}
{getSortableHeaderCell('percentage', tableColumnNames.PERCENTAGE)}
{getSortableHeaderCell('n', tableColumnNames.FAILED)}
{getSortableHeaderCell('many times', tableColumnNames.FAILED_MANY)}
{getSortableHeaderCell('n', tableColumnNames.ATTEMPTS)}
{getSortableHeaderCell('per student', tableColumnNames.PER_STUDENT)}
{getSortableHeaderCell('Passed', tableColumnNames.PASSED_OF_POPULATION)}
{getSortableHeaderCell('attempted', tableColumnNames.TRIED_OF_POPULATION)}
</Table.Row>
</Table.Header>
)
}
export default PassFailHeader
@@ -1,84 +0,0 @@
import React from 'react'
import { Table, Icon, Popup, Item } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { shape, string } from 'prop-types'
import { getTextIn } from '../../../common'
import FilterToggleIcon from '../../FilterToggleIcon'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import useLanguage from '../../LanguagePicker/useLanguage'
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
import useFilters from '../../FilterView/useFilters'
const PassFailRow = ({ courseStats }) => {
const { language } = useLanguage()
const { useFilterSelector, filterDispatch } = useFilters()
const { onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { course, stats } = courseStats
const { code, name } = course
const {
failed,
passed,
retryPassed,
failedMany,
attempts,
percentage,
perStudent,
passedOfPopulation,
triedOfPopulation,
} = stats
const isActive = useFilterSelector(isCourseSelected(course.code))
return (
<Table.Row key={code} active={isActive} data-cy={name.fi}>
<Popup
trigger={
<Table.Cell className="filterCell clickableCell">
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
</Table.Cell>
}
content={
isActive ? (
<span>
Poista rajaus kurssin <b>{getTextIn(name, language)}</b> perusteella
</span>
) : (
<span>
Rajaa opiskelijat kurssin <b>{getTextIn(name, language)}</b> perusteella
</span>
)
}
position="top right"
/>
<Table.Cell content={getTextIn(name, language)} className="nameCell" />
<Table.Cell className="iconCell clickableCell">
<p>
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
code
)}"]&separate=false&unifyOpenUniCourses=false`}
data-cy={`coursestats-link-${code}`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(code)} />
</Item>
</p>
</Table.Cell>
<Table.Cell content={code} />
<Table.Cell content={passed + failed} />
<Table.Cell content={passed} />
<Table.Cell content={retryPassed} />
<Table.Cell content={`${percentage.toFixed(2)} %`} />
<Table.Cell content={failed} />
<Table.Cell content={failedMany} />
<Table.Cell content={attempts} />
<Table.Cell content={perStudent.toFixed(2)} />
<Table.Cell content={`${passedOfPopulation.toFixed(2)} %`} />
<Table.Cell content={`${triedOfPopulation.toFixed(2)} %`} />
</Table.Row>
)
}
PassFailRow.propTypes = {
courseStats: shape({ course: shape({ code: string }) }).isRequired,
}
export default PassFailRow
@@ -1,34 +1,215 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
import { func, instanceOf } from 'prop-types'
import React, { useMemo } from 'react'
import _ from 'lodash'
import SortableTable, { group } from 'components/SortableTable'
import { Item, Icon } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import CourseFilterToggle from '../CourseFilterToggle'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import CollapsibleModuleTable from '../CollapsibleModuleTable'
import PassFailRow from './PassFailRow'
import PassFailHeader from './PassFailHeader'
import { getTextIn } from '../../../common'
const PassFail = ({ expandedGroups, toggleGroupExpansion }) => {
const { modules } = UsePopulationCourseContext()
const createModuleAggregateRow = ({ definition, children }) => ({
label_code: definition.module.code,
label_name: definition.module.name,
stats: {
passed: _.chain(children).map('stats.passed').sum().value(),
retryPassed: _.chain(children).map('stats.retryPassed').sum().value(),
percentage: _.chain(children).map('stats.percentage').mean().value(),
failed: _.chain(children).map('stats.failed').sum().value(),
failedMany: _.chain(children).map('stats.failedMany').sum().value(),
attempts: _.chain(children).map('stats.attempts').sum().value(),
perStudent: _.chain(children).map('stats.perStudent').mean().value(),
passedOfPopulation: _.chain(children).map('stats.passedOfPopulation').mean().value(),
triedOfPopulation: _.chain(children).map('stats.triedOfPopulation').mean().value(),
},
})
const PassFail = ({ flat }) => {
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const columns = useMemo(
() => [
{
key: 'course',
title: 'Course',
children: [
{
key: 'course-name-parent',
mergeHeader: true,
merge: true,
children: [
{
key: 'course-name',
title: 'Name',
getRowVal: (row, isGroup) => getTextIn(isGroup ? row.label_name : row.name ?? row.course.name),
},
{
key: 'filter-toggle',
export: false,
getRowContent: (row, isGroup) => {
if (isGroup) return null
return <CourseFilterToggle course={row} />
},
},
{
key: 'go-to-course',
export: false,
getRowContent: (row, isGroup) =>
!isGroup && (
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
row.code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
</Item>
),
},
],
},
{
key: 'course-code',
title: 'Code',
getRowVal: (row, isGroup) => (isGroup ? row.label_code : row.code ?? row.course.code),
},
],
},
{
key: 'statistics',
noHeader: true,
getRowVal: (_, isGroup) => (isGroup ? ' ' : undefined),
children: [
{
key: 'passed',
title: 'Passed',
children: [
{
key: 'passed-n',
title: 'Total',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.passed ?? 0,
},
{
key: 'passed-after-retry',
title: 'After Retry',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.retryPassed ?? 0,
},
{
key: 'passed-percentage',
title: 'Passed-%',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.percentage ?? 0,
formatValue: value =>
value &&
new Intl.NumberFormat('fi-FI', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value / 100),
},
],
},
{
key: 'failed',
title: 'Failed',
children: [
{
key: 'failed-n',
title: 'Total',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.failed ?? 0,
},
{
key: 'failed-many',
title: 'Multiple Times',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.failedMany ?? 0,
},
],
},
{
key: 'attempts',
title: 'Attempts',
children: [
{
key: 'attempts-n',
title: 'Total',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.attempts,
},
{
key: 'attempts-per-student',
title: 'per Student',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.perStudent,
formatValue: value =>
new Intl.NumberFormat('fi-FI', { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(value),
},
],
},
{
key: 'of-population',
title: 'Percentage of Population',
children: [
{
key: 'passed-of-population',
title: 'Passed',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.passedOfPopulation,
formatValue: value =>
new Intl.NumberFormat('fi-FI', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value / 100),
},
{
key: 'attempted-of-population',
title: 'Attempted',
cellStyle: { textAlign: 'right' },
getRowVal: row => row.stats?.triedOfPopulation,
formatValue: value =>
new Intl.NumberFormat('fi-FI', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value / 100),
},
],
},
],
},
],
[onGoToCourseStatisticsClick]
)
const data = useMemo(() => {
if (flat) {
return courseStatistics.map(course => ({ ...course, code: course.course.code, name: course.course.name }))
}
return _.chain(modules)
.map(({ module, courses }) =>
group(
{
key: `module-${module.code}`,
module,
headerRowData: createModuleAggregateRow,
columnOverrides: {},
},
courses
)
)
.value()
}, [modules])
return (
<Table className="fixed-header" celled sortable>
<PassFailHeader />
<Table.Body>
<CollapsibleModuleTable
emptyColSpan={12}
modules={modules}
expandedGroups={expandedGroups}
toggleGroupExpansion={toggleGroupExpansion}
>
{courses => courses.map(stats => <PassFailRow key={stats.course.code} courseStats={stats} />)}
</CollapsibleModuleTable>
</Table.Body>
</Table>
<>
<SortableTable title="Pass and fail statistics of courses" data={data} columns={columns} />
</>
)
}
PassFail.propTypes = {
expandedGroups: instanceOf(Set).isRequired,
toggleGroupExpansion: func.isRequired,
}
export default PassFail
@@ -14,7 +14,9 @@ const CumulativeCheckbox = ({ cumulativeStats, setCumulativeStats }) => {
setCumulativeStats(!cumulativeStats)
}
return <Checkbox toggle checked={cumulativeStats} onChange={handleChange} label="Show cumulative stats" />
return (
<Checkbox toggle checked={cumulativeStats} onChange={handleChange} label="Show cumulative stats" size="small" />
)
}
CumulativeCheckbox.propTypes = {
@@ -1,43 +1,148 @@
import React, { useState } from 'react'
import { Table } from 'semantic-ui-react'
import { func, instanceOf } from 'prop-types'
import PassingSemesterRow from './PassingSemesterRow'
import CollapsibleModuleTable from '../CollapsibleModuleTable'
import PassingSemestersHeader from './PassingSemestersHeader'
import React, { useMemo, useState } from 'react'
import _ from 'lodash'
import { Link } from 'react-router-dom'
import { Item, Icon, Button } from 'semantic-ui-react'
import SortableTable, { group } from 'components/SortableTable'
import { getTextIn } from '../../../common'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import CumulativeCheckbox from './CumulativeCheckbox'
import CourseFilterToggle from '../CourseFilterToggle'
const PassingSemesters = ({ expandedGroups, toggleGroupExpansion }) => {
const { modules } = UsePopulationCourseContext()
const semesterColumn = (year, semester, cumulative) => ({
key: `semester-${year}-${semester}`,
cellProps: { style: { textAlign: 'right' } },
title: semester,
getRowVal: row =>
((cumulative ? row?.stats?.passingSemestersCumulative : row?.stats?.passingSemesters) ?? {})[
`${year}-${semester.toUpperCase()}`
],
getRowContent: row => {
const value = ((cumulative ? row?.stats?.passingSemestersCumulative : row?.stats?.passingSemesters) ?? {})[
`${year}-${semester.toUpperCase()}`
]
if (value === 0) {
return null
}
return value
},
})
const yearColumn = (year, cumulative) => ({
key: `year-${year}`,
title: `${year + 1}${['st', 'nd', 'rd'][year] ?? 'th'} year`,
children: [semesterColumn(year, 'Fall', cumulative), semesterColumn(year, 'Spring', cumulative)],
})
const PassingSemesters = () => {
const { modules, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const [cumulativeStats, setCumulativeStats] = useState(false)
const columns = useMemo(
() => [
{
key: 'course-name',
mergeHeader: true,
merge: true,
children: [
{
key: 'name',
title: 'Name',
getRowVal: (row, isGroup, parents) => getTextIn(isGroup ? parents[0].module.name : row.name),
},
{
key: 'filter-toggle',
export: false,
getRowContent: (row, isGroup) => {
if (isGroup) return null
return <CourseFilterToggle course={row} />
},
},
{
key: 'go-to-course',
export: false,
getRowContent: (row, isGroup) =>
!isGroup && (
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
row.code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
</Item>
),
},
],
},
{
key: 'code',
title: 'Code',
getRowVal: (row, isGroup, parents) => (isGroup ? parents[0].module.code : row.code),
},
{
key: 'stats',
noHeader: true,
getRowVal: (__, isGroup) => (isGroup ? ' ' : null),
children: [
{
key: 'students',
title: 'Students',
getRowVal: row => row.stats?.students,
cellProps: { style: { textAlign: 'right' } },
},
{
key: 'passed',
title: 'Passed',
getRowVal: row => row.stats?.passed,
cellProps: { style: { textAlign: 'right' } },
},
{
key: 'passed-before',
title: 'Before 1st year',
getRowVal: row =>
(cumulativeStats ? row.stats?.passingSemesters : row.stats?.passingSemestersCumulative)?.BEFORE,
cellProps: { style: { textAlign: 'right' } },
},
..._.range(0, 6).map(i => yearColumn(i, cumulativeStats)),
],
},
],
[cumulativeStats, onGoToCourseStatisticsClick]
)
const data = useMemo(
() =>
_.chain(modules)
.map(({ module, courses }) =>
group(
{
key: `module-${module.code}`,
module,
headerRowData: {},
columnOverrides: {},
},
courses
)
)
.value(),
[modules]
)
return (
<div>
<CumulativeCheckbox cumulativeStats={cumulativeStats} setCumulativeStats={setCumulativeStats} />
<Table celled className="fixed-header">
<PassingSemestersHeader />
<Table.Body>
<CollapsibleModuleTable
modules={modules}
emptyColSpan={15}
expandedGroups={expandedGroups}
toggleGroupExpansion={toggleGroupExpansion}
>
{courses =>
courses.map(stats => (
<PassingSemesterRow key={stats.course.code} statistics={stats} cumulative={cumulativeStats} />
))
}
</CollapsibleModuleTable>
</Table.Body>
</Table>
<SortableTable
title="Students Passing a Course per Semester"
actions={
<Button size="mini" style={{ padding: '.75em .75em' }} onClick={() => setCumulativeStats(!cumulativeStats)}>
{cumulativeStats ? 'Show yearly stats' : 'Show cumulative stats'}
</Button>
}
columns={columns}
data={data}
/>
</div>
)
}
PassingSemesters.propTypes = {
expandedGroups: instanceOf(Set).isRequired,
toggleGroupExpansion: func.isRequired,
}
export default PassingSemesters
@@ -6,8 +6,8 @@ import { clearCourseStats } from '../../redux/coursestats'
import './populationCourseStats.css'
import { PopulationCourseContext } from './PopulationCourseContext'
import TSA from '../../common/tsa'
import GradeDistribution from './GradeDistribution/GradeDistributionFlat'
import PassFail from './PassFail/PassFailFlat'
import GradeDistribution from './GradeDistribution'
import PassFail from './PassFail'
import { getTextIn } from '../../common'
import useLanguage from '../LanguagePicker/useLanguage'
@@ -215,17 +215,17 @@ const PopulationCourseStatsFlat = ({ courses, pending, filteredStudents, showFil
{
menuItem: 'pass/fail',
render: () => (
<div className="menuTab">
<PassFail />
</div>
<Tab.Pane>
<PassFail flat />
</Tab.Pane>
),
},
{
menuItem: 'grades',
render: () => (
<div className="menuTab">
<GradeDistribution />
</div>
<Tab.Pane>
<GradeDistribution flat />
</Tab.Pane>
),
},
]
@@ -1,99 +1,41 @@
import React, { useState, useMemo } from 'react'
import { Table, Icon, Popup, Item, Pagination } from 'semantic-ui-react'
import React, { useState, useMemo, useCallback } from 'react'
import { Icon, Button, Item, Pagination } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { useSelector, connect } from 'react-redux'
import { bool, instanceOf, func } from 'prop-types'
import _ from 'lodash'
import SortableTable, { group } from 'components/SortableTable'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import FilterToggleIcon from '../../FilterToggleIcon'
import useFilters from '../../FilterView/useFilters'
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
import CourseFilterToggle from '../CourseFilterToggle'
import { getTextIn } from '../../../common'
import StudentNameVisibilityToggle from '../../StudentNameVisibilityToggle'
import { useStudentNameVisibility } from '../../StudentNameVisibilityToggle'
const verticalTitle = (...params) => {
// https://stackoverflow.com/a/41396815
return (
<div className="studentVerticalTitle">
{params.map(p => (
<div key={p}>{p}</div>
))}
</div>
)
}
const StudentsRow = ({ course, pagedStudents, hasCompleted, onGoToCourseStatisticsClick }) => {
const { language } = useSelector(({ settings }) => settings)
const { useFilterSelector, filterDispatch } = useFilters()
const isActive = useFilterSelector(isCourseSelected(course.code))
return (
<Table.Row key={course.code}>
<Popup
trigger={
<Table.Cell className="filterCell clickableCell">
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
</Table.Cell>
}
content={
isActive ? (
<span>
Poista rajaus kurssin <b>{getTextIn(course.name, language)}</b> perusteella
</span>
) : (
<span>
Rajaa opiskelijat kurssin <b>{getTextIn(course.name, language)}</b> perusteella
</span>
)
}
position="top right"
/>
<Table.Cell className="nameCell" key="name" content={course.name.fi} />
<Table.Cell className="iconCell clickableCell">
<p>
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
course.code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(course.code)} />
</Item>
</p>
</Table.Cell>
<Table.Cell key="code" content={course.code} />
{pagedStudents.map(student => (
<Table.Cell
key={student.studentnumber}
content={hasCompleted(student.studentnumber) ? <Icon fitted name="check" color="green" /> : null}
/>
))}
</Table.Row>
)
}
const Students = ({ filteredStudents, expandedGroups, toggleGroupExpansion, showNames }) => {
const { courseStatistics, filterInput, onGoToCourseStatisticsClick, modules } = UsePopulationCourseContext()
const { language } = useSelector(({ settings }) => settings)
const Students = ({ filteredStudents }) => {
const { courseStatistics, onGoToCourseStatisticsClick, modules } = UsePopulationCourseContext()
const { visible: namesVisible, toggle: toggleStudentNames } = useStudentNameVisibility()
const [page, setPage] = useState(0)
const hasCompleted = (courseCode, student) => {
const course = courseStatistics.find(c => c.course.code === courseCode)
if (!course) return false
const hasCompleted = useCallback(
(courseCode, student) => {
const course = courseStatistics.find(c => c.course.code === courseCode)
if (!course) return false
return Boolean(course.students.passed[student])
}
return Boolean(course.students.passed[student])
},
[courseStatistics]
)
const countCompleted = (courses, student) => {
let completed = 0
courses.forEach(course => {
if (hasCompleted(course.code, student)) {
completed++
}
})
const countCompleted = useCallback(
(courses, student) => {
let completed = 0
courses.forEach(course => {
if (hasCompleted(course.code, student)) {
completed++
}
})
return completed
}
return completed
},
[hasCompleted]
)
const students = useMemo(() => {
const studentSet = new Set()
@@ -124,81 +66,123 @@ const Students = ({ filteredStudents, expandedGroups, toggleGroupExpansion, show
const pagedStudents = students.slice(page * 10, page * 10 + 10)
const data = useMemo(() => {
return _.chain(modules)
.map(({ module, courses }) =>
group(
{
key: `module-${module.code}`,
headerRowData: {
name: module.name,
code: module.code,
courses,
},
},
courses
)
)
.value()
}, [modules])
const columns = useMemo(
() => [
{
key: 'course',
title: 'Course',
children: [
{
key: 'title-parent',
mergeHeader: 'title',
merge: true,
children: [
{
key: 'title',
title: 'Name',
getRowVal: row => getTextIn(row.name),
},
{
key: 'filter-toggle',
export: false,
getRowContent: (row, isGroup) => {
if (isGroup) return null
return <CourseFilterToggle course={row} />
},
},
{
key: 'go-to-course',
export: false,
getRowContent: (row, isGroup) =>
!isGroup && (
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
row.code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
</Item>
),
},
],
},
{
key: 'code',
title: 'Code',
getRowVal: row => row.code,
},
],
},
{
key: 'students',
title: 'Students',
children: pagedStudents.map(student => ({
key: `student-${student.studentnumber}`,
title: `${namesVisible ? student.name : student.studentnumber}`,
getRowVal: (row, isGroup) =>
isGroup
? countCompleted(row.courses, student.studentnumber)
: hasCompleted(row.code, student.studentnumber),
getRowContent: (row, isGroup) =>
isGroup
? countCompleted(row.courses, student.studentnumber)
: hasCompleted(row.code, student.studentnumber) && <Icon fitted name="check" color="green" />,
formatValue: value => (value ? 'Passed' : 'Not passed'),
})),
},
],
[modules, pagedStudents]
)
return (
<div>
<div style={{ marginBottom: '1.5rem' }}>
<StudentNameVisibilityToggle />
</div>
<Pagination
secondary
activePage={page + 1}
totalPages={maxPages + 1}
onPageChange={(e, { activePage }) => setPage(activePage - 1)}
ellipsisItem={null}
firstItem={null}
lastItem={null}
<SortableTable
title={
<>
Courses passed by students
<Pagination
style={{ marginLeft: '1em' }}
size="mini"
secondary
activePage={page + 1}
totalPages={maxPages + 1}
onPageChange={(e, { activePage }) => setPage(activePage - 1)}
ellipsisItem={null}
firstItem={null}
lastItem={null}
/>
</>
}
actions={
<Button size="tiny" onClick={() => toggleStudentNames()}>
{namesVisible ? 'Hide student names' : 'Show student names'}
</Button>
}
columns={columns}
data={data}
/>
<Table sortable className="fixed-header" striped celled>
<Table.Header>
<Table.Row>
{filterInput('nameFilter', 'Name', '3')}
{filterInput('codeFilter', 'Code')}
{pagedStudents.map(student => (
<Table.HeaderCell
key={student.studentnumber}
content={
<Link style={{ color: 'black' }} to={`/students/${student.studentnumber}`}>
{showNames ? verticalTitle(student.name) : verticalTitle(student.studentnumber)}
</Link>
}
/>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{modules.map(({ module, courses }) => (
<React.Fragment key={module.code}>
<Table.Row>
<Table.Cell style={{ cursor: 'pointer' }} colSpan="3" onClick={() => toggleGroupExpansion(module.code)}>
<Icon name={expandedGroups.has(module.code) ? 'angle down' : 'angle right'} />
<b>{getTextIn(module.name, language)}</b>
</Table.Cell>
<Table.Cell>
<b>{module.code}</b>
</Table.Cell>
{pagedStudents.map(student => (
<Table.Cell key={`${module.code}-${student.studentnumber}`}>
{countCompleted(courses, student.studentnumber)}
</Table.Cell>
))}
</Table.Row>
{expandedGroups.has(module.code) &&
courses
.filter(c => c.visible.visibility)
.map(col => (
<StudentsRow
course={col}
pagedStudents={pagedStudents}
hasCompleted={sn => hasCompleted(col.code, sn)}
onGoToCourseStatisticsClick={onGoToCourseStatisticsClick}
/>
))}
</React.Fragment>
))}
</Table.Body>
</Table>
</div>
)
}
const mapStateToProps = state => ({
showNames: state.settings.namesVisible,
})
Students.propTypes = {
showNames: bool.isRequired,
expandedGroups: instanceOf(Set).isRequired,
toggleGroupExpansion: func.isRequired,
}
export default connect(mapStateToProps)(Students)
export default Students
@@ -195,7 +195,7 @@ const PopulationCourseStats = props => {
}))
.sort((a, b) => a.module.order - b.module.order)
)
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter, mandatoryCourses])
}, [state.studentAmountLimit, props.courses.coursestatistics, state.codeFilter, state.nameFilter, mandatoryCourses])
const onFilterChange = (e, field) => {
const {
@@ -229,11 +229,14 @@ const PopulationCourseStats = props => {
})
}
const onGoToCourseStatisticsClick = courseCode => {
const { clearCourseStats: clearCourseStatsfn } = props
sendAnalytics('Courses of Population course stats button clicked', courseCode)
clearCourseStatsfn()
}
const onGoToCourseStatisticsClick = useCallback(
courseCode => {
const { clearCourseStats: clearCourseStatsfn } = props
sendAnalytics('Courses of Population course stats button clicked', courseCode)
clearCourseStatsfn()
},
[sendAnalytics, props.clearCourseStats]
)
const onFilterReset = field => {
setFilterFields({ ...filterFields, [field]: '' })
@@ -282,48 +285,49 @@ const PopulationCourseStats = props => {
tableColumnNames,
sortCriteria,
reversed,
courses,
}
const panes = [
{
menuItem: 'pass/fail',
render: () => (
<div className="menuTab">
<Tab.Pane className="menuTab">
<PassFail expandedGroups={expandedGroups} toggleGroupExpansion={toggleGroupExpansion} />
</div>
</Tab.Pane>
),
},
{
menuItem: 'grades',
render: () => (
<div className="menuTab">
<Tab.Pane className="menuTab">
<GradeDistribution expandedGroups={expandedGroups} toggleGroupExpansion={toggleGroupExpansion} />
</div>
</Tab.Pane>
),
},
{
menuItem: 'when passed',
render: () => (
<div className="menuTab" style={{ marginTop: '0.5em' }}>
<Tab.Pane className="menuTab">
<PassingSemesters
filterInput={renderFilterInputHeaderCell}
courseStatistics={courseStatistics}
expandedGroups={expandedGroups}
toggleGroupExpansion={toggleGroupExpansion}
/>
</div>
</Tab.Pane>
),
},
{
menuItem: 'students',
render: () => (
<div className="menuTab" style={{ marginTop: '0.5em' }}>
<Tab.Pane className="menuTab">
<Students
filteredStudents={props.filteredStudents}
expandedGroups={expandedGroups}
toggleGroupExpansion={toggleGroupExpansion}
/>
</div>
</Tab.Pane>
),
},
]
@@ -33,9 +33,3 @@
white-space: initial;
text-align: center;
}
.menuTab {
overflow-y: auto;
max-height: 80vh;
padding: 0;
}
@@ -212,32 +212,40 @@ const GeneralTab = ({
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'),
getRowContent: s => (
<span style={s.obfuscated ? { fontStyle: 'italic', color: 'graytext' } : {}}>
{!s.obfuscated ? s.studentNumber : 'hidden'}
</span>
),
headerProps: { colSpan: 2 },
},
icon: {
key: 'icon',
getRowVal: s =>
!s.obfuscated && (
<Item
as={Link}
to={`/students/${s.studentNumber}`}
onClick={() => {
sendAnalytics('Student details button clicked', 'General tab')
}}
>
<Icon name="level up alternate" />
</Item>
),
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
'studentnumber-parent': {
key: 'studentnumber-parent',
mergeHeader: true,
merge: true,
children: [
{
key: 'studentnumber',
title: 'Student Number',
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
getRowContent: s => (
<span style={s.obfuscated ? { fontStyle: 'italic', color: 'graytext' } : {}}>
{!s.obfuscated ? s.studentNumber : 'hidden'}
</span>
),
headerProps: { colSpan: 2 },
},
{
key: 'icon',
export: false,
getRowVal: s =>
!s.obfuscated && (
<Item
as={Link}
to={`/students/${s.studentNumber}`}
onClick={() => {
sendAnalytics('Student details button clicked', 'General tab')
}}
>
<Icon name="level up alternate" />
</Item>
),
cellProps: { className: 'iconCellNoPointer' },
},
],
},
creditsSinceStart: {
key: 'creditsSinceStart',
@@ -303,19 +311,19 @@ const GeneralTab = ({
studyStartDate: {
key: 'studyStartDate',
title: 'start of studyright',
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]),
formatValue: value => reformatDate(new Date(value), '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'),
formatValue: value => reformatDate(new Date(value), 'YYYY-MM-DD'),
getRowVal: s => new Date(getActualStartDate(s.studentNumber)),
},
endDate: {
key: 'endDate',
title: 'graduation date',
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]).getTime(),
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]),
getRowContent: s =>
studentToStudyrightEndMap[s.studentNumber]
? reformatDate(studentToStudyrightEndMap[s.studentNumber], 'YYYY-MM-DD')
@@ -393,12 +401,13 @@ const GeneralTab = ({
/>
) : null,
headerProps: { onClick: null, sorted: null },
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
cellProps: { className: 'iconCellNoPointer' },
},
updatedAt: {
key: 'updatedAt',
title: 'Last Updated At',
getRowVal: s => reformatDate(s.updatedAt, 'YYYY-MM-DD HH:mm:ss'),
getRowVal: s => new Date(s.updatedAt),
formatValue: value => reformatDate(value, 'YYYY-MM-DD HH:mm:ss'),
},
}
// Columns are shown in order they're declared above. JS guarantees this order of keys
@@ -418,20 +427,20 @@ const GeneralTab = ({
.value()
return (
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
<SortableTable
getRowKey={s => s.studentNumber}
tableProps={{
collapsing: true,
basic: true,
compact: 'very',
padded: false,
celled: true,
}}
columns={columns}
data={selectedStudents.map(sn => students[sn])}
/>
</div>
<SortableTable
style={{ height: '80vh' }}
title="General student information"
getRowKey={s => s.studentNumber}
tableProps={{
collapsing: true,
basic: true,
compact: 'very',
padded: false,
celled: true,
}}
columns={columns}
data={selectedStudents.map(sn => students[sn])}
/>
)
}
@@ -477,7 +486,7 @@ const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) => {
studyGuidanceGroupPopulation: getStudyGuidanceGroupColumns(),
}
const baseColumns = ['studentnumber', 'icon', 'allCredits', 'tags', 'updatedAt', 'option']
const baseColumns = ['studentnumber-parent', 'allCredits', 'tags', 'updatedAt', 'option']
const nameColumnsToAdd = namesVisible ? ['email', 'copyEmail', 'lastname', 'firstname'] : []
const adminColumnsToFilter = isAdmin ? [] : ['priority', 'extent', 'studyStartDate', 'updatedAt']
@@ -2,7 +2,7 @@ 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 { orderBy, uniqBy, sortBy, isNumber } from 'lodash'
import scrollToComponent from 'react-scroll-to-component'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import { getTextIn } from '../../common'
@@ -145,30 +145,37 @@ const PopulationStudents = ({
: []
nameColumns.push(
{
key: 'studentnumber',
title: verticalTitle('student number'),
cellProps: { title: 'student number' },
getRowVal: s => (s.total ? '*' : s.studentNumber),
getRowContent: s => (s.total ? 'Summary:' : s.studentNumber),
child: true,
},
{
key: 'icon',
title: '',
getRowVal: s =>
!s.total && (
<Item
as={Link}
to={`/students/${s.studentNumber}`}
onClick={() => {
sendAnalytics('Student details button clicked', 'Mandatory courses table')
}}
>
<Icon name="level up alternate" />
</Item>
),
cellProps: { collapsing: true, className: 'iconCell' },
child: true,
key: 'studentnumber-parent',
mergeHeader: true,
merge: true,
title: 'Student Number',
children: [
{
key: 'studentnumber',
cellProps: { title: 'student number' },
getRowVal: s => (s.total ? '*' : s.studentNumber),
getRowContent: s => (s.total ? 'Summary:' : s.studentNumber),
child: true,
},
{
key: 'icon',
export: false,
getRowVal: s =>
!s.total && (
<Item
as={Link}
to={`/students/${s.studentNumber}`}
onClick={() => {
sendAnalytics('Student details button clicked', 'Mandatory courses table')
}}
>
<Icon name="level up alternate" />
</Item>
),
cellProps: { className: 'iconCell' },
child: true,
},
],
},
{
key: 'totalpassed',
@@ -224,6 +231,8 @@ const PopulationStudents = ({
{ visibleLabels: new Set(), visibleCourseCodes: new Set() }
)
const getTotalRowVal = (t, m) => t[m.code]
const labelColumns = []
labelColumns.push(
{
@@ -231,6 +240,7 @@ const PopulationStudents = ({
title: <b>Labels:</b>,
parent: true,
headerProps: { colSpan: nameColumns.length, style: { textAlign: 'right' } },
children: nameColumns,
},
...sortedlabels
.filter(({ code }) => visibleLabels.has(code))
@@ -247,17 +257,7 @@ const PopulationStudents = ({
title: e.label,
ordernumber: e.orderNumber,
},
}))
)
const getTotalRowVal = (t, m) => t[m.code]
const mandatoryCourseColumns = [
...nameColumns,
...labelColumns,
...flatten(
sortedlabels.map(e =>
sortBy(labelToMandatoryCourses[e.label], [
children: sortBy(labelToMandatoryCourses[e.label], [
m => {
const res = m.code.match(/\d+/)
return res ? Number(res[0]) : Number.MAX_VALUE
@@ -270,7 +270,8 @@ const PopulationStudents = ({
title: verticalTitle(mandatoryTitle(m)),
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)),
getRowVal: s =>
s.total ? getTotalRowVal(s, m) : JSON.stringify(hasPassedMandatory(s.studentNumber, m.code)),
getRowContent: s => {
if (s.total) return getTotalRowVal(s, m)
return hasPassedMandatory(s.studentNumber, m.code) ? <Icon fitted name="check" color="green" /> : null
@@ -278,10 +279,11 @@ const PopulationStudents = ({
child: true,
childOf: e.label,
code: m.code,
}))
)
),
]
})),
}))
)
const mandatoryCourseColumns = [...labelColumns]
const totals = filteredStudents.reduce(
(acc, s) => {
@@ -317,9 +319,10 @@ const PopulationStudents = ({
render: () => (
<Tab.Pane>
<div style={{ display: 'flex' }}>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
<div style={{ maxHeight: '80vh', width: '100%' }}>
{mandatoryCourses.length > 0 && (
<SortableTable
title={`Courses of population's students`}
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
tableProps={{
celled: true,
@@ -28,6 +28,9 @@ const ProtectedRoute = ({ requiredRoles = [], requireUserHasRights = false, ...r
if (rest.path.includes('students')) {
return hasRequiredRoles || hasRequiredRights
}
if (rest.path.includes('custompopulation')) {
return hasRequiredRoles || hasRequiredRights
}
return hasRequiredRoles && hasRequiredRights
}
@@ -83,7 +83,13 @@ const Routes = () => (
<ProtectedRoute requiredRoles={['admin']} exact path={routes.users} component={Users} />
<ProtectedRoute requiredRoles={['teachers']} exact path={routes.teachers} component={Teachers} />
<ProtectedRoute requireUserHasRights exact path={routes.coursepopulation} component={CoursePopulation} />
<ProtectedRoute requireUserHasRights exact path={routes.custompopulation} component={CustomPopulation} />
<ProtectedRoute
requiredRoles={['admin', 'studyGuidanceGroups']}
requireUserHasRights
exact
path={routes.custompopulation}
component={CustomPopulation}
/>
<ProtectedRoute requiredRoles={['admin']} requireUserHasRights exact path={routes.updater} component={Updater} />
<ProtectedRoute
requiredRoles={['studyGuidanceGroups']}
@@ -0,0 +1,191 @@
/* eslint-disable no-continue */
/* eslint-disable max-classes-per-file */
import React, { useState, useMemo } from 'react'
import { Modal, Table, Button, Checkbox } from 'semantic-ui-react'
import _ from 'lodash'
import xlsx from 'xlsx'
import { getDataItemType, getColumnValue, DataItemType, DataVisitor } from './common'
const getExportColumns = columns => {
const stack = _.cloneDeep(columns)
const exportColumns = []
while (stack.length > 0) {
const [column] = stack.splice(0, 1)
const parents = column.parents ?? []
if (column.export === false) {
continue
}
if (column.children && column.children.length > 0) {
stack.splice(0, 0, ...column.children.map(c => ({ ...c, parents: [...parents, column] })))
continue
}
exportColumns.push(column)
}
return exportColumns
}
const flattenData = data => {
const stack = [...data]
const flat = []
while (stack.length > 0) {
const [item] = stack.splice(0, 1)
if (getDataItemType(item) === DataItemType.Group) {
stack.splice(0, 0, ...item.children)
} else {
flat.push(item)
}
}
return flat
}
const getColumnTitle = column => {
return [...(column.parents ?? []), column]
.filter(c => c.title)
.map(c => c.title)
.join(' - ')
}
class ExportVisitor extends DataVisitor {
constructor(columns) {
super()
this.columns = columns
this.rows = []
}
visitRow(ctx) {
const row = _.chain(this.columns)
.map(column => [getColumnTitle(column), getColumnValue(ctx, column)])
.fromPairs()
.value()
this.rows.push(row)
}
}
class ValueSamplerVisitor extends DataVisitor {
constructor(columns) {
super()
this.columns = columns
this.values = _.fromPairs(_.map(columns, c => [c.key, new Set()]))
}
visitRow(ctx) {
this.columns.forEach(column => {
const value = getColumnValue(ctx, column)
this.values[column.key].add(value)
})
}
sample(n) {
return _.chain(this.values)
.mapValues(v => _.sampleSize([...v], n))
.value()
}
}
const ExportModal = ({ open, onOpen, onClose, data, columns }) => {
const exportColumns = useMemo(() => getExportColumns(columns), [columns])
const flatData = useMemo(() => flattenData(data), [data])
const [selected, setSelected] = useState(_.uniq(_.map(exportColumns, 'key')))
const sampledValues = useMemo(() => ValueSamplerVisitor.visit(data, exportColumns).sample(10), [exportColumns, data])
const handleExport = () => {
const columns = exportColumns.filter(ec => _.includes(selected, ec.key))
const { rows } = ExportVisitor.visit(data, columns)
const sheet = xlsx.utils.json_to_sheet(rows)
const book = xlsx.utils.book_new()
xlsx.utils.book_append_sheet(book, sheet)
xlsx.writeFile(book, 'Oodikone_Export.xlsx')
}
const toggleSelection = key => {
setSelected(prev => {
const i = prev.indexOf(key)
if (i > -1) {
const n = [...prev]
n.splice(i, 1)
return n
}
return [...prev, key]
})
}
return (
<Modal onOpen={onOpen} onClose={onClose} open={open}>
<Modal.Header>Export to Excel</Modal.Header>
<Modal.Content>
<p>
Exporting {flatData.length} rows into an Excel (.xlsx) file. Choose which columns you want to include in the
generated field from the list below.
</p>
<div>
<Button
onClick={() => setSelected(exportColumns.map(c => c.key))}
disabled={selected.length === exportColumns.length}
>
Include all
</Button>
<Button onClick={() => setSelected([])} disabled={selected.length === 0}>
Exclude all
</Button>
</div>
<Table celled striped>
<Table.Header>
<Table.HeaderCell />
<Table.HeaderCell>Column</Table.HeaderCell>
<Table.HeaderCell>Sample Values</Table.HeaderCell>
</Table.Header>
<Table.Body>
{exportColumns.map(column => (
<Table.Row key={column.key} onClick={() => toggleSelection(column.key)} style={{ cursor: 'pointer' }}>
<Table.Cell collapsing verticalAlign="middle">
<Checkbox checked={_.includes(selected, column.key)} />
</Table.Cell>
<Table.Cell collapsing>{getColumnTitle(column)}</Table.Cell>
<Table.Cell style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
<div style={{ width: '0' }}>
{sampledValues[column.key].map(value => (
<span
style={{
border: '1px solid #dedede',
borderRadius: '3px',
padding: '0px 3px',
backgroundColor: '#f9fafb',
marginRight: '0.25em',
color: '#555',
}}
>
{column.formatValue ? column.formatValue(value) : `${value}`}
</span>
))}
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Modal.Content>
<Modal.Actions>
<Button content="Cancel" onClick={() => onClose()} />
<Button primary content="Export" onClick={() => handleExport()} />
</Modal.Actions>
</Modal>
)
}
export default ExportModal
@@ -0,0 +1,83 @@
/* eslint-disable max-classes-per-file */
/* eslint-disable class-methods-use-this */
const DataItemTypeKey = Symbol('DATA_ITEM_TYPE')
export const DataItemType = {
Group: 'group',
Row: 'row',
}
export const getDataItemType = item => {
if (item[DataItemTypeKey] === DataItemType.Group) {
return DataItemType.Group
}
return DataItemType.Row
}
export const group = (definition, children) => {
return {
definition,
children,
[DataItemTypeKey]: DataItemType.Group,
}
}
export const getColumnValue = (ctx, column) => {
if (column.getRowVal) {
return column.getRowVal(ctx.item, ctx.isGroup, ctx.parents)
}
return null
}
class DataVisitorContext {
constructor(visitor, item, parents = [], isGroup = false) {
this.visitor = visitor
this.item = item
this.parents = parents
this.isGroup = isGroup
}
visitItem(item) {
return this.visitor.visitItem(item, this)
}
withGroup(group) {
return new DataVisitorContext(this.visitor, group, [group, ...this.parents], true)
}
withRow(row) {
return new DataVisitorContext(this.visitor, row, this.parents, false)
}
}
export class DataVisitor {
visitRow() {}
visitGroup(ctx) {
ctx.item.children.forEach(item => ctx.visitItem(item))
}
visitItem(item, pCtx) {
let ctx = pCtx
if (ctx === undefined) {
ctx = new DataVisitorContext(this, null)
}
const type = getDataItemType(item)
if (type === DataItemType.Group) {
return this.visitGroup(ctx.withGroup(item))
}
return this.visitRow(ctx.withRow(item))
}
static visit(items, ...args) {
const visitor = new this(...args)
items.forEach(item => visitor.visitItem(item))
return visitor
}
}
File diff suppressed because it is too large Load Diff
@@ -1,38 +1,34 @@
import React, { useCallback } from 'react'
import { connect } from 'react-redux'
import { func, bool } from 'prop-types'
import { useDispatch, useSelector } from 'react-redux'
import { Radio } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'
import TSA from '../../common/tsa'
import { toggleStudentNameVisibility } from '../../redux/settings'
const StudentNameVisibilityToggle = ({ showNames, toggleStudentNameVisibility: toggle }) => {
export const useStudentNameVisibility = () => {
const visible = useSelector(state => state.settings.namesVisible)
const dispatch = useDispatch()
const toggle = useCallback(() => {
dispatch(toggleStudentNameVisibility())
}, [dispatch])
return { visible, toggle }
}
const StudentNameVisibilityToggle = ({ style = {} }) => {
const { visible, toggle } = useStudentNameVisibility()
const handleChange = useCallback(() => {
TSA.Matomo.sendEvent('Common', 'Toggle student name visibility', showNames ? 'hide' : 'show')
TSA.Matomo.sendEvent('Common', 'Toggle student name visibility', visible ? 'hide' : 'show')
toggle()
}, [showNames, toggle])
}, [visible, toggle])
return (
<div style={{ marginTop: 15, marginBottom: 10 }}>
<Radio
data-cy="toggleStudentNames"
toggle
label="Show student names"
checked={showNames}
onChange={handleChange}
/>
<div style={{ marginTop: 15, marginBottom: 10, ...style }}>
<Radio data-cy="toggleStudentNames" toggle label="Show student names" checked={visible} onChange={handleChange} />
</div>
)
}
StudentNameVisibilityToggle.propTypes = {
showNames: bool.isRequired,
toggleStudentNameVisibility: func.isRequired,
}
const mapStateToProps = state => ({
showNames: state.settings.namesVisible,
})
export default connect(mapStateToProps, { toggleStudentNameVisibility })(withRouter(StudentNameVisibilityToggle))
export default StudentNameVisibilityToggle
@@ -28,13 +28,11 @@ const TagsTable = ({ student, language }) => {
key: 'PROGRAMME',
title: 'Programme',
getRowVal: t => getTextIn(t.programme.name, language),
cellProps: { collapsing: true },
},
{
key: 'CODE',
title: 'Code',
getRowVal: t => t.programme.code,
cellProps: { collapsing: true },
},
{
key: 'TAGS',
@@ -182,7 +182,18 @@ const SingleStudyGroupContent = ({ filteredStudents, population, group, language
},
]
return <Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={createPanels(filteredStudents)} />
return (
<div style={{ overflow: 'hidden', flexGrow: 1, padding: '1px' }}>
<Accordion
activeIndex={activeIndex}
exclusive={false}
styled
fluid
panels={createPanels(filteredStudents)}
style={{ overflow: 'hidden' }}
/>
</div>
)
}
const SingleStudyGroupFilterView = props => {
@@ -38,7 +38,7 @@ const prettifyCamelCase = str => {
}
const cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
const cellContent = { flex: '0 1 50%' }
const cellContent = { flexGrow: 1 }
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
@@ -99,7 +99,7 @@ const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
/>
)}
</div>
<div style={cellContent}>
<div style={{ ...cellContent, flexGrow: 0 }}>
<Button type="submit" style={{ margin: '0' }} disabled={isLoading}>
Add {prettifyCamelCase(tagName)}
</Button>
@@ -134,7 +134,7 @@ const TagCell = ({ tagName, group, studyProgrammes }) => {
return group.tags?.[tagName] && !showEdit ? (
<div style={{ ...cellWrapper, alignItems: 'baseline' }}>
<p style={{ ...cellContent, textAlign: 'center' }}>{getText()}</p>
<div style={cellContent}>
<div style={{ ...cellContent, flexGrow: 0 }}>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
@@ -152,7 +152,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
const headers = [
{
key: 'name',
title: 'name',
title: 'Name',
getRowVal: group => getTextIn(group.name, language),
getRowContent: group => <LinkToGroup group={group} language={language} />,
cellProps: {
@@ -164,7 +164,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
},
{
key: 'students',
title: 'students',
title: 'Students',
getRowVal: group => group.members?.length || 0,
getRowContent: group => group.members?.length || 0,
cellProps: {
@@ -176,14 +176,16 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
},
{
key: 'studyProgramme',
title: 'Study programme',
title: 'Study Programme',
getRowVal: group => group.tags?.studyProgramme,
formatValue: value => studyProgrammes.find(p => p.value === value)?.text,
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
},
{
key: 'associatedyear',
title: 'Associated starting academic year',
title: 'Associated Starting Academic Year',
getRowVal: group => group.tags?.year,
formatValue: startYearToAcademicYear,
getRowContent: group => <TagCell tagName="year" group={group} />,
},
]
@@ -200,7 +202,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
lisäominaisuuksia.{' '}
</p>
</StyledMessage>
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
<SortableTable figure={false} columns={headers} getRowKey={group => group.id} data={groups} />
</>
)
}
@@ -1,114 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Table, Header, Grid, Segment } from 'semantic-ui-react'
import { shape, any, arrayOf, bool, oneOfType, array } from 'prop-types'
const DIRECTIONS = {
ASC: 'ascending', // true
DESC: 'descending', // false
}
function sortBy(column, data) {
const sortByString = column === 'name' || column === 'code'
return data.concat().sort((a, b) => {
const keyA = column === 'name' ? a[column].fi : a[column]
const keyB = column === 'name' ? b[column].fi : b[column]
return sortByString ? keyB.localeCompare(keyA) : keyB - keyA
})
}
const BachelorsTable = ({ bachelors, loading }) => {
const [bachelorsData, setBachelorsData] = useState({})
const [sortColumn, setSortColumn] = useState(null)
const [direction, setDirection] = useState(false)
useEffect(() => setBachelorsData(bachelors), [bachelors])
useEffect(() => {
if (!bachelorsData || !bachelorsData.data) return
const data = sortBy(sortColumn, bachelorsData.data)
if (direction) data.reverse()
setBachelorsData({ ...bachelorsData, data })
}, [direction, sortColumn])
const getSorted = column => {
if (column !== sortColumn) return null
return direction ? DIRECTIONS.ASC : DIRECTIONS.DESC
}
const setSorted = column => {
if (column === sortColumn) setDirection(!direction)
else {
setSortColumn(column)
setDirection(false)
}
}
if (!loading && (!bachelorsData.data || !bachelorsData.data.length)) return null
return (
<>
<Header>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>Study programme options</Grid.Column>
</Grid.Row>
</Grid>
</Header>
{bachelorsData && bachelorsData.years && `Statistics from ${bachelorsData.years.length} years`}
<Segment basic loading={loading} style={{ overflowX: 'auto' }}>
<Table celled structured compact striped selectable sortable>
{bachelorsData && bachelorsData.data ? (
<>
<Table.Header>
<Table.Row>
<Table.HeaderCell sorted={getSorted('name')} onClick={() => setSorted('name')}>
Name
</Table.HeaderCell>
<Table.HeaderCell sorted={getSorted('code')} onClick={() => setSorted('code')}>
Code
</Table.HeaderCell>
{bachelorsData.years.map(year => (
<Table.HeaderCell sorted={getSorted(year)} onClick={() => setSorted(year)} key={year}>
{year}
</Table.HeaderCell>
))}
<Table.HeaderCell sorted={getSorted('total')} onClick={() => setSorted('total')}>
Total
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{bachelorsData.data.map(item => (
<Table.Row key={item.code}>
<Table.Cell>{item.name.fi}</Table.Cell>
<Table.Cell>{item.code}</Table.Cell>
{bachelorsData.years.map(year => (
<Table.Cell key={year}>{item[year]}</Table.Cell>
))}
<Table.Cell>{item.total}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</>
) : null}
</Table>
</Segment>
</>
)
}
BachelorsTable.propTypes = {
bachelors: oneOfType([
shape({
data: arrayOf(any),
years: arrayOf(any),
}),
array,
]),
loading: bool.isRequired,
}
BachelorsTable.defaultProps = {
bachelors: null,
}
export default BachelorsTable
@@ -4,7 +4,7 @@ const ReactHighcharts = require('react-highcharts')
const colors = ['#003E65', '#1392c2', '#036415']
const BarChart = ({ data }) => {
const BarChart = ({ cypress, data }) => {
const dataWithColors = data?.graphStats?.map((series, index) => ({ ...series, color: colors[index] }))
const defaultConfig = {
@@ -51,7 +51,7 @@ const BarChart = ({ data }) => {
if (!data) return <></>
return (
<div className="graph-container">
<div className="graph-container" data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
@@ -6,7 +6,7 @@ const DataTable = ({ data, cypress, titles, wideTable }) => {
return (
<div className={`table-container${wideTable ? '-wide' : ''}`}>
<Table data-cy={cypress} celled>
<Table data-cy={`Table-${cypress}`} celled>
<Table.Header>
<Table.Row>
{titles?.map(title => (
@@ -2,7 +2,7 @@ import React from 'react'
const ReactHighcharts = require('react-highcharts')
const GaugeChart = ({ data, year, graduationAmount, studyprogramme }) => {
const GaugeChart = ({ cypress, data, year, graduationAmount, studyprogramme }) => {
if (!data || !graduationAmount || !data.length) return null
const thresholdValues = studyprogramme.includes('KH') ? [36, 41] : [24, 27]
@@ -76,7 +76,11 @@ const GaugeChart = ({ data, year, graduationAmount, studyprogramme }) => {
],
}
return <ReactHighcharts config={defaultConfig} />
return (
<div data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
}
export default GaugeChart
@@ -7,7 +7,7 @@ require('highcharts-exporting')(ReactHighcharts.Highcharts)
const colors = ['#003E65', '#1392c2', '#E68825', '#333737', '#036415']
const LineGraph = ({ data }) => {
const LineGraph = ({ cypress, data }) => {
const dataWithColors = data?.graphStats.map((series, index) => ({
...series,
color: colors[index],
@@ -49,7 +49,7 @@ const LineGraph = ({ data }) => {
if (!data) return <></>
return (
<div className="graph-container">
<div className="graph-container" data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
@@ -4,7 +4,7 @@ const ReactHighcharts = require('react-highcharts')
const colors = ['#003E65', '#1392c2', '#036415', '#bdc2c7', '#E68825', '#333737', '#54997b', '#195f8a']
const StackedBarChart = ({ data, labels, wideTable }) => {
const StackedBarChart = ({ cypress, data, labels, wideTable }) => {
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
const defaultConfig = {
@@ -55,7 +55,7 @@ const StackedBarChart = ({ data, labels, wideTable }) => {
if (!data) return <></>
return (
<div className={`graph-container${wideTable ? '-narrow' : ''}`}>
<div className={`graph-container${wideTable ? '-narrow' : ''}`} data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
@@ -1,35 +1,19 @@
import React, { useState } from 'react'
import { Divider, Loader, Message, Radio } from 'semantic-ui-react'
import { Divider, Loader, Message } from 'semantic-ui-react'
import { useGetBasicStatsQuery, useGetCreditStatsQuery, useGetGraduationStatsQuery } from 'redux/studyProgramme'
import WithHelpTooltip from '../../Info/InfoWithHelpTooltip'
import LineGraph from './LineGraph'
import StackedBarChart from './StackedBarChart'
import BarChart from './BarChart'
import GaugeChart from './GaugeChart'
import DataTable from './DataTable'
import Toggle from '../Toggle'
import InfoBox from '../../Info/InfoBox'
import InfotoolTips from '../../../common/InfoToolTips'
import '../studyprogramme.css'
const getRadioButton = (toolTip, firstLabel, secondLabel, value, setValue) => (
<div className="radio-toggle">
<label className="toggle-label">{firstLabel}</label>
<Radio toggle checked={value} onChange={() => setValue(!value)} />
{toolTip ? (
<WithHelpTooltip tooltip={{ short: toolTip }}>
<label className="toggle-label">{secondLabel}</label>
</WithHelpTooltip>
) : (
<label className="toggle-label">{secondLabel}</label>
)}
</div>
)
const Overview = ({ studyprogramme }) => {
const [academicYear, setAcademicYear] = useState(false)
const [specialGroups, setSpecialGroups] = useState(false)
const Overview = ({ studyprogramme, specialGroups, setSpecialGroups, academicYear, setAcademicYear }) => {
const [showMeanTime, setShowMeanTime] = useState(true)
const toolTips = InfotoolTips.Studyprogramme
const yearType = academicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR'
@@ -71,14 +55,22 @@ const Overview = ({ studyprogramme }) => {
return (
<div className="studyprogramme-overview">
<div className="toggle-container">
{getRadioButton(toolTips.YearToggle, 'Calendar year ', 'Academic year', academicYear, setAcademicYear)}
{getRadioButton(
toolTips.StudentToggle,
'All studyrights included',
'Special studyrights excluded',
specialGroups,
setSpecialGroups
)}
<Toggle
cypress="YearToggle"
toolTips={toolTips.YearToggle}
firstLabel="Calendar year"
secondLabel="Academic year"
value={academicYear}
setValue={setAcademicYear}
/>
<Toggle
cypress="StudentToggle"
toolTips={toolTips.StudentToggle}
firstLabel="All studyrights"
secondLabel="Special studyrights excluded"
value={specialGroups}
setValue={setSpecialGroups}
/>
</div>
{isFetchingOrLoading ? (
@@ -94,7 +86,7 @@ const Overview = ({ studyprogramme }) => {
<>
{getDivider('Students of the studyprogramme', 'StudentsOfTheStudyprogramme')}
<div className="section-container">
<LineGraph data={basics?.data} />
<LineGraph cypress="StudentsOfTheStudyprogramme" data={basics?.data} />
<DataTable
cypress="StudentsOfTheStudyprogramme"
data={basics?.data?.tableStats}
@@ -107,7 +99,11 @@ const Overview = ({ studyprogramme }) => {
<>
{getDivider('Credits produced by the studyprogramme', 'CreditsProducedByTheStudyprogramme')}
<div className="section-container">
<StackedBarChart data={credits?.data?.graphStats} labels={credits?.data?.years} />
<StackedBarChart
cypress="CreditsProducedByTheStudyprogramme"
data={credits?.data?.graphStats}
labels={credits?.data?.years}
/>
<DataTable
cypress="CreditsProducedByTheStudyprogramme"
data={credits?.data?.tableStats}
@@ -120,7 +116,7 @@ const Overview = ({ studyprogramme }) => {
<>
{getDivider('Graduated and thesis writers of the programme', 'GraduatedAndThesisWritersOfTheProgramme')}
<div className="section-container">
<BarChart data={graduations?.data} />
<BarChart cypress="GraduatedAndThesisWritersOfTheProgramme" data={graduations?.data} />
<DataTable
cypress="GraduatedAndThesisWritersOfTheProgramme"
data={graduations?.data?.tableStats}
@@ -128,40 +124,51 @@ const Overview = ({ studyprogramme }) => {
/>
</div>
{getDivider('Average graduation times', 'AverageGraduationTimes')}
{getRadioButton(null, 'Mean time', 'Median time', showMeanTime, setShowMeanTime)}
<Toggle
cypress="GraduationTimeToggle"
firstLabel="Mean time"
secondLabel="Median time"
value={showMeanTime}
setValue={setShowMeanTime}
/>
<div className="section-container-centered">
{graduations?.data?.years.map(year => (
<GaugeChart
cypress={`${year}-AverageGraduationTimes`}
key={year}
year={year}
data={
showMeanTime
? graduations?.data?.graduationMeanTime[year]
: graduations?.data?.graduationMedianTime[year]
? graduations?.data?.graduationMeanTime?.[year]
: graduations?.data?.graduationMedianTime?.[year]
}
graduationAmount={graduations?.data?.graduationAmounts[year]}
totalAmount={graduations?.data?.totalAmounts[year]}
graduationAmount={graduations?.data?.graduationAmounts?.[year]}
totalAmount={graduations?.data?.totalAmounts?.[year]}
studyprogramme={studyprogramme}
/>
))}
</div>
{studyprogramme.includes('KH') && (
<>
{getDivider('Primary master programme studies after this programme', 'ProgrammesAfterGraduation')}
<div className="section-container">
<StackedBarChart
wideTable
data={graduations?.data?.programmesAfterGraphStats}
labels={graduations?.data?.years}
/>
<DataTable
wideTable
data={graduations?.data?.programmesAfterTableStats}
titles={graduations?.data?.programmesAfterTitles}
/>
</div>
</>
)}
<>
{getDivider(
studyprogramme.includes('KH')
? 'Primary master programme studies after this programme'
: 'Primary bachelor programme studies before this programme',
'ProgrammesBeforeOrAfter'
)}
<div className="section-container">
<StackedBarChart
cypress="ProgrammesBeforeOrAfter"
wideTable
data={graduations?.data?.programmesBeforeOrAfterGraphStats}
labels={graduations?.data?.years}
/>
<DataTable
wideTable
data={graduations?.data?.programmesBeforeOrAfterTableStats}
titles={graduations?.data?.programmesBeforeOrAfterTitles}
/>
</div>
</>
</>
)}
</>
@@ -1,62 +0,0 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import ProductivityTable from '../ProductivityTable'
import ThroughputTable from '../ThroughputTable'
import BachelorsTable from '../BachelorsTable'
import { getProductivity } from '../../../redux/productivity'
import { getThroughput } from '../../../redux/throughput'
import { getBachelors } from '../../../redux/studyProgrammeBachelors'
import { isNewHYStudyProgramme } from '../../../common'
const Overview = props => {
const {
productivity,
throughput,
bachelors,
studyprogramme,
dispatchGetProductivity,
dispatchGetThroughput,
dispatchGetBachelors,
history,
} = props
useEffect(() => {
dispatchGetProductivity(studyprogramme)
dispatchGetThroughput(studyprogramme)
dispatchGetBachelors(studyprogramme)
}, [])
return (
<>
<ThroughputTable
throughput={throughput.data[studyprogramme]}
thesis={throughput.data.thesis}
loading={throughput.pending}
error={throughput.error}
studyprogramme={studyprogramme}
history={history}
newProgramme={isNewHYStudyProgramme(studyprogramme)}
/>
<ProductivityTable
productivity={productivity.data[studyprogramme]}
thesis={throughput.data.thesis}
loading={productivity.pending}
error={productivity.error}
showCredits={isNewHYStudyProgramme(studyprogramme)}
newProgramme={isNewHYStudyProgramme(studyprogramme)}
/>
<BachelorsTable bachelors={bachelors.data} loading={throughput.pending} />
</>
)
}
const mapStateToProps = ({ studyProgrammeProductivity, studyProgrammeThroughput, studyProgrammeBachelors }) => ({
productivity: studyProgrammeProductivity,
throughput: studyProgrammeThroughput,
bachelors: studyProgrammeBachelors,
})
export default connect(mapStateToProps, {
dispatchGetProductivity: getProductivity,
dispatchGetThroughput: getThroughput,
dispatchGetBachelors: getBachelors,
})(Overview)
@@ -1,162 +0,0 @@
import React, { useState, useEffect } from 'react'
import moment from 'moment'
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 '../../Info/InfoBox'
import infotooltips from '../../../common/InfoToolTips'
import './productivityTable.css'
const ProductivityTable = ({ productivity, thesis, loading, error, showCredits, newProgramme }) => {
const [selectedYear, setYear] = useState(newProgramme ? 2017 : null)
const years = productivity
? productivity.data
.map(stats => ({ key: stats.year, text: stats.year, value: stats.year }))
.sort((year1, year2) => Number(year2.value) - Number(year1.value))
: []
useEffect(() => {
if (!selectedYear && years.length > 5) {
setYear(years[5].value)
} else if (!selectedYear && years.length > 1) {
setYear(years[years.length - 1].value)
}
}, [years])
if (error) return <h1>Oh no so error {error}</h1>
let thesisTypes = []
if (thesis) {
thesisTypes = thesis.map(t => t.thesisType)
}
const headerList = [
'Year',
'Credits',
thesisTypes.includes('MASTER') && 'Masters Thesis',
thesisTypes.includes('BACHELOR') && 'Bachelors Thesis',
'Graduated',
'Credits for studyprogramme majors',
'Credits for non major students',
'Hyväksiluettu (not included in Credits column)',
].filter(_ => _)
const handleChange = (event, { value }) => {
event.preventDefault()
setYear(value)
}
const creditCell = content =>
showCredits ? (
<Table.Cell>{content}</Table.Cell>
) : (
<Table.Cell
className="productivity-table__not-available"
title="Credits productivity statistics not available for old programmes"
>
Not available
</Table.Cell>
)
return (
<>
<div style={{ marginTop: '2em' }}>
<InfoBox content={infotooltips.PopulationOverview.YearlyProductivity} />
</div>
<Header>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>
Yearly productivity
{productivity && (
<Header.Subheader data-cy="productivityUpdateStatus">
{`Last updated ${
productivity.lastUpdated
? moment(productivity.lastUpdated).format('HH:mm:ss MM-DD-YYYY')
: 'unknown'
}`}
{productivity.status === 'RECALCULATING' && (
<Label content="Recalculating! Refresh page in a few minutes" color="red" />
)}
</Header.Subheader>
)}
</Grid.Column>
</Grid.Row>
</Grid>
</Header>
<div>Statistics from selected year onwards</div>
{years.length > 4 ? <Dropdown options={years} onChange={handleChange} selection value={selectedYear} /> : null}
<Segment basic loading={loading} style={{ overflowX: 'auto' }}>
<Table structured celled compact striped selectable className="fixed-header">
<Table.Header>
<Table.Row>
{headerList.map(header => (
<Table.HeaderCell key={header}>{header}</Table.HeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{productivity && productivity.data
? productivity.data
.filter(stats => Number(stats.year) >= Number(selectedYear))
.sort((year1, year2) => year2.year - year1.year)
.map(year => (
<Table.Row key={year.year}>
<Table.Cell>{year.year}</Table.Cell>
{creditCell(year.credits.toFixed(2))}
{thesisTypes.includes('BACHELOR') && <Table.Cell>{year.bThesis}</Table.Cell>}
{thesisTypes.includes('MASTER') && <Table.Cell>{year.mThesis}</Table.Cell>}
<Table.Cell>{year.graduated}</Table.Cell>
{creditCell(year.creditsForMajors.toFixed(2))}
{creditCell((year.credits - year.creditsForMajors).toFixed(2))}
{creditCell(year.transferredCredits.toFixed(2))}
</Table.Row>
))
: null}
</Table.Body>
</Table>
</Segment>
</>
)
}
ProductivityTable.propTypes = {
productivity: shape({
lastUpdated: string,
status: string,
data: arrayOf(
shape({
year: oneOfType([number, string]),
credits: number,
mThesis: number,
bThesis: number,
graduated: number,
creditsForMajors: number,
transferredCredits: number,
})
),
}),
thesis: arrayOf(
shape({
programmeCode: string,
courseCode: string,
thesisType: string,
createdAt: string,
updatedAt: string,
})
),
loading: bool.isRequired,
error: bool.isRequired,
showCredits: bool.isRequired,
newProgramme: bool.isRequired,
}
ProductivityTable.defaultProps = {
productivity: null,
thesis: undefined,
}
export default connect(null, {
dispatchGetProductivity: getProductivity,
})(ProductivityTable)
@@ -1,3 +0,0 @@
.productivity-table__not-available {
color: rgba(40, 40, 40, 0.3);
}
@@ -117,20 +117,35 @@ const StudyProgrammeSelector = ({ studyprogrammes, selected, language }) => {
{bachelorProgrammes.length > 0 ? (
<>
<Header>Bachelor programmes</Header>
<SortableTable columns={headers} getRowKey={programme => programme.code} data={bachelorProgrammes} />
<SortableTable
figure={false}
columns={headers}
getRowKey={programme => programme.code}
data={bachelorProgrammes}
/>
</>
) : null}
{masterProgrammes.length > 0 ? (
<>
<Header>Master programmes</Header>
<SortableTable columns={headers} getRowKey={programme => programme.code} data={masterProgrammes} />
<SortableTable
figure={false}
columns={headers}
getRowKey={programme => programme.code}
data={masterProgrammes}
/>
</>
) : null}
{otherProgrammes.length > 0 ? (
<>
<Header>Doctoral programmes and old programmes</Header>
<SortableTable columns={headers} getRowKey={programme => programme.code} data={otherProgrammes} />
<SortableTable
figure={false}
columns={headers}
getRowKey={programme => programme.code}
data={otherProgrammes}
/>
</>
) : null}
</>
@@ -1,265 +0,0 @@
import React, { useEffect, useState } from 'react'
import { string, func, shape, bool } from 'prop-types'
import { connect } from 'react-redux'
import { uniqBy } from 'lodash'
import { Dropdown, Tab, Message, Button } from 'semantic-ui-react'
import ThroughputTable from '../ThroughputTable'
import { getProductivity } from '../../../redux/productivity'
import { getThroughput } from '../../../redux/throughput'
import { isNewHYStudyProgramme, textAndDescriptionSearch, getTextIn } from '../../../common'
import ProtoC from '../../CoolDataScience/ProtoC'
const Overview = ({
language,
throughput,
studyprogramme,
dispatchGetProductivity,
dispatchGetThroughput,
history,
studyprogrammes,
}) => {
const [selectedTrack, setTrack] = useState('')
const [selectedYear, setYear] = useState('')
const [throughputData, setData] = useState(null)
const [studytrackOptions, setStudytrackOptions] = useState([])
const [yearOptions, setYearOptions] = useState([])
const [activeIndex, setIndex] = useState(0)
useEffect(() => {
dispatchGetProductivity(studyprogramme)
dispatchGetThroughput(studyprogramme)
}, [])
// set dropdown options
useEffect(() => {
if (throughput.data[studyprogramme]) {
const years = throughput.data[studyprogramme].data.map(data => data.year)
const filteredStudytracks = years.reduce((acc, curr) => {
if (studyprogrammes[studyprogramme].enrollmentStartYears[curr.slice(0, 4)])
acc.push(...Object.values(studyprogrammes[studyprogramme].enrollmentStartYears[curr.slice(0, 4)].studyTracks))
return uniqBy(acc, 'code')
}, [])
const yearDropdownOptions = years.map(year => ({
key: year,
text: year,
value: year,
}))
const dropdownOptions = filteredStudytracks.map(st => ({
key: st.code,
text: getTextIn(st.name, language),
description: st.code,
value: st.code,
}))
if (yearDropdownOptions.length > 0) {
setYear(yearDropdownOptions[0].value)
}
setYearOptions(yearDropdownOptions)
setStudytrackOptions(dropdownOptions)
}
}, [throughput, studyprogrammes])
// mankel throughput data after selecting studytrack
useEffect(() => {
if (throughput.data[studyprogramme] && selectedTrack) {
if (throughput.data[studyprogramme].data[0].studytrackdata) {
const selectedData = throughput.data[studyprogramme].data.reduce((acc, curr) => {
if (curr.studytrackdata[selectedTrack]) acc.push(curr.studytrackdata[selectedTrack])
return acc
}, [])
const newData = {
data: selectedData,
lastUpdated: throughput.data[studyprogramme].lastUpdated,
status: throughput.data[studyprogramme].status,
totals: throughput.data[studyprogramme].stTotals[selectedTrack],
}
setData(newData)
}
}
}, [selectedTrack, activeIndex])
// mankel throughput data after selecting a year
useEffect(() => {
if (throughput.data[studyprogramme] && selectedYear) {
if (throughput.data[studyprogramme].data[0].studytrackdata) {
const selectedYearData = throughput.data[studyprogramme].data.find(data => data.year === selectedYear)
const filteredStudytracks = Object.values(studyprogrammes[studyprogramme].enrollmentStartYears).reduce(
(acc, curr) => {
acc.push(...Object.values(curr.studyTracks))
return uniqBy(acc, 'code')
},
[]
)
const studytrackYearData = Object.keys(selectedYearData.studytrackdata).reduce((acc, curr) => {
const studytrack = filteredStudytracks.find(st => st.code === curr)
if (studytrack) {
const newStudyTrackObject = {
...selectedYearData.studytrackdata[curr],
// oh pls no, pls fix asap
// need to fix logic in throughputtable component so that we can name this better
year: `${getTextIn(studytrack.name, language)}, ${curr}`,
}
acc.push(newStudyTrackObject)
}
return acc
}, [])
const newData = {
data: studytrackYearData,
lastUpdated: throughput.data[studyprogramme].lastUpdated,
status: throughput.data[studyprogramme].status,
totals: {
...selectedYearData,
credits: selectedYearData.creditValues,
genders: {
Male: Number(selectedYearData.genders.Male) || 0,
Female: Number(selectedYearData.genders.Female) || 0,
Other: Number(selectedYearData.genders.Other) || 0,
},
students:
(Number(selectedYearData.genders.Male) || 0) +
(Number(selectedYearData.genders.Female) || 0) +
(Number(selectedYearData.genders.Other) || 0),
},
}
setData(newData)
}
}
}, [selectedYear, activeIndex])
const handleStudytrackChange = (event, { value }) => {
event.preventDefault()
setTrack(value)
}
const handleYearChange = (event, { value }) => {
event.preventDefault()
setYear(value)
}
const renderSelection = year => {
if (year && yearOptions.length < 10)
return (
<>
<h3>Year selection</h3>
<Button.Group>
{yearOptions.map(option => (
<Button
key={option.key}
content={option.text}
style={{ borderRadius: '1px' }}
onClick={e => handleYearChange(e, option)}
active={option.value === selectedYear}
color={option.value === selectedYear ? 'blue' : 'black'}
basic={option.value !== selectedYear}
/>
))}
</Button.Group>
</>
)
return (
<>
<h3>{year ? 'Year' : 'Studytrack'} selection</h3>
<Dropdown
options={year ? yearOptions : studytrackOptions}
onChange={year ? handleYearChange : handleStudytrackChange}
defaultValue={year ? yearOptions[0].value : studytrackOptions[0].value}
selection
placeholder={`Select ${year ? 'year' : ' studytrack'}`}
search={textAndDescriptionSearch}
fluid
/>
</>
)
}
const renderTable = year => {
if (!throughputData || throughputData.data.length < 1) {
return (
<>
{renderSelection(year)}
<h2>No {year ? 'year' : 'studytrack'} selected or no data available for selection</h2>
</>
)
}
return (
<>
{renderSelection(year)}
<ThroughputTable
throughput={throughputData}
thesis={throughput.data.thesis}
loading={throughput.pending}
error={throughput.error}
studyprogramme={studyprogramme}
studytrack={selectedTrack}
history={history}
newProgramme={isNewHYStudyProgramme(studyprogramme)}
isStudytrackView={year}
/>
</>
)
}
const handleTabChange = (event, { activeIndex }) => {
setData(null)
if (activeIndex === 1) {
setTrack(studytrackOptions[0].value)
setYear('')
} else if (activeIndex === 0) {
setYear(yearOptions[0].value)
setTrack('')
}
setIndex(activeIndex)
}
const panes = [
{
menuItem: 'Year specific',
render: () => renderTable(true),
},
{
menuItem: 'Studytrack specific',
render: () => renderTable(false),
},
]
if (studyprogramme.includes('KH'))
panes.push({
menuItem: 'Studytrack Graph',
render: () => <ProtoC programme={studyprogramme} />,
})
return (
<>
<Message
content="Here you can see statistics on studytrack level. In year specific tab you can compare statistics for all studytracks within selected year.
In studytrack specific tab you can compare different years from one selected studytrack."
/>
<Tab panes={panes} activeIndex={activeIndex} onTabChange={handleTabChange} />
</>
)
}
Overview.propTypes = {
studyprogramme: string.isRequired,
dispatchGetProductivity: func.isRequired,
dispatchGetThroughput: func.isRequired,
history: shape({}).isRequired,
studyprogrammes: shape({}).isRequired,
language: string.isRequired,
throughput: shape({
error: bool,
pending: bool,
data: shape({}),
}).isRequired // eslint-disable-line
}
const mapStateToProps = ({ studyProgrammeThroughput, populationProgrammes, settings }) => ({
throughput: studyProgrammeThroughput,
studyprogrammes: populationProgrammes.data.programmes || {},
language: settings.language,
})
export default connect(mapStateToProps, {
dispatchGetProductivity: getProductivity,
dispatchGetThroughput: getThroughput,
})(Overview)
@@ -5,7 +5,7 @@ const ReactHighcharts = require('react-highcharts')
const colors = ['#333737', '#bdc2c7', '#1392c2', '#195f8a', '#54997b', '#036415']
const BarChart = ({ data, track }) => {
const BarChart = ({ cypress, data, track }) => {
if (!data || !data.creditGraphStats || !data.creditGraphStats[track])
return <NoDataMessage message="No progress data for the studytrack found. Try with another studytrack" />
@@ -64,7 +64,7 @@ const BarChart = ({ data, track }) => {
}
return (
<div className="graph-container">
<div className="graph-container" data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
@@ -18,7 +18,7 @@ const DataTable = ({ data, titles, track }) => {
return (
<div className="table-container">
<Table compact celled>
<Table data-cy="Table-StudytrackProgress" compact celled>
<Table.Header>
<Table.Row>
{titles?.map(title => (
@@ -2,7 +2,7 @@ import React from 'react'
const ReactHighcharts = require('react-highcharts')
const GaugeChart = ({ data, year, graduationAmount, totalAmount, studyprogramme }) => {
const GaugeChart = ({ cypress, data, year, graduationAmount, totalAmount, studyprogramme }) => {
if (!data || !graduationAmount || !data.length) return null
const thresholdValues = studyprogramme.includes('KH') ? [36, 41] : [24, 27]
@@ -76,7 +76,11 @@ const GaugeChart = ({ data, year, graduationAmount, totalAmount, studyprogramme
],
}
return <ReactHighcharts config={defaultConfig} />
return (
<div data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
}
export default GaugeChart
@@ -1,8 +1,9 @@
import React, { useState } from 'react'
import { Icon, Radio, Table } from 'semantic-ui-react'
import { Icon, Table } from 'semantic-ui-react'
import * as _ from 'lodash'
import PopulationLink from './PopulationLink'
import Toggle from '../Toggle'
const getKey = year => `${year}-${Math.random()}`
@@ -124,14 +125,6 @@ const sortMainDataByYear = data => {
return sortedData
}
const getRadioButton = (toolTip, firstLabel, secondLabel, value, setValue) => (
<div className="radio-toggle">
<label className="toggle-label">{firstLabel}</label>
<Radio toggle checked={value} onChange={() => setValue(!value)} />
<label className="toggle-label">{secondLabel}</label>
</div>
)
const StudytrackDataTable = ({
studyprogramme,
dataOfAllTracks,
@@ -151,8 +144,13 @@ const StudytrackDataTable = ({
return (
<div className="datatable">
{getRadioButton(null, 'Show percentages', 'Hide percentages', hidePercentages, setHidePercentages)}
<Table celled>
<Toggle
firstLabel="Show percentages"
secondLabel="Hide percentages"
value={hidePercentages}
setValue={setHidePercentages}
/>
<Table data-cy="Table-StudytrackOverview" celled>
<Table.Header>
<Table.Row>
{titles.map((title, index) => (
@@ -1,37 +1,39 @@
import React, { useEffect, useState } from 'react'
import { Divider, Loader, Radio } from 'semantic-ui-react'
import { Divider, Loader } from 'semantic-ui-react'
import { useGetStudytrackStatsQuery } from 'redux/studyProgramme'
import WithHelpTooltip from '../../Info/InfoWithHelpTooltip'
import InfoBox from '../../Info/InfoBox'
import BarChart from './BarChart'
import BasicDataTable from './BasicDataTable'
import GaugeChart from './GaugeChart'
import StudytrackDataTable from './StudytrackDataTable'
import StudytrackSelector from './StudytrackSelector'
import Toggle from '../Toggle'
import InfotoolTips from '../../../common/InfoToolTips'
import '../studyprogramme.css'
const getRadioButton = (toolTip, firstLabel, secondLabel, value, setValue) => (
<div className="radio-toggle">
<label className="toggle-label">{firstLabel}</label>
<Radio toggle checked={value} onChange={() => setValue(!value)} />
<WithHelpTooltip tooltip={{ short: toolTip }}>
<label className="toggle-label">{secondLabel}</label>
</WithHelpTooltip>
</div>
)
const StudytrackOverview = ({ studyprogramme }) => {
const StudytrackOverview = ({
studyprogramme,
specialGroups,
setSpecialGroups,
graduated,
setGraduated,
combined,
setCombined,
}) => {
const toolTips = InfotoolTips.Studyprogramme
const [showMeanTime, setShowMeanTime] = useState(true)
const [specialGroups, setSpecialGroups] = useState(false)
const [graduated, setGraduated] = useState(false)
const [track, setTrack] = useState(studyprogramme)
const special = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
const grad = graduated ? 'GRADUATED_EXCLUDED' : 'GRADUATED_INCLUDED'
const stats = useGetStudytrackStatsQuery({ id: studyprogramme, specialGroups: special, graduated: grad })
const yearsCombined = combined ? 'YEARS_COMBINED' : 'YEARS_SEPARATED'
const stats = useGetStudytrackStatsQuery({
id: studyprogramme,
specialGroups: special,
graduated: grad,
yearsCombined,
})
useEffect(() => {
if (!track && stats?.data?.mainStatsByTrack[studyprogramme]) {
@@ -62,14 +64,30 @@ const StudytrackOverview = ({ studyprogramme }) => {
<>
<StudytrackSelector track={track} setTrack={setTrack} studytracks={stats?.data?.studytrackOptions} />
<div className="toggle-container">
{getRadioButton(
toolTips.StudentToggle,
'All studyrights included',
'Special studyrights excluded',
specialGroups,
setSpecialGroups
)}
{getRadioButton(toolTips.GradToggle, 'Graduated included', 'Graduated excluded', graduated, setGraduated)}
<Toggle
cypress="StudentToggle"
toolTips={toolTips.StudentToggle}
firstLabel="All studyrights"
secondLabel="Special studyrights excluded"
value={specialGroups}
setValue={setSpecialGroups}
/>
<Toggle
cypress="GraduatedToggle"
toolTips={toolTips.GraduatedToggle}
firstLabel="Graduated included"
secondLabel="Graduated excluded"
value={graduated}
setValue={setGraduated}
/>
<Toggle
cypress="AllStudentsToggles"
toolTips={toolTips.GraduatedToggle}
firstLabel="Show by year"
secondLabel="Show all years combined"
value={combined}
setValue={setCombined}
/>
</div>
{getDivider(
`Students of ${
@@ -97,8 +115,9 @@ const StudytrackOverview = ({ studyprogramme }) => {
'StudytrackProgress'
)}
<div className="section-container">
<BarChart data={stats?.data} track={track || studyprogramme} />
<BarChart cypress="StudytrackProgress" data={stats?.data} track={track || studyprogramme} />
<BasicDataTable
cypress="StudytrackProgress"
data={stats?.data?.creditTableStats}
track={track || studyprogramme}
titles={stats?.data?.creditTableTitles}
@@ -107,10 +126,16 @@ const StudytrackOverview = ({ studyprogramme }) => {
{stats?.isSuccess && stats?.data?.includeGraduated && (
<>
{getDivider('Average graduation times', 'AverageGraduationTimes')}
{getRadioButton(null, 'Mean time', 'Median time', showMeanTime, setShowMeanTime)}
<Toggle
firstLabel="Mean time"
secondLabel="Median time"
value={showMeanTime}
setValue={setShowMeanTime}
/>
<div className="section-container-centered">
{stats?.data?.years.map(year => (
<GaugeChart
cypress={`${year.slice(0, 4)}-AverageGraduationTimes`}
key={year}
year={year}
data={stats?.data?.graduationMedianTime[track][year]}
@@ -1,56 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { Segment } from 'semantic-ui-react'
import { getCourseSearchResults } from '../../../selectors/courses'
import useLanguage from '../../LanguagePicker/useLanguage'
import SortableTable from '../../SortableTable'
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 }}
columns={[
{
key: 'code',
title: 'Code',
getRowVal: c => c.code,
headerProps: { width: 2, textAlign: 'center' },
},
{
key: 'name',
title: 'Name',
getRowVal: c => c.name[language],
headerProps: { width: 12, textAlign: 'left' },
},
{
key: 'actions',
title: 'Action',
getRowContent: getCourseActions,
headerProps: { width: 2, textAlign: 'center' },
},
]}
data={courses}
/>
)
}
SearchResult.defaultProps = {
courses: [],
}
const mapStateToProps = state => {
const { thesisCourses } = state
const { data } = thesisCourses || null
const thesisCourseCodes = data.map(c => c.courseCode)
const searchResults = getCourseSearchResults(state).courses
const filteredResults = searchResults.filter(c => !thesisCourseCodes.includes(c.code))
return {
courses: filteredResults,
}
}
export default connect(mapStateToProps)(SearchResult)
@@ -1,67 +0,0 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { Dimmer, Loader, Segment, Button } from 'semantic-ui-react'
import { getThesisCourses, deleteThesisCourse } from '../../../redux/thesisCourses'
import SortableTable from '../../SortableTable'
const ThesisCourseList = ({ studyprogramme, getThesisCourses, pending, data, deleteThesisCourse }) => {
useEffect(() => {
getThesisCourses(studyprogramme)
}, [studyprogramme])
const handleDelete = course => () => {
deleteThesisCourse(studyprogramme, course)
}
return (
<>
<Dimmer inverted active={pending}>
<Loader />
</Dimmer>
{data.length === 0 ? (
<Segment content="No thesis courses" />
) : (
<SortableTable
tableProps={{ celled: true }}
data={data}
getRowKey={c => c.courseCode}
columns={[
{
key: 'code',
title: 'Code',
headerProps: { width: 2 },
getRowVal: c => c.courseCode,
},
{
key: 'type',
title: 'Type',
headerProps: { width: 13 },
getRowVal: c => c.thesisType,
},
{
key: 'action',
title: 'Remove',
headerProps: { width: 1, textAlign: 'center', onClick: null, sorted: null },
cellProps: { textAlign: 'center' },
getRowContent: c => <Button basic icon="trash" onClick={handleDelete(c.courseCode)} />,
},
]}
/>
)}
</>
)
}
ThesisCourseList.defaultProps = {
data: [],
}
const mapStateToProps = ({ thesisCourses }) => {
const { pending, data } = thesisCourses
return {
pending,
data,
}
}
export default connect(mapStateToProps, { getThesisCourses, deleteThesisCourse })(ThesisCourseList)
@@ -1,39 +0,0 @@
import React from 'react'
import { Button, Message } from 'semantic-ui-react'
import { connect } from 'react-redux'
import CourseSearchForm from '../../CourseSearchForm'
import ThesisCourseList from './ThesisCourseList'
import SearchResult from './SearchResult'
import Expandable from '../../Expandable'
import { createNewThesisCourse, getThesisCourses } from '../../../redux/thesisCourses'
const ThesisCourses = ({ studyprogramme, createThesis, getTheses }) => {
const handleClick = (course, thesis) => () => {
createThesis(studyprogramme, course, thesis).then(() => {
getTheses(studyprogramme)
})
}
return (
<>
<Message content="The set of courses used for calculating thesis productivity statistics" />
<Expandable fluid title="Add thesis course">
<CourseSearchForm />
<SearchResult
getCourseActions={({ code }) => (
<Button.Group basic fluid width="3">
<Button content="Bachelors" onClick={handleClick(code, 'BACHELOR')} />
<Button.Or />
<Button content="Masters" onClick={handleClick(code, 'MASTER')} />
</Button.Group>
)}
/>
</Expandable>
<ThesisCourseList studyprogramme={studyprogramme} />
</>
)
}
export default connect(null, {
createThesis: createNewThesisCourse,
getTheses: getThesisCourses,
})(ThesisCourses)
@@ -1,390 +0,0 @@
import React, { useState } from 'react'
import moment from 'moment'
import { Header, Table, Icon, Label, Segment, Dropdown, Button, Modal, Popup } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { flatten, uniq, range } from 'lodash'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import { isDev } from '../../../conf'
import InfoBox from '../../Info/InfoBox'
import infotooltips from '../../../common/InfoToolTips'
const getMonths = year => {
const end = moment()
const lastDayOfMonth = moment(end).endOf('month')
const start = `${year}-08-01`
return Math.round(moment.duration(moment(lastDayOfMonth).diff(moment(start))).asMonths())
}
const PopulationStatisticsLink = ({ studytrack, studyprogramme, year: yearLabel, children }) => {
const year = Number(yearLabel.slice(0, 4))
const months = Math.ceil(moment.duration(moment().diff(`${year}-08-01`)).asMonths())
const href = studytrack
? `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"studyTrack"%3A"${studytrack}"%7D&year=${year}`
: `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${year}`
return (
<Link title={`Population statistics of class ${yearLabel}`} to={href}>
{children}
</Link>
)
}
const TotalPopulationLink = ({ confirm, years, studyprogramme, studytrack, children }) => {
const confirmWrapper = e => {
if (confirm) {
// eslint-disable-next-line no-alert
const c = window.confirm(
`Are you sure you want to see a combined population of ${years.length} different populations?`
)
if (!c) e.preventDefault()
}
}
const yearsString = years.map(year => year.value).join('&years=')
const months = getMonths(Math.min(...years.map(year => Number(year.value))))
const href = studytrack
? `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"studyTrack"%3A"${studytrack}"%7D&year=${years[0].value}&years=${yearsString}`
: `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${years[0].value}&years=${yearsString}`
return (
<Link onClick={confirmWrapper} title="Population statistics of all years" to={href}>
{children}
</Link>
)
}
const ThroughputTable = ({
throughput,
thesis,
loading,
error,
studyprogramme,
studytrack,
history,
newProgramme,
isStudytrackView,
}) => {
const [lowerYear, setLower] = useState(null)
const [upperYear, setUpper] = useState(null)
const [yearDifference, setDifference] = useState(null)
const { isAdmin } = useGetAuthorizedUserQuery()
const data = throughput && throughput.data ? throughput.data.filter(year => year.credits.length > 0) : []
const years =
data && !isStudytrackView
? data
.map(stats => ({
key: stats.year.substring(0, 4),
text: stats.year,
value: stats.year.substring(0, 4),
}))
.sort((year1, year2) => Number(year2.value) - Number(year1.value))
: []
if (error) return <h1>Oh no so error {error}</h1>
// toggle only on dev env and with admin user
const GRADUATED_FEATURE_TOGGLED_ON = isAdmin && isDev
const genders = data.length > 0 ? uniq(flatten(data.map(year => Object.keys(year.genders)))) : []
genders.sort()
const renderGenders = genders.length > 0
const calculateTotalNationalities = () =>
data.length > 0 ? Object.values(throughput.totals.nationalities).reduce((res, curr) => res + curr, 0) : 0
const renderRatioOfFinns = calculateTotalNationalities() > 0
let thesisTypes = []
if (thesis) {
thesisTypes = thesis.map(t => t.thesisType)
}
const handleLowerBoundChange = (event, { value }) => {
event.preventDefault()
if (Number(value) > Number(upperYear)) {
setUpper(value)
setLower(value)
setDifference(1)
} else if (Number(upperYear) - Number(value) > 5) {
setUpper(String(Number(value) + 5))
setLower(value)
setDifference(6)
} else {
setLower(value)
setDifference(Number(upperYear) - Number(value) + 1)
}
}
const handleUpperBoundChange = (event, { value }) => {
event.preventDefault()
if (Number(value) < Number(lowerYear)) {
setUpper(value)
setLower(value)
setDifference(1)
} else if (Number(value) - Number(lowerYear) > 5) {
setLower(String(Number(value) - 5))
setUpper(value)
setDifference(6)
} else {
setUpper(value)
setDifference(Number(value) - Number(lowerYear) + 1)
}
}
const pushQueryToUrl = event => {
event.preventDefault()
const yearRange = range(Number(lowerYear), Number(upperYear) + 1)
const months = getMonths(Number(lowerYear))
const yearsString = yearRange.join('&years=')
if (yearRange.length > 1) {
history.push(
`/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${lowerYear}&years=${yearsString}`
)
} else {
history.push(
`/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${lowerYear}&years[]=${yearsString}`
)
}
}
const headerCellPopUp = (title, cell) => {
return (
<Popup trigger={cell} wide="very" position="left center">
<Popup.Content>{infotooltips.PopulationOverview[title]}</Popup.Content>
</Popup>
)
}
const renderStudentsHeader = () => {
let colSpan = 4
let rowSpan = 1
if (renderGenders) colSpan += genders.length
if (renderRatioOfFinns) colSpan += 1
if (!renderGenders && !renderRatioOfFinns) rowSpan += 1
return headerCellPopUp(
'Students',
<Table.HeaderCell colSpan={colSpan} rowSpan={rowSpan}>
Students
</Table.HeaderCell>
)
}
const ratioOfFinnsIn = year => {
const total = Object.values(year.nationalities).reduce((res, curr) => res + curr, 0)
return (
<Table.Cell>
{`${year.nationalities.Finland || 0} (${Math.floor((year.nationalities.Finland / total) * 100) || 0}%)`}
</Table.Cell>
)
}
return (
<>
<div style={{ marginTop: '2em' }}>
<InfoBox content={infotooltips.PopulationOverview.PopulationProgress} />
</div>
<Header>
{studytrack ? `Population progress for selected studytrack` : 'Population progress'}
{throughput && (
<Header.Subheader data-cy="throughputUpdateStatus">
{`Last updated ${
throughput.lastUpdated ? moment(throughput.lastUpdated).format('HH:mm:ss MM-DD-YYYY') : 'unknown'
}`}
{throughput.status === 'RECALCULATING' && (
<Label content="Recalculating! Refresh page in a few minutes" color="red" />
)}
</Header.Subheader>
)}
</Header>
<Segment basic loading={loading} style={{ overflowX: 'auto' }}>
<Table celled structured compact striped selectable className="fixed-header">
<Table.Header>
<Table.Row>
<Table.HeaderCell rowSpan="2">{isStudytrackView ? 'Studytrack' : 'Year'}</Table.HeaderCell>
{renderStudentsHeader()}
{headerCellPopUp(
'Graduated',
<Table.HeaderCell colSpan={GRADUATED_FEATURE_TOGGLED_ON ? '3' : '1'}>Graduated</Table.HeaderCell>
)}
{headerCellPopUp('Credits', <Table.HeaderCell colSpan="5">Credits</Table.HeaderCell>)}
{(thesisTypes.includes('BACHELOR') || thesisTypes.includes('MASTER')) &&
headerCellPopUp('Thesis', <Table.HeaderCell colSpan={thesisTypes.length}>Thesis</Table.HeaderCell>)}
</Table.Row>
<Table.Row>
{renderGenders || renderRatioOfFinns ? <Table.HeaderCell content="All" /> : null}
{genders.map(gender => (
<Table.HeaderCell key={gender} content={gender} />
))}
{renderRatioOfFinns ? <Table.HeaderCell content="Finnish" /> : null}
{headerCellPopUp(
'Started',
<Table.HeaderCell rowSpan="2" colSpan="1">
Started
</Table.HeaderCell>
)}
{headerCellPopUp(
'Transferred',
<Table.HeaderCell rowSpan="2" colSpan="1">
Transferred to
</Table.HeaderCell>
)}
{headerCellPopUp(
'Cancelled',
<Table.HeaderCell rowSpan="2" colSpan="1">
Cancelled
</Table.HeaderCell>
)}
<Table.HeaderCell>Graduated overall</Table.HeaderCell>
{GRADUATED_FEATURE_TOGGLED_ON && (
<>
<Table.HeaderCell>Graduated in time</Table.HeaderCell>
<Table.HeaderCell>Graduation median time</Table.HeaderCell>
</>
)}
<Table.HeaderCell content="≥ 30" />
<Table.HeaderCell content="≥ 60" />
<Table.HeaderCell content="≥ 90" />
<Table.HeaderCell content="≥ 120" />
<Table.HeaderCell content="≥ 150" />
{thesisTypes.includes('MASTER') && <Table.HeaderCell content="Master" />}
{thesisTypes.includes('BACHELOR') && <Table.HeaderCell content="Bachelor" />}
</Table.Row>
</Table.Header>
<Table.Body>
{data
.sort((year1, year2) => Number(year2.year.slice(0, 4)) - Number(year1.year.slice(0, 4)))
.map(year => (
<Table.Row key={year.year}>
<Table.Cell>
{year.year}
{!isStudytrackView && (
<PopulationStatisticsLink
studytrack={studytrack}
studyprogramme={studyprogramme}
year={year.year}
>
<Icon name="level up alternate" />
</PopulationStatisticsLink>
)}
</Table.Cell>
<Table.Cell>{year.credits.length}</Table.Cell>
{genders.map(gender => (
<Table.Cell key={`${year.year} gender:${gender}`}>
{`${year.genders[gender] || 0} (${
Math.floor((year.genders[gender] / year.credits.length) * 100) || 0
}%)`}
</Table.Cell>
))}
{renderRatioOfFinns && ratioOfFinnsIn(year)}
<Table.Cell>{year.started}</Table.Cell>
<Table.Cell>{year.transferred}</Table.Cell>
<Table.Cell>{year.cancelled}</Table.Cell>
<Table.Cell>{year.graduated}</Table.Cell>
{GRADUATED_FEATURE_TOGGLED_ON && (
<>
<Table.Cell>{year.inTargetTime}</Table.Cell>
<Table.Cell>{year.medianGraduationTime ? `${year.medianGraduationTime} months` : '∞'}</Table.Cell>
</>
)}
{Object.keys(year.creditValues).map(creditKey => (
<Table.Cell key={`${year.year} credit:${creditKey}`}>{year.creditValues[creditKey]}</Table.Cell>
))}
{thesisTypes.includes('MASTER') ? <Table.Cell>{year.thesisM}</Table.Cell> : null}
{thesisTypes.includes('BACHELOR') ? <Table.Cell>{year.thesisB}</Table.Cell> : null}
</Table.Row>
))}
</Table.Body>
{throughput && throughput.totals ? (
<Table.Footer>
<Table.Row>
<Table.HeaderCell style={{ fontWeight: 'bold' }}>
All{' '}
{newProgramme && !isStudytrackView && years.length > 0 && years.length < 5 ? (
<TotalPopulationLink confirm studyprogramme={studyprogramme} studytrack={studytrack} years={years}>
<Icon name="level up alternate" />
</TotalPopulationLink>
) : null}
</Table.HeaderCell>
<Table.HeaderCell>{throughput.totals.students}</Table.HeaderCell>
{Object.keys(throughput.totals.genders)
.sort()
.map(genderKey => (
<Table.HeaderCell key={`${genderKey}total`}>
{`${throughput.totals.genders[genderKey]} (${Math.floor(
(throughput.totals.genders[genderKey] / throughput.totals.students) * 100
)}%)`}
</Table.HeaderCell>
))}
{renderRatioOfFinns ? (
<Table.HeaderCell>
{`${throughput.totals.nationalities.Finland || 0} (${
Math.floor((throughput.totals.nationalities.Finland / calculateTotalNationalities()) * 100) || 0
}%)`}
</Table.HeaderCell>
) : null}
<Table.HeaderCell>{throughput.totals.started}</Table.HeaderCell>
<Table.HeaderCell>{throughput.totals.transferred}</Table.HeaderCell>
<Table.HeaderCell>{throughput.totals.cancelled}</Table.HeaderCell>
<Table.HeaderCell>{throughput.totals.graduated}</Table.HeaderCell>
{GRADUATED_FEATURE_TOGGLED_ON && (
<>
<Table.HeaderCell>{throughput.totals.inTargetTime}</Table.HeaderCell>
<Table.HeaderCell>
{throughput.totals.medianGraduationTime
? `${throughput.totals.medianGraduationTime} months`
: '∞'}
</Table.HeaderCell>
</>
)}
{Object.keys(throughput.totals.credits).map(creditKey => (
<Table.HeaderCell key={`${creditKey}total`}>{throughput.totals.credits[creditKey]}</Table.HeaderCell>
))}
{thesisTypes.includes('MASTER') ? (
<Table.HeaderCell>{throughput.totals.thesisM}</Table.HeaderCell>
) : null}
{thesisTypes.includes('BACHELOR') ? (
<Table.HeaderCell>{throughput.totals.thesisB}</Table.HeaderCell>
) : null}
</Table.Row>
</Table.Footer>
) : null}
</Table>
</Segment>
{years.length > 4 ? (
<>
Statistics from:
<Dropdown selection options={years} onChange={handleLowerBoundChange} value={lowerYear} />
to:
<Dropdown selection options={years} onChange={handleUpperBoundChange} value={upperYear} />
<Modal
trigger={<Button disabled={!lowerYear || !upperYear}>Show combined population</Button>}
header={`Are you sure you want to see a combined population of ${yearDifference} different populations?`}
actions={[
'Cancel',
{
key: 'fetch',
content: 'Show population',
positive: true,
onClick: event => pushQueryToUrl(event),
},
]}
/>
</>
) : null}
</>
)
}
export default ThroughputTable
@@ -0,0 +1,20 @@
import React from 'react'
import { Radio } from 'semantic-ui-react'
import WithHelpTooltip from '../Info/InfoWithHelpTooltip'
const Toggle = ({ cypress, toolTips, firstLabel, secondLabel, value, setValue }) => (
<div className="radio-toggle">
<label className={`toggle-label${value ? '-checked' : ''}`}>{firstLabel}</label>
<Radio data-cy={cypress} toggle checked={value} onChange={() => setValue(!value)} />
{toolTips ? (
<WithHelpTooltip tooltip={{ short: toolTips }}>
<label className={`toggle-label${value ? '' : '-checked'}`}>{secondLabel}</label>
</WithHelpTooltip>
) : (
<label className={`toggle-label${value ? '' : '-checked'}`}>{secondLabel}</label>
)}
</div>
)
export default Toggle
@@ -1,35 +1,39 @@
import React, { useCallback, useEffect } from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import React, { useCallback, useEffect, useState } from 'react'
import { useHistory, withRouter } from 'react-router-dom'
import { connect, useDispatch, useSelector } from 'react-redux'
import { Header, Segment, Tab } from 'semantic-ui-react'
import { isEqual } from 'lodash'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import DegreeCoursesTable from './DegreeCourses'
import StudyProgrammeSelector from './StudyProgrammeSelector'
import NewOverview from './NewOverview'
import NewStudytrackOverview from './NewStudytrackOverview'
import NewUpdateView from './NewUpdateView'
import BasicOverview from './BasicOverview'
import StudytrackOverview from './StudytrackOverview'
import UpdateView from './UpdateView'
import '../PopulationQueryCard/populationQueryCard.css'
import { getTextIn } from '../../common'
import { useTabs, useTitle } from '../../common/hooks'
import TSA from '../../common/tsa'
import Tags from './Tags'
import { getThroughput } from '../../redux/throughput'
import { getProductivity } from '../../redux/productivity'
import { getProgrammes } from '../../redux/populationProgrammes'
import useLanguage from '../LanguagePicker/useLanguage'
const StudyProgramme = props => {
const dispatch = useDispatch()
const history = useHistory()
const programmes = useSelector(state => state.populationProgrammes?.data?.programmes)
const { language } = useLanguage()
const { isAdmin } = useGetAuthorizedUserQuery()
const [tab, setTab] = useTabs('p_tab', 0, props.history)
const [tab, setTab] = useTabs('p_tab', 0, history)
const [academicYear, setAcademicYear] = useState(false)
const [specialGroups, setSpecialGroups] = useState(false)
const [graduated, setGraduated] = useState(false)
const [combined, setCombined] = useState(false)
useTitle('Study programmes')
useEffect(() => {
props.getProgrammesDispatch()
dispatch(getProgrammes())
}, [])
const getPanes = () => {
@@ -38,16 +42,36 @@ const StudyProgramme = props => {
const panes = []
panes.push({
menuItem: 'Basic information (NEW)',
render: () => <NewOverview studyprogramme={studyProgrammeId} history={props.history} />,
render: () => (
<BasicOverview
studyprogramme={studyProgrammeId}
history={history}
specialGroups={specialGroups}
setSpecialGroups={setSpecialGroups}
academicYear={academicYear}
setAcademicYear={setAcademicYear}
/>
),
})
panes.push({
menuItem: 'Studytracks and student populations (NEW)',
render: () => <NewStudytrackOverview studyprogramme={studyProgrammeId} history={props.history} />,
render: () => (
<StudytrackOverview
studyprogramme={studyProgrammeId}
history={history}
specialGroups={specialGroups}
setSpecialGroups={setSpecialGroups}
graduated={graduated}
setGraduated={setGraduated}
combined={combined}
setCombined={setCombined}
/>
),
})
if (isAdmin) {
panes.push({
menuItem: 'Update statistics',
render: () => <NewUpdateView studyprogramme={studyProgrammeId} />,
render: () => <UpdateView studyprogramme={studyProgrammeId} />,
})
}
panes.push({
@@ -63,12 +87,12 @@ const StudyProgramme = props => {
const handleSelect = useCallback(
programme => {
props.history.push(`/study-programme/${programme}`, { selected: programme })
history.push(`/study-programme/${programme}`, { selected: programme })
},
[props.history]
[history]
)
const { match, programmes } = props
const { match } = props
const { studyProgrammeId } = match.params
const programmeName = programmes?.[studyProgrammeId] && getTextIn(programmes?.[studyProgrammeId].name, language)
const panes = getPanes()
@@ -112,22 +136,4 @@ const StudyProgramme = props => {
)
}
const mapStateToProps = ({ populationProgrammes }) => {
const programmes = populationProgrammes.data ? populationProgrammes.data.programmes : {}
return {
programmes,
}
}
export default connect(
mapStateToProps,
{
getThroughputDispatch: getThroughput,
getProductivityDispatch: getProductivity,
getProgrammesDispatch: getProgrammes,
},
null,
{
areStatePropsEqual: isEqual,
}
)(withRouter(StudyProgramme))
export default connect()(withRouter(StudyProgramme))
@@ -16,6 +16,7 @@
.studyprogramme-overview .toggle-container {
display: flex;
justify-content: center;
flex-wrap: wrap;
margin-top: 1em;
}
@@ -39,6 +40,22 @@
font-weight: bold;
}
.studytrack-overview .toggle-label-checked,
.studyprogramme-overview .toggle-label-checked {
cursor: pointer;
padding-left: 0.5em;
padding-right: 0.5em;
font-weight: bold;
opacity: 0.5;
}
.studytrack-overview .ui.toggle.checkbox input:checked ~ label::before,
.studyprogramme-overview .ui.toggle.checkbox input:checked ~ label::before,
.studytrack-overview .ui.toggle.checkbox input:focus:checked ~ label::before,
.studyprogramme-overview .ui.toggle.checkbox input:focus:checked ~ label::before {
background-color: rgba(0, 0, 0, 0.05) !important;
}
.studyprogramme-overview .divider {
margin-top: 4em;
}
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Segment, Message, Button, Popup } from 'semantic-ui-react'
import { Segment, Message } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import { func, arrayOf, bool, shape, any, string } from 'prop-types'
@@ -8,7 +8,6 @@ import { getTopTeachersCategories } from '../../../redux/teachersTopCategories'
import { getTopTeachers } from '../../../redux/teachersTop'
import TeacherStatisticsTable from '../TeacherStatisticsTable'
import LeaderForm from './LeaderForm'
import { callApi } from '../../../apiConnection'
const TeacherLeaderBoard = ({
getTopTeachersCategories,
@@ -22,31 +21,12 @@ const TeacherLeaderBoard = ({
}) => {
const [selectedyear, setSelectedyear] = useState(null)
const [selectedcategory, setSelectedcategory] = useState(null)
const [recalculating, setRecalculating] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
getTopTeachersCategories()
}, [])
const handleOpen = () => {
setIsOpen(true)
setRecalculating(true)
setTimeout(() => {
setIsOpen(false)
}, 5000)
}
const handleClose = () => {
setIsOpen(false)
}
const refresh = () => {
callApi('/teachers/top', 'post', { startyearcode: selectedyear, endyearcode: selectedyear + 1 }, null)
}
const updateAndSubmitForm = args => {
setRecalculating(false)
const year = args.selectedyear || selectedyear
const category = args.selectedcategory || selectedcategory
getTopTeachers(year, category)
@@ -96,24 +76,13 @@ const TeacherLeaderBoard = ({
/>
<Segment>
<Message>{`Last updated: ${updated}`}</Message>
<Popup
trigger={
<Button
disabled={recalculating}
content="Recalculate this year"
onClick={() => {
refresh()
}}
/>
}
content="Recalculation started. Recalculation might take multiple minutes. Refresh page to see the results"
on="click"
open={isOpen}
onClose={handleClose}
onOpen={handleOpen}
/>
<TeacherStatisticsTable statistics={statistics} onClickFn={id => history.push(`/teachers/${id}`)} />
<TeacherStatisticsTable
statistics={statistics}
onClickFn={id => history.push(`/teachers/${id}`)}
unifyOpenUniCourses={false}
renderLink={false}
/>
</Segment>
</div>
)}
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useMemo } from 'react'
import { Search, Segment, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, string } from 'prop-types'
import { withRouter } from 'react-router-dom'
@@ -51,15 +51,34 @@ const TeacherSearch = ({ icon, teachers, onClick, setTimeout, clearTimeout, find
}
}
const columns = [
{ key: 'name', title: 'Name', getRowVal: s => s.name, headerProps: { onClick: null, sorted: null, colSpan: 2 } },
{
key: 'icon',
getRowContent: () => <Icon name={icon} />,
cellProps: { collapsing: true },
headerProps: { onClick: null, sorted: null },
},
]
const columns = useMemo(
() => [
{
key: 'name-parent',
mergeHeader: true,
merge: true,
children: [
{
key: 'name',
title: 'Name',
getRowVal: s => s.name,
cellProps: row => ({
onClick: () => onClick(row),
}),
},
{
key: 'icon',
getRowContent: () => <Icon name={icon} />,
export: false,
cellProps: row => ({
onClick: () => onClick(row),
}),
},
],
},
],
[onClick]
)
return (
<div>

Some files were not shown because too many files have changed in this diff Show More