FRONTEND-509: Configure CommitLint, Add Partial-Fetcher & Component Changes Util (#2)

This commit is contained in:
Rinat Arsaev 2021-04-13 15:31:05 +03:00 committed by GitHub
parent 44f1047cb0
commit 171118c831
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 4153 additions and 361 deletions

3
.commitlintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["@commitlint/config-conventional"]
}

26
.eslintrc.js Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: ['plugin:prettier/recommended', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
rules: {
'sort-imports': [
'error',
{
ignoreCase: false,
ignoreDeclarationSort: false,
ignoreMemberSort: false,
memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
allowSeparatedGroups: false,
},
],
},
};

20
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Publish
on:
push:
branches:
- master
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 15
registry-url: https://npm.pkg.github.com/
- run: npm ci
- run: npm run release
env:
NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}

View File

@ -10,5 +10,6 @@ jobs:
- uses: actions/setup-node@v1
with:
node-version: 15
- run: npm run bootstrap
- run: npm ci
- run: npm run test
- run: npm run build

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx commitlint --edit ${HUSKY_GIT_PARAMS}

View File

@ -1,3 +1,27 @@
# Frontend Libs Monorepo
- [NPM Workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces)
- [Lerna](https://github.com/lerna/lerna)
- CommitLint
- [Prettier](https://prettier.io/)
- ESLint
## Installation
```sh
npm i '@rbkmoney/<PACKAGE_NAME>'
```
## Development / Usage
```sh
npm link '<PATH>/fe-core/packages/<PACKAGE_DIR>'
```
## Publish
1. Bump package version
2. ```sh
npm publish
```

View File

@ -8,6 +8,7 @@
}
},
"publishConfig": {
"access": "public",
"registry": "https://npm.pkg.github.com/"
}
}

3991
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,12 @@
{
"name": "fe-root",
"version": "0.0.0",
"private": true,
"scripts": {
"bootstrap": "npx lerna bootstrap",
"build": "lerna run build",
"test": "lerna run test",
"clean": "shx rm -rf **/node_modules",
"release": "lerna run build & lerna publish --no-commit-hooks"
"release": "lerna run build & lerna publish --no-commit-hooks --yes",
"prepare": "husky install"
},
"workspaces": [
"./packages/*"
@ -17,7 +16,21 @@
"url": "https://github.com/rbkmoney/fe-core"
},
"devDependencies": {
"@commitlint/cli": "^12.1.1",
"@commitlint/config-conventional": "^12.1.1",
"@types/jasmine": "^3.6.9",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"eslint": "^7.23.0",
"eslint-config-prettier": "^8.1.0",
"husky": "^6.0.0",
"jasmine": "^3.7.0",
"jasmine-console-reporter": "^3.1.0",
"jasmine-marbles": "^0.8.1",
"lerna": "^4.0.0",
"shx": "^0.3.3"
"prettier": "^2.2.1",
"shx": "^0.3.3",
"ts-node": "^9.1.1",
"typescript": "^4.2.4"
}
}

View File

@ -0,0 +1 @@
@rbkmoney:registry=https://npm.pkg.github.com/

View File

@ -0,0 +1,3 @@
package.json
package-lock.json
node_modules

View File

@ -0,0 +1,5 @@
# Partial Fetcher
```sh
npm i @rbkmoney/partial-fetcher
```

View File

@ -0,0 +1,24 @@
{
"name": "@rbkmoney/partial-fetcher",
"version": "0.0.0",
"description": "Partial Fetcher",
"author": "rbkmoney",
"main": "lib/index.js",
"types": "lib/index.d.js",
"scripts": {
"build": "npm run clean & tsc -p tsconfig.json",
"clean": "shx rm -rf lib",
"test": "ts-node ../../node_modules/jasmine/bin/jasmine src/**/*.spec.ts --reporter=jasmine-console-reporter"
},
"repository": {
"type": "git",
"url": "https://github.com/rbkmoney/fe-core"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com/@rbkmoney"
},
"optionalDependencies": {
"@ngneat/until-destroy": "^8.0.4",
"rxjs": "^6.6.7"
}
}

View File

@ -0,0 +1 @@
export * from './partial-fetcher';

View File

@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
export const DEBOUNCE_FETCHER_ACTION_TIME = new InjectionToken<number>('debounceFetcherActionTime');
export const DEFAULT_FETCHER_DEBOUNCE_ACTION_TIME = 300;

View File

@ -0,0 +1,4 @@
export interface FetchAction<P extends any = any> {
type: 'search' | 'fetchMore';
value?: P;
}

View File

@ -0,0 +1,5 @@
import { Observable } from 'rxjs';
import { FetchResult } from './fetch-result';
export type FetchFn<P, R> = (params: P, continuationToken?: string) => Observable<FetchResult<R>>;

View File

@ -0,0 +1,5 @@
export interface FetchResult<T> {
result?: T[];
continuationToken?: string;
error?: any;
}

View File

@ -0,0 +1,4 @@
export * from './partial-fetcher';
export * from './fetch-result';
export * from './fetch-action';
export * from './consts';

View File

@ -0,0 +1,2 @@
export * from './scan-action';
export * from './scan-search-result';

View File

@ -0,0 +1,5 @@
import { Observable } from 'rxjs';
import { scan } from 'rxjs/operators';
export const scanAction = <T>(s: Observable<T>) =>
s.pipe(scan<T, T>((lastAction, currentAction) => ({ ...lastAction, ...currentAction }), null));

View File

@ -0,0 +1,44 @@
import { Observable, of } from 'rxjs';
import { catchError, first, map, mergeScan } from 'rxjs/operators';
import { FetchAction } from '../fetch-action';
import { FetchFn } from '../fetch-fn';
import { FetchResult } from '../fetch-result';
export const handleFetchResultError = <R>(result: R[] = [], continuationToken?: string) => (
s: Observable<FetchResult<R>>
): Observable<FetchResult<R>> =>
s.pipe(
catchError((error) =>
of<FetchResult<R>>({
result,
continuationToken,
error,
})
)
);
export const scanFetchResult = <P, R>(fn: FetchFn<P, R>) => (
s: Observable<FetchAction<P>>
): Observable<FetchResult<R>> =>
s.pipe(
mergeScan<FetchAction<P>, FetchResult<R>>(
({ result, continuationToken }, { type, value }) => {
switch (type) {
case 'search':
return fn(value).pipe(first(), handleFetchResultError());
case 'fetchMore':
return fn(value, continuationToken).pipe(
first(),
map((r) => ({
result: result.concat(r.result),
continuationToken: r.continuationToken,
})),
handleFetchResultError(result, continuationToken)
);
}
},
{ result: [] },
1
)
);

View File

@ -0,0 +1,118 @@
import { Observable } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { FetchResult } from './fetch-result';
import { PartialFetcher } from './partial-fetcher';
function assertDeepEqual(actual: any, expected: any) {
expect(actual).toEqual(expected);
}
function createScheduler() {
return new TestScheduler(assertDeepEqual);
}
describe('PartialFetch', () => {
class PartialFetched extends PartialFetcher<any, any> {
constructor(private fetchFn: (params?: any, continuationToken?: string) => Observable<any>, debounce?: number) {
super(debounce);
}
protected fetch(params: any, continuationToken: string) {
return this.fetchFn(params, continuationToken);
}
}
it('should init', () => {
createScheduler().run(({ cold, expectObservable }) => {
const result: FetchResult<any> = { result: ['test'] };
const partialFetched = new PartialFetched(() => cold('--x|', { x: result }), 100);
expectObservable(partialFetched.searchResult$).toBe('');
expectObservable(partialFetched.errors$).toBe('');
expectObservable(partialFetched.doAction$).toBe('0', [true]);
expectObservable(partialFetched.hasMore$).toBe('0', [null]);
});
});
it('should search with debounce', () => {
createScheduler().run(({ cold, expectObservable }) => {
const result: FetchResult<any> = { result: ['test'] };
const partialFetched = new PartialFetched(() => cold('--x|', { x: result }), 100);
partialFetched.search(null);
expectObservable(partialFetched.searchResult$).toBe('100ms --0', [['test']]);
expectObservable(partialFetched.errors$).toBe('');
expectObservable(partialFetched.doAction$).toBe('0 100ms -1', [true, false]);
expectObservable(partialFetched.hasMore$).toBe('0 100ms -1', [null, false]);
});
});
it('should load more with last token', () => {
createScheduler().run(({ cold, expectObservable }) => {
const partialFetched = new PartialFetched(
(_params, token) =>
cold('--x|', {
x: { result: [token], continuationToken: token ? token + '0' : 'token' },
} as FetchResult<any>),
0
);
partialFetched.search('token');
partialFetched.fetchMore();
partialFetched.fetchMore();
partialFetched.fetchMore();
expectObservable(partialFetched.searchResult$).toBe('--0-1-2-3', [
[undefined],
[undefined, 'token'],
[undefined, 'token', 'token0'],
[undefined, 'token', 'token0', 'token00'],
]);
expectObservable(partialFetched.errors$).toBe('');
expectObservable(partialFetched.doAction$).toBe('0-1', [true, false]);
expectObservable(partialFetched.hasMore$).toBe('0-1', [null, true]);
});
});
it('should reload with old params', () => {
createScheduler().run(({ cold, expectObservable }) => {
const partialFetched = new PartialFetched(
(params) => cold('--x|', { x: { result: [params], continuationToken: 'token' } as FetchResult<any> }),
0
);
partialFetched.search('params');
partialFetched.fetchMore();
partialFetched.refresh();
expectObservable(partialFetched.searchResult$).toBe('--0-1-2', [
['params'],
['params', 'params'],
['params'],
]);
expectObservable(partialFetched.errors$).toBe('');
expectObservable(partialFetched.doAction$).toBe('0-1', [true, false]);
expectObservable(partialFetched.hasMore$).toBe('0-1', [null, true]);
});
});
describe('throw error', () => {
it('should return error with delay', () => {
createScheduler().run(({ cold, expectObservable }) => {
const partialFetched = new PartialFetched(() => cold('--#|'), 100);
partialFetched.search(null);
expectObservable(partialFetched.searchResult$).toBe('100ms --0', [[]]);
expectObservable(partialFetched.errors$).toBe('100ms --0', ['error']);
expectObservable(partialFetched.doAction$).toBe('0 100ms -1', [true, false]);
expectObservable(partialFetched.hasMore$).toBe('0 100ms -1', [null, false]);
});
});
it('should fetch after error', () => {
createScheduler().run(({ cold, expectObservable }) => {
const partialFetched = new PartialFetched(() => cold('--#|'), 0);
partialFetched.search(null);
partialFetched.fetchMore();
expectObservable(partialFetched.searchResult$).toBe('--0-1', [[], []]);
expectObservable(partialFetched.errors$).toBe('--0-1', ['error', 'error']);
expectObservable(partialFetched.doAction$).toBe('0-1', [true, false]);
expectObservable(partialFetched.hasMore$).toBe('0-1', [null, false]);
});
});
});
});

View File

@ -0,0 +1,106 @@
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { EMPTY, merge, Observable, of, Subject } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter,
map,
pluck,
share,
shareReplay,
startWith,
switchMap,
tap,
} from 'rxjs/operators';
import { FetchAction } from './fetch-action';
import { FetchFn } from './fetch-fn';
import { FetchResult } from './fetch-result';
import { scanAction, scanFetchResult } from './operators';
import { SHARE_REPLAY_CONF } from './utils/share-replay-conf';
import { progress } from './utils/progress';
// TODO: make free of subscription & UntilDestroy
// TODO: share public props together
// TODO: make fetcher injectable
@UntilDestroy()
export abstract class PartialFetcher<R, P> {
readonly fetchResultChanges$: Observable<{ result: R[]; hasMore: boolean; continuationToken: string }>;
readonly searchResult$: Observable<R[]>;
readonly hasMore$: Observable<boolean>;
readonly doAction$: Observable<boolean>;
readonly doSearchAction$: Observable<boolean>;
readonly errors$: Observable<any>;
private action$ = new Subject<FetchAction<P>>();
// TODO: make a dependency for DI
constructor(debounceActionTime: number = 300) {
const actionWithParams$ = this.getActionWithParams(debounceActionTime);
const fetchResult$ = this.getFetchResult(actionWithParams$);
this.fetchResultChanges$ = fetchResult$.pipe(
map(({ result, continuationToken }) => ({
result: result ?? [],
continuationToken,
hasMore: !!continuationToken,
})),
share()
);
this.searchResult$ = this.fetchResultChanges$.pipe(pluck('result'), shareReplay(SHARE_REPLAY_CONF));
this.hasMore$ = this.fetchResultChanges$.pipe(
pluck('hasMore'),
startWith(null as boolean),
distinctUntilChanged(),
shareReplay(SHARE_REPLAY_CONF)
);
this.doAction$ = progress(actionWithParams$, fetchResult$, true).pipe(shareReplay(SHARE_REPLAY_CONF));
this.doSearchAction$ = progress(
actionWithParams$.pipe(filter(({ type }) => type === 'search')),
fetchResult$,
true
).pipe(shareReplay(SHARE_REPLAY_CONF));
this.errors$ = fetchResult$.pipe(
switchMap(({ error }) => (error ? of(error) : EMPTY)),
tap((error) => console.error('Partial fetcher error: ', error)),
share()
);
merge(
this.searchResult$,
this.hasMore$,
this.doAction$,
this.doSearchAction$,
this.errors$,
this.fetchResultChanges$
)
.pipe(untilDestroyed(this))
.subscribe();
}
search(value: P) {
this.action$.next({ type: 'search', value });
}
refresh() {
this.action$.next({ type: 'search' });
}
fetchMore() {
this.action$.next({ type: 'fetchMore' });
}
protected abstract fetch(...args: Parameters<FetchFn<P, R>>): ReturnType<FetchFn<P, R>>;
private getActionWithParams(debounceActionTime: number): Observable<FetchAction<P>> {
return this.action$.pipe(scanAction, debounceActionTime ? debounceTime(debounceActionTime) : tap(), share());
}
private getFetchResult(actionWithParams$: Observable<FetchAction<P>>): Observable<FetchResult<R>> {
const fetchFn = this.fetch.bind(this) as FetchFn<P, R>;
return actionWithParams$.pipe(scanFetchResult(fetchFn), shareReplay(SHARE_REPLAY_CONF));
}
}

View File

@ -0,0 +1,9 @@
import { merge, Observable, of } from 'rxjs';
import { catchError, distinctUntilChanged, map, startWith } from 'rxjs/operators';
export const progress = (start$: Observable<any>, end$: Observable<any>, startValue = false): Observable<boolean> =>
merge(start$.pipe(map(() => true)), end$.pipe(map(() => false))).pipe(
catchError(() => of(false)),
startWith(startValue),
distinctUntilChanged()
);

View File

@ -0,0 +1,4 @@
import { ShareReplayConfig } from 'rxjs/internal/operators/shareReplay';
// Default share replay config
export const SHARE_REPLAY_CONF: ShareReplayConfig = { bufferSize: 1, refCount: true };

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src/index.ts"]
}

1
packages/utils/.npmrc Normal file
View File

@ -0,0 +1 @@
@rbkmoney:registry=https://npm.pkg.github.com/

View File

@ -0,0 +1,3 @@
package.json
package-lock.json
node_modules

5
packages/utils/README.md Normal file
View File

@ -0,0 +1,5 @@
# Utils
```sh
npm i @rbkmoney/utils
```

View File

@ -0,0 +1,22 @@
{
"name": "@rbkmoney/utils",
"version": "0.0.0",
"description": "Utils",
"author": "rbkmoney",
"main": "lib/index.js",
"types": "lib/index.d.js",
"scripts": {
"build": "npm run clean & tsc -p tsconfig.json",
"clean": "shx rm -rf lib"
},
"repository": {
"type": "git",
"url": "https://github.com/rbkmoney/fe-core"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com/@rbkmoney"
},
"optionalDependencies": {
"@angular/core": "^11.2.9"
}
}

View File

@ -0,0 +1,10 @@
import { SimpleChange } from '@angular/core';
export interface ComponentChange<T, P extends keyof T> extends Omit<SimpleChange, 'previousValue' | 'currentValue'> {
previousValue: T[P];
currentValue: T[P];
}
export type ComponentChanges<T> = {
[P in keyof T]?: ComponentChange<T, P>;
};

View File

@ -0,0 +1 @@
export * from './component-changes';

View File

@ -0,0 +1 @@
export * from './component-changes';

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src/index.ts"]
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2015",
"lib": ["ESNext", "DOM"],
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"experimentalDecorators": true,
"sourceMap": true,
"declaration": true,
"skipLibCheck": true
}
}