diff --git a/.vscode/launch.json b/.vscode/launch.json index c4b5da161..1f770b29a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,7 +27,7 @@ "args": ["start"], "port": 9229, "restart": true, - "protocol": "inspector", + "protocol": "legacy", "console": "integratedTerminal", "sourceMaps": true }, diff --git a/client/craco.config.js b/client/craco.config.js index df42380ab..4c92a9403 100644 --- a/client/craco.config.js +++ b/client/craco.config.js @@ -50,7 +50,7 @@ module.exports = { (plugin) => !(plugin instanceof ModuleScopePlugin) ); - config.output.publicPath = isEnvProduction ? '#{ROUTE_PREFIX}' : ''; + config.output.publicPath = isEnvProduction ? '/#{ROUTE_PREFIX}' : '/'; config.plugins = config.plugins.map((plugin) => { if (!(plugin instanceof HtmlWebpackPlugin)) { return plugin; diff --git a/client/src/components/CustomSelect.tsx b/client/src/components/CustomSelect.tsx index c7f01bc74..fac6e3d09 100644 --- a/client/src/components/CustomSelect.tsx +++ b/client/src/components/CustomSelect.tsx @@ -1,6 +1,7 @@ import { Checkbox, Chip, + CircularProgress, FormControl, FormHelperText, InputLabel, @@ -30,20 +31,29 @@ const useStyles = makeStyles((theme: Theme) => }) ); -type ItemToString = (item: T) => string; +export interface ItemDisabledInformation { + isDisabled: boolean; + reason?: string; +} + +export type ItemToString = (item: T) => string; +export type ItemToBoolean = (item: T) => boolean; +export type IsItemDisabledFunction = (item: T) => ItemDisabledInformation; export interface CustomSelectProps extends Omit { name?: string; label: string; - emptyPlaceholder: string; + emptyPlaceholder?: string; nameOfNoneItem?: string; helperText?: React.ReactNode; items: T[]; itemToString: ItemToString; itemToValue: ItemToString; - isItemSelected?: (item: T) => boolean; + isItemDisabled?: IsItemDisabledFunction; + isItemSelected?: ItemToBoolean; FormControlProps?: Omit; + showLoadingIndicator?: boolean; } export type OnChangeHandler = CustomSelectProps<{}>['onChange']; @@ -93,14 +103,16 @@ function CustomSelect({ itemToString, itemToValue, nameOfNoneItem, + isItemDisabled, isItemSelected, multiple, FormControlProps, classes: classesFromProps, + showLoadingIndicator, ...other }: CustomSelectProps): JSX.Element { if (multiple && !isItemSelected) { - console.error( + console.warn( `[CustomSelect] -- You have set the Select '${name}' to allow multiple selections but you have not passed an isItemSelected function via props. Therefore Checkboxes won't be shown for the items.` ); } @@ -128,9 +140,16 @@ function CustomSelect({ return ( - {label} + + {label} + diff --git a/client/src/components/StudentTableRow.tsx b/client/src/components/StudentTableRow.tsx deleted file mode 100644 index b42cf7e36..000000000 --- a/client/src/components/StudentTableRow.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { - createStyles, - LinearProgress, - makeStyles, - TableCell, - Theme, - withStyles, -} from '@material-ui/core'; -import { lighten } from '@material-ui/core/styles'; -import { Account as PersonIcon } from 'mdi-material-ui'; -import React, { useEffect, useState } from 'react'; -import { ScheinCriteriaSummary } from 'shared/model/ScheinCriteria'; -import { useAxios } from '../hooks/FetchingService'; -import { Student } from '../model/Student'; -import EntityListItemMenu from './list-item-menu/EntityListItemMenu'; -import PaperTableRow, { PaperTableRowProps } from './PaperTableRow'; - -interface Props extends PaperTableRowProps { - student: Student; - onEditStudentClicked: (student: Student) => void; - onDeleteStudentClicked: (student: Student) => void; -} - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - flexGrow: 1, - }, - margin: { - width: '100%', - margin: theme.spacing(1), - }, - }) -); - -const BorderLinearProgress = withStyles({ - root: { - height: 10, - backgroundColor: lighten('#ff6c5c', 0.5), - }, - bar: { - borderRadius: 20, - backgroundColor: '#ff6c5c', - }, -})(LinearProgress); - -function StudentTableRow({ - student, - onEditStudentClicked, - onDeleteStudentClicked, - ...rest -}: Props): JSX.Element { - const classes = useStyles(); - const { firstname, lastname, team } = student; - const { getScheinCriteriaSummaryOfStudent } = useAxios(); - - const [CriteriaResult, setCriteriaResult] = useState( - undefined - ); - - useEffect(() => { - getScheinCriteriaSummaryOfStudent(student.id).then((response) => setCriteriaResult(response)); - }, [getScheinCriteriaSummaryOfStudent, student]); - - return ( - <> - onEditStudentClicked(student)} - onDeleteClicked={() => onDeleteStudentClicked(student)} - /> - } - {...rest} - > - -
- {CriteriaResult && ( - - )} -
-
-
- - ); -} - -export default StudentTableRow; diff --git a/client/src/components/TableWithPadding.tsx b/client/src/components/TableWithPadding.tsx index 46d353da5..7d262dc0c 100644 --- a/client/src/components/TableWithPadding.tsx +++ b/client/src/components/TableWithPadding.tsx @@ -24,7 +24,7 @@ const useStyles = makeStyles((theme: Theme) => export interface TableWithPaddingProps extends TableProps { items: T[]; - createRowFromItem: (item: T) => React.ReactNode; + createRowFromItem: (item: T, idx: number) => React.ReactNode; placeholder?: string; } @@ -50,7 +50,7 @@ function TableWithPadding({ {idx !== 0 && } - {createRowFromItem(item)} + {createRowFromItem(item, idx)} ))} diff --git a/client/src/components/date-picker/DatePicker.tsx b/client/src/components/date-picker/DatePicker.tsx new file mode 100644 index 000000000..fcadf036f --- /dev/null +++ b/client/src/components/date-picker/DatePicker.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { KeyboardDatePicker, KeyboardDatePickerProps } from '@material-ui/pickers'; +import { DateTimeFormatOptions, DateTime } from 'luxon'; + +const DATE_FORMAT: DateTimeFormatOptions = { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', +}; + +export type CustomDatePickerProps = KeyboardDatePickerProps; + +function CustomDatePicker(props: CustomDatePickerProps): JSX.Element { + const labelFunc = (date: DateTime | null, invalidLabel: string) => { + if (!date || !date.isValid) { + return invalidLabel; + } + + return date.toLocaleString(DATE_FORMAT); + }; + + return ( + + ); +} + +export default CustomDatePicker; diff --git a/client/src/components/drawer/Drawer.tsx b/client/src/components/drawer/Drawer.tsx deleted file mode 100644 index 145a85342..000000000 --- a/client/src/components/drawer/Drawer.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { - Divider, - Drawer as MuiDrawer, - List, - ListSubheader, - Typography, - Link, -} from '@material-ui/core'; -import { DrawerProps } from '@material-ui/core/Drawer'; -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import { OpenInNew as ExternalLinkIcon } from 'mdi-material-ui'; -import clsx from 'clsx'; -import React, { useMemo, useState, useEffect } from 'react'; -import { useLogin } from '../../hooks/LoginService'; -import { ROUTES, RouteType } from '../../routes/Routing.routes'; -import DrawerListItem from './components/DrawerListItem'; -import TutorialSubList from './components/TutorialSubList'; -import { Role } from 'shared/model/Role'; -import { getVersionOfApp } from '../../hooks/fetching/Information'; - -const DRAWER_WIDTH_OPEN = 260; -const DRAWER_WIDTH_CLOSED = 56; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - drawer: { - maxWidth: DRAWER_WIDTH_OPEN, - flexShrink: 0, - whiteSpace: 'nowrap', - overflow: 'hidden', - }, - drawerOpen: { - width: DRAWER_WIDTH_OPEN, - transition: theme.transitions.create('width', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.enteringScreen, - }), - }, - drawerClose: { - width: DRAWER_WIDTH_CLOSED, - transition: theme.transitions.create('width', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.leavingScreen, - }), - }, - drawerList: { - paddingBottom: theme.spacing(4), - overflowY: 'auto', - overflowX: 'hidden', - ...theme.mixins.scrollbar(4), - }, - displayNone: { - display: 'none', - }, - toolbar: { - ...theme.mixins.toolbar, - }, - version: { - position: 'absolute', - bottom: theme.spacing(1), - left: theme.spacing(1), - right: theme.spacing(1), - textAlign: 'center', - }, - }) -); - -function isRoleMatching(userRoles: Role[], routeRoles: Role[] | 'all'): boolean { - if (routeRoles === 'all') { - return true; - } - - return routeRoles.findIndex((role) => userRoles.includes(role)) !== -1; -} - -function filterRoutes(userRoles: Role[]) { - const userRoutesWithoutTutorialRoutes: RouteType[] = []; - const tutorialRoutes: RouteType[] = []; - const substituteRoutes: RouteType[] = []; - const managementRoutes: RouteType[] = []; - - for (const route of ROUTES) { - if (!route.isInDrawer) { - continue; - } - - if (!isRoleMatching(userRoles, route.roles)) { - continue; - } - - if ( - Array.isArray(route.roles) && - (route.roles.indexOf(Role.ADMIN) !== -1 || route.roles.indexOf(Role.EMPLOYEE) !== -1) - ) { - managementRoutes.push(route); - } else { - if (!route.isTutorialRelated) { - userRoutesWithoutTutorialRoutes.push(route); - } - - if (route.isTutorialRelated) { - tutorialRoutes.push(route); - } - - if (route.isAccessibleBySubstitute) { - if (!route.isTutorialRelated) { - console.error( - `[DRAWER] -- The route ${route.path} is accessible by substitutes but NOT tutorial related. It has to be both to be present in the Drawer.` - ); - } else { - substituteRoutes.push(route); - } - } - } - } - - return { userRoutesWithoutTutorialRoutes, tutorialRoutes, substituteRoutes, managementRoutes }; -} - -function Drawer({ - className, - onClose, - PaperProps, - classes: PaperClasses, - open, - ...other -}: DrawerProps): JSX.Element { - const classes = useStyles(); - const { userData } = useLogin(); - const [version, setVersion] = useState(undefined); - - if (!userData) { - throw new Error('Drawer without a user should be rendered. This is forbidden.'); - } - - const { tutorials, tutorialsToCorrect, substituteTutorials } = userData; - const { - userRoutesWithoutTutorialRoutes, - tutorialRoutes, - substituteRoutes, - managementRoutes, - } = useMemo(() => filterRoutes(userData.roles), [userData.roles]); - - useEffect(() => { - getVersionOfApp() - .then((version) => setVersion(version)) - .catch(() => setVersion(undefined)); - }, []); - - return ( - -
- - - {userRoutesWithoutTutorialRoutes.map((route) => ( - - ))} - - {tutorials.map((tutorial) => ( - - - - - - ))} - - {tutorialsToCorrect.map((tutorial) => ( - - - - - isRoleMatching([Role.CORRECTOR], route.roles) - )} - isDrawerOpen={!!open} - isTutorialToCorrect - /> - - ))} - - {substituteTutorials.map((tutorial) => ( - - - - - - ))} - - {managementRoutes.length > 0 && ( - <> - - - Verwaltung - - {managementRoutes.map((route) => ( - - ))} - - )} - - - {version && ( - - {open && <>Version: } - - - {version} - - - - )} - - ); -} - -export default Drawer; diff --git a/client/src/components/drawer/components/DrawerListItem.tsx b/client/src/components/drawer/components/DrawerListItem.tsx deleted file mode 100644 index 08bb6e1b9..000000000 --- a/client/src/components/drawer/components/DrawerListItem.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { ListItem, ListItemIcon, ListItemText } from '@material-ui/core'; -import { ListItemProps } from '@material-ui/core/ListItem'; -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import { SvgIconProps } from '@material-ui/core/SvgIcon'; -import clsx from 'clsx'; -import React from 'react'; -import { useRouteMatch } from 'react-router'; -import { renderLink } from './renderLink'; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - currentPath: { - color: theme.palette.secondary.main, - }, - }) -); - -interface DrawerListItemProps extends ListItemProps { - path: string; - icon: React.ComponentType; - text: string; -} - -function getTargetLink(path: string): string { - if (!path.endsWith('?')) { - return path; - } - - const idx = path.lastIndexOf('/'); - - return path.substring(0, idx); -} - -function DrawerListItem({ path, icon: Icon, text, ...other }: DrawerListItemProps): JSX.Element { - const classes = useStyles(); - const isCurrentPath = useRouteMatch(path); - - return ( - - - - - {text} - - ); -} - -export default DrawerListItem; diff --git a/client/src/components/drawer/components/TutorialSubList.tsx b/client/src/components/drawer/components/TutorialSubList.tsx deleted file mode 100644 index b76024404..000000000 --- a/client/src/components/drawer/components/TutorialSubList.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Collapse, List, ListItem, ListItemText } from '@material-ui/core'; -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import { ChevronUp as ExpandLessIcon, ChevronDown as ExpandMoreIcon } from 'mdi-material-ui'; -import { - AccountConvert as SubstituteTutorialIcon, - CheckboxMarkedCircleOutline as TutorialToCorrectIcon, -} from 'mdi-material-ui'; -import React, { useEffect, useState } from 'react'; -import { RouteType } from '../../../routes/Routing.routes'; -import DrawerListItem from './DrawerListItem'; -import { getTutorialRelatedPath } from '../../../routes/Routing.helpers'; -import { TutorialInEntity } from '../../../../../server/src/shared/model/Common'; -import { Tutorial } from '../../../model/Tutorial'; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - primary: { - display: 'flex', - alignItems: 'center', - }, - icon: { - marginLeft: theme.spacing(1), - }, - }) -); - -interface TutorialSubListProps { - tutorial: TutorialInEntity; - tutorialRoutes: RouteType[]; - isDrawerOpen: boolean; - isTutorialToCorrect?: boolean; - isSubstituteTutorial?: boolean; -} - -function TutorialSubList({ - tutorial, - tutorialRoutes, - isDrawerOpen, - isTutorialToCorrect, - isSubstituteTutorial, -}: TutorialSubListProps) { - const classes = useStyles(); - const [isOpen, setOpen] = useState(true); - - function handleClick() { - if (isDrawerOpen) { - setOpen(!isOpen); - } - } - - useEffect(() => { - setOpen(true); - }, [isDrawerOpen]); - - return ( - <> - - - {Tutorial.getDisplayString(tutorial)}{' '} - {isSubstituteTutorial && ( - - )} - {isTutorialToCorrect && ( - - )} - - ) : ( - `${tutorial.slot}` - ) - } - classes={{ - primary: classes.primary, - }} - /> - {isDrawerOpen && (isOpen ? : )} - - - - {tutorialRoutes.map((route) => { - return ( - - ); - })} - - - - ); -} - -export default TutorialSubList; diff --git a/client/src/components/forms/FormikBaseForm.tsx b/client/src/components/forms/FormikBaseForm.tsx index 060c7cdf2..b38147ec4 100644 --- a/client/src/components/forms/FormikBaseForm.tsx +++ b/client/src/components/forms/FormikBaseForm.tsx @@ -96,12 +96,7 @@ function FormikBaseForm({
- {enableDebug && ( - - )} + {enableDebug && } )} diff --git a/client/src/components/forms/UserForm.tsx b/client/src/components/forms/UserForm.tsx index 6916a43a5..ed1b8ceec 100644 --- a/client/src/components/forms/UserForm.tsx +++ b/client/src/components/forms/UserForm.tsx @@ -37,7 +37,12 @@ interface Props extends Omit, CommonlyUsedFor onSubmit: UserFormSubmitCallback; } -function generateTemporaryPassword(): string { +interface HasName { + firstname: string; + lastname: string; +} + +export function generateTemporaryPassword(): string { return pwGenerator.generate({ length: 16, numbers: true, @@ -46,6 +51,14 @@ function generateTemporaryPassword(): string { }); } +export function generateUsernameFromName({ firstname, lastname }: HasName): string { + const charsFromLastname: string = lastname.substr(0, 6); + const charsFromFirstname: string = firstname.charAt(0) + firstname.charAt(firstname.length - 1); + const username: string = (charsFromLastname + charsFromFirstname).toLowerCase(); + + return username; +} + function getInitialFormState(user?: IUser): UserFormState { if (!user) { return { @@ -114,12 +127,10 @@ function UserForm({ const initialFormState: UserFormState = getInitialFormState(user); function generateUsername( - { firstname, lastname }: { firstname: string; lastname: string }, + name: HasName, setFieldValue: FormikHelpers['setFieldValue'] ) { - const charsFromLastname: string = lastname.substr(0, 6); - const charsFromFirstname: string = firstname.charAt(0) + firstname.charAt(firstname.length - 1); - const username: string = (charsFromLastname + charsFromFirstname).toLowerCase(); + const username: string = generateUsernameFromName(name); setFieldValue('username', username); } diff --git a/client/src/components/forms/components/FormikDatePicker.tsx b/client/src/components/forms/components/FormikDatePicker.tsx index 2816fa8d1..c1d265b1c 100644 --- a/client/src/components/forms/components/FormikDatePicker.tsx +++ b/client/src/components/forms/components/FormikDatePicker.tsx @@ -1,27 +1,32 @@ -import { DatePicker, DatePickerProps } from '@material-ui/pickers'; import { Field, FieldProps } from 'formik'; -import React from 'react'; +import React, { useState } from 'react'; +import CustomDatePicker, { CustomDatePickerProps } from '../../date-picker/DatePicker'; interface Props { name: string; } -type PropType = Props & Omit; +type PropType = Props & Omit; function FormikDatePicker({ name, className, ...other }: PropType): JSX.Element { + const [innerError, setInnerError] = useState(); + return ( {({ field, form, meta: { touched, error } }: FieldProps) => ( - form.setFieldValue(field.name, date, true)} - helperText={!!touched && error} - error={touched && !!error} + onChange={(date) => { + if (date && date.isValid) { + setInnerError(undefined); + form.setFieldValue(field.name, date, true); + } else { + setInnerError('Ungültiges Datum'); + } + }} + helperText={!!touched && (error || innerError)} + error={touched && (!!error || !!innerError)} className={className} inputVariant='outlined' /> diff --git a/client/src/components/forms/components/FormikDebugDisplay.tsx b/client/src/components/forms/components/FormikDebugDisplay.tsx index c5ceae6fe..ce60e691b 100644 --- a/client/src/components/forms/components/FormikDebugDisplay.tsx +++ b/client/src/components/forms/components/FormikDebugDisplay.tsx @@ -1,8 +1,8 @@ -import { FormikValues, FormikErrors } from 'formik'; +import { Paper, Portal, Typography } from '@material-ui/core'; +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import { useFormikContext } from 'formik'; import React, { useState } from 'react'; import { isDevelopment } from '../../../util/isDevelopmentMode'; -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; -import { Paper, Portal, Typography } from '@material-ui/core'; import CollapseButton from '../../CollapseButton'; const useStyles = makeStyles((theme: Theme) => @@ -35,20 +35,21 @@ const useStyles = makeStyles((theme: Theme) => ); interface Props { - values: FormikValues; - errors?: FormikErrors; + showErrors?: boolean; collapsed?: boolean; + disabled?: boolean; } function FormikDebugDisplay({ - values, - errors, + showErrors, collapsed: collapsedFromProps, + disabled, }: Props): JSX.Element | null { const classes = useStyles(); const [isCollapsed, setCollapsed] = useState(collapsedFromProps ?? true); + const { values, errors } = useFormikContext(); - if (!isDevelopment()) { + if (!isDevelopment() || disabled) { return null; } @@ -61,7 +62,7 @@ function FormikDebugDisplay({ Form values:
{JSON.stringify(values, null, 2)}
- {errors && ( + {showErrors && ( <> Form errors:
{JSON.stringify(errors, null, 2)} diff --git a/client/src/components/forms/components/FormikFilterableSelect.tsx b/client/src/components/forms/components/FormikFilterableSelect.tsx index 2a80defad..31cba6d3e 100644 --- a/client/src/components/forms/components/FormikFilterableSelect.tsx +++ b/client/src/components/forms/components/FormikFilterableSelect.tsx @@ -1,7 +1,7 @@ import { Checkbox, List, ListItem, ListItemIcon, ListItemText, TextField } from '@material-ui/core'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; import clsx from 'clsx'; -import { FieldArray, useFormikContext } from 'formik'; +import { FieldArray, useField } from 'formik'; import { deburr } from 'lodash'; import React, { useState } from 'react'; @@ -66,13 +66,11 @@ function FormikFilterableSelect({ }: Props): JSX.Element { const classes = useStyles(); const [filter, setFilter] = useState(''); - const { values } = useFormikContext(); + const [, meta] = useField(name); - if (!Array.isArray(values[name])) { + if (!Array.isArray(meta.value)) { throw new Error( - `FormikFilterableSelect -- The values object of the Formik form should be an array at property '${name}'. This is not the case. The current type is ${typeof values[ - name - ]}` + `FormikFilterableSelect -- The values object of the Formik form should be an array at property '${name}'. This is not the case. The current type is ${typeof meta.value}` ); } @@ -114,9 +112,9 @@ function FormikFilterableSelect({ key={itemValue} button onClick={() => { - const idx = values[name].indexOf(itemValue); + const idx = meta.value.indexOf(itemValue); if (idx === -1) { - arrayHelpers.insert(values[name].length - 1, itemValue); + arrayHelpers.insert(meta.value.length - 1, itemValue); } else { arrayHelpers.remove(idx); } diff --git a/client/src/components/forms/components/FormikMarkdownTextfield.tsx b/client/src/components/forms/components/FormikMarkdownTextfield.tsx index ef7fc0559..a2b0a2685 100644 --- a/client/src/components/forms/components/FormikMarkdownTextfield.tsx +++ b/client/src/components/forms/components/FormikMarkdownTextfield.tsx @@ -49,6 +49,7 @@ function FormikMarkdownTextfield({ name, className, ...other }: FormikTextFieldP const [isPreview, setPreview] = useState(false); const handleKeyDown: React.KeyboardEventHandler = (event) => { + // FIXME: Does this need to be in here? Can it use the useKeyboardShortcut() hook or better - can this be handled by the parent component? if (event.ctrlKey && event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); diff --git a/client/src/components/forms/components/FormikSelect.tsx b/client/src/components/forms/components/FormikSelect.tsx index f58181aa3..6211e621f 100644 --- a/client/src/components/forms/components/FormikSelect.tsx +++ b/client/src/components/forms/components/FormikSelect.tsx @@ -29,7 +29,7 @@ function FormikSelect({ onChange, name, helperText, ...other }: Props): JS {...other} {...field} onChange={handleChange(field.onChange)} - helperText={!!touched && error} + helperText={(!!touched && error) || helperText} error={touched && !!error} /> )} diff --git a/client/src/view/navigation-rail/NavigationRail.helper.ts b/client/src/components/navigation-rail/NavigationRail.helper.ts similarity index 100% rename from client/src/view/navigation-rail/NavigationRail.helper.ts rename to client/src/components/navigation-rail/NavigationRail.helper.ts diff --git a/client/src/view/navigation-rail/NavigationRail.tsx b/client/src/components/navigation-rail/NavigationRail.tsx similarity index 98% rename from client/src/view/navigation-rail/NavigationRail.tsx rename to client/src/components/navigation-rail/NavigationRail.tsx index 8838a8342..f94c0cd9d 100644 --- a/client/src/view/navigation-rail/NavigationRail.tsx +++ b/client/src/components/navigation-rail/NavigationRail.tsx @@ -133,9 +133,9 @@ function NavigationRail({ )} - {version && ( + {open && version && ( - {open && <>Version: } + {<>Version: } + createStyles({ + startPicker: { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + endPicker: { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + }) +); + +export enum SelectIntervalMode { + DATE, + TIME, +} + +interface Props extends Omit { + value?: Interval; + onChange?: (newValue: Interval, oldValue: Interval) => void; + + /** + * Mode of the picker component. Defaults to `DATE` if not present. + * + * Changes the behaviour of the component and what one wants to select: + * - `DATE`: Select a range of dates. + * - `TIME`: Select a range of time (hour & minutes) on the same day. + */ + mode?: SelectIntervalMode; + + /** + * The unit depends on the selected `mode`: If `mode` is set to `DATE` the unit is 'days'. If it is set to `TIME` the unit is 'minutes'. + */ + autoIncreaseStep?: number; + + /** + * Disables keyboard input. Defaults to `false`. + * + * **Currently only `TIME` is using keyboard input**. + */ + disableKeyboard?: boolean; +} + +interface TouchedState { + start: boolean; + end: boolean; +} + +interface ValueState { + lastValid: Interval; +} + +function getDefaultInterval(): Interval { + return Interval.fromDateTimes(DateTime.local(), DateTime.local().plus({ days: 1 })); +} + +function getFormatForMode( + mode: SelectIntervalMode +): { display: DateTimeFormatOptions; mask: string } { + switch (mode) { + case SelectIntervalMode.DATE: + return { + mask: 'dd.MM.yyyy', + display: { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + }, + }; + case SelectIntervalMode.TIME: + return { mask: 'HH:mm', display: { hour: '2-digit', minute: '2-digit', hour12: false } }; + default: + throw new Error(`No format available for mode ${mode}`); + } +} + +function getComponentForMode( + mode: SelectIntervalMode, + isKeyboardDisabled: boolean +): React.FC | React.FC { + switch (mode) { + case SelectIntervalMode.DATE: + return isKeyboardDisabled ? DatePicker : KeyboardDatePicker; + case SelectIntervalMode.TIME: + return isKeyboardDisabled ? TimePicker : KeyboardTimePicker; + default: + throw new Error(`No component available for mode ${mode}`); + } +} + +function getDurationToAdd(mode: SelectIntervalMode, steps: number = 1): DurationObject { + switch (mode) { + case SelectIntervalMode.DATE: + return { days: steps }; + case SelectIntervalMode.TIME: + return { minutes: steps }; + default: + throw new Error(`No duration available for mode ${mode}`); + } +} + +function isOneTouched(touched: TouchedState): boolean { + return Object.values(touched).reduce((prev, current) => prev || current, false); +} + +function SelectInterval({ + value: valueFromProps, + autoIncreaseStep, + onChange, + mode: modeFromProps, + disableKeyboard, + ...props +}: Props): JSX.Element { + const classes = useStyles(); + const [touched, setTouched] = useState({ start: false, end: false }); + const [error, setError] = useState(); + const [{ lastValid }, setInternalValue] = useState(() => ({ + lastValid: !!valueFromProps && valueFromProps.isValid ? valueFromProps : getDefaultInterval(), + })); + + const mode = modeFromProps ?? SelectIntervalMode.DATE; + const format = getFormatForMode(mode); + const Component = getComponentForMode(mode, disableKeyboard ?? false); + const displayError = !!error && isOneTouched(touched); + + const updateInternalValue = useCallback( + (newValue: Interval | undefined) => { + setInternalValue({ + lastValid: newValue?.isValid ? newValue : lastValid, + }); + }, + [lastValid] + ); + + useEffect(() => { + updateInternalValue(valueFromProps); + }, [valueFromProps, updateInternalValue]); + + const setValue = (newValue: Interval) => { + if (!valueFromProps) { + updateInternalValue(newValue); + } + + if (newValue.isValid) { + if (onChange) { + onChange(newValue, lastValid); + } + + setError(undefined); + } else { + if (onChange) { + onChange(newValue, lastValid); + } + + setError('Zeitbereich ungültig.'); + } + }; + + const labelFunc = (date: DateTime | null, invalidLabel: string) => { + if (!date || !date.isValid) { + return invalidLabel; + } + + return date.toLocaleString(format.display); + }; + + const handleStartChanged = (date: DateTime | null) => { + if (!date) { + setValue(Interval.invalid('Start date not defined.')); + return; + } + + const durationToAdd = getDurationToAdd(mode, autoIncreaseStep); + let endDate: DateTime = lastValid.end; + + if (date.isValid) { + if (date <= lastValid.end) { + endDate = touched.end ? lastValid.end : date.plus(durationToAdd); + } else { + endDate = date.plus(durationToAdd); + } + } + + setValue(Interval.fromDateTimes(date, endDate)); + }; + + const handleEndChanged = (date: DateTime | null) => { + if (!!date) { + setValue(Interval.fromDateTimes(lastValid.start, date)); + } else { + setValue(Interval.invalid('End date not defined.')); + } + }; + + return ( + + + setTouched({ ...touched, start: true })} + onChange={handleStartChanged} + error={displayError} + /> + setTouched({ ...touched, end: true })} + onChange={handleEndChanged} + error={displayError} + /> + + + {displayError && {error}} + + ); +} + +export default SelectInterval; diff --git a/client/src/components/sheet-selector/SheetSelector.tsx b/client/src/components/sheet-selector/SheetSelector.tsx index d5665ab34..201939e52 100644 --- a/client/src/components/sheet-selector/SheetSelector.tsx +++ b/client/src/components/sheet-selector/SheetSelector.tsx @@ -3,12 +3,12 @@ import React, { ChangeEvent, useEffect, useState } from 'react'; import { useHistory, useParams } from 'react-router'; import CustomSelect from '../CustomSelect'; import { getAllSheets } from '../../hooks/fetching/Sheet'; -import { useErrorSnackbar } from '../../hooks/useErrorSnackbar'; +import { useErrorSnackbar } from '../../hooks/snackbar/useErrorSnackbar'; import { Sheet } from '../../model/Sheet'; import { makeStyles, createStyles } from '@material-ui/core/styles'; import clsx from 'clsx'; -const useStyles = makeStyles((theme) => +const useStyles = makeStyles(() => createStyles({ select: { flex: 1, @@ -116,6 +116,7 @@ function SheetSelector({ itemToValue={(sheet) => sheet.id} value={currentSheet ? currentSheet.id : ''} onChange={onChange} + showLoadingIndicator={isLoadingSheets} /> ); } diff --git a/client/src/components/SnackbarWithList.tsx b/client/src/components/snackbar-with-list/SnackbarWithList.tsx similarity index 76% rename from client/src/components/SnackbarWithList.tsx rename to client/src/components/snackbar-with-list/SnackbarWithList.tsx index cf4d235cf..72556e086 100644 --- a/client/src/components/SnackbarWithList.tsx +++ b/client/src/components/snackbar-with-list/SnackbarWithList.tsx @@ -3,19 +3,18 @@ import { CardActions, Collapse, IconButton, - Paper, - Typography, List, ListItem, ListItemText, + Paper, + Typography, } from '@material-ui/core'; -import { amber } from '@material-ui/core/colors'; import { SnackbarContentProps } from '@material-ui/core/SnackbarContent'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import { Close as CloseIcon, ChevronDown as ExpandMoreIcon } from 'mdi-material-ui'; import clsx from 'clsx'; -import React, { useState } from 'react'; +import { ChevronDown as ExpandMoreIcon, Close as CloseIcon } from 'mdi-material-ui'; import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -24,15 +23,17 @@ const useStyles = makeStyles((theme: Theme) => }, typography: { fontWeight: 'bold', + marginRight: 'auto', }, actionRoot: { + display: 'flex', padding: theme.spacing(1, 1, 1, 2), - backgroundColor: amber[700], - }, - icons: { - marginLeft: 'auto', + backgroundColor: theme.palette.orange.main, + color: theme.palette.getContrastText(theme.palette.orange.main), + cursor: 'pointer', }, expand: { + color: theme.palette.getContrastText(theme.palette.orange.main), padding: theme.spacing(1, 1), transform: 'rotate(0deg)', transition: theme.transitions.create('transform', { @@ -48,7 +49,7 @@ const useStyles = makeStyles((theme: Theme) => }) ); -interface Props { +export interface SnackbarWithListProps { id: string | number | undefined; title: string; textBeforeList: string; @@ -57,7 +58,7 @@ interface Props { } function Component( - { title, textBeforeList, items, id, isOpen }: Props, + { title, textBeforeList, items, id, isOpen }: SnackbarWithListProps, ref: React.Ref ): JSX.Element { const [isExpanded, setExpanded] = useState(!!isOpen); @@ -68,18 +69,21 @@ function Component( setExpanded(!isExpanded); } - function handleDismiss() { + function handleDismiss(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + closeSnackbar(id); } return ( - + {title} -
+
{textBeforeList} - {items.map((item) => ( - + {items.map((item, idx) => ( + ))} @@ -110,6 +114,6 @@ function Component( ); } -const SnackbarWithList = React.forwardRef(Component); +const SnackbarWithList = React.forwardRef(Component); export default SnackbarWithList; diff --git a/client/src/components/snackbar-with-list/useSnackbarWithList.tsx b/client/src/components/snackbar-with-list/useSnackbarWithList.tsx new file mode 100644 index 000000000..64bd8ae8a --- /dev/null +++ b/client/src/components/snackbar-with-list/useSnackbarWithList.tsx @@ -0,0 +1,22 @@ +import { SnackbarKey, useSnackbar } from 'notistack'; +import React, { useCallback } from 'react'; +import SnackbarWithList, { SnackbarWithListProps } from './SnackbarWithList'; + +interface UseSnackbarWithList { + showSnackbarWithList: (props: Omit) => void; +} + +export function useSnackbarWithList() { + const { enqueueSnackbar } = useSnackbar(); + const enqueueSnackbarWithList = useCallback( + (props: Omit) => { + enqueueSnackbar('', { + persist: true, + content: (id: SnackbarKey) => , + }); + }, + [enqueueSnackbar] + ); + + return { enqueueSnackbarWithList }; +} diff --git a/client/src/components/stepper-with-buttons/StepperWithButtons.tsx b/client/src/components/stepper-with-buttons/StepperWithButtons.tsx new file mode 100644 index 000000000..8a2add92d --- /dev/null +++ b/client/src/components/stepper-with-buttons/StepperWithButtons.tsx @@ -0,0 +1,117 @@ +import { Box } from '@material-ui/core'; +import React, { useCallback, useEffect, useState } from 'react'; +import StepperContent from './components/StepperContent'; +import StepperHeader, { StepperHeaderProps } from './components/StepperHeader'; +import { NextStepCallback, StepData, StepperContext } from './context/StepperContext'; + +export interface StepInformation { + label: string; + component: React.FunctionComponent; + skippable?: boolean; +} + +export interface StepperWithButtonsProps extends StepperHeaderProps { + steps: StepInformation[]; +} + +interface State { + callback: NextStepCallback | undefined; +} + +function StepperWithButtons({ + steps: stepsFromProps, + ...props +}: StepperWithButtonsProps): JSX.Element { + const [activeStep, setActiveStep] = useState(0); + const [state, setState] = useState({ callback: undefined }); + const [isWaitingOnNextCallback, setWaitingOnNextCallback] = useState(false); + const [steps, setSteps] = useState([]); + const [isNextDisabled, setNextDisabled] = useState(false); + + useEffect(() => { + setSteps([...stepsFromProps]); + }, [stepsFromProps]); + + async function nextStep(skipCallback?: boolean) { + if (isWaitingOnNextCallback) { + return; + } + + if (skipCallback) { + return setActiveStep(activeStep + 1); + } + + const { callback } = state; + + if (!callback) { + return setActiveStep(activeStep + 1); + } + + setWaitingOnNextCallback(true); + const { goToNext, error, runAfterFinished } = await callback(); + + setSteps( + steps.map((step, index) => { + if (index !== activeStep) { + return step; + } + + return { + ...step, + error, + }; + }) + ); + setWaitingOnNextCallback(false); + + if (goToNext && activeStep < steps.length - 1) { + setActiveStep(activeStep + 1); + } + + if (!error && runAfterFinished) { + runAfterFinished(); + } + } + + async function prevStep() { + setActiveStep(activeStep - 1); + } + + const setNextCallback = useCallback((cb: NextStepCallback) => { + setState({ callback: cb }); + }, []); + + const removeNextCallback = useCallback(() => { + setState({ callback: undefined }); + }, []); + + const getNextCallback = useCallback(() => { + return state.callback; + }, [state.callback]); + + return ( + + + + + + + + ); +} + +export default StepperWithButtons; diff --git a/client/src/components/stepper-with-buttons/components/StepperContent.tsx b/client/src/components/stepper-with-buttons/components/StepperContent.tsx new file mode 100644 index 000000000..ebccb7f03 --- /dev/null +++ b/client/src/components/stepper-with-buttons/components/StepperContent.tsx @@ -0,0 +1,17 @@ +import { Box, BoxProps } from '@material-ui/core'; +import React from 'react'; +import { useStepper } from '../context/StepperContext'; + +function StepperContent(props: BoxProps) { + const { activeStep, steps } = useStepper(); + const StepElement = + steps[activeStep]?.component ?? (() =>
NO ELEMENT FOUND FOR STEP {activeStep}
); + + return ( + + + + ); +} + +export default StepperContent; diff --git a/client/src/components/stepper-with-buttons/components/StepperHeader.tsx b/client/src/components/stepper-with-buttons/components/StepperHeader.tsx new file mode 100644 index 000000000..f2d582db2 --- /dev/null +++ b/client/src/components/stepper-with-buttons/components/StepperHeader.tsx @@ -0,0 +1,119 @@ +import { + Box, + Button, + createStyles, + makeStyles, + Paper, + Step, + StepLabel, + Stepper, + StepperProps, +} from '@material-ui/core'; +import clsx from 'clsx'; +import React from 'react'; +import BackButton from '../../BackButton'; +import SubmitButton from '../../loading/SubmitButton'; +import { useStepper } from '../context/StepperContext'; + +const useStyles = makeStyles((theme) => + createStyles({ + paper: { + width: '100%', + display: 'flex', + alignItems: 'center', + }, + buttonBox: { + margin: theme.spacing(0, 2), + }, + skipButton: { + marginRight: theme.spacing(1), + }, + stepper: { + flex: 1, + padding: theme.spacing(3, 1), + }, + }) +); + +export interface StepperHeaderProps extends Omit { + backButtonLabel: string; + backButtonRoute?: string; + nextButtonLabel: string; + nextButtonDoneLabel?: string; +} + +export interface StepperBackButtonProps { + backButtonLabel: string; + backButtonRoute?: string; +} + +function StepperBackButton({ + backButtonLabel, + backButtonRoute, +}: StepperBackButtonProps): JSX.Element { + const { activeStep, prevStep } = useStepper(); + + if (!!backButtonRoute && activeStep === 0) { + return ; + } + + return ( + + ); +} + +function StepperHeader({ + backButtonLabel, + nextButtonLabel, + nextButtonDoneLabel, + backButtonRoute, + className, + ...props +}: StepperHeaderProps) { + const classes = useStyles(); + const { activeStep, nextStep, isWaitingOnNextCallback, steps, isNextDisabled } = useStepper(); + + return ( + + + + + + + {steps.map((data, index) => { + return ( + + {data.label} + + ); + })} + + + + {steps[activeStep]?.skippable && ( + + )} + + nextStep()} + disabled={isNextDisabled || activeStep === steps.length} + > + {!!nextButtonDoneLabel + ? activeStep < steps.length - 1 + ? nextButtonLabel + : nextButtonDoneLabel + : nextButtonLabel} + + + + ); +} + +export default StepperHeader; diff --git a/client/src/components/stepper-with-buttons/context/StepperContext.tsx b/client/src/components/stepper-with-buttons/context/StepperContext.tsx new file mode 100644 index 000000000..02471029b --- /dev/null +++ b/client/src/components/stepper-with-buttons/context/StepperContext.tsx @@ -0,0 +1,54 @@ +import React, { useContext } from 'react'; + +export interface NextStepInformation { + goToNext: boolean; + error?: boolean; + runAfterFinished?: () => void; +} + +export type NextStepCallback = () => Promise; + +export interface StepData { + label: string; + component: React.FunctionComponent; + error?: boolean; + skippable?: boolean; +} + +interface StepperContextValue { + activeStep: number; + isWaitingOnNextCallback: boolean; + isNextDisabled: boolean; + steps: StepData[]; + nextStep: (skipCallback?: boolean) => Promise; + prevStep: () => Promise; + setWaitingOnNextCallback: (waiting: boolean) => void; + setNextCallback: (cb: NextStepCallback) => void; + setNextDisabled: (isDisabled: boolean) => void; + removeNextCallback: () => void; + getNextCallback: () => NextStepCallback | undefined; +} + +function notInitialised(): any { + throw new Error('StepperContext is NOT initialized.'); +} + +export const StepperContext = React.createContext({ + activeStep: -1, + isWaitingOnNextCallback: false, + isNextDisabled: false, + steps: [], + setWaitingOnNextCallback: notInitialised, + prevStep: notInitialised, + nextStep: notInitialised, + setNextDisabled: notInitialised, + setNextCallback: notInitialised, + removeNextCallback: notInitialised, + getNextCallback: notInitialised, +}); + +export function useStepper(): Omit { + const { getNextCallback, ...context } = useContext(StepperContext); + + return { ...context }; +} diff --git a/client/src/hooks/DialogService.tsx b/client/src/hooks/DialogService.tsx index 6cc3c8dd3..b9aeb3540 100644 --- a/client/src/hooks/DialogService.tsx +++ b/client/src/hooks/DialogService.tsx @@ -8,11 +8,22 @@ import { import Button, { ButtonProps } from '@material-ui/core/Button'; import { DialogProps } from '@material-ui/core/Dialog'; import React, { PropsWithChildren, useContext, useState } from 'react'; +import { makeStyles, createStyles } from '@material-ui/core/styles'; +import clsx from 'clsx'; + +const useStyles = makeStyles((theme) => + createStyles({ + deleteButton: { + color: theme.palette.red.main, + }, + }) +); interface ActionParams { label: string; onClick: React.MouseEventHandler; buttonProps?: ButtonProps; + deleteButton?: boolean; } interface DialogOptions { @@ -20,11 +31,26 @@ interface DialogOptions { content: React.ReactNode; actions: ActionParams[]; onClose?: () => void; + DialogProps?: Omit; +} + +interface ConfirmationActionProps { + label?: string; + buttonProps?: ButtonProps; + deleteButton?: boolean; +} + +interface ConfirmationDialogOptions { + title: string; + content: React.ReactNode; + cancelProps?: ConfirmationActionProps; + acceptProps?: ConfirmationActionProps; DialogProps?: Omit; } export interface DialogHelpers { show: (dialogOptions: Partial) => void; + showConfirmationDialog: (diaologOptions: ConfirmationDialogOptions) => Promise; hide: () => void; } @@ -44,6 +70,7 @@ let showDialogGlobal: CreateDialogFunction | undefined; let closeDialogGlobal: (() => void) | undefined; function DialogService({ children }: PropsWithChildren<{}>): JSX.Element { + const classes = useStyles(); const [dialog, setDialog] = useState(undefined); function handleSetDialog(dialog: DialogOptions | undefined) { @@ -79,7 +106,15 @@ function DialogService({ children }: PropsWithChildren<{}>): JSX.Element { {dialog.actions.map((action) => ( - ))} @@ -121,11 +156,37 @@ function useDialog(): DialogHelpers { createDialogFunction(dialog); }, + showConfirmationDialog: (dialogOptions: ConfirmationDialogOptions) => { + return new Promise((resolve) => { + const { title, content, cancelProps, acceptProps, DialogProps } = dialogOptions; + + const closeDialog = (isAccepted: boolean) => { + createDialogFunction(undefined); + resolve(isAccepted); + }; + + const dialog: DialogOptions = { + title, + content, + actions: [ + { + ...cancelProps, + onClick: () => closeDialog(false), + label: cancelProps?.label ?? 'Nein', + }, + { ...acceptProps, onClick: () => closeDialog(true), label: acceptProps?.label ?? 'Ja' }, + ], + DialogProps: { ...DialogProps, onClose: () => closeDialog(false) }, + }; + + createDialogFunction(dialog); + }); + }, hide: () => createDialogFunction(undefined), }; } -function getDialogOutsideContext(): DialogHelpers { +function getDialogOutsideContext(): Pick { return { show: showDialogOutsideContext, hide: hideDialogOutsideContext, diff --git a/client/src/hooks/fetching/CSV.ts b/client/src/hooks/fetching/CSV.ts new file mode 100644 index 000000000..ab951b361 --- /dev/null +++ b/client/src/hooks/fetching/CSV.ts @@ -0,0 +1,12 @@ +import { IParseCsvDTO, ParseCsvResult } from 'shared/model/CSV'; +import axios from './Axios'; + +export async function getParsedCSV(csvDTO: IParseCsvDTO): Promise> { + const response = await axios.post>('/excel/parseCSV', csvDTO); + + if (response.status === 200) { + return response.data; + } + + return Promise.reject(`Wrong response code (${response.status})`); +} diff --git a/client/src/hooks/fetching/Tutorial.ts b/client/src/hooks/fetching/Tutorial.ts index 0194fb4e9..5811bf2ed 100644 --- a/client/src/hooks/fetching/Tutorial.ts +++ b/client/src/hooks/fetching/Tutorial.ts @@ -1,6 +1,11 @@ import { plainToClass } from 'class-transformer'; import { IStudent } from 'shared/model/Student'; -import { ISubstituteDTO, ITutorial, ITutorialDTO } from 'shared/model/Tutorial'; +import { + ISubstituteDTO, + ITutorial, + ITutorialDTO, + ITutorialGenerationDTO, +} from 'shared/model/Tutorial'; import { sortByName } from 'shared/util/helpers'; import { Student } from '../../model/Student'; import { Tutorial } from '../../model/Tutorial'; @@ -38,6 +43,18 @@ export async function createTutorial(tutorialInformation: ITutorialDTO): Promise return Promise.reject(`Wrong response code (${response.status}).`); } +export async function createMultipleTutorials( + generationInformation: ITutorialGenerationDTO +): Promise { + const response = await axios.post('tutorial/generate', generationInformation); + + if (response.status === 201) { + return plainToClass(Tutorial, response.data); + } + + return Promise.reject(`Wrong response code (${response.status})`); +} + export async function editTutorial( id: string, tutorialInformation: ITutorialDTO diff --git a/client/src/hooks/fetching/User.ts b/client/src/hooks/fetching/User.ts index 2564b2da7..b9f8471ee 100644 --- a/client/src/hooks/fetching/User.ts +++ b/client/src/hooks/fetching/User.ts @@ -1,6 +1,6 @@ import { MailingStatus } from 'shared/model/Mail'; import { Role } from 'shared/model/Role'; -import { ICreateUserDTO, IUserDTO, INewPasswordDTO, IUser } from 'shared/model/User'; +import { ICreateUserDTO, INewPasswordDTO, IUser, IUserDTO } from 'shared/model/User'; import { sortByName } from 'shared/util/helpers'; import axios from './Axios'; @@ -40,6 +40,16 @@ export async function createUser(userInformation: ICreateUserDTO): Promise { + const response = await axios.post('user/generate', dto); + + if (response.status === 201) { + return response.data; + } + + return Promise.reject((response.data as any).message ?? 'Unbekannter Fehler'); +} + export async function editUser(userid: string, userInformation: IUserDTO): Promise { const response = await axios.patch(`user/${userid}`, userInformation); diff --git a/client/src/hooks/snackbar/useCustomSnackbar.ts b/client/src/hooks/snackbar/useCustomSnackbar.ts new file mode 100644 index 000000000..eaa5e3da4 --- /dev/null +++ b/client/src/hooks/snackbar/useCustomSnackbar.ts @@ -0,0 +1,11 @@ +import { useSnackbar } from 'notistack'; +import { useSnackbarWithList } from '../../components/snackbar-with-list/useSnackbarWithList'; +import { useErrorSnackbar } from './useErrorSnackbar'; + +export function useCustomSnackbar() { + const useSnackbarFunctions = useSnackbar(); + const useErrorSnackbarFunctions = useErrorSnackbar(); + const useSnackbarWithListFunctions = useSnackbarWithList(); + + return { ...useSnackbarFunctions, ...useErrorSnackbarFunctions, ...useSnackbarWithListFunctions }; +} diff --git a/client/src/hooks/useErrorSnackbar.ts b/client/src/hooks/snackbar/useErrorSnackbar.ts similarity index 100% rename from client/src/hooks/useErrorSnackbar.ts rename to client/src/hooks/snackbar/useErrorSnackbar.ts diff --git a/client/src/model/Tutorial.ts b/client/src/model/Tutorial.ts index 0a2e915ac..dcd19c2d5 100644 --- a/client/src/model/Tutorial.ts +++ b/client/src/model/Tutorial.ts @@ -48,7 +48,22 @@ export class Tutorial implements Modify { return this.substitutes.get(parseDateToMapKey(date)); } + /** + * @returns Unified display string of the tutorial including it's slot. + */ toDisplayString(): string { return Tutorial.getDisplayString(this); } + + /** + * @returns String in the following format: '{slot} ({weekday}, {start}-{end})' + */ + toDisplayStringWithTime(): string { + const displayString = Tutorial.getDisplayString(this); + const dayShort = this.dates[0]?.weekdayShort; + const startTime = this.startTime.toFormat('HH:mm'); + const endTime = this.endTime.toFormat('HH:mm'); + + return `${displayString} (${dayShort}, ${startTime}-${endTime})`; + } } diff --git a/client/src/routes/Routing.routes.tsx b/client/src/routes/Routing.routes.tsx index 4d802f25b..1aaf0e191 100644 --- a/client/src/routes/Routing.routes.tsx +++ b/client/src/routes/Routing.routes.tsx @@ -1,17 +1,17 @@ import { SvgIconProps } from '@material-ui/core/SvgIcon'; import { Account as StudentIcon, - BadgeAccount as UserIcon, AccountMultiple as TeamIcon, AccountMultipleCheck as AttendancesIcon, + BadgeAccount as UserIcon, Book as EnterPointsIcon, Comment as PresentationIcon, File as SheetIcon, - TextBox as ScheinexamPointsIcon, - TextBoxMultiple as ScheinexamManagementIcon, Login as LoginIcon, ScriptText as ScheincriteriaIcon, Teach as TutorialIcon, + TextBox as ScheinexamPointsIcon, + TextBoxMultiple as ScheinexamManagementIcon, ViewDashboard as DashboardIcon, } from 'mdi-material-ui'; import React from 'react'; @@ -21,6 +21,8 @@ import AttendanceAdminView from '../view/attendance/AttendanceAdminView'; import AttendanceView from '../view/attendance/AttendanceView'; import CriteriaInfoView from '../view/criteria-info-view/CriteriaInfoView'; import Dashboard from '../view/dashboard/Dashboard'; +import GenerateTutorials from '../view/generate-tutorials/GenerateTutorials'; +import ImportUsers from '../view/import-data/ImportUsers'; import Login from '../view/Login'; import EnterScheinexamPoints from '../view/points-scheinexam/enter-form/EnterScheinexamPoints'; import ScheinexamPointsOverview from '../view/points-scheinexam/overview/ScheinexamPointsOverview'; @@ -54,14 +56,17 @@ export enum RoutingPath { SCHEIN_EXAMS_STUDENT = '/scheinexams/:examId/student/:studentId', DASHBOARD = '/dashboard', MANAGE_USERS = '/admin/usermanagement', + IMPORT_USERS = '/admin/usermanagement/generate', MANAGE_TUTORIALS = '/admin/tutorialmanagement', - MANAGE_TUTORIALS_SUBSTITUTES = '/admin/tutorialmanagement/:tutorialid/substitute', + GENERATE_TUTORIALS = '/admin/tutorialmanagement/generate', + MANAGE_TUTORIALS_SUBSTITUTES = '/admin/tutorialmanagement/substitutes/:tutorialid/substitute', MANAGE_SCHEIN_CRITERIAS = '/admin/scheincriterias', SCHEIN_CRITERIAS_INFO = '/admin/scheincriterias/info/:id', MANAGE_ATTENDANCES = '/admin/attendances', MANAGE_SHEETS = '/admin/sheets', MANAGE_ALL_STUDENTS = '/admin/students', MANAGE_SCHEIN_EXAMS = '/admin/scheinexams', + IMPORT_TUTORIALS_AND_USERS = '/admin/import', } type RouteComponent = React.ComponentType> | React.ComponentType; @@ -79,6 +84,7 @@ export interface RouteType { isExact?: boolean; } +export const ROOT_REDIRECT_PATH: RoutingPath = RoutingPath.LOGIN; export const PATH_REDIRECT_AFTER_LOGIN: RoutingPath = RoutingPath.DASHBOARD; export const ROUTES: readonly RouteType[] = [ @@ -211,6 +217,16 @@ export const ROUTES: readonly RouteType[] = [ roles: [Role.ADMIN], isInDrawer: true, isPrivate: true, + isExact: true, + }, + { + path: RoutingPath.IMPORT_USERS, + title: 'Importiere Nutzer', + component: ImportUsers, + icon: UserIcon, + roles: [Role.ADMIN], + isInDrawer: false, + isPrivate: true, }, { path: RoutingPath.MANAGE_TUTORIALS_SUBSTITUTES, @@ -221,6 +237,15 @@ export const ROUTES: readonly RouteType[] = [ isInDrawer: false, isPrivate: true, }, + { + path: RoutingPath.GENERATE_TUTORIALS, + title: 'Generiere Tutorien', + component: GenerateTutorials, + icon: TutorialIcon, + roles: [Role.ADMIN, Role.EMPLOYEE], + isInDrawer: false, + isPrivate: true, + }, { path: RoutingPath.MANAGE_TUTORIALS, title: 'Tutorienverwaltung', diff --git a/client/src/view/App.tsx b/client/src/view/App.tsx index c5deebeed..29ff77bdc 100644 --- a/client/src/view/App.tsx +++ b/client/src/view/App.tsx @@ -4,9 +4,9 @@ import React, { useState } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import PrivateRoute from '../components/PrivateRoute'; import { useLogin } from '../hooks/LoginService'; -import { ROUTES, RouteType, RoutingPath } from '../routes/Routing.routes'; +import { ROUTES, RouteType, RoutingPath, ROOT_REDIRECT_PATH } from '../routes/Routing.routes'; import AppBar from './AppBar'; -import NavigationRail from './navigation-rail/NavigationRail'; +import NavigationRail from '../components/navigation-rail/NavigationRail'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -63,7 +63,6 @@ function App() { const classes = useStyles(); const { isLoggedIn } = useLogin(); const [isDrawerOpen, setDrawerOpen] = useState(true); - const { ROOT, LOGIN } = RoutingPath; const routes = ROUTES.map((route) => getRouteElementForRoute(route)); @@ -83,7 +82,11 @@ function App() { {routes} - } /> + } + />
diff --git a/client/src/view/generate-tutorials/GenerateTutorials.tsx b/client/src/view/generate-tutorials/GenerateTutorials.tsx new file mode 100644 index 000000000..503f8ef83 --- /dev/null +++ b/client/src/view/generate-tutorials/GenerateTutorials.tsx @@ -0,0 +1,215 @@ +import { Box, Typography } from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import { Formik, useFormikContext } from 'formik'; +import { DateTime, Interval } from 'luxon'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { ITutorialGenerationData, ITutorialGenerationDTO, Weekday } from 'shared/model/Tutorial'; +import FormikDatePicker from '../../components/forms/components/FormikDatePicker'; +import FormikDebugDisplay from '../../components/forms/components/FormikDebugDisplay'; +import { createMultipleTutorials } from '../../hooks/fetching/Tutorial'; +import { FormikSubmitCallback } from '../../types'; +import FormikExcludedDates, { + FormExcludedDate, +} from './components/excluded-dates/FormikExcludedDates'; +import { WeekdayTimeSlot } from './components/weekday-slots/FormikWeekdaySlot'; +import WeekdayTabs from './components/weekday-slots/WeekdayTabs'; +import { validationSchema } from './GenerateTutorials.validation'; +import BackButton from '../../components/BackButton'; +import { RoutingPath } from '../../routes/Routing.routes'; +import SubmitButton from '../../components/loading/SubmitButton'; +import { useHistory } from 'react-router'; + +const useStyles = makeStyles((theme) => + createStyles({ + backButton: { + marginRight: theme.spacing(1), + }, + form: { + flex: 1, + }, + errorLabel: { + marginBottom: theme.spacing(1), + }, + }) +); + +export interface FormState { + startDate: string; + endDate: string; + excludedDates: FormExcludedDate[]; + weekdays: { [day: string]: WeekdayTimeSlot[] }; + prefixes: { [day: string]: string }; +} + +function mapKeyToWeekday(key: string): Weekday { + switch (key.toLowerCase()) { + case 'monday': + return Weekday.MONDAY; + case 'tuesday': + return Weekday.TUESDAY; + case 'wednesday': + return Weekday.WEDNESDAY; + case 'thursday': + return Weekday.THURSDAY; + case 'friday': + return Weekday.FRIDAY; + case 'saturday': + return Weekday.SATURDAY; + case 'sunday': + return Weekday.SUNDAY; + default: + throw new Error(`No weekday mapped to given key '${key}'`); + } +} + +function generateDTOFromValues(values: FormState): ITutorialGenerationDTO { + const { startDate, endDate, excludedDates, weekdays, prefixes } = values; + + return { + firstDay: DateTime.fromISO(startDate).toISODate(), + lastDay: DateTime.fromISO(endDate).toISODate(), + excludedDates: excludedDates.map((ex) => { + if (ex instanceof DateTime) { + return { date: ex.toISODate() }; + } else if (ex instanceof Interval) { + if (ex.start.day === ex.end.day) { + return { date: ex.start.toISODate() }; + } else { + return { interval: ex.toISODate() }; + } + } else { + throw new Error('Given excluded date is neither a DateTime nor an Interval'); + } + }), + generationDatas: Object.entries(weekdays) + .map(([key, val]) => { + const dataOfWeekday: ITutorialGenerationData[] = val.map((day) => { + return { + weekday: mapKeyToWeekday(key), + prefix: prefixes[key] ?? `${key.charAt(0).toUpperCase() + key.charAt(1).toLowerCase()}`, + amount: Number.parseInt(day.count), + interval: day.interval.toISOTime(), + }; + }); + + return [...dataOfWeekday]; + }) + .flat(), + }; +} + +function GenerateTutorialsContent(): JSX.Element { + const classes = useStyles(); + const { handleSubmit, isSubmitting, isValid, errors } = useFormikContext(); + + return ( +
+ + + + Terminbereich + + + + + + + + + + {errors.weekdays && ( + + {typeof errors.weekdays === 'string' + ? errors.weekdays + : 'Keine gültige Slotkonfiguration.'} + + )} + + + Tutorien generieren + + + + + + + + + + + ); +} + +const initialPrefixes = { + monday: 'Mo', + tuesday: 'Di', + wednesday: 'Mi', + thursday: 'Do', + friday: 'Fr', + saturday: 'Sa', +}; + +function GenerateTutorials(): JSX.Element { + const { enqueueSnackbar } = useSnackbar(); + const history = useHistory(); + + const initialValues: FormState = { + startDate: DateTime.local().toISODate(), + endDate: DateTime.local().plus({ days: 1 }).toISODate(), + excludedDates: [], + weekdays: {}, + prefixes: initialPrefixes, + }; + + const onSubmit: FormikSubmitCallback = async (values, helpers) => { + const dto: ITutorialGenerationDTO = generateDTOFromValues(values); + + // Manually validate the form to get a response. Formik's submitForm() does not respond in a manner to check if inner validation succeded or failed. + const errors = await helpers.validateForm(); + + if (Object.entries(errors).length > 0) { + enqueueSnackbar('Ungültige Formulardaten.', { variant: 'error' }); + return; + } + + try { + const response = await createMultipleTutorials(dto); + + enqueueSnackbar(`${response.length} Tutorien wurden erfolgreich generiert.`, { + variant: 'success', + }); + + history.push(RoutingPath.MANAGE_TUTORIALS); + } catch (err) { + enqueueSnackbar('Tutorien konnten nicht generiert werden.', { variant: 'error' }); + } + }; + + return ( + + + + ); +} + +export default GenerateTutorials; diff --git a/client/src/view/generate-tutorials/GenerateTutorials.validation.tsx b/client/src/view/generate-tutorials/GenerateTutorials.validation.tsx new file mode 100644 index 000000000..cd22edfa5 --- /dev/null +++ b/client/src/view/generate-tutorials/GenerateTutorials.validation.tsx @@ -0,0 +1,150 @@ +import { DateTime, Interval } from 'luxon'; +import * as Yup from 'yup'; +import { TestContext, TestFunction, ValidationError } from 'yup'; +import { FormExcludedDate } from './components/excluded-dates/FormikExcludedDates'; +import { WeekdayTimeSlot } from './components/weekday-slots/FormikWeekdaySlot'; +import { FormState } from './GenerateTutorials'; + +function isDateTime(this: TestContext, value: string): boolean { + const date = DateTime.fromISO(value); + + return date.isValid; +} + +function isAfterStartDay(field: string): TestFunction { + return function (this, value: string) { + const startDate: DateTime = DateTime.fromISO(this.resolve(Yup.ref(field))); + const endDate: DateTime = DateTime.fromISO(value); + + return startDate.toMillis() < endDate.toMillis(); + }; +} + +const excludedDateSchema = Yup.object() + .test('excludedDate', `Not a valid excluded date`, (obj) => { + if (obj instanceof DateTime) { + return obj.isValid; + } else if (obj instanceof Interval) { + return obj.isValid; + } + + return false; + }) + .required(); + +const singleWeekdaySlotSchema = Yup.object().shape({ + _id: Yup.number().required('Benötigt'), + count: Yup.string() + .matches(/^\d+(\.\d+)?$/, 'Count muss eine Zahl sein') + .required('Benötigt'), + interval: Yup.object() + .test('is-interval', 'Is not a valid luxon Interval', function (this, obj) { + if (!(obj instanceof Interval)) { + return this.createError({ message: 'Not a luxon Interval object.' }); + } + + if (!obj.isValid) { + return this.createError({ message: 'Not a valid luxon Interval.' }); + } + + if (obj.start >= obj.end) { + return this.createError({ message: 'End time must be greater than start time' }); + } + + return true; + }) + .required('Benötigt'), +}); + +function areWeekdaysValid(this: Yup.TestContext, value: unknown, path: string) { + if (!(value instanceof Array)) { + throw this.createError({ path, message: 'Value is not an array.' }); + } + + value.forEach((val, idx) => { + const pathPrefix = `${path}[${idx}]`; + try { + singleWeekdaySlotSchema.validateSync(val); + } catch (validationError) { + if (validationError instanceof Yup.ValidationError) { + throw this.createError({ + message: validationError.message, + path: `${pathPrefix}.${validationError.path}`, + }); + } else { + throw validationError; + } + } + }); +} + +const weekdaysSchema = Yup.object() + .test('weekdays', 'Keine gültige Slotkonfiguration.', function (this, obj) { + const entries = Object.entries(obj); + const inner: ValidationError[] = []; + + if (entries.length === 0) { + return this.createError({ message: 'Kein Slot vorhanden.' }); + } + + for (const [key, value] of entries) { + try { + areWeekdaysValid.bind(this)(value, `${this.path}.${key}`); + } catch (validationError) { + inner.push(validationError); + } + } + + if (inner.length > 0) { + const error = this.createError({ message: 'Keine gültige Slotkonfiguration.' }); + error.inner = inner; + + return error; + } else { + return true; + } + }) + .required('Benötigt'); + +const prefixesSchema = Yup.object() + .test('prefixes', 'Keine gültigen Präfixe', function (this, obj) { + if (typeof obj !== 'object') { + return this.createError({ message: 'Ist nicht vom Typ "object"' }); + } + + const inner: Yup.ValidationError[] = []; + + for (const [key, value] of Object.entries(obj)) { + if (!value) { + inner.push( + this.createError({ message: `Präfix wird benötigt.`, path: `${this.path}.${key}` }) + ); + } else if (typeof value !== 'string') { + inner.push( + this.createError({ message: `Präfix ist kein String.`, path: `${this.path}.${key}` }) + ); + } + } + + if (inner.length > 0) { + const error = this.createError({ message: 'Ungültige Präfixe.' }); + error.inner = inner; + return error; + } + + return true; + }) + .required('Benötigt'); + +export const validationSchema = Yup.object().shape({ + startDate: Yup.string() + .required('Benötigt') + .test({ test: isDateTime, message: 'Ungültiges Datum' }), + endDate: Yup.string() + .required('Benötigt') + .test({ test: isDateTime, message: 'Ungültiges Datum' }) + .test({ test: isAfterStartDay('startDate'), message: 'Muss nach dem Startdatum liegen' }), + excludedDates: Yup.array().of(excludedDateSchema).defined(), + weekdays: weekdaysSchema, + prefixes: prefixesSchema, +}); diff --git a/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateBox.tsx b/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateBox.tsx new file mode 100644 index 000000000..8d56bcab2 --- /dev/null +++ b/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateBox.tsx @@ -0,0 +1,49 @@ +import { Box, IconButton } from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import { Delete as DeleteIcon, SquareEditOutline as EditIcon } from 'mdi-material-ui'; +import React from 'react'; +import ExcludedDateText from './ExcludedDateDisplay'; +import { FormExcludedDate } from './FormikExcludedDates'; + +const useStyles = makeStyles((theme) => + createStyles({ + deleteButton: { + color: theme.palette.red.main, + }, + }) +); + +interface Props { + excluded: FormExcludedDate; + onEdit: () => void; + onDelete: () => void; +} + +function ExcludedDateBox({ excluded, onEdit, onDelete }: Props): JSX.Element { + const classes = useStyles(); + + return ( + + + + + + + + + + + + + + ); +} + +export default ExcludedDateBox; diff --git a/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateDialog.tsx b/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateDialog.tsx new file mode 100644 index 000000000..19575a003 --- /dev/null +++ b/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateDialog.tsx @@ -0,0 +1,133 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogProps, + DialogTitle, + Tab, + Tabs, +} from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import { DateTime, Interval } from 'luxon'; +import React, { useState } from 'react'; +import CustomDatePicker from '../../../../components/date-picker/DatePicker'; +import SelectInterval, { + SelectIntervalMode, +} from '../../../../components/select-interval/SelectInterval'; +import TabPanel from '../../../../components/TabPanel'; +import { FormExcludedDate } from './FormikExcludedDates'; + +const useStyles = makeStyles(() => + createStyles({ + tabContent: { display: 'flex' }, + }) +); + +interface Props extends DialogProps { + excluded?: FormExcludedDate; + onAccept: (excluded: FormExcludedDate) => void; +} + +interface ValueState { + single: DateTime; + interval: Interval; +} + +function getDefaultValue(excluded?: FormExcludedDate): ValueState { + const defaultSingle = DateTime.local(); + const defaultInterval = Interval.fromDateTimes( + DateTime.local(), + DateTime.local().plus({ days: 7 }) + ); + + if (excluded instanceof DateTime) { + return { single: excluded, interval: defaultInterval }; + } + + if (excluded instanceof Interval) { + return { + single: defaultSingle, + interval: Interval.fromDateTimes(excluded.start, excluded.end), + }; + } + + return { single: defaultSingle, interval: defaultInterval }; +} + +function getSelectedTab(excluded?: FormExcludedDate): number { + if (excluded instanceof DateTime) { + return 0; + } + + if (excluded instanceof Interval) { + return 1; + } + + return 0; +} + +function ExcludedDateDialog({ excluded, onClose, onAccept, ...props }: Props): JSX.Element { + const classes = useStyles(); + const [value, setValue] = useState(() => getDefaultValue(excluded)); + const [selected, setSelected] = useState(() => getSelectedTab(excluded)); + + const handleTabChange = (_: React.ChangeEvent<{}>, newValue: number) => { + setSelected(newValue); + }; + + return ( + + Zeitspanne ausschließen + + + + + + + + + { + if (!!date) { + setValue({ ...value, single: date }); + } + }} + /> + + + { + setValue({ ...value, interval }); + }} + /> + + + + + + + + + ); +} + +export default ExcludedDateDialog; diff --git a/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateDisplay.tsx b/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateDisplay.tsx new file mode 100644 index 000000000..c202cb06c --- /dev/null +++ b/client/src/view/generate-tutorials/components/excluded-dates/ExcludedDateDisplay.tsx @@ -0,0 +1,32 @@ +import { Typography } from '@material-ui/core'; +import { DateTime, DateTimeFormatOptions } from 'luxon'; +import React from 'react'; +import { FormExcludedDate } from './FormikExcludedDates'; + +interface Props { + excluded: FormExcludedDate; +} + +function ExcludedDateText({ excluded }: Props): JSX.Element { + const format: DateTimeFormatOptions = { + weekday: 'short', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }; + + return ( + + {excluded instanceof DateTime ? ( + excluded.toLocaleString(format) + ) : ( + <> + {excluded.start.toLocaleString(format)} - + {excluded.end.toLocaleString(format)} + + )} + + ); +} + +export default ExcludedDateText; diff --git a/client/src/view/generate-tutorials/components/excluded-dates/FormikExcludedDates.tsx b/client/src/view/generate-tutorials/components/excluded-dates/FormikExcludedDates.tsx new file mode 100644 index 000000000..8a542322a --- /dev/null +++ b/client/src/view/generate-tutorials/components/excluded-dates/FormikExcludedDates.tsx @@ -0,0 +1,159 @@ +import { Box, BoxProps, Button, Typography } from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import { useField } from 'formik'; +import { DateTime, Interval } from 'luxon'; +import React, { useState } from 'react'; +import { useDialog } from '../../../../hooks/DialogService'; +import ExcludedDateBox from './ExcludedDateBox'; +import ExcludedDateDialog from './ExcludedDateDialog'; + +const useStyles = makeStyles((theme) => + createStyles({ + addButton: { + height: 'fit-content', + }, + paper: { + padding: theme.spacing(1), + }, + }) +); + +export type FormExcludedDate = DateTime | Interval; + +interface DialogClosedState { + isShowDialog: false; +} + +interface DialogOpenState { + isShowDialog: true; + onAccept: (excluded: FormExcludedDate) => void; + excludedDate?: FormExcludedDate; +} + +type DialogState = DialogOpenState | DialogClosedState; + +interface Props extends BoxProps { + name: string; +} + +function FormikExcludedDates({ name, ...props }: Props): JSX.Element { + const classes = useStyles(); + const [, meta, helpers] = useField(name); + const [dialogState, setDialogState] = useState({ isShowDialog: false }); + const dialog = useDialog(); + + const { value } = meta; + + const setFieldValue = (newValue: FormExcludedDate[]) => { + newValue.sort((a, b) => { + const dateA = a instanceof Interval ? a.start : a; + const dateB = b instanceof Interval ? b.start : b; + + return dateA.toMillis() - dateB.toMillis(); + }); + + helpers.setValue(newValue); + }; + + const addExcludedDate = (excluded: FormExcludedDate) => { + setFieldValue([...value, excluded]); + setDialogState({ isShowDialog: false }); + }; + + const replaceExcludedDate = (idx: number) => (excluded: FormExcludedDate) => { + const newValue = [...value]; + newValue[idx] = excluded; + + setFieldValue([...newValue]); + setDialogState({ isShowDialog: false }); + }; + + const deleteExcludedDate = (idx: number) => { + const newValues = [...value]; + newValues.splice(idx, 1); + + dialog.hide(); + setFieldValue(newValues); + }; + + const handleDeleteExcludedDateClicked = (idx: number) => () => { + dialog.show({ + title: 'Ausgeschlossene Zeitspanne löschen', + content: + 'Soll die ausgewählte Zeitspanne wirklich gelöscht werden? Dies kann nicht rückgängig gemacht werden.', + actions: [ + { + label: 'Nicht löschen', + onClick: () => dialog.hide(), + }, + { + label: 'Löschen', + onClick: () => deleteExcludedDate(idx), + deleteButton: true, + }, + ], + }); + }; + + return ( + + + Ausgeschlossene Zeiten + + + + {value.map((val, idx) => ( + { + setDialogState({ + isShowDialog: true, + excludedDate: val, + onAccept: replaceExcludedDate(idx), + }); + }} + onDelete={handleDeleteExcludedDateClicked(idx)} + /> + ))} + + + + + {dialogState.isShowDialog && ( + setDialogState({ isShowDialog: false })} + onAccept={dialogState.onAccept} + /> + )} + + ); +} + +export default FormikExcludedDates; diff --git a/client/src/view/generate-tutorials/components/weekday-slots/AddSlotForm.tsx b/client/src/view/generate-tutorials/components/weekday-slots/AddSlotForm.tsx new file mode 100644 index 000000000..9176fc319 --- /dev/null +++ b/client/src/view/generate-tutorials/components/weekday-slots/AddSlotForm.tsx @@ -0,0 +1,113 @@ +import { Box, BoxProps, Grow, IconButton, TextField } from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import { useFormik } from 'formik'; +import { DateTime, Interval } from 'luxon'; +import { Check as AcceptIcon, Close as AbortIcon } from 'mdi-material-ui'; +import React, { useMemo } from 'react'; +import * as Yup from 'yup'; +import SelectInterval, { + SelectIntervalMode, +} from '../../../../components/select-interval/SelectInterval'; + +const useStyles = makeStyles((theme) => + createStyles({ + weekdayCountField: { + margin: theme.spacing(0, 2), + }, + weekdayEntryDeleteButton: { + color: theme.palette.red.main, + }, + }) +); + +interface Props extends BoxProps { + onAccept: (data: AddSlotFormData) => void; + onAbort: () => void; +} + +export interface AddSlotFormData { + interval: Interval; + count: number; +} + +const validationSchema = Yup.object().shape({ + count: Yup.number().min(1, 'Anzahl muss größer als 0 sein.').required('Benötigt'), + interval: Yup.object() + .test('is-interval', 'Ist kein Luxon Interval', (obj) => obj instanceof Interval && obj.isValid) + .required('Benötigt'), +}); + +function getDefaultInterval(): Interval { + const currentTime = DateTime.local().startOf('minute'); + return Interval.fromDateTimes(currentTime, currentTime.plus({ minutes: 90 })); +} + +function AddSlotForm({ onAbort, onAccept, ...props }: Props): JSX.Element { + const classes = useStyles(); + const initialValues: AddSlotFormData = useMemo( + () => ({ interval: getDefaultInterval(), count: 1 }), + [] + ); + const { values, errors, touched, setFieldValue, getFieldProps, isValid, submitForm } = useFormik< + AddSlotFormData + >({ + initialValues, + onSubmit: ({ count, interval }) => { + onAccept({ count, interval }); + }, + validationSchema, + }); + + const handleAccept = () => { + submitForm(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleAccept(); + } + }; + + return ( + + + setFieldValue('interval', i)} + /> + + + + + + + + + + + + + ); +} + +export default AddSlotForm; diff --git a/client/src/view/generate-tutorials/components/weekday-slots/FormikWeekdaySlot.tsx b/client/src/view/generate-tutorials/components/weekday-slots/FormikWeekdaySlot.tsx new file mode 100644 index 000000000..bb66078db --- /dev/null +++ b/client/src/view/generate-tutorials/components/weekday-slots/FormikWeekdaySlot.tsx @@ -0,0 +1,66 @@ +import { IconButton, Paper, PaperProps } from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import { useField } from 'formik'; +import { Interval } from 'luxon'; +import { Delete as DeleteIcon } from 'mdi-material-ui'; +import React from 'react'; +import FormikTextField from '../../../../components/forms/components/FormikTextField'; +import SelectInterval, { + SelectIntervalMode, +} from '../../../../components/select-interval/SelectInterval'; + +const useStyles = makeStyles((theme) => + createStyles({ + weekdayCountField: { + margin: theme.spacing(0, 2), + }, + weekdayEntryDeleteButton: { + color: theme.palette.red.main, + }, + }) +); + +export interface WeekdayTimeSlot { + _id: number; + interval: Interval; + count: string; +} + +interface Props extends PaperProps { + name: string; + onDelete: () => void; +} + +function FormikWeekdaySlot({ name, onDelete, ...props }: Props): JSX.Element { + const classes = useStyles(); + const [, meta, helpers] = useField(name); + const { value } = meta; + + const handleIntervalChanged = (interval: Interval) => { + helpers.setValue({ ...value, interval }); + }; + + return ( + + + + + + + + + + ); +} + +export default FormikWeekdaySlot; diff --git a/client/src/view/generate-tutorials/components/weekday-slots/IconForTab.tsx b/client/src/view/generate-tutorials/components/weekday-slots/IconForTab.tsx new file mode 100644 index 000000000..fbaaa5fae --- /dev/null +++ b/client/src/view/generate-tutorials/components/weekday-slots/IconForTab.tsx @@ -0,0 +1,39 @@ +import { Box } from '@material-ui/core'; +import { useFormikContext } from 'formik'; +import React from 'react'; +import { FormState } from '../../GenerateTutorials'; +import { WeekdayTimeSlot } from './FormikWeekdaySlot'; + +interface TabIconProps { + weekday: string; + showError: boolean; +} + +function IconForTab({ weekday, showError }: TabIconProps): JSX.Element { + const { values } = useFormikContext(); + + const weekdayValues: WeekdayTimeSlot[] | undefined = values.weekdays[weekday]; + const slotCountOnWeekday: number = + weekdayValues?.reduce((sum, current) => { + const parsed = Number.parseInt(current.count); + + return Number.isSafeInteger(parsed) ? sum + parsed : sum; + }, 0) ?? 0; + + return ( + + {!showError ? slotCountOnWeekday : '!'} + + ); +} + +export default IconForTab; diff --git a/client/src/view/generate-tutorials/components/weekday-slots/WeekdayBox.tsx b/client/src/view/generate-tutorials/components/weekday-slots/WeekdayBox.tsx new file mode 100644 index 000000000..eb73310de --- /dev/null +++ b/client/src/view/generate-tutorials/components/weekday-slots/WeekdayBox.tsx @@ -0,0 +1,158 @@ +import { Box, BoxProps, Button, IconButton, Paper, Tooltip } from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import { useField } from 'formik'; +import { Plus as AddIcon, SortAscending as SortIcon } from 'mdi-material-ui'; +import React, { useState } from 'react'; +import FormikTextField from '../../../../components/forms/components/FormikTextField'; +import { useDialog } from '../../../../hooks/DialogService'; +import AddSlotForm, { AddSlotFormData } from './AddSlotForm'; +import FormikWeekdaySlot, { WeekdayTimeSlot } from './FormikWeekdaySlot'; + +const useStyles = makeStyles((theme) => + createStyles({ + weekdayEntry: { + padding: theme.spacing(1), + display: 'flex', + marginTop: theme.spacing(1), + alignItems: 'center', + '&:first-of-type': { + marginTop: 0, + }, + }, + addNewWeekdayEntry: { + minHeight: 72, + display: 'flex', + marginTop: theme.spacing(1), + alignItems: 'center', + justifyContent: 'center', + }, + addSlotButton: { + width: '100%', + height: '100%', + minHeight: 'inherit', + }, + sortButton: { + marginLeft: theme.spacing(1), + }, + }) +); + +interface Props extends BoxProps { + name: string; + prefixName: string; +} + +function sortSlots(intervals: WeekdayTimeSlot[]): WeekdayTimeSlot[] { + return [...intervals].sort((a, b) => { + if (!a.interval.isValid || !b.interval.isValid) { + return -1; + } + + return a.interval.start.toFormat('HH:mm').localeCompare(b.interval.start.toFormat('HH:mm')); + }); +} + +function WeekdayBox({ name, prefixName, ...props }: Props): JSX.Element { + const classes = useStyles(); + const [, meta, helpers] = useField(name); + const [, { value: prefixValue }] = useField(prefixName); + const [isAddMode, setAddMode] = useState(false); + const dialog = useDialog(); + + const value = meta.value ?? []; + + const setValue = (newValue: WeekdayTimeSlot[]) => { + helpers.setValue(sortSlots(newValue)); + }; + + const deleteSlot = (id: number) => { + const newValue = [...value]; + const idx = newValue.findIndex((val) => val._id === id); + newValue.splice(idx, 1); + + dialog.hide(); + setValue(newValue); + }; + + const handleDeleteSlotClicked = (id: number) => () => { + dialog.show({ + title: 'Slot löschen', + content: + 'Soll der ausgewählte Slot wirklich gelöscht werden? Dies kann nicht rückgängig gemacht werden.', + actions: [ + { + label: 'Nicht löschen', + onClick: () => dialog.hide(), + }, + { + label: 'Löschen', + onClick: () => deleteSlot(id), + deleteButton: true, + }, + ], + }); + }; + + const onAcceptClicked = ({ count, interval }: AddSlotFormData) => { + const highestId = value.reduce((id, val) => (val._id > id ? val._id : id), -1); + const newValue: WeekdayTimeSlot[] = [ + ...value, + { _id: highestId + 1, count: `${count}`, interval }, + ]; + + setValue(newValue); + setAddMode(false); + }; + + const handleSortClicked = () => { + helpers.setValue(sortSlots(value)); + }; + + return ( + + + + + + + + + + + + {value.map((val, idx) => ( + + ))} + + + {isAddMode ? ( + setAddMode(false)} onAccept={onAcceptClicked} flex={1} /> + ) : ( + + )} + + + ); +} + +export default WeekdayBox; diff --git a/client/src/view/generate-tutorials/components/weekday-slots/WeekdayTabs.tsx b/client/src/view/generate-tutorials/components/weekday-slots/WeekdayTabs.tsx new file mode 100644 index 000000000..504a9fa72 --- /dev/null +++ b/client/src/view/generate-tutorials/components/weekday-slots/WeekdayTabs.tsx @@ -0,0 +1,103 @@ +import { Tab, Tabs } from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import clsx from 'clsx'; +import { FormikErrors, useFormikContext } from 'formik'; +import React, { useMemo, useState } from 'react'; +import TabPanel from '../../../../components/TabPanel'; +import { FormState } from '../../GenerateTutorials'; +import IconForTab from './IconForTab'; +import WeekdayBox from './WeekdayBox'; + +const useStyles = makeStyles((theme) => + createStyles({ + errorTab: { + color: theme.palette.error.main, + }, + }) +); + +interface GetTabsForAllWeekdaysParams { + errors: FormikErrors; + errorClass: string; + selectedTab: number; +} + +const weekdaysToShow: { [date: string]: string } = { + monday: 'Montag', + tuesday: 'Dienstag', + wednesday: 'Mittwoch', + thursday: 'Donnerstag', + friday: 'Freitag', + saturday: 'Samstag', +}; + +function getTabsForAllWeekdays({ + errors, + errorClass, + selectedTab, +}: GetTabsForAllWeekdaysParams): { tabs: JSX.Element[]; panels: JSX.Element[] } { + const tabs: JSX.Element[] = []; + const panels: JSX.Element[] = []; + + Object.entries(weekdaysToShow).forEach(([key, weekday], idx) => { + const lowerKeyWeekday = key.toLowerCase(); + const showError = !!errors.weekdays?.[lowerKeyWeekday] || !!errors.prefixes?.[lowerKeyWeekday]; + + tabs.push( + } + /> + ); + + panels.push( + + + + ); + }); + + return { tabs, panels }; +} + +function WeekdayTabs(): JSX.Element { + const classes = useStyles(); + const [selectedTab, setSelectedTab] = useState(0); + const { errors } = useFormikContext(); + + const handleTabChange = (_: React.ChangeEvent<{}>, newValue: number) => { + setSelectedTab(newValue); + }; + + const { tabs, panels } = useMemo( + () => + getTabsForAllWeekdays({ + errors, + selectedTab, + errorClass: classes.errorTab, + }), + [errors, selectedTab, classes.errorTab] + ); + + return ( + <> + + {tabs} + + + {panels} + + ); +} + +export default WeekdayTabs; diff --git a/client/src/view/import-data/ImportUsers.context.tsx b/client/src/view/import-data/ImportUsers.context.tsx new file mode 100644 index 000000000..a1524c961 --- /dev/null +++ b/client/src/view/import-data/ImportUsers.context.tsx @@ -0,0 +1,123 @@ +import React, { useContext, useState, useEffect, useCallback } from 'react'; +import { Tutorial } from '../../model/Tutorial'; +import { getAllTutorials } from '../../hooks/fetching/Tutorial'; + +interface ParsedCSVDataRow { + [header: string]: string; +} + +export interface ParsedCSVData { + headers: string[]; + rows: ParsedCSVDataRow[]; +} + +export interface CSVDataRow { + rowNr: number; + data: ParsedCSVDataRow; +} + +export interface CSVData { + headers: string[]; + rows: CSVDataRow[]; +} + +export interface MappedColumns { + firstnameColumn: string; + lastnameColumn: string; + emailColumn: string; + rolesColumn: string; + usernameColumn: string; + passwordColumn: string; + tutorialsColumn: string; + tutorialsToCorrectColumn: string; +} + +interface CSVFormData { + csvInput: string; + seperator: string; +} + +interface DataContextValue { + tutorials: Tutorial[]; + data: CSVData; + setData: (data: ParsedCSVData) => void; + mappedColumns: MappedColumns; + setMappedColumns: (columns: MappedColumns) => void; + csvFormData?: CSVFormData; + setCSVFormData: (data: CSVFormData) => void; +} + +const initialMappedColumns: MappedColumns = { + firstnameColumn: '', + lastnameColumn: '', + emailColumn: '', + rolesColumn: '', + usernameColumn: '', + passwordColumn: '', + tutorialsColumn: '', + tutorialsToCorrectColumn: '', +}; + +function notInitializied() { + throw new Error('ImportDataContext not initialised.'); +} + +const DataContext = React.createContext({ + tutorials: [], + data: { headers: [], rows: [] }, + mappedColumns: initialMappedColumns, + csvFormData: undefined, + setData: notInitializied, + setMappedColumns: notInitializied, + setCSVFormData: notInitializied, +}); + +function convertParsedToInternalCSV(data: ParsedCSVData): CSVData { + const { headers, rows: parsedRows } = data; + const rows: CSVDataRow[] = parsedRows.map((row, idx) => ({ rowNr: idx, data: row })); + + headers.sort((a, b) => a.localeCompare(b)); + return { headers, rows }; +} + +function ImportUsersContext({ children }: React.PropsWithChildren<{}>): JSX.Element { + const [data, setInternalData] = useState(() => + convertParsedToInternalCSV({ headers: [], rows: [] }) + ); + const [mappedColumns, setMappedColumns] = useState(initialMappedColumns); + const [tutorials, setTutorials] = useState([]); + const [csvFormData, setCSVFormData] = useState(); + + useEffect(() => { + getAllTutorials().then((tutorials) => setTutorials(tutorials)); + }, []); + + const setData = useCallback((data: ParsedCSVData) => { + const newData = convertParsedToInternalCSV(data); + setInternalData(newData); + }, []); + + return ( + + {children} + + ); +} + +export function useImportDataContext(): DataContextValue { + const value = useContext(DataContext); + + return value; +} + +export default ImportUsersContext; diff --git a/client/src/view/import-data/ImportUsers.tsx b/client/src/view/import-data/ImportUsers.tsx new file mode 100644 index 000000000..da6e7662a --- /dev/null +++ b/client/src/view/import-data/ImportUsers.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import StepperWithButtons from '../../components/stepper-with-buttons/StepperWithButtons'; +import { RoutingPath } from '../../routes/Routing.routes'; +import AdjustImportedUserDataForm from './adjust-data-form/AdjustImportedUserDataForm'; +import ImportUserCSV from './import-csv/ImportUserCSV'; +import ImportUsersContext from './ImportUsers.context'; +import MapCSVColumns from './map-columns/MapCSVColumns'; + +function ImportUsers(): JSX.Element { + return ( + + + + ); +} + +export default ImportUsers; diff --git a/client/src/view/import-data/adjust-data-form/AdjustImportedUserDataForm.tsx b/client/src/view/import-data/adjust-data-form/AdjustImportedUserDataForm.tsx new file mode 100644 index 000000000..acfa88c02 --- /dev/null +++ b/client/src/view/import-data/adjust-data-form/AdjustImportedUserDataForm.tsx @@ -0,0 +1,118 @@ +import { Box } from '@material-ui/core'; +import { Formik, useFormikContext } from 'formik'; +import { useSnackbar } from 'notistack'; +import React, { useEffect, useMemo } from 'react'; +import { useHistory } from 'react-router'; +import { Role } from 'shared/model/Role'; +import { ICreateUserDTO, IUser } from 'shared/model/User'; +import FormikDebugDisplay from '../../../components/forms/components/FormikDebugDisplay'; +import { + generateTemporaryPassword, + generateUsernameFromName, +} from '../../../components/forms/UserForm'; +import { useStepper } from '../../../components/stepper-with-buttons/context/StepperContext'; +import { createManyUsers } from '../../../hooks/fetching/User'; +import { useCustomSnackbar } from '../../../hooks/snackbar/useCustomSnackbar'; +import { RoutingPath } from '../../../routes/Routing.routes'; +import { FormikSubmitCallback } from '../../../types'; +import { useImportDataContext } from '../ImportUsers.context'; +import UserDataBox from './components/UserDataBox'; +import { convertCSVDataToFormData } from './components/UserDataBox.helpers'; + +export interface UserFormStateValue { + rowNr: number; + firstname: string; + lastname: string; + email: string; + roles: Role[]; + tutorials: string[]; + tutorialsToCorrect: string[]; + username?: string; + password?: string; +} + +export interface UserFormState { + [id: string]: UserFormStateValue; +} + +function convertValuesToDTOS(values: UserFormState): ICreateUserDTO[] { + return Object.values(values).map(({ rowNr, username, password, ...user }) => ({ + ...user, + username: + username || generateUsernameFromName({ firstname: user.firstname, lastname: user.lastname }), + password: password || generateTemporaryPassword(), + })); +} + +function AdjustImportedUserDataFormContent(): JSX.Element { + const { setNextCallback, removeNextCallback } = useStepper(); + const { values, isValid, validateForm, submitForm } = useFormikContext(); + const { enqueueSnackbar } = useSnackbar(); + const history = useHistory(); + + useEffect(() => { + setNextCallback(async () => { + const errors = await validateForm(); + + if (Object.entries(errors).length > 0) { + enqueueSnackbar('Nutzerdaten sind ungültig.', { variant: 'error' }); + return { goToNext: false, error: true }; + } + + await submitForm(); + + return { goToNext: true, runAfterFinished: () => history.push(RoutingPath.MANAGE_USERS) }; + }); + + return () => removeNextCallback(); + }, [ + setNextCallback, + removeNextCallback, + isValid, + values, + enqueueSnackbar, + history, + submitForm, + validateForm, + ]); + + return ( + + + + + + ); +} + +function AdjustImportedUserDataForm(): JSX.Element { + const { data, mappedColumns, tutorials } = useImportDataContext(); + const { enqueueSnackbar } = useCustomSnackbar(); + + const initialValues: UserFormState = useMemo( + () => convertCSVDataToFormData({ data, values: mappedColumns, tutorials }), + [data, mappedColumns, tutorials] + ); + + const handleSubmit: FormikSubmitCallback = async (values) => { + try { + const dtos: ICreateUserDTO[] = convertValuesToDTOS(values); + const response: IUser[] = await createManyUsers(dtos); + + enqueueSnackbar(`${response.length} Nutzer/innen wurden erstellt.`, { + variant: 'success', + }); + // TODO: Parse error message and show SnackbarWithList -- create a new type like "RequestError". + } catch (err) { + enqueueSnackbar(`Es konnten keine Nutzer/innen erstellt werden.`, { variant: 'error' }); + } + }; + + return ( + + + + ); +} + +export default AdjustImportedUserDataForm; diff --git a/client/src/view/import-data/adjust-data-form/components/UserDataBox.helpers.ts b/client/src/view/import-data/adjust-data-form/components/UserDataBox.helpers.ts new file mode 100644 index 000000000..56b43da11 --- /dev/null +++ b/client/src/view/import-data/adjust-data-form/components/UserDataBox.helpers.ts @@ -0,0 +1,75 @@ +import { CSVData, MappedColumns } from '../../ImportUsers.context'; +import { Role } from 'shared/model/Role'; +import { UserFormState } from '../AdjustImportedUserDataForm'; +import { Tutorial } from '../../../../model/Tutorial'; + +interface ConversionParams { + data: CSVData; + values: MappedColumns; + tutorials: Tutorial[]; +} + +function isRole(role: Role | undefined): role is Role { + return role !== undefined; +} + +function convertColumnToRoles(roleData?: string): Role[] { + const defaultRoles = [Role.TUTOR]; + + if (!roleData) { + return defaultRoles; + } + + const enumRoles = Object.values(Role); + const roles: Role[] = roleData + .split(',') + .map((r) => r.trim().toUpperCase()) + .map((r) => enumRoles.find((role) => r === role.toString())) + .filter(isRole); + + return roles.length > 0 ? roles : defaultRoles; +} + +function convertColumnToTutorials(tutorials: Tutorial[], tutorialData?: string): string[] { + if (!tutorialData) { + return []; + } + + const slots: string[] = tutorialData.split(',').map((s) => s.trim()); + const tutorialIds: string[] = []; + + for (const slot of slots) { + const tutorial = tutorials.find((t) => t.slot === slot); + if (!!tutorial) { + tutorialIds.push(tutorial.id); + } + } + + return tutorialIds; +} + +export function convertCSVDataToFormData(params: ConversionParams): UserFormState { + const { data, values, tutorials } = params; + const emptyString = 'N/A'; + + const userFormState: UserFormState = {}; + data.rows.forEach(({ rowNr, data }) => { + const key = rowNr.toString(); + userFormState[key] = { + rowNr, + firstname: data[values.firstnameColumn] ?? emptyString, + lastname: data[values.lastnameColumn] ?? emptyString, + email: data[values.emailColumn] ?? emptyString, + roles: convertColumnToRoles(data[values.rolesColumn]), + username: data[values.usernameColumn], + password: data[values.passwordColumn], + tutorials: convertColumnToTutorials(tutorials, data[values.tutorialsColumn]), + tutorialsToCorrect: convertColumnToTutorials( + tutorials, + data[values.tutorialsToCorrectColumn] + ), + }; + }); + + return userFormState; +} diff --git a/client/src/view/import-data/adjust-data-form/components/UserDataBox.tsx b/client/src/view/import-data/adjust-data-form/components/UserDataBox.tsx new file mode 100644 index 000000000..7f0e0d14d --- /dev/null +++ b/client/src/view/import-data/adjust-data-form/components/UserDataBox.tsx @@ -0,0 +1,36 @@ +import { Box, Typography } from '@material-ui/core'; +import { useFormikContext } from 'formik'; +import React, { useMemo } from 'react'; +import TableWithPadding from '../../../../components/TableWithPadding'; +import { UserFormState } from '../AdjustImportedUserDataForm'; +import UserDataRow from './UserDataRow'; +import { getNameOfEntity } from '../../../../../../server/src/shared/util/helpers'; + +function UserDataBox(): JSX.Element { + const { values } = useFormikContext(); + const users = useMemo( + () => + Object.values(values).sort((a, b) => getNameOfEntity(a).localeCompare(getNameOfEntity(b))), + [values] + ); + + return ( + + Nutzerdaten festlegen + + } + /> + + ); +} + +export default UserDataBox; diff --git a/client/src/view/import-data/adjust-data-form/components/UserDataRow.tsx b/client/src/view/import-data/adjust-data-form/components/UserDataRow.tsx new file mode 100644 index 000000000..51fb548d5 --- /dev/null +++ b/client/src/view/import-data/adjust-data-form/components/UserDataRow.tsx @@ -0,0 +1,157 @@ +import { + Box, + Chip, + createStyles, + IconButton, + makeStyles, + TableCell, + Typography, +} from '@material-ui/core'; +import { useField, useFormikContext } from 'formik'; +import { SquareEditOutline as EditIcon, Undo as ResetIcon } from 'mdi-material-ui'; +import React, { useState } from 'react'; +import { getNameOfEntity } from 'shared/util/helpers'; +import PaperTableRow from '../../../../components/PaperTableRow'; +import { useDialog } from '../../../../hooks/DialogService'; +import { FormikSubmitCallback } from '../../../../types'; +import { useImportDataContext } from '../../ImportUsers.context'; +import { UserFormState, UserFormStateValue } from '../AdjustImportedUserDataForm'; +import EditUserDialog, { EditFormState } from './edit-user-dialog/EditUserDialog'; + +const useStyles = makeStyles((theme) => + createStyles({ + chip: { + margin: theme.spacing(0, 1, 1, 0), + }, + editButton: { + marginRight: theme.spacing(1), + }, + }) +); + +interface UserDataRowProps { + name: string; +} + +interface TutorialOfUser { + id: string; + isCorrector: boolean; +} + +function getTutorialsOfUser(value: UserFormStateValue): TutorialOfUser[] { + const { tutorials, tutorialsToCorrect } = value; + const tutorialsOfUser: TutorialOfUser[] = []; + + tutorials.forEach((id) => tutorialsOfUser.push({ id, isCorrector: false })); + tutorialsToCorrect.forEach((id) => tutorialsOfUser.push({ id, isCorrector: true })); + + return tutorialsOfUser; +} + +function UserDataRow({ name }: UserDataRowProps): JSX.Element { + const classes = useStyles(); + + const [showDialog, setShowDialog] = useState(false); + const { values: parentValues } = useFormikContext(); + const [, meta, helpers] = useField(name); + const { tutorials } = useImportDataContext(); + const { showConfirmationDialog } = useDialog(); + + const { firstname, lastname, username, roles } = meta.value; + const tutorialsOfUser = getTutorialsOfUser(meta.value); + const subText = [username, ...roles].filter(Boolean).join(', '); + + const handleEditClicked = () => { + setShowDialog(true); + }; + + const handleFormSubmit: FormikSubmitCallback = (values) => { + helpers.setValue({ + ...meta.value, + roles: values.roles, + tutorials: values.tutorials, + tutorialsToCorrect: values.tutorialsToCorrect, + }); + + setShowDialog(false); + }; + + const handleResetClicked = async () => { + if (!meta.initialValue) { + return; + } + + const name: string = getNameOfEntity({ firstname, lastname }, { firstNameFirst: true }); + const isAcceptReset = await showConfirmationDialog({ + title: `${name} zurücksetzen?`, + content: `Sollen die Erstell-Daten für "${name}" wirklich zurückgesetzt werden? Dies kann nicht rückgängig gemacht werden.`, + acceptProps: { label: 'Zurücksetzen', deleteButton: true }, + cancelProps: { label: 'Nicht zurücksetzen' }, + }); + + if (isAcceptReset) { + helpers.setValue(meta.initialValue); + } + }; + + return ( + <> + + + + + + + + + } + > + + {tutorialsOfUser.length === 0 ? ( + + Keine Tutorien zugeordnet. + + ) : ( + tutorialsOfUser.map(({ id, isCorrector }) => { + const tutorial = tutorials.find((tut) => tut.id === id); + + if (!tutorial) { + return null; + } else { + const label = isCorrector + ? `Korrigiert: ${tutorial.toDisplayString()}` + : tutorial.toDisplayString(); + + return ( + + ); + } + }) + )} + + + + {showDialog && ( + setShowDialog(false)} + onCancelClicked={() => setShowDialog(false)} + onFormSubmit={handleFormSubmit} + userFormValue={meta.value} + parentValues={parentValues} + /> + )} + + ); +} + +export default UserDataRow; diff --git a/client/src/view/import-data/adjust-data-form/components/edit-user-dialog/EditUserDialog.tsx b/client/src/view/import-data/adjust-data-form/components/edit-user-dialog/EditUserDialog.tsx new file mode 100644 index 000000000..e8cecfb14 --- /dev/null +++ b/client/src/view/import-data/adjust-data-form/components/edit-user-dialog/EditUserDialog.tsx @@ -0,0 +1,72 @@ +import { + Button, + ButtonProps, + Dialog, + DialogActions, + DialogContent, + DialogProps, + DialogTitle, +} from '@material-ui/core'; +import { Formik } from 'formik'; +import React from 'react'; +import { Role } from 'shared/model/Role'; +import { getNameOfEntity } from 'shared/util/helpers'; +import { FormikSubmitCallback } from '../../../../../types'; +import { UserFormState, UserFormStateValue } from '../../AdjustImportedUserDataForm'; +import EditUserDialogContent from './EditUserDialogContent'; + +export interface EditFormState { + userId: string; + roles: Role[]; + tutorials: string[]; + tutorialsToCorrect: string[]; +} + +interface Props extends DialogProps { + parentValues: UserFormState; + userFormValue: UserFormStateValue; + onCancelClicked: ButtonProps['onClick']; + onFormSubmit: FormikSubmitCallback; +} + +function calculateInitialValues(value: UserFormStateValue): EditFormState { + const { roles, tutorials, tutorialsToCorrect, rowNr } = value; + + return { userId: rowNr.toString(), roles, tutorials, tutorialsToCorrect }; +} + +function EditUserDialog({ + onFormSubmit, + parentValues, + userFormValue, + onCancelClicked, + ...props +}: Props): JSX.Element { + const { firstname, lastname } = userFormValue; + + return ( + + + {({ submitForm }) => ( + <> + {getNameOfEntity({ firstname, lastname })} bearbeiten + + + + + + + + + + + + )} + + + ); +} + +export default EditUserDialog; diff --git a/client/src/view/import-data/adjust-data-form/components/edit-user-dialog/EditUserDialogContent.tsx b/client/src/view/import-data/adjust-data-form/components/edit-user-dialog/EditUserDialogContent.tsx new file mode 100644 index 000000000..71ef2ca11 --- /dev/null +++ b/client/src/view/import-data/adjust-data-form/components/edit-user-dialog/EditUserDialogContent.tsx @@ -0,0 +1,126 @@ +import { Box } from '@material-ui/core'; +import { useFormikContext } from 'formik'; +import React, { useCallback, useMemo } from 'react'; +import { Role } from 'shared/model/Role'; +import { IsItemDisabledFunction } from '../../../../../components/CustomSelect'; +import FormikSelect from '../../../../../components/forms/components/FormikSelect'; +import { Tutorial } from '../../../../../model/Tutorial'; +import { useImportDataContext } from '../../../ImportUsers.context'; +import { UserFormState } from '../../AdjustImportedUserDataForm'; +import { EditFormState } from './EditUserDialog'; + +interface EditUserDialogProps { + parentFormValue: UserFormState; +} + +function EditUserDialogContent({ parentFormValue }: EditUserDialogProps): JSX.Element { + const { values, setFieldValue } = useFormikContext(); + const { tutorials } = useImportDataContext(); + + const tutorialsOfOthers: string[] = useMemo( + () => + Object.values(parentFormValue).flatMap((user) => { + if (user.rowNr.toString() === values.userId) { + return []; + } + + return [...user.tutorials]; + }), + [parentFormValue, values.userId] + ); + + const isTutorialItemDisabled: IsItemDisabledFunction = useCallback( + (tutorial) => { + const isSelectedByOther = tutorialsOfOthers.findIndex((id) => id === tutorial.id) !== -1; + + if (isSelectedByOther) { + return { isDisabled: true, reason: 'Anderem/r Nutzer/in zugeordnet.' }; + } + + const isCorrectedBySelf = + values.tutorialsToCorrect.findIndex((id) => id === tutorial.id) !== -1; + + if (isCorrectedBySelf) { + return { isDisabled: true, reason: 'Nutzer/in korrigiert Tutorium bereits.' }; + } + + return { isDisabled: false }; + }, + [tutorialsOfOthers, values.tutorialsToCorrect] + ); + + const isTutorialToCorrectItemDisabled: IsItemDisabledFunction = useCallback( + (tutorial) => { + const isHoldBySelf = values.tutorials.findIndex((id) => id === tutorial.id) !== -1; + + if (isHoldBySelf) { + return { isDisabled: true, reason: 'Nutzer/in hält Tutorium bereits.' }; + } + + return { isDisabled: false }; + }, + [values.tutorials] + ); + + return ( + + { + const roles: string[] = e.target.value; + + if (!roles.includes(Role.TUTOR)) { + setFieldValue('tutorials', []); + } + + if (!roles.includes(Role.CORRECTOR)) { + setFieldValue('tutorialsToCorrect', []); + } + }} + items={[Role.ADMIN, Role.CORRECTOR, Role.TUTOR, Role.EMPLOYEE]} + itemToString={(role) => Role[role].toString()} + itemToValue={(role) => role} + multiple + isItemSelected={(role) => values['roles'].indexOf(role) > -1} + /> + + tutorial.toDisplayStringWithTime()} + itemToValue={(tutorial) => tutorial.id} + isItemDisabled={isTutorialItemDisabled} + multiple + isItemSelected={(tutorial) => values['tutorials'].indexOf(tutorial.id) > -1} + disabled={!values['roles'] || values['roles'].indexOf(Role.TUTOR) === -1} + /> + + tutorial.toDisplayStringWithTime()} + itemToValue={(tutorial) => tutorial.id} + isItemDisabled={isTutorialToCorrectItemDisabled} + multiple + isItemSelected={(tutorial) => values['tutorialsToCorrect'].indexOf(tutorial.id) > -1} + disabled={!values['roles'] || values['roles'].indexOf(Role.CORRECTOR) === -1} + /> + + ); +} + +export default EditUserDialogContent; diff --git a/client/src/view/import-data/import-csv/ImportUserCSV.tsx b/client/src/view/import-data/import-csv/ImportUserCSV.tsx new file mode 100644 index 000000000..0efb34e91 --- /dev/null +++ b/client/src/view/import-data/import-csv/ImportUserCSV.tsx @@ -0,0 +1,178 @@ +import { Box, Button, Divider, TextField, Typography } from '@material-ui/core'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; +import { Upload as UploadIcon } from 'mdi-material-ui'; +import React, { useCallback, useEffect, useState } from 'react'; +import LoadingModal from '../../../components/loading/LoadingModal'; +import { + NextStepCallback, + useStepper, +} from '../../../components/stepper-with-buttons/context/StepperContext'; +import { useDialog } from '../../../hooks/DialogService'; +import { getParsedCSV } from '../../../hooks/fetching/CSV'; +import { useCustomSnackbar } from '../../../hooks/snackbar/useCustomSnackbar'; +import { useKeyboardShortcut } from '../../../hooks/useKeyboardShortcut'; +import { useImportDataContext } from '../ImportUsers.context'; + +const useStyles = makeStyles((theme) => + createStyles({ + divider: { flex: 1, margin: theme.spacing(0, 2) }, + uploadLabel: { flex: 1 }, + uploadButton: { width: '100%' }, + }) +); + +function ImportUserCSV(): JSX.Element { + const classes = useStyles(); + + const { setNextCallback, removeNextCallback, setNextDisabled, nextStep } = useStepper(); + const { setData, csvFormData, setCSVFormData } = useImportDataContext(); + const { enqueueSnackbar, enqueueSnackbarWithList } = useCustomSnackbar(); + const { showConfirmationDialog } = useDialog(); + + const [csvInput, setCSVInput] = useState(csvFormData?.csvInput ?? ''); + const [seperator, setSeparator] = useState(csvFormData?.seperator ?? ''); + const [isLoadingCSV, setLoadingCSV] = useState(false); + + useKeyboardShortcut([{ key: 'Enter', modifiers: { ctrlKey: true } }], (e) => { + e.preventDefault(); + e.stopPropagation(); + + nextStep(); + }); + + const handleSubmit: NextStepCallback = useCallback(async () => { + if (!csvInput) { + return { goToNext: false, error: true }; + } + + try { + const response = await getParsedCSV<{ [header: string]: string }>({ + data: csvInput.trim(), + options: { header: true, delimiter: seperator }, + }); + + if (response.errors.length === 0) { + setData({ headers: response.meta.fields, rows: response.data }); + setCSVFormData({ csvInput, seperator }); + + enqueueSnackbar('CSV erfolgreich importiert.', { variant: 'success' }); + return { goToNext: true }; + } else { + enqueueSnackbarWithList({ + title: 'CSV konnte nicht importiert werden.', + textBeforeList: 'Folgende Fehler sind aufgetreten:', + items: response.errors.map((err) => `${err.message} (Zeile: ${err.row})`), + }); + return { goToNext: false, error: true }; + } + } catch { + enqueueSnackbar('CSV konnte nicht importiert werden.', { variant: 'error' }); + return { goToNext: false, error: true }; + } + }, [csvInput, seperator, setData, setCSVFormData, enqueueSnackbar, enqueueSnackbarWithList]); + + useEffect(() => { + setNextDisabled(!csvInput); + }, [csvInput, setNextDisabled]); + + useEffect(() => { + setNextCallback(handleSubmit); + + return removeNextCallback; + }, [setNextCallback, removeNextCallback, handleSubmit]); + + const handleFileUpload = async (e: React.ChangeEvent) => { + // We need the target after async code to reset the text field. + e.persist(); + const file = e.target.files?.[0]; + + if (!file) { + return; + } + + if (file.type !== 'application/vnd.ms-excel') { + enqueueSnackbar('Ausgewählte Datei ist keine CSV-Datei.', { variant: 'error' }); + return; + } + + setLoadingCSV(true); + if (!!csvInput) { + const overrideExisting = await showConfirmationDialog({ + title: 'Überschreiben?', + content: + 'Es sind noch Daten im Textfeld vorhanden. Sollen diese wirklich überschrieben werden?', + acceptProps: { label: 'Überschreiben', deleteButton: true }, + cancelProps: { label: 'Nicht überschreiben' }, + }); + + if (!overrideExisting) { + setLoadingCSV(false); + return; + } + } + const content: string = await file.text(); + + // Reset the file so the user could select the same file twice. + e.target.value = ''; + setCSVInput(content); + setLoadingCSV(false); + }; + + return ( + + + CSV importieren + + setSeparator(e.target.value)} + style={{ marginLeft: 'auto' }} + /> + + + setCSVInput(e.target.value)} + fullWidth + multiline + /> + + + + ODER + + + + + + + + + + + ); +} + +export default ImportUserCSV; diff --git a/client/src/view/import-data/map-columns/MapCSVColumns.tsx b/client/src/view/import-data/map-columns/MapCSVColumns.tsx new file mode 100644 index 000000000..2368d8776 --- /dev/null +++ b/client/src/view/import-data/map-columns/MapCSVColumns.tsx @@ -0,0 +1,210 @@ +import { Box, createStyles, makeStyles, Typography } from '@material-ui/core'; +import { Formik, useFormikContext } from 'formik'; +import React, { useEffect } from 'react'; +import * as Yup from 'yup'; +import FormikDebugDisplay from '../../../components/forms/components/FormikDebugDisplay'; +import FormikSelect from '../../../components/forms/components/FormikSelect'; +import { useStepper } from '../../../components/stepper-with-buttons/context/StepperContext'; +import { MappedColumns, useImportDataContext } from '../ImportUsers.context'; + +const useStyles = makeStyles((theme) => + createStyles({ + select: { + minWidth: 210, + marginTop: theme.spacing(2), + marginRight: theme.spacing(2), + }, + }) +); + +const validationSchema = Yup.object().shape({ + firstnameColumn: Yup.string().required('Benötigt.'), + lastnameColumn: Yup.string().required('Benötigt.'), + emailColumn: Yup.string().required('Benötigt.'), + rolesColumn: Yup.string(), + usernameColumn: Yup.string(), + passwordColumn: Yup.string(), + tutorialsColumn: Yup.string(), + tutorialsToCorrectColumn: Yup.string(), +}); + +function MapCSVColumnsContent(): JSX.Element { + const classes = useStyles(); + + const { values, isValid, validateForm } = useFormikContext(); + const { setNextCallback, removeNextCallback, setNextDisabled } = useStepper(); + const { + data: { headers }, + setMappedColumns, + } = useImportDataContext(); + + useEffect(() => { + setNextDisabled(!isValid); + }, [isValid, setNextDisabled]); + + useEffect(() => { + setNextCallback(async () => { + const results = await validateForm(); + if (Object.entries(results).length > 0) { + return { goToNext: false, error: true }; + } + + setMappedColumns(values); + return { goToNext: true }; + }); + + return removeNextCallback; + }, [setNextCallback, removeNextCallback, values, setMappedColumns, isValid, validateForm]); + + return ( + + Spalten zuordnen + + + Nutzerinformationen + + i} + itemToString={(i) => i} + emptyPlaceholder='Keine Überschriften verfügbar' + className={classes.select} + /> + + i} + itemToString={(i) => i} + emptyPlaceholder='Keine Überschriften verfügbar' + className={classes.select} + /> + + i} + itemToString={(i) => i} + emptyPlaceholder='Keine Überschriften verfügbar' + className={classes.select} + /> + + i} + itemToString={(i) => i} + emptyPlaceholder='Keine Überschriften verfügbar' + className={classes.select} + /> + + + + + Informationen über Tutorien + + i} + itemToString={(i) => i} + emptyPlaceholder='Keine Überschriften verfügbar' + className={classes.select} + /> + + i} + itemToString={(i) => i} + emptyPlaceholder='Keine Überschriften verfügbar' + className={classes.select} + /> + + + + + Zugangsdaten + + i} + itemToString={(i) => i} + emptyPlaceholder='Keine Überschriften verfügbar' + className={classes.select} + /> + + i} + itemToString={(i) => i} + emptyPlaceholder='Keine Überschriften verfügbar' + className={classes.select} + /> + + + + + + ); +} + +function MapCSVColumns(): JSX.Element { + const { mappedColumns } = useImportDataContext(); + const { nextStep } = useStepper(); + + return ( + nextStep()} + > + + + ); +} + +export default MapCSVColumns; diff --git a/client/src/view/points-scheinexam/enter-form/EnterScheinexamPoints.tsx b/client/src/view/points-scheinexam/enter-form/EnterScheinexamPoints.tsx index a4f91ef31..839338ae7 100644 --- a/client/src/view/points-scheinexam/enter-form/EnterScheinexamPoints.tsx +++ b/client/src/view/points-scheinexam/enter-form/EnterScheinexamPoints.tsx @@ -1,6 +1,5 @@ import { Box, CircularProgress, Typography } from '@material-ui/core'; import { createStyles, makeStyles } from '@material-ui/core/styles'; -import { useSnackbar } from 'notistack'; import React, { useEffect, useState } from 'react'; import { useHistory, useParams } from 'react-router'; import { getNameOfEntity } from 'shared/util/helpers'; @@ -11,7 +10,7 @@ import Placeholder from '../../../components/Placeholder'; import { getScheinexam } from '../../../hooks/fetching/ScheinExam'; import { getStudent, setExamPointsOfStudent } from '../../../hooks/fetching/Student'; import { getStudentsOfTutorial } from '../../../hooks/fetching/Tutorial'; -import { useErrorSnackbar } from '../../../hooks/useErrorSnackbar'; +import { useCustomSnackbar } from '../../../hooks/snackbar/useCustomSnackbar'; import { Scheinexam } from '../../../model/Scheinexam'; import { Student } from '../../../model/Student'; import { @@ -53,8 +52,7 @@ function EnterScheinexamPoints(): JSX.Element { const history = useHistory(); const { tutorialId, examId, studentId } = useParams(); - const { enqueueSnackbar } = useSnackbar(); - const { setError, isError } = useErrorSnackbar(); + const { enqueueSnackbar, setError, isError } = useCustomSnackbar(); const [exam, setExam] = useState(); const [student, setStudent] = useState(); diff --git a/client/src/view/points-scheinexam/enter-form/components/ScheinexamPointsForm.tsx b/client/src/view/points-scheinexam/enter-form/components/ScheinexamPointsForm.tsx index 2e51b988b..39d36b63a 100644 --- a/client/src/view/points-scheinexam/enter-form/components/ScheinexamPointsForm.tsx +++ b/client/src/view/points-scheinexam/enter-form/components/ScheinexamPointsForm.tsx @@ -87,15 +87,7 @@ function ScheinexamPointsFormInner({ exam, className, ...props }: FormProps): JS const classes = useStyles(); const formikContext = useFormikContext(); - const { - values, - errors, - handleSubmit, - resetForm, - isSubmitting, - dirty, - submitForm, - } = formikContext; + const { values, handleSubmit, resetForm, isSubmitting, dirty, submitForm } = formikContext; const dialog = useDialog(); @@ -195,7 +187,7 @@ function ScheinexamPointsFormInner({ exam, className, ...props }: FormProps): JS - + ); diff --git a/client/src/view/points-scheinexam/overview/ScheinexamPointsOverview.tsx b/client/src/view/points-scheinexam/overview/ScheinexamPointsOverview.tsx index 8d07df32e..6072b4f22 100644 --- a/client/src/view/points-scheinexam/overview/ScheinexamPointsOverview.tsx +++ b/client/src/view/points-scheinexam/overview/ScheinexamPointsOverview.tsx @@ -5,7 +5,7 @@ import CustomSelect from '../../../components/CustomSelect'; import Placeholder from '../../../components/Placeholder'; import { getAllScheinExams } from '../../../hooks/fetching/ScheinExam'; import { getStudentsOfTutorial } from '../../../hooks/fetching/Tutorial'; -import { useErrorSnackbar } from '../../../hooks/useErrorSnackbar'; +import { useErrorSnackbar } from '../../../hooks/snackbar/useErrorSnackbar'; import { Scheinexam } from '../../../model/Scheinexam'; import { Student } from '../../../model/Student'; import { getScheinexamPointsOverviewPath } from '../../../routes/Routing.helpers'; diff --git a/client/src/view/points-sheet/enter-form/EnterPoints.tsx b/client/src/view/points-sheet/enter-form/EnterPoints.tsx index bbcd695e9..6b3560b0a 100644 --- a/client/src/view/points-sheet/enter-form/EnterPoints.tsx +++ b/client/src/view/points-sheet/enter-form/EnterPoints.tsx @@ -5,7 +5,7 @@ import BackButton from '../../../components/BackButton'; import CustomSelect, { CustomSelectProps } from '../../../components/CustomSelect'; import Placeholder from '../../../components/Placeholder'; import { getSheet } from '../../../hooks/fetching/Sheet'; -import { useErrorSnackbar } from '../../../hooks/useErrorSnackbar'; +import { useErrorSnackbar } from '../../../hooks/snackbar/useErrorSnackbar'; import { Exercise } from '../../../model/Exercise'; import { Sheet } from '../../../model/Sheet'; import { getPointOverviewPath } from '../../../routes/Routing.helpers'; diff --git a/client/src/view/points-sheet/enter-form/EnterStudentPoints.tsx b/client/src/view/points-sheet/enter-form/EnterStudentPoints.tsx index 443ddda0e..76c83251f 100644 --- a/client/src/view/points-sheet/enter-form/EnterStudentPoints.tsx +++ b/client/src/view/points-sheet/enter-form/EnterStudentPoints.tsx @@ -1,12 +1,11 @@ import { Typography } from '@material-ui/core'; -import { useSnackbar } from 'notistack'; import React, { useEffect, useState } from 'react'; import { useHistory, useParams } from 'react-router'; import { getNameOfEntity } from 'shared/util/helpers'; import { IGradingDTO } from '../../../../../server/src/shared/model/Points'; import { getStudent, setPointsOfStudent } from '../../../hooks/fetching/Student'; import { getTeamOfTutorial } from '../../../hooks/fetching/Team'; -import { useErrorSnackbar } from '../../../hooks/useErrorSnackbar'; +import { useCustomSnackbar } from '../../../hooks/snackbar/useCustomSnackbar'; import { Student } from '../../../model/Student'; import { Team } from '../../../model/Team'; import { getEnterPointsForStudentPath } from '../../../routes/Routing.helpers'; @@ -25,8 +24,7 @@ function EnterStudentPoints(): JSX.Element { const history = useHistory(); const { tutorialId, sheetId, teamId, studentId } = useParams(); - const { enqueueSnackbar } = useSnackbar(); - const { setError } = useErrorSnackbar(); + const { enqueueSnackbar, setError } = useCustomSnackbar(); const [student, setStudent] = useState(); const [team, setTeam] = useState(); diff --git a/client/src/view/points-sheet/enter-form/EnterTeamPoints.tsx b/client/src/view/points-sheet/enter-form/EnterTeamPoints.tsx index 365aa0292..e64313890 100644 --- a/client/src/view/points-sheet/enter-form/EnterTeamPoints.tsx +++ b/client/src/view/points-sheet/enter-form/EnterTeamPoints.tsx @@ -1,5 +1,4 @@ import { Typography } from '@material-ui/core'; -import { useSnackbar } from 'notistack'; import React, { useEffect, useState } from 'react'; import { useHistory, useParams } from 'react-router'; import { @@ -7,7 +6,7 @@ import { getTeamsOfTutorial, setPointsOfTeam, } from '../../../hooks/fetching/Team'; -import { useErrorSnackbar } from '../../../hooks/useErrorSnackbar'; +import { useCustomSnackbar } from '../../../hooks/snackbar/useCustomSnackbar'; import { Team } from '../../../model/Team'; import { getEnterPointsForTeamPath } from '../../../routes/Routing.helpers'; import { PointsFormSubmitCallback } from './components/EnterPointsForm.helpers'; @@ -24,8 +23,7 @@ function EnterTeamPoints(): JSX.Element { const history = useHistory(); const { tutorialId, sheetId, teamId } = useParams(); - const { enqueueSnackbar } = useSnackbar(); - const { setError } = useErrorSnackbar(); + const { enqueueSnackbar, setError } = useCustomSnackbar(); const [teams, setTeams] = useState([]); const [selectedTeam, setSelectedTeam] = useState(); diff --git a/client/src/view/points-sheet/enter-form/components/EnterPointsForm.tsx b/client/src/view/points-sheet/enter-form/components/EnterPointsForm.tsx index 057f9d67c..26808d660 100644 --- a/client/src/view/points-sheet/enter-form/components/EnterPointsForm.tsx +++ b/client/src/view/points-sheet/enter-form/components/EnterPointsForm.tsx @@ -89,15 +89,7 @@ function EnterPointsFormInner({ sheet, exercise, className, ...props }: FormProp const dialog = useDialog(); const formikContext = useFormikContext(); - const { - values, - errors, - handleSubmit, - resetForm, - isSubmitting, - dirty, - submitForm, - } = formikContext; + const { values, handleSubmit, resetForm, isSubmitting, dirty, submitForm } = formikContext; const achieved = getAchievedPointsFromState(values); const total = getPointsOfAllExercises(sheet); @@ -181,7 +173,7 @@ function EnterPointsFormInner({ sheet, exercise, className, ...props }: FormProp - + ); diff --git a/client/src/view/points-sheet/overview/PointsOverview.tsx b/client/src/view/points-sheet/overview/PointsOverview.tsx index 07a8cbbbf..8fdba9672 100644 --- a/client/src/view/points-sheet/overview/PointsOverview.tsx +++ b/client/src/view/points-sheet/overview/PointsOverview.tsx @@ -1,15 +1,14 @@ import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import { useSnackbar } from 'notistack'; import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router'; import SubmitButton from '../../../components/loading/SubmitButton'; import Placeholder from '../../../components/Placeholder'; +import { useSheetSelector } from '../../../components/sheet-selector/SheetSelector'; import { getTeamsOfTutorial } from '../../../hooks/fetching/Team'; -import { useErrorSnackbar } from '../../../hooks/useErrorSnackbar'; +import { useCustomSnackbar } from '../../../hooks/snackbar/useCustomSnackbar'; import { usePDFs } from '../../../hooks/usePDFs'; import { Team } from '../../../model/Team'; import { getPointOverviewPath } from '../../../routes/Routing.helpers'; -import { useSheetSelector } from '../../../components/sheet-selector/SheetSelector'; import TeamCardList from './components/TeamCardList'; const useStyles = makeStyles((theme: Theme) => @@ -49,7 +48,7 @@ function PointsOverview(): JSX.Element { const classes = useStyles(); const { tutorialId } = useParams(); - const { SheetSelector, currentSheet, isLoadingSheets: isLoadingSheet } = useSheetSelector({ + const { SheetSelector, currentSheet, isLoadingSheets } = useSheetSelector({ generatePath: ({ sheetId }) => { if (!tutorialId) { throw new Error('The path needs to contain a tutorialId parameter.'); @@ -59,8 +58,7 @@ function PointsOverview(): JSX.Element { }, }); - const { setError } = useErrorSnackbar(); - const { enqueueSnackbar } = useSnackbar(); + const { enqueueSnackbar, setError } = useCustomSnackbar(); const [teams, setTeams] = useState([]); @@ -153,7 +151,7 @@ function PointsOverview(): JSX.Element { {currentSheet && tutorialId && ( (); - const { enqueueSnackbar } = useSnackbar(); - const { setError } = useErrorSnackbar(); + const { enqueueSnackbar, setError } = useCustomSnackbar(); const [student, setStudent] = useState(); const [sheets, setSheets] = useState([]); const [exams, setExams] = useState([]); const [tutorialOfStudent, setTutorialOfStudent] = useState(); const [scheinStatus, setScheinStatus] = useState(); - const [selectedTab, setSelectedTab] = React.useState(0); + const [selectedTab, setSelectedTab] = useState(0); useEffect(() => { getStudent(studentId) diff --git a/client/src/view/tutorialmanagement/TutorialManagement.tsx b/client/src/view/tutorialmanagement/TutorialManagement.tsx index c9f56f91b..750393c46 100644 --- a/client/src/view/tutorialmanagement/TutorialManagement.tsx +++ b/client/src/view/tutorialmanagement/TutorialManagement.tsx @@ -1,7 +1,10 @@ +import { Button } from '@material-ui/core'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; import { DateTime } from 'luxon'; +import { AutoFix as GenerateIcon } from 'mdi-material-ui'; import { withSnackbar, WithSnackbarProps } from 'notistack'; import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { HasId } from 'shared/model/Common'; import { Role } from 'shared/model/Role'; import { ITutorialDTO } from 'shared/model/Tutorial'; @@ -23,6 +26,7 @@ import { } from '../../hooks/fetching/Tutorial'; import { getUsersWithRole } from '../../hooks/fetching/User'; import { Tutorial } from '../../model/Tutorial'; +import { RoutingPath } from '../../routes/Routing.routes'; import { compareDateTimes } from '../../util/helperFunctions'; import TutorialTableRow from './components/TutorialTableRow'; @@ -191,6 +195,16 @@ function TutorialManagement({ enqueueSnackbar }: WithSnackbarProps): JSX.Element form={ } + topBarContent={ + + } items={tutorials} createRowFromItem={(tutorial) => ( - + )} diff --git a/client/src/view/tutorialmanagement/components/TutorialTableRow.tsx b/client/src/view/tutorialmanagement/components/TutorialTableRow.tsx index 76533be98..32282959b 100644 --- a/client/src/view/tutorialmanagement/components/TutorialTableRow.tsx +++ b/client/src/view/tutorialmanagement/components/TutorialTableRow.tsx @@ -2,7 +2,7 @@ import { Button, Chip, TableCell } from '@material-ui/core'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; import { DateTime } from 'luxon'; import React from 'react'; -import { renderLink } from '../../../components/drawer/components/renderLink'; +import { renderLink } from '../../../components/navigation-rail/components/renderLink'; import EntityListItemMenu from '../../../components/list-item-menu/EntityListItemMenu'; import PaperTableRow, { PaperTableRowProps } from '../../../components/PaperTableRow'; import { Tutorial } from '../../../model/Tutorial'; diff --git a/client/src/view/usermanagement/UserManagement.tsx b/client/src/view/usermanagement/UserManagement.tsx index 7b90a5e02..b724f080e 100644 --- a/client/src/view/usermanagement/UserManagement.tsx +++ b/client/src/view/usermanagement/UserManagement.tsx @@ -1,30 +1,37 @@ +import { Button } from '@material-ui/core'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import { useSnackbar, SnackbarKey } from 'notistack'; +import { + EmailSendOutline as SendIcon, + Printer as PrintIcon, + TableArrowDown as ImportIcon, +} from 'mdi-material-ui'; import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { MailingStatus } from 'shared/model/Mail'; import { Role } from 'shared/model/Role'; -import { ICreateUserDTO, IUserDTO, IUser } from 'shared/model/User'; +import { ICreateUserDTO, IUser, IUserDTO } from 'shared/model/User'; import { getNameOfEntity } from 'shared/util/helpers'; -import SubmitButton from '../../components/loading/SubmitButton'; import UserForm, { UserFormState, UserFormSubmitCallback } from '../../components/forms/UserForm'; import LoadingSpinner from '../../components/loading/LoadingSpinner'; -import SnackbarWithList from '../../components/SnackbarWithList'; +import SubmitButton from '../../components/loading/SubmitButton'; import TableWithForm from '../../components/TableWithForm'; import { useDialog } from '../../hooks/DialogService'; -import { saveBlob } from '../../util/helperFunctions'; -import UserTableRow from './components/UserTableRow'; +import { getCredentialsPDF } from '../../hooks/fetching/Files'; import { getAllTutorials } from '../../hooks/fetching/Tutorial'; import { - setTemporaryPassword, - getUsers, createUser, + deleteUser, editUser, + getUsers, sendCredentials as sendCredentialsRequest, sendCredentialsToSingleUser as sendCredentialsToSingleUserRequest, - deleteUser, + setTemporaryPassword, } from '../../hooks/fetching/User'; -import { getCredentialsPDF } from '../../hooks/fetching/Files'; +import { useCustomSnackbar } from '../../hooks/snackbar/useCustomSnackbar'; import { Tutorial } from '../../model/Tutorial'; +import { RoutingPath } from '../../routes/Routing.routes'; +import { saveBlob } from '../../util/helperFunctions'; +import UserTableRow from './components/UserTableRow'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -80,7 +87,7 @@ function UserManagement(): JSX.Element { const [isSendingCredentials, setSendingCredentials] = useState(false); const [users, setUsers] = useState([]); const [tutorials, setTutorials] = useState([]); - const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const { enqueueSnackbar, closeSnackbar, enqueueSnackbarWithList } = useCustomSnackbar(); const dialog = useDialog(); useEffect(() => { @@ -259,19 +266,12 @@ function UserManagement(): JSX.Element { return user ? getNameOfEntity(user) : 'NOT_FOUND'; }); - enqueueSnackbar('', { - persist: true, - content: (id: SnackbarKey) => ( - - ), + enqueueSnackbarWithList({ + title: 'Nicht zugestellte Zugangsdaten', + textBeforeList: + 'Die Zugangsdaten konnten nicht an folgende Nutzer/innen zugestellt werden:', + items: failedNames, + isOpen: true, }); } } catch { @@ -332,6 +332,7 @@ function UserManagement(): JSX.Element { } onClick={handleSendCredentials} > Zugangsdaten verschicken @@ -340,13 +341,22 @@ function UserManagement(): JSX.Element { } onClick={handlePrintCredentials} - style={{ - marginLeft: 8, - }} + style={{ marginLeft: 8 }} > Zugangsdaten ausdrucken + + } items={users} diff --git a/server/package.json b/server/package.json index 0458dadd8..00f1a0ef7 100644 --- a/server/package.json +++ b/server/package.json @@ -21,7 +21,7 @@ "test": "env-cmd cross-env NODE_ENV=test jest", "test:watch": "yarn test --watch", "test:cov": "yarn test --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:debug:win": "env-cmd cross-env NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register ../node_modules/jest/bin/jest.js --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { @@ -44,6 +44,7 @@ "mongoose-field-encryption": "^3.0.4", "nestjs-typegoose": "^7.1.28", "nodemailer": "^6.4.10", + "papaparse": "^5.2.0", "passport": "^0.4.1", "passport-local": "^1.0.0", "pug": "^3.0.0", @@ -66,6 +67,7 @@ "@types/mongoose": "^5.7.21", "@types/node": "^14.0.14", "@types/nodemailer": "^6.4.0", + "@types/papaparse": "^5.0.4", "@types/passport-local": "^1.0.33", "@types/pug": "^2.0.4", "@types/puppeteer": "1.19.0", diff --git a/server/src/helpers/validators/luxon.validator.ts b/server/src/helpers/validators/luxon.validator.ts index ff17c7ff9..99987e525 100644 --- a/server/src/helpers/validators/luxon.validator.ts +++ b/server/src/helpers/validators/luxon.validator.ts @@ -1,5 +1,5 @@ import { registerDecorator, ValidationOptions } from 'class-validator'; -import { DateTime } from 'luxon'; +import { DateTime, Interval } from 'luxon'; /** * Validates the property to match the luxon format of a date in the ISO format. @@ -29,3 +29,32 @@ export function IsLuxonDateTime(validationOptions?: ValidationOptions) { }); }; } + +/** + * Validates the property to match the luxon format of an interval in the ISO format. + * + * @param validationOptions Options passed to the class-validator. + */ +export function IsLuxonInterval(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + const message: any = { + message: validationOptions?.each + ? `each interval in ${propertyName} must be in a valid ISO format` + : `${propertyName} must be in the ISO format`, + }; + + registerDecorator({ + name: 'isLuxonInterval', + target: object.constructor, + propertyName, + options: { ...message, ...validationOptions }, + validator: { + validate(value: any): boolean { + const interval = Interval.fromISO(value); + + return interval.isValid && !interval.invalidReason; + }, + }, + }); + }; +} diff --git a/server/src/module/excel/excel.controller.ts b/server/src/module/excel/excel.controller.ts index 93703aae1..141219d66 100644 --- a/server/src/module/excel/excel.controller.ts +++ b/server/src/module/excel/excel.controller.ts @@ -1,7 +1,22 @@ -import { Controller, Get, Param, Res, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Post, + Res, + UseGuards, + UsePipes, + ValidationPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; import { Response } from 'express'; import { AllowCorrectors } from '../../guards/decorators/allowCorrectors.decorator'; +import { HasRoleGuard } from '../../guards/has-role.guard'; import { TutorialGuard } from '../../guards/tutorial.guard'; +import { ParseCsvResult } from '../../shared/model/CSV'; +import { ParseCsvDTO } from './excel.dto'; import { ExcelService } from './excel.service'; @Controller('excel') @@ -17,4 +32,12 @@ export class ExcelController { res.contentType('xlsx'); res.send(buffer); } + + @Post('/parseCSV') + @HttpCode(HttpStatus.OK) + @UseGuards(HasRoleGuard) + @UsePipes(ValidationPipe) + async parseCSV(@Body() body: ParseCsvDTO): Promise> { + return await this.excelService.parseCSV(body); + } } diff --git a/server/src/module/excel/excel.dto.ts b/server/src/module/excel/excel.dto.ts new file mode 100644 index 000000000..664713aa3 --- /dev/null +++ b/server/src/module/excel/excel.dto.ts @@ -0,0 +1,11 @@ +import { IsString, IsOptional } from 'class-validator'; +import { ParseConfig } from 'papaparse'; +import { IParseCsvDTO } from '../../shared/model/CSV'; + +export class ParseCsvDTO implements IParseCsvDTO { + @IsString() + data!: string; + + @IsOptional() + options?: ParseConfig; +} diff --git a/server/src/module/excel/excel.service.ts b/server/src/module/excel/excel.service.ts index fa29f0618..c8845a7fc 100644 --- a/server/src/module/excel/excel.service.ts +++ b/server/src/module/excel/excel.service.ts @@ -1,11 +1,14 @@ import { Injectable, Logger } from '@nestjs/common'; import xl, { Workbook, Worksheet } from 'excel4node'; +import { parse } from 'papaparse'; import { SheetDocument } from '../../database/models/sheet.model'; -import { StudentDocument, populateStudentDocument } from '../../database/models/student.model'; +import { populateStudentDocument, StudentDocument } from '../../database/models/student.model'; import { TutorialDocument } from '../../database/models/tutorial.model'; import { AttendanceState } from '../../shared/model/Attendance'; +import { ParseCsvResult } from '../../shared/model/CSV'; import { SheetService } from '../sheet/sheet.service'; import { TutorialService } from '../tutorial/tutorial.service'; +import { ParseCsvDTO } from './excel.dto'; interface HeaderData { name: string; @@ -81,6 +84,21 @@ export class ExcelService { return workbook.writeToBuffer(); } + /** + * Parses the given CSV string with the given options. + * + * Internally the papaparser is used to parse the string. If parsing fails the results papaparser result is still returned but it will be in it's errornous state. + * + * @param dto DTO with the CSV string to parse and the options to use. + * + * @returns Parsed results from papaparser. + */ + async parseCSV(dto: ParseCsvDTO): Promise> { + const { data, options } = dto; + + return parse(data, options); + } + private createMemberWorksheet(workbook: Workbook, students: StudentDocument[]) { const overviewSheet = workbook.addWorksheet('Teilnehmer'); const headers: HeaderDataCollection = { diff --git a/server/src/module/tutorial/tutorial.controller.ts b/server/src/module/tutorial/tutorial.controller.ts index fb41f1aaa..5a6b5159a 100644 --- a/server/src/module/tutorial/tutorial.controller.ts +++ b/server/src/module/tutorial/tutorial.controller.ts @@ -21,8 +21,9 @@ import { TutorialGuard } from '../../guards/tutorial.guard'; import { Role } from '../../shared/model/Role'; import { IStudent } from '../../shared/model/Student'; import { ITutorial } from '../../shared/model/Tutorial'; -import { SubstituteDTO, TutorialDTO } from './tutorial.dto'; +import { SubstituteDTO, TutorialDTO, TutorialGenerationDTO } from './tutorial.dto'; import { TutorialService } from './tutorial.service'; +import { ClassTransformerPipe } from '../../pipes/class-transformer.pipe'; @Controller('tutorial') export class TutorialController { @@ -46,6 +47,15 @@ export class TutorialController { return tutorial; } + @Post('/generate') + @UseGuards(HasRoleGuard) + @UsePipes(ClassTransformerPipe) + async createManyTutorials(@Body() dto: TutorialGenerationDTO): Promise { + const tutorials = await this.tutorialService.createMany(dto); + + return tutorials; + } + @Get('/:id') @UseGuards(TutorialGuard) @AllowSubstitutes() diff --git a/server/src/module/tutorial/tutorial.dto.ts b/server/src/module/tutorial/tutorial.dto.ts index d3fd75fb3..625486c09 100644 --- a/server/src/module/tutorial/tutorial.dto.ts +++ b/server/src/module/tutorial/tutorial.dto.ts @@ -1,6 +1,25 @@ -import { IsArray, IsOptional, IsString, IsMongoId } from 'class-validator'; -import { ITutorialDTO, ISubstituteDTO } from '../../shared/model/Tutorial'; -import { IsLuxonDateTime } from '../../helpers/validators/luxon.validator'; +import { BadRequestException } from '@nestjs/common'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsMongoId, + IsNumber, + IsOptional, + IsString, + Min, + ValidateNested, +} from 'class-validator'; +import { DateTime, Interval } from 'luxon'; +import { IsLuxonDateTime, IsLuxonInterval } from '../../helpers/validators/luxon.validator'; +import { + IExcludedDate, + ISubstituteDTO, + ITutorialDTO, + ITutorialGenerationData, + ITutorialGenerationDTO, + Weekday, +} from '../../shared/model/Tutorial'; export class TutorialDTO implements ITutorialDTO { @IsString() @@ -34,3 +53,74 @@ export class SubstituteDTO implements ISubstituteDTO { @IsLuxonDateTime({ each: true }) dates!: string[]; } + +export class ExcludedTutorialDate implements IExcludedDate { + @IsOptional() + @IsLuxonDateTime() + date?: string; + + @IsOptional() + @IsLuxonInterval() + interval?: string; + + getDates(): DateTime[] { + if (!!this.date) { + return [DateTime.fromISO(this.date)]; + } + + if (!!this.interval) { + return Interval.fromISO(this.interval) + .splitBy({ days: 1 }) + .map((i) => i.start.startOf('day')); + } + + throw new BadRequestException( + 'The ExcludedTutorialDate object does neither contain a date nor an interval property.' + ); + } +} + +export class TutorialGenerationData implements ITutorialGenerationData { + @IsString() + prefix!: string; + + @IsEnum(Weekday) + weekday!: Weekday; + + @IsNumber() + @Min(1) + amount!: number; + + @IsLuxonInterval() + interval!: string; + + getInterval(): Interval { + return Interval.fromISO(this.interval); + } +} + +export class TutorialGenerationDTO implements ITutorialGenerationDTO { + @IsLuxonDateTime() + firstDay!: string; + + @IsLuxonDateTime() + lastDay!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ExcludedTutorialDate) + excludedDates!: ExcludedTutorialDate[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TutorialGenerationData) + generationDatas!: TutorialGenerationData[]; + + getFirstDay(): DateTime { + return DateTime.fromISO(this.firstDay); + } + + getLastDay(): DateTime { + return DateTime.fromISO(this.lastDay); + } +} diff --git a/server/src/module/tutorial/tutorial.service.spec.ts b/server/src/module/tutorial/tutorial.service.spec.ts index 02ddde060..b5435642f 100644 --- a/server/src/module/tutorial/tutorial.service.spec.ts +++ b/server/src/module/tutorial/tutorial.service.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { DateTime, ToISOTimeOptions } from 'luxon'; +import { plainToClass } from 'class-transformer'; +import { DateTime, Interval, ToISOTimeOptions } from 'luxon'; import { generateObjectId } from '../../../test/helpers/test.helpers'; import { TestModule } from '../../../test/helpers/test.module'; import { MockedModel } from '../../../test/helpers/testdocument'; @@ -14,10 +15,10 @@ import { } from '../../../test/mocks/documents.mock.helpers'; import { TutorialModel } from '../../database/models/tutorial.model'; import { Role } from '../../shared/model/Role'; -import { ITutorial, UserInEntity } from '../../shared/model/Tutorial'; +import { ITutorial, ITutorialGenerationDTO, UserInEntity } from '../../shared/model/Tutorial'; import { UserService } from '../user/user.service'; +import { ExcludedTutorialDate, TutorialDTO, TutorialGenerationDTO } from './tutorial.dto'; import { TutorialService } from './tutorial.service'; -import { TutorialDTO } from './tutorial.dto'; interface AssertTutorialParams { expected: MockedModel; @@ -35,6 +36,11 @@ interface AssertTutorialDTOParams { oldTutorial?: ITutorial; } +interface AssertGenerateTutorialsParams { + expected: TutorialGenerationDTO; + actual: ITutorial[]; +} + /** * Checks if the `expected` and the `actual` tutorials are equal. * @@ -125,6 +131,92 @@ function assertTutorialDTO({ expected, actual, oldTutorial }: AssertTutorialDTOP } } +/** + * Returns a map organized by weekdays containing the ISO dates of all dates within the given interval which are NOT excluded via the `excludedDates` parameter. + * + * @param interval Interval to get dates from. + * @param excludedDates Information about all dates to exclude. + * + * @returns Map with weekdays as keys and arrays of ISO dates. Does not have some weekdays as keys if no day in the interval of that weekday is present. + */ +function getDatesInInterval( + interval: Interval, + excludedDates: ExcludedTutorialDate[] +): Map { + const dates: Map = new Map(); + let current = interval.start.startOf('day'); + + while (current <= interval.end) { + const datesInMap = dates.get(current.weekday) ?? []; + let isExcluded = false; + for (const excluded of excludedDates) { + if (!!excluded.getDates().find((date) => date.hasSame(current, 'day'))) { + isExcluded = true; + break; + } + } + + if (!isExcluded) { + datesInMap.push(current.toISODate()); + dates.set(current.weekday, datesInMap); + } + current = current.plus({ days: 1 }); + } + + return dates; +} + +/** + * Checks if the given generated tutorials (`actual`) match the information in the DTO. + * + * They match if: + * - `actual` has as many tutorials as `expected` defines. + * - Every generation information in `expected` has a corresponding tutorial. + * - These tutorials each match their generation data and each does NOT have a tutor or any correctors. + * + * @param expected DTO containing the information about the expected tutorials + * @param actual Array of actually generated tutorials. + */ +function assertGeneratedTutorials({ expected, actual }: AssertGenerateTutorialsParams) { + const { excludedDates, generationDatas } = expected; + const dates = getDatesInInterval( + Interval.fromDateTimes(expected.getFirstDay(), expected.getLastDay()), + excludedDates + ); + let amountToGenerate = 0; + + for (const data of generationDatas) { + const { amount, prefix, weekday } = data; + const time = data.getInterval(); + const tutorials = actual.filter((t) => { + const tutorialWeekDay = DateTime.fromISO(t.dates[0]).weekday; + + // We us toFormat() due to hasSame with 'hours' respecting days aswell - which is what we do NOT want here, we only want to compare hours and minutes (and the timezones). + const format = 'HH:mmZZ'; + const startTime = DateTime.fromISO(t.startTime); + const endTime = DateTime.fromISO(t.endTime); + + return ( + tutorialWeekDay === weekday && + time.start.toFormat(format) === startTime.toFormat(format) && + time.end.toFormat(format) === endTime.toFormat(format) + ); + }); + + expect(tutorials.length).toBe(amount); + amountToGenerate += amount; + + for (const tutorial of tutorials) { + expect(tutorial.slot.startsWith(prefix)).toBeTruthy(); + expect(tutorial.dates).toEqual(dates.get(weekday) ?? []); + expect(tutorial.tutor).toBeUndefined(); + expect(tutorial.correctors.length).toBe(0); + } + } + + expect(actual.length).toBe(amountToGenerate); +} + describe('TutorialService', () => { let testModule: TestingModule; let service: TutorialService; @@ -494,4 +586,220 @@ describe('TutorialService', () => { await expect(service.getAllStudentsOfTutorial(nonExisting)).rejects.toThrow(NotFoundException); }); + + it('generate multiple tutorials without excluded dates', async () => { + const dto: ITutorialGenerationDTO = { + firstDay: '2020-05-28', // Thursday + lastDay: '2020-06-12', // Friday + excludedDates: [], + generationDatas: [ + { + // Generate 2 in the slot Monady, 08:00 - 09:30 + amount: 2, + weekday: 1, + interval: '2020-05-28T08:00:00Z/2020-05-28T09:30:00Z', + prefix: 'Mo', + }, + { + // Generate 1 in the slot Wednesday, 15:45 - 17:15 + amount: 1, + weekday: 3, + interval: '2020-05-28T15:45:00Z/2020-05-28T17:00:00Z', + prefix: 'We', + }, + { + // Generate 1 in the slot Thursday, 14:00 - 15:30 + amount: 1, + weekday: 4, + interval: '2020-05-28T14:00:00Z/2020-05-28T15:30:00Z', + prefix: 'Th', + }, + ], + }; + const tutorialCountBefore = (await service.findAll()).length; + const generatedTutorials = await service.createMany(plainToClass(TutorialGenerationDTO, dto)); + const tutorialCountAfter = (await service.findAll()).length; + + expect(generatedTutorials.length).toBe(4); + expect(tutorialCountAfter).toBe(tutorialCountBefore + 4); + + assertGeneratedTutorials({ + expected: plainToClass(TutorialGenerationDTO, dto), + actual: generatedTutorials, + }); + }); + + it('make sure tutorial generation does take all days in the interval into account', async () => { + const dto: ITutorialGenerationDTO = { + firstDay: '2020-05-25', // Monday + lastDay: '2020-06-15', // Monday + excludedDates: [], + generationDatas: [ + { + // Generate 2 in the slot Monady, 08:00 - 09:30 + amount: 2, + weekday: 1, + interval: '2020-05-28T08:00:00Z/2020-05-28T09:30:00Z', + prefix: 'Mo', + }, + ], + }; + const tutorialCountBefore = (await service.findAll()).length; + const generatedTutorials = await service.createMany(plainToClass(TutorialGenerationDTO, dto)); + const tutorialCountAfter = (await service.findAll()).length; + + expect(generatedTutorials.length).toBe(2); + expect(tutorialCountAfter).toBe(tutorialCountBefore + 2); + + assertGeneratedTutorials({ + expected: plainToClass(TutorialGenerationDTO, dto), + actual: generatedTutorials, + }); + }); + + it('generate multiple tutorials with single excluded dates', async () => { + const dto: ITutorialGenerationDTO = { + firstDay: '2020-05-28', // Thursday + lastDay: '2020-06-12', // Friday + excludedDates: [{ date: '2020-06-01' }, { date: '2020-06-11' }], + generationDatas: [ + { + // Generate 2 in the slot Monady, 08:00 - 09:30 + amount: 2, + weekday: 1, + interval: '2020-05-28T08:00:00Z/2020-05-28T09:30:00Z', + prefix: 'Mo', + }, + { + // Generate 1 in the slot Wednesday, 15:45 - 17:15 + amount: 1, + weekday: 3, + interval: '2020-05-28T15:45:00Z/2020-05-28T17:00:00Z', + prefix: 'We', + }, + { + // Generate 1 in the slot Thursday, 14:00 - 15:30 + amount: 1, + weekday: 4, + interval: '2020-05-28T14:00:00Z/2020-05-28T15:30:00Z', + prefix: 'Th', + }, + ], + }; + const tutorialCountBefore = (await service.findAll()).length; + const generatedTutorials = await service.createMany(plainToClass(TutorialGenerationDTO, dto)); + const tutorialCountAfter = (await service.findAll()).length; + + expect(generatedTutorials.length).toBe(4); + expect(tutorialCountAfter).toBe(tutorialCountBefore + 4); + + assertGeneratedTutorials({ + expected: plainToClass(TutorialGenerationDTO, dto), + actual: generatedTutorials, + }); + }); + + it('generate multiple tutorials with an excluded interval', async () => { + const dto: ITutorialGenerationDTO = { + firstDay: '2020-05-28', // Thursday + lastDay: '2020-06-12', // Friday + excludedDates: [{ interval: '2020-06-08/2020-06-14' }], + generationDatas: [ + { + // Generate 2 in the slot Monady, 08:00 - 09:30 + amount: 2, + weekday: 1, + interval: '2020-05-28T08:00:00Z/2020-05-28T09:30:00Z', + prefix: 'Mo', + }, + { + // Generate 1 in the slot Wednesday, 15:45 - 17:15 + amount: 1, + weekday: 3, + interval: '2020-05-28T15:45:00Z/2020-05-28T17:00:00Z', + prefix: 'We', + }, + { + // Generate 1 in the slot Thursday, 14:00 - 15:30 + amount: 1, + weekday: 4, + interval: '2020-05-28T14:00:00Z/2020-05-28T15:30:00Z', + prefix: 'Th', + }, + ], + }; + const tutorialCountBefore = (await service.findAll()).length; + const generatedTutorials = await service.createMany(plainToClass(TutorialGenerationDTO, dto)); + const tutorialCountAfter = (await service.findAll()).length; + + expect(generatedTutorials.length).toBe(4); + expect(tutorialCountAfter).toBe(tutorialCountBefore + 4); + + assertGeneratedTutorials({ + expected: plainToClass(TutorialGenerationDTO, dto), + actual: generatedTutorials, + }); + }); + + it('generate multiple tutorials with mixed excluded dates', async () => { + const dto: ITutorialGenerationDTO = { + firstDay: '2020-05-28', // Thursday + lastDay: '2020-06-12', // Friday + excludedDates: [{ date: '2020-06-04' }, { interval: '2020-06-08/2020-06-14' }], + generationDatas: [ + { + // Generate 2 in the slot Monady, 08:00 - 09:30 + amount: 2, + weekday: 1, + interval: '2020-05-28T08:00:00Z/2020-05-28T09:30:00Z', + prefix: 'Mo', + }, + { + // Generate 1 in the slot Wednesday, 15:45 - 17:15 + amount: 1, + weekday: 3, + interval: '2020-05-28T15:45:00Z/2020-05-28T17:00:00Z', + prefix: 'We', + }, + { + // Generate 1 in the slot Thursday, 14:00 - 15:30 + amount: 1, + weekday: 4, + interval: '2020-05-28T14:00:00Z/2020-05-28T15:30:00Z', + prefix: 'Th', + }, + ], + }; + const tutorialCountBefore = (await service.findAll()).length; + const generatedTutorials = await service.createMany(plainToClass(TutorialGenerationDTO, dto)); + const tutorialCountAfter = (await service.findAll()).length; + + expect(generatedTutorials.length).toBe(4); + expect(tutorialCountAfter).toBe(tutorialCountBefore + 4); + + assertGeneratedTutorials({ + expected: plainToClass(TutorialGenerationDTO, dto), + actual: generatedTutorials, + }); + }); + + it('do NOT generate a tutorial if no dates are available', async () => { + const dto: ITutorialGenerationDTO = { + firstDay: '2020-05-28', // Thursday + lastDay: '2020-06-12', // Friday + excludedDates: [{ date: '2020-06-03' }, { interval: '2020-06-08/2020-06-14' }], // All wednesdays are excluded! + generationDatas: [ + { + // Effectivly generate 0 in the slot Wednesday, 15:45 - 17:15 + amount: 1, + weekday: 3, + interval: '2020-05-28T15:45:00Z/2020-05-28T17:00:00Z', + prefix: 'We', + }, + ], + }; + const generatedTutorials = await service.createMany(plainToClass(TutorialGenerationDTO, dto)); + + expect(generatedTutorials.length).toBe(0); + }); }); diff --git a/server/src/module/tutorial/tutorial.service.ts b/server/src/module/tutorial/tutorial.service.ts index b09986a4a..a770bc79f 100644 --- a/server/src/module/tutorial/tutorial.service.ts +++ b/server/src/module/tutorial/tutorial.service.ts @@ -6,7 +6,7 @@ import { NotFoundException, } from '@nestjs/common'; import { ReturnModelType } from '@typegoose/typegoose'; -import { DateTime } from 'luxon'; +import { DateTime, Interval } from 'luxon'; import { InjectModel } from 'nestjs-typegoose'; import { StudentDocument } from '../../database/models/student.model'; import { @@ -19,7 +19,12 @@ import { CRUDService } from '../../helpers/CRUDService'; import { Role } from '../../shared/model/Role'; import { ITutorial } from '../../shared/model/Tutorial'; import { UserService } from '../user/user.service'; -import { SubstituteDTO, TutorialDTO } from './tutorial.dto'; +import { + ExcludedTutorialDate, + SubstituteDTO, + TutorialDTO, + TutorialGenerationDTO, +} from './tutorial.dto'; @Injectable() export class TutorialService implements CRUDService { @@ -79,23 +84,15 @@ export class TutorialService implements CRUDService this.userService.findById(id))), ]); - this.assertTutorHasTutorRole(tutor); - this.assertCorrectorsHaveCorrectorRole(correctors); - - const startDate = DateTime.fromISO(startTime); - const endDate = DateTime.fromISO(endTime); - - const tutorial = new TutorialModel({ + const created = await this.createTutorial({ slot, tutor, - startTime: startDate, - endTime: endDate, - dates: dates.map((date) => DateTime.fromISO(date)), correctors, + startTime: DateTime.fromISO(startTime), + endTime: DateTime.fromISO(endTime), + dates: dates.map((date) => DateTime.fromISO(date)), }); - const created = await this.tutorialModel.create(tutorial); - return created.toDTO(); } @@ -152,9 +149,24 @@ export class TutorialService implements CRUDService team.remove())); + return tutorial.remove(); } + /** + * Sets the substitute for the given dates to the given tutor. + * + * If the DTO does not contain a `tutorId` field (ie it is `undefined`) the substitutes of the given dates will be removed. If there is already a substitute for a given date in the DTO the previous substitute gets overridden. + * + * @param id ID of the tutorial. + * @param dto DTO containing the information of the substitute. + * + * @returns Updated tutorial. + * + * @throws `BadRequestException` - If the tutorial of the given `id` parameter could not be found. + * @throws `BadRequestException` - If the `tutorId` field contains a user ID which can not be found or which does not belong to a tutor. + */ async setSubstitute(id: string, dto: SubstituteDTO): Promise { const tutorial = await this.findById(id); const { dates, tutorId } = dto; @@ -188,6 +200,138 @@ export class TutorialService implements CRUDService { + const { excludedDates, generationDatas } = dto; + const createdTutorials: TutorialDocument[] = []; + const interval = Interval.fromDateTimes(dto.getFirstDay(), dto.getLastDay()); + const daysInInterval = this.datesInIntervalGroupedByWeekday(interval); + const indexForWeekday: { [key: string]: number } = {}; + + for (const data of generationDatas) { + const { amount, prefix, weekday } = data; + const days = daysInInterval.get(weekday) ?? []; + const dates = this.removeExcludedDates(days, excludedDates); + const timeInterval = data.getInterval(); + + if (dates.length > 0) { + for (let i = 0; i < amount; i++) { + const nr = (indexForWeekday[weekday] ?? 0) + 1; + const created = await this.createTutorial({ + slot: `${prefix}${nr.toString().padStart(2, '0')}`, + dates, + startTime: timeInterval.start, + endTime: timeInterval.end, + tutor: undefined, + correctors: [], + }); + + indexForWeekday[weekday] = nr; + createdTutorials.push(created); + } + } + } + + return createdTutorials.map((t) => t.toDTO()); + } + + /** + * Creates a new tutorial and adds it to the database. + * + * This function first checks if the given `tutor` and `correctors` are all valid. Afterwards a new TutorialDocument is created and saved in the database. This document is returned in the end. + * + * @param params Parameters needed to create a tutorial. + * + * @returns Document of the created tutorial. + * + * @throws `BadRequestException` - If the given `tutor` is not a TUTOR or one of the given `correctors` is not a CORRECTOR. + */ + private async createTutorial({ + slot, + tutor, + startTime, + endTime, + dates, + correctors, + }: CreateParameters): Promise { + this.assertTutorHasTutorRole(tutor); + this.assertCorrectorsHaveCorrectorRole(correctors); + this.assertAtLeastOneDate(dates); + + const tutorial = new TutorialModel({ + slot, + tutor, + startTime, + endTime, + dates, + correctors, + }); + + return this.tutorialModel.create(tutorial); + } + + /** + * Returns all dates in the interval grouped by their weekday. + * + * Groups all dates in the interval by their weekday (1 - monday, 7 - sunday) and returns a map with those weekdays as keys. The map only contains weekdays as keys which are present in the interval (ie if only a monday and a tuesday are in the interval the map will only contain the keys `1` and `2`). + * + * @param interval Interval to get dates from. + * + * @returns Map with weekdays as keys and all dates from the interval on the corresponding weekday. Note: Not all weekdays may be present in the returned map. + */ + private datesInIntervalGroupedByWeekday(interval: Interval): Map { + const datesInInterval: Map = new Map(); + let cursor = interval.start.startOf('day'); + + while (cursor <= interval.end) { + const dates = datesInInterval.get(cursor.weekday) ?? []; + + dates.push(cursor); + datesInInterval.set(cursor.weekday, dates); + + cursor = cursor.plus({ day: 1 }); + } + + return datesInInterval; + } + + /** + * Creates a copy of the `dates` array without the excluded dates. + * + * The `dates` array itself will **not** be changed but copied in the process. + * + * @param dates Dates to remove the excludedDates from. + * @param excludedDates Information about the dates which should be excluded. + * + * @returns A copy of the `dates` array but without the excluded dates. + */ + private removeExcludedDates( + dates: DateTime[], + excludedDates: ExcludedTutorialDate[] + ): DateTime[] { + const dateArray = [...dates]; + + for (const excluded of excludedDates) { + for (const excludedDate of excluded.getDates()) { + const idx = dateArray.findIndex((date) => date.hasSame(excludedDate, 'day')); + + if (idx !== -1) { + dateArray.splice(idx, 1); + } + } + } + + return dateArray; + } + private assertTutorHasTutorRole(tutor?: UserDocument) { if (tutor && !tutor.roles.includes(Role.TUTOR)) { throw new BadRequestException('The tutor of a tutorial needs to have the TUTOR role.'); @@ -211,4 +355,21 @@ export class TutorialService implements CRUDService { + const createdUsers = await this.userService.createMany(users); + + return createdUsers; + } + @Get('/:id') @UseGuards(SameUserGuard) @Roles(Role.ADMIN, Role.EMPLOYEE) diff --git a/server/src/module/user/user.service.spec.ts b/server/src/module/user/user.service.spec.ts index 541c44526..ec4edf949 100644 --- a/server/src/module/user/user.service.spec.ts +++ b/server/src/module/user/user.service.spec.ts @@ -23,10 +23,15 @@ interface AssertUserListParam { } interface AssertUserDTOParams { - expected: UserDTO; + expected: UserDTO & { password?: string }; actual: IUser; } +interface AssertGeneratedUsersParams { + expected: CreateUserDTO[]; + actual: IUser[]; +} + /** * Checks if the given user representations are considered equal. * @@ -80,12 +85,13 @@ function assertUserList({ expected, actual }: AssertUserListParam) { * Equalitiy is defined as: * - The IDs of the tutorials are the same. * - The IDS of the tutorials to correct are the same. + * - If `expected` has a `password` field the `temporaryPassword` field of the `actual` user must match the `password` field. * - The rest of `expected` matches the rest of `actual` (__excluding `id` and `temporaryPassword`__). * * @param params Must contain an expected UserDTO and an actual User. */ function assertUserDTO({ expected, actual }: AssertUserDTOParams) { - const { tutorials, tutorialsToCorrect, ...restExpected } = expected; + const { tutorials, tutorialsToCorrect, password, ...restExpected } = expected; const { id, temporaryPassword, @@ -99,9 +105,36 @@ function assertUserDTO({ expected, actual }: AssertUserDTOParams) { expect(actualTutorials.map((tutorial) => tutorial.id)).toEqual(tutorials); expect(actualToCorrect.map((tutorial) => tutorial.id)).toEqual(tutorialsToCorrect); + if (!!password) { + expect(temporaryPassword).toEqual(password); + } + expect(restActual).toEqual(restExpected); } +/** + * Checks if the two given lists match. + * + * Matching is defined as: + * - Both lists have the same length. + * - Each DTO has a corresponding user which got created using it's information. + * + * @param expected List containing the DTO holding the information of the users which should have been created. + * @param actual List of actually generated users. + */ +function assertGeneratedUsers({ expected, actual }: AssertGeneratedUsersParams) { + expect(actual.length).toBe(expected.length); + + for (const { ...dto } of expected) { + const idx = actual.findIndex((u) => u.username === dto.username); + const user = actual[idx]; + + expect(idx).not.toBe(-1); + assertUserDTO({ expected: dto, actual: user }); + // expect(user.temporaryPassword).toEqual(password); + } +} + describe('UserService', () => { let testModule: TestingModule; let service: UserService; @@ -150,10 +183,8 @@ describe('UserService', () => { const createdUser: IUser = await service.create(userToCreate); const { password, ...expected } = userToCreate; - const { temporaryPassword } = sanitizeObject(createdUser); assertUserDTO({ expected, actual: createdUser }); - expect(temporaryPassword).toBe(password); }); it('create user with ONE tutorial', async () => { @@ -169,10 +200,8 @@ describe('UserService', () => { }; const createdUser: IUser = await service.create(userToCreate); const { password, ...expected } = userToCreate; - const { temporaryPassword } = sanitizeObject(createdUser); assertUserDTO({ expected, actual: createdUser }); - expect(temporaryPassword).toBe(password); }); it('create user with multiple tutorials', async () => { @@ -189,10 +218,8 @@ describe('UserService', () => { const createdUser: IUser = await service.create(userToCreate); const { password, ...expected } = userToCreate; - const { temporaryPassword } = sanitizeObject(createdUser); assertUserDTO({ expected, actual: createdUser }); - expect(temporaryPassword).toBe(password); }); it('fail on creating a user with already existing username', async () => { @@ -224,10 +251,8 @@ describe('UserService', () => { const createdUser: IUser = await service.create(userToCreate); const { password, ...expected } = userToCreate; - const { temporaryPassword } = sanitizeObject(createdUser); assertUserDTO({ expected, actual: createdUser }); - expect(temporaryPassword).toBe(password); }); it('create user with multiple tutorials to correct', async () => { @@ -244,10 +269,8 @@ describe('UserService', () => { const createdUser: IUser = await service.create(userToCreate); const { password, ...expected } = userToCreate; - const { temporaryPassword } = sanitizeObject(createdUser); assertUserDTO({ expected, actual: createdUser }); - expect(temporaryPassword).toBe(password); }); it('fail on creating non-tutor with tutorials', async () => { @@ -295,6 +318,190 @@ describe('UserService', () => { await expect(service.create(userToCreate)).rejects.toThrow(BadRequestException); }); + it('create multiple users without tutorials', async () => { + const usersToCreate: CreateUserDTO[] = [ + { + firstname: 'Harry', + lastname: 'Potter', + email: 'harrypotter@hogwarts.com', + username: 'usernameOfHarry', + password: 'harrysPassword', + roles: [Role.TUTOR], + tutorials: [], + tutorialsToCorrect: [], + }, + { + firstname: 'Granger', + lastname: 'Hermine', + email: 'herminegranger@hogwarts.com', + username: 'grangehe', + password: 'grangersPassword', + roles: [Role.TUTOR], + tutorials: [], + tutorialsToCorrect: [], + }, + ]; + + const created = await service.createMany(usersToCreate); + + assertGeneratedUsers({ expected: usersToCreate, actual: created }); + }); + + it('create mutliple users with one tutorial each', async () => { + const usersToCreate: CreateUserDTO[] = [ + { + firstname: 'Harry', + lastname: 'Potter', + email: 'harrypotter@hogwarts.com', + username: 'usernameOfHarry', + password: 'harrysPassword', + roles: [Role.TUTOR], + tutorials: [TUTORIAL_DOCUMENTS[0]._id], + tutorialsToCorrect: [], + }, + { + firstname: 'Granger', + lastname: 'Hermine', + email: 'herminegranger@hogwarts.com', + username: 'grangehe', + password: 'grangersPassword', + roles: [Role.TUTOR], + tutorials: [TUTORIAL_DOCUMENTS[1]._id], + tutorialsToCorrect: [], + }, + ]; + + const created = await service.createMany(usersToCreate); + + assertGeneratedUsers({ expected: usersToCreate, actual: created }); + }); + + it('create multiple users with one tutorial to correct each', async () => { + const usersToCreate: CreateUserDTO[] = [ + { + firstname: 'Harry', + lastname: 'Potter', + email: 'harrypotter@hogwarts.com', + username: 'usernameOfHarry', + password: 'harrysPassword', + roles: [Role.CORRECTOR], + tutorials: [], + tutorialsToCorrect: [TUTORIAL_DOCUMENTS[0]._id], + }, + { + firstname: 'Granger', + lastname: 'Hermine', + email: 'herminegranger@hogwarts.com', + username: 'grangehe', + password: 'grangersPassword', + roles: [Role.CORRECTOR], + tutorials: [], + tutorialsToCorrect: [TUTORIAL_DOCUMENTS[1]._id], + }, + ]; + + const created = await service.createMany(usersToCreate); + + assertGeneratedUsers({ expected: usersToCreate, actual: created }); + }); + + it('create multiple users with tutorials and tutorials to correct.', async () => { + const usersToCreate: CreateUserDTO[] = [ + { + firstname: 'Harry', + lastname: 'Potter', + email: 'harrypotter@hogwarts.com', + username: 'usernameOfHarry', + password: 'harrysPassword', + roles: [Role.TUTOR], + tutorials: [TUTORIAL_DOCUMENTS[0]._id], + tutorialsToCorrect: [], + }, + { + firstname: 'Granger', + lastname: 'Hermine', + email: 'herminegranger@hogwarts.com', + username: 'grangehe', + password: 'grangersPassword', + roles: [Role.TUTOR, Role.CORRECTOR], + tutorials: [TUTORIAL_DOCUMENTS[1]._id], + tutorialsToCorrect: [TUTORIAL_DOCUMENTS[0]._id], + }, + ]; + + const created = await service.createMany(usersToCreate); + + assertGeneratedUsers({ expected: usersToCreate, actual: created }); + }); + + it('fail with correct error on creating multiple users with tutorials where one is NOT a tutor', async () => { + const usersToCreate: CreateUserDTO[] = [ + { + firstname: 'Harry', + lastname: 'Potter', + email: 'harrypotter@hogwarts.com', + username: 'usernameOfHarry', + password: 'harrysPassword', + roles: [Role.CORRECTOR], + tutorials: [TUTORIAL_DOCUMENTS[0]._id], + tutorialsToCorrect: [], + }, + { + firstname: 'Hermine', + lastname: 'Granger', + email: 'herminegranger@hogwarts.com', + username: 'grangehe', + password: 'grangersPassword', + roles: [Role.TUTOR], + tutorials: [TUTORIAL_DOCUMENTS[1]._id], + tutorialsToCorrect: [], + }, + ]; + + const userCountBefore = (await service.findAll()).length; + await expect(service.createMany(usersToCreate)).rejects.toThrow( + `["[Potter, Harry]: A user with tutorials needs to have the 'TUTOR' role"]` + ); + + // No user should have effectively been created. + const userCountAfter = (await service.findAll()).length; + expect(userCountAfter).toBe(userCountBefore); + }); + + it('fail with correct error on creating multiple users with tutorials to correct where one is NOT a corrector', async () => { + const usersToCreate: CreateUserDTO[] = [ + { + firstname: 'Harry', + lastname: 'Potter', + email: 'harrypotter@hogwarts.com', + username: 'usernameOfHarry', + password: 'harrysPassword', + roles: [Role.CORRECTOR], + tutorials: [], + tutorialsToCorrect: [TUTORIAL_DOCUMENTS[0]._id], + }, + { + firstname: 'Hermine', + lastname: 'Granger', + email: 'herminegranger@hogwarts.com', + username: 'grangehe', + password: 'grangersPassword', + roles: [Role.TUTOR], + tutorials: [], + tutorialsToCorrect: [TUTORIAL_DOCUMENTS[1]._id], + }, + ]; + + const userCountBefore = (await service.findAll()).length; + await expect(service.createMany(usersToCreate)).rejects.toThrow( + `["[Granger, Hermine]: A user with tutorials to correct needs to have the 'CORRECTOR' role"]` + ); + + // No user should have effectively been created. + const userCountAfter = (await service.findAll()).length; + expect(userCountAfter).toBe(userCountBefore); + }); + it('get a user with a specific ID', async () => { const expected = USER_DOCUMENTS[0]; const user = await service.findById(expected._id); diff --git a/server/src/module/user/user.service.ts b/server/src/module/user/user.service.ts index 940c583f5..70b3e3a76 100644 --- a/server/src/module/user/user.service.ts +++ b/server/src/module/user/user.service.ts @@ -109,44 +109,34 @@ export class UserService implements OnModuleInit, CRUDService { - const { - tutorials: tutorialIds, - tutorialsToCorrect: toCorrectIds, - password, - username, - ...dto - } = user; - - await this.checkUserDTO(user); - - const tutorials = await this.getAllTutorials(tutorialIds); - const tutorialsToCorrect = await this.getAllTutorials(toCorrectIds); - - const userDocument: UserModel = new UserModel({ - ...dto, - username, - password, - temporaryPassword: password, - }); - - const result = (await this.userModel.create(userDocument)) as UserDocument; + const createdUser = await this.createUser(user); + return createdUser.toDTO(); + } - await Promise.all( - tutorials.map((tutorial) => { - tutorial.tutor = result; - return tutorial.save(); - }) - ); + async createMany(users: CreateUserDTO[]): Promise { + // TODO: Better logic in error cases due to an error state should not effectivly change the database state. + // 1. If creation fails save the error message in a list (like one does right now). + // 2.1 If this list is empty -> Leave DB as is (users are already created) and return created users. + // 2.2 If this list has errors -> Delete all previously created users from the DB (.remove()) and throw an Error with a list of the error message so the client can pick it up. + const created: UserDocument[] = []; + const errors: string[] = []; + + for (const user of users) { + try { + const doc = await this.createUser(user); + created.push(doc); + } catch (err) { + const message = err.message || 'Unknown error.'; + errors.push(`[${user.lastname}, ${user.firstname}]: ${message}`); + } + } - await Promise.all( - tutorialsToCorrect.map((tutorial) => { - tutorial.correctors.push(result); - return tutorial.save(); - }) - ); + if (errors.length > 0) { + await Promise.all(created.map((u) => u.remove())); + throw new BadRequestException(JSON.stringify(errors)); + } - const createdUser = await this.findById(result.id); - return createdUser.toDTO(); + return created.map((u) => u.toDTO()); } /** @@ -357,6 +347,71 @@ export class UserService implements OnModuleInit, CRUDService { + await this.checkUserDTO(user); + const { + tutorials: tutorialIds, + tutorialsToCorrect: toCorrectIds, + password, + username, + ...dto + } = user; + const userDocument: UserModel = new UserModel({ + ...dto, + username, + password, + temporaryPassword: password, + }); + + const [tutorials, tutorialsToCorrect] = await Promise.all([ + this.getAllTutorials(tutorialIds), + this.getAllTutorials(toCorrectIds), + ]); + + const result = (await this.userModel.create(userDocument)) as UserDocument; + + await this.updateTutorialsWithUser({ tutor: result, tutorials, tutorialsToCorrect }); + + return this.findById(result.id); + } + + /** + * Sets the given `tutor` as tutor for all given `tutorials` and as corrector for all given `tutorialsToCorrect`. + * + * @param tutor Tutor or corrector to set as tutor of the `tutorials` and as corrector of the `tutorialsToCorrect`. + * @param tutorials Tutorials to set the given `tutor` as tutor. + * @param tutorialsToCorrect Tutorials to set the given `tutor` as corrector. + */ + private async updateTutorialsWithUser({ + tutor, + tutorials, + tutorialsToCorrect, + }: UpdateTutorialsParams): Promise { + await Promise.all( + tutorials.map((tutorial) => { + tutorial.tutor = tutor; + return tutorial.save(); + }) + ); + + await Promise.all( + tutorialsToCorrect.map((tutorial) => { + tutorial.correctors.push(tutor); + return tutorial.save(); + }) + ); + } + /** * Checks if there is already a user with the given username saved in the database. * @@ -459,3 +514,9 @@ export class UserService implements OnModuleInit, CRUDService { + it('should be defined', () => { + expect(new ClassTransformerPipe()).toBeDefined(); + }); +}); diff --git a/server/src/pipes/class-transformer.pipe.ts b/server/src/pipes/class-transformer.pipe.ts new file mode 100644 index 000000000..bda942632 --- /dev/null +++ b/server/src/pipes/class-transformer.pipe.ts @@ -0,0 +1,15 @@ +import { ArgumentMetadata, Injectable, ValidationPipe } from '@nestjs/common'; +import { plainToClass } from 'class-transformer'; + +@Injectable() +export class ClassTransformerPipe extends ValidationPipe { + async transform(value: any, metadata: ArgumentMetadata) { + await super.transform(value, metadata); + + if (typeof value === 'object' && !!metadata.metatype) { + return plainToClass(metadata.metatype, value); + } + + return value; + } +} diff --git a/server/src/shared/model/CSV.ts b/server/src/shared/model/CSV.ts new file mode 100644 index 000000000..fd7d71ab2 --- /dev/null +++ b/server/src/shared/model/CSV.ts @@ -0,0 +1,8 @@ +import type { ParseConfig, ParseResult } from 'papaparse'; + +export interface IParseCsvDTO { + data: string; + options?: ParseConfig; +} + +export type ParseCsvResult = ParseResult; diff --git a/server/src/shared/model/Tutorial.ts b/server/src/shared/model/Tutorial.ts index ddb507c9e..391cca7c1 100644 --- a/server/src/shared/model/Tutorial.ts +++ b/server/src/shared/model/Tutorial.ts @@ -31,3 +31,52 @@ export interface ISubstituteDTO { tutorId?: string; dates: string[]; } + +// export enum TutorialGenerationDataType { +// SINGLE = 'single', +// MULTIPLE = 'multiple', +// } + +/** + * Numerated weekdays. These follow the same numeration than the [weekdays in luxon](https://moment.github.io/luxon/docs/class/src/datetime.js~DateTime.html#instance-get-weekday). + */ +export enum Weekday { + MONDAY = 1, + TUESDAY = 2, + WEDNESDAY = 3, + THURSDAY = 4, + FRIDAY = 5, + SATURDAY = 6, + SUNDAY = 7, +} + +export interface ITutorialGenerationDTO { + firstDay: string; + lastDay: string; + excludedDates: IExcludedDate[]; + generationDatas: ITutorialGenerationData[]; +} + +export interface ITutorialGenerationData { + prefix: string; + weekday: Weekday; + amount: number; + interval: string; +} + +export interface IExcludedDate { + date?: string; + interval?: string; +} + +// export type IExcludedDate = IExcludedSingleDate | IExcludedMultipleDate; + +// export interface IExcludedSingleDate { +// type: TutorialGenerationDataType.SINGLE; +// date: string; +// } + +// export interface IExcludedMultipleDate { +// type: TutorialGenerationDataType.MULTIPLE; +// interval: string; +// } diff --git a/yarn.lock b/yarn.lock index 713610139..aaf5209e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2580,6 +2580,13 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== +"@types/papaparse@^5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.0.4.tgz#70792c74d9932bcc0bfa945ae7dacfef67f4ee57" + integrity sha512-jFv9NcRddMiW4+thmntwZ1AhvMDAX4+tAUDkWWbNcIzgqyjjkuSHOEUPoVh1/gqJTWfDOD1tvl+hSp88W3UtqA== + dependencies: + "@types/node" "*" + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -10649,6 +10656,11 @@ pako@~1.0.2, pako@~1.0.5: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +papaparse@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.2.0.tgz#97976a1b135c46612773029153dc64995caa3b7b" + integrity sha512-ylq1wgUSnagU+MKQtNeVqrPhZuMYBvOSL00DHycFTCxownF95gpLAk1HiHdUW77N8yxRq1qHXLdlIPyBSG9NSA== + parallel-transform@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"