Skip to content

Commit

Permalink
feat(contacts): prevent unsaved navigation and mock contact update
Browse files Browse the repository at this point in the history
Switching first and last names for now - clearly demonstrates real-time sorting.
  • Loading branch information
Drew Thompson committed Oct 9, 2019
1 parent 786ca29 commit 3059c7e
Show file tree
Hide file tree
Showing 25 changed files with 365 additions and 82 deletions.
38 changes: 20 additions & 18 deletions .firebase/hosting.ZGlzdC9hcHBzL2NvbnRhY3Rz.cache
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
1-es2015.e7af892d619294c6f10b.js,1570151716936,9bc3f517e371dc49751b0f2eded89482be48790d8028d1ac900301b5f7156801
2-es2015.cf61c887277e599edd9e.js,1570151716936,7246638068b38398089ed24c597dface912398a849a5c85366cdaae4a30c542e
1-es5.133d3b3ef1f6080c4a2a.js,1570151670472,c7796fc0f8f285d27c909c065d9d1d8d46b266ae631dfb66a0dd21f6999a0635
7-es2015.6d83e586fcd6698a15b8.js,1570151716936,57c17bb5f537bc3203100770e1b5a126cf89ba1121437b63a60e548f512ca875
2-es5.8d162f8ecd291218ce07.js,1570151670473,e9529b750c61eaee4fcd7c55ca13d9e3002fa16a2c212f327926aca43a91f0d5
index.html,1570151717034,2783d289ed3a530b0911a54acaa308fd24aadcd2847156e328ac3b4d5291a908
favicon.ico,1570151716936,3c8f1dca744007357393e9228949f1d36e0865c21c06cb504be0fa21e397df5f
6-es5.82a3c0c64b3f0527fcf0.js,1570151670473,cf6dd161a01ab60301c78ce4c03444bc842233e06a7f1abda28630a010c8f4fb
3rdpartylicenses.txt,1570151716936,8f01e63db654a8696ca891db2419774b52733f5e5818986c2ccd0f6dcdb9c59c
6-es2015.dea6e267bb4dd1918eb3.js,1570151716936,e7695cd07ff2a06ae2e17f8cabeab9d9c654c4895e5326a18a69aea371b4a7d6
runtime-es2015.bbb1387d5556d2d4d25f.js,1570151716936,4c14a384aa5b8e3113870864b76448ff8f90a915130b62816fc2bffd0d30516f
5-es5.5d95c407cf4399007c1a.js,1570151670472,ac1701cd0b6e5ac36c830485c96b405f1ffeaee3ee5c90bbccb65cd31b52939c
runtime-es5.9b83a7d4ce2632016771.js,1570151670473,cfd1723cecfea737b23e4c2660b1fad812a2389757d2df33fef07db6df153861
polyfills-es2015.7a3fd0896c7409fd324a.js,1570151716936,cc9922840022e6b0d06ce77c93ba93e2f42ffd114519726ef6cf7343e6e126f9
polyfills-es5.39109a8401f3432dfda5.js,1570151670472,17776f2c960d429843841080145cece7bec7c99f8e3b36f95a24ec27a5341999
styles.4fef18a0a5c78eb678e7.css,1570151716936,5ccf8c6bcd5a260e9e2ab24292e21332bc60cc7df5ef48aebfe7bf47e158d245
main-es2015.5cfddd1478d7aad79eaf.js,1570151716936,ad73a3cc2eff7470818be95128c4868708f690d618b42e01c3cef1aadd51a96e
main-es5.2ea14c6ae2f6110e5635.js,1570151670473,18c038b63d65698497ea25494a4aca46c3bbf9b9e4d0055f28a8e67a18ba9529
1-es2015.8cb530e936a06ffa918a.js,1570552646460,05b4f716b60702867d2432d88aa3e66f37e05493a363022324dc61dcfb9401b6
1-es5.8cb530e936a06ffa918a.js,1570552646464,a524df381123a5f75af4a229d69f5ff48e825d6669d59bd5ea1e31765df44331
2-es2015.68829c41236885dc948b.js,1570552646571,0a6cd2e7422d74d844d958a8da71db2ad822a8015f59c7da2eaa0df6add56f44
2-es5.68829c41236885dc948b.js,1570552646578,f0a9f165524d0240654ff22b9737a80cf3d5915ced7f8fe0b9ed6b081799bca1
3-es2015.5d33ee2af5c4c7168873.js,1570552647124,87cfa762c918d00581c363a26e7ddcdfd8dc3be75905d28bd608bbca7b8277d5
9-es2015.57cdca593d8996d75bee.js,1570552647365,59ccf92fd8c47ff2f2fac4fc178b73e9e6669f8fb2f2474a908847058a24c820
3-es5.5d33ee2af5c4c7168873.js,1570552647132,88e15a5e5d3d59812dd267fb0ed1794cbd4d19461c4f5cb9fccebbac71702d84
index.html,1570552666784,f9ef719c8855f8c0f972500b4b4fbc33610c3992ef3659e8f250cf009dbe91bb
9-es5.57cdca593d8996d75bee.js,1570552647369,4ca4696f15e8cb8ec7a10aa2e848cd2032bbf519c67db8760f42d2632e54d11e
3rdpartylicenses.txt,1570552644706,098d84a3c88dfdf9a637a4602a57369bd8ceba661b6a8d12bf80e9a1271a3139
favicon.ico,1570552644707,3c8f1dca744007357393e9228949f1d36e0865c21c06cb504be0fa21e397df5f
runtime-es2015.82837d58bf43f15ef217.js,1570552666737,c87547f2af3824ca4af5697350bba112b44a3861b3757aef4e39cf3a10330565
runtime-es5.82837d58bf43f15ef217.js,1570552666769,a24ca66488b9b3316e73787f5efb7e38c6a902144f86c22bb97ad25f86e416c7
8-es2015.69487fe812dd24bef160.js,1570552647656,b660fd5b5acb1bca7ffaaf510002eb6446d2b012db02adb20b4466e0ad16ab61
8-es5.69487fe812dd24bef160.js,1570552647662,db1995887d1f1b0fa21c4eebc8ea28f921b4f4452f569b846029f890805e00e6
polyfills-es2015.b82779919efac1d9f563.js,1570552645968,75bfbe610083033190bcfa9e89c77280e59ba57c2a702ba65618dbec79f636ae
polyfills-es5.c29f928566b9d8fedd39.js,1570552649551,64f97cbb905200d375585bce60acdf9d99a15de6b1186b57ce86a48cb438f842
styles.66018ba56e86d1acc7b6.css,1570552644707,921419efb08cc01682b6919a06e53f944a37d4b7a4780284e620522731fb9a42
main-es2015.233587ad89240ccee716.js,1570552666152,26f60d0a7d870a6e2adc0c0eed3103c61b0fa888c7a48b65a383e09bd791fd26
main-es5.233587ad89240ccee716.js,1570552666179,7d9dbc2cba9b40f7e1c3bf9dd25f042c2ea646ae47da409edb33ba0516c9538f
8 changes: 4 additions & 4 deletions apps/api/src/app/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Contact, Message } from '@contacts/api-interface';
import { Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
Expand All @@ -21,8 +21,8 @@ export class AppController {
return { data: this.appService.getContacts() };
}

@Post('contacts')
postContacts(): { data: Message } {
return { data: this.appService.getData() };
@Post('contact')
updateContact(@Body() contact: Contact): { data: Contact } {
return { data: this.appService.updateContact(contact) };
}
}
33 changes: 23 additions & 10 deletions apps/api/src/app/app.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,68 +3,76 @@ import { Injectable } from '@nestjs/common';

const mockTacts: Contact[] = [
{
address: '99 Weiland Way<br/>Cupertino CA 95014<br/>UnitedStates',
address: '99 Weiland Way\nCupertino CA 95014\nUnited States',
email: 'adam.acer@gmail.com',
nameFirst: 'Adam',
nameLast: 'Acer',
notes: "Adam's California Address",
phone: '3996927753'
phone: '3996927753',
id: '1288194745'
},
{
address: '99 Weiland Way<br/>Cupertino CA 95014<br/>UnitedStates',
address: '99 Weiland Way\nCupertino CA 95014\nUnited States',
email: 'bob@saget.com',
nameFirst: 'Bob',
nameLast: 'Saget',
notes: "Can't tell if he's a nice guy",
phone: '1238675309'
phone: '1238675309',
id: '1922253444'
},
{
address: '',
email: '',
nameFirst: 'Someone',
nameLast: 'Cool',
notes: '',
phone: ''
phone: '',
id: ''
},
{
address: '',
email: '',
nameFirst: 'Cat',
nameLast: '',
notes: '',
phone: ''
phone: '',
id: ''
},
{
address: '',
email: '',
nameFirst: 'Clementine',
nameLast: 'Cat',
notes: '',
phone: ''
phone: '',
id: ''
},
{
address: '',
email: '',
nameFirst: 'Leon',
nameLast: 'Merrigold',
notes: '',
phone: ''
phone: '',
id: ''
},
{
address: '',
email: '',
nameFirst: 'Jasmine',
nameLast: 'Pringle',
notes: '',
phone: ''
phone: '',
id: ''
},
{
address: '',
email: '',
nameFirst: 'Michael',
nameLast: 'Scott',
notes: '',
phone: ''
phone: '',
id: ''
}
];

Expand All @@ -77,4 +85,9 @@ export class AppService {
getContacts(): Contact[] {
return mockTacts;
}

updateContact(contact: Contact): Contact {
const nameLast = contact.nameLast;
return { ...contact, nameLast: contact.nameFirst, nameFirst: nameLast };
}
}
2 changes: 2 additions & 0 deletions libs/api-interface/src/lib/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface Contact {
address?: string;
/** Any further information associated with this contact. */
notes?: string;
/** Unique identifier. */
id?: string;
}

/**
Expand Down
7 changes: 5 additions & 2 deletions libs/common/styles/src/lib/contacts/_inputs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@
label {
margin: 0;
margin-right: 0.5rem;
text-align: right;
}
}

&,
input {
input,
textarea {
width: 100%;
}

input {
input,
textarea {
padding: 0 0.25rem 0.25rem;
border: 1px solid gray;
border-radius: 3px;
Expand Down
57 changes: 55 additions & 2 deletions libs/common/utils/src/lib/common-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { InjectionToken } from '@angular/core';
import { Contact } from '@contacts/api-interface';
import { InjectionToken, OnDestroy } from '@angular/core';
import { Actions, ofType } from '@ngrx/effects';
import { ActionCreator } from '@ngrx/store';
import { Observable, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';

/**
* Injection token for an app's environment file.
Expand Down Expand Up @@ -38,3 +41,53 @@ export function debounceEvent(delay: number = 150): MethodDecorator {
return descriptor;
};
}

export class LifeCycleComponent implements OnDestroy {
/** Trigger to cancel subscriptions when this component is destroyed. */
protected destroyed$: Subject<boolean> = new Subject<boolean>();

ngOnDestroy() {
this.destroyed$.next();
this.destroyed$.complete();
}

/**
* Automatically unsubscribe from observable on component OnDestroy lifecycle event.
*
* @param stream Input Observable stream that will unsubscribe when the current component is destroyed
*/
protected autoDestroy<T>(stream: Observable<T>): Observable<T> {
return stream.pipe(takeUntil(this.destroyed$));
}
}

export class ContainerComponent extends LifeCycleComponent {
constructor(protected actions: Actions) {
super();
}

/**
* Facilitates listening to actions within the ngrx store.
*
*
* @param actionCreator The type of action, must be one of the ActionTypes enums used in .actions files
* @param [opts={ autoDestroying: true, persist: false }] Options to prevent autodestroying and persist
* the observable beyond its first event
* @param The mutated action, ready for listening
*/
protected onAction(
actionCreator: ActionCreator,
opts: { autoDestroying?: boolean; persist?: boolean } = { autoDestroying: true, persist: false }
) {
let action = this.actions.pipe(ofType(actionCreator));
if (!opts.persist) {
action = action.pipe(take(1));
} else if (opts.autoDestroying === undefined) {
opts.autoDestroying = true;
}
if (opts.autoDestroying) {
action = this.autoDestroy(action);
}
return action;
}
}
4 changes: 3 additions & 1 deletion libs/contacts/data-access/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from './lib/+state/contacts.actions';
export * from './lib/+state/contacts.facade';
export * from './lib/+state/contacts.reducer';
export * from './lib/+state/contacts.models';
export * from './lib/+state/contacts.reducer';
export * from './lib/+state/contacts.selectors';
export * from './lib/contact.resolver';
export * from './lib/contacts-data-access.module';
export * from './lib/contacts.service';
12 changes: 11 additions & 1 deletion libs/contacts/data-access/src/lib/+state/contacts.actions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Update } from '@ngrx/entity';
import { createAction, props } from '@ngrx/store';
import { ContactsEntity } from './contacts.models';

Expand All @@ -10,6 +11,15 @@ export const loadContactsSuccess = createAction(

export const loadContactsFailure = createAction('[Contacts] Load Contacts Failure', props<{ error: any }>());

export const selectContact = createAction('[Contacts] Select Contact', props<{ selectedId: string | number }>());
export const selectContact = createAction('[Contacts] Select Contact', props<{ selectedId: string }>());

export const deselectContact = createAction('[Contacts] Deselect Contact');

export const saveContact = createAction('[Contacts] Save Contact', props<{ contact: ContactsEntity }>());

export const saveContactSuccess = createAction(
'[Contacts] Save Contact Success',
props<{ update: Update<ContactsEntity> }>()
);

export const saveContactFailure = createAction('[Contacts] Save Contact Failure', props<{ error: any }>());
23 changes: 22 additions & 1 deletion libs/contacts/data-access/src/lib/+state/contacts.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export class ContactsEffects {
loadContacts$ = createEffect(() =>
this.dataPersistence.fetch(ContactsActions.loadContacts, {
run: (action: ReturnType<typeof ContactsActions.loadContacts>, state: ContactsPartialState) => {
// Your custom service 'load' logic goes here. For now just return a success action...
return this.contactsService.getContacts().pipe(
map(contacts => {
if (!contacts) {
Expand All @@ -32,6 +31,28 @@ export class ContactsEffects {
})
);

saveContact$ = createEffect(() =>
this.dataPersistence.pessimisticUpdate(ContactsActions.saveContact, {
run: (action: ReturnType<typeof ContactsActions.saveContact>, state: ContactsPartialState) => {
return this.contactsService.saveContact(action.contact).pipe(
map(res => {
console.log(res);
if (!res) {
throw new Error('Contact could not be updated.');
}
const { id } = action.contact;
return ContactsActions.saveContactSuccess({ update: { id, changes: res.data } });
})
);
},

onError: (action: ReturnType<typeof ContactsActions.saveContact>, error) => {
console.error('Error', error);
return ContactsActions.loadContactsFailure({ error });
}
})
);

constructor(
private actions$: Actions,
private dataPersistence: DataPersistence<ContactsPartialState>,
Expand Down
7 changes: 6 additions & 1 deletion libs/contacts/data-access/src/lib/+state/contacts.facade.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { select, Store } from '@ngrx/store';
import * as ContactsActions from './contacts.actions';
import { ContactsEntity } from './contacts.models';
import * as fromContacts from './contacts.reducer';
import * as ContactsSelectors from './contacts.selectors';

Expand All @@ -19,11 +20,15 @@ export class ContactsFacade {
this.store.dispatch(ContactsActions.loadContacts());
}

select(selectedId: string | number): void {
select(selectedId: string): void {
this.store.dispatch(ContactsActions.selectContact({ selectedId }));
}

deselect(): void {
this.store.dispatch(ContactsActions.deselectContact());
}

saveCurrent(contact: ContactsEntity): void {
this.store.dispatch(ContactsActions.saveContact({ contact }));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import { Contact } from '@contacts/api-interface';
* Interface for the 'Contacts' data
*/
export interface ContactsEntity extends Contact {
id: string | number; // Primary ID
id: string; // Primary ID
}
13 changes: 10 additions & 3 deletions libs/contacts/data-access/src/lib/+state/contacts.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { ContactsEntity } from './contacts.models';
export const CONTACTS_FEATURE_KEY = 'contacts';

export interface ContactsState extends EntityState<ContactsEntity> {
selectedId?: string | number; // which Contacts record has been selected
selectedId?: string; // which Contacts record has been selected
loaded: boolean; // has the Contacts list been loaded
saving: boolean;
error?: string | null; // last none error (if any)
}

Expand All @@ -19,7 +20,8 @@ export const contactsAdapter: EntityAdapter<ContactsEntity> = createEntityAdapte

export const initialState: ContactsState = contactsAdapter.getInitialState({
// set initial required properties
loaded: false
loaded: false,
saving: false
});

const contactsReducer = createReducer(
Expand All @@ -30,7 +32,12 @@ const contactsReducer = createReducer(
),
on(ContactsActions.loadContactsFailure, (state, { error }) => ({ ...state, error })),
on(ContactsActions.selectContact, (state, { selectedId }) => ({ ...state, selectedId })),
on(ContactsActions.deselectContact, state => ({ ...state, selectedId: undefined }))
on(ContactsActions.deselectContact, state => ({ ...state, selectedId: undefined })),
on(ContactsActions.saveContact, state => ({ ...state, saving: true })),
on(ContactsActions.saveContactSuccess, (state, { update }) =>
contactsAdapter.updateOne(update, { ...state, saving: false })
),
on(ContactsActions.saveContactFailure, (state, { error }) => ({ ...state, saving: false, error }))
);

export function reducer(state: ContactsState | undefined, action: Action) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const getSelected = createSelector(

export const getIsSelectedContact = createSelector(
getSelectedId,
(id: string | number, props: string | number) => id === props
(id: string, props: string) => id === props
);

const byLastNameDefaultFirstName = (a: ContactsEntity, b: ContactsEntity) => {
Expand Down
Loading

0 comments on commit 3059c7e

Please sign in to comment.