From 8aa10215dffd38b0fdc89fd8d4e3a46ee60dc4ba Mon Sep 17 00:00:00 2001 From: BenjaVR Date: Sun, 23 Dec 2018 16:30:47 +0100 Subject: [PATCH] Add schools functionality (through Cloud Functions) --- functions/src/index.ts | 50 ++++++-- .../src/shared/firebase/firestoreConstants.ts | 1 + functions/src/shared/firebase/interfaces.ts | 4 - functions/src/shared/models/LoginDetails.ts | 6 +- functions/src/shared/models/School.ts | 4 +- functions/src/shared/translations/En.ts | 2 +- .../src/shared/validators/ValidationResult.ts | 2 +- functions/tslint.json | 120 +----------------- .../auth/AuthChecker/AuthChecker.tsx | 24 +++- .../schools/SchoolForm/SchoolForm.tsx | 89 +++++++++---- .../schools/SchoolList/SchoolList.tsx | 2 +- .../schools/SchoolsPage/SchoolsPage.tsx | 51 +------- web/src/config/I18nextInitializer.ts | 4 + web/src/services/SchoolsService.ts | 14 +- .../services/functions/FirebaseFunctions.ts | 14 +- .../stores/schools/actions/addSchoolAction.ts | 65 ++++++++++ .../schools/actions/fetchSchoolsAction.ts | 4 +- web/src/stores/schools/actions/index.ts | 5 +- web/src/stores/schools/reducer.ts | 49 ++++++- 19 files changed, 272 insertions(+), 238 deletions(-) create mode 100644 functions/src/shared/firebase/firestoreConstants.ts create mode 100644 web/src/stores/schools/actions/addSchoolAction.ts diff --git a/functions/src/index.ts b/functions/src/index.ts index 1a46898..9d1d24b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,23 +1,53 @@ -import * as functions from "firebase-functions"; import * as admin from "firebase-admin"; +import * as functions from "firebase-functions"; +import { UserRecord } from "firebase-functions/lib/providers/auth"; import { CallableContext, HttpsError } from "firebase-functions/lib/providers/https"; +import { schoolsCollection } from "./shared/firebase/firestoreConstants"; +import { IFirebaseFunctionParam as Param } from "./shared/firebase/interfaces"; import { ISchool, validateSchool } from "./shared/models/School"; -import { IFirebaseFunctionParam } from "./shared/firebase/interfaces"; admin.initializeApp(); const db = admin.firestore(); const auth = admin.auth(); -export const test = functions.https.onCall((data, context) => { +/////////////// +// Utilities // +/////////////// + +function checkAuthentication(context: CallableContext): void { if (!context.auth) { - throw new HttpsError("permission-denied", "You need permissions"); + throw new HttpsError("unauthenticated", "You need permissions"); // TODO: translate } -}); +} + +function errorCallback(details: any): void { + throw new HttpsError("unknown", "Something went wrong... Please try again later!", details); // TODO: translate +} + +///////////////////////// +// Database operations // +///////////////////////// + +export const addSchool = functions.https.onCall((param: Param, context): Promise => { + checkAuthentication(context); -export const addSchool = functions.https.onCall((param: IFirebaseFunctionParam, context: CallableContext) => { - return new Promise(((resolve, reject) => { - if (validateSchool(param.data).hasErrors) { - // TODO: failed! + return new Promise(((resolve) => { + const validated = validateSchool(param.data); + if (validated.hasErrors()) { + throw new HttpsError("invalid-argument", "Fields are not correct", validated); // TODO: translate + } else { + db.collection(schoolsCollection).add(param.data) + .then((ref) => { + return ref.get(); + }) + .then((doc) => { + const school = { + ...doc.data(), + id: doc.id, + }; + resolve(school as ISchool); + }) + .catch(errorCallback); } })); }); @@ -29,7 +59,7 @@ export const addSchool = functions.https.onCall((param: IFirebaseFunctionParam { +export const disableUserAfterSignUp = functions.auth.user().onCreate((user: UserRecord): void => { auth.updateUser(user.uid, { disabled: true, }); diff --git a/functions/src/shared/firebase/firestoreConstants.ts b/functions/src/shared/firebase/firestoreConstants.ts new file mode 100644 index 0000000..da2ff4d --- /dev/null +++ b/functions/src/shared/firebase/firestoreConstants.ts @@ -0,0 +1 @@ +export const schoolsCollection = "/schools"; diff --git a/functions/src/shared/firebase/interfaces.ts b/functions/src/shared/firebase/interfaces.ts index 1c922ad..0b90718 100644 --- a/functions/src/shared/firebase/interfaces.ts +++ b/functions/src/shared/firebase/interfaces.ts @@ -4,7 +4,3 @@ export interface IFirebaseFunctionParam { lang: Language; data: T; } - -export interface IFirebaseFunctionResponse { - // TODO -} diff --git a/functions/src/shared/models/LoginDetails.ts b/functions/src/shared/models/LoginDetails.ts index bf25d4c..ed82326 100644 --- a/functions/src/shared/models/LoginDetails.ts +++ b/functions/src/shared/models/LoginDetails.ts @@ -1,6 +1,6 @@ -import { ValidationResult } from "../validators/ValidationResult"; import { StringValidator } from "../validators/StringValidator"; import { ValidationError } from "../validators/ValidationError"; +import { ValidationResult } from "../validators/ValidationResult"; export interface ILoginDetails { username: string; @@ -14,14 +14,14 @@ export function validateLoginDetails(loginDetails: ILoginDetails): ValidationRes * username */ if (StringValidator.isEmpty(loginDetails.username)) { - result.add(new ValidationError("username", "validation.field_should_not_be_empty")) + result.add(new ValidationError("username", "validation.field_should_not_be_empty")); } /** * password */ if (StringValidator.isEmpty(loginDetails.password)) { - result.add(new ValidationError("password", "validation.field_should_not_be_empty")) + result.add(new ValidationError("password", "validation.field_should_not_be_empty")); } return result; diff --git a/functions/src/shared/models/School.ts b/functions/src/shared/models/School.ts index 1d0ee94..1745b39 100644 --- a/functions/src/shared/models/School.ts +++ b/functions/src/shared/models/School.ts @@ -1,7 +1,7 @@ import { FirebaseValidator } from "../validators/FirebaseValidator"; import { StringValidator } from "../validators/StringValidator"; -import { ValidationResult } from "../validators/ValidationResult"; import { ValidationError } from "../validators/ValidationError"; +import { ValidationResult } from "../validators/ValidationResult"; export interface ISchool { id?: string; @@ -15,7 +15,7 @@ export function validateSchool(school: ISchool): ValidationResult { * id */ if (FirebaseValidator.hasId(school)) { - result.add(new ValidationError("id", "validation.model_should_not_have_id")) + result.add(new ValidationError("id", "validation.model_should_not_have_id")); } /** diff --git a/functions/src/shared/translations/En.ts b/functions/src/shared/translations/En.ts index 45d714f..86bf7bf 100644 --- a/functions/src/shared/translations/En.ts +++ b/functions/src/shared/translations/En.ts @@ -1,4 +1,4 @@ -import { ILanguage, I18nextResourceTranslations } from "./types"; +import { I18nextResourceTranslations, ILanguage } from "./types"; export class En implements ILanguage { public getTranslations(): I18nextResourceTranslations { diff --git a/functions/src/shared/validators/ValidationResult.ts b/functions/src/shared/validators/ValidationResult.ts index e3ef6fc..e08306a 100644 --- a/functions/src/shared/validators/ValidationResult.ts +++ b/functions/src/shared/validators/ValidationResult.ts @@ -18,6 +18,6 @@ export class ValidationResult { } public getErrorsWithField(field: keyof T): Array> { - return this.errors.filter(error => error.field === field); + return this.errors.filter((error) => error.field === field); } } diff --git a/functions/tslint.json b/functions/tslint.json index be2c5f4..78af234 100644 --- a/functions/tslint.json +++ b/functions/tslint.json @@ -1,121 +1,3 @@ { - "rules": { - // -- Strict errors -- - // These lint rules are likely always a good idea. - - // Force function overloads to be declared together. This ensures readers understand APIs. - "adjacent-overload-signatures": true, - - // Do not allow the subtle/obscure comma operator. - "ban-comma-operator": true, - - // Do not allow internal modules or namespaces . These are deprecated in favor of ES6 modules. - "no-namespace": true, - - // Do not allow parameters to be reassigned. To avoid bugs, developers should instead assign new values to new vars. - "no-parameter-reassignment": true, - - // Force the use of ES6-style imports instead of /// imports. - "no-reference": true, - - // Do not allow type assertions that do nothing. This is a big warning that the developer may not understand the - // code currently being edited (they may be incorrectly handling a different type case that does not exist). - "no-unnecessary-type-assertion": true, - - // Disallow nonsensical label usage. - "label-position": true, - - // Disallows the (often typo) syntax if (var1 = var2). Replace with if (var2) { var1 = var2 }. - "no-conditional-assignment": true, - - // Disallows constructors for primitive types (e.g. new Number('123'), though Number('123') is still allowed). - "no-construct": true, - - // Do not allow super() to be called twice in a constructor. - "no-duplicate-super": true, - - // Do not allow the same case to appear more than once in a switch block. - "no-duplicate-switch-case": true, - - // Do not allow a variable to be declared more than once in the same block. Consider function parameters in this - // rule. - "no-duplicate-variable": [true, "check-parameters"], - - // Disallows a variable definition in an inner scope from shadowing a variable in an outer scope. Developers should - // instead use a separate variable name. - "no-shadowed-variable": true, - - // Empty blocks are almost never needed. Allow the one general exception: empty catch blocks. - "no-empty": [true, "allow-empty-catch"], - - // Functions must either be handled directly (e.g. with a catch() handler) or returned to another function. - // This is a major source of errors in Cloud Functions and the team strongly recommends leaving this rule on. - "no-floating-promises": true, - - // Do not allow any imports for modules that are not in package.json. These will almost certainly fail when - // deployed. - "no-implicit-dependencies": true, - - // The 'this' keyword can only be used inside of classes. - "no-invalid-this": true, - - // Do not allow strings to be thrown because they will not include stack traces. Throw Errors instead. - "no-string-throw": true, - - // Disallow control flow statements, such as return, continue, break, and throw in finally blocks. - "no-unsafe-finally": true, - - // Do not allow variables to be used before they are declared. - "no-use-before-declare": true, - - // Expressions must always return a value. Avoids common errors like const myValue = functionReturningVoid(); - "no-void-expression": [true, "ignore-arrow-function-shorthand"], - - // Disallow duplicate imports in the same file. - "no-duplicate-imports": true, - - - // -- Strong Warnings -- - // These rules should almost never be needed, but may be included due to legacy code. - // They are left as a warning to avoid frustration with blocked deploys when the developer - // understand the warning and wants to deploy anyway. - - // Warn when an empty interface is defined. These are generally not useful. - "no-empty-interface": {"severity": "warning"}, - - // Warn when an import will have side effects. - "no-import-side-effect": {"severity": "warning"}, - - // Warn when variables are defined with var. Var has subtle meaning that can lead to bugs. Strongly prefer const for - // most values and let for values that will change. - "no-var-keyword": {"severity": "warning"}, - - // Prefer === and !== over == and !=. The latter operators support overloads that are often accidental. - "triple-equals": {"severity": "warning"}, - - // Warn when using deprecated APIs. - "deprecation": {"severity": "warning"}, - - // -- Light Warnings -- - // These rules are intended to help developers use better style. Simpler code has fewer bugs. These would be "info" - // if TSLint supported such a level. - - // prefer for( ... of ... ) to an index loop when the index is only used to fetch an object from an array. - // (Even better: check out utils like .map if transforming an array!) - "prefer-for-of": {"severity": "warning"}, - - // Warns if function overloads could be unified into a single function with optional or rest parameters. - "unified-signatures": {"severity": "warning"}, - - // Warns if code has an import or variable that is unused. - "no-unused-variable": {"severity": "warning"}, - - // Prefer const for values that will not change. This better documents code. - "prefer-const": {"severity": "warning"}, - - // Multi-line object literals and function calls should have a trailing comma. This helps avoid merge conflicts. - "trailing-comma": {"severity": "warning"} - }, - - "defaultSeverity": "error" + "extends": "tslint:recommended" } diff --git a/web/src/components/auth/AuthChecker/AuthChecker.tsx b/web/src/components/auth/AuthChecker/AuthChecker.tsx index fd630fc..48299ef 100644 --- a/web/src/components/auth/AuthChecker/AuthChecker.tsx +++ b/web/src/components/auth/AuthChecker/AuthChecker.tsx @@ -1,5 +1,5 @@ import { ITranslations } from "@studentplanner/functions/dist/shared/translations/types"; -import { notification, Spin } from "antd"; +import { notification, Spin, Layout } from "antd"; import React from "react"; import { withNamespaces, WithNamespaces } from "react-i18next"; import { connect } from "react-redux"; @@ -14,6 +14,8 @@ interface IAuthCheckerProps { type AuthCheckerProps = IAuthCheckerProps & IStateProps & IDispatchProps & WithNamespaces; interface IAuthCheckerState { + isDoingInitialCheck: boolean; + initialCheckDone: boolean; } class AuthChecker extends React.Component { @@ -27,6 +29,18 @@ class AuthChecker extends React.Component { constructor(props: AuthCheckerProps) { super(props); + this.state = { + initialCheckDone: false, + isDoingInitialCheck: true, + }; + + window.setTimeout(() => { + this.setState({ + initialCheckDone: true, + isDoingInitialCheck: false, + }); + }, 1000); + const sessionReloadedValue = sessionStorage.getItem(this.pageWasReloadedSessionKey); this.pageWasReloaded = sessionReloadedValue !== null ? JSON.parse(sessionReloadedValue) === true : false; } @@ -68,8 +82,12 @@ class AuthChecker extends React.Component { public render(): React.ReactNode { return ( - - {this.props.children} + + + {this.state.initialCheckDone && + this.props.children + } + ); } diff --git a/web/src/components/schools/SchoolForm/SchoolForm.tsx b/web/src/components/schools/SchoolForm/SchoolForm.tsx index 01ee7aa..d660b0f 100644 --- a/web/src/components/schools/SchoolForm/SchoolForm.tsx +++ b/web/src/components/schools/SchoolForm/SchoolForm.tsx @@ -1,26 +1,31 @@ import { ISchool } from "@studentplanner/functions/dist/shared/models/School"; import React from "react"; +import { ISchoolsState } from "../../../stores/schools/reducer"; +import { IApplicationState } from "../../../stores"; +import { Dispatch, bindActionCreators } from "redux"; +import { fetchSchools, addSchool } from "../../../stores/schools/actions"; +import { connect } from "react-redux"; +import { notification } from "antd"; interface ISchoolFormProps { - addSchool: (school: ISchool) => Promise; } +type SchoolFormProps = ISchoolFormProps & IStateProps & IDispatchProps; + interface ISchoolFormState { - isLoading: boolean; school: ISchool; } -class SchoolForm extends React.Component { +class SchoolForm extends React.Component { private readonly emptySchool: ISchool = { name: "", }; - constructor(props: ISchoolFormProps) { + constructor(props: SchoolFormProps) { super(props); this.state = { - isLoading: false, school: this.emptySchool, }; @@ -28,11 +33,30 @@ class SchoolForm extends React.Component { this.handleChange = this.handleChange.bind(this); } + public componentDidUpdate(prevProps: SchoolFormProps): void { + if (prevProps.schoolsStore.addingStatus === "ADDING" && this.props.schoolsStore.addingStatus === "ADDED") { + const schoolName = this.props.schoolsStore.lastAddedSchool !== undefined + ? this.props.schoolsStore.lastAddedSchool.name : ""; + notification.success({ + message: `Successfully added school "${schoolName}"!`, // TODO: translate + }); + this.resetForm(); + } + + if (prevProps.schoolsStore.addingStatus === "ADDING" && this.props.schoolsStore.addingStatus === "FAILED") { + notification.error({ + message: this.props.schoolsStore.addErrorMessage, // TODO: translate (key) + }); + } + } + public render(): React.ReactNode { + const isLoading = this.props.schoolsStore.addingStatus === "ADDING"; + return (
- + {/*//TODO: translate */}
{ name="name" onChange={this.handleChange} value={this.state.school.name} - disabled={this.state.isLoading} + disabled={isLoading} />
@@ -49,9 +73,9 @@ class SchoolForm extends React.Component {
@@ -62,21 +86,7 @@ class SchoolForm extends React.Component { private handleSubmit(event: React.FormEvent): void { event.preventDefault(); - this.setState({isLoading: true}); - - // TODO: validate here? or rely on firebase validation rules? - this.props.addSchool(this.state.school) - .then(() => { - this.resetForm(); - }) - .catch((error) => { - // TODO: add form validation styles (red input field, ...?) - console.log(error); - return; - }) - .finally(() => { - this.setState({isLoading: false}); - }); + this.props.actions.addSchool(this.state.school); } private handleChange(event: React.FormEvent): void { @@ -100,4 +110,33 @@ class SchoolForm extends React.Component { } } -export default SchoolForm; +interface IStateProps { + schoolsStore: ISchoolsState; +} + +function mapStateToProps(state: IApplicationState): IStateProps { + return { + schoolsStore: state.schools, + } +} + +interface IDispatchProps { + actions: { + fetchSchools: () => void; + addSchool: (school: ISchool) => void; + } +} + +function mapDispatchToProps(dispatch: Dispatch): IDispatchProps { + return { + actions: bindActionCreators({ + fetchSchools, + addSchool, + }, dispatch), + } +} + +const ConnectedSchoolForm = connect( + mapStateToProps, mapDispatchToProps)(SchoolForm); + +export default ConnectedSchoolForm; diff --git a/web/src/components/schools/SchoolList/SchoolList.tsx b/web/src/components/schools/SchoolList/SchoolList.tsx index 720acdb..c0f1090 100644 --- a/web/src/components/schools/SchoolList/SchoolList.tsx +++ b/web/src/components/schools/SchoolList/SchoolList.tsx @@ -20,7 +20,7 @@ class SchoolList extends React.Component { public render(): React.ReactNode { return ( - + diff --git a/web/src/components/schools/SchoolsPage/SchoolsPage.tsx b/web/src/components/schools/SchoolsPage/SchoolsPage.tsx index da49644..20d390e 100644 --- a/web/src/components/schools/SchoolsPage/SchoolsPage.tsx +++ b/web/src/components/schools/SchoolsPage/SchoolsPage.tsx @@ -1,8 +1,6 @@ import { ISchool } from "@studentplanner/functions/dist/shared/models/School"; -import { notification } from "antd"; import React from "react"; import { RoutePageComponentProps } from "../../../routes"; -import { SchoolsService } from "../../../services/SchoolsService"; import { SignedInLayout } from "../../layouts/SignedInLayout"; import { SchoolForm } from "../SchoolForm"; import { SchoolList } from "../SchoolList"; @@ -16,67 +14,20 @@ interface ISchoolsPageState { export default class SchoolsPage extends React.Component { - private readonly schoolsService = new SchoolsService(); - constructor(props: ISchoolsPageProps) { super(props); this.state = { schools: [], }; - - this.addSchool = this.addSchool.bind(this); - } - - public componentDidMount(): void { - this.fetchSchools(); } public render(): React.ReactNode { return ( - + ); } - - private fetchSchools(): void { - this.schoolsService.listSchools() - .then((schools: ISchool[]) => { - this.setState({ schools }); - }) - .catch((error: Error) => { - notification.error({ - message: error.message, // TODO: show this message or choose a different one? - }); - }); - } - - private addSchool(school: ISchool): Promise { - const promise = new Promise((resolve, reject) => { - this.schoolsService.addSchool(school) - .then(() => { - return resolve(); - }) - .catch((error) => { - return reject(error); - }); - }); - - promise - .then(() => { - this.fetchSchools(); - notification.success({ - message: "School successfully added!", - }); - }) - .catch(() => { - notification.error({ - message: "School could not be added!", - }); - }); - - return promise; - } } diff --git a/web/src/config/I18nextInitializer.ts b/web/src/config/I18nextInitializer.ts index 1cdeb4d..1dbc6a6 100644 --- a/web/src/config/I18nextInitializer.ts +++ b/web/src/config/I18nextInitializer.ts @@ -23,3 +23,7 @@ export class I18nextInitializer { }); } } + +export function getCurrentLanguage(): Language { + return i18next.language as Language; +} diff --git a/web/src/services/SchoolsService.ts b/web/src/services/SchoolsService.ts index 6666478..7974c06 100644 --- a/web/src/services/SchoolsService.ts +++ b/web/src/services/SchoolsService.ts @@ -1,6 +1,7 @@ import { ISchool } from "@studentplanner/functions/dist/shared/models/School"; import { FirestoreServiceBase } from "./firestore/FirestoreServiceBase"; import { FirebaseFunctions } from "./functions/FirebaseFunctions"; +import { getCurrentLanguage } from "../config/I18nextInitializer"; export class SchoolsService extends FirestoreServiceBase { @@ -34,12 +35,13 @@ export class SchoolsService extends FirestoreServiceBase { }); } - public addSchool(school: ISchool): Promise { - return new Promise((resolve, reject) => { - FirebaseFunctions.addSchoolFunction({ school }) - .then(() => { - resolve(); - }) + public addSchool(school: ISchool): Promise { + return new Promise((resolve, reject) => { + FirebaseFunctions.addSchoolFunction({ + data: school, + lang: getCurrentLanguage(), + }) + .then(resolve) .catch(reject); }); } diff --git a/web/src/services/functions/FirebaseFunctions.ts b/web/src/services/functions/FirebaseFunctions.ts index cdcbf90..2ad2509 100644 --- a/web/src/services/functions/FirebaseFunctions.ts +++ b/web/src/services/functions/FirebaseFunctions.ts @@ -1,10 +1,16 @@ -import { ISchool } from "@studentplanner/functions/dist/shared/models/School"; -import firebase from "firebase"; +import { IFirebaseFunctionParam } from "@studentplanner/functions/dist/shared/firebase/interfaces"; import { Firebase } from "../../config/FirebaseInitializer"; +import { ISchool } from "@studentplanner/functions/dist/shared/models/School"; export class FirebaseFunctions { - public static addSchoolFunction(data: { school: ISchool }): Promise { - return Firebase.functions().httpsCallable("addSchool")(data); + public static addSchoolFunction(data: IFirebaseFunctionParam): Promise { + return new Promise((resolve, reject) => { + Firebase.functions().httpsCallable("addSchool")(data) + .then((result) => { + resolve(result.data); + }) + .catch(reject); + }); } } diff --git a/web/src/stores/schools/actions/addSchoolAction.ts b/web/src/stores/schools/actions/addSchoolAction.ts new file mode 100644 index 0000000..a53d3e2 --- /dev/null +++ b/web/src/stores/schools/actions/addSchoolAction.ts @@ -0,0 +1,65 @@ +import { Action, Dispatch } from "redux"; +import { ISchool } from "@studentplanner/functions/dist/shared/models/School"; +import { SchoolsService } from "../../../services/SchoolsService"; + +interface IAddSchoolStartedAction extends Action { + type: "ADD_SCHOOL_STARTED"; +} + +interface IAddSchoolSuccessAction extends Action { + type: "ADD_SCHOOL_SUCCESS"; + payload: { + school: ISchool; + } +} + +interface IAddSchoolFailureAction extends Action { + type: "ADD_SCHOOL_FAILURE"; + payload: { + error: string; + } +} + +export type AddSchoolAction = + IAddSchoolStartedAction | + IAddSchoolSuccessAction | + IAddSchoolFailureAction; + +function actionAddSchoolStarted(): IAddSchoolStartedAction { + return { + type: "ADD_SCHOOL_STARTED", + }; +} + +function actionAddSchoolSuccess(school: ISchool): IAddSchoolSuccessAction { + return { + type: "ADD_SCHOOL_SUCCESS", + payload: { + school, + }, + }; +} + +function actionAddSchoolFailure(error: string): IAddSchoolFailureAction { + return { + type: "ADD_SCHOOL_FAILURE", + payload: { + error, + }, + }; +} + +export function addSchool(school: ISchool): (d: Dispatch) => void { + return (dispatch: Dispatch) => { + dispatch(actionAddSchoolStarted()); + + SchoolsService.getInstance().addSchool(school) + .then((school: ISchool) => { + return dispatch(actionAddSchoolSuccess(school)); + }) + .catch((error: firebase.functions.HttpsError) => { + console.log(error.details); + return dispatch(actionAddSchoolFailure(error.message)); + }); + }; +} diff --git a/web/src/stores/schools/actions/fetchSchoolsAction.ts b/web/src/stores/schools/actions/fetchSchoolsAction.ts index db20f8c..80705a2 100644 --- a/web/src/stores/schools/actions/fetchSchoolsAction.ts +++ b/web/src/stores/schools/actions/fetchSchoolsAction.ts @@ -40,7 +40,6 @@ function actionFetchSchoolsSuccess(schools: ISchool[]): IFetchSchoolsSuccessActi }; } -// TODO: error should be (own) specific object! function actionFetchSchoolsFailure(error: string): IFetchSchoolsFailureAction { return { type: "FETCH_SCHOOLS_FAILURE", @@ -59,8 +58,7 @@ export function fetchSchools(): (d: Dispatch) => void { return dispatch(actionFetchSchoolsSuccess(schools)); }) .catch(() => { - // TODO: handle error arguments - return dispatch(actionFetchSchoolsFailure("failed")); + return dispatch(actionFetchSchoolsFailure("Could not load schools.")); // TODO: translate }); }; } diff --git a/web/src/stores/schools/actions/index.ts b/web/src/stores/schools/actions/index.ts index 64fcf8e..1677a41 100644 --- a/web/src/stores/schools/actions/index.ts +++ b/web/src/stores/schools/actions/index.ts @@ -1,8 +1,11 @@ import { fetchSchools, FetchSchoolsAction } from "./fetchSchoolsAction"; +import { AddSchoolAction, addSchool } from "./addSchoolAction"; export type SchoolsAction = - FetchSchoolsAction; + FetchSchoolsAction | + AddSchoolAction; export { fetchSchools, + addSchool, }; diff --git a/web/src/stores/schools/reducer.ts b/web/src/stores/schools/reducer.ts index e0efde6..6972ba5 100644 --- a/web/src/stores/schools/reducer.ts +++ b/web/src/stores/schools/reducer.ts @@ -7,14 +7,28 @@ export type SchoolsLoadingStatus = "LOADING_DONE" | "LOADING_FAILED"; +export type SchoolAddingStatus = + "NOTHING_ADDED" | + "ADDING" | + "ADDED" | + "FAILED"; + export interface ISchoolsState { readonly schools: ISchool[]; - readonly loadingStatus: SchoolsLoadingStatus; + readonly listLoadingStatus: SchoolsLoadingStatus; + readonly listErrorMessage: string; + readonly lastAddedSchool: ISchool | undefined; + readonly addingStatus: SchoolAddingStatus; + readonly addErrorMessage: string; } const initialState: ISchoolsState = { schools: [], - loadingStatus: "NOT_LOADED", + listLoadingStatus: "NOT_LOADED", + listErrorMessage: "", + lastAddedSchool: undefined, + addingStatus: "NOTHING_ADDED", + addErrorMessage: "", }; export function schoolsReducer(state: ISchoolsState = initialState, action: SchoolsAction): ISchoolsState { @@ -22,22 +36,47 @@ export function schoolsReducer(state: ISchoolsState = initialState, action: Scho case "FETCH_SCHOOLS_STARTED": return { ...state, - loadingStatus: "LOADING", + listLoadingStatus: "LOADING", }; case "FETCH_SCHOOLS_SUCCESS": return { ...state, - loadingStatus: "LOADING_DONE", + listLoadingStatus: "LOADING_DONE", schools: action.payload.schools, + listErrorMessage: "", }; case "FETCH_SCHOOLS_FAILURE": return { ...state, - loadingStatus: "LOADING_FAILED", + listLoadingStatus: "LOADING_FAILED", + listErrorMessage: action.payload.error, }; + case "ADD_SCHOOL_STARTED": + return { + ...state, + addingStatus: "ADDING" + } + + case "ADD_SCHOOL_SUCCESS": + state.schools.push(action.payload.school); + return { + ...state, + addingStatus: "ADDED", + lastAddedSchool: action.payload.school, + addErrorMessage: "" + } + + case "ADD_SCHOOL_FAILURE": + return { + ...state, + addingStatus: "FAILED", + addErrorMessage: action.payload.error, + lastAddedSchool: undefined, + } + default: return state; }