Skip to content

Commit

Permalink
feat(ngrx): store with otter
Browse files Browse the repository at this point in the history
  • Loading branch information
ExFlo committed May 10, 2024
1 parent e42f945 commit a94b4e3
Show file tree
Hide file tree
Showing 18 changed files with 846 additions and 515 deletions.
157 changes: 13 additions & 144 deletions apps/showcase/src/components/showcase/tanstack/backend.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { computed, effect, inject, Injectable, signal} from '@angular/core';
import { inject, Injectable, signal} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Contact } from './contact';
import { Contact, type ContactWithoutId } from './contact';
import { URL } from './config';
import { injectInfiniteQuery, injectMutation, injectQuery, injectQueryClient } from '@tanstack/angular-query-experimental';
import { lastValueFrom, tap } from 'rxjs';
import { Store } from '@ngrx/store';
import { selectAllContact, selectContactStorePendingStatus } from './store/contact/contact.selectors';
import { setContactEntitiesFromApi } from './store/contact/contact.actions';

interface ContactResponse {
data: Contact[];
Expand All @@ -18,152 +20,27 @@ interface ContactResponse {
export class BackEndService {
/// Tanstack query usage
private readonly http = inject(HttpClient);
public queryClient = injectQueryClient();
public currentId = signal('1');
public filter = signal('');
public currentStart = signal(0);
public currentLimit = signal(5);
public currentPage = signal(1);

public contact = injectQuery(() => ({
queryKey: ['contact', this.currentId()],
queryFn: () => {
// console.log('in getContact$ with id', id);
return lastValueFrom(this.http.get<Contact>(`${URL}/${this.currentId()}`));
},
staleTime: 60 * 1000, // 1 minute
initialData: () => this.queryClient.getQueryData<Contact[] | undefined>(['contacts', ''])?.find((contact) => contact.id === this.currentId()),
initialDataUpdatedAt: () => this.queryClient.getQueryState(['contacts', ''])?.dataUpdatedAt
}));
// store solution
public readonly store = inject(Store);

public contacts = injectQuery(() => ({
queryKey: ['contacts', this.filter()],
queryFn: () => {
// console.log('in getContact$ with id', id);
return lastValueFrom(this.http.get<Contact[]>(`${URL}?q=${this.filter()}`));
},
staleTime: 60 * 1000 // 1 min
}));
public allContact = this.store.select(selectAllContact);

public mutationSave = injectMutation(() => ({
mutationFn: (contact: Contact) => {
// console.log('Save mutate contact:', contact);
return lastValueFrom(this.saveFn(contact));
},
onMutate: async (contact) => {
// cancel potential queries
await this.queryClient.cancelQueries({ queryKey: ['contacts'] });


const savedCache = this.queryClient.getQueryData(['contacts', '']);
// console.log('savedCache', savedCache);
this.queryClient.setQueryData(['contacts', ''], (contacts: Contact[]) => {
if (contact.id) {
return contacts.map((contactCache) =>
contactCache.id === contact.id ? contact : contactCache
);
}
// optimistic update
return contacts.concat({ ...contact, id: Math.random().toString() });
});
return () => {
this.queryClient.setQueryData(['contacts', ''], savedCache);
};
},
onSuccess: (data: Contact, contact: Contact, restoreCache: () => void) => {
// Should we update the cache of a "contact" here ?
restoreCache();
this.queryClient.setQueryData(['contact', data.id], data);
this.queryClient.setQueryData(['contacts', ''], (contactsCache: Contact[]) => {
if (contact.id) {
return contactsCache.map((contactCache) =>
contactCache.id === contact.id ? contact : contactCache
);
}
return contactsCache.concat(data);
});
},
onError: async (_error, variables, context) => {
context?.();
await this.settledFn(variables.id);
}
}));

public mutationDelete = injectMutation(() => ({
mutationFn: (id: string) => {
// console.log('Save mutate contact:', contact);
return lastValueFrom(this.removeFn(id));
},
onMutate: (id: string) => {
const savedCache = this.queryClient.getQueryData<Contact[]>(['contacts', '']);
// console.log('savedCache', savedCache);
this.queryClient.setQueryData(['contacts', ''], (contacts: Contact[]) =>
// optimistic update
contacts.filter((contactCached) => contactCached.id !== id)
);
return () => {
this.queryClient.setQueryData(['contacts', ''], savedCache);
};
},
onError: async (_error, variables, context) => {
context?.();
await this.settledFn(variables);
},
onSettled: (_data: Contact | undefined, _error, variables, _context) => this.settledFn(variables)
}));

public infiniteQuery = injectInfiniteQuery(() => ({
queryKey: ['contacts'],
queryFn: ({ pageParam }) => {
return lastValueFrom(this.getInfiniteContacts(pageParam));
},
initialPageParam: this.currentPage(),
getPreviousPageParam: (firstPage) => firstPage.prev ?? undefined,
getNextPageParam: (lastPage) => lastPage.next ?? undefined
}));


public nextButtonDisabled = computed(
() => !this.#hasNextPage() || this.#isFetchingNextPage()
);
public nextButtonText = computed(() =>
this.#isFetchingNextPage()
? 'Loading more...'
: this.#hasNextPage()
? 'Load newer'
: 'Nothing more to load'
);
public previousButtonDisabled = computed(
() => !this.#hasPreviousPage() || this.#isFetchingNextPage()
);
public previousButtonText = computed(() =>
this.#isFetchingPreviousPage()
? 'Loading more...'
: this.#hasPreviousPage()
? 'Load Older'
: 'Nothing more to load'
);

readonly #hasPreviousPage = this.infiniteQuery.hasPreviousPage;
readonly #hasNextPage = this.infiniteQuery.hasNextPage;
readonly #isFetchingPreviousPage = this.infiniteQuery.isFetchingPreviousPage;
readonly #isFetchingNextPage = this.infiniteQuery.isFetchingNextPage;
public isPending = this.store.select(selectContactStorePendingStatus);

public isFailing = this.store.select(selectContactStorePendingStatus);

constructor() {
effect(async () => { if (!this.nextButtonDisabled()) {
await this.fetchNextPage();
}});
}

public async settledFn(contactId: string | undefined) {
await this.queryClient.invalidateQueries({ queryKey: ['contacts']});
if (contactId) {
await this.queryClient.invalidateQueries({ queryKey: ['contact', contactId]});
}
// store solution
this.store.dispatch(setContactEntitiesFromApi({call: lastValueFrom(this.http.get<Contact[]>(`${URL}?q=`))}));
}

public saveFn(contact: Contact) {
public saveFn(contact: ContactWithoutId) {
if (contact.id) {
return this.http.put<Contact>(`${URL}/${contact.id}`, contact);
}
Expand All @@ -177,12 +54,4 @@ export class BackEndService {
public getInfiniteContacts(pageParam: number) {
return this.http.get<ContactResponse>(`${URL}?_page=${pageParam.toString()}&_per_page=${this.currentLimit().toString()}`).pipe(tap(() => this.currentPage.set(pageParam)));
}

public async fetchNextPage() {
// Do nothing if already fetching
if (this.infiniteQuery.isFetching()) {
return;
}
await this.infiniteQuery.fetchNextPage();
}
}
7 changes: 7 additions & 0 deletions apps/showcase/src/components/showcase/tanstack/contact.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
export interface Contact {
id: string;
firstName: string;
lastName: string;
}

export interface ContactWithoutId {
id?: string;
firstName: string;
lastName: string;
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
asyncProps,
AsyncRequest,
FailAsyncStoreItemEntitiesActionPayload,
FromApiActionPayload,
SetActionPayload,
SetAsyncStoreItemEntitiesActionPayload,
UpdateActionPayload,
UpdateAsyncStoreItemEntitiesActionPayloadWithId
} from '@o3r/core';

import {createAction, props} from '@ngrx/store';
// import {ContactModel} from './contact.state';
import {ContactStateDetails} from './contact.state';
import type { Contact } from '../../contact';

/** StateDetailsActions */
const ACTION_SET = '[Contact] set';
const ACTION_UPDATE = '[Contact] update';
const ACTION_RESET = '[Contact] reset';
const ACTION_CANCEL_REQUEST = '[Contact] cancel request';

/** Entity Actions */
const ACTION_CLEAR_ENTITIES = '[Contact] clear entities';
const ACTION_UPDATE_ENTITIES = '[Contact] update entities';
const ACTION_UPSERT_ENTITIES = '[Contact] upsert entities';
const ACTION_SET_ENTITIES = '[Contact] set entities';
const ACTION_FAIL_ENTITIES = '[Contact] fail entities';

/** Async Actions */
const ACTION_SET_ENTITIES_FROM_API = '[Contact] set entities from api';
const ACTION_UPDATE_ENTITIES_FROM_API = '[Contact] update entities from api';
const ACTION_UPSERT_ENTITIES_FROM_API = '[Contact] upsert entities from api';

/** Action to clear the StateDetails of the store and replace it */
export const setContact = createAction(ACTION_SET, props<SetActionPayload<ContactStateDetails>>());

/** Action to change a part or the whole object in the store. */
export const updateContact = createAction(ACTION_UPDATE, props<UpdateActionPayload<ContactStateDetails>>());

/** Action to reset the whole state, by returning it to initial state. */
export const resetContact = createAction(ACTION_RESET);

/** Action to cancel a Request ID registered in the store. Can happen from effect based on a switchMap for instance */
export const cancelContactRequest = createAction(ACTION_CANCEL_REQUEST, props<AsyncRequest>());

/** Action to clear all contact and fill the store with the payload */
export const setContactEntities = createAction(ACTION_SET_ENTITIES, props<SetAsyncStoreItemEntitiesActionPayload<Contact>>());

/** Action to update contact with known IDs, ignore the new ones */
export const updateContactEntities = createAction(ACTION_UPDATE_ENTITIES, props<UpdateAsyncStoreItemEntitiesActionPayloadWithId<Contact>>());

/** Action to update contact with known IDs, insert the new ones */
export const upsertContactEntities = createAction(ACTION_UPSERT_ENTITIES, props<SetAsyncStoreItemEntitiesActionPayload<Contact>>());

/** Action to empty the list of entities, keeping the global state */
export const clearContactEntities = createAction(ACTION_CLEAR_ENTITIES);

/** Action to update failureStatus for every ContactModel */
export const failContactEntities = createAction(ACTION_FAIL_ENTITIES, props<FailAsyncStoreItemEntitiesActionPayload<any>>());

/**
* Action to put the global status of the store in a pending state. Call SET action with the list of ContactModels received, when this action resolves.
* If the call fails, dispatch FAIL_ENTITIES action
*/
export const setContactEntitiesFromApi = createAction(ACTION_SET_ENTITIES_FROM_API, asyncProps<FromApiActionPayload<Contact[]>>());

/**
* Action to change isPending status of elements to be updated with a request. Call UPDATE action with the list of ContactModels received, when this action resolves.
* If the call fails, dispatch FAIL_ENTITIES action
*/
export const updateContactEntitiesFromApi = createAction(ACTION_UPDATE_ENTITIES_FROM_API, asyncProps<FromApiActionPayload<Contact[]> & { ids: string[] }>());

/**
* Action to put global status of the store in a pending state. Call UPSERT action with the list of ContactModels received, when this action resolves.
* If the call fails, dispatch FAIL_ENTITIES action
*/
export const upsertContactEntitiesFromApi = createAction(ACTION_UPSERT_ENTITIES_FROM_API, asyncProps<FromApiActionPayload<Contact[]>>());
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {Injectable} from '@angular/core';
import {Actions, createEffect, ofType} from '@ngrx/effects';
import {from, of} from 'rxjs';
import {catchError, map, mergeMap} from 'rxjs/operators';
import {fromApiEffectSwitchMap} from '@o3r/core';
import {
cancelContactRequest,
failContactEntities,
setContactEntities, setContactEntitiesFromApi,
updateContactEntities, updateContactEntitiesFromApi,
upsertContactEntities, upsertContactEntitiesFromApi
} from './contact.actions';

/**
* Service to handle async Contact actions
*/
@Injectable()
export class ContactEffect {

/**
* Set the entities with the reply content, dispatch failContactEntities if it catches a failure
*/
public setEntitiesFromApi$ = createEffect(() =>
this.actions$.pipe(
ofType(setContactEntitiesFromApi),
fromApiEffectSwitchMap(
(reply, action) => setContactEntities({entities: reply, requestId: action.requestId}),
(error, action) => of(failContactEntities({error, requestId: action.requestId})),
cancelContactRequest
)
)
);

/**
* Update the entities with the reply content, dispatch failContactEntities if it catches a failure
*/
public updateEntitiesFromApi$ = createEffect(() =>
this.actions$.pipe(
ofType(updateContactEntitiesFromApi),
mergeMap((payload) =>
from(payload.call).pipe(
map((reply) => updateContactEntities({entities: reply, requestId: payload.requestId})),
catchError((err) => of(failContactEntities({ids: payload.ids, error: err, requestId: payload.requestId})))
)
)
)
);

/**
* Upsert the entities with the reply content, dispatch failContactEntities if it catches a failure
*/
public upsertEntitiesFromApi$ = createEffect(() =>
this.actions$.pipe(
ofType(upsertContactEntitiesFromApi),
mergeMap((payload) =>
from(payload.call).pipe(
map((reply) => upsertContactEntities({entities: reply, requestId: payload.requestId})),
catchError((err) => of(failContactEntities({error: err, requestId: payload.requestId})))
)
)
)
);

constructor(protected actions$: Actions) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
import { Action, ActionReducer, StoreModule } from '@ngrx/store';

import { EffectsModule } from '@ngrx/effects';
import { ContactEffect } from './contact.effect';
import { contactReducer } from './contact.reducer';
import { CONTACT_STORE_NAME, ContactState } from './contact.state';

/** Token of the Contact reducer */
export const CONTACT_REDUCER_TOKEN = new InjectionToken<ActionReducer<ContactState, Action>>('Feature Contact Reducer');

/** Provide default reducer for Contact store */
export function getDefaultContactReducer() {
return contactReducer;
}

@NgModule({
imports: [
StoreModule.forFeature(CONTACT_STORE_NAME, CONTACT_REDUCER_TOKEN), EffectsModule.forFeature([ContactEffect])
],
providers: [
{ provide: CONTACT_REDUCER_TOKEN, useFactory: getDefaultContactReducer }
]
})
export class ContactStoreModule {
public static forRoot<T extends ContactState>(reducerFactory: () => ActionReducer<T, Action>): ModuleWithProviders<ContactStoreModule> {
return {
ngModule: ContactStoreModule,
providers: [
{ provide: CONTACT_REDUCER_TOKEN, useFactory: reducerFactory }
]
};
}
}
Loading

0 comments on commit a94b4e3

Please sign in to comment.