Skip to content

Instantly share code, notes, and snippets.

@drewwiens
Created April 14, 2022 20:05
Show Gist options
  • Save drewwiens/2fb77f2e162ce7b87d9e1d59314033cd to your computer and use it in GitHub Desktop.
Save drewwiens/2fb77f2e162ce7b87d9e1d59314033cd to your computer and use it in GitHub Desktop.
One-line console log/warn/error/info + Angular Material snackbar messages
// 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