From 2c25c5c777875e40e8c60fd15e88cc4662224758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Musia=C5=82?= Date: Fri, 29 Jan 2021 15:07:25 +0100 Subject: [PATCH] build(lib): Changed whole logic of Query library --- README.md | 142 +++++++++------------------ package.json | 2 +- src/lib/index.ts | 7 +- src/lib/query.decorator.ts | 43 ++++++++ src/lib/query.helpers.ts | 78 +++++++++++++++ src/lib/query.model.ts | 30 +++++- src/lib/query.module.ts | 18 ++++ src/lib/query.reducers.ts | 36 ------- src/lib/query.utils.ts | 23 +++-- src/lib/{ => store}/query.actions.ts | 13 +-- src/lib/store/query.facade.ts | 41 ++++++++ src/lib/store/query.reducer.ts | 40 ++++++++ src/lib/store/query.selectors.ts | 41 ++++++++ src/lib/store/query.state.ts | 13 +++ 14 files changed, 370 insertions(+), 157 deletions(-) create mode 100644 src/lib/query.decorator.ts create mode 100644 src/lib/query.helpers.ts create mode 100644 src/lib/query.module.ts delete mode 100644 src/lib/query.reducers.ts rename src/lib/{ => store}/query.actions.ts (56%) create mode 100644 src/lib/store/query.facade.ts create mode 100644 src/lib/store/query.reducer.ts create mode 100644 src/lib/store/query.selectors.ts create mode 100644 src/lib/store/query.state.ts diff --git a/README.md b/README.md index b284383..d30c18b 100644 --- a/README.md +++ b/README.md @@ -17,117 +17,67 @@ Library requires `@ngrx/store` module. ## Usage -State (for example `homepage.state.ts`): +Import QueryModule in your app.module.ts: ```ts -import { queryReducer } from '@ngsm/query'; -import { HomepageApiResponseDto } from 'your-api-dto.interfaces.ts'; +import { QueryModule } from '@ngsm/query'; -export interface HomepageQueryState { - getHomepageApiQuery?: Query; -} - -export const HOMEPAGE_QUERY_KEY = 'homepageQuery'; - -export interface HomepagePartialState { - readonly [HOMEPAGE_QUERY_KEY]: HomepageQueryState; - // Your feature states, for example: - // readonly [HOMEPAGE_FEATURE_KEY]: HomepageState; -} -``` - -Reducer (for example `homepage.reducer.ts`): -```ts -import { Action } from '@ngrx/store'; -import { HomepageQueryState } from './homepage.state'; - -... - -export function homepageQueryReducer(state: HomepageQueryState | undefined, action: Action) { - return queryReducer(state, action); +..., +@NgModule({ + ...., + imports: [ + ...., + QueryModule + ] } ``` -Selectors (for example `homepage.selectors.ts`): -```ts -import { createFeatureSelector, createSelector } from '@ngrx/store'; - -export const homepageQueryState = createFeatureSelector(HOMEPAGE_QUERY_KEY); - -export const getHomepageApiQuery = createSelector( - homepageQueryState, - (state: HomepageQueryState) => state.getHomepageApiQuery -); -``` -State module (for example `homepage-state.module.ts`): +How to use @Query decorator in your HTTP service: ```ts -import { NgModule } from '@angular/core'; -import { EffectsModule } from '@ngrx/effects'; -import { StoreModule } from '@ngrx/store'; +import { Query } from '@ngsm/query'; +import { HttpClient } from '@angular/common/http'; -import { HomapageEffects } from './homepage.effects'; -import { HomapageFacade } from './homepage.facade'; -import { homepageQueryReducer, homepageReducer } from './homepage.reducer'; -import { HOMEPAGE_QUERY_KEY } from './homepage.state'; - -@NgModule({ - imports: [ - StoreModule.forFeature(HOMEPAGE_QUERY_KEY, homepageQueryReducer), - EffectsModule.forFeature([HomapageEffects]), - ], - providers: [HomapageFacade] -}) -export class HomapageStateModule {} -``` +@Injectable() +export class CarRepository { + constructor(private http: HttpClient) { } -Effects (for example `homepage.effects.ts`): -```ts - ... - - getHomepageApi$ = createEffect(() => - this.actions$.pipe( - ofType(HomepageActions.getHomepageApi), - mergeMap(() => concat( - // run inProgress action - of(QueryActions.inProgress({ query: HomepageQuery.getHomepageApiQuery })), - this.homepageRepository - .getHomepageApi() - .pipe( - mergeMap((response) => [ - // run success action - QueryActions.success({ query: HomepageQuery.getHomepageApiQuery, response }), - ]), - catchError(error => [ - // run failure action - QueryActions.failure({ query: HomepageQuery.getHomepageApiQuery, error }), - ]) - ) - )) - ) - ); - - ... + @Query({ name: 'getCars', groups: ['cars'] }) + getCars(): Observable { + return this.http.get('API_URL'); + } +} ``` -Facade (for example `homepage.facade.ts`): +In your component (for example `car.component.ts`): ```ts ... +import { QueryFacade } from '@ngsm/query'; +... -@Injectable() -export class HomepageFacade { - getHomepageQuery$ = this.store.pipe(select(HomepageSelectors.getHomepageQuery)); - - loader$ = isQueryInProgress$([ - this.getHomepageQuery$, - // add all feature queries - ..., - ]); - - constructor(private store: Store) {} - - dispatch(action: Action) { - this.store.dispatch(action); +@Component({ + selector: 'app-cars', + templateUrl: './cars.component.html', +}) +export class CarsComponent implements OnInit, OnDestroy { + // Available methods: + loader$ = this.queryFacade.isInProgress$('cars'); + response$ = this.queryFacade.response$('getCars'); + query$ = this.queryFacade.query$('getCars'); + error$ = this.queryFacade.error$('getCars'); + status$ = this.queryFacade.status$('getCars'); + + constructor( + private carRepository: CarRepository, + private queryFacade: QueryFacade, + ) {} + + ngOnInit() { + this.carRepository + .getCars() + .subscribe(); } + + ngOnDestroy() { } } ``` diff --git a/package.json b/package.json index a2ba29b..d8486ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ngsm/query", - "version": "0.1.0", + "version": "0.2.1-alpha.0", "private": false, "dependencies": { "@angular/common": "^9.1.0", diff --git a/src/lib/index.ts b/src/lib/index.ts index c899af3..d151f5b 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,7 +1,10 @@ -import * as QueryActions from './query.actions'; +import * as QueryActions from './store/query.actions'; export * from './query.model'; -export * from './query.reducers'; export * from './query.utils'; +export * from './query.decorator'; +export * from './query.helpers'; +export * from './store/query.facade'; +export * from './query.module'; export { QueryActions }; diff --git a/src/lib/query.decorator.ts b/src/lib/query.decorator.ts new file mode 100644 index 0000000..4dab2a7 --- /dev/null +++ b/src/lib/query.decorator.ts @@ -0,0 +1,43 @@ +import { map, catchError } from 'rxjs/operators'; +import { APP_BOOTSTRAP_LISTENER } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { QueryConfig } from './query.model'; +import * as QueryActions from './store/query.actions'; + +let _store; + +export const BOOTSTRAP_QUERY_PROVIDER = { + provide: APP_BOOTSTRAP_LISTENER, + multi: true, + deps: [Store], + useFactory: (s) => { + _store = s; + return (store) => store; + } +}; + +export const Query = (queryConfig: QueryConfig): MethodDecorator => ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor +) => { + const original = descriptor.value; + + descriptor.value = function () { + _store.dispatch(QueryActions.inProgress({ queryConfig })); + + return original.apply(this, arguments) + .pipe( + map((response) => { + _store.dispatch(QueryActions.success({ queryConfig, response })); + return response; + }), + catchError((error) => { + _store.dispatch(QueryActions.failure({ queryConfig, error })); + throw error; + }) + ); + }; + + return descriptor; +}; diff --git a/src/lib/query.helpers.ts b/src/lib/query.helpers.ts new file mode 100644 index 0000000..9dd98e2 --- /dev/null +++ b/src/lib/query.helpers.ts @@ -0,0 +1,78 @@ +import { QueryResponse, QueryConfig, QueryGroups, Queries } from './query.model'; +import { QueryState } from './store/query.state'; +import { isQueryGroupInProgress } from './query.utils'; + +export const getInitialQuery = (): QueryResponse => ({ + status: null, + response: null, + error: null, + isDirty: false, + isInProgress: false, + isSuccess: false, + isError: false, +}); + +export const getQueriesGroup = ( + queryState: QueryState, + queryConfig: QueryConfig, + query: QueryResponse, + groupName: string, +): Queries[] => { + const { groups } = queryState; + const { name } = queryConfig; + + return { + ...(groups[groupName] ? groups[groupName].queries : []), + [name]: query, + }; +}; + +export const getQueryGroups = ( + queryState: QueryState, + queryConfig: QueryConfig, + query: QueryResponse +): QueryGroups => { + const { groups: stateGroups } = queryState; + const { groups } = queryConfig; + const extendedGroups = { ...stateGroups }; + + if (!groups) { + return extendedGroups; + } + + groups.map(groupName => { + const queries = getQueriesGroup(queryState, queryConfig, query, groupName); + extendedGroups[groupName] = { + isInProgress: isQueryGroupInProgress(queries), + queries + }; + }); + + return extendedGroups; +}; + +export const getQueries = ( + queryState: QueryState, + queryConfig: QueryConfig, + query: QueryResponse +): Queries => { + return { + ...queryState.queries, + [queryConfig.name]: query + }; +}; + +export const parseQueryState = ( + queryState: QueryState, + queryConfig: QueryConfig, + query: QueryResponse +): QueryState => { + const groups = getQueryGroups(queryState, queryConfig, query); + const queries = getQueries(queryState, queryConfig, query); + + return { + ...queryState, + queries, + groups + }; +}; diff --git a/src/lib/query.model.ts b/src/lib/query.model.ts index c956c63..37a9c43 100644 --- a/src/lib/query.model.ts +++ b/src/lib/query.model.ts @@ -1,13 +1,35 @@ import { HttpErrorResponse } from '@angular/common/http'; +export interface Queries { + [key: string]: QueryResponse; +} + +export interface QueryGroup { + queries: Queries[]; + isInProgress: boolean; +} + +export interface QueryGroups { + [key: string]: QueryGroup; +} + +export interface QueryConfig { + name: string; + groups?: string[]; +} + export enum QueryStatus { Success = 'SUCCESS', InProgress = 'IN_PROGRESS', Failure = 'FAILURE', } -export interface Query { - status?: QueryStatus; - response?: T; - error?: HttpErrorResponse; +export interface QueryResponse { + response: T; + error: HttpErrorResponse; + status: QueryStatus; + isSuccess: boolean; + isError: boolean; + isInProgress: boolean; + isDirty: boolean; } diff --git a/src/lib/query.module.ts b/src/lib/query.module.ts new file mode 100644 index 0000000..9e611a2 --- /dev/null +++ b/src/lib/query.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { StoreModule } from '@ngrx/store'; +import { QueryReducer } from './store/query.reducer'; + +import { QUERY_STORE_KEY } from './store/query.state'; +import { QueryFacade } from './store/query.facade'; +import { BOOTSTRAP_QUERY_PROVIDER } from './query.decorator'; + +@NgModule({ + imports: [ + StoreModule.forFeature(QUERY_STORE_KEY, QueryReducer), + ], + providers: [ + QueryFacade, + BOOTSTRAP_QUERY_PROVIDER + ] +}) +export class QueryModule {} diff --git a/src/lib/query.reducers.ts b/src/lib/query.reducers.ts deleted file mode 100644 index b30443d..0000000 --- a/src/lib/query.reducers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createReducer, on } from '@ngrx/store'; - -import * as QueryActions from './query.actions'; -import { QueryStatus } from './query.model'; - -export const queryReducer = createReducer( - {}, - on(QueryActions.init, (state, action) => ({ - ...state, - [action.query]: {}, - })), - on(QueryActions.inProgress, (state, action) => ({ - ...state, - [action.query]: { - status: QueryStatus.InProgress, - }, - })), - on(QueryActions.success, (state, action) => ({ - ...state, - [action.query]: { - status: QueryStatus.Success, - response: action.response, - }, - })), - on(QueryActions.failure, (state, action) => ({ - ...state, - [action.query]: { - status: QueryStatus.Failure, - error: action.error, - }, - })), - on(QueryActions.clear, (state, action) => ({ - ...state, - [action.query]: {}, - })), -); diff --git a/src/lib/query.utils.ts b/src/lib/query.utils.ts index a0609ce..0e6c143 100644 --- a/src/lib/query.utils.ts +++ b/src/lib/query.utils.ts @@ -1,20 +1,19 @@ import { combineLatest, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { Query, QueryStatus } from './query.model'; +import { Queries, QueryResponse, QueryStatus } from './query.model'; -export const hasQueryStatus = (query: Query): boolean => query && !!query.status; -export const isQueryInProgress = (query: Query): boolean => hasQueryStatus(query) && query.status === QueryStatus.InProgress; -export const hasQueryFailed = (query: Query): boolean => hasQueryStatus(query) && query.status === QueryStatus.Failure; -export const hasQuerySucceeded = (query: Query): boolean => hasQueryStatus(query) && query.status === QueryStatus.Success; -export const isQueryFinished = (query: Query): boolean => hasQueryStatus(query) && query.status !== QueryStatus.InProgress; +export const hasQueryStatus = (query: QueryResponse): boolean => query && !!query.status; +export const isQueryInProgress = (query: QueryResponse): boolean => hasQueryStatus(query) && query.status === QueryStatus.InProgress; +export const hasQueryFailed = (query: QueryResponse): boolean => hasQueryStatus(query) && query.status === QueryStatus.Failure; +export const hasQuerySucceeded = (query: QueryResponse): boolean => hasQueryStatus(query) && query.status === QueryStatus.Success; +export const isQueryFinished = (query: QueryResponse): boolean => hasQueryStatus(query) && query.status !== QueryStatus.InProgress; -export const getQueryStatus = (query: Query) => ({ - success: hasQuerySucceeded(query), - error: hasQueryFailed(query), - pending: isQueryInProgress(query), -}); +export const isQueryGroupInProgress = (querieDatas: Queries[]): boolean => { + const queries = Object.values(querieDatas) as any; + return queries.some(query => isQueryInProgress(query)); +}; -export const isQueryInProgress$ = (queries: Observable>[]): Observable => { +export const isQueryInProgress$ = (queries: Observable>[]): Observable => { return combineLatest(queries).pipe(map(data => data.some(query => isQueryInProgress(query)))); }; diff --git a/src/lib/query.actions.ts b/src/lib/store/query.actions.ts similarity index 56% rename from src/lib/query.actions.ts rename to src/lib/store/query.actions.ts index 8ed5ca7..77ac0a3 100644 --- a/src/lib/query.actions.ts +++ b/src/lib/store/query.actions.ts @@ -1,29 +1,30 @@ import { HttpErrorResponse } from '@angular/common/http'; import { createAction, props } from '@ngrx/store'; +import { QueryConfig } from '../query.model'; -export const QUERY_KEY = '[Query]'; +export const QUERY_KEY = '[ngsm query]'; export const init = createAction( `${QUERY_KEY} INIT`, - props<{ query: string }>(), + props<{ queryConfig: QueryConfig }>(), ); export const inProgress = createAction( `${QUERY_KEY} IN_PROGRESS`, - props<{ query: string }>(), + props<{ queryConfig: QueryConfig }>(), ); export const success = createAction( `${QUERY_KEY} SUCCESS`, - props<{ query: string, response?: any }>(), + props<{ queryConfig: QueryConfig, response?: any }>(), ); export const failure = createAction( `${QUERY_KEY} FAILURE`, - props<{ query: string, error: HttpErrorResponse }>(), + props<{ queryConfig: QueryConfig, error: HttpErrorResponse }>(), ); export const clear = createAction( `${QUERY_KEY} CLEAR`, - props<{ query: string }>(), + props<{ queryConfig: QueryConfig }>(), ); diff --git a/src/lib/store/query.facade.ts b/src/lib/store/query.facade.ts new file mode 100644 index 0000000..566e1a6 --- /dev/null +++ b/src/lib/store/query.facade.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Action, select, Store } from '@ngrx/store'; +import { QueryState } from './query.state'; +import * as QuerySelectors from './query.selectors'; +import { QueryResponse, QueryStatus } from '../query.model'; + +@Injectable() +export class QueryFacade { + constructor(private store: Store) {} + + dispatch(action: Action): void { + this.store.dispatch(action); + } + + isInProgress$(groupName: string): Observable { + const selector = QuerySelectors.isInProgress(groupName); + return this.store.pipe(select(selector)); + } + + query$(queryName: string): Observable> { + const selector = QuerySelectors.query(queryName); + return this.store.pipe(select(selector)); + } + + response$(queryName: string): Observable { + const selector = QuerySelectors.response(queryName); + return this.store.pipe(select(selector)); + } + + error$(queryName: string): Observable { + const selector = QuerySelectors.error(queryName); + return this.store.pipe(select(selector)); + } + + status$(queryName: string): Observable { + const selector = QuerySelectors.status(queryName); + return this.store.pipe(select(selector)); + } +} diff --git a/src/lib/store/query.reducer.ts b/src/lib/store/query.reducer.ts new file mode 100644 index 0000000..9925577 --- /dev/null +++ b/src/lib/store/query.reducer.ts @@ -0,0 +1,40 @@ +import { createReducer, on, Action } from '@ngrx/store'; +import { QueryStatus } from '../query.model'; + +import * as QueryActions from './query.actions'; +import { initialState, QueryState } from './query.state'; +import { getInitialQuery, parseQueryState } from '../query.helpers'; + +export const queryReducer = createReducer( + initialState, + on(QueryActions.init, (state, { queryConfig }) => parseQueryState(state, queryConfig, getInitialQuery())), + on(QueryActions.clear, (state, { queryConfig }) => parseQueryState(state, queryConfig, getInitialQuery())), + on(QueryActions.inProgress, (state, { queryConfig }) => parseQueryState(state, queryConfig, { + ...state.queries[queryConfig.name], + status: QueryStatus.InProgress, + isDirty: true, + isInProgress: true, + isSuccess: false, + isError: false, + })), + on(QueryActions.success, (state, { queryConfig, response}) => parseQueryState(state, queryConfig, { + status: QueryStatus.Success, + response, + error: null, + isDirty: true, + isInProgress: false, + isSuccess: true, + isError: false, + })), + on(QueryActions.failure, (state, { queryConfig, error }) => parseQueryState(state, queryConfig, { + status: QueryStatus.Failure, + response: null, + error, + isDirty: true, + isInProgress: false, + isSuccess: false, + isError: true, + })), +); + +export const QueryReducer = (state: QueryState, action: Action) => queryReducer(state, action); diff --git a/src/lib/store/query.selectors.ts b/src/lib/store/query.selectors.ts new file mode 100644 index 0000000..672ffc8 --- /dev/null +++ b/src/lib/store/query.selectors.ts @@ -0,0 +1,41 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { QueryResponse } from '../query.model'; +import { QueryState, QUERY_STORE_KEY } from './query.state'; + +export const queryState = createFeatureSelector(QUERY_STORE_KEY); + +export const isInProgress = (groupName: string) => createSelector( + queryState, + (state: QueryState) => state.groups[groupName] ? state.groups[groupName].isInProgress : null, +); + +const getQuery = (state: QueryState, name: string): QueryResponse => state.queries[name] || null; + +export const query = (queryName: string) => createSelector( + queryState, + (state: QueryState) => getQuery(state, queryName) +); + +export const response = (queryName: string) => createSelector( + queryState, + (state: QueryState) => { + const query = getQuery(state, queryName); + return query ? query.response : null; + } +); + +export const error = (queryName: string) => createSelector( + queryState, + (state: QueryState) => { + const query = getQuery(state, queryName); + return query ? query.error : null; + } +); + +export const status = (queryName: string) => createSelector( + queryState, + (state: QueryState) => { + const query = getQuery(state, queryName); + return query ? query.status : null; + } +); diff --git a/src/lib/store/query.state.ts b/src/lib/store/query.state.ts new file mode 100644 index 0000000..4dcfd8e --- /dev/null +++ b/src/lib/store/query.state.ts @@ -0,0 +1,13 @@ +import { Queries, QueryGroups } from '../query.model'; + +export const QUERY_STORE_KEY = 'ngsmQuery'; + +export interface QueryState { + queries: Queries, + groups: QueryGroups, +} + +export const initialState: QueryState = { + queries: {}, + groups: {}, +};