Commit 0c1b50c5 authored by Guillaume Perréal's avatar Guillaume Perréal
Browse files

First version.

No related merge requests found
Showing with 906 additions and 0 deletions
+906 -0
.gitignore 0 → 100644
/node_modules
/dist
/coverage
/.idea
include:
- project: pole-is/tools/ci-config
ref: "1.5.0"
file: /ngx-library-ci.yml
angular.json 0 → 100644
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": ".",
"projects": {
"ngx-rxtools": {
"root": "",
"sourceRoot": "src",
"projectType": "library",
"cli": {
"packageManager": "npm"
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"aot": true,
"main": "src/public-api.ts",
"tsConfig": "./tsconfig.lib.json"
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "ngx-rxtools:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"karmaConfig": "./karma.conf.js",
"tsConfig": "./tsconfig.spec.json"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["./tsconfig.lib.json", "./tsconfig.spec.json"],
"exclude": ["**/node_modules/**"]
}
}
}
}
},
"defaultProject": "default"
}
karma.conf.js 0 → 100644
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function(config) {
config.set({
basePath: "",
frameworks: ["jasmine", "@angular-devkit/build-angular"],
plugins: [
require("karma-jasmine"),
require("karma-firefox-launcher"),
require("karma-jasmine-html-reporter"),
require("karma-coverage-istanbul-reporter"),
require("@angular-devkit/build-angular/plugins/karma")
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: __dirname + "/coverage/ngx-rxtools",
reports: ["html", "lcovonly", "text-summary"],
fixWebpackSourcePaths: true
},
reporters: ["progress", "kjhtml"],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ["Firefox"],
singleRun: false,
restartOnFileChange: true
});
};
{
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
"dest": "dist/ngx-rxtools",
"lib": {
"entryFile": "src/public-api.ts"
}
}
This diff is collapsed.
package.json 0 → 100644
{
"name": "@devatscience/ngx-rxtools",
"version": "0.0.1",
"keywords": [
"angular",
"ngx"
],
"license": "LGPL-3.0-or-later",
"author": "Guillaume Perréal",
"contributors": [
"Harold Boissenin"
],
"repository": {
"type": "git",
"url": "https://gitlab.irstea.fr/pole-is/packages/ngx-rxtools.git"
},
"scripts": {
"test": "ng test --code-coverage --no-watch --no-progress",
"lint": "ng lint",
"package": "ng-packagr"
},
"prettier": "./node_modules/irstea-typescript-config/prettier.config.js",
"peerDependencies": {
"@angular/core": "^8.2.0",
"@angular/router": "^8.2.0",
"@devatscience/ngx-errors": "^0.1.1",
"rxjs": "^6.5.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.803.5",
"@angular/cli": "^8.3.5",
"@angular/common": "^8.2.7",
"@angular/compiler": "^8.2.7",
"@angular/compiler-cli": "^8.2.7",
"@angular/core": "^8.2.7",
"@angular/router": "^8.2.7",
"@types/jasmine": "^3.4.0",
"codelyzer": "^4.5.0",
"irstea-typescript-config": "^1.0.7",
"jasmine": "^3.4.0",
"karma": "^4.3.0",
"karma-coverage-istanbul-reporter": "^2.1.0",
"karma-firefox-launcher": "^1.2.0",
"karma-jasmine": "^2.0.1",
"karma-jasmine-html-reporter": "^1.4.2",
"lodash": "^4.17.15",
"ng-packagr": "^5.5.1",
"prettier": "^1.18.2",
"prettier-tslint": "^0.4.2",
"primeng": "^8.0.3",
"rxjs": "^6.5.3",
"rxjs-marbles": "^5.0.3",
"rxjs-tslint-rules": "^4.25.0",
"tslint": "^5.20.0",
"tslint-config-prettier": "^1.18.0",
"tslint-defocus": "^2.0.6",
"tslint-plugin-prettier": "^2.0.1",
"tsutils": "^3.17.1",
"typescript": "^3.6.3",
"zone.js": "^0.9.1"
}
}
export { RouteParam, QueryParam } from './route-param.decorator';
export { SubjectAccessors, SubjectSetter } from './subject-accessors.decorator';
export { SubscribeOnInit } from './subscribe-on-init.decorator';
import { OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router';
import { Observable, ReplaySubject } from 'rxjs';
import { marbles } from 'rxjs-marbles';
import { QueryParam, RouteParam } from './route-param.decorator';
function testOne(decorator: any, mapProperty: string) {
function mockActivatedRoute(
map$: Observable<ParamMap>,
snapshot: any
): ActivatedRoute {
// tslint:disable-next-line:rxjs-finnish
return {
[mapProperty]: map$,
snapshot: { [mapProperty]: snapshot },
} as any;
}
class FakeComponent implements OnInit, OnDestroy {
@decorator()
private readonly param$ = new ReplaySubject<string>(1);
// tslint:disable-next-line:no-unused-variable
public constructor(private readonly route: ActivatedRoute) {}
public getParam$(): Observable<string> {
return this.param$;
}
public ngOnInit(): void {}
public ngOnDestroy(): void {}
}
it(
'should subscribe to paramMap on init',
marbles(m => {
const map$ = m.cold('');
const comp = new FakeComponent(
mockActivatedRoute(map$, convertToParamMap({}))
);
comp.ngOnInit();
m.expect(map$).toHaveSubscriptions('^');
m.flush();
expect(comp).toBeTruthy();
})
);
it(
'should unsubscribe from paramMap on destroy',
marbles(m => {
const map$ = m.cold('');
const comp = new FakeComponent(
mockActivatedRoute(map$, convertToParamMap({}))
);
comp.ngOnInit();
comp.ngOnDestroy();
m.expect(map$).toHaveSubscriptions('(^!)');
m.flush();
expect(comp).toBeTruthy();
})
);
it(
'should propagate values to the observable property',
marbles(m => {
const VALUES = {
a: convertToParamMap({ param: 'foo' }),
b: convertToParamMap({ param: 'bar' }),
c: convertToParamMap({ param: 'quz' }),
};
const map$ = m.hot('--a--b-^-c--|', VALUES);
const comp = new FakeComponent(mockActivatedRoute(map$, VALUES.b));
m.expect(comp.getParam$()).toBeObservable('b-c--|', {
b: 'bar',
c: 'quz',
});
comp.ngOnInit();
m.flush();
expect(comp).toBeTruthy();
})
);
}
describe('RouteParam', () => testOne(RouteParam, 'paramMap'));
describe('QueryParam', () => testOne(QueryParam, 'queryParamMap'));
import { ActivatedRoute } from '@angular/router';
import { Observer } from 'rxjs';
import { map } from 'rxjs/operators';
import { hookBefore, hookFinally } from '../hooks';
function ParamFromMap<K extends 'paramMap' | 'queryParamMap'>(
param: string = null,
routeProperty: string = 'route',
mapName: K
) {
return (prototype: any, property: string | symbol): void => {
let resolvedParam = param || property.toString();
if (resolvedParam.endsWith('$')) {
resolvedParam = resolvedParam.substr(0, resolvedParam.length - 1);
}
const subscription = Symbol.for(`RouteParamSub_${resolvedParam}`);
hookBefore(prototype, 'ngOnInit', function() {
const route: ActivatedRoute = (this as any)[routeProperty];
const observer: Observer<string> = (this as any)[property];
if (!route || !(mapName in route)) {
throw new Error(
`this.${routeProperty.toString()} must contains an ActivatedRoute`
);
}
if (
observer === null ||
!('next' in observer) ||
typeof observer.next !== 'function'
) {
throw new Error(`this.${property.toString()} must implement Observer`);
}
const snapshot = route.snapshot[mapName];
if (snapshot) {
observer.next(snapshot.get(resolvedParam));
}
(this as any)[subscription] = route[mapName]
.pipe(map(p => p.get(resolvedParam)))
.subscribe(observer);
});
hookFinally(prototype, 'ngOnDestroy', function() {
this[subscription].unsubscribe();
});
};
}
/**
* Décorateur pour récuperer un paramètre de la route.
*
* La propriété décorée doit implémenter Observer<string>.
*
* @param {string|null} param Nom du paramètre. Par défaut égal au nom de la propriété minus le '$' final.
* @param {string} routeProperty nom de la propriété du Component contenant l'ActivatedRoute. Par défaut 'route'.
*/
export function RouteParam(
param: string = null,
routeProperty: string = 'route'
) {
return ParamFromMap(param, routeProperty, 'paramMap');
}
/**
* Décorateur pour récuperer un paramètre de la requête.
*
* La propriété décorée doit implémenter Observer<string>.
*
* @param {string|null} param Nom du paramètre. Par défaut égal au nom de la propriété minus le '$' final.
* @param {string} routeProperty nom de la propriété du Component contenant l'ActivatedRoute. Par défaut 'route'.
*/
export function QueryParam(
param: string = null,
routeProperty: string = 'route'
) {
return ParamFromMap(param, routeProperty, 'queryParamMap');
}
/**
* Génère un getter et un setter publics pour un attribut de type BehaviorSubject.
*
* Le setter utilise .next() et le getter .getValue().
*
* Le nom de la propriété doit se terminer par '$'.
*/
export function SubjectAccessors() {
return (prototype: object, observablePropertyName: string | symbol): void => {
Object.defineProperty(
prototype,
getPlainPropertyName(observablePropertyName),
{
get(): any {
return this[observablePropertyName].getValue();
},
set(value: any): void {
this[observablePropertyName].next(value);
},
}
);
};
}
/**
* Génère un setter public pour un attribut de type Subject. Il utilise utilise .next().
*
* Le nom de la propriété doit se terminer par '$'.
*/
export function SubjectSetter() {
return (prototype: object, observablePropertyName: string | symbol): void => {
Object.defineProperty(
prototype,
getPlainPropertyName(observablePropertyName),
{
set(value: any): void {
this[observablePropertyName].next(value);
},
}
);
};
}
/**
* Extrait le nom de propriété "normale" d'un nom de propriété de "setter".
*/
function getPlainPropertyName(observablePropertyName: string | symbol): string {
const name = observablePropertyName.toString();
if (!name.endsWith('$')) {
throw new Error(`Property name must end with '$', '${name}' does noit`);
}
return name.substr(0, name.length - 1);
}
import { OnDestroy, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { marbles } from 'rxjs-marbles/jasmine';
import { SubscribeOnInit } from './subscribe-on-init.decorator';
describe('@SubscribeOnInit', () => {
const VALUES = { a: 'a' };
class PropertyTestClass<T extends Observable<any>>
implements OnInit, OnDestroy {
@SubscribeOnInit()
public readonly obs$: T;
public constructor(
obs$: T,
private readonly initSpy = (): void => undefined,
private readonly destroySpy = (): void => undefined
) {
this.obs$ = obs$;
}
public ngOnInit(): void {
this.initSpy();
}
public ngOnDestroy(): void {
this.destroySpy();
}
}
it(
'should subscribe on init and unsubscribe on destroy',
marbles(m => {
const obj = new PropertyTestClass(m.cold('a'));
obj.ngOnInit();
obj.ngOnDestroy();
m.expect(obj.obs$).toHaveSubscriptions('(^!)');
})
);
it(
'should be tied only to object instances',
marbles(m => {
const obj1 = new PropertyTestClass(m.cold('a'));
const obj2 = new PropertyTestClass(m.cold('a'));
obj1.ngOnInit();
obj2.ngOnInit();
obj1.ngOnDestroy();
obj2.ngOnDestroy();
m.expect(obj1.obs$).toHaveSubscriptions('(^!)');
m.expect(obj2.obs$).toHaveSubscriptions('(^!)');
})
);
it(
'should call original ngOnInit',
marbles(m => {
const obs$ = m.cold('a');
const initSpy = jasmine.createSpy('ngOnInit');
const obj = new PropertyTestClass(obs$, initSpy);
obj.ngOnInit();
expect(initSpy).toHaveBeenCalled();
})
);
it(
'should call original ngOnDestroy',
marbles(m => {
const obs$ = m.cold('a');
const destroySpy = jasmine.createSpy('ngOnDestroy');
const obj = new PropertyTestClass(obs$, undefined, destroySpy);
obj.ngOnInit();
obj.ngOnDestroy();
expect(destroySpy).toHaveBeenCalled();
})
);
it(
'can be used on more than one property',
marbles(m => {
class TwoPropertyTestClass implements OnInit, OnDestroy {
@SubscribeOnInit()
public readonly obs1$ = m.cold('a');
@SubscribeOnInit()
public readonly obs2$ = m.cold('b');
public ngOnDestroy(): void {}
public ngOnInit(): void {}
}
const obj = new TwoPropertyTestClass();
obj.ngOnInit();
obj.ngOnDestroy();
m.expect(obj.obs1$).toHaveSubscriptions('(^!)');
m.expect(obj.obs2$).toHaveSubscriptions('(^!)');
})
);
it(
'should be inherited',
marbles(m => {
class ParentClass implements OnInit, OnDestroy {
@SubscribeOnInit()
public readonly obs1$ = m.cold('a');
public ngOnDestroy(): void {}
public ngOnInit(): void {}
}
class ChildrenClass extends ParentClass {
@SubscribeOnInit()
public readonly obs2$ = m.cold('a');
}
const obj = new ChildrenClass();
obj.ngOnInit();
obj.ngOnDestroy();
m.expect(obj.obs1$).toHaveSubscriptions('(^!)');
m.expect(obj.obs2$).toHaveSubscriptions('(^!)');
})
);
});
import { OnDestroy, OnInit } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { hookBefore, hookFinally } from '../hooks';
const SUBSCRIPTION_KEY = Symbol.for('SubscribeOnInit');
interface AutoSubscribable extends OnInit, OnDestroy {}
type ObjectKey = string | number | symbol;
type AutoSubscriber<Name extends ObjectKey> = AutoSubscribable & {
[SUBSCRIPTION_KEY]: Subscription;
} & {
[K in Name]?: Observable<any>;
};
/**
* Ce décortaeur est à utiliser sur des propriétés de type Observable dans un composant implémentant OnInit
* et OnDestroy. Il s'assure qu'un abonnnement à l'observavble est fait dans ngOnInit et que le désabonnement
* est géré dans ngOnDestroy().
*/
export function SubscribeOnInit() {
return <Target extends AutoSubscribable, Name extends ObjectKey>(
target: Target,
name: Name
): any => {
const prototype = (target as any) as AutoSubscriber<Name>;
hookBefore(prototype, 'ngOnInit', function() {
if (!(this as any)[SUBSCRIPTION_KEY]) {
(this as any)[SUBSCRIPTION_KEY] = new Subscription(
() => delete (this as any)[SUBSCRIPTION_KEY]
);
}
if ((this as any)[name]) {
(this as any)[SUBSCRIPTION_KEY].add((this as any)[name].subscribe());
}
});
hookFinally(prototype, 'ngOnDestroy', function() {
if (this[SUBSCRIPTION_KEY]) {
this[SUBSCRIPTION_KEY].unsubscribe();
}
});
};
}
import { hookAfter, hookBefore, hookFinally } from './hooks';
type Callback = (x: string) => void;
describe('hooksBefore', () => {
it('should execute the hook before the original method', () => {
class TestClass {
public foo(f: Callback): void {
f('bar');
}
}
hookBefore(TestClass.prototype, 'foo', (f: Callback) => f('before'));
const instance = new TestClass();
const callback = jasmine.createSpy('callback');
instance.foo(callback);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback.calls.first().args).toEqual(['before']);
expect(callback.calls.mostRecent().args).toEqual(['bar']);
});
});
describe('hookAfter', () => {
it('should execute the hook after the original method', () => {
class TestClass {
public foo(f: Callback): void {
f('bar');
}
}
hookAfter(TestClass.prototype, 'foo', (f: Callback) => f('after'));
const instance = new TestClass();
const callback = jasmine.createSpy('callback');
instance.foo(callback);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback.calls.first().args).toEqual(['bar']);
expect(callback.calls.mostRecent().args).toEqual(['after']);
});
it('should not execute the hook when the original method throws', () => {
const err = new Error('foo');
class TestClass {
public foo(f: Callback): void {
throw err;
}
}
hookAfter(TestClass.prototype, 'foo', (f: Callback) => f('after'));
const instance = new TestClass();
const callback = jasmine.createSpy('callback');
expect(() => instance.foo(callback)).toThrow(err);
expect(callback).not.toHaveBeenCalled();
});
});
describe('hookFinally', () => {
it('should execute the hook after the original method', () => {
class TestClass {
public foo(f: Callback): void {
f('bar');
}
}
hookFinally(TestClass.prototype, 'foo', (f: Callback) => f('finally'));
const instance = new TestClass();
const callback = jasmine.createSpy('callback');
instance.foo(callback);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback.calls.first().args).toEqual(['bar']);
expect(callback.calls.mostRecent().args).toEqual(['finally']);
});
it('should execute the hook even if the original method throws', () => {
const err = new Error('foo');
class TestClass {
public foo(f: Callback): void {
throw err;
}
}
hookFinally(TestClass.prototype, 'foo', (f: Callback) => f('finally'));
const instance = new TestClass();
const callback = jasmine.createSpy('callback');
expect(() => instance.foo(callback)).toThrow(err);
expect(callback).toHaveBeenCalledWith('finally');
});
});
src/hooks.ts 0 → 100644
/**
* Fonctions pour "patcher" les méthodes d'une classe.
*/
type Hookable<K extends string | symbol, M extends (...args: any[]) => any> = {
[X in K]: M
};
function isHookable<
K extends string | symbol,
M extends (...args: any[]) => any
>(what: unknown, key: K): what is Hookable<K, M> {
return (
typeof what === 'object' &&
what !== null &&
key in what &&
typeof (what as any)[key] === 'function'
);
}
/**
* Installe un hook.
*/
function installHook<
T extends Hookable<K, M>,
M extends (this: T, ...args: any[]) => void,
K extends string | symbol
>(target: T, name: K, makeHook: (old: M) => M): void {
if (!isHookable<K, M>(target, name)) {
throw new Error(`cannot hook ${name} on ${target}`);
}
if (name === 'constructor') {
throw new Error('cannot hook constructors');
}
target[name] = <T[K]>makeHook(target[name]);
}
/**
* Modifie une méthode d'un objet pour éxecuter une fonction avant son éxecution normale.
*
* @param {object} target Le prototype à modifier.
* @param {string|symbol} name Le nom de la méthode à surcharger.
* @param {function} hook La fonction à ajouter.
*/
export function hookBefore<
T extends Hookable<K, M>,
M extends (this: T, ...args: any[]) => void,
K extends string | symbol
>(target: Hookable<K, M>, name: K, hook: M): void {
installHook(
target,
name,
oldMethod =>
function(...args: any[]): void {
hook.apply(this, args);
oldMethod.apply(this, args);
}
);
}
/**
* Modifie une méthode d'un objet pour éxecuter une fonction après son éxecution normale.
*
* @param {object} target Le prototype à modifier.
* @param {string|symbol} name Le nom de la méthode à surcharger.
* @param {function} hook La fonction à ajouter.
*/
export function hookAfter<
T extends Hookable<K, M>,
M extends (this: T, ...args: any[]) => void,
K extends string | symbol
>(target: T, name: K, hook: M): void {
installHook(
target,
name,
oldMethod =>
function(...args: any[]): void {
oldMethod.apply(this, args);
hook.apply(this, args);
}
);
}
/**
* Modifie une méthode d'un objet pour éxecuter inconditionnellement une fonction après son éxecution normale.
*
* @param {object} target Le prototype à modifier.
* @param {string|symbol} name Le nom de la méthode à surcharger.
* @param {function} hook La fonction à ajouter.
*/
export function hookFinally<
T extends Hookable<K, M>,
M extends (this: T, ...args: any[]) => void,
K extends string | symbol
>(target: T, name: K, hook: M): void {
installHook(
target,
name,
oldMethod =>
function(...args: any[]): void {
try {
oldMethod.apply(this, args);
} finally {
hook.apply(this, args);
}
}
);
}
import './decorators';
import './rxjs';
export { lazy } from './lazy.observable';
export { safeCombineLatest } from './safe-combine-latest.observable';
export { safeForkJoin } from './safe-fork-join.observable';
export { select } from './select.operator';
export { untilDestroyed } from './until-destroyed.operator';
import { defer, Observable, ObservedValueOf } from 'rxjs';
/**
* Un observable qui attend le premier abonnement pour fabriquer l'observable qui va vraiment être
* utilisé.
*/
export function lazy<O extends Observable<any>>(
create: () => O
): Observable<ObservedValueOf<O>> {
let obs: O | null = null;
return defer(() => (obs !== null ? obs : (obs = create())));
}
import { Observable } from 'rxjs';
import { marbles } from 'rxjs-marbles/jasmine';
import { safeCombineLatest } from './safe-combine-latest.observable';
describe('safe-combine-latest', () => {
const INPUTS: { [key: string]: number } = {
a: 0,
b: 1,
c: 2,
d: 3,
e: 4,
};
const OUTPUTS: { [key: string]: number[] } = {
N: [],
A: [0],
B: [1],
C: [2],
U: [1, 3, 4],
V: [2, 3, 4],
};
it(
'should emit an empty array on empty inputs',
marbles(m => {
const inputs$: Array<Observable<number>> = [
// EMPTY
];
const output = 'N';
m.expect(safeCombineLatest(inputs$)).toBeObservable(output, OUTPUTS);
})
);
it(
'should emit an 1-sized array on 1-sized input',
marbles(m => {
const inputs$: Array<Observable<number>> = [
//
m.cold('abc', INPUTS),
];
const output = 'ABC';
m.expect(safeCombineLatest(inputs$)).toBeObservable(output, OUTPUTS);
})
);
it(
'should emit combinations of each inputs',
marbles(m => {
const inputs$: Array<Observable<number>> = [
//
m.cold('a-b-c', INPUTS),
m.cold('d', INPUTS),
m.cold('--e', INPUTS),
];
const output = '--U-V';
m.expect(safeCombineLatest(inputs$)).toBeObservable(output, OUTPUTS);
})
);
});
import { combineLatest, Observable, ObservableInput } from 'rxjs';
/**
* Observable qui envoie un tableau vide et ne complète pas.
*/
// tslint:disable-next-line:rxjs-finnish
const EMPTY_ARRAY_OBSERVABLE: Observable<any[]> = new Observable(subscriber =>
subscriber.next([])
);
/**
* Variante de combineLatest qui transmets un tableau vide si le tableau des entrées est vide.
*/
export function safeCombineLatest<T>(
inputs$: Array<ObservableInput<T>>
): Observable<T[]> {
if (inputs$.length === 0) {
return EMPTY_ARRAY_OBSERVABLE;
}
return combineLatest(inputs$);
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment