terriajs
Version:
Geospatial data visualization platform.
497 lines (441 loc) • 17.5 kB
text/typescript
import i18next from "i18next";
import { observable, makeObservable } from "mobx";
import RequestErrorEvent from "terriajs-cesium/Source/Core/RequestErrorEvent";
import Terria from "../Models/Terria";
import { Notification } from "../ReactViewModels/NotificationState";
import { terriaErrorNotification } from "../ReactViews/Notification/terriaErrorNotification";
import filterOutUndefined from "./filterOutUndefined";
import flatten from "./flatten";
import isDefined from "./isDefined";
/** This is used for I18n translation strings so we can "resolve" them when the Error is displayed to the user.
* This means we can create TerriaErrors before i18next has been initialised.
*/
export interface I18nTranslateString {
key: string;
parameters?: Record<string, string>;
}
function resolveI18n(i: I18nTranslateString | string) {
return typeof i === "string" ? i : i18next.t(i.key, i.parameters);
}
/** `TerriaErrorSeverity` can be `Error` or `Warning`.
* Errors with severity `Error` are presented to the user. `Warning` will just be printed to console.
*/
export enum TerriaErrorSeverity {
/** Errors which should be shown to the user. This is the default value for all errors.
*/
Error,
/** Errors which can be ignored by the user. These will be printed to console s
* For example:
* - Failing to load models (from share links or stories) if they are **NOT** in the workbench
*/
Warning
}
/** Object used to create a TerriaError */
export interface TerriaErrorOptions {
/** A detailed message describing the error. This message may be HTML and it should be sanitized before display to the user. */
message: string | I18nTranslateString;
/** Importance of the error message, this is used to determine which message is displayed to the user if multiple error messages exist.
* Higher importance messages are shown to user over lower importance. Default value is 0
* If two errors of equal importance are found - the first error found through depth-first search will be shown
*/
importance?: number;
/** A short title describing the error. */
title?: string | I18nTranslateString;
/** The object that raised the error. */
sender?: unknown;
/** True if error message should be shown to user *regardless* of error severity. If this is undefined, then error severity will be used to determine if overrideRaiseToUser (severity `Error` are presented to the user. `Warning` will just be printed to console) */
overrideRaiseToUser?: boolean;
/** True if the user has seen this error; otherwise, false. */
raisedToUser?: boolean;
/** Error which this error was created from. This means TerriaErrors can be represented as a tree of errors - and therefore a stacktrace can be generated */
originalError?: TerriaError | Error | (TerriaError | Error)[];
/** TerriaErrorSeverity - will default to `Error`
* A function can be used here, which will be resolved when the error is raised to user.
*/
severity?: TerriaErrorSeverity | (() => TerriaErrorSeverity);
/** If true, show error details in terriaErrorNotification by default. If false, error details will be collapsed by default */
showDetails?: boolean;
}
/** Object used to clone an existing TerriaError (see `TerriaError.createParentError()`).
*
* If this is a `string` it will be used to set `TerriaError.message`
* If this is `TerriaErrorSeverity` it will be used to set `TerriaError.severity`
*/
export type TerriaErrorOverrides =
| Partial<TerriaErrorOptions>
| string
| TerriaErrorSeverity;
/** Turn TerriaErrorOverrides to TerriaErrorOptions so it can be passed to TerriaError constructor */
export function parseOverrides(
overrides: TerriaErrorOverrides | undefined
): Partial<TerriaErrorOptions> {
// If overrides is a string - we treat is as the `message` parameter
if (typeof overrides === "string") {
overrides = { message: overrides };
} else if (typeof overrides === "number") {
overrides = { severity: overrides };
}
// Remove undefined properties
if (overrides)
Object.keys(overrides).forEach((key) =>
(overrides as any)[key] === undefined
? delete (overrides as any)[key]
: null
);
return overrides ?? {};
}
/**
* Represents an error that occurred in a TerriaJS module, especially an asynchronous one that cannot be raised
* by throwing an exception because no one would be able to catch it.
*/
export default class TerriaError {
private readonly _message: string | I18nTranslateString;
private readonly _title: string | I18nTranslateString;
private _raisedToUser: boolean;
readonly importance: number = 0;
readonly severity: TerriaErrorSeverity | (() => TerriaErrorSeverity);
/** `sender` isn't really used for anything at the moment... */
readonly sender: unknown;
readonly originalError?: (TerriaError | Error)[];
readonly stack: string;
/** Override shouldRaiseToUser (see `get shouldRaiseToUser()`) */
overrideRaiseToUser: boolean | undefined;
showDetails: boolean;
/**
* Convenience function to generate a TerriaError from some unknown error. It will try to extract a meaningful message from whatever object it is given.
*
* `overrides` can be used to add more context to the TerriaError
*
* If error is a `TerriaError`, and `overrides` are provided - then `createParentError` will be used to create a tree of `TerriaErrors` (see {@link `TerriaError#createParentError}`).
*
* Note, you can not pass `TerriaErrorOptions` (or JSON version of `TerriaError`) as the error parameter.
*
* For example:
*
* This is **incorrect**:
*
* ```
* TerriaError.from({message: "Some message", title: "Some title"})
* ```
*
* Instead you must use TerriaError constructor
*
* This is **correct**:
*
* ```
* new TerriaError({message: "Some message", title: "Some title"})
* ```
*/
static from(error: unknown, overrides?: TerriaErrorOverrides): TerriaError {
if (error instanceof TerriaError) {
return isDefined(overrides) ? error.createParentError(overrides) : error;
}
// Try to find message/title from error object
let message: string | I18nTranslateString = {
key: "core.terriaError.defaultMessage"
};
let title: string | I18nTranslateString = {
key: "core.terriaError.defaultTitle"
};
// Create original Error from `error` object
let originalError: Error | undefined;
if (typeof error === "string") {
message = error;
originalError = new Error(message);
}
// If error is RequestErrorEvent - use networkRequestTitle and networkRequestMessage
else if (error instanceof RequestErrorEvent) {
title = { key: "core.terriaError.networkRequestTitle" };
message = {
key: "core.terriaError.networkRequestMessage"
};
originalError = new Error(error.toString());
} else if (error instanceof Error) {
message = error.message;
originalError = error;
} else if (typeof error === "object" && error !== null) {
message = error.toString();
originalError = new Error(error.toString());
}
return new TerriaError({
title,
message,
originalError,
...parseOverrides(overrides)
});
}
/** Combine an array of `TerriaErrors` into a single `TerriaError`.
* `overrides` can be used to add more context to the combined `TerriaError`.
*/
static combine(
errors: (TerriaError | undefined)[],
overrides: TerriaErrorOverrides
): TerriaError | undefined {
const filteredErrors = errors.filter((e) => isDefined(e)) as TerriaError[];
if (filteredErrors.length === 0) return;
// If only one error, just create parent error - this is so we don't get unnecessary levels of TerriaError created
if (filteredErrors.length === 1) {
return filteredErrors[0].createParentError(overrides);
}
// Find highest severity across errors (eg if one if `Error`, then the new TerriaError will also be `Error`)
const severity = () =>
filteredErrors
.map((error) =>
typeof error.severity === "function"
? error.severity()
: error.severity
)
.includes(TerriaErrorSeverity.Error)
? TerriaErrorSeverity.Error
: TerriaErrorSeverity.Warning;
// overrideRaiseToUser will be true if at least one error includes overrideRaiseToUser = true
// Otherwise, it will be undefined
let overrideRaiseToUser: boolean | undefined =
filteredErrors.some((error) => error.overrideRaiseToUser === true) ||
undefined;
// overrideRaiseToUser will be false if:
// - NO errors includes overrideRaiseToUser = true
// - and at least one error includes overrideRaiseToUser = false
if (
!isDefined(overrideRaiseToUser) &&
filteredErrors.some((error) => error.overrideRaiseToUser === false)
) {
overrideRaiseToUser = false;
}
return new TerriaError({
// Set default title and message
title: { key: "core.terriaError.defaultCombineTitle" },
message: { key: "core.terriaError.defaultCombineMessage" },
// Add original errors and overrides
originalError: filteredErrors,
severity,
overrideRaiseToUser,
...parseOverrides(overrides)
});
}
constructor(options: TerriaErrorOptions) {
makeObservable(this);
this._message = options.message;
this._title = options.title ?? { key: "core.terriaError.defaultTitle" };
this.sender = options.sender;
this._raisedToUser = options.raisedToUser ?? false;
this.overrideRaiseToUser = options.overrideRaiseToUser;
this.importance = options.importance ?? 0;
this.showDetails = options.showDetails ?? false;
// Transform originalError to an array if needed
this.originalError = isDefined(options.originalError)
? Array.isArray(options.originalError)
? options.originalError
: [options.originalError]
: [];
this.severity = options.severity ?? TerriaErrorSeverity.Error;
this.stack = (new Error().stack ?? "")
.split("\n")
// Filter out some less useful lines in the stack trace
.filter((s) =>
["result.ts", "terriaerror.ts", "opendatasoft.apiclient.umd.js"].every(
(remove) => !s.toLowerCase().includes(remove)
)
)
.join("\n");
}
get message() {
return resolveI18n(this._message);
}
/** Return error with message of highest importance in Error tree */
get highestImportanceError() {
return this.flatten().sort((a, b) => b.importance - a.importance)[0];
}
get title() {
return resolveI18n(this._title);
}
/** True if `severity` is `Error` and the error hasn't been raised yet - or return this.overrideRaiseToUser if it is defined */
get shouldRaiseToUser() {
return (
// Return this.overrideRaiseToUser override if it is defined
this.overrideRaiseToUser ??
// Otherwise, we should raise the error if it hasn't already been raised and the severity is ERROR
(!this.raisedToUser &&
(typeof this.severity === "function"
? this.severity()
: this.severity) === TerriaErrorSeverity.Error)
);
}
/** Has any error in the error tree been raised to the user? */
get raisedToUser() {
return !!this.flatten().find((error) => error._raisedToUser);
}
/** Resolve error seveirty */
get resolvedSeverity() {
return typeof this.severity === "function"
? this.severity()
: this.severity;
}
/** Set raisedToUser value for **all** `TerriaErrors` in this tree. */
set raisedToUser(r: boolean) {
this._raisedToUser = r;
if (this.originalError) {
this.originalError.forEach((err) =>
err instanceof TerriaError ? (err.raisedToUser = r) : null
);
}
}
/** Print error to console */
log(): void {
if (this.resolvedSeverity === TerriaErrorSeverity.Warning) {
console.warn(this.toString());
} else {
console.error(this.toString());
}
}
/** Convert `TerriaError` to `Notification` */
toNotification(): Notification {
return {
title: () => this.highestImportanceError.title, // Title may need to be resolved when error is raised to user (for example after i18next initialisation)
message: terriaErrorNotification(this),
// Don't show TerriaError Notification if shouldRaiseToUser is false, or we have already raisedToUser
ignore: () => !this.shouldRaiseToUser,
// Set raisedToUser to true on dismiss
onDismiss: () => (this.raisedToUser = true)
};
}
/**
* Create a new parent `TerriaError` from this error. This essentially "clones" the `TerriaError` and applied `overrides` on top. It will also set `originalError` so we get a nice tree of `TerriaErrors`
*/
createParentError(overrides?: TerriaErrorOverrides): TerriaError {
// Note: we don't copy over `raisedToUser` or `importance` here
// We don't need `raisedToUser` as the getter will check all errors in the tree when called
// We don't want `importance` copied over, as it may vary between errors in the tree - and we want to be able to find errors with highest importance when diplaying the entire error tree to the user
return new TerriaError({
message: this._message,
title: this._title,
sender: this.sender,
originalError: this,
severity: this.severity,
overrideRaiseToUser: this.overrideRaiseToUser,
...parseOverrides(overrides)
});
}
/** Depth-first flatten */
flatten(): TerriaError[] {
return filterOutUndefined([
this,
...flatten(
this.originalError
? this.originalError.map((error) =>
error instanceof TerriaError ? error.flatten() : []
)
: []
)
]);
}
/**
* Returns a plain error object for this TerriaError instance.
*
* The `message` string for the returned plain error will include the
* messages from all the nested `originalError`s for this instance.
*/
toError(): Error {
// indentation required per nesting when stringifying nested error messages
const indentChar = " ";
const buildNested: (
prop: "message" | "stack"
) => (error: TerriaError, depth: number) => string | undefined =
(prop) => (error, depth) => {
if (!Array.isArray(error.originalError)) {
return;
}
const indent = indentChar.repeat(depth);
const nestedMessage = error.originalError
.map((e) => {
if (e instanceof TerriaError) {
// recursively build the message for nested errors
return `${e[prop]
?.split("\n")
.map((s) => indent + s)
.join("\n")}\n${buildNested(prop)(e, depth + 1)}`;
} else {
return `${e[prop]
?.split("\n")
.map((s) => indent + s)
.join("\n")}`;
}
})
.join("\n");
return nestedMessage;
};
let message = this.message;
const nestedMessage = buildNested("message")(this, 1);
if (nestedMessage) {
message = `${message}\nNested error:\n${nestedMessage}`;
}
const error = new Error(message);
error.name = this.title;
let stack = this.stack;
const nestedStack = buildNested("stack")(this, 1);
if (nestedStack) {
stack = `${stack}\n${nestedStack}`;
}
error.stack = stack;
return error;
}
toString(): string {
// indentation required per nesting when stringifying nested error messages
const indentChar = " ";
const buildNested: (
error: TerriaError,
depth: number
) => string | undefined = (error, depth) => {
if (!Array.isArray(error.originalError)) {
return;
}
const indent = indentChar.repeat(depth);
const nestedMessage = error.originalError
.map((e) => {
const log = `${e.message}\n${e.stack}`
.split("\n")
.map((s) => indent + s)
.join("\n");
if (e instanceof TerriaError) {
// recursively build the message for nested errors
return `${log}\n${buildNested(e, depth + 1)}`;
} else {
return log;
}
})
.join("\n");
return nestedMessage;
};
const nestedMessage = buildNested(this, 1);
return `${this.title}: ${this.highestImportanceError.message}\n${nestedMessage}`;
}
raiseError(
terria: Terria,
errorOverrides?: TerriaErrorOverrides,
forceRaiseToUser?: boolean
): void {
terria.raiseErrorToUser(this, errorOverrides, forceRaiseToUser);
}
}
/** Wrap up network request error with user-friendly message */
export function networkRequestError(error: TerriaError | TerriaErrorOptions) {
// Combine network error with "networkRequestMessageDetailed" - this contains extra info about what could cause network error
return TerriaError.combine(
[
error instanceof TerriaError ? error : new TerriaError(error),
new TerriaError({
message: {
key: "core.terriaError.networkRequestMessageDetailed"
}
})
],
// Override combined error with user-friendly title and message
{
title: { key: "core.terriaError.networkRequestTitle" },
message: {
key: "core.terriaError.networkRequestMessage"
},
importance: 1
}
);
}