Compare commits

...
18 Commits
Author SHA1 Message Date
Petri Palmu e9242b3063 update frontend packages 2021-11-04 08:22:58 +02:00
Petri Palmu 6ccf320bb2 Fix to the open uni programme trends 2021-11-04 08:14:03 +02:00
Petri Palmu 4e9b579843 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-04 08:06:13 +02:00
Petri Palmu 985084864c Added open uni programme view to the trends 2021-11-04 08:05:57 +02:00
Otto Ahoniemi 71c61c3393 Fix single group loading failing 2021-11-03 15:26:36 +02:00
Otto Ahoniemi f3b1f639b1 Disable immutable & serializablechecks from middleware 2021-11-03 15:01:15 +02:00
Otto Ahoniemi e599e620c0 Add student counts 2021-11-03 14:34:12 +02:00
Otto Ahoniemi f5b081656f use native isnan instead of lodash 2021-11-03 14:04:16 +02:00
Otto Ahoniemi 7af8e1a4df Change single year to academic year 2021-11-03 14:00:18 +02:00
Otto Ahoniemi 8c092c8d9f Remove more redux boilerplate 2021-11-03 12:47:52 +02:00
Otto Ahoniemi e0789186ba Change combinereducer import location 2021-11-03 12:26:56 +02:00
Otto Ahoniemi c798a4747f Remove unnecessary crudreducer abstraction 2021-11-03 12:20:18 +02:00
Otto Ahoniemi 8f396a9913 Cleanup studyguidancegroup reducer 2021-11-03 12:11:30 +02:00
Otto Ahoniemi 148a5acc25 Use one source of truth for redux types 2021-11-03 09:49:09 +02:00
Otto Ahoniemi 9cf1b87b3f Remove deprecated context dev tool pkg 2021-11-03 09:34:58 +02:00
Otto Ahoniemi 82ed55e1f9 Simplify store config 2021-11-03 09:30:41 +02:00
Otto Ahoniemi 82041e673d Swap individual redux&reselect to official redux toolkit package 2021-11-03 09:25:45 +02:00
Otto Ahoniemi 1e4405aac9 Change frontpage to work with hooks 2021-11-02 22:06:42 +02:00
22 changed files with 499 additions and 22880 deletions
@@ -193,7 +193,7 @@ const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias
return sequelize.query(
`
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
o.code AS organizationcode, co.code, co.id
o.code AS organizationcode, co.code, co.id, co.name
FROM credit cr
INNER JOIN course co ON cr.course_code = co.code
INNER JOIN course_providers cp ON cp.coursecode = co.id
@@ -245,6 +245,27 @@ const getOrgStats = (stats, orgId) =>
},
}
const getOrgsOfOpenUniReqularCounterparts = async codes => {
const rows = await sequelize.query(
`
SELECT co.id, co.code, cp.organizationcode, org.code AS organization
FROM course co, course_providers cp, organization org
WHERE co.code IN(:codes)
AND co.id = cp.coursecode
AND org.id = cp.organizationcode;
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { codes },
}
)
return rows.reduce((acc, { code, organization }) => {
acc[code] = organization
return acc
}, {})
}
const calculateStatusStatistics = async (unixMillis, showByYear) => {
const YEAR_TO_MILLISECONDS = 31556952000
/* Memoize parses booleans into strings... */
@@ -314,6 +335,21 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
Organization.findAll(),
])
const isOpenUniCourseCode = code => code.match(/^AY?(.+?)(?:en|fi|sv)?$/)
const unifyOpenUniversity = code => {
const regexresult = isOpenUniCourseCode(code)
if (!regexresult) return code
return regexresult[1]
}
const fromAyToNormalCourses = [
...new Set(_.flatten(yearlyOpenUniAccCredits).map(c => unifyOpenUniversity(c.code))),
...new Set(_.flatten(yearlyOpenUniTotalCredits).map(c => unifyOpenUniversity(c.code))),
]
const orgsOfOpenUniReqularCounterparts = await getOrgsOfOpenUniReqularCounterparts(fromAyToNormalCourses)
const facultyProgrammes = facultiesAndProgrammesForTrends
const yearlyOrgStatPromises = yearRange.map(async year => {
@@ -380,7 +416,6 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
}, {})
programmeToFaculties['H906'] = ['H906']
programmeToFaculties['H930'] = ['H930']
const providerToProgramme = elementDetails.reduce((res, curr) => {
const [p] = mapToProviders([curr.code])
@@ -418,17 +453,64 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return _.mergeWith(a, b, sumMerge)
}
const refactoredOpenUniCredits = [..._.flatten(yearlyOpenUniAccCredits), ..._.flatten(yearlyOpenUniTotalCredits)].map(
ele => {
const org = orgsOfOpenUniReqularCounterparts[unifyOpenUniversity(ele.code)]
return {
...ele,
organizationcode: org ? org : 'H930',
}
}
)
/* Calculate course level stats and group by providers */
const coursesGroupedByProvider = Object.entries(
_.groupBy(
[
..._.flatten(yearlyAccCredits),
..._.flatten(yearlyTotalCredits),
..._.flatten(yearlyOpenUniAccCredits),
..._.flatten(yearlyOpenUniTotalCredits),
],
'organizationcode'
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'organizationcode')
).reduce((acc, [organizationcode, courseCredits]) => {
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
(acc, [courseCode, yearlyInstances]) => {
acc[courseCode] = {
yearly: {},
name: yearlyInstances[0].name,
type: 'course',
code: courseCode,
}
const array = _.range(2017, startYear + 1)
array.forEach(year => {
acc[courseCode]['yearly'][year] = {}
acc[courseCode]['yearly'][year]['acc'] = 0
acc[courseCode]['yearly'][year]['accStudents'] = 0
acc[courseCode]['yearly'][year]['total'] = undefined
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
})
yearlyInstances.forEach(instance => {
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
if (instance.acc !== undefined) {
acc[courseCode]['yearly'][instance.year]['acc'] = instance.acc
acc[courseCode]['yearly'][instance.year]['accStudents'] = Number(instance.students)
} else {
acc[courseCode]['yearly'][instance.year]['total'] = instance.total
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
}
})
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
acc[courseCode]['previousStudents'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'accStudents']) || 0
return acc
},
{}
)
return acc
}, {})
const openUniCoursesGroupedByProvider = Object.entries(
_.groupBy(refactoredOpenUniCredits, 'organizationcode')
).reduce((acc, [organizationcode, courseCredits]) => {
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
(acc, [courseCode, yearlyInstances]) => {
@@ -477,7 +559,7 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
const programme = providerToProgramme[organizationcode]
if (programme && programme.code) {
if (programme.code === 'H906' || programme.code === 'H930') {
if (programme.code === 'H906') {
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
@@ -519,6 +601,34 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return acc
}, {})
const openUniGroupedByProgramme = Object.entries(openUniCoursesGroupedByProvider).reduce(
(acc, [organizationcode, courses]) => {
const programme = providerToProgramme[organizationcode]
if (programme && programme.code) {
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
acc[programme.code] = {
type: 'programme',
code: programme.code,
name: programme.name,
drill: courses,
yearly: _.mergeWith({}, ...yearlyValues, mergele),
current: _.sumBy(courseValues, 'current') | undefined,
previous: _.sumBy(courseValues, 'previous') | undefined,
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
}
return acc
}
return acc
},
{}
)
/* Group programmes into faculties and calculate faculty level stats */
const groupedByFaculty = Object.entries(groupedByProgramme).reduce((acc, [programmeCode, programmeStats]) => {
const facultyCodes = programmeToFaculties[programmeCode]
@@ -569,7 +679,31 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return acc
}, {})
return groupedByFaculty
const openUniGroupedByFaculty = Object.entries(openUniGroupedByProgramme).reduce(
(acc, [programmeCode, programmeStats]) => {
const facultyCode = 'H930'
if (!acc[facultyCode]) {
acc[facultyCode] = {
type: 'faculty',
code: facultyCode,
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly: {},
current: 0,
previous: 0,
}
}
acc[facultyCode]['drill'][programmeCode] = programmeStats
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
acc[facultyCode]['current'] += programmeStats.current
acc[facultyCode]['previous'] += programmeStats.previous
return acc
},
{}
)
return { ...groupedByFaculty, ...openUniGroupedByFaculty }
}
const getStatus = async (unixMillis, showByYear, doRefresh = false) => {
+72 -22583
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -6,6 +6,7 @@
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build"
},
"dependencies": {
"@reduxjs/toolkit": "^1.6.2",
"@sentry/browser": "^6.9.0",
"@sentry/tracing": "^6.11.0",
"axios": "^0.21.1",
@@ -22,18 +23,14 @@
"qs": "^6.10.1",
"query-string": "^6.14.1",
"react": "^16.14.0",
"react-context-devtool": "^2.0.3",
"react-datetime": "^3.0.4",
"react-dom": "^16.14.0",
"react-highcharts": "^16.1.0",
"react-markdown": "^6.0.2",
"react-redux": "^7.2.4",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-scroll-to-component": "^1.0.2",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^2.0.3",
"unfuck-spa-shibboleth-session": "^3.1.3",
-5
View File
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
import { LanguageProvider } from './components/LanguagePicker/useLanguage'
@@ -9,8 +8,4 @@ const CommonContext = ({ children }) => (
</LanguageProvider>
)
CommonContext.propTypes = {
children: PropTypes.node.isRequired,
}
export default CommonContext
+23 -16
View File
@@ -2,6 +2,7 @@ import axios from 'axios'
import * as Sentry from '@sentry/browser'
import { getMocked, setMocking } from '../common'
import { apiBasePath, isDev } from '../conf'
import { loginPrefix, logoutPrefix } from '../redux/auth'
const getHeaders = () => {
// Set up dev user for development environment, mimicking production admin user
@@ -19,16 +20,16 @@ const getHeaders = () => {
export const api = axios.create({ baseURL: apiBasePath, headers: getHeaders() })
const types = {
attempt: prefix => `${prefix}ATTEMPT`,
failure: prefix => `${prefix}FAILURE`,
success: prefix => `${prefix}SUCCESS`,
export const actionSuffixes = {
attempt: `ATTEMPT`,
failure: `FAILURE`,
success: `SUCCESS`,
}
export const actionTypes = prefix => ({
attempt: types.attempt(prefix),
failure: types.failure(prefix),
success: types.success(prefix),
attempt: `${prefix}${actionSuffixes.attempt}`,
failure: `${prefix}${actionSuffixes.failure}`,
success: `${prefix}${actionSuffixes.success}`,
})
export const logout = async () => {
@@ -69,7 +70,8 @@ export const callController = (route, prefix, data, method = 'get', query, param
params,
onProgress,
}
return { type: `${prefix}ATTEMPT`, requestSettings }
const { attempt } = actionTypes(prefix)
return { type: attempt, requestSettings }
}
const handleError = (err, actionHistory = []) => {
@@ -90,20 +92,24 @@ export const handleRequest = store => next => async action => {
const { requestSettings } = action
if (requestSettings) {
const { route, method, data, prefix, query, params, onProgress } = requestSettings
const { success, failure } = actionTypes(prefix)
try {
const res = await callApi(route, method, data, params, 0, onProgress)
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
store.dispatch({ type: success, response: res.data, query })
} catch (e) {
store.dispatch({ type: `${prefix}FAILURE`, response: e, query })
store.dispatch({ type: failure, response: e, query })
handleError(e, store.getState().actionHistory)
}
}
}
export const handleAuth = store => next => async action => {
const loginTypes = actionTypes(loginPrefix)
const logoutTypes = actionTypes(logoutPrefix)
next(action)
const { type } = action
if (type === 'LOGIN_ATTEMPT') {
if (type === loginTypes.attempt) {
const { success, failure } = loginTypes
try {
let token
const mocked = getMocked()
@@ -118,17 +124,18 @@ export const handleAuth = store => next => async action => {
...api.defaults.headers.common,
'x-access-token': token,
}
store.dispatch({ type: 'LOGIN_SUCCESS', token })
store.dispatch({ type: success, token })
} catch (err) {
store.dispatch({ type: 'LOGIN_FAILURE' })
store.dispatch({ type: failure })
handleError(err, store.getState().actionHistory)
}
} else if (type === 'LOGOUT_ATTEMPT') {
} else if (type === loginTypes.attempt) {
const { success, failure } = logoutTypes
try {
await logout()
store.dispatch({ type: 'LOGOUT_SUCCESSFUL' })
store.dispatch({ type: success })
} catch (err) {
store.dispatch({ type: 'LOGOUT_FAILURE' })
store.dispatch({ type: failure })
handleError(err, store.getState().actionHistory)
}
}
+6 -28
View File
@@ -1,9 +1,7 @@
import React, { useEffect, useState } from 'react'
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
import moment from 'moment'
import { connect } from 'react-redux'
import propTypes from 'prop-types'
import { isEqual } from 'lodash'
import { useSelector } from 'react-redux'
import { images, getUserRoles, checkUserAccess } from '../common'
import { useTitle } from '../common/hooks'
import { builtAt } from '../conf'
@@ -49,10 +47,11 @@ const OodiToOodikone = () => {
)
}
const FrontPage = props => {
const { userRoles, rights } = props
const FrontPage = () => {
const { roles, rights } = useSelector(state => state.auth.token)
const userRoles = getUserRoles(roles)
useTitle()
const showItems = {
populations: userRoles.includes('admin') || rights.length !== 0,
studyProgramme: userRoles.includes('admin') || rights.length !== 0,
@@ -122,25 +121,4 @@ const FrontPage = props => {
)
}
FrontPage.propTypes = {
userRoles: propTypes.arrayOf(propTypes.string),
rights: propTypes.arrayOf(propTypes.string),
}
FrontPage.defaultProps = {
userRoles: [],
rights: [],
}
const mapStateToProps = ({
auth: {
token: { roles, rights },
},
}) => ({
userRoles: getUserRoles(roles),
rights,
})
export default connect(mapStateToProps, null, null, {
areStatePropsEqual: isEqual,
})(FrontPage)
export default FrontPage
@@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux'
import { Button, Loader, Header, Accordion, Divider, Segment } from 'semantic-ui-react'
import { useHistory } from 'react-router-dom'
import scrollToComponent from 'react-scroll-to-component'
import { getCustomPopulation } from '../../redux/populations'
import { getCustomPopulation, clearPopulations } from '../../redux/populations'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import StyledMessage from './StyledMessage'
import { getTextIn } from '../../common'
@@ -150,11 +150,17 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
}
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
const dispatch = useDispatch()
const handleBack = () => {
dispatch(clearPopulations())
history.push('/studyguidancegroups')
}
return (
<>
<div className="segmentContainer">
<Segment className="contentSegment">
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
<Button icon="arrow circle left" content="Back" onClick={handleBack} />
<Divider />
<Header size="medium">{getTextIn(groupName, language)}</Header>
</Segment>
@@ -174,9 +180,8 @@ const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
const { data: population, pending } = useSelector(({ populations }) => populations)
useEffect(() => {
if (!group) return
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
if (groupStudentNumbers.length === 0) return
if (groupStudentNumbers.length === 0 || population?.students?.length > 0) return
dispatch(getCustomPopulation({ studentnumberlist: groupStudentNumbers, usingStudyGuidanceGroups: true }))
dispatch(
getCustomPopulationCoursesByStudentnumbers({
@@ -31,6 +31,10 @@ const prettifyCamelCase = str => {
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 AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const dispatch = useDispatch()
@@ -65,7 +69,14 @@ const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
renderYear={(props, selectableYear) => <td {...props}>{selectableYear}</td>}
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'))}
@@ -90,8 +101,17 @@ const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const TagCell = ({ tagName, group, studyProgrammes }) => {
const [showEdit, toggleEdit] = useToggle()
const value = group.tags?.[tagName]
const text = tagName === 'studyProgramme' ? studyProgrammes.find(p => p.value === value)?.text : value
const getText = () => {
switch (tagName) {
case 'studyProgramme':
return studyProgrammes.find(p => p.value === group.tags?.[tagName])?.text
case 'year':
return startYearToAcademicYear(group.tags?.[tagName])
default:
throw Error(`Wrong tagname: ${tagName}`)
}
}
const text = getText()
return group.tags?.[tagName] && !showEdit ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
@@ -138,10 +158,22 @@ const StudyGuidanceGroupOverview = () => {
},
},
},
{
key: 'students',
title: 'students',
getRowVal: group => group.members?.length || 0,
getRowContent: group => group.members?.length || 0,
cellProps: {
style: {
padding: '0',
textAlign: 'center',
},
},
},
{
key: 'studyProgramme',
title: 'Study programme',
getRowVal: () => 'studyProgramme',
getRowVal: group => group.tags?.studyProgramme,
headerProps: { onClick: null, sorted: null },
getRowContent: group => (
<TagCell tagName="studyProgramme" studyProgrammes={studyProgrammesFilteredForDropdown} group={group} />
@@ -149,8 +181,8 @@ const StudyGuidanceGroupOverview = () => {
},
{
key: 'associatedyear',
title: 'Associated year',
getRowVal: () => 'associatedYear',
title: 'Associated starting academic year',
getRowVal: group => group.tags?.year,
headerProps: { onClick: null, sorted: null },
getRowContent: group => <TagCell tagName="year" group={group} />,
},
+2 -9
View File
@@ -4,18 +4,14 @@ import { Provider } from 'react-redux'
import 'semantic-ui-css/semantic.min.css'
import 'react-datetime/css/react-datetime.css'
import './styles/custom.css'
import { debugContextDevtool } from 'react-context-devtool'
import store from './store'
import store from './redux'
import ErrorBoundary from './components/ErrorBoundary'
import Main from './components/Main'
import CommonContext from './CommonContext'
import initializeSentry from './util/sentry'
import { isProduction } from './conf'
initializeSentry()
const container = document.getElementById('root')
ReactDOM.render(
<Provider store={store}>
<CommonContext>
@@ -24,8 +20,5 @@ ReactDOM.render(
</ErrorBoundary>
</CommonContext>
</Provider>,
container
document.getElementById('root')
)
// Best not to use this in production as the performance impact appears to be quite high.
debugContextDevtool(container, { disable: isProduction })
+3
View File
@@ -1,5 +1,8 @@
import { decodeToken } from '../common'
export const loginPrefix = 'LOGIN_'
export const logoutPrefix = 'LOGOUT_'
export const login = () => ({
type: 'LOGIN_ATTEMPT',
})
@@ -1,85 +0,0 @@
import { actionTypes } from '../../apiConnection'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
export default createReducer
@@ -0,0 +1,33 @@
import { actionSuffixes } from '../../apiConnection'
export const matcherReducers = [
{
matcher: action => action.type.endsWith(actionSuffixes.attempt),
reducer(state) {
state.error = false
state.pending = true
},
},
{
matcher: action => action.type.endsWith(actionSuffixes.failure),
reducer(state) {
state.error = true
state.pending = false
},
},
{
matcher: action => action.type.endsWith(actionSuffixes.success),
reducer(state) {
state.error = false
state.pending = false
},
},
]
export const defaultReducer = () => {}
export const initialState = {
pending: false,
error: false,
data: [],
}
+53 -43
View File
@@ -1,4 +1,5 @@
import { combineReducers } from 'redux'
import { configureStore } from '@reduxjs/toolkit'
import { handleRequest, handleAuth } from '../apiConnection'
import actionHistory from './actionHistory'
import users from './users'
import populations from './populations'
@@ -41,46 +42,55 @@ import customPopulationSearch from './customPopulationSearch'
import coolDataScience from './coolDataScience'
import studyGuidanceGroups from './studyGuidanceGroups'
export default combineReducers({
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationSelectedStudentCourses,
populationProgrammes,
populationProgrammesUnfiltered,
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
studyProgrammeBachelors,
faculties,
units,
students,
errors,
courses,
courseInstances,
graphSpinner,
settings,
teachers,
providers,
semesters,
teacherStatistics,
teachersTop,
teachersTopCategories,
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
tags,
tagstudent,
auth,
singleCourseStats,
userAccessEmail,
customPopulationSearch,
coolDataScience,
studyGuidanceGroups,
export default configureStore({
reducer: {
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationSelectedStudentCourses,
populationProgrammes,
populationProgrammesUnfiltered,
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
studyProgrammeBachelors,
faculties,
units,
students,
errors,
courses,
courseInstances,
graphSpinner,
settings,
teachers,
providers,
semesters,
teacherStatistics,
teachersTop,
teachersTopCategories,
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
tags,
tagstudent,
auth,
singleCourseStats,
userAccessEmail,
customPopulationSearch,
coolDataScience,
studyGuidanceGroups,
},
// oodikone is currently too heavy for other middlewares than thunk, but
// feel free to take use them at some point if possible
middleware: getDefaultMiddleware => [
...getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }),
handleRequest,
handleAuth,
],
})
@@ -1,80 +1,37 @@
import { createReducer } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
import { initialState, matcherReducers, defaultReducer } from './common'
const baseUrl = '/studyguidancegroups'
const getPrefix = 'GET_STUDY_GUIDANCE_GROUPS_'
const getTypes = {
...actionTypes(getPrefix),
}
export const getStudyGuidanceGroups = () => {
const route = '/studyguidancegroups'
return callController(route, getPrefix)
}
const changeTagsPrefix = 'CHANGE_STUDY_GUIDANCE_GROUP_TAGS_'
const changeTagsTypes = {
...actionTypes(changeTagsPrefix),
}
export const changeStudyGuidanceGroupTags = (groupId, tags) => {
const route = `/studyguidancegroups/${groupId}/tags`
return callController(route, changeTagsPrefix, tags, 'put')
}
const initialState = {
pending: false,
error: false,
data: [],
}
export const getStudyGuidanceGroups = () => callController(baseUrl, getPrefix)
const reducer = (state = initialState, action) => {
switch (action.type) {
case getTypes.attempt:
return {
...state,
error: false,
pending: true,
}
case getTypes.failure:
return {
...state,
pending: false,
error: true,
}
case getTypes.success:
return {
...state,
pending: false,
error: false,
data: action.response,
}
case changeTagsTypes.attempt:
return {
...state,
error: false,
pending: false,
}
case changeTagsTypes.failure:
return {
...state,
pending: false,
error: true,
}
case changeTagsTypes.success:
return {
...state,
pending: false,
error: false,
data: state.data.map(group =>
group.id === action.response.studyGuidanceGroupId
? {
...state.data.find(group => group.id === action.response.studyGuidanceGroupId),
tags: (({ studyGuidanceGroupId, ...rest }) => rest)(action.response), // omit id
}
: group
),
}
default:
return state
}
}
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
+83 -2
View File
@@ -1,5 +1,86 @@
import createReducer from './common/crudreducer'
import { callController } from '../apiConnection/index'
import { callController, actionTypes } from '../apiConnection/index'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
const prefix = 'PROGRAMME_THESIS_'
@@ -1,4 +1,4 @@
import { combineReducers } from 'redux'
import { combineReducers } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
const CLEAR_USER_ACCESS_ERRORS = 'CLEAR_USER_ACCESS_ERRORS'
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
import { sortBy, flatten } from 'lodash'
const courseStatsSelector = state => state.courseStats.data
+1 -1
View File
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
import { byDateDesc, reformatDate } from '../common'
const getData = obj => obj
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
const getPopulations = state => state.populations
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
const associationsSelector = state => state.populationProgrammesUnfiltered.data
+1 -1
View File
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
import { reformatDate } from '../common'
const getStudents = students => students.data
-10
View File
@@ -1,10 +0,0 @@
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import reducers from './redux'
import { handleRequest, handleAuth } from './apiConnection'
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const store = createStore(reducers, composeEnhancers(applyMiddleware(thunk, handleRequest, handleAuth)))
export default store