UNPKG

terriajs

Version:

Geospatial data visualization platform.

497 lines (441 loc) 17.5 kB
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; @observable 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 } ); }