Skip to content

Instantly share code, notes, and snippets.

@drewwiens
Last active September 8, 2024 06:11
Show Gist options
  • Save drewwiens/8e6e33f67b0adbee3ff6468e3221c71a to your computer and use it in GitHub Desktop.
Save drewwiens/8e6e33f67b0adbee3ff6468e3221c71a to your computer and use it in GitHub Desktop.
Handy custom RxJS operators for Angular etc
import { AbstractControl, FormArray, FormGroup } from '@angular/forms';
import { map, toPairs, fromPairs, differenceWith, isEqual, isNull, isUndefined } from 'lodash';
import { Observable, OperatorFunction, defer, empty, of, merge, pipe } from 'rxjs';
import { distinctUntilChanged, filter, map, shareReplay, pairwise } from 'rxjs/operators';
/**
* Convenience RxJS operator that filters out undefined & null and modifies the downstream type
* appropriately.
*/
export function exists<T>(): OperatorFunction<T | undefined | null, T> {
return filter(
(v: T | undefined | null): v is T => !isUndefined(v) && !isNull(v),
);
}
/**
* Get an observable for the latest value of a control including its initial value.
*
* @param source The AbstractControl itself or its ancestor in the hierarchy.
* @param path If included, the path from source to the control. If not included, then source is
* used directly.
*/
export function toValueOfControl<T>(
source: AbstractControl,
path?: string | (string | number)[],
): Observable<T> {
const control = path ? source.get(path) : source;
if (!control) {
return empty();
}
// startWith evaluates immediately, but we need to get the value of the control at subscription
// time. Deferring allows us to get the updated value.
return merge(control.valueChanges, defer(() => of(control.value))).pipe(
distinctUntilChanged(isEqual),
shareReplay(1),
);
}
/**
* Get an observable for the latest raw value of a control including its initial raw value.
*
* @param source The AbstractControl itself or its ancestor in the hierarchy.
* @param path If included, the path from source to the control. If not included, then source is
* used directly.
*/
export function toRawValueOfControl<T>(
source: AbstractControl,
path?: string | (string | number)[],
): Observable<T> {
const control = path ? source.get(path) : source;
if (!control) {
return empty();
}
if (isFormGroup(control) || isFormArray(control)) {
// startWith evaluates immediately, but we need to get the value of the control at
// subscription time. Deferring allows us to get the updated value.
return merge(control.valueChanges, defer(() => of(control.getRawValue()))).pipe(
// get the raw value that contains all of the children, even the disabled ones
map(_value => control.getRawValue()),
distinctUntilChanged(isEqual),
shareReplay(1),
);
} else {
// a FormControl doesn't distinguish between value and rawValue, so just use the other function.
return toValueOfControl(control);
}
}
/**
* An RxJS operator that emits true if the source string starts with any of the given prefixes.
* @param prefixes Array of string prefixes.
*/
export function startsWith(prefixes: string[]) {
return map<string, boolean>(type => prefixes.some(pre => type.startsWith(pre)));
}
function isFormGroup(control: AbstractControl): control is FormGroup {
return control instanceof FormGroup;
}
function isFormArray(control: AbstractControl): control is FormArray {
return control instanceof FormArray;
}
/**
* An RxJS operator for the common shareReplay use-case where
* refCount = true and bufferSize = 1. Equivalent to shareReplay(1) in older
* versions of rxjs.
*/
export function shareReplay1<T>() {
return shareReplay<T>({ refCount: true, bufferSize: 1 });
}
/**
* RxJS operator that emits an object containing only the key-value pairs that changed. Does not
* emit anything until at least two values have been received.
*/
export function diff<T extends Record<string, any>>(): OperatorFunction<T, Partial<T>> {
return pipe(
map(toPairs),
pairwise(),
map(([prev, next]) => fromPairs(differenceWith(next, prev, isEqual)) as Partial<T>),
);
}
import { isFunction, isNull, isUndefined } from 'lodash';
import { EMPTY, Observable, of, OperatorFunction } from 'rxjs';
import { filter, map, shareReplay, take, timeoutWith } from 'rxjs/operators';
/**
* Generic assertion function to prove that a value is truthy.
*
* @param value The value to check
* @param message The error message to display
*/
export function truthy<T>(
value: T | undefined | null,
message = 'value is falsy',
): asserts value is T {
if (!value) {
throw new Error(message);
}
}
/**
* Generic assertion function to prove that a value is truthy.
*
* @param value The value to check
* @param message The error message to display
*/
export function assertFunction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
message = 'value is not a function',
): asserts value is CallableFunction {
if (!isFunction(value)) {
throw new Error(message);
}
}
/**
* asserts the value is truthy as to return it for chaining
*/
export function assure<T>(value: T | undefined | null): T {
truthy(value);
return value;
}
/**
* An RxJS operator for the common shareReplay use-case where
* refCount = true and bufferSize = 1.
*/
export function shareReplay1<T>() {
return shareReplay<T>({ refCount: true, bufferSize: 1 });
}
/**
* Get one item from source observable as a Promise.
*
* @param obs$ Source observable.
*/
export function resolveOne<T>(obs$: Observable<T>): Promise<T> {
return obs$.pipe(take(1)).toPromise();
}
/**
* Get current value from source observable as a Promise that resolves on the
* next tick.
*
* Note this can only get the "current value" of the observable if the
* observable emits immediately, e.g. BehaviorSubject (no other subscribers
* needed) or any observable piped thru shareReplay1() operator that has at
* least one other active subscription.
*
* Resolves to rxjs's EMPTY object if nothing was emitted, i.e. you can check
* if nothing was emitted by checking if the resolved value === EMPTY. This is
* not the same as resolving to EMPTY, which would resolve to undefined and
* would be indistinguishable from an observable that has a value of undefined.
*
* @param obs$ Source observable.
*/
export function valueOf<T>(obs$: Observable<T>): Promise<T | typeof EMPTY> {
return obs$.pipe(take(1), timeoutWith(0, of(EMPTY))).toPromise();
}
/**
* Convenience RxJS operator that filters out undefined & null and modifies the
* downstream type appropriately.
*/
export function exists<T>(): OperatorFunction<T | undefined | null, T> {
return filter(
(v: T | undefined | null): v is T => !isUndefined(v) && !isNull(v),
);
}
/** RxJS pipeable operator that emits true if all array items are truthy. */
export const allTruthy = () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map((array: any[]) => array.every(Boolean));
/** RxJS pipeable operator that emits true if any array items are truthy. */
export const someTruthy = () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map((array: any[]) => array.some(Boolean));
/**
* Convert a scalar, array, or undefined value to an array of that type. If the
* input is an array, then just use that. If it's a scalar, then wrap it in an
* array. If it's falsy, then replace with an empty array.
*
* @param input scalar, array, or undefined
*/
export function wrapInArray<T>(input?: T | T[]): T[] {
if (input === undefined) {
return [];
}
return Array.isArray(input) ? [...input] : [input];
}
// Tests for some operators in rxjs-operators.ts are contained in this file
/* eslint-disable rxjs/no-sharereplay */
import { subYears } from 'date-fns';
import { isEqual } from 'lodash';
import { firstValueFrom, from, of } from 'rxjs';
import { shareReplay, toArray, distinctUntilChanged } from 'rxjs/operators';
import {
allTruthy,
assertPropsNotNil,
assertString,
assureNotNil,
assureNotNull,
assurePropsNotNil,
convertNullProps,
convertToTimestamp,
distinctUntilNotEqual,
exists,
hash,
isBlankString,
isEmptyOrNil,
isNotNil,
isNotNull,
notNil,
notNull,
parseNumber,
resolveMany,
shareReplay1,
someTruthy,
toBoolean,
truthy,
urlJoin,
wildcardSearch,
wrapInArray,
extractFilenameFromFullPath,
caseInsensitiveIncludes,
caseInsensitiveEquals,
getLastYearRange,
escapeSpecialChars,
parseJson,
} from './functions';
jest.mock('rxjs/operators', () => ({
...jest.requireActual('rxjs/operators'),
shareReplay: jest.fn(() => 'mockShareReplay'),
distinctUntilChanged: jest.fn(() => 'mockDistinctUntilChanged'),
}));
describe('lang functions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe(isBlankString.name, () => {
it('should handle true cases', () => {
expect(isBlankString('')).toBe(true);
expect(isBlankString(' ')).toBe(true);
});
it('should handle false cases for string', () => {
expect(isBlankString('abc')).toBe(false);
});
it('should handle none string cases', () => {
expect(isBlankString(0)).toBe(false);
expect(isBlankString(false)).toBe(false);
});
});
describe(isEmptyOrNil.name, () => {
it('should handle true cases', () => {
expect(isEmptyOrNil(' ')).toBe(true);
expect(isEmptyOrNil('')).toBe(true);
expect(isEmptyOrNil(undefined)).toBe(true);
expect(isEmptyOrNil(null)).toBe(true);
});
it('should handle false cases', () => {
expect(isEmptyOrNil('0')).toBe(false);
expect(isEmptyOrNil('abc')).toBe(false);
});
});
describe('notNull', () => {
it('should assert if a non-null value is passed in', () => {
[0, 1, 'hello', {}, [], [1], true, undefined].forEach((value) => {
try {
notNull(value);
} catch (e) {
fail('do not expect to get here');
}
});
});
it('should throw an error if a null value is passed in', () => {
try {
notNull(null);
fail('do not expect to get here');
} catch (e) {
expect(e).toBeTruthy();
}
});
});
describe('assureNotNull', () => {
it('should assert if a non-null value is passed in', () => {
[0, 1, 'hello', {}, [], [1], true, undefined].forEach((value) => {
try {
expect(assureNotNull(value)).toBe(value);
} catch (e) {
fail('do not expect to get here');
}
});
});
it('should throw an error if a null value is passed in', () => {
try {
assureNotNull(null);
fail('do not expect to get here');
} catch (e) {
expect(e).toBeTruthy();
}
});
});
describe('notNil', () => {
it('should assert if a non-nil value is passed in', () => {
[0, 1, 'hello', {}, [], [1], true].forEach((value) => {
try {
notNil(value);
} catch (e) {
fail('do not expect to get here');
}
});
});
it('should throw an error if a nil value is passed in', () => {
[undefined, null].forEach((value) => {
try {
notNil(value);
fail('do not expect to get here');
} catch (e) {
expect(e).toBeTruthy();
}
});
});
});
describe('assureNotNil', () => {
it('should assert if a non-nil value is passed in', () => {
[0, 1, 'hello', {}, [], [1], true].forEach((value) => {
try {
expect(assureNotNil(value)).toBe(value);
} catch (e) {
fail('do not expect to get here');
}
});
});
it('should throw an error if a nil value is passed in', () => {
[undefined, null].forEach((value) => {
try {
assureNotNil(value);
fail('do not expect to get here');
} catch (e) {
expect(e).toBeTruthy();
}
});
});
});
describe('truthy', () => {
it('should assert if a truthy value is passed in', () => {
[1, 'hello', {}, [], [1], true].forEach((value) => {
try {
truthy(value);
} catch (e) {
fail('do not expect to get here');
}
});
});
it('should throw an error if a falsy value is passed in', () => {
[0, '', undefined, NaN, false, null].forEach((value) => {
try {
truthy(value);
fail('do not expect to get here');
} catch (e) {
expect(e).toBeTruthy();
}
});
});
});
describe('convertNullProps', () => {
it('should convert all nulls to undefined', () => {
const obj = { a: null, b: 0, c: undefined };
expect(convertNullProps(obj)).toEqual({
a: undefined,
b: 0,
c: undefined,
});
});
it('should convert all nulls to undefined for a frozen object', () => {
const obj = Object.freeze({ a: null, b: 0, c: undefined });
expect(convertNullProps(obj)).toEqual({
a: undefined,
b: 0,
c: undefined,
});
});
});
describe('assertPropsNotNil', () => {
it('should assert if a correct value is passed in', () => {
[{ f: 0 }, { f: 3 }, { f: 'x' }, { f: {} }, {}].forEach((value) => {
expect(() => assertPropsNotNil(value)).not.toThrow();
});
});
it('should throw an error if an incorrect value is passed in', () => {
[{ f: null }, { f: undefined }].forEach((value) => {
expect(() =>
assertPropsNotNil(value, (f, v) => `field ${f} is ${v}`),
).toThrow('field f is');
});
});
});
describe('assertString', () => {
it('should assert if a string is passed in', () => {
expect(() => assertString('')).not.toThrow();
});
it('should throw an error if an incorrect value is passed in', () => {
[0, {}, null, undefined, Symbol(), []].forEach((value) => {
expect(() => assertString(value)).toThrow(
new RegExp(`string.+${typeof value}`, 'i'),
);
});
});
});
describe('assurePropsNotNil', () => {
it('should assert if a correct value is passed in', () => {
[{ f: 0 }, { f: 3 }, { f: 'x' }, { f: {} }, {}].forEach((value) => {
expect(assurePropsNotNil(value)).toBe(value);
});
});
it('should throw an error if an incorrect value is passed in', () => {
[{ f: null }, { f: undefined }].forEach((value) => {
expect(() => assurePropsNotNil(value)).toThrow();
});
});
});
describe('isNotNull', () => {
it('should return not isNull', () => {
expect(isNotNull(null)).toBe(false);
expect(isNotNull(undefined)).toBe(true);
expect(isNotNull(0)).toBe(true);
expect(isNotNull(NaN)).toBe(true);
expect(isNotNull('')).toBe(true);
expect(isNotNull(false)).toBe(true);
});
});
describe('isNotNil', () => {
it('should return not isNil', () => {
expect(isNotNil(null)).toBe(false);
expect(isNotNil(undefined)).toBe(false);
expect(isNotNil(0)).toBe(true);
expect(isNotNil(NaN)).toBe(true);
expect(isNotNil('')).toBe(true);
expect(isNotNil(false)).toBe(true);
});
});
describe('shareReplay1', () => {
it('should return shareReplay with correct options', () => {
const result = shareReplay1();
expect(shareReplay).toHaveBeenCalledWith({
refCount: true,
bufferSize: 1,
});
expect(result).toBe('mockShareReplay');
});
});
describe('resolveMany', () => {
it('should emit an array', async () => {
const obs$ = of(1, 2, 3);
const output = await resolveMany(obs$, 3);
expect(output).toEqual([1, 2, 3]);
});
});
describe('exists', () => {
it('should filter undefined and null', async () => {
const array = [1, undefined, null, 2];
const result = await firstValueFrom(
from(array).pipe(exists(), toArray()),
);
expect(result).toEqual([1, 2]);
});
});
describe('allTruthy', () => {
it('should return array.every(Boolean)', async () => {
const mock = { every: jest.fn(() => 'every') };
const source$ = of(mock as any).pipe(allTruthy());
expect(await firstValueFrom(source$)).toBe('every');
expect(mock.every).toHaveBeenCalledWith(Boolean);
});
});
describe('someTruthy', () => {
it('should return array.some(Boolean)', async () => {
const mock = { some: jest.fn(() => 'some') };
const source$ = of(mock as any).pipe(someTruthy());
expect(await firstValueFrom(source$)).toBe('some');
expect(mock.some).toHaveBeenCalledWith(Boolean);
});
});
describe('wrapInArray', () => {
it('should handle undefined', () => {
expect(wrapInArray(undefined)).toEqual([]);
});
it('should handle an empty array', () => {
expect(wrapInArray([])).toEqual([]);
});
it('should handle an array', () => {
expect(wrapInArray(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('should handle an array or arrays', () => {
expect(wrapInArray([['a', 'b'], ['c']])).toEqual([['a', 'b'], ['c']]);
});
it('should handle a truthy scalar', () => {
expect(wrapInArray('a')).toEqual(['a']);
expect(wrapInArray(1)).toEqual([1]);
expect(wrapInArray(true)).toEqual([true]);
});
it('should handle a falsy scalar', () => {
expect(wrapInArray('')).toEqual(['']);
expect(wrapInArray(0)).toEqual([0]);
expect(wrapInArray(false)).toEqual([false]);
expect(wrapInArray(null)).toEqual([null]);
});
});
describe('hash', () => {
it('should produce different hash', () => {
const hello = hash('hello world');
const test = hash('test 123');
expect(hello).not.toEqual(test);
});
});
describe('parseNumber', () => {
it('should parse valid numbers', () => {
expect(parseNumber('5')).toBe(5);
expect(parseNumber('8.2')).toBe(8.2);
expect(parseNumber('3e7')).toBe(3e7);
});
it('should ignore whitespace', () => {
expect(parseNumber(' 8')).toBe(8);
expect(parseNumber(' \n 8 \n ')).toBe(8);
});
it('should parse other bases', () => {
expect(parseNumber('0x34ab')).toBe(0x34ab);
expect(parseNumber('0b110100')).toBe(0b110100);
expect(parseNumber('0o1246')).toBe(0o1246);
});
it('should parse NaN and infinities', () => {
expect(parseNumber('Infinity')).toBe(Infinity);
expect(parseNumber('-Infinity')).toBe(-Infinity);
expect(parseNumber('NaN')).toBe(NaN);
});
it('should fail on non digits', () => {
expect(() => parseNumber('octopus')).toThrow();
expect(() => parseNumber('a67')).toThrow();
expect(() => parseNumber('67a')).toThrow();
});
});
describe(toBoolean.name, () => {
it('should handle "true"', () => {
expect(toBoolean('true')).toBe(true);
});
it('should handle " true "', () => {
expect(toBoolean(' true ')).toBe(true);
});
it('should handle "TRUE"', () => {
expect(toBoolean('TRUE')).toBe(true);
});
it('should handle "false"', () => {
expect(toBoolean('false')).toBe(false);
});
it('should handle "FALSE"', () => {
expect(toBoolean('FALSE')).toBe(false);
});
it('should handle missing', () => {
expect(toBoolean(undefined)).toBe(false);
});
});
describe(wildcardSearch.name, () => {
it('should make wildcard search', () => {
expect(wildcardSearch('m*', 'Mac')).toBe(true);
expect(wildcardSearch('*c', 'Mac')).toBe(true);
expect(wildcardSearch('*a*', 'Mac')).toBe(true);
expect(wildcardSearch('*m', 'Mac')).toBe(false);
expect(wildcardSearch('*x*', 'Mac')).toBe(false);
});
it('should make a regular search', () => {
expect(wildcardSearch('m', 'Mac')).toBe(true);
expect(wildcardSearch('x', 'Mac')).toBe(false);
});
});
describe(convertToTimestamp.name, () => {
it('should convert date string to timestamp in ms', () => {
const ms = convertToTimestamp('2021-12-31T23:00:00-0200');
expect(ms).toEqual(1640998800000);
expect(new Date(ms).toISOString()).toEqual('2022-01-01T01:00:00.000Z');
});
});
describe('urlJoin', () => {
it('should handle leading slash', () => {
expect(urlJoin('/foo', 'bar')).toBe('/foo/bar');
});
it('should handle no leading slash', () => {
expect(urlJoin('foo', 'bar')).toBe('foo/bar');
});
it('should handle "/" before a leading slash', () => {
expect(urlJoin('/', '/foo', 'bar')).toBe('/foo/bar');
});
// Test case from url-join-ts, (C) Alexey Nikitin, MIT license:
it.each([['http'], ['https']])('urlJoin with %s protocol', (protocol) => {
const baseUrl = `${protocol}://test.com`;
expect(urlJoin(baseUrl)).toBe(`${baseUrl}`);
expect(urlJoin(baseUrl, null)).toBe(`${baseUrl}`);
expect(urlJoin(baseUrl, undefined)).toBe(`${baseUrl}`);
expect(urlJoin(baseUrl, null, '1')).toBe(`${baseUrl}/1`);
expect(urlJoin(baseUrl, undefined, '1')).toBe(`${baseUrl}/1`);
expect(urlJoin(baseUrl, '')).toBe(`${baseUrl}`);
expect(urlJoin(baseUrl, '1')).toBe(`${baseUrl}/1`);
expect(urlJoin(baseUrl, '1/')).toEqual(`${baseUrl}/1`);
expect(urlJoin(baseUrl, '/1')).toBe(`${baseUrl}/1`);
expect(urlJoin(baseUrl, '1', '2')).toBe(`${baseUrl}/1/2`);
expect(urlJoin(baseUrl, '1/2')).toBe(`${baseUrl}/1/2`);
});
// Test case from url-join-ts, (C) Alexey Nikitin, MIT license:
it('urlJoin for path', () => {
expect(urlJoin(undefined, '')).toBe(``);
expect(urlJoin(undefined, '1')).toBe(`1`);
expect(urlJoin(undefined, '/1')).toBe(`/1`);
expect(urlJoin(undefined, '1', '/2/')).toBe(`1/2`);
expect(urlJoin(undefined, '/1', '/2/')).toBe(`/1/2`);
expect(urlJoin(undefined, '1', '/2', '3')).toBe(`1/2/3`);
expect(urlJoin(undefined, '1', '/2', '/3/4/5/')).toBe(`1/2/3/4/5`);
});
// Test case from url-join-ts, (C) Alexey Nikitin, MIT license:
it('urlJoin for localhost', () => {
expect(urlJoin('http://localhost', '1')).toBe(`http://localhost/1`);
expect(urlJoin('http://localhost', '/1')).toBe(`http://localhost/1`);
expect(urlJoin('localhost', '1')).toBe(`localhost/1`);
expect(urlJoin('http://0.0.0.0', '1')).toBe(`http://0.0.0.0/1`);
expect(urlJoin('http://127.0.0.1', '1')).toBe(`http://127.0.0.1/1`);
});
});
describe('distinctUntilNotEqual', () => {
it('should return distinctUntilChanged(isEqual)', () => {
// Arrange
// Act
const result = distinctUntilNotEqual();
// Assert
expect(result).toBe('mockDistinctUntilChanged');
expect(distinctUntilChanged).toHaveBeenCalledTimes(1);
expect(distinctUntilChanged).toHaveBeenCalledWith(isEqual);
});
});
describe(extractFilenameFromFullPath.name, () => {
it('should return file name', () => {
expect(extractFilenameFromFullPath('/folder1/folder2/filename.txt')).toBe(
'filename.txt',
);
});
});
describe(caseInsensitiveIncludes.name, () => {
it('should handle happy case', () => {
expect(['Foo', 'bar'].filter(caseInsensitiveIncludes('foo'))).toEqual([
'Foo',
]);
});
});
describe(caseInsensitiveEquals.name, () => {
it('should handle happy case', () => {
expect(['Foo', 'bar'].some(caseInsensitiveEquals('foo'))).toEqual(true);
});
});
describe(getLastYearRange.name, () => {
it('should handle the happy case', () => {
// Arrange
const today = new Date();
const endDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const startDate = subYears(endDate, 1);
// Act
const result = getLastYearRange();
// Assert
expect(result).toEqual({
startDate,
endDate,
});
});
});
describe(escapeSpecialChars.name, () => {
it('should handle the happy case', () => {
// Act
const result = escapeSpecialChars('So special! +<>#.*+?^${}()|/');
// Assert
expect(result).toEqual(
'So special\\! \\+\\<\\>\\#\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\/',
);
});
});
describe(parseJson.name, () => {
it('should handle the happy case', () => {
// Act
const result = parseJson('{"foo":"bar"}');
// Assert
expect(result).toEqual({ foo: 'bar' });
});
it('should handle the error case', () => {
// Act
const result = parseJson('foo');
// Assert
expect(result).toEqual(undefined);
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment