Add reporting with custom fields support

This commit is contained in:
Kacper Donat 2020-12-30 21:22:23 +01:00
parent 3d827317f0
commit 7f71e6758c
13 changed files with 476 additions and 7 deletions

View File

@ -29,19 +29,18 @@ export const Step = (props: StepProps) => {
{ label }
<Box>
{ state && <Typography variant="subtitle2" display="inline">{ state }</Typography> }
{ (notBefore || until) && <Typography variant="subtitle2" display="inline" color="textSecondary"> </Typography> }
{ notBefore &&
<Typography variant="subtitle2" color="textSecondary" display="inline">
{ t('not-before', { date: notBefore }) }
</Typography> }
{ until && <>
<Typography variant="subtitle2" display="inline" color="textSecondary"> </Typography>
{ until &&
<Typography variant="subtitle2" color="textSecondary" display="inline">
{ t('until', { date: until }) }
{ isLate && <Typography color="error" display="inline"
variant="body2"> - { t('late', { by: moment.duration(now.diff(until)) }) }</Typography> }
{ !isLate && !completed && <Typography display="inline" variant="body2"> - { t('left', { left: left }) }</Typography> }
</Typography>
</> }
</Typography> }
</Box>
</StepLabel>
{ children && <StepContent>{ children }</StepContent> }

37
src/data/report.ts Normal file
View File

@ -0,0 +1,37 @@
import { Multilingual } from "@/data/common";
interface PredefinedChoices {
choices: Multilingual<string>[];
}
export interface BaseField {
name: string;
description: Multilingual<string>;
label: Multilingual<string>;
}
export interface TextField extends BaseField {
type: "text";
value: string | null;
}
export interface MultiChoiceField extends BaseField, PredefinedChoices {
type: "multi-choice";
value: Multilingual<string>[] | null;
}
export interface SingleChoiceField extends BaseField, PredefinedChoices {
type: "single-choice";
value: Multilingual<string> | null
}
export interface SelectField extends BaseField, PredefinedChoices {
type: "select";
value: Multilingual<string> | null
}
export type ReportField = TextField | MultiChoiceField | SingleChoiceField | SelectField;
export interface Report {
fields: ReportField[];
}

136
src/forms/report.tsx Normal file
View File

@ -0,0 +1,136 @@
import React from "react";
import { emptyReport } from "@/provider/dummy/report";
import {
Button,
FormControl,
FormLabel,
Grid,
Typography,
FormGroup,
FormControlLabel,
Checkbox,
Radio,
InputLabel,
Select,
MenuItem
} from "@material-ui/core";
import { Actions } from "@/components";
import { Link as RouterLink } from "react-router-dom";
import { route } from "@/routing";
import { useTranslation } from "react-i18next";
import { MultiChoiceField, Report, ReportField, SelectField, SingleChoiceField } from "@/data/report";
import { TextField as TextFieldFormik } from "formik-material-ui";
import { Field, Form, Formik, useFormik, useFormikContext } from "formik";
import { Multilingual } from "@/data";
import { Transformer } from "@/serialization";
export type ReportFieldProps<TField = ReportField> = {
field: TField;
}
const CustomField = ({ field, ...props }: ReportFieldProps) => {
switch (field.type) {
case "text":
return <CustomField.Text {...props} field={ field } />
case "single-choice":
case "multi-choice":
return <CustomField.Choice {...props} field={ field }/>
case "select":
return <CustomField.Select {...props} field={ field }/>
}
}
CustomField.Text = ({ field }: ReportFieldProps) => {
return <>
<Field label={ field.label.pl } name={ field.name } fullWidth component={ TextFieldFormik }/>
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
</>
}
CustomField.Select = ({ field }: ReportFieldProps<SelectField>) => {
const { t } = useTranslation();
const id = `custom-field-${field.name}`;
const { values, setFieldValue } = useFormikContext<any>();
const value = values[field.name];
return <FormControl variant="outlined">
<InputLabel htmlFor={id}>{ field.label.pl }</InputLabel>
<Select label={ field.label.pl } name={ field.name } id={id} value={ value } onChange={ ({ target }) => setFieldValue(field.name, target.value, false) }>
{ field.choices.map(choice => <MenuItem value={ choice as any }>{ choice.pl }</MenuItem>) }
</Select>
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
</FormControl>
}
CustomField.Choice = ({ field }: ReportFieldProps<SingleChoiceField | MultiChoiceField>) => {
const { t } = useTranslation();
const { values, setFieldValue } = useFormikContext<any>();
const value = values[field.name];
const isSelected = field.type == 'single-choice'
? (checked: Multilingual<string>) => value == checked
: (checked: Multilingual<string>) => (value || []).includes(checked)
const handleChange = field.type == 'single-choice'
? (choice: Multilingual<string>) => () => setFieldValue(field.name, choice, false)
: (choice: Multilingual<string>) => () => {
const current = value || [];
setFieldValue(field.name, !current.includes(choice) ? [ ...current, choice ] : current.filter((c: Multilingual<string>) => c != choice), false);
}
const Component = field.type == 'single-choice' ? Radio : Checkbox;
return <FormControl component="fieldset">
<FormLabel component="legend">{ field.label.pl }</FormLabel>
<FormGroup>
{ field.choices.map(choice => <FormControlLabel
control={ <Component checked={ isSelected(choice) } onChange={ handleChange(choice) }/> }
label={ choice.pl }
/>) }
</FormGroup>
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
</FormControl>
}
export type ReportFormValues = { [field: string]: any };
const reportToFormValuesTransformer: Transformer<Report, ReportFormValues, { report: Report }> = {
reverseTransform(subject: ReportFormValues, context: { report: Report }): Report {
return { ...context.report };
},
transform(subject: Report, context: undefined): ReportFormValues {
return Object.fromEntries(subject.fields.map(field => [ field.name, field.value ]));
}
}
export default function ReportForm() {
const report = emptyReport;
const { t } = useTranslation();
const handleSubmit = async () => {};
return <Formik initialValues={ reportToFormValuesTransformer.transform(report) } onSubmit={ handleSubmit }>
{ ({ submitForm }) => <Form>
<Grid container>
<Grid item xs={12}>
<Typography variant="body1" component="p">{ t('forms.report.instructions') }</Typography>
</Grid>
{ report.fields.map(field => <Grid item xs={12}><CustomField field={ field }/></Grid>) }
<Grid item xs={12}>
<Actions>
<Button variant="contained" color="primary" onClick={ submitForm }>
{ t('confirm') }
</Button>
<Button component={ RouterLink } to={ route("home") }>
{ t('go-back') }
</Button>
</Actions>
</Grid>
</Grid>
</Form> }
</Formik>
}

View File

@ -0,0 +1,26 @@
import { Page } from "@/pages/base";
import { Container, Link, Typography } from "@material-ui/core";
import { Link as RouterLink } from "react-router-dom";
import { route } from "@/routing";
import React from "react";
import { useTranslation } from "react-i18next";
import ReportForm from "@/forms/report";
export const SubmitReportPage = () => {
const { t } = useTranslation();
return <Page title={ t("steps.report.submit") }>
<Page.Header maxWidth="md">
<Page.Breadcrumbs>
<Link component={ RouterLink } to={ route("home") }>{ t('pages.my-internship.header') }</Link>
<Typography color="textPrimary">{ t("steps.report.submit") }</Typography>
</Page.Breadcrumbs>
<Page.Title>{ t("steps.report.submit") }</Page.Title>
</Page.Header>
<Container maxWidth={ "md" }>
<ReportForm/>
</Container>
</Page>
}
export default SubmitReportPage;

View File

@ -18,6 +18,7 @@ import api from "@/api";
import { AppDispatch, InternshipPlanActions, InternshipProposalActions, useDispatch } from "@/state/actions";
import { internshipRegistrationDtoTransformer } from "@/api/dto/internship-registration";
import { UploadType } from "@/api/upload";
import { ReportStep } from "@/pages/steps/report";
export const updateInternshipInfo = async (dispatch: AppDispatch) => {
const internship = await api.internship.get();
@ -48,10 +49,8 @@ export const MainPage = () => {
const student = useSelector<AppState, Student | null>(state => state.student);
const deadlines = useDeadlines();
const insurance = useSelector<AppState, InsuranceState>(root => root.insurance);
const dispatch = useDispatch();
const edition = useCurrentEdition();
useEffect(() => {
dispatch(updateInternshipInfo);
@ -69,7 +68,7 @@ export const MainPage = () => {
if (insurance.required)
yield <InsuranceStep key="insurance"/>;
yield <Step label={ t('steps.report.header') } until={ deadlines.report } notBefore={ edition?.reportingStart } key="report"/>
yield <ReportStep key="report"/>;
yield <Step label={ t('steps.grade.header') } key="grade"/>
}

View File

@ -0,0 +1,85 @@
import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer";
import { getSubmissionStatus, SubmissionState, SubmissionStatus } from "@/state/reducer/submission";
import { useTranslation } from "react-i18next";
import { Box, Button, ButtonProps, StepProps } from "@material-ui/core";
import { FileUploadOutline } from "mdi-material-ui/index";
import { route } from "@/routing";
import { Link as RouterLink } from "react-router-dom";
import { Actions, Step } from "@/components";
import React, { HTMLProps } from "react";
import { Alert, AlertTitle } from "@material-ui/lab";
import { ContactButton, Status } from "@/pages/steps/common";
import { useCurrentEdition, useDeadlines } from "@/hooks";
import { useSpacing } from "@/styles";
const ReportActions = () => {
const status = useSelector<AppState, SubmissionStatus>(state => getSubmissionStatus(state.report));
const { t } = useTranslation();
const FormAction = ({ children = t('steps.report.submit'), ...props }: ButtonProps) =>
<Button to={ route("internship_report") } variant="contained" color="primary" component={ RouterLink } startIcon={ <FileUploadOutline /> } { ...props as any }>
{ children }
</Button>
switch (status) {
case "awaiting":
return <Actions>
</Actions>
case "accepted":
return <Actions>
<FormAction variant="outlined" color="secondary">{ t('send-again') }</FormAction>
</Actions>
case "declined":
return <Actions>
<FormAction>{ t('send-again') }</FormAction>
<ContactButton/>
</Actions>
case "draft":
return <Actions>
<FormAction />
</Actions>
default:
return <Actions/>
}
}
export const PlanComment = (props: HTMLProps<HTMLDivElement>) => {
const { comment, declined } = useSelector<AppState, SubmissionState>(state => state.plan);
const { t } = useTranslation();
return comment ? <Alert severity={ declined ? "error" : "warning" } { ...props as any }>
<AlertTitle>{ t('comments') }</AlertTitle>
{ comment }
</Alert> : null
}
export const ReportStep = (props: StepProps) => {
const { t } = useTranslation();
const submission = useSelector<AppState, SubmissionState>(state => state.report);
const spacing = useSpacing(2);
const edition = useCurrentEdition();
const status = getSubmissionStatus(submission);
const deadlines = useDeadlines();
const { sent, declined, comment } = submission;
return <Step { ...props }
label={ t('steps.report.header') }
active={ true } completed={ sent } declined={ declined } waiting={ status == "awaiting" }
until={ deadlines.report }
notBefore={ edition?.reportingStart }
state={ <Status submission={ submission } /> }>
<div className={ spacing.vertical }>
<p>{ t(`steps.report.info.${ status }`) }</p>
{ comment && <Box pb={ 2 }><PlanComment/></Box> }
<ReportActions/>
</div>
</Step>;
}

View File

@ -0,0 +1,66 @@
import { Report } from "@/data/report";
const choices = [1, 2, 3, 4, 5].map(n => ({
pl: `Wybór ${n}`,
en: `Choice ${n}`
}))
export const emptyReport: Report = {
fields: [
{
type: "text",
name: "text",
description: {
en: "Text field, with <strong>HTML</strong> description",
pl: "Pole tekstowe, z opisem w formacie <strong>HTML</strong>"
},
value: null,
label: {
en: "Text Field",
pl: "Pole tekstowe",
},
},
{
type: "single-choice",
name: "single",
description: {
en: "single choice field, with <strong>HTML</strong> description",
pl: "Pole jednokrotnego wyboru, z opisem w formacie <strong>HTML</strong>"
},
value: null,
choices,
label: {
en: "Single choice field",
pl: "Pole jednokrotnego wyboru",
},
},
{
type: "select",
name: "select",
description: {
en: "select field, with <strong>HTML</strong> description",
pl: "Pole jednokrotnego wyboru z selectboxem, z opisem w formacie <strong>HTML</strong>"
},
value: choices[2],
choices,
label: {
en: "Select field",
pl: "Pole jednokrotnego wyboru (selectbox)",
},
},
{
name: "multi",
type: "multi-choice",
description: {
en: "Multiple choice field, with <strong>HTML</strong> description",
pl: "Pole wielokrotnego wyboru, z opisem w formacie <strong>HTML</strong>"
},
value: [ choices[0], choices[3] ],
choices,
label: {
en: "Multi choice field",
pl: "Pole wielokrotnego wyboru",
},
},
]
}

View File

@ -11,6 +11,7 @@ import { isLoggedInMiddleware, isReadyMiddleware } from "@/middleware";
import UserFillPage from "@/pages/user/fill";
import UserProfilePage from "@/pages/user/profile";
import { managementRoutes } from "@/management/routing";
import SubmitReportPage from "@/pages/internship/report";
export type Route = {
name?: string;
@ -44,6 +45,7 @@ export const routes: Route[] = [
{ name: "internship_proposal", path: "/internship/proposal", exact: true, content: () => <InternshipProposalFormPage/>, middlewares: [ isReadyMiddleware ] },
{ name: "internship_proposal_preview", path: "/internship/preview/proposal", exact: true, content: () => <InternshipProposalPreviewPage/>, middlewares: [ isReadyMiddleware ] },
{ name: "internship_plan", path: "/internship/plan", exact: true, content: () => <SubmitPlanPage/>, middlewares: [ isReadyMiddleware ] },
{ name: "internship_report", path: "/internship/report", exact: true, content: () => <SubmitReportPage/>, middlewares: [ isReadyMiddleware ] },
// user
{ name: "user_login", path: "/user/login", content: () => <UserLoginPage/> },

View File

@ -0,0 +1,4 @@
import { identityTransformer, Serializable, Transformer } from "@/serialization/types";
import { Report } from "@/data/report";
export const reportSerializationTransformer: Transformer<Report, Serializable<Report>> = identityTransformer;

View File

@ -9,6 +9,7 @@ import { UserAction, UserActions } from "@/state/actions/user";
import { ThunkDispatch } from "redux-thunk";
import { AppState } from "@/state/reducer";
import { StudentAction, StudentActions } from "@/state/actions/student";
import { InternshipReportAction, InternshipReportActions } from "@/state/actions/report";
export * from "./base"
export * from "./edition"
@ -17,6 +18,7 @@ export * from "./proposal"
export * from "./plan"
export * from "./user"
export * from "./student"
export * from "./report"
export type Action
= UserAction
@ -25,6 +27,7 @@ export type Action
| InternshipProposalAction
| StudentAction
| InternshipPlanAction
| InternshipReportAction
| InsuranceAction;
export const Actions = {
@ -35,6 +38,7 @@ export const Actions = {
...InternshipPlanActions,
...InsuranceActions,
...StudentActions,
...InternshipReportActions,
}
export type Actions = typeof Actions;
export type AppDispatch = ThunkDispatch<AppState, any, Action>;

View File

@ -0,0 +1,43 @@
import { Internship } from "@/data";
import {
ReceiveSubmissionApproveAction,
ReceiveSubmissionDeclineAction,
ReceiveSubmissionUpdateAction,
SaveSubmissionAction,
SendSubmissionAction
} from "@/state/actions/submission";
import { SubmissionState } from "@/api/dto/internship-registration";
import { Report } from "@/data/report";
export enum InternshipReportActions {
Send = "SEND_REPORT",
Save = "SAVE_REPORT",
Approve = "RECEIVE_REPORT_APPROVE",
Decline = "RECEIVE_REPORT_DECLINE",
Receive = "RECEIVE_REPORT_STATE",
}
export interface SendReportAction extends SendSubmissionAction<InternshipReportActions.Send> {
}
export interface ReceiveReportApproveAction extends ReceiveSubmissionApproveAction<InternshipReportActions.Approve> {
}
export interface ReceiveReportDeclineAction extends ReceiveSubmissionDeclineAction<InternshipReportActions.Decline> {
}
export interface ReceiveReportUpdateAction extends ReceiveSubmissionUpdateAction<InternshipReportActions.Receive> {
report: Report;
state: SubmissionState,
}
export interface SaveReportAction extends SaveSubmissionAction<InternshipReportActions.Save> {
report: Report;
}
export type InternshipReportAction
= SendReportAction
| SaveReportAction
| ReceiveReportApproveAction
| ReceiveReportDeclineAction
| ReceiveReportUpdateAction;

View File

@ -7,6 +7,7 @@ import internshipProposalReducer from "@/state/reducer/proposal";
import internshipPlanReducer from "@/state/reducer/plan";
import insuranceReducer from "@/state/reducer/insurance";
import userReducer from "@/state/reducer/user";
import internshipReportReducer from "@/state/reducer/report";
const rootReducer = combineReducers({
student: studentReducer,
@ -16,6 +17,7 @@ const rootReducer = combineReducers({
plan: internshipPlanReducer,
insurance: insuranceReducer,
user: userReducer,
report: internshipReportReducer,
})
export type AppState = ReturnType<typeof rootReducer>;

View File

@ -0,0 +1,66 @@
import { InternshipReportAction, InternshipReportActions } from "@/state/actions";
import { Serializable } from "@/serialization/types";
import {
createSubmissionReducer,
defaultDeanApprovalsState,
defaultSubmissionState,
SubmissionState
} from "@/state/reducer/submission";
import { Reducer } from "react";
import { SubmissionAction } from "@/state/actions/submission";
import { SubmissionState as ApiSubmissionState } from "@/api/dto/internship-registration";
import { Report } from "@/data/report";
import { reportSerializationTransformer } from "@/serialization/report";
export type InternshipReportState = SubmissionState & {
report: Serializable<Report> | null;
}
const defaultInternshipReportState: InternshipReportState = {
...defaultDeanApprovalsState,
...defaultSubmissionState,
report: null,
}
export const getInternshipReport = ({ report }: InternshipReportState): Report | null =>
report && reportSerializationTransformer.reverseTransform(report);
const internshipReportSubmissionReducer: Reducer<InternshipReportState, InternshipReportAction> = createSubmissionReducer({
[InternshipReportActions.Approve]: SubmissionAction.Approve,
[InternshipReportActions.Decline]: SubmissionAction.Decline,
[InternshipReportActions.Receive]: SubmissionAction.Receive,
[InternshipReportActions.Save]: SubmissionAction.Save,
[InternshipReportActions.Send]: SubmissionAction.Send,
})
const internshipReportReducer = (state: InternshipReportState = defaultInternshipReportState, action: InternshipReportAction): InternshipReportState => {
state = internshipReportSubmissionReducer(state, action);
switch (action.type) {
case InternshipReportActions.Save:
case InternshipReportActions.Send:
return {
...state,
}
case InternshipReportActions.Receive:
if (state.overwritten) {
return state;
}
return {
...state,
accepted: action.state === ApiSubmissionState.Accepted,
declined: action.state === ApiSubmissionState.Rejected,
sent: [
ApiSubmissionState.Accepted,
ApiSubmissionState.Rejected,
ApiSubmissionState.Submitted
].includes(action.state),
report: reportSerializationTransformer.transform(action.report),
}
default:
return state;
}
}
export default internshipReportReducer;