Skip to content

Commit a94b4e3

Browse files
committed
feat(ngrx): store with otter
1 parent e42f945 commit a94b4e3

18 files changed

+846
-515
lines changed
Lines changed: 13 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { computed, effect, inject, Injectable, signal} from '@angular/core';
1+
import { inject, Injectable, signal} from '@angular/core';
22
import { HttpClient } from '@angular/common/http';
3-
import { Contact } from './contact';
3+
import { Contact, type ContactWithoutId } from './contact';
44
import { URL } from './config';
5-
import { injectInfiniteQuery, injectMutation, injectQuery, injectQueryClient } from '@tanstack/angular-query-experimental';
65
import { lastValueFrom, tap } from 'rxjs';
6+
import { Store } from '@ngrx/store';
7+
import { selectAllContact, selectContactStorePendingStatus } from './store/contact/contact.selectors';
8+
import { setContactEntitiesFromApi } from './store/contact/contact.actions';
79

810
interface ContactResponse {
911
data: Contact[];
@@ -18,152 +20,27 @@ interface ContactResponse {
1820
export class BackEndService {
1921
/// Tanstack query usage
2022
private readonly http = inject(HttpClient);
21-
public queryClient = injectQueryClient();
2223
public currentId = signal('1');
2324
public filter = signal('');
2425
public currentStart = signal(0);
2526
public currentLimit = signal(5);
2627
public currentPage = signal(1);
2728

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

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

48-
public mutationSave = injectMutation(() => ({
49-
mutationFn: (contact: Contact) => {
50-
// console.log('Save mutate contact:', contact);
51-
return lastValueFrom(this.saveFn(contact));
52-
},
53-
onMutate: async (contact) => {
54-
// cancel potential queries
55-
await this.queryClient.cancelQueries({ queryKey: ['contacts'] });
56-
57-
58-
const savedCache = this.queryClient.getQueryData(['contacts', '']);
59-
// console.log('savedCache', savedCache);
60-
this.queryClient.setQueryData(['contacts', ''], (contacts: Contact[]) => {
61-
if (contact.id) {
62-
return contacts.map((contactCache) =>
63-
contactCache.id === contact.id ? contact : contactCache
64-
);
65-
}
66-
// optimistic update
67-
return contacts.concat({ ...contact, id: Math.random().toString() });
68-
});
69-
return () => {
70-
this.queryClient.setQueryData(['contacts', ''], savedCache);
71-
};
72-
},
73-
onSuccess: (data: Contact, contact: Contact, restoreCache: () => void) => {
74-
// Should we update the cache of a "contact" here ?
75-
restoreCache();
76-
this.queryClient.setQueryData(['contact', data.id], data);
77-
this.queryClient.setQueryData(['contacts', ''], (contactsCache: Contact[]) => {
78-
if (contact.id) {
79-
return contactsCache.map((contactCache) =>
80-
contactCache.id === contact.id ? contact : contactCache
81-
);
82-
}
83-
return contactsCache.concat(data);
84-
});
85-
},
86-
onError: async (_error, variables, context) => {
87-
context?.();
88-
await this.settledFn(variables.id);
89-
}
90-
}));
91-
92-
public mutationDelete = injectMutation(() => ({
93-
mutationFn: (id: string) => {
94-
// console.log('Save mutate contact:', contact);
95-
return lastValueFrom(this.removeFn(id));
96-
},
97-
onMutate: (id: string) => {
98-
const savedCache = this.queryClient.getQueryData<Contact[]>(['contacts', '']);
99-
// console.log('savedCache', savedCache);
100-
this.queryClient.setQueryData(['contacts', ''], (contacts: Contact[]) =>
101-
// optimistic update
102-
contacts.filter((contactCached) => contactCached.id !== id)
103-
);
104-
return () => {
105-
this.queryClient.setQueryData(['contacts', ''], savedCache);
106-
};
107-
},
108-
onError: async (_error, variables, context) => {
109-
context?.();
110-
await this.settledFn(variables);
111-
},
112-
onSettled: (_data: Contact | undefined, _error, variables, _context) => this.settledFn(variables)
113-
}));
114-
115-
public infiniteQuery = injectInfiniteQuery(() => ({
116-
queryKey: ['contacts'],
117-
queryFn: ({ pageParam }) => {
118-
return lastValueFrom(this.getInfiniteContacts(pageParam));
119-
},
120-
initialPageParam: this.currentPage(),
121-
getPreviousPageParam: (firstPage) => firstPage.prev ?? undefined,
122-
getNextPageParam: (lastPage) => lastPage.next ?? undefined
123-
}));
124-
125-
126-
public nextButtonDisabled = computed(
127-
() => !this.#hasNextPage() || this.#isFetchingNextPage()
128-
);
129-
public nextButtonText = computed(() =>
130-
this.#isFetchingNextPage()
131-
? 'Loading more...'
132-
: this.#hasNextPage()
133-
? 'Load newer'
134-
: 'Nothing more to load'
135-
);
136-
public previousButtonDisabled = computed(
137-
() => !this.#hasPreviousPage() || this.#isFetchingNextPage()
138-
);
139-
public previousButtonText = computed(() =>
140-
this.#isFetchingPreviousPage()
141-
? 'Loading more...'
142-
: this.#hasPreviousPage()
143-
? 'Load Older'
144-
: 'Nothing more to load'
145-
);
146-
147-
readonly #hasPreviousPage = this.infiniteQuery.hasPreviousPage;
148-
readonly #hasNextPage = this.infiniteQuery.hasNextPage;
149-
readonly #isFetchingPreviousPage = this.infiniteQuery.isFetchingPreviousPage;
150-
readonly #isFetchingNextPage = this.infiniteQuery.isFetchingNextPage;
34+
public isPending = this.store.select(selectContactStorePendingStatus);
15135

36+
public isFailing = this.store.select(selectContactStorePendingStatus);
15237

15338
constructor() {
154-
effect(async () => { if (!this.nextButtonDisabled()) {
155-
await this.fetchNextPage();
156-
}});
157-
}
158-
159-
public async settledFn(contactId: string | undefined) {
160-
await this.queryClient.invalidateQueries({ queryKey: ['contacts']});
161-
if (contactId) {
162-
await this.queryClient.invalidateQueries({ queryKey: ['contact', contactId]});
163-
}
39+
// store solution
40+
this.store.dispatch(setContactEntitiesFromApi({call: lastValueFrom(this.http.get<Contact[]>(`${URL}?q=`))}));
16441
}
16542

166-
public saveFn(contact: Contact) {
43+
public saveFn(contact: ContactWithoutId) {
16744
if (contact.id) {
16845
return this.http.put<Contact>(`${URL}/${contact.id}`, contact);
16946
}
@@ -177,12 +54,4 @@ export class BackEndService {
17754
public getInfiniteContacts(pageParam: number) {
17855
return this.http.get<ContactResponse>(`${URL}?_page=${pageParam.toString()}&_per_page=${this.currentLimit().toString()}`).pipe(tap(() => this.currentPage.set(pageParam)));
17956
}
180-
181-
public async fetchNextPage() {
182-
// Do nothing if already fetching
183-
if (this.infiniteQuery.isFetching()) {
184-
return;
185-
}
186-
await this.infiniteQuery.fetchNextPage();
187-
}
18857
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
export interface Contact {
2+
id: string;
3+
firstName: string;
4+
lastName: string;
5+
}
6+
7+
export interface ContactWithoutId {
28
id?: string;
39
firstName: string;
410
lastName: string;
511
}
612

13+
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
asyncProps,
3+
AsyncRequest,
4+
FailAsyncStoreItemEntitiesActionPayload,
5+
FromApiActionPayload,
6+
SetActionPayload,
7+
SetAsyncStoreItemEntitiesActionPayload,
8+
UpdateActionPayload,
9+
UpdateAsyncStoreItemEntitiesActionPayloadWithId
10+
} from '@o3r/core';
11+
12+
import {createAction, props} from '@ngrx/store';
13+
// import {ContactModel} from './contact.state';
14+
import {ContactStateDetails} from './contact.state';
15+
import type { Contact } from '../../contact';
16+
17+
/** StateDetailsActions */
18+
const ACTION_SET = '[Contact] set';
19+
const ACTION_UPDATE = '[Contact] update';
20+
const ACTION_RESET = '[Contact] reset';
21+
const ACTION_CANCEL_REQUEST = '[Contact] cancel request';
22+
23+
/** Entity Actions */
24+
const ACTION_CLEAR_ENTITIES = '[Contact] clear entities';
25+
const ACTION_UPDATE_ENTITIES = '[Contact] update entities';
26+
const ACTION_UPSERT_ENTITIES = '[Contact] upsert entities';
27+
const ACTION_SET_ENTITIES = '[Contact] set entities';
28+
const ACTION_FAIL_ENTITIES = '[Contact] fail entities';
29+
30+
/** Async Actions */
31+
const ACTION_SET_ENTITIES_FROM_API = '[Contact] set entities from api';
32+
const ACTION_UPDATE_ENTITIES_FROM_API = '[Contact] update entities from api';
33+
const ACTION_UPSERT_ENTITIES_FROM_API = '[Contact] upsert entities from api';
34+
35+
/** Action to clear the StateDetails of the store and replace it */
36+
export const setContact = createAction(ACTION_SET, props<SetActionPayload<ContactStateDetails>>());
37+
38+
/** Action to change a part or the whole object in the store. */
39+
export const updateContact = createAction(ACTION_UPDATE, props<UpdateActionPayload<ContactStateDetails>>());
40+
41+
/** Action to reset the whole state, by returning it to initial state. */
42+
export const resetContact = createAction(ACTION_RESET);
43+
44+
/** Action to cancel a Request ID registered in the store. Can happen from effect based on a switchMap for instance */
45+
export const cancelContactRequest = createAction(ACTION_CANCEL_REQUEST, props<AsyncRequest>());
46+
47+
/** Action to clear all contact and fill the store with the payload */
48+
export const setContactEntities = createAction(ACTION_SET_ENTITIES, props<SetAsyncStoreItemEntitiesActionPayload<Contact>>());
49+
50+
/** Action to update contact with known IDs, ignore the new ones */
51+
export const updateContactEntities = createAction(ACTION_UPDATE_ENTITIES, props<UpdateAsyncStoreItemEntitiesActionPayloadWithId<Contact>>());
52+
53+
/** Action to update contact with known IDs, insert the new ones */
54+
export const upsertContactEntities = createAction(ACTION_UPSERT_ENTITIES, props<SetAsyncStoreItemEntitiesActionPayload<Contact>>());
55+
56+
/** Action to empty the list of entities, keeping the global state */
57+
export const clearContactEntities = createAction(ACTION_CLEAR_ENTITIES);
58+
59+
/** Action to update failureStatus for every ContactModel */
60+
export const failContactEntities = createAction(ACTION_FAIL_ENTITIES, props<FailAsyncStoreItemEntitiesActionPayload<any>>());
61+
62+
/**
63+
* 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.
64+
* If the call fails, dispatch FAIL_ENTITIES action
65+
*/
66+
export const setContactEntitiesFromApi = createAction(ACTION_SET_ENTITIES_FROM_API, asyncProps<FromApiActionPayload<Contact[]>>());
67+
68+
/**
69+
* 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.
70+
* If the call fails, dispatch FAIL_ENTITIES action
71+
*/
72+
export const updateContactEntitiesFromApi = createAction(ACTION_UPDATE_ENTITIES_FROM_API, asyncProps<FromApiActionPayload<Contact[]> & { ids: string[] }>());
73+
74+
/**
75+
* 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.
76+
* If the call fails, dispatch FAIL_ENTITIES action
77+
*/
78+
export const upsertContactEntitiesFromApi = createAction(ACTION_UPSERT_ENTITIES_FROM_API, asyncProps<FromApiActionPayload<Contact[]>>());
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {Injectable} from '@angular/core';
2+
import {Actions, createEffect, ofType} from '@ngrx/effects';
3+
import {from, of} from 'rxjs';
4+
import {catchError, map, mergeMap} from 'rxjs/operators';
5+
import {fromApiEffectSwitchMap} from '@o3r/core';
6+
import {
7+
cancelContactRequest,
8+
failContactEntities,
9+
setContactEntities, setContactEntitiesFromApi,
10+
updateContactEntities, updateContactEntitiesFromApi,
11+
upsertContactEntities, upsertContactEntitiesFromApi
12+
} from './contact.actions';
13+
14+
/**
15+
* Service to handle async Contact actions
16+
*/
17+
@Injectable()
18+
export class ContactEffect {
19+
20+
/**
21+
* Set the entities with the reply content, dispatch failContactEntities if it catches a failure
22+
*/
23+
public setEntitiesFromApi$ = createEffect(() =>
24+
this.actions$.pipe(
25+
ofType(setContactEntitiesFromApi),
26+
fromApiEffectSwitchMap(
27+
(reply, action) => setContactEntities({entities: reply, requestId: action.requestId}),
28+
(error, action) => of(failContactEntities({error, requestId: action.requestId})),
29+
cancelContactRequest
30+
)
31+
)
32+
);
33+
34+
/**
35+
* Update the entities with the reply content, dispatch failContactEntities if it catches a failure
36+
*/
37+
public updateEntitiesFromApi$ = createEffect(() =>
38+
this.actions$.pipe(
39+
ofType(updateContactEntitiesFromApi),
40+
mergeMap((payload) =>
41+
from(payload.call).pipe(
42+
map((reply) => updateContactEntities({entities: reply, requestId: payload.requestId})),
43+
catchError((err) => of(failContactEntities({ids: payload.ids, error: err, requestId: payload.requestId})))
44+
)
45+
)
46+
)
47+
);
48+
49+
/**
50+
* Upsert the entities with the reply content, dispatch failContactEntities if it catches a failure
51+
*/
52+
public upsertEntitiesFromApi$ = createEffect(() =>
53+
this.actions$.pipe(
54+
ofType(upsertContactEntitiesFromApi),
55+
mergeMap((payload) =>
56+
from(payload.call).pipe(
57+
map((reply) => upsertContactEntities({entities: reply, requestId: payload.requestId})),
58+
catchError((err) => of(failContactEntities({error: err, requestId: payload.requestId})))
59+
)
60+
)
61+
)
62+
);
63+
64+
constructor(protected actions$: Actions) {
65+
}
66+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
2+
import { Action, ActionReducer, StoreModule } from '@ngrx/store';
3+
4+
import { EffectsModule } from '@ngrx/effects';
5+
import { ContactEffect } from './contact.effect';
6+
import { contactReducer } from './contact.reducer';
7+
import { CONTACT_STORE_NAME, ContactState } from './contact.state';
8+
9+
/** Token of the Contact reducer */
10+
export const CONTACT_REDUCER_TOKEN = new InjectionToken<ActionReducer<ContactState, Action>>('Feature Contact Reducer');
11+
12+
/** Provide default reducer for Contact store */
13+
export function getDefaultContactReducer() {
14+
return contactReducer;
15+
}
16+
17+
@NgModule({
18+
imports: [
19+
StoreModule.forFeature(CONTACT_STORE_NAME, CONTACT_REDUCER_TOKEN), EffectsModule.forFeature([ContactEffect])
20+
],
21+
providers: [
22+
{ provide: CONTACT_REDUCER_TOKEN, useFactory: getDefaultContactReducer }
23+
]
24+
})
25+
export class ContactStoreModule {
26+
public static forRoot<T extends ContactState>(reducerFactory: () => ActionReducer<T, Action>): ModuleWithProviders<ContactStoreModule> {
27+
return {
28+
ngModule: ContactStoreModule,
29+
providers: [
30+
{ provide: CONTACT_REDUCER_TOKEN, useFactory: reducerFactory }
31+
]
32+
};
33+
}
34+
}

0 commit comments

Comments
 (0)