Created
April 14, 2022 20:05
-
-
Save drewwiens/2fb77f2e162ce7b87d9e1d59314033cd to your computer and use it in GitHub Desktop.
One-line console log/warn/error/info + Angular Material snackbar messages
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// One-line console log/warn/error/info + Angular Material snackbar messages | |
// | |
// Example Usage: | |
// | |
// constructor(private notifySvc: NotifyService) {} | |
// | |
// Replaces: | |
// console.error(e); | |
// this.notifySvc.notify(e); | |
// | |
// With: | |
// this.notifySvc.error(e).and.notify(); | |
// | |
// Lots of other chains are possible too | |
// | |
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
import { Injectable } from '@angular/core'; | |
import { MatSnackBar } from '@angular/material/snack-bar'; | |
import { Action } from '@ngrx/store'; | |
import stringify from 'fast-json-stable-stringify'; | |
import { get, isNumber, isString, isUndefined } from 'lodash'; | |
import { EMPTY, Observable } from 'rxjs'; | |
export const FORCE_MILLIS = 50; | |
export const DURATION = { short: 1500, medium: 4000, long: 10000 }; // in milliseconds | |
export type Duration = keyof typeof DURATION; | |
export const levels = ['error', 'warn', 'log'] as const; | |
export type Level = typeof levels[number]; | |
/** | |
* Shallow-clone an instance of a class with all methods and props pointing to | |
* those of the original instance (except props that have primitive types like | |
* string, number, boolean, etc) | |
* | |
* @param obj The class instance to clone | |
* @returns Shallow-cloned class instance | |
*/ | |
export function cloneClassInstance<T>(obj: T): T { | |
return Object.assign(Object.create(Object.getPrototypeOf(obj)), obj); | |
} | |
/** | |
* A clone of the RxJs EMPTY observable with one additional prop, "and", that | |
* points to the chain object. | |
* | |
* EMPTY allows returning inside RxJs' catchError operator, e.g. `return | |
* notifySvc.notify('msg')`, and the "and" prop allows chaining multiple | |
* notifications, e.g. `return notifySvc.error('msg').and.notify()`. | |
* | |
* @see NotifyService | |
*/ | |
export type NotifyServiceChainObservable = Observable<never> & { | |
and: NotifyServiceChain; | |
}; | |
/** | |
* A clone of NotifyService with additional props to support chaining multiple | |
* notifications, e.g. `notifySvc.error(x).and.notify()`. | |
* | |
* @see NotifyService | |
*/ | |
export type NotifyServiceChain = NotifyService & { | |
args: any[]; | |
uiDuration: number; | |
}; | |
export function toString(arg: any) { | |
return isString(arg) || isUndefined(arg) | |
? String(arg) | |
: (stringify as any)(arg, { cycle: true }); // XXX: stringify's .d.ts lacks the "opts" param: https://github.com/epoberezkin/fast-json-stable-stringify/issues/11 | |
} | |
/** | |
* Allows chained notifications similar to Jest and Jasmine statements, like: | |
* | |
* notifySvc.error(x).and.notify(); | |
* Log x to both console and snackbar | |
* | |
* notifySvc.duration('long').notify('Thing failed').and.action(a).error(); | |
* Show a user-facing msg for a long time & log ngrx action to console.error | |
*/ | |
@Injectable({ providedIn: 'root' }) | |
export class NotifyService { | |
/** Snackbar messages are blocked while this.enabled.ui is false. */ | |
private isEnabled = { ui: true }; // Object so the same inner value is referenced in all NotifyServiceChain objects | |
constructor(private snackbar: MatSnackBar) {} | |
/** | |
* Send message(s) to console.error(). | |
* | |
* If called without any params, the contents of the previous notification in | |
* the chain is used. E.g. "notifySvc.error('msg').and.notify()" sends 'msg' | |
* to both console.error and snackbar. | |
* | |
* Each param is printed to console separated by a blank line. | |
* | |
* Non-string params are stringified to decycled JSON. | |
* | |
* @param msg The parts of the message. | |
* @returns Object that can be used to make another notification. | |
*/ | |
error(...msg: any[]): NotifyServiceChainObservable { | |
if (!this.isNotifyServiceChain()) { | |
return this.getChain().error(...msg); | |
} | |
this._event('error', ...msg); | |
this.setArgs(msg); | |
return this.showInConsole('error'); | |
} | |
/** | |
* Send message(s) to console.warn(). | |
* | |
* If called without any params, the contents of the previous notification in | |
* the chain is used. E.g. "notifySvc.error('msg').and.notify()" sends 'msg' | |
* to both console.error and snackbar. | |
* | |
* Each param is printed to console separated by a blank line. | |
* | |
* Non-string params are stringified to decycled JSON. | |
* | |
* @param msg The parts of the message. | |
* @returns Object that can be used to make another notification. | |
*/ | |
warn(...msg: any[]): NotifyServiceChainObservable { | |
if (!this.isNotifyServiceChain()) { | |
return this.getChain().warn(...msg); | |
} | |
this._event('warn', ...msg); | |
this.setArgs(msg); | |
return this.showInConsole('warn'); | |
} | |
/** | |
* Send message(s) to console.log(). | |
* | |
* If called without any params, the contents of the previous notification in | |
* the chain is used. E.g. "notifySvc.error('msg').and.notify()" sends 'msg' | |
* to both console.error and snackbar. | |
* | |
* Each param is printed to console separated by a blank line. | |
* | |
* Non-string params are stringified to decycled JSON. | |
* | |
* @param msg The parts of the message. | |
* @returns Object that can be used to make another notification. | |
*/ | |
log(...msg: any[]): NotifyServiceChainObservable { | |
if (!this.isNotifyServiceChain()) { | |
return this.getChain().log(...msg); | |
} | |
this._event('log', ...msg); | |
this.setArgs(msg); | |
return this.showInConsole('log'); | |
} | |
/** | |
* Show message(s) in a snackbar. | |
* | |
* For Error objects, only the error's message prop is extracted. | |
* | |
* If called without any params, the contents of the previous notification in | |
* the chain is used. E.g. "notifySvc.error('msg').and.notify()" sends 'msg' | |
* to both console.error and snackbar. | |
* | |
* Non-string params are stringified to decycled JSON. | |
* | |
* @param msg The parts of the message. | |
* @returns Object that can be used to make another notification. | |
*/ | |
notify(...msg: any[]): NotifyServiceChainObservable { | |
if (!this.isNotifyServiceChain()) { | |
return this.getChain().notify(...msg); | |
} | |
this._event('notify', ...msg); | |
this.setArgs(msg); | |
return this.showInUi(false); | |
} | |
/** | |
* Show message(s) in a snackbar and prevent any other snackbar | |
* notifications for FORCE_MILLIS milliseconds. | |
* | |
* For Error objects, only the error's message prop is extracted. | |
* | |
* If called without any params, the contents of the previous notification in | |
* the chain is used. E.g. "notifySvc.error('msg').and.notify()" sends 'msg' | |
* to both console.error and snackbar. | |
* | |
* Non-string params are stringified to decycled JSON. | |
* | |
* @param msg The parts of the message. | |
* @returns Object that can be used to make another notification. | |
*/ | |
forceNotify(...msg: any[]): NotifyServiceChainObservable { | |
if (!this.isNotifyServiceChain()) { | |
return this.getChain().forceNotify(...msg); | |
} | |
this._event('forceNotify', ...msg); | |
this.setArgs(msg); | |
return this.showInUi(true); | |
} | |
/** | |
* Set snackbar duration for any following UI notification(s) in the chain. | |
* | |
* Has no effect on console notifications. | |
*/ | |
duration(duration: Duration | number): NotifyServiceChain { | |
if (!this.isNotifyServiceChain()) { | |
return this.getChain().duration(duration); | |
} | |
this._event('duration', duration); | |
this.uiDuration = isNumber(duration) ? duration : DURATION[duration]; | |
return this; | |
} | |
/** | |
* Set an automatic message for given NgRx action that will be used by any | |
* following notification(s) in the chain. | |
* | |
* If action contains an "error" prop, its message is extracted, else the | |
* whole action object is stringified. | |
*/ | |
action(action: Action): NotifyServiceChain { | |
if (!this.isNotifyServiceChain()) { | |
return this.getChain().action(action); | |
} | |
this._event('action', action); | |
this.args = [`Error with action "${action.type}"`]; | |
this.args.push((action as any).error || action); // Extract error if it exists, else send the whole action object | |
return this; | |
} | |
/** | |
* Only unit tests should use this outside of NotifyService. | |
* | |
* Allows unit testing the sequence of method calls in the notification chain | |
* by spying on this method. | |
* | |
* Every other public method should call this method internally. | |
* | |
* @param _method The public method called on NotifyService (or a chain) | |
* @param _params The params passed to that method | |
*/ | |
_event(_method: keyof NotifyService, ..._params: any[]) { | |
return; | |
} | |
/** | |
* Return true if "this" is a NotifyServiceChain, i.e. a NotifyService with | |
* some extra props, and false if "this" is just a NotifyService. | |
*/ | |
private isNotifyServiceChain(): this is NotifyServiceChain { | |
return !!get(this, 'args'); | |
} | |
private throwIfNotChain(): asserts this is NotifyServiceChain { | |
if (!this.isNotifyServiceChain()) { | |
throw Error('Only callable on a NotifyServiceChain object'); | |
} | |
} | |
/** | |
* Get a shallow copy of this NotifyService and attach additional props to | |
* make it a NotifyServiceChain that supports chainable operations. | |
*/ | |
private getChain(): NotifyServiceChain { | |
if (this.isNotifyServiceChain()) { | |
return this; | |
} | |
return Object.assign( | |
); | |
} | |
/** | |
* Return an object that is a clone of the EMPTY observable with one | |
* additional prop, "and", that points to the chain object. | |
* | |
* EMPTY allows returning inside RxJs' catchError operator, e.g. `return | |
* notifySvc.notify('msg')`, and the "and" prop allows chaining multiple | |
* notifications, e.g. `return notifySvc.error('msg').and.notify()`. | |
*/ | |
private getChain$(): NotifyServiceChainObservable { | |
return Object.assign( | |
cloneClassInstance(EMPTY), // Return EMPTY as return value for use in catchError() operators | |
{ and: this.getChain() }, // Allow chaining via declarative "and" statements | |
); | |
} | |
/** | |
* Sets the given args on the chain object, unless none were passed. | |
* | |
* @param _method Name of caller | |
* @param args Args to set on the chain obj, unless none were passed | |
*/ | |
private setArgs(args: any[]): void { | |
this.throwIfNotChain(); | |
if (args.length === 0) { | |
return; // If no args were passed, use args from previous method in chain | |
} | |
this.args = args; | |
} | |
private getConsoleMsg(): string { | |
this.throwIfNotChain(); | |
return this.args.map(toString).join('\n\n'); // Separate each argument with a blank line in the console for better readability | |
} | |
private getUiMsg(): string { | |
this.throwIfNotChain(); | |
return this.args | |
.map((a) => (a instanceof Error && a.message) || a) // Extract message from any error objects | |
.map(toString) | |
.map((a, i, { length }) => | |
// Append ":" to end of first arg if more than 1 arg, and surround any extra args with quotes: | |
i === 0 ? (length > 1 ? `${a}:` : `${a}`) : `"${a}"`, | |
) | |
.join(' '); | |
} | |
private showInConsole(level: Level): NotifyServiceChainObservable { | |
this.throwIfNotChain(); | |
console[level](this.getConsoleMsg()); | |
return this.getChain$(); | |
} | |
private showInUi(force: boolean): NotifyServiceChainObservable { | |
this.throwIfNotChain(); | |
if (this.isEnabled.ui) { | |
this.snackbar.open(this.getUiMsg(), 'CLOSE', { | |
duration: this.uiDuration, | |
}); | |
// If "force", globally disable the UI option for FORCE_MILLIS: | |
if (force) { | |
this.isEnabled.ui = false; | |
setTimeout(() => (this.isEnabled.ui = true), FORCE_MILLIS); | |
} | |
} | |
return this.getChain$(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment