Skip to content

Commit

Permalink
feat: extend sample app with saveEntities example
Browse files Browse the repository at this point in the history
  • Loading branch information
wardbell committed Sep 19, 2018
1 parent 4894008 commit ec1a9a2
Show file tree
Hide file tree
Showing 10 changed files with 136 additions and 43 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ Please look there.

**_This_** Changelog covers changes to the repository and the demo applications.

<a id="0.6.1">
<a id="0.6.3">
# 0.6.3 (2018-09-18)

* Added "Delete All Villains" to demonstrate the multi-entity save feature introduced in ngrx-data v6.1.0.

<a id="0.6.2">
# 0.6.2 (2018-06-26)

* Significantly refactored for ngrx-data `6.0.2-beta.7`.
Expand Down
24 changes: 19 additions & 5 deletions lib/src/dispatchers/entity-cache-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Action, createSelector, select, Store } from '@ngrx/store';
import { Injectable, Inject } from '@angular/core';
import { Action, createSelector, ScannedActionsSubject, select, Store } from '@ngrx/store';

import { Observable, of, throwError } from 'rxjs';
import { Observable, of, Subscription, throwError } from 'rxjs';
import { filter, map, mergeMap, shareReplay, take } from 'rxjs/operators';

import { CorrelationIdGenerator } from '../utils/correlation-id-generator';
Expand Down Expand Up @@ -33,6 +33,13 @@ import {
*/
@Injectable()
export class EntityCacheDispatcher {
/**
* Actions scanned by the store after it processed them with reducers.
* A replay observable of the most recent action reduced by the store.
*/
reducedActions$: Observable<Action>;
private raSubscription: Subscription;

constructor(
/** Generates correlation ids for query and save methods */
private correlationIdGenerator: CorrelationIdGenerator,
Expand All @@ -42,10 +49,17 @@ export class EntityCacheDispatcher {
*/
private defaultDispatcherOptions: EntityDispatcherDefaultOptions,
/** Actions scanned by the store after it processed them with reducers. */
private reducedActions$: Observable<Action>,
@Inject(ScannedActionsSubject) scannedActions$: Observable<Action>,
/** The store, scoped to the EntityCache */
private store: Store<EntityCache>
) {}
) {
// Replay because sometimes in tests will fake data service with synchronous observable
// which makes subscriber miss the dispatched actions.
// Of course that's a testing mistake. But easy to forget, leading to painful debugging.
this.reducedActions$ = scannedActions$.pipe(shareReplay(1));
// Start listening so late subscriber won't miss the most recent action.
this.raSubscription = this.reducedActions$.subscribe();
}

/**
* Dispatch an Action to the store.
Expand Down
2 changes: 1 addition & 1 deletion lib/src/dispatchers/entity-dispatcher-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class EntityDispatcherFactory implements OnDestroy {
) {
// Replay because sometimes in tests will fake data service with synchronous observable
// which makes subscriber miss the dispatched actions.
// Sure that's a testing mistake. But easy to forget, leading to painful debugging.
// Of course that's a testing mistake. But easy to forget, leading to painful debugging.
this.reducedActions$ = scannedActions$.pipe(shareReplay(1));
// Start listening so late subscriber won't miss the most recent action.
this.raSubscription = this.reducedActions$.subscribe();
Expand Down
4 changes: 2 additions & 2 deletions src/app/heroes/heroes.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export class HeroesService extends EntityCollectionServiceBase<Hero> {
filterObserver: FilterObserver;

/** Run `getAll` if the datasource changes. */
getAllOnDataSourceChange = this.appSelectors.dataSource$().pipe(tap(_ => this.getAll()), shareReplay(1));
getAllOnDataSourceChange = this.appSelectors.dataSource$.pipe(tap(_ => this.getAll()), shareReplay(1));

constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory, private appSelectors: AppSelectors) {
constructor(private appSelectors: AppSelectors, serviceElementsFactory: EntityCollectionServiceElementsFactory) {
super('Hero', serviceElementsFactory);

/** User's filter pattern */
Expand Down
8 changes: 2 additions & 6 deletions src/app/store/app-config/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,13 @@ const getAppState = createFeatureSelector<AppState>('appConfig');

// The following selector implementation guards against empty session state
// as happens when replay with redux dev tools
const getDataSource = createSelector(
getAppState,
(state: AppState) =>
state ? state.session.dataSource : initialState.dataSource
);
const getDataSource = createSelector(getAppState, (state: AppState) => (state ? state.session.dataSource : initialState.dataSource));

@Injectable()
export class AppSelectors {
constructor(private store: Store<AppState>) {}

dataSource$() {
get dataSource$() {
return this.store.select(getDataSource).pipe(distinctUntilChanged());
}
}
8 changes: 6 additions & 2 deletions src/app/store/entity/ngrx-data-toast.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Injectable } from '@angular/core';
import { Actions } from '@ngrx/effects';
import { Actions, ofType } from '@ngrx/effects';

import { filter } from 'rxjs/operators';
import { EntityAction, ofEntityOp, OP_ERROR, OP_SUCCESS } from 'ngrx-data';
import { EntityAction, ofEntityOp, OP_ERROR, OP_SUCCESS, EntityCacheAction } from 'ngrx-data';
import { ToastService } from '../../core/toast.service';

/** Report ngrx-data success/error actions as toast messages **/
Expand All @@ -13,5 +13,9 @@ export class NgrxDataToastService {
.pipe(ofEntityOp(), filter((ea: EntityAction) => ea.payload.entityOp.endsWith(OP_SUCCESS) || ea.payload.entityOp.endsWith(OP_ERROR)))
// this service never dies so no need to unsubscribe
.subscribe(action => toast.openSnackBar(`${action.payload.entityName} action`, action.payload.entityOp));

actions$
.pipe(ofType(EntityCacheAction.SAVE_ENTITIES_SUCCESS, EntityCacheAction.SAVE_ENTITIES_ERROR))
.subscribe((action: any) => toast.openSnackBar(`${action.type} - url: ${action.payload.url}`, 'SaveEntities'));
}
}
55 changes: 50 additions & 5 deletions src/app/villains/villains.service.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import { Injectable } from '@angular/core';
import { Villain, IdGeneratorService } from '../core';

import { combineLatest, Observable } from 'rxjs';
import { filter, first, map, shareReplay, tap } from 'rxjs/operators';

import {
ChangeSet,
ChangeSetOperation,
EntityCacheDispatcher,
EntityCollectionServiceBase,
EntityCollectionServiceElementsFactory
} from 'ngrx-data';

import { AppSelectors } from '../store/app-config';
import { EntityCollectionServiceBase, EntityCollectionServiceElementsFactory } from 'ngrx-data';
import { FilterObserver } from '../shared/filter';
import { shareReplay, tap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class VillainsService extends EntityCollectionServiceBase<Villain> {
filterObserver: FilterObserver;

/** Run `getAll` if the datasource changes. */
getAllOnDataSourceChange = this.appSelectors.dataSource$().pipe(tap(_ => this.getAll()), shareReplay(1));
getAllOnDataSourceChange = this.appSelectors.dataSource$.pipe(tap(_ => this.getAll()), shareReplay(1));
constructor(
private serviceElementsFactory: EntityCollectionServiceElementsFactory,
private appSelectors: AppSelectors,
private idGenerator: IdGeneratorService
private entityCacheDispatcher: EntityCacheDispatcher,
private idGenerator: IdGeneratorService,
private serviceElementsFactory: EntityCollectionServiceElementsFactory
) {
super('Villain', serviceElementsFactory);

Expand All @@ -35,4 +45,39 @@ export class VillainsService extends EntityCollectionServiceBase<Villain> {
}
return super.add(villain);
}

// Demonstrates saveEntities
deleteAll() {
// Build the "change set" of all changes to process at one time.
// An array of changeSetItems, each a specific kind of change to entities of an entity type.
// Here an array with just one item: a delete for all entities of type Villain.
const deleteAllChangeSet$ = (this.keys$ as Observable<number[]>).pipe(
map(keys => {
const changeSet: ChangeSet = {
changes: [
{
entityName: 'Villain',
op: ChangeSetOperation.Delete,
entities: keys
}
],
tag: 'DELETE ALL VILLAINS' // optional descriptive tag
};
return changeSet;
})
);

combineLatest(deleteAllChangeSet$, this.appSelectors.dataSource$)
.pipe(first(), map(x => x))
.subscribe(([changeSet, source]) => {
if (source === 'local') {
// only works with in-mem db for this demo
this.entityCacheDispatcher.saveEntities(
changeSet,
'api/save/delete-villains', // whatever your server expects
{ isOptimistic: true }
);
}
});
}
}
4 changes: 2 additions & 2 deletions src/app/villains/villains/villains.component.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<div class="control-panel">
<div class="button-panel">
<button mat-raised-button color="primary" type="button" (click)="getVillains()" matTooltip="Refresh the villains">Refresh</button>
<button mat-raised-button color="primary" type="button" (click)="enableAddMode()" *ngIf="!selectedVillain"
matTooltip="Add a new villain">Add</button>
<button mat-raised-button color="primary" type="button" (click)="enableAddMode()" *ngIf="!selectedVillain">Add</button>
<button mat-raised-button color="warn" type="button" (click)="deleteAll()" matTooltip="Delete all villains">Delete All</button>
</div>
<app-filter [filterObserver]="filterObserver" filterPlaceholder="Filter by each villain's name and saying"
class="filter-panel"></app-filter>
Expand Down
15 changes: 7 additions & 8 deletions src/app/villains/villains/villains.component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy
} from '@angular/core';
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';

import { Observable, Subscription } from 'rxjs';
Expand All @@ -18,8 +13,7 @@ import { VillainsService } from '../villains.service';
styleUrls: ['./villains.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VillainsComponent
implements MasterDetailCommands<Villain>, OnInit, OnDestroy {
export class VillainsComponent implements MasterDetailCommands<Villain>, OnInit, OnDestroy {
commands = this;
selectedVillain: Villain = null;
subscription: Subscription;
Expand Down Expand Up @@ -64,6 +58,11 @@ export class VillainsComponent
this.villainsService.delete(villain.id);
}

deleteAll() {
this.close();
this.villainsService.deleteAll();
}

select(villain: Villain) {
this.selectedVillain = villain;
}
Expand Down
52 changes: 41 additions & 11 deletions src/in-memory-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
*/
import { Injectable } from '@angular/core';

import {
RequestInfo,
RequestInfoUtilities,
ParsedRequestUrl
} from 'angular-in-memory-web-api';
import { RequestInfo, RequestInfoUtilities, ParsedRequestUrl, ResponseOptions, STATUS } from 'angular-in-memory-web-api';

import { ChangeSet } from 'ngrx-data';

/** In-memory database data */
interface Db {
Expand Down Expand Up @@ -47,9 +45,7 @@ export class InMemoryDataService {
* Seed grows by highest id seen in any of the collections.
*/
genId(collection: { id: number }[], collectionName: string) {
this.maxId =
1 +
collection.reduce((prev, cur) => Math.max(prev, cur.id || 0), this.maxId);
this.maxId = 1 + collection.reduce((prev, cur) => Math.max(prev, cur.id || 0), this.maxId);
return this.maxId;
}

Expand All @@ -63,11 +59,45 @@ export class InMemoryDataService {
*/
parseRequestUrl(url: string, utils: RequestInfoUtilities): ParsedRequestUrl {
const parsed = utils.parseRequestUrl(url);
parsed.collectionName = this.active
? mapCollectionName(parsed.collectionName)
: undefined;
parsed.collectionName = this.active ? mapCollectionName(parsed.collectionName) : undefined;
return parsed;
}

/**
* Special-cases for POST methods
*/
post(req: RequestInfo) {
if (req.url.endsWith('/save/delete-villains')) {
return this.mockDeleteVillains(req);
}

// Let all other POST requests through
return null;
}

private mockDeleteVillains(requestInfo: RequestInfo) {
const originalRequest = requestInfo.req as any;
const changeSet: ChangeSet = originalRequest.body;

const changes = changeSet.changes;
changes.forEach(item => {
// Only know how to delete villains
if (item.entityName === 'Villain') {
const deleteIds = item.entities as number[];
this.db['villains'] = this.db['villains'].filter(v => !deleteIds.includes(v.id));
}
});

// Respond with the changeSet in the request.
// That is a typical SaveEntities response.
const options: ResponseOptions = {
url: requestInfo.url,
status: STATUS.OK,
statusText: 'OK',
body: changeSet
};
return requestInfo.utils.createResponse$(() => options);
}
}

/**
Expand Down

0 comments on commit ec1a9a2

Please sign in to comment.