Compare commits

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 12:57:32 +00:00
Otto AhoniemiandGitHub 6d5b91782c Merge pull request #3287 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/backend/validator-13.7.0
Bump validator from 13.6.0 to 13.7.0 in /services/backend
2021-11-11 14:56:48 +02:00
Otto AhoniemiandGitHub b8762aae34 Merge pull request #3288 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/userservice/validator-13.7.0
Bump validator from 13.6.0 to 13.7.0 in /services/userservice
2021-11-11 14:56:41 +02:00
Otto Ahoniemi 4da78df1e6 Refactor studyguidancegroup logic 2021-11-11 14:12:39 +02:00
Otto Ahoniemi 93258ad12a Fix toggling between panel statuses throwing user away from current panel 2021-11-11 13:29:19 +02:00
Otto Ahoniemi 0028b69661 Revert tunkki solution for getting study guidance group population instead of generic population from redux 2021-11-11 12:52:27 +02:00
Otto Ahoniemi ef902b1c0c Fix population stats failing 2021-11-11 08:10:25 +02:00
Otto Ahoniemi d8e3ee092f Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-11 07:48:49 +02:00
Otto Ahoniemi 8763b1868e Prevent manual typing to datetime field 2021-11-11 02:40:51 +02:00
Otto Ahoniemi 700d799b00 Add more filters to study guidance groups 2021-11-11 02:31:37 +02:00
Otto Ahoniemi b0ecc3d4f5 Fix showing students in study guidance groups, add better indicators for links 2021-11-11 02:27:43 +02:00
Otto Ahoniemi e681d5a94f Style betterments 2021-11-11 01:42:01 +02:00
Otto Ahoniemi 980cd8ecf0 Don't load mandatory programmes if already in state 2021-11-11 01:17:24 +02:00
Otto Ahoniemi 357c0b3f44 Make it possible to toggle showing programme structure / flat list in courses of population 2021-11-11 01:12:43 +02:00
Otto Ahoniemi 6467f5046e Make it possible to toggle showing credits from associated year 2021-11-10 23:09:57 +02:00
Otto Ahoniemi 21ff440ebd Cleanup unused props and proptypes 2021-11-10 23:07:23 +02:00
Otto Ahoniemi 73228fb26f Allow absolute imports 2021-11-10 21:46:43 +02:00
Otto Ahoniemi 2c1807fe10 Migrate studyguidancegroup redux logic to rtk query, fix some issues in overview 2021-11-10 21:02:30 +02:00
Otto Ahoniemi 50091a3098 Add RTK query baseapi and migrate studyguidancegroups to use it 2021-11-10 20:59:12 +02:00
Otto Ahoniemi 3ba7965402 Add original token to state and export dev headers in order to use them in rtk query 2021-11-10 20:32:04 +02:00
Otto Ahoniemi 067a53a072 Return copy instead of modifying course array 2021-11-10 16:41:39 +02:00
Otto Ahoniemi 9a3956832d Random fixes' 2021-11-10 00:40:28 +02:00
Otto Ahoniemi 831b638c99 Migrate populationcoursestatsflat from hoc to hooks, fix state updates on unmounted 2021-11-09 13:32:51 +02:00
Otto Ahoniemi 38c343b8d2 Add study guidance group filterset 2021-11-08 13:33:24 +02:00
Otto Ahoniemi c05346ad9a proptype cleaning 2021-11-08 13:25:33 +02:00
Petri Palmu dd12cd3626 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-08 10:58:22 +02:00
Petri Palmu 55eebe32a6 groomed course search method 2021-11-08 10:58:05 +02:00
dependabot[bot]andGitHub 8099dff1b6 Bump validator from 13.6.0 to 13.7.0 in /services/userservice
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.6.0 to 13.7.0.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.6.0...13.7.0)

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-03 19:42:56 +00:00
41 changed files with 760 additions and 914 deletions
+7 -3
View File
@@ -64,6 +64,7 @@ describe('Population Statistics tests', () => {
it('Population statistics is usable on general level', () => {
cy.visit(pathToCSBach2017)
cy.cs('filtered-students')
setPopStatsUntil('toukokuu 2020')
cy.get('.card').within(() => {
@@ -74,7 +75,8 @@ describe('Population Statistics tests', () => {
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
})
cy.contains('Courses of population').click({ force: true })
cy.cs('filtered-students')
cy.contains('Courses of population').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
// eslint-disable-next-line cypress/no-unnecessary-waiting
@@ -165,7 +167,9 @@ describe('Population Statistics tests', () => {
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
cy.visit(pathToCSMaster2019)
cy.contains('Credit statistics').click()
cy.cs('filtered-students')
cy.contains('Credit statistics')
cy.get("[data-cy='credit-statistics']").click()
cy.get('.credits-gained-divider').click()
cy.get("[data-cy='credits-gained-table-Avoin väylä']").should('exist')
cy.get("[data-cy='credits-gained-table-Yhteispisteet']").should('exist')
@@ -175,7 +179,7 @@ describe('Population Statistics tests', () => {
it('Credit Statistics, Statistics pane works', () => {
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Credit statistics').click()
cy.contains('Credit statistics').click({ force: true })
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 149 Students')
+3 -3
View File
@@ -3353,9 +3353,9 @@
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
},
"validator": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz",
"integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg=="
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
},
"vary": {
"version": "1.1.2",
+3 -33
View File
@@ -368,39 +368,9 @@ const byNameAndOrCodeLike = async (name, code) => {
})
const courses = rawCourses
.map(
({
id,
code,
name,
latest_instance_date,
is_study_module,
coursetypecode,
startdate,
max_attainment_date,
min_attainment_date,
createdAt,
updatedAt,
substitutions,
organizations,
}) => {
return {
id,
code,
name,
latest_instance_date,
is_study_module,
coursetypecode,
startdate,
max_attainment_date,
min_attainment_date,
createdAt,
updatedAt,
substitutions,
organizations: organizations.map(o => o.id),
}
}
)
.map(course => {
return { ...course.dataValues, organizations: course.organizations.map(o => o.id) }
})
.sort(a => (a.code.match(/^[A-Za-z]{3}[0-9]{1}/) ? -1 : 1))
let substitutionGroupIndex = 0
+3 -2
View File
@@ -1,7 +1,8 @@
{
"root": true,
"env": {
"browser": true
"browser": true,
"node": true
},
"parser": "@babel/eslint-parser",
"parserOptions": {
@@ -30,7 +31,7 @@
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/prefer-stateless-function": "error",
"import/no-unresolved": "error",
"import/no-unresolved": "off",
"react/jsx-no-bind": "error",
"prefer-object-spread/prefer-object-spread": "error",
"babel/no-invalid-this": "error",
+6
View File
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": "src"
},
"include": ["src"]
}
+10 -10
View File
@@ -3160,11 +3160,11 @@
"integrity": "sha512-99FZt8qS/xukgxU/8daV8WT7wAakqBzt6lF3XCweO6pwcf50/NgxxBj6ZC7/ejR+F4PWeFpkb9lAMH3y2quBXA=="
},
"axios": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
"integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
"requires": {
"follow-redirects": "^1.10.0"
"follow-redirects": "^1.14.0"
}
},
"axobject-query": {
@@ -14919,9 +14919,9 @@
"integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="
},
"tar": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
"integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
"version": "6.1.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
"integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
"requires": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -15143,9 +15143,9 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"tmpl": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE="
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
},
"to-arraybuffer": {
"version": "1.0.1",
+1 -1
View File
@@ -9,7 +9,7 @@
"@reduxjs/toolkit": "^1.6.2",
"@sentry/browser": "^6.9.0",
"@sentry/tracing": "^6.11.0",
"axios": "^0.21.1",
"axios": "^0.21.2",
"formik": "^2.2.9",
"highcharts": "^9.1.2",
"highcharts-custom-events": "^3.0.10",
+22 -1
View File
@@ -1,10 +1,11 @@
import axios from 'axios'
import * as Sentry from '@sentry/browser'
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { getMocked, setMocking } from '../common'
import { apiBasePath, isDev } from '../conf'
import { loginPrefix, logoutPrefix } from '../redux/auth'
const getHeaders = () => {
export const getHeaders = () => {
// Set up dev user for development environment, mimicking production admin user
const devUserHeaders = {
uid: 'mluukkai',
@@ -140,3 +141,23 @@ export const handleAuth = store => next => async action => {
}
}
}
// Redux-toolkit query based API
// Api can be extended with enhanceEndpoints method
// All tags used for invalidating cache must be defined here
export const RTKApi = createApi({
reducerPath: 'api',
tagTypes: ['StudyGuidanceGroups'],
baseQuery: fetchBaseQuery({
baseUrl: apiBasePath,
prepareHeaders: (headers, { getState }) => {
// Get token from state and add it to headers
const token = getState().auth.encodedToken
if (token) headers.set('x-access-token', token)
// Add possible default headers
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
return headers
},
}),
endpoints: () => ({}),
})
@@ -99,7 +99,8 @@ const createGraphOptions = ({
}
}
const sortCoursesByDate = courses => courses.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
const sortCoursesByDate = courses =>
[...courses].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
const filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
@@ -172,8 +173,8 @@ const createGoalSeries = (starting, ending, absences) => {
}
}
const createStudentCreditLines = (students, singleStudent) => {
return students.map(student => {
const createStudentCreditLines = (students, singleStudent) =>
students.map(student => {
const { started, studyrightStart } = student
const startDate = students.length === 1 ? started : studyrightStart
@@ -215,7 +216,6 @@ const createStudentCreditLines = (students, singleStudent) => {
},
}
})
}
const CreditAccumulationGraphHighCharts = ({ students, singleStudent, absences, startDate, endDate }) => {
const history = useHistory()
@@ -1,11 +1,11 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { useSelector } from 'react-redux'
import PopulationCourseStatsFlat from '../../PopulationCourseStats/PopulationCourseStatsFlat'
import useCourseFilter from '../../FilterTray/filters/Courses/useCourseFilter'
const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, error, showFilter }) => {
const CustomPopulationCourses = ({ selectedStudents, showFilter = false }) => {
const { setCourses, resetCourses } = useCourseFilter()
const { data: courses, pending, error } = useSelector(({ populationCourses }) => populationCourses)
useEffect(() => {
if (!pending && !error && courses.coursestatistics) {
@@ -21,33 +21,10 @@ const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, er
return (
<PopulationCourseStatsFlat
courses={courses}
query={query}
pending={pending}
selectedStudents={selectedStudents}
customPopulation
showFilter={showFilter}
/>
)
}
CustomPopulationCourses.propTypes = {
pending: bool.isRequired,
courses: shape([]).isRequired,
selectedStudents: arrayOf(string).isRequired,
query: shape({}).isRequired,
error: bool.isRequired,
showFilter: bool,
}
CustomPopulationCourses.defaultProps = {
showFilter: false,
}
const mapStateToProps = ({ populationCourses }) => ({
courses: populationCourses.data,
pending: populationCourses.pending,
query: populationCourses.query,
error: populationCourses.error,
})
export default connect(mapStateToProps)(CustomPopulationCourses)
export default CustomPopulationCourses
@@ -16,7 +16,6 @@ import InfoBox from '../InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import useLanguage from '../LanguagePicker/useLanguage'
import CustomPopulationSearch from './CustomPopulationSearch'
import UnihowDataExport from './UnihowDataExport'
@@ -35,7 +34,6 @@ const CustomPopulation = ({
const [, setSelectedSearchId] = useState('')
const [newestIndex, setNewest] = useState(null)
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const { progress } = useProgress(loading)
@@ -98,12 +96,7 @@ const CustomPopulation = ({
content: {
content: (
<div ref={creditGainRef}>
<CreditAccumulationGraphHighCharts
students={filteredStudents}
render={false}
trayOpen={trayOpen}
language={language}
/>
<CreditAccumulationGraphHighCharts students={filteredStudents} />
</div>
),
},
@@ -1,17 +1,24 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Popup, Button, Icon } from 'semantic-ui-react'
import { contextKey as filterTrayContextKey } from './index'
import useFilterTray from './useFilterTray'
import useAnalytics from './useAnalytics'
/**
* @param {func} applyFilter Function to apply the filter when toggled on
* @param {func} clearFilter Function to clear the filter when toggled off.
* @param {string} filterName Name to display in analytics.
* @param {active} bool Toggle state. Must be controlled in the parent.
* @param {funcs} popupContent Content component to show in the tooltip, if given. Tooltip is not shown if omitted.
* @param {string} filterPanelContextKey Used to open the filter's panel, if given. If not given, the filter tray will not be opened either.
*/
const ExternalFilterToggle = ({
filterPanelContextKey,
applyFilter,
clearFilter,
filterName,
active,
popupContent,
filterPanelContextKey,
}) => {
const [, setFilterTrayOpen] = useFilterTray(filterTrayContextKey)
const [, setCreditFilterOpen] = useFilterTray(filterPanelContextKey)
@@ -55,24 +62,4 @@ const ExternalFilterToggle = ({
)
}
ExternalFilterToggle.propTypes = {
/** Used to open the filter's panel, if given. If not given, the filter tray will not be opened either. */
filterPanelContextKey: PropTypes.string,
/** Function to apply the filter when toggled on. */
applyFilter: PropTypes.func.isRequired,
/** Function to clear the filter when toggled off. */
clearFilter: PropTypes.func.isRequired,
/** Name to display in analytics. */
filterName: PropTypes.string.isRequired,
/** Toggle state. Must be controlled in the parent. */
active: PropTypes.bool.isRequired,
/** Content to show in the tooltip, if given. Tooltip is not shown if omitted. */
popupContent: PropTypes.node,
}
ExternalFilterToggle.defaultProps = {
filterPanelContextKey: undefined,
popupContent: undefined,
}
export default ExternalFilterToggle
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { CourseFilterProvider } from './filters/Courses/useCourseFilter'
import { CreditFilterProvider } from './filters/CreditsEarned/useCreditFilter'
import { AgeFilterProvider } from './filters/Age/useAgeFilter'
@@ -23,8 +22,4 @@ const filterProviders = [
const FilterContextProvider = ({ children }) =>
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
FilterContextProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default FilterContextProvider
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react'
import useAnalytics from './useAnalytics'
import GraduatedFromProgramme from './filters/GraduatedFromProgramme'
import TransferredToProgramme from './filters/TransferredToProgramme'
import EnrollmentStatus from './filters/EnrollmentStatus'
@@ -7,70 +8,67 @@ import Gender from './filters/Gender'
import StartYearAtUni from './filters/StartYearAtUni'
import Courses from './filters/Courses'
import Programmes from './filters/Programmes'
import useAnalytics from './useAnalytics'
import Grade from './filters/Grade'
import Date from './filters/Date'
import Age from './filters/Age'
import AdmissionType from './filters/AdmissionType'
import Tags from './filters/Tags'
export const PopulationStatisticsFilters = ({ query }) => {
const Filters = ({ name, children }) => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Population Statistics')
analytics.setTarget(name)
})
return (
<>
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
<Date />
</>
)
return React.Children.toArray(children)
}
export const CoursePopulationFilters = () => {
const analytics = useAnalytics()
export const PopulationStatisticsFilters = ({ query }) => (
<Filters name="Population Statistics">
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
<Date />
</Filters>
)
useEffect(() => {
analytics.setTarget('Course Population')
})
export const CoursePopulationFilters = () => (
<Filters name="Course Population">
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</Filters>
)
return (
<>
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</>
)
}
export const CustomPopulationFilters = () => (
<Filters name="Custom Population">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</Filters>
)
export const CustomPopulationFilters = () => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Custom Population')
})
return (
<>
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</>
)
}
export const StudyGuidanceGroupFilters = () => (
<Filters name="Study Guidance Group">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<AdmissionType />
<GraduatedFromProgramme />
<Tags />
<Courses />
</Filters>
)
@@ -1,5 +1,4 @@
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { getPopulationSelectedStudentCourses } from '../../../../redux/populationSelectedStudentCourses'
@@ -23,11 +22,6 @@ const CourseFilterProvider = ({ children, getPopulationSelectedStudentCourses })
return <CourseFilterContext.Provider value={[state, setState]}>{children}</CourseFilterContext.Provider>
}
CourseFilterProvider.propTypes = {
children: PropTypes.node.isRequired,
getPopulationSelectedStudentCourses: PropTypes.func.isRequired,
}
const ConnectedProvider = connect(null, { getPopulationSelectedStudentCourses })(CourseFilterProvider)
export { ConnectedProvider as CourseFilterProvider }
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Button, Icon } from 'semantic-ui-react'
const ClearFilterButton = ({ disabled, onClick, name }) => (
@@ -9,10 +8,4 @@ const ClearFilterButton = ({ disabled, onClick, name }) => (
</Button>
)
ClearFilterButton.propTypes = {
disabled: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
}
export default ClearFilterButton
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Icon, Header } from 'semantic-ui-react'
import useFilterTray from '../../useFilterTray'
@@ -23,12 +22,4 @@ const FilterCard = ({ title, children, contextKey, name, active }) => {
)
}
FilterCard.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
contextKey: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
active: PropTypes.bool.isRequired,
}
export default FilterCard
@@ -1,8 +1,16 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Input, Label, Popup, Button, Icon } from 'semantic-ui-react'
const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonDisabled, className, name }) => (
const NumericInput = ({
onChange,
onKeyDown = () => {},
onClear,
value,
label,
clearButtonDisabled,
className,
name,
}) => (
<Input
labelPosition="left"
size="mini"
@@ -30,20 +38,4 @@ const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonD
</Input>
)
NumericInput.propTypes = {
onChange: PropTypes.func.isRequired,
onKeyDown: PropTypes.func,
onClear: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
clearButtonDisabled: PropTypes.bool.isRequired,
className: PropTypes.string,
name: PropTypes.string.isRequired,
}
NumericInput.defaultProps = {
onKeyDown: () => {},
className: null,
}
export default NumericInput
@@ -1,12 +1,11 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Segment, Header } from 'semantic-ui-react'
import './filterTray.css'
import useFilters from './useFilters'
export const contextKey = 'filterTray'
const FilterTray = ({ children, filterSet, visible }) => {
const FilterTray = ({ children, filterSet, visible = true }) => {
const { filteredStudents, allStudents } = useFilters()
if (!visible || !allStudents.length) {
@@ -17,7 +16,7 @@ const FilterTray = ({ children, filterSet, visible }) => {
<>
<div style={{ display: 'inline-flex', alignItems: 'flex-start', width: '100%' }}>
<Segment style={{ marginLeft: '0.5rem', width: '12rem', position: 'sticky', top: '10px' }}>
<Header size="small" style={{ textAlign: 'center' }}>
<Header size="small" style={{ textAlign: 'center' }} data-cy="filtered-students">
Filter students
</Header>
<div style={{ textAlign: 'center' }}>
@@ -34,14 +33,4 @@ const FilterTray = ({ children, filterSet, visible }) => {
)
}
FilterTray.propTypes = {
children: PropTypes.node.isRequired,
filterSet: PropTypes.node.isRequired,
visible: PropTypes.bool,
}
FilterTray.defaultProps = {
visible: true,
}
export default FilterTray
@@ -1,5 +1,4 @@
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
import TSA from '../../common/tsa'
const FilterAnalyticsContext = createContext([[], () => {}])
@@ -10,10 +9,6 @@ export const FilterAnalyticsProvider = ({ children }) => {
return <FilterAnalyticsContext.Provider value={[state, setState]}>{children}</FilterAnalyticsContext.Provider>
}
FilterAnalyticsProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterAnalyticsContext)
const category = `Filters (${state})`
@@ -2,7 +2,6 @@
* Context for those expandable filter panels that need to be opened/closed remotely.
*/
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
const FilterTrayContext = createContext([[], () => {}])
FilterTrayContext.displayName = 'Filter Tray'
@@ -13,10 +12,6 @@ export const FilterTrayProvider = ({ children }) => {
return <FilterTrayContext.Provider value={[state, setState]}>{children}</FilterTrayContext.Provider>
}
FilterTrayProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default key => {
const [state, setState] = useContext(FilterTrayContext)
@@ -2,7 +2,6 @@
* Context for managing filtering.
*/
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import lodash from 'lodash'
const defaultState = {
@@ -37,10 +36,6 @@ export const FilterProvider = ({ children }) => {
return <FilterContext.Provider value={[state, setState]}>{children}</FilterContext.Provider>
}
FilterProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterContext)
@@ -1,9 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import { Table, Form, Input, Tab, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
import './populationCourseStats.css'
import { PopulationCourseContext } from './PopulationCourseContext'
@@ -43,7 +41,7 @@ const lodashSortOrderTypes = {
DESC: 'desc',
}
function updateCourseStatisticsCriteria(props, language, state) {
const updateCourseStatisticsCriteria = (props, language, state) => {
const { studentAmountLimit, sortCriteria, codeFilter, nameFilter, reversed } = state
const {
courses: { coursestatistics },
@@ -90,8 +88,21 @@ const initialState = props => ({
selectedStudentsLength: props.selectedStudentsLength || 0,
})
function PopulationCourseStats(props) {
const PopulationCourseStats = ({ courses, pending, selectedStudents, showFilter = true }) => {
const dispatch = useDispatch()
const { years } = useSelector(({ semesters }) => semesters.data)
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
const isAdmin = getUserIsAdmin(useSelector(({ auth }) => auth.token.roles))
const { language } = useLanguage()
const props = {
courses,
isAdmin,
pending,
populationCourses,
selectedStudents,
showFilter,
years,
}
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
const [courseStatistics, setCourseStatistics] = useState(
@@ -105,7 +116,7 @@ function PopulationCourseStats(props) {
const filterAnalytics = useAnalytics()
useEffect(() => {
if (state && props.courses) {
if (!pending && state && props.courses) {
const studentAmountLimit =
state.selectedStudentsLength !== props.selectedStudents.length
? Math.round(props.selectedStudents.length * 0.3)
@@ -119,44 +130,46 @@ function PopulationCourseStats(props) {
})
setCourseStatistics(updateCourseStatisticsCriteria(props, language, state))
}
}, [props.courses, props.selectedStudents])
}, [props.courses, props.selectedStudents, pending])
useEffect(() => {
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
const {
courses: { coursestatistics },
language,
} = props
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return studentAmountLimit === 0 || students >= studentAmountLimit
if (!pending) {
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
const {
courses: { coursestatistics },
language,
} = props
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return studentAmountLimit === 0 || students >= studentAmountLimit
}
const courseCodeFilter = ({ course }) => {
const { code } = course
return code.toLowerCase().includes(codeFilter.toLowerCase())
}
const courseNameFilter = ({ course }) => {
const { name } = course
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
}
const filteredCourses =
coursestatistics &&
coursestatistics
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
const sortedStatistics = orderBy(
filteredCourses,
[course => course.stats[sortCriteria], course => course.course.code],
[lodashSortOrder, lodashSortOrderTypes.ASC]
)
setCourseStatistics(sortedStatistics)
}
const courseCodeFilter = ({ course }) => {
const { code } = course
return code.toLowerCase().includes(codeFilter.toLowerCase())
}
const courseNameFilter = ({ course }) => {
const { name } = course
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
}
const filteredCourses =
coursestatistics &&
coursestatistics
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
const sortedStatistics = orderBy(
filteredCourses,
[course => course.stats[sortCriteria], course => course.course.code],
[lodashSortOrder, lodashSortOrderTypes.ASC]
)
setCourseStatistics(sortedStatistics)
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter])
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter, pending])
const onFilterChange = (e, field) => {
const {
@@ -174,8 +187,8 @@ function PopulationCourseStats(props) {
)
useEffect(() => {
setFilters(filterFields)
}, [filterFields])
if (!pending) setFilters(filterFields)
}, [filterFields, pending])
const handleCourseStatisticsCriteriaChange = () => {
// eslint-disable-next-line react/no-access-state-in-setstate
@@ -232,13 +245,14 @@ function PopulationCourseStats(props) {
}
const onGoToCourseStatisticsClick = courseCode => {
const { clearCourseStats: clearCourseStatsfn } = props
sendAnalytics('Courses of Population course stats button clicked', courseCode)
clearCourseStatsfn()
dispatch(clearCourseStats())
}
const onCourseNameCellClick = code => {
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
const courseStatistic =
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
if (courseStatistic) {
const isSelected = courseIsSelected(code)
const name = 'Course Filtername'
@@ -280,7 +294,6 @@ function PopulationCourseStats(props) {
)
}
const { courses, pending } = props
const { sortCriteria, reversed } = state
const contextValue = {
courseStatistics,
@@ -324,8 +337,6 @@ function PopulationCourseStats(props) {
return null
}
const { showFilter = true } = props
return (
<div>
{showFilter && (
@@ -343,37 +354,4 @@ function PopulationCourseStats(props) {
)
}
PopulationCourseStats.propTypes = {
courses: shape({
coursestatistics: arrayOf(object),
disciplines: shape({}),
}).isRequired,
populationCourses: shape({
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
}).isRequired,
clearCourseStats: func.isRequired,
pending: bool.isRequired,
selectedStudents: arrayOf(string).isRequired,
years: shape({}), // eslint-disable-line
showFilter: bool,
language: string.isRequired,
}
PopulationCourseStats.defaultProps = {
showFilter: false,
}
const mapStateToProps = state => {
const { years } = state.semesters.data
const isAdmin = getUserIsAdmin(state.auth.token.roles)
return {
years,
populationCourses: state.populationCourses,
isAdmin,
}
}
export default connect(mapStateToProps, {
clearCourseStats,
})(withRouter(PopulationCourseStats))
export default PopulationCourseStats
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect, useSelector } from 'react-redux'
import { Table, Input, Tab, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
@@ -92,7 +91,7 @@ const initialState = props => ({
selectedStudentsLength: props.selectedStudentsLength || 0,
})
function PopulationCourseStats(props) {
const PopulationCourseStats = props => {
const { language } = useLanguage()
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
@@ -268,7 +267,9 @@ function PopulationCourseStats(props) {
}
const onCourseNameCellClick = code => {
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
const courseStatistic =
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
if (courseStatistic) {
const isSelected = courseIsSelected(code)
const name = 'Course Filtername'
@@ -397,19 +398,6 @@ function PopulationCourseStats(props) {
)
}
PopulationCourseStats.propTypes = {
courses: shape({
coursestatistics: arrayOf(object),
}).isRequired,
populationCourses: shape({
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
}).isRequired,
clearCourseStats: func.isRequired,
pending: bool.isRequired,
selectedStudents: arrayOf(string).isRequired,
language: string.isRequired,
}
const mapStateToProps = ({ populationCourses }) => ({
populationCourses,
})
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import uuidv4 from 'uuid/v4'
import SegmentDimmer from '../SegmentDimmer'
@@ -11,15 +10,13 @@ import FilterDegreeCoursesModal from './FilterDegreeCoursesModal'
import useCourseFilter from '../FilterTray/filters/Courses/useCourseFilter'
import infotooltips from '../../common/InfoToolTips'
const PopulationCourses = ({
populationSelectedStudentCourses,
populationCourses,
selectedStudents,
query,
filteredStudents,
}) => {
const PopulationCourses = ({ selectedStudents, query = {}, filteredStudents }) => {
const { setCoursesOnce, resetCourses, runCourseQuery } = useCourseFilter()
const [showByStudytrack, setShowByStudytrack] = useState(true)
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
const populationSelectedStudentCourses = useSelector(
({ populationSelectedStudentCourses }) => populationSelectedStudentCourses
)
const selectedPopulationCourses = populationSelectedStudentCourses.data
? populationSelectedStudentCourses
@@ -89,7 +86,6 @@ const PopulationCourses = ({
<PopulationCourseStats
key={selectedPopulationCourses.query.uuid}
courses={selectedPopulationCourses.data}
query={selectedPopulationCourses.query}
pending={pending}
selectedStudents={selectedStudents}
/>
@@ -100,21 +96,4 @@ const PopulationCourses = ({
)
}
PopulationCourses.defaultPropTypes = {
query: {},
}
PopulationCourses.propTypes = {
populationSelectedStudentCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
populationCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
selectedStudents: arrayOf(string).isRequired,
query: shape({}).isRequired,
filteredStudents: arrayOf(shape({})).isRequired,
}
const mapStateToProps = ({ populationSelectedStudentCourses, populationCourses }) => ({
populationCourses,
populationSelectedStudentCourses,
})
export default connect(mapStateToProps)(PopulationCourses)
export default PopulationCourses
@@ -100,7 +100,10 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, dataExport }
key: 1,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
<span
style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}
data-cy="credit-statistics"
>
Credit statistics
</span>
),
@@ -37,6 +37,8 @@ const GeneralTab = ({
const [popupStates, setPopupStates] = useState({})
const sendAnalytics = sendEvent.populationStudents
if (!populationStatistics) return null
// TODO: Refactor this away:
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
@@ -1,14 +1,12 @@
import React, { Component, Fragment } from 'react'
import { connect } from 'react-redux'
import { string, arrayOf, object, func, bool, shape, node } from 'prop-types'
import { Icon, Tab, Grid, Ref, Item, Dropdown } from 'semantic-ui-react'
import { withRouter, Link } from 'react-router-dom'
import React, { useState, useEffect, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Icon, Tab, Grid, Item, Dropdown } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { orderBy, uniqBy, flatten, sortBy, isNumber } from 'lodash'
import scrollToComponent from 'react-scroll-to-component'
import { getTextIn, getUserRoles } from '../../common'
import { useTabChangeAnalytics } from '../../common/hooks'
import { getTextIn } from '../../common'
import { useTabChangeAnalytics, usePrevious, useIsAdmin } from '../../common/hooks'
import { toggleStudentListVisibility } from '../../redux/settings'
import { getTagsByStudytrackAction } from '../../redux/tags'
import { getStudentTagsByStudytrackAction } from '../../redux/tagstudent'
@@ -24,79 +22,95 @@ import GeneralTab from './StudentTable/GeneralTab'
import sendEvent, { ANALYTICS_CATEGORIES } from '../../common/sendEvent'
import infotoolTips from '../../common/InfoToolTips'
// TODO: Refactoring in process, contains lot of duplicate code.
const sendAnalytics = sendEvent.populationStudents
const StudentTableTabs = ({ panes, filterPanes }) => {
// only its own component really because I needed to use hooks and didn't want to refactor
// this megamillion line component :) /Joona
const PopulationStudents = ({
language,
filteredStudents,
studentToTargetCourseDateMap,
dataExport,
customPopulation = false,
coursePopulation = false,
coursecode = [],
}) => {
const [state, setState] = useState({})
const studentRef = useRef()
const dispatch = useDispatch()
const { namesVisible: showNames, studentlistVisible: showList } = useSelector(({ settings }) => settings)
const { data: tags } = useSelector(({ tags }) => tags)
const { query } = useSelector(({ populations }) => populations)
const queryStudyrights = query ? Object.values(query.studyRights) : []
const { data: mandatoryCourses } = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses)
const { data: populationCourses } = useSelector(({ populationCourses }) => populationCourses)
const prevShowList = usePrevious(showList)
const admin = useIsAdmin()
const { handleTabChange } = useTabChangeAnalytics(
ANALYTICS_CATEGORIES.populationStudents,
'Change students table tab'
)
return <Tab onTabChange={handleTabChange} panes={filterPanes(panes)} data-cy="student-table-tabs" />
}
useEffect(() => {
if (tags && tags.length > 0) return
const queryStudyright = queryStudyrights[0]
dispatch(getTagsByStudytrackAction(queryStudyright))
dispatch(getStudentTagsByStudytrackAction(queryStudyright))
setState({ ...state, admin })
}, [])
StudentTableTabs.propTypes = {
panes: arrayOf(object).isRequired,
filterPanes: func.isRequired,
}
class PopulationStudents extends Component {
constructor(props) {
super(props)
this.state = {}
this.studentsRef = React.createRef()
}
componentDidMount() {
const queryStudyright = this.props.queryStudyrights[0]
const admin = this.props.userRoles.includes('admin')
if (queryStudyright) {
this.props.getTagsByStudytrack(queryStudyright)
this.props.getStudentTagsStudyTrack(queryStudyright)
useEffect(() => {
if (!prevShowList && showList) {
scrollToComponent(studentRef.current, { align: 'bottom' })
}
this.setState({ admin })
}, [prevShowList])
const mandatoryCodes = mandatoryCourses.filter(course => course.visible && course.visible.visibility).map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
mandatoryCourses
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
componentDidUpdate = prevProps => {
if (!prevProps.showList && this.props.showList) {
scrollToComponent(this.studentsRef.current, { align: 'bottom' })
}
}
studyrightCodes = (studyrights, value) => {
const { queryStudyrights } = this.props
return studyrights
.filter(sr => {
const { studyright_elements: studyrightElements } = sr
return studyrightElements.filter(sre => queryStudyrights.includes(sre.code)).length >= queryStudyrights.length
})
.map(a => a[value])
}
handleRef = node => {
this.studentsRef.current = node
}
renderStudentTable() {
const { customPopulation, coursePopulation } = this.props
const renderStudentTable = () => {
const verticalTitle = title => <div className="verticalTitle">{title}</div>
const hasPassedMandatory = (studentNumber, code) =>
this.props.mandatoryPassed[code] && this.props.mandatoryPassed[code].includes(studentNumber)
mandatoryPassed[code] && mandatoryPassed[code].includes(studentNumber)
const totalMandatoryPassed = studentNumber => {
this.props.mandatoryCourses.reduce((acc, m) => {
mandatoryCourses.reduce((acc, m) => {
return hasPassedMandatory(studentNumber, m.code) ? acc + 1 : acc
}, 0)
}
const nameColumns = this.props.showNames
const nameColumns = showNames
? [
{
key: 'lastname',
@@ -164,7 +178,7 @@ class PopulationStudents extends Component {
const mandatoryCourseLabels = []
const labelToMandatoryCourses = this.props.mandatoryCourses.reduce((acc, e) => {
const labelToMandatoryCourses = mandatoryCourses.reduce((acc, e) => {
const label = e.label ? e.label.label : ''
acc[label] = acc[label] || []
if (acc[label].some(l => l.code === e.code)) return acc
@@ -183,14 +197,14 @@ class PopulationStudents extends Component {
const mandatoryTitle = m => {
return (
<>
{getTextIn(m.name, this.props.language)}
{getTextIn(m.name, language)}
<br />
{m.code}
</>
)
}
const { visibleLabels, visibleCourseCodes } = this.props.mandatoryCourses.reduce(
const { visibleLabels, visibleCourseCodes } = mandatoryCourses.reduce(
(acc, cur) => {
if (cur.visible && cur.visible.visibility) {
acc.visibleLabels.add(cur.label_code)
@@ -220,7 +234,11 @@ class PopulationStudents extends Component {
</div>
),
parent: true,
headerProps: { colSpan: labelToMandatoryCourses[e.label].length, title: e.label, ordernumber: e.orderNumber },
headerProps: {
colSpan: labelToMandatoryCourses[e.label].length,
title: e.label,
ordernumber: e.orderNumber,
},
}))
)
@@ -242,8 +260,8 @@ class PopulationStudents extends Component {
.map(m => ({
key: `${m.label ? m.label.label : 'fix'}-${m.code}`, // really quick and dirty fix
title: verticalTitle(mandatoryTitle(m)),
cellProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
headerProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
cellProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
headerProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
getRowVal: s => (s.total ? getTotalRowVal(s, m) : hasPassedMandatory(s.studentNumber, m.code)),
getRowContent: s => {
if (s.total) return getTotalRowVal(s, m)
@@ -257,22 +275,20 @@ class PopulationStudents extends Component {
),
]
const totals = this.props.filteredStudents.reduce(
const totals = filteredStudents.reduce(
(acc, s) => {
const passedCourses = new Set()
this.props.mandatoryCourses.forEach(m => {
mandatoryCourses.forEach(m => {
if (passedCourses.has(m.code)) return
passedCourses.add(m.code)
if (hasPassedMandatory(s.studentNumber, m.code)) ++acc[m.code]
})
return acc
},
this.props.mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
)
const mandatoryCourseData = [totals, ...this.props.filteredStudents]
const mandatoryCourseData = [totals, filteredStudents]
// FIXME: here only for refactorment
const { showNames, studentToTargetCourseDateMap, coursecode } = this.props
const panes = [
{
menuItem: 'General',
@@ -294,7 +310,7 @@ class PopulationStudents extends Component {
<Tab.Pane>
<div style={{ display: 'flex' }}>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
{this.props.mandatoryCourses.length > 0 && (
{mandatoryCourses.length > 0 && (
<SortableTable
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
tableProps={{
@@ -308,7 +324,7 @@ class PopulationStudents extends Component {
textAlign: 'center',
}}
collapsingHeaders
showNames={this.props.showNames}
showNames={showNames}
columns={mandatoryCourseColumns}
data={mandatoryCourseData}
/>
@@ -323,7 +339,7 @@ class PopulationStudents extends Component {
render: () => (
<Tab.Pane>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
{this.props.tags.length === 0 && (
{tags.length === 0 && (
<div
style={{
paddingLeft: '10px',
@@ -337,7 +353,7 @@ class PopulationStudents extends Component {
<h3>
No tags defined. You can define them{' '}
<Link
to={`/study-programme/${this.props.queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
to={`/study-programme/${queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
onClick={() => {
sendAnalytics('No tags defined button clicked', 'Tags tab')
}}
@@ -348,14 +364,14 @@ class PopulationStudents extends Component {
</h3>
</div>
)}
{this.props.tags.length > 0 && (
{tags.length > 0 && (
<>
<TagPopulation
tags={this.props.tags}
selectedStudents={this.props.filteredStudents.map(stu => stu.studentNumber)}
studytrack={this.props.queryStudyrights[0]}
tags={tags}
selectedStudents={filteredStudents.map(stu => stu.studentNumber)}
studytrack={queryStudyrights[0]}
/>
<TagList studytrack={this.props.queryStudyrights[0]} selectedStudents={this.props.filteredStudents} />
<TagList studytrack={queryStudyrights[0]} selectedStudents={filteredStudents} />
</>
)}
</div>
@@ -377,142 +393,34 @@ class PopulationStudents extends Component {
<Grid.Column>
<StudentNameVisibilityToggle />
</Grid.Column>
{this.props.dataExport && (
{dataExport && (
<Grid.Column textAlign="right">
<Dropdown text="Export Data" icon="save" button labeled className="icon" direction="left">
<Dropdown.Menu>{this.props.dataExport}</Dropdown.Menu>
<Dropdown.Menu>{dataExport}</Dropdown.Menu>
</Dropdown>
</Grid.Column>
)}
</Grid>
<StudentTableTabs panes={panes} filterPanes={filteredPanes} />
<Tab onTabChange={handleTabChange} panes={filteredPanes(panes)} data-cy="student-table-tabs" />
</>
)
}
render() {
if (this.props.filteredStudents.length === 0) {
return null
}
if (filteredStudents.length === 0) return null
return (
<Ref innerRef={this.handleRef}>
<>
<span style={{ marginRight: '0.5rem' }}>
<InfoBox
content={
this.props.coursePopulation
? infotoolTips.CoursePopulation.Students
: infotoolTips.PopulationStatistics.Students
}
/>
</span>
{this.state.admin ? (
<CheckStudentList students={this.props.filteredStudents.map(stu => stu.studentNumber)} />
) : null}
{this.renderStudentTable()}
</>
</Ref>
)
}
return (
<>
<span style={{ marginRight: '0.5rem' }} ref={studentRef}>
<InfoBox
content={
coursePopulation ? infotoolTips.CoursePopulation.Students : infotoolTips.PopulationStatistics.Students
}
/>
</span>
{admin ? <CheckStudentList students={filteredStudents.map(stu => stu.studentNumber)} /> : null}
{renderStudentTable()}
</>
)
}
PopulationStudents.defaultProps = {
studentToTargetCourseDateMap: null,
customPopulation: false,
coursePopulation: false,
dataExport: null,
coursecode: [],
}
PopulationStudents.propTypes = {
showNames: bool.isRequired,
showList: bool.isRequired,
language: string.isRequired,
queryStudyrights: arrayOf(string).isRequired,
mandatoryCourses: arrayOf(
shape({
name: shape({}).isRequired,
code: string.isRequired,
})
).isRequired,
mandatoryPassed: shape({}).isRequired,
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
getTagsByStudytrack: func.isRequired,
getStudentTagsStudyTrack: func.isRequired,
userRoles: arrayOf(string).isRequired,
studentToTargetCourseDateMap: shape({}),
coursePopulation: bool,
customPopulation: bool,
filteredStudents: arrayOf(shape({})).isRequired,
dataExport: node,
coursecode: arrayOf(string),
}
const mapStateToProps = state => {
const {
settings,
populations,
populationCourses,
populationMandatoryCourses,
tags,
auth: {
token: { roles },
},
} = state
const mandatoryCodes = populationMandatoryCourses.data
.filter(course => course.visible && course.visible.visibility)
.map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.data.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
populationMandatoryCourses.data
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.data.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
return {
showNames: settings.namesVisible,
showList: settings.studentlistVisible,
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
mandatoryCourses: populationMandatoryCourses.data,
mandatoryPassed,
tags: tags.data,
userRoles: getUserRoles(roles),
}
}
export default connect(mapStateToProps, {
toggleStudentListVisibility,
getTagsByStudytrack: getTagsByStudytrackAction,
getStudentTagsStudyTrack: getStudentTagsByStudytrackAction,
})(withRouter(PopulationStudents))
export default PopulationStudents
@@ -1,5 +1,4 @@
import React, { useState } from 'react'
import { shape, string, arrayOf } from 'prop-types'
import { Menu, Tab, Input, Message } from 'semantic-ui-react'
import { flattenDeep } from 'lodash'
import Highcharts from 'highcharts/highstock'
@@ -7,38 +6,26 @@ import ReactHighcharts from 'react-highcharts'
import CreditAccumulationGraphHighCharts from '../../CreditAccumulationGraphHighCharts'
import { byDateDesc, reformatDate, getTextIn } from '../../../common'
import TSA from '../../../common/tsa'
import useLanguage from '../../LanguagePicker/useLanguage'
const ANALYTICS_CATEGORY = 'Student stats'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const CreditsGraph = ({ graphYearStart, student, absences }) => {
const { language } = useLanguage()
const selectedStart = new Date(graphYearStart ?? student.started)
const dates = flattenDeep(student.courses.map(c => c.date)).map(d => new Date(d).getTime())
const endDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
return (
<CreditAccumulationGraphHighCharts
singleStudent
students={[student]}
title="Credit Accumulation"
startDate={selectedStart}
endDate={endDate}
absences={absences}
language={language}
/>
)
}
CreditsGraph.propTypes = {
student: shape({}).isRequired,
absences: arrayOf(shape({})).isRequired,
graphYearStart: string.isRequired,
}
const chunkifyArray = (array, size = 1) => {
if (!array) return []
const firstChunk = array.slice(0, size) // create the first chunk of the given array
@@ -197,12 +184,6 @@ const GradeGraph = ({ student, semesters, language }) => {
)
}
GradeGraph.propTypes = {
student: shape({}).isRequired,
semesters: shape({}).isRequired,
language: string.isRequired,
}
const StudentGraphs = ({ student, absences, graphYearStart, semesters, language }) => {
const panes = [
{
@@ -225,12 +206,4 @@ const StudentGraphs = ({ student, absences, graphYearStart, semesters, language
return <Tab panes={panes} />
}
StudentGraphs.propTypes = {
student: shape({}).isRequired,
absences: arrayOf(shape({})).isRequired,
graphYearStart: string.isRequired,
semesters: shape({}).isRequired,
language: string.isRequired,
}
export default StudentGraphs
@@ -1,63 +1,95 @@
/* eslint-disable react/prop-types */
// temp disable prop types
import React, { useEffect, useState, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Button, Loader, Header, Accordion, Divider, Segment } from 'semantic-ui-react'
import React, { useCallback, useEffect, useState, useRef } from 'react'
import { useSelector } from 'react-redux'
import { Button, Header, Accordion, Divider, Label, Segment } from 'semantic-ui-react'
import { useHistory } from 'react-router-dom'
import scrollToComponent from 'react-scroll-to-component'
import { getCustomPopulation, clearPopulations } from '../../redux/populations'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import StyledMessage from './StyledMessage'
import { getTextIn } from '../../common'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
import InfoBox from '../InfoBox'
import infotooltips from '../../common/InfoToolTips'
import { getTextIn } from 'common'
import { useToggle } from 'common/hooks'
import FilterTray from 'components/FilterTray'
import useFilters from 'components/FilterTray/useFilters'
import PopulationStudents from 'components/PopulationStudents'
import CreditAccumulationGraphHighCharts from 'components/CreditAccumulationGraphHighCharts'
import { StudyGuidanceGroupFilters } from 'components/FilterTray/FilterSets'
import { useGetStudyGuidanceGroupPopulationQuery } from 'redux/studyGuidanceGroups'
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import StudyGuidanceGroupPopulationCourses from './StudyGuidanceGroupPopulationCourses'
import { startYearToAcademicYear, Wrapper, StyledMessage } from './common'
const createAcademicYearStartDate = year => new Date(year, 7, 1)
const takeOnlyCoursesStartingFromGivenAcademicYear = ({ students, year }) => {
if (!year) return students
const academicYearStartDate = createAcademicYearStartDate(year)
return students.map(student => ({
...student,
courses: student.courses.filter(course => new Date(course.date) > academicYearStartDate),
}))
}
const useIsMounted = () => {
const isMounted = useRef(false)
useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
}, [])
return useCallback(() => isMounted.current, [])
}
const useToggleAndSetNewestIndex = ({ defaultValue, indexOfPanelToScroll, setNewestIndex, isMounted }) => {
const [state, toggle] = useToggle(defaultValue)
useEffect(() => {
if (isMounted()) setNewestIndex(indexOfPanelToScroll)
}, [state])
return [state, toggle]
}
const SingleStudyGroupContent = ({ population, group, language }) => {
const custompop = population?.students || []
const isMounted = useIsMounted()
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const creditGainRef = useRef()
const programmeRef = useRef()
const coursesRef = useRef()
const studentRef = useRef()
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
const [activeIndex, setIndex] = useState([])
const [newestIndex, setNewest] = useState(null)
const refs = [useRef(), useRef(), useRef(), useRef()]
const [activeIndex, setActiveIndex] = useState([])
const [newestIndex, setNewestIndex] = useState(null)
const [creditsStartingFromAssociatedYear, toggleCreditsStartingFromAssociatedYear] = useToggleAndSetNewestIndex({
defaultValue: !!group.tags?.year,
indexOfPanelToScroll: 0,
setNewestIndex,
isMounted,
})
const [coursesStructuredByProgramme, toggleCoursesStructuredByProgramme] = useToggleAndSetNewestIndex({
defaultValue: !!group.tags?.studyProgramme,
indexOfPanelToScroll: 1,
setNewestIndex,
isMounted,
})
const filteredStudentsWithFilteredCourses = takeOnlyCoursesStartingFromGivenAcademicYear({
students: filteredStudents,
year: group.tags?.year,
})
const handleClick = index => {
const indexes = [...activeIndex].sort()
if (indexes.includes(index)) {
indexes.splice(
indexes.findIndex(ind => ind === index),
1
)
const togglePanel = index => {
const currentActiveIndex = new Set(activeIndex)
if (currentActiveIndex.has(index)) {
currentActiveIndex.delete(index)
} else {
indexes.push(index)
currentActiveIndex.add(index)
setNewestIndex(index)
}
if (activeIndex.length < indexes.length) setNewest(index)
else setNewest(null)
setIndex(indexes)
setActiveIndex([...currentActiveIndex])
}
useEffect(() => {
setAllStudents(custompop)
handleClick(0)
}, [custompop])
if (newestIndex) scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}, [newestIndex])
useEffect(() => {
if (newestIndex) {
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}
}, [activeIndex])
setAllStudents(population?.students || [])
togglePanel(0)
}, [population])
const panels = [
{
@@ -65,20 +97,22 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Credit accumulation (for {selectedStudents.length} students)
Credit accumulation (for {filteredStudents.length} students)
</span>
),
},
onTitleClick: () => handleClick(0),
onTitleClick: () => togglePanel(0),
content: {
content: (
<div ref={creditGainRef}>
<div ref={refs[0]}>
{group.tags?.year && (
<Button primary onClick={() => toggleCreditsStartingFromAssociatedYear()}>
{creditsStartingFromAssociatedYear ? 'Show all credits' : 'Show starting from associated year'}
</Button>
)}
<CreditAccumulationGraphHighCharts
students={filteredStudents}
render={false}
trayOpen={trayOpen}
language={language}
startDate={new Date(parseInt(group?.tags?.year, 10), 7, 1).getTime()}
students={creditsStartingFromAssociatedYear ? filteredStudentsWithFilteredCourses : filteredStudents}
startDate={creditsStartingFromAssociatedYear && createAcademicYearStartDate(group.tags?.year)}
/>
</div>
),
@@ -89,16 +123,20 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Programme distribution
Courses of population
</span>
),
},
onTitleClick: () => handleClick(1),
onTitleClick: () => togglePanel(1),
content: {
content: (
<div ref={programmeRef}>
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
<div ref={refs[1]}>
<StudyGuidanceGroupPopulationCourses
selectedStudents={filteredStudents.map(({ studentNumber }) => studentNumber)}
showStructured={coursesStructuredByProgramme}
toggleShowStructured={toggleCoursesStructuredByProgramme}
studyProgramme={group.tags?.studyProgramme}
/>
</div>
),
},
@@ -108,33 +146,15 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Courses of population
Students ({filteredStudents.length})
</span>
),
},
onTitleClick: () => handleClick(2),
onTitleClick: () => togglePanel(2),
content: {
content: (
<div ref={coursesRef}>
<CustomPopulationCourses selectedStudents={selectedStudents} />
</div>
),
},
},
{
key: 3,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({selectedStudents.length})
</span>
),
},
onTitleClick: () => handleClick(3),
content: {
content: (
<div ref={studentRef}>
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
<div ref={refs[2]}>
<PopulationStudents customPopulation language={language} filteredStudents={filteredStudents} />
</div>
),
},
@@ -142,78 +162,73 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
]
return (
<FilterTray filterSet={<CustomPopulationFilters />}>
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
<FilterTray filterSet={<StudyGuidanceGroupFilters />}>
<Segment className="contentSegment">
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
</Segment>
</FilterTray>
)
}
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
const dispatch = useDispatch()
const SingleStudyGroupViewWrapper = ({ group, language, isLoading, studyProgrammes, children }) => {
const history = useHistory()
const handleBack = () => {
dispatch(clearPopulations())
history.push('/studyguidancegroups')
}
return (
<>
<div className="segmentContainer">
<Segment className="contentSegment">
<Button icon="arrow circle left" content="Back" onClick={handleBack} />
<Divider />
<Header size="medium">{getTextIn(groupName, language)}</Header>
</Segment>
</div>
<Wrapper isLoading={isLoading}>
<Button icon="arrow circle left" content="Back" onClick={handleBack} />
<Divider />
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
<Header size="medium" style={{ marginRight: 'auto' }}>
{group.name && language && getTextIn(group.name, language)}
</Header>
{group.tags?.studyProgramme && (
<Label tag content={studyProgrammes.find(p => p.value === group.tags.studyProgramme)?.text} color="blue" />
)}
{group.tags?.year && <Label tag content={startYearToAcademicYear(group.tags.year)} color="blue" />}
</div>
</Wrapper>
{children}
</>
)
}
const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
const dispatch = useDispatch()
const history = useHistory()
const SingleStudyGuidanceGroupContainer = ({ group }) => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const group = groups.find(g => g.id === groupid)
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const { data: population, pending } = useSelector(({ populations }) => populations)
useEffect(() => {
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
if (groupStudentNumbers.length === 0 || population?.students?.length > 0) return
dispatch(getCustomPopulation({ studentnumberlist: groupStudentNumbers, usingStudyGuidanceGroups: true }))
dispatch(
getCustomPopulationCoursesByStudentnumbers({
studentnumberlist: groupStudentNumbers,
usingStudyGuidanceGroups: true,
})
)
}, [])
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const { data, isLoading } = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
if (!group) {
return (
<StyledMessage>
Couldn't find group with id {groupid}! Please check that id is correct and you have rights to this study
guidance group.
</StyledMessage>
<SingleStudyGroupViewWrapper>
<StyledMessage>
Couldn't find group with this id! Please check that id is correct and you have rights to this study guidance
group.
</StyledMessage>
</SingleStudyGroupViewWrapper>
)
}
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false && Object.keys(population).length > 0
const renderMessage = groupStudentNumbers.length === 0
const renderLoader = groupStudentNumbers.length > 0 && isLoading
const renderGroup = groupStudentNumbers.length > 0 && isLoaded
if (groupStudentNumbers.length === 0) {
return (
<SingleStudyGroupViewWrapper>
<StyledMessage>This study guidance group doesn't contain any students.</StyledMessage>
</SingleStudyGroupViewWrapper>
)
}
return (
<SingleStudyGroupViewWrapper history={history} groupName={group.name} language={language}>
{renderMessage ? <StyledMessage>This study guidance group doesn't contain any students.</StyledMessage> : null}
{renderLoader ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{renderGroup ? <SingleStudyGroupContent population={population} group={group} language={language} /> : null}
<SingleStudyGroupViewWrapper
group={group}
language={language}
isLoading={isLoading}
studyProgrammes={studyProgrammes}
>
{!isLoading && <SingleStudyGroupContent population={data} group={group} language={language} />}
</SingleStudyGroupViewWrapper>
)
}
@@ -1,100 +1,109 @@
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Link } from 'react-router-dom'
import React from 'react'
import { useSelector } from 'react-redux'
import { Link, useHistory } from 'react-router-dom'
import { Formik } from 'formik'
import { Form, Button, Icon } from 'semantic-ui-react'
import Datetime from 'react-datetime'
import SortableTable from '../SortableTable'
import { getTextIn, textAndDescriptionSearch } from '../../common'
import StyledMessage from './StyledMessage'
import { changeStudyGuidanceGroupTags } from '../../redux/studyGuidanceGroups'
import { getElementDetails } from '../../redux/elementdetails'
import { useToggle } from '../../common/hooks'
import { getTextIn, textAndDescriptionSearch } from 'common'
import { useChangeStudyGuidanceGroupTagsMutation } from 'redux/studyGuidanceGroups'
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import { useToggle } from 'common/hooks'
import SortableTable from 'components/SortableTable'
import { startYearToAcademicYear, StyledMessage } from './common'
const LinkToGroup = ({ group, language }) => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/studyguidancegroups/${group.id}`}
>
{getTextIn(group.name, language)}
</Link>
)
const LinkToGroup = ({ group, language }) => {
const history = useHistory()
const dest = `/studyguidancegroups/${group.id}`
return (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={dest}
>
{getTextIn(group.name, language)}
<Icon color="blue" name="level up alternate" onClick={() => history.push(dest)} />
</Link>
)
}
const prettifyCamelCase = str => {
const splitted = str.match(/[A-Za-z][a-z]*/g) || []
return splitted.map(w => w.charAt(0).toLowerCase() + w.substring(1)).join(' ')
}
const startYearToAcademicYear = year => {
return year === '' || Number.isNaN(year) ? '' : `${year} - ${parseInt(year, 10) + 1}`
}
const cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
const cellContent = { flex: '0 1 50%' }
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const dispatch = useDispatch()
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
return (
<div style={{ display: 'flex', gap: '8px' }}>
<div>
<Formik
initialValues={{ [tagName]: '' }}
onSubmit={values => {
dispatch(changeStudyGuidanceGroupTags(group.id, values))
toggleEdit()
changeStudyGuidanceGroupTags({ groupId: group.id, tags: values })
if (group.tags?.[tagName]) toggleEdit()
}}
validate={values => (!values[tagName] ? { [tagName]: `${tagName} is required` } : {})}
>
{formik => (
<Form onSubmit={formik.handleSubmit} style={{ display: 'flex', gap: '8px' }}>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
search={textAndDescriptionSearch}
fluid
placeholder={
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
/>
) : (
<Datetime
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
renderYear={(props, selectableYear) => <td {...props}>{startYearToAcademicYear(selectableYear)}</td>}
renderInput={({ value, ...rest }) => {
return (
<div>
<input value={startYearToAcademicYear(value)} placeholder="Select year" {...rest} />
</div>
)
}}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
/>
<Form onSubmit={formik.handleSubmit} style={{ ...cellWrapper, alignItems: 'center' }}>
<div style={cellContent}>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
search={textAndDescriptionSearch}
fluid
placeholder={
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
/>
) : (
<Datetime
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
inputProps={{ readOnly: true }}
renderYear={(props, selectableYear) => <td {...props}>{startYearToAcademicYear(selectableYear)}</td>}
renderInput={({ value, ...rest }) => {
return (
<div>
<input value={startYearToAcademicYear(value)} placeholder="Select year" {...rest} />
</div>
)
}}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
/>
)}
</div>
<div style={cellContent}>
<Button type="submit" style={{ margin: '0' }} disabled={isLoading}>
Add {prettifyCamelCase(tagName)}
</Button>
</div>
{group.tags?.[tagName] && (
<div style={cellContent}>
<Button icon type="button" style={{ margin: '0' }} onClick={() => toggleEdit()}>
Close
<Icon name="close" />
</Button>
</div>
)}
<Button type="submit" style={{ margin: '0 0 1em 0' }}>
Add {prettifyCamelCase(tagName)}
</Button>
</Form>
)}
</Formik>
{group.tags?.[tagName] ? (
<Button icon style={{ margin: '0 0 1em 0' }} onClick={() => toggleEdit()}>
Close
<Icon name="close" />
</Button>
) : null}
</div>
)
}
@@ -111,40 +120,23 @@ const TagCell = ({ tagName, group, studyProgrammes }) => {
throw Error(`Wrong tagname: ${tagName}`)
}
}
const text = getText()
return group.tags?.[tagName] && !showEdit ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
<p>{text}</p>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
<div style={{ ...cellWrapper, alignItems: 'baseline' }}>
<p style={{ ...cellContent, textAlign: 'center' }}>{getText()}</p>
<div style={cellContent}>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
</div>
</div>
) : (
<AssociateTagForm group={group} tagName={tagName} toggleEdit={toggleEdit} selectFieldItems={studyProgrammes} />
)
}
const StudyGuidanceGroupOverview = () => {
const dispatch = useDispatch()
const StudyGuidanceGroupOverview = ({ groups }) => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const { data: elementDetails } = useSelector(({ elementdetails }) => elementdetails)
useEffect(() => {
if (elementDetails && elementDetails.length > 0) return
dispatch(getElementDetails())
}, [dispatch])
const studyProgrammesFilteredForDropdown =
elementDetails
?.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
.map(elem => ({
key: elem.code,
value: elem.code,
description: elem.code,
text: getTextIn(elem.name, language),
})) || []
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const headers = [
{
@@ -156,6 +148,7 @@ const StudyGuidanceGroupOverview = () => {
style: {
padding: '0',
},
className: 'iconCellNoPointer',
},
},
{
@@ -174,16 +167,12 @@ const StudyGuidanceGroupOverview = () => {
key: 'studyProgramme',
title: 'Study programme',
getRowVal: group => group.tags?.studyProgramme,
headerProps: { onClick: null, sorted: null },
getRowContent: group => (
<TagCell tagName="studyProgramme" studyProgrammes={studyProgrammesFilteredForDropdown} group={group} />
),
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
},
{
key: 'associatedyear',
title: 'Associated starting academic year',
getRowVal: group => group.tags?.year,
headerProps: { onClick: null, sorted: null },
getRowContent: group => <TagCell tagName="year" group={group} />,
},
]
@@ -0,0 +1,62 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import InfoBox from 'components/InfoBox'
import PopulationCourseStatsFlat from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
import PopulationCourseStats from 'components/PopulationCourseStats'
import useCourseFilter from 'components/FilterTray/filters/Courses/useCourseFilter'
import SegmentDimmer from 'components/SegmentDimmer'
import { useGetStudyGuidanceGroupPopulationCoursesQuery } from 'redux/studyGuidanceGroups'
import { getMandatoryCourses, getMandatoryCourseModules } from 'redux/populationMandatoryCourses'
import infotooltips from 'common/InfoToolTips'
const StudyGuidanceGroupPopulationCourses = ({
selectedStudents,
showStructured,
toggleShowStructured,
studyProgramme,
}) => {
const { setCourses, resetCourses } = useCourseFilter()
const dispatch = useDispatch()
const mandatoryCourses = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses.data)
const { data: courses, isLoading } = useGetStudyGuidanceGroupPopulationCoursesQuery(selectedStudents)
useEffect(() => {
// ensure mandatory courses are available for course stats structured
if (showStructured && studyProgramme && mandatoryCourses.length === 0) {
dispatch(getMandatoryCourses(studyProgramme))
dispatch(getMandatoryCourseModules(studyProgramme))
}
}, [showStructured])
useEffect(() => {
if (!isLoading) setCourses(courses.coursestatistics)
}, [courses])
useEffect(() => {
return resetCourses
}, [])
if (isLoading) return <SegmentDimmer isLoading={isLoading} />
return (
<Segment basic>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
{studyProgramme && (
<Button primary onClick={() => toggleShowStructured()} style={{ marginLeft: '1em' }}>
{showStructured ? 'Show courses as flat list' : 'Show by programme structure'}
</Button>
)}
{showStructured ? (
<PopulationCourseStats courses={courses} pending={isLoading} selectedStudents={selectedStudents} />
) : (
<PopulationCourseStatsFlat
courses={courses}
pending={isLoading}
selectedStudents={selectedStudents}
showFilter={false}
/>
)}
</Segment>
)
}
export default StudyGuidanceGroupPopulationCourses
@@ -1,4 +0,0 @@
import React from 'react'
import { Message } from 'semantic-ui-react'
export default ({ children }) => <Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
@@ -0,0 +1,18 @@
import React from 'react'
import { Message, Segment } from 'semantic-ui-react'
export const startYearToAcademicYear = year => {
return year === '' || Number.isNaN(year) ? '' : `${year} - ${parseInt(year, 10) + 1}`
}
export const StyledMessage = ({ children }) => (
<Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
)
export const Wrapper = ({ isLoading, children }) => (
<div className="segmentContainer">
<Segment loading={isLoading} className="contentSegment">
{children}
</Segment>
</div>
)
@@ -1,26 +1,16 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useRouteMatch } from 'react-router-dom'
import { Header, Loader, Segment } from 'semantic-ui-react'
import { useTitle } from '../../common/hooks'
import { getStudyGuidanceGroups } from '../../redux/studyGuidanceGroups'
import React from 'react'
import { useParams } from 'react-router-dom'
import { Header } from 'semantic-ui-react'
import { useGetAllStudyGuidanceGroupsQuery } from 'redux/studyGuidanceGroups'
import { useTitle } from 'common/hooks'
import StudyGuidanceGroupOverview from './StudyGuidanceGroupOverview'
import SingleStudyGuidanceGroup from './SingleStudyGuidanceGroup'
import { Wrapper } from './common'
const StudyGuidanceGroups = () => {
useTitle('Study guidance groups')
const dispatch = useDispatch()
const { pending, data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const match = useRouteMatch('/studyguidancegroups/:groupid')
const groupid = match?.params?.groupid
useEffect(() => {
if (groups && groups.length > 0) return
dispatch(getStudyGuidanceGroups())
}, [dispatch])
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false
const { groupid } = useParams()
const { data, isLoading } = useGetAllStudyGuidanceGroupsQuery()
return (
<>
@@ -28,16 +18,9 @@ const StudyGuidanceGroups = () => {
<Header className="segmentTitle" size="large">
Study guidance groups
</Header>
<Segment className="contentSegment">
{isLoading ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
</Segment>
</div>
<div>{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}</div>
<Wrapper isLoading={isLoading}>{!isLoading && !groupid && <StudyGuidanceGroupOverview groups={data} />}</Wrapper>
{!isLoading && groupid && <SingleStudyGuidanceGroup group={data.find(group => group.id === groupid)} />}
</>
)
}
+4 -1
View File
@@ -9,7 +9,7 @@ export const login = () => ({
export const logout = () => ({ type: 'LOGOUT_ATTEMPT' })
const reducer = (state = { pending: false, error: false, token: null }, action) => {
const reducer = (state = { pending: false, error: false, token: null, encodedToken: null }, action) => {
switch (action.type) {
case 'LOGIN_ATTEMPT':
return {
@@ -31,6 +31,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: false,
token: decodeToken(action.token),
encodedToken: action.token,
}
case 'LOGOUT_ATTEMPT':
@@ -46,6 +47,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: true,
token: null,
encodedToken: null,
}
case 'LOGOUT_SUCCESS':
@@ -54,6 +56,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: false,
token: null,
encodedToken: null,
}
default:
@@ -1,5 +1,8 @@
import { RTKApi } from 'apiConnection'
import { getTextIn } from 'common'
import { callController } from '../apiConnection'
// original elementdetail stuff
export const getElementDetails = () => {
const route = '/elementdetails/all'
const prefix = 'GET_ELEMENTDETAILS_'
@@ -31,4 +34,38 @@ const reducer = (state = { data: [] }, action) => {
}
}
// RTK query based stuff
const elementDetailsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getAllElementDetails: builder.query({
query: () => '/elementdetails/all',
providesTags: result => [
...result?.map(({ id }) => ({ type: 'StudyGuidanceGroups', id })),
{ type: 'StudyGuidanceGroups', id: 'LIST' },
],
}),
}),
overrideExisting: false,
})
export const { useGetAllElementDetailsQuery } = elementDetailsApi
// Returns only newest studyprogrammes and formats them to be used in semantic ui dropdowns
export const useFilteredAndFormattedElementDetails = language => {
const { data, isLoading } = useGetAllElementDetailsQuery()
const filteredAndFormatted = isLoading
? []
: data
.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
.map(elem => ({
key: elem.code,
value: elem.code,
description: elem.code,
text: getTextIn(elem.name, language),
}))
return filteredAndFormatted
}
export default reducer
+7 -4
View File
@@ -1,5 +1,6 @@
import { configureStore } from '@reduxjs/toolkit'
import { handleRequest, handleAuth } from '../apiConnection'
import { setupListeners } from '@reduxjs/toolkit/query/react'
import { handleRequest, handleAuth, RTKApi } from 'apiConnection'
import actionHistory from './actionHistory'
import users from './users'
import populations from './populations'
@@ -40,9 +41,8 @@ import singleCourseStats from './singleCourseStats'
import userAccessEmail from './userAccessEmail'
import customPopulationSearch from './customPopulationSearch'
import coolDataScience from './coolDataScience'
import studyGuidanceGroups from './studyGuidanceGroups'
export default configureStore({
const store = configureStore({
reducer: {
actionHistory,
users,
@@ -84,7 +84,7 @@ export default configureStore({
userAccessEmail,
customPopulationSearch,
coolDataScience,
studyGuidanceGroups,
[RTKApi.reducerPath]: RTKApi.reducer,
},
// oodikone is currently too heavy for other middlewares than thunk, but
// feel free to take use them at some point if possible
@@ -92,5 +92,8 @@ export default configureStore({
...getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }),
handleRequest,
handleAuth,
RTKApi.middleware,
],
})
setupListeners(store.dispatch)
export default store
@@ -1,37 +1,43 @@
import { createReducer } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
import { initialState, matcherReducers, defaultReducer } from './common'
import { RTKApi } from 'apiConnection'
const baseUrl = '/studyguidancegroups'
const getPrefix = 'GET_STUDY_GUIDANCE_GROUPS_'
const getTypes = {
...actionTypes(getPrefix),
}
const changeTagsPrefix = 'CHANGE_STUDY_GUIDANCE_GROUP_TAGS_'
const changeTagsTypes = {
...actionTypes(changeTagsPrefix),
}
const studyGuidanceGroupsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getAllStudyGuidanceGroups: builder.query({
query: () => '/studyguidancegroups',
providesTags: result => [
...result?.map(({ id }) => ({ type: 'StudyGuidanceGroups', id })),
{ type: 'StudyGuidanceGroups', id: 'LIST' },
],
}),
changeStudyGuidanceGroupTags: builder.mutation({
query: ({ groupId, tags }) => ({
url: `/studyguidancegroups/${groupId}/tags`,
method: 'PUT',
body: tags,
}),
invalidatesTags: ({ studyGuidanceGroupId }) => [{ type: 'StudyGuidanceGroups', id: studyGuidanceGroupId }],
}),
getStudyGuidanceGroupPopulation: builder.query({
query: studentnumberlist => ({
url: '/v3/populationstatisticsbystudentnumbers',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
}),
}),
getStudyGuidanceGroupPopulationCourses: builder.query({
query: studentnumberlist => ({
url: '/v2/populationstatistics/coursesbystudentnumberlist',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
}),
}),
}),
overrideExisting: false,
})
export const getStudyGuidanceGroups = () => callController(baseUrl, getPrefix)
export const changeStudyGuidanceGroupTags = (groupId, tags) =>
callController(`${baseUrl}/${groupId}/tags`, changeTagsPrefix, tags, 'put')
const reducer = createReducer(
initialState,
{
[getTypes.success]: (state, action) => {
state.data = action.response
},
[changeTagsTypes.success]: (state, action) => {
state.data.find(group => group.id === action.response.studyGuidanceGroupId).tags = (({
studyGuidanceGroupId,
...rest
}) => rest)(action.response)
},
},
matcherReducers,
defaultReducer
)
export default reducer
export const {
useGetAllStudyGuidanceGroupsQuery,
useChangeStudyGuidanceGroupTagsMutation,
useGetStudyGuidanceGroupPopulationQuery,
useGetStudyGuidanceGroupPopulationCoursesQuery,
} = studyGuidanceGroupsApi
+3 -3
View File
@@ -6596,9 +6596,9 @@
}
},
"validator": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz",
"integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg=="
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
},
"vary": {
"version": "1.1.2",
@@ -358,20 +358,27 @@ const updateAttainments = async (attainments, personIdToStudentNumber, attainmen
} else {
coursesToBeCreated.set('AY'.concat(course.code), {
...course.dataValues,
name: course.name,
id: course.id.concat('-ay'),
code: 'AY'.concat(course.code),
substitutions: [course.id],
})
const subs = [...course.substitutions, course.id.concat('-ay')]
coursesToBeUpdated.set(course.code, {
...course.dataValues,
substitutions: subs,
})
courseCodeToAyCodelessId.set('AY'.concat(course.code), course.id.concat('-ay'))
const courseProvider = await CourseProvider.findOne({
where: {
coursecode: course ? course.id : 'AY'.concat(course.code),
coursecode: `${course.id}-ay`,
},
})
if (!courseProvider) {
const mapCourseProvider = courseProviderMapper('AY'.concat(course.code))
courseProvidersToBeCreated.push(mapCourseProvider('hy-org-48645785'))
const mapCourseProvider = courseProviderMapper(course.id.concat('-ay'))
courseProvidersToBeCreated.push(mapCourseProvider({ organisationId: 'hy-org-48645785' }))
}
}
}