Catch and display errors (#508)
* Added error codes to known errors * Restructured errors for localization * Added localized error messages for errors and messages * Created error handler component to display application errors * Added unit tests for error handler component * Added unit tests for error handler component * Updated setting AppError properties to resolve weird unit test failure * Addressed PR feedback
This commit is contained in:
Родитель
ed98ef2ce3
Коммит
6d97cef60e
36
src/App.tsx
36
src/App.tsx
|
@ -5,13 +5,12 @@ import { ToastContainer } from "react-toastify";
|
|||
import Navbar from "./react/components/shell/navbar";
|
||||
import Sidebar from "./react/components/shell/sidebar";
|
||||
import MainContentRouter from "./react/components/shell/mainContentRouter";
|
||||
import { IAppError, IApplicationState, IProject } from "./models/applicationState";
|
||||
import { IAppError, IApplicationState, IProject, AppError, ErrorCode } from "./models/applicationState";
|
||||
import "./App.scss";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import ErrorBoundary from "./react/components/common/errorBoundary";
|
||||
import IAppErrorActions, * as appErrorActions from "./redux/actions/appErrorActions";
|
||||
import { bindActionCreators } from "redux";
|
||||
import Alert from "./react/components/common/alert/alert";
|
||||
import { ErrorHandler } from "./react/components/common/errorHandler/errorHandler";
|
||||
|
||||
interface IAppProps {
|
||||
currentProject?: IProject;
|
||||
|
@ -37,7 +36,7 @@ function mapDispatchToProps(dispatch) {
|
|||
* @description - Root level component for VoTT Application
|
||||
*/
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
class App extends React.Component<IAppProps> {
|
||||
export default class App extends React.Component<IAppProps> {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
|
@ -46,20 +45,23 @@ class App extends React.Component<IAppProps> {
|
|||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const showError = (this.props.appError !== null);
|
||||
const errorTitle = showError ? this.props.appError.title : "";
|
||||
const errorMessage = showError ? this.props.appError.message : "";
|
||||
public componentDidCatch(error: Error) {
|
||||
this.props.actions.showError({
|
||||
errorCode: ErrorCode.GenericRenderError,
|
||||
title: error.name,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<Alert title={errorTitle}
|
||||
message={errorMessage}
|
||||
closeButtonColor="info"
|
||||
show={showError}
|
||||
onClose={this.props.actions.clearError}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<ErrorHandler
|
||||
error={this.props.appError}
|
||||
onError={this.props.actions.showError}
|
||||
onClearError={this.props.actions.clearError} />
|
||||
{/* Don't render app contents during a render error */}
|
||||
{(!this.props.appError || this.props.appError.errorCode !== ErrorCode.GenericRenderError) &&
|
||||
<Router>
|
||||
<div className="app-shell">
|
||||
<Navbar />
|
||||
|
@ -70,10 +72,8 @@ class App extends React.Component<IAppProps> {
|
|||
<ToastContainer />
|
||||
</div>
|
||||
</Router >
|
||||
</ErrorBoundary >
|
||||
}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
|
@ -28,10 +28,6 @@ export const english: IAppStrings = {
|
|||
title: "Delete Project",
|
||||
confirmation: "Are you sure you want to delete project",
|
||||
},
|
||||
loadProjectError: {
|
||||
title: "Project Loading has an error",
|
||||
message: "File is not valid json",
|
||||
},
|
||||
},
|
||||
appSettings: {
|
||||
title: "Application Settings",
|
||||
|
@ -58,6 +54,9 @@ export const english: IAppStrings = {
|
|||
description: "Reload the app discarding all current changes",
|
||||
button: "Refresh Application",
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: "Successfully saved application settings",
|
||||
},
|
||||
},
|
||||
projectSettings: {
|
||||
title: "Project Settings",
|
||||
|
@ -80,6 +79,9 @@ export const english: IAppStrings = {
|
|||
frameExtractionRate: "Frame Extraction Rate (frames per a video second)",
|
||||
},
|
||||
addConnection: "Add Connection",
|
||||
messages: {
|
||||
saveSuccess: "Successfully saved ${project.name} project settings",
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
title: "Tags",
|
||||
|
@ -160,6 +162,9 @@ export const english: IAppStrings = {
|
|||
tfRecords: "Tensorflow Records",
|
||||
tfPascalVoc: "Tensorflow Pascal VOC",
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: "Successfully saved export settings",
|
||||
},
|
||||
},
|
||||
activeLearning: {
|
||||
title: "Active Learning",
|
||||
|
@ -167,4 +172,33 @@ export const english: IAppStrings = {
|
|||
profile: {
|
||||
settings: "Profile Settings",
|
||||
},
|
||||
errors: {
|
||||
unknown: {
|
||||
title: "Unknown Error",
|
||||
message: "The app encounted an unknown error. Please try again.",
|
||||
},
|
||||
projectUploadError: {
|
||||
title: "Error Uploading File",
|
||||
message: `There was an error uploading the file.
|
||||
Please verify the file is of the correct format and try again.`,
|
||||
},
|
||||
genericRenderError: {
|
||||
title: "Error Loading Application",
|
||||
message: "An error occured while rendering the application. Please try again",
|
||||
},
|
||||
projectInvalidSecurityToken: {
|
||||
title: "Error loading project file",
|
||||
message: `The security token referenced by the project is invalid.
|
||||
Verify that the security token for the project has been set correctly within your application settings`,
|
||||
},
|
||||
projectInvalidJson: {
|
||||
title: "Error parsing project file",
|
||||
message: "The selected project files does not contain valid JSON. Please check the file any try again.",
|
||||
},
|
||||
securityTokenNotFound: {
|
||||
title: "Error loading project file",
|
||||
message: `The security token referenced by the project cannot be found in your current application settings.
|
||||
Verify the security token exists and try to reload the project.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -28,10 +28,6 @@ export const spanish: IAppStrings = {
|
|||
title: "Borrar Proyecto",
|
||||
confirmation: "¿Está seguro que quiere borrar el proyecto",
|
||||
},
|
||||
loadProjectError: {
|
||||
title: "Proyecto de carga tiene un error",
|
||||
message: "El archivo no es válido json",
|
||||
},
|
||||
},
|
||||
appSettings: {
|
||||
title: "Configuración de Aplicación",
|
||||
|
@ -59,6 +55,9 @@ export const spanish: IAppStrings = {
|
|||
description: "Recargar la aplicación descartando todos los cambios actuales",
|
||||
button: "Recargar la aplicación",
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: "Configuración de la aplicación guardada correctamente",
|
||||
},
|
||||
},
|
||||
projectSettings: {
|
||||
title: "Configuración de Proyecto",
|
||||
|
@ -81,6 +80,9 @@ export const spanish: IAppStrings = {
|
|||
frameExtractionRate: "Tasa de extracción de cuadros (cuadros por segundo de video)",
|
||||
},
|
||||
addConnection: "Agregar Conexión",
|
||||
messages: {
|
||||
saveSuccess: "Guardado correctamente ${project.name} configuración del proyecto",
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
title: "Etiquetas",
|
||||
|
@ -161,6 +163,9 @@ export const spanish: IAppStrings = {
|
|||
tfRecords: "Registros de Tensorflow",
|
||||
tfPascalVoc: "Tensorflow Pascal VOC",
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: "Configuración de exportación guardada correctamente",
|
||||
},
|
||||
},
|
||||
activeLearning: {
|
||||
title: "Aprendizaje Activo",
|
||||
|
@ -168,4 +173,33 @@ export const spanish: IAppStrings = {
|
|||
profile: {
|
||||
settings: "Configuración de Perfíl",
|
||||
},
|
||||
errors: {
|
||||
unknown: {
|
||||
title: "Error desconocido",
|
||||
message: "La aplicación contó un error desconocido. Por favor inténtalo de nuevo.",
|
||||
},
|
||||
projectUploadError: {
|
||||
title: "Error al cargar el archivo",
|
||||
message: `Se ha cargado un error al cargar el archivo.
|
||||
Compruebe que el archivo es del tipo correcto e inténtelo de nuevo.`,
|
||||
},
|
||||
genericRenderError: {
|
||||
title: "Error desconocido",
|
||||
message: "La aplicación contó un error desconocido. Por favor inténtalo de nuevo.",
|
||||
},
|
||||
projectInvalidSecurityToken: {
|
||||
title: "Error al cargar el archivo de proyecto",
|
||||
message: "Asegúrese de que el token de seguridad del proyecto existe",
|
||||
},
|
||||
projectInvalidJson: {
|
||||
title: "Error al analizar el archivo de proyecto",
|
||||
message: "El archivo de proyecto no es válido JSON",
|
||||
},
|
||||
securityTokenNotFound: {
|
||||
title: "Error loading project file",
|
||||
message: `El token de seguridad al que hace referencia el proyecto no se encuentra en la
|
||||
configuración de la aplicación actual. Compruebe que existe el token de seguridad e intente
|
||||
volver a cargar el proyecto.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import shortid from "shortid";
|
|||
import {
|
||||
AssetState, AssetType, IApplicationState, IAppSettings, IAsset, IAssetMetadata,
|
||||
IConnection, IExportFormat, IProject, ITag, StorageType, ISecurityToken,
|
||||
IAppError, IProjectVideoSettings, AppErrorType, EditorMode,
|
||||
EditorMode, IAppError, IProjectVideoSettings, AppError, ErrorCode,
|
||||
} from "../models/applicationState";
|
||||
import { ExportAssetState } from "../providers/export/exportProvider";
|
||||
import { IAssetProvider, IAssetProviderRegistrationOptions } from "../providers/storage/assetProviderFactory";
|
||||
|
@ -27,20 +27,19 @@ export default class MockFactory {
|
|||
|
||||
/**
|
||||
* Creates sample IAppError
|
||||
* @param {string} title to be display in Alert
|
||||
* @param {string} message to be display in body of Alert
|
||||
* @param {string} errorType to specify whether this is a render error or generic exception thrown
|
||||
* @param errorCode The error code to map to the error
|
||||
* @param title The title of the error
|
||||
* @param message The detailed error message
|
||||
* @returns {IAppError}
|
||||
*/
|
||||
public static createAppError(
|
||||
errorCode: ErrorCode = ErrorCode.Unknown,
|
||||
title: string = "",
|
||||
message: string = "",
|
||||
errorType: string = AppErrorType.Generic,
|
||||
): IAppError {
|
||||
message: string = ""): IAppError {
|
||||
return {
|
||||
errorCode,
|
||||
title,
|
||||
message,
|
||||
errorType,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -21,20 +21,16 @@ export interface IAppStrings {
|
|||
newProject: string;
|
||||
openLocalProject: {
|
||||
title: string;
|
||||
}
|
||||
},
|
||||
openCloudProject: {
|
||||
title: string;
|
||||
selectConnection: string;
|
||||
}
|
||||
},
|
||||
deleteProject: {
|
||||
title: string;
|
||||
confirmation: string;
|
||||
}
|
||||
recentProjects: string;
|
||||
loadProjectError: {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
},
|
||||
recentProjects: string,
|
||||
};
|
||||
appSettings: {
|
||||
title: string;
|
||||
|
@ -60,7 +56,10 @@ export interface IAppStrings {
|
|||
reload: {
|
||||
description: string;
|
||||
button: string;
|
||||
}
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: string;
|
||||
},
|
||||
};
|
||||
projectSettings: {
|
||||
title: string;
|
||||
|
@ -82,7 +81,10 @@ export interface IAppStrings {
|
|||
description: string;
|
||||
frameExtractionRate: string;
|
||||
},
|
||||
addConnection: string;
|
||||
addConnection: string,
|
||||
messages: {
|
||||
saveSuccess: string;
|
||||
},
|
||||
};
|
||||
tags: {
|
||||
title: string;
|
||||
|
@ -163,6 +165,9 @@ export interface IAppStrings {
|
|||
tfRecords: string;
|
||||
tfPascalVoc: string;
|
||||
},
|
||||
messages: {
|
||||
saveSuccess: string;
|
||||
},
|
||||
};
|
||||
activeLearning: {
|
||||
title: string;
|
||||
|
@ -170,6 +175,19 @@ export interface IAppStrings {
|
|||
profile: {
|
||||
settings: string;
|
||||
};
|
||||
errors: {
|
||||
unknown: IErrorMetadata,
|
||||
projectInvalidJson: IErrorMetadata,
|
||||
projectInvalidSecurityToken: IErrorMetadata,
|
||||
projectUploadError: IErrorMetadata,
|
||||
genericRenderError: IErrorMetadata,
|
||||
securityTokenNotFound: IErrorMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
interface IErrorMetadata {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface IStrings extends LocalizedStringsMethods, IAppStrings { }
|
||||
|
|
|
@ -25,19 +25,39 @@ export interface IApplicationState {
|
|||
* @member errorCode - error category
|
||||
*/
|
||||
export interface IAppError {
|
||||
title?: string;
|
||||
errorCode: ErrorCode;
|
||||
message: string;
|
||||
errorCode?: string;
|
||||
errorType: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @enum Generic - describe an error happened in an eventhandler, async function etc.
|
||||
* @enum Render - describe an error happened inside render
|
||||
* Enum of supported error codes
|
||||
*/
|
||||
export enum AppErrorType {
|
||||
Generic = "generic",
|
||||
Render = "render",
|
||||
export enum ErrorCode {
|
||||
// Note that the value of the enum is in camelCase while
|
||||
// the enum key is in Pascal casing
|
||||
Unknown = "unknown",
|
||||
GenericRenderError = "genericRenderError",
|
||||
ProjectInvalidJson = "projectInvalidJson",
|
||||
ProjectInvalidSecurityToken = "projectInvalidSecurityToken",
|
||||
ProjectUploadError = "projectUploadError",
|
||||
SecurityTokenNotFound = "securityTokenNotFound",
|
||||
}
|
||||
|
||||
/**
|
||||
* Base application error
|
||||
*/
|
||||
export class AppError extends Error implements IAppError {
|
||||
public errorCode: ErrorCode;
|
||||
public message: string;
|
||||
public title?: string;
|
||||
|
||||
constructor(errorCode: ErrorCode, message: string, title: string = null) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
this.message = message;
|
||||
this.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
import React from "react";
|
||||
import ErrorBoundary, { IErrorBoundaryProps } from "./errorBoundary";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import { Provider } from "react-redux";
|
||||
|
||||
import { IApplicationState } from "../../../models/applicationState";
|
||||
import IAppErrorActions, * as appErrorActions from "../../../redux/actions/appErrorActions";
|
||||
import { AnyAction, Store } from "redux";
|
||||
import createReduxStore from "../../../redux/store/store";
|
||||
|
||||
const ERROR_MSG = "Uncaught Exception from Child Component";
|
||||
|
||||
class ChildComponent extends React.Component {
|
||||
public render() {
|
||||
return <div />;
|
||||
}
|
||||
}
|
||||
|
||||
describe("ErrorBoundary Component", () => {
|
||||
function createStore(state?: IApplicationState): Store<any, AnyAction> {
|
||||
return createReduxStore(state);
|
||||
}
|
||||
|
||||
function createComponent(store, props: IErrorBoundaryProps): ReactWrapper {
|
||||
return mount(
|
||||
<Provider store={store}>
|
||||
<ErrorBoundary{...props}>
|
||||
<ChildComponent />
|
||||
</ErrorBoundary>
|
||||
</Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
function createProps(): IErrorBoundaryProps {
|
||||
return {
|
||||
actions: (appErrorActions as any) as IAppErrorActions,
|
||||
};
|
||||
}
|
||||
|
||||
it("call showError action when child component throws error", () => {
|
||||
const props = createProps();
|
||||
const showError = jest.spyOn(props.actions, "showError");
|
||||
|
||||
const wrapper = createComponent(createStore(), props);
|
||||
|
||||
const error = new Error(ERROR_MSG);
|
||||
wrapper.find(ChildComponent).simulateError(error);
|
||||
|
||||
expect(showError).toBeCalledWith({ title: "Error", message: ERROR_MSG, errorType: "render" });
|
||||
});
|
||||
|
||||
it("does not render anything when there's an error", () => {
|
||||
const props = createProps();
|
||||
const wrapper = createComponent(createStore(), props);
|
||||
|
||||
const error = new Error(ERROR_MSG);
|
||||
wrapper.find(ChildComponent).simulateError(error);
|
||||
|
||||
const childWrapper = wrapper.find(ChildComponent);
|
||||
expect(childWrapper.exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("render child component when there's no error", () => {
|
||||
const props = createProps();
|
||||
const wrapper = createComponent(createStore(), props);
|
||||
|
||||
const childWrapper = wrapper.find(ChildComponent);
|
||||
expect(childWrapper.exists()).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -1,53 +0,0 @@
|
|||
import React, { ErrorInfo } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import IAppErrorActions from "../../../redux/actions/appErrorActions";
|
||||
import { bindActionCreators } from "redux";
|
||||
import * as appErrorActions from "../../../redux/actions/appErrorActions";
|
||||
import {
|
||||
IAppError,
|
||||
IApplicationState,
|
||||
AppErrorType,
|
||||
} from "../../../models/applicationState";
|
||||
|
||||
export interface IErrorBoundaryProps {
|
||||
appError?: IAppError;
|
||||
actions?: IAppErrorActions;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IApplicationState) {
|
||||
return {
|
||||
appError: state.appError,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(appErrorActions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class ErrorBoundary extends React.Component<IErrorBoundaryProps> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
this.props.actions.showError({
|
||||
title: error.name,
|
||||
message: error.message,
|
||||
errorType: AppErrorType.Render,
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (
|
||||
this.props.appError &&
|
||||
this.props.appError.errorType === AppErrorType.Render
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
import React from "react";
|
||||
import { IErrorHandlerProps, ErrorHandler } from "./errorHandler";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import Alert from "../alert/alert";
|
||||
import { ErrorCode, IAppError, AppError } from "../../../../models/applicationState";
|
||||
import { strings } from "../../../../common/strings";
|
||||
|
||||
describe("Error Handler Component", () => {
|
||||
const onErrorHandler = jest.fn();
|
||||
const onClearErrorHandler = jest.fn();
|
||||
const defaultProps: IErrorHandlerProps = {
|
||||
error: null,
|
||||
onError: onErrorHandler,
|
||||
onClearError: onClearErrorHandler,
|
||||
};
|
||||
|
||||
function createComponent(props: IErrorHandlerProps = null): ReactWrapper<IErrorHandlerProps> {
|
||||
props = props || defaultProps;
|
||||
return mount(<ErrorHandler {...props} />);
|
||||
}
|
||||
|
||||
it("does not render an alert when error property is not set", () => {
|
||||
const wrapper = createComponent();
|
||||
expect(wrapper.find(Alert).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders an alert when error property is set", () => {
|
||||
const props: IErrorHandlerProps = {
|
||||
...defaultProps,
|
||||
error: {
|
||||
errorCode: ErrorCode.Unknown,
|
||||
message: "error message",
|
||||
title: "error title",
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = createComponent(props);
|
||||
const alert = wrapper.find(Alert);
|
||||
expect(alert.exists()).toBe(true);
|
||||
expect(alert.prop("title")).toEqual(strings.errors.unknown.title);
|
||||
expect(alert.prop("message")).toEqual(strings.errors.unknown.message);
|
||||
});
|
||||
|
||||
it("renders an alert with localized error messages", () => {
|
||||
const props: IErrorHandlerProps = {
|
||||
...defaultProps,
|
||||
error: {
|
||||
errorCode: ErrorCode.ProjectInvalidJson,
|
||||
message: "JSON is messed up",
|
||||
title: "JSON error",
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = createComponent(props);
|
||||
const alert = wrapper.find(Alert);
|
||||
expect(alert.exists()).toBe(true);
|
||||
expect(alert.prop("title")).toEqual(strings.errors.projectInvalidJson.title);
|
||||
expect(alert.prop("message")).toEqual(strings.errors.projectInvalidJson.message);
|
||||
});
|
||||
|
||||
it("calls onError when window Error is thrown", () => {
|
||||
const thrownError = new Error("New generic error message");
|
||||
const errorEvent = new ErrorEvent("error", {
|
||||
message: thrownError.message,
|
||||
lineno: 100,
|
||||
colno: 1,
|
||||
error: thrownError,
|
||||
});
|
||||
|
||||
const expectedError: IAppError = {
|
||||
errorCode: ErrorCode.Unknown,
|
||||
title: thrownError.name,
|
||||
message: thrownError.message,
|
||||
};
|
||||
|
||||
createComponent();
|
||||
document.dispatchEvent(errorEvent);
|
||||
|
||||
expect(onErrorHandler).toBeCalledWith(expectedError);
|
||||
});
|
||||
|
||||
it("calls onError when window AppError is thrown", () => {
|
||||
const thrownError = new AppError(ErrorCode.ProjectInvalidJson, "message", "title");
|
||||
const errorEvent = new ErrorEvent("error", {
|
||||
message: thrownError.message,
|
||||
lineno: 100,
|
||||
colno: 1,
|
||||
error: thrownError,
|
||||
});
|
||||
|
||||
const expectedError: IAppError = {
|
||||
errorCode: ErrorCode.ProjectInvalidJson,
|
||||
title: thrownError.title,
|
||||
message: thrownError.message,
|
||||
};
|
||||
|
||||
createComponent();
|
||||
document.dispatchEvent(errorEvent);
|
||||
|
||||
expect(onErrorHandler).toBeCalledWith(expectedError);
|
||||
});
|
||||
|
||||
it("calls onError when unhandled rejection throws Error", () => {
|
||||
const thrownError = new Error("New generic error message");
|
||||
const errorEvent = new CustomEvent("unhandledrejection", {
|
||||
detail: thrownError,
|
||||
});
|
||||
|
||||
const expectedError: IAppError = {
|
||||
errorCode: ErrorCode.Unknown,
|
||||
title: thrownError.name,
|
||||
message: thrownError.message,
|
||||
};
|
||||
|
||||
createComponent();
|
||||
document.dispatchEvent(errorEvent);
|
||||
|
||||
expect(onErrorHandler).toBeCalledWith(expectedError);
|
||||
});
|
||||
|
||||
it("calls onError when unhandled rejection throws AppError", () => {
|
||||
const thrownError = new AppError(ErrorCode.ProjectInvalidJson, "message", "title");
|
||||
const errorEvent = new CustomEvent("unhandledrejection", {
|
||||
detail: thrownError,
|
||||
});
|
||||
|
||||
const expectedError: IAppError = {
|
||||
errorCode: ErrorCode.ProjectInvalidJson,
|
||||
title: thrownError.title,
|
||||
message: thrownError.message,
|
||||
};
|
||||
|
||||
createComponent();
|
||||
document.dispatchEvent(errorEvent);
|
||||
|
||||
expect(onErrorHandler).toBeCalledWith(expectedError);
|
||||
});
|
||||
|
||||
it("calls onError when unhandled rejection resolves with string reason", () => {
|
||||
const thrownError = "message as string";
|
||||
const errorEvent = new CustomEvent("unhandledrejection", {
|
||||
detail: thrownError,
|
||||
});
|
||||
|
||||
const expectedError: IAppError = {
|
||||
errorCode: ErrorCode.Unknown,
|
||||
message: thrownError,
|
||||
};
|
||||
|
||||
createComponent();
|
||||
document.dispatchEvent(errorEvent);
|
||||
|
||||
expect(onErrorHandler).toBeCalledWith(expectedError);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,130 @@
|
|||
import React from "react";
|
||||
import { IAppError, ErrorCode, AppError } from "../../../../models/applicationState";
|
||||
import { strings } from "../../../../common/strings";
|
||||
import Alert from "../alert/alert";
|
||||
import { instanceOf } from "prop-types";
|
||||
|
||||
/**
|
||||
* Component properties for ErrorHandler component
|
||||
*/
|
||||
export interface IErrorHandlerProps extends React.Props<ErrorHandler> {
|
||||
error: IAppError;
|
||||
onError: (error: IAppError) => void;
|
||||
onClearError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for catching and handling global application errors
|
||||
*/
|
||||
export class ErrorHandler extends React.Component<IErrorHandlerProps> {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.onWindowError = this.onWindowError.bind(this);
|
||||
this.onUnhandedRejection = this.onUnhandedRejection.bind(this);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
window.addEventListener("error", this.onWindowError, true);
|
||||
window.addEventListener("unhandledrejection", this.onUnhandedRejection, true);
|
||||
}
|
||||
|
||||
public componentWillMount() {
|
||||
window.removeEventListener("error", this.onWindowError);
|
||||
window.removeEventListener("unhandledrejection", this.onUnhandedRejection);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const showError = !!this.props.error;
|
||||
let localizedError: IAppError = null;
|
||||
if (showError) {
|
||||
localizedError = this.getLocalizedError(this.props.error);
|
||||
}
|
||||
|
||||
if (!showError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert title={localizedError ? localizedError.title : ""}
|
||||
message={localizedError ? localizedError.message : ""}
|
||||
closeButtonColor="secondary"
|
||||
show={showError}
|
||||
onClose={this.props.onClearError} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unhandled errors that bubbled up to top of stack
|
||||
* @param evt Error Event
|
||||
*/
|
||||
private onWindowError(evt: ErrorEvent) {
|
||||
this.handleError(evt.error);
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles async / promise based errors
|
||||
* @param evt Unhandled Rejection Event
|
||||
*/
|
||||
private onUnhandedRejection(evt: any) {
|
||||
this.handleError(evt.reason || evt.detail);
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles various error format scenarios
|
||||
* @param error The error to handle
|
||||
*/
|
||||
private handleError(error: string | Error | AppError) {
|
||||
let appError: IAppError = null;
|
||||
|
||||
// Promise rejection with reason
|
||||
if (typeof (error) === "string") {
|
||||
// Promise rejection with string base reason
|
||||
appError = {
|
||||
errorCode: ErrorCode.Unknown,
|
||||
message: error,
|
||||
};
|
||||
} else if (error instanceof AppError) {
|
||||
// Promise rejection with AppError
|
||||
const reason = error as IAppError;
|
||||
appError = {
|
||||
errorCode: reason.errorCode,
|
||||
message: reason.message,
|
||||
title: reason.title,
|
||||
};
|
||||
} else if (error instanceof Error) {
|
||||
// Promise rejection with other error like object
|
||||
const reason = error as Error;
|
||||
appError = {
|
||||
errorCode: ErrorCode.Unknown,
|
||||
message: reason.message,
|
||||
title: reason.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (!appError) {
|
||||
appError = new AppError(ErrorCode.Unknown, "Unknown Error occurred");
|
||||
}
|
||||
|
||||
this.props.onError(appError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a localized version of the error
|
||||
* @param appError The error thrown by the application
|
||||
*/
|
||||
private getLocalizedError(appError: IAppError): IAppError {
|
||||
const localizedError = strings.errors[appError.errorCode];
|
||||
if (!localizedError) {
|
||||
return appError;
|
||||
}
|
||||
|
||||
return {
|
||||
errorCode: appError.errorCode,
|
||||
message: localizedError.message || strings.errors.unknown.message,
|
||||
title: localizedError.title || strings.errors.unknown.title,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -79,7 +79,7 @@ export default class AppSettingsPage extends React.Component<IAppSettingsProps>
|
|||
|
||||
private async onFormSubmit(appSettings: IAppSettings) {
|
||||
await this.props.actions.saveAppSettings(appSettings);
|
||||
toast.success(`Successfully saved application settings`);
|
||||
toast.success(strings.appSettings.messages.saveSuccess);
|
||||
this.props.history.goBack();
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import ExportForm from "./exportForm";
|
|||
import { IProject, IApplicationState, IExportFormat } from "../../../../models/applicationState";
|
||||
import { strings } from "../../../../common/strings";
|
||||
import { ExportAssetState } from "../../../../providers/export/exportProvider";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
/**
|
||||
* Properties for Export Page
|
||||
|
@ -89,6 +90,7 @@ export default class ExportPage extends React.Component<IExportPageProps> {
|
|||
};
|
||||
|
||||
await this.props.actions.saveProject(projectToUpdate);
|
||||
toast.success(strings.export.messages.saveSuccess);
|
||||
this.props.history.goBack();
|
||||
}
|
||||
|
||||
|
|
|
@ -4,14 +4,13 @@ import { Provider } from "react-redux";
|
|||
import { BrowserRouter as Router, Link } from "react-router-dom";
|
||||
import { AnyAction, Store } from "redux";
|
||||
import MockFactory from "../../../../common/mockFactory";
|
||||
import { IApplicationState, IProject, AppErrorType } from "../../../../models/applicationState";
|
||||
import { IApplicationState, IProject } from "../../../../models/applicationState";
|
||||
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
|
||||
import createReduxStore from "../../../../redux/store/store";
|
||||
import ProjectService from "../../../../services/projectService";
|
||||
import CondensedList from "../../common/condensedList/condensedList";
|
||||
import FilePicker from "../../common/filePicker/filePicker";
|
||||
import HomePage, { IHomepageProps } from "./homePage";
|
||||
import IAppErrorActions, * as appErrorActions from "../../../../redux/actions/appErrorActions";
|
||||
|
||||
jest.mock("../../../../services/projectService");
|
||||
|
||||
|
@ -112,32 +111,6 @@ describe("Connection Picker Component", () => {
|
|||
expect(openProjectSpy).toBeCalledWith(testProject);
|
||||
});
|
||||
|
||||
it("should call showError action when passed an invalid json project", async () => {
|
||||
// refactoring warning: action spy have to be created before creating component
|
||||
const showErrorSpy = jest.spyOn(props.appErrorActions, "showError");
|
||||
|
||||
const wrapper = createComponent(store, props);
|
||||
const textBlob = new Blob(["foo"], { type: "text/plain" });
|
||||
|
||||
const fileUpload = wrapper.find("a.file-upload").first();
|
||||
const fileInput = wrapper.find(`input[type="file"]`);
|
||||
|
||||
fileUpload.simulate("click");
|
||||
await MockFactory.flushUi(() => {
|
||||
fileInput.simulate("change", ({ target: { files: [textBlob] } }));
|
||||
});
|
||||
|
||||
await MockFactory.flushUi();
|
||||
|
||||
const expectedAppError = {
|
||||
title: "Project Loading has an error",
|
||||
message: "File is not valid json",
|
||||
errorType: AppErrorType.Generic,
|
||||
};
|
||||
|
||||
expect(showErrorSpy).toBeCalledWith(expectedAppError);
|
||||
});
|
||||
|
||||
function createProps(): IHomepageProps {
|
||||
return {
|
||||
recentProjects: [],
|
||||
|
@ -162,7 +135,6 @@ describe("Connection Picker Component", () => {
|
|||
state: null,
|
||||
},
|
||||
actions: (projectActions as any) as IProjectActions,
|
||||
appErrorActions: (appErrorActions as any) as IAppErrorActions,
|
||||
match: {
|
||||
params: {},
|
||||
isExact: true,
|
||||
|
|
|
@ -3,7 +3,6 @@ import { connect } from "react-redux";
|
|||
import { Link, RouteComponentProps } from "react-router-dom";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { strings } from "../../../../common/strings";
|
||||
import { IApplicationState, IConnection, IProject, AppErrorType } from "../../../../models/applicationState";
|
||||
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
|
||||
import { CloudFilePicker } from "../../common/cloudFilePicker/cloudFilePicker";
|
||||
import CondensedList from "../../common/condensedList/condensedList";
|
||||
|
@ -12,13 +11,15 @@ import FilePicker from "../../common/filePicker/filePicker";
|
|||
import "./homePage.scss";
|
||||
import RecentProjectItem from "./recentProjectItem";
|
||||
import { constants } from "../../../../common/constants";
|
||||
import IAppErrorActions, * as appErrorActions from "../../../../redux/actions/appErrorActions";
|
||||
import {
|
||||
IApplicationState, IConnection, IProject,
|
||||
ErrorCode, AppError, IAppError,
|
||||
} from "../../../../models/applicationState";
|
||||
|
||||
export interface IHomepageProps extends RouteComponentProps, React.Props<HomePage> {
|
||||
recentProjects: IProject[];
|
||||
connections: IConnection[];
|
||||
actions: IProjectActions;
|
||||
appErrorActions: IAppErrorActions;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IApplicationState) {
|
||||
|
@ -31,7 +32,6 @@ function mapStateToProps(state: IApplicationState) {
|
|||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(projectActions, dispatch),
|
||||
appErrorActions: bindActionCreators(appErrorActions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -120,20 +120,22 @@ export default class HomePage extends React.Component<IHomepageProps> {
|
|||
|
||||
private onProjectFileUpload = async (e, project) => {
|
||||
let projectJson: IProject;
|
||||
|
||||
try {
|
||||
projectJson = JSON.parse(project);
|
||||
await this.loadSelectedProject(projectJson);
|
||||
} catch (error) {
|
||||
this.props.appErrorActions.showError({
|
||||
title: strings.homePage.loadProjectError.title,
|
||||
message: strings.homePage.loadProjectError.message,
|
||||
errorType: AppErrorType.Generic,
|
||||
});
|
||||
throw new AppError(ErrorCode.ProjectInvalidJson, "Error parsing JSON");
|
||||
}
|
||||
|
||||
await this.loadSelectedProject(projectJson);
|
||||
}
|
||||
|
||||
private onProjectFileUploadError = (e, err) => {
|
||||
console.error(err);
|
||||
private onProjectFileUploadError = (e, error: any) => {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(ErrorCode.ProjectUploadError, "Error uploading project file");
|
||||
}
|
||||
|
||||
private loadSelectedProject = async (project: IProject) => {
|
||||
|
@ -144,5 +146,4 @@ export default class HomePage extends React.Component<IHomepageProps> {
|
|||
private deleteProject = async (project: IProject) => {
|
||||
await this.props.actions.deleteProject(project);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -3,11 +3,12 @@ import { connect } from "react-redux";
|
|||
import { bindActionCreators } from "redux";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import ProjectForm from "./projectForm";
|
||||
import { strings } from "../../../../common/strings";
|
||||
import { strings, interpolate } from "../../../../common/strings";
|
||||
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
|
||||
import { IApplicationState, IProject, IConnection, IAppSettings } from "../../../../models/applicationState";
|
||||
import IApplicationActions, * as applicationActions from "../../../../redux/actions/applicationActions";
|
||||
import { generateKey } from "../../../../common/crypto";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
/**
|
||||
* Properties for Project Settings Page
|
||||
|
@ -87,6 +88,8 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
|
|||
await this.ensureSecurityToken(project);
|
||||
await this.props.projectActions.saveProject(project);
|
||||
|
||||
toast.success(interpolate(strings.projectSettings.messages.saveSuccess, { project }));
|
||||
|
||||
if (isNew) {
|
||||
this.props.history.push(`/projects/${this.props.project.id}/edit`);
|
||||
} else {
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
import { Dispatch, Action } from "redux";
|
||||
import ProjectService from "../../services/projectService";
|
||||
import {
|
||||
IProject, IAsset, IAssetMetadata, IApplicationState,
|
||||
ISecurityToken, IAppSettings,
|
||||
} from "../../models/applicationState";
|
||||
import { ActionTypes } from "./actionTypes";
|
||||
import { AssetService } from "../../services/assetService";
|
||||
import { ExportProviderFactory } from "../../providers/export/exportProviderFactory";
|
||||
import {
|
||||
IProject, IAsset, IAssetMetadata, IApplicationState,
|
||||
ErrorCode, AppError,
|
||||
} from "../../models/applicationState";
|
||||
import { createPayloadAction, IPayloadAction, createAction } from "./actionCreators";
|
||||
import { IExportResults } from "../../providers/export/exportProvider";
|
||||
import { generateKey } from "../../common/crypto";
|
||||
import { saveAppSettingsAction } from "./applicationActions";
|
||||
|
||||
/**
|
||||
* Actions to be performed in relation to projects
|
||||
|
@ -41,7 +39,7 @@ export function loadProject(project: IProject):
|
|||
.find((st) => st.name === project.securityToken);
|
||||
|
||||
if (!securityToken) {
|
||||
throw new Error(`Cannot locate security token '${project.securityToken}' from project`);
|
||||
throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found");
|
||||
}
|
||||
const loadedProject = await projectService.load(project, securityToken);
|
||||
|
||||
|
@ -69,7 +67,7 @@ export function saveProject(project: IProject):
|
|||
.find((st) => st.name === project.securityToken);
|
||||
|
||||
if (!securityToken) {
|
||||
throw new Error(`Cannot locate security token '${project.securityToken}' from project`);
|
||||
throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found");
|
||||
}
|
||||
|
||||
const savedProject = await projectService.save(project, securityToken);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { reducer } from "./appErrorReducer";
|
||||
import { IAppError } from "../../models/applicationState";
|
||||
import { IAppError, ErrorCode } from "../../models/applicationState";
|
||||
import { clearErrorAction, showErrorAction } from "../actions/appErrorActions";
|
||||
import { anyOtherAction } from "../actions/actionCreators";
|
||||
import MockFactory from "../../common/mockFactory";
|
||||
|
@ -13,6 +13,7 @@ describe("AppError Reducer", () => {
|
|||
|
||||
it("ShowError discard previous state and return an appError", () => {
|
||||
const appError = MockFactory.createAppError(
|
||||
ErrorCode.Unknown,
|
||||
"Sample Error Title",
|
||||
"Sample Error Message",
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import shortid from "shortid";
|
||||
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
|
||||
import { IProject, ISecurityToken } from "../models/applicationState";
|
||||
import { IProject, ISecurityToken, AppError, ErrorCode } from "../models/applicationState";
|
||||
import Guard from "../common/guard";
|
||||
import { constants } from "../common/constants";
|
||||
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
|
||||
|
@ -31,9 +31,13 @@ export default class ProjectService implements IProjectService {
|
|||
public load(project: IProject, securityToken: ISecurityToken): Promise<IProject> {
|
||||
Guard.null(project);
|
||||
|
||||
const loadedProject = decryptProject(project, securityToken);
|
||||
|
||||
return Promise.resolve(loadedProject);
|
||||
try {
|
||||
const loadedProject = decryptProject(project, securityToken);
|
||||
return Promise.resolve(loadedProject);
|
||||
} catch (e) {
|
||||
const error = new AppError(ErrorCode.ProjectInvalidSecurityToken, "Error decrypting project settings");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Загрузка…
Ссылка в новой задаче