show confirmation dialog using redux-observable - reactjs

I'm trying to implement a delete epic with a confirmation dialog.
I came up with this approach. It has the advantage of being easy to test.
My question is, is this a good approach, should I worry about adding takeUntil(action$.ofType(MODAL_NO_CLICKED))?
Please let me know if you can think of a better way to implement this.
const deleteNotification$ = (id, { ajax }) => ajax({ url: `api/delete/{id}` });
// showYesNo is an action to eventually show a dialog using this approach https://stackoverflow.com/a/35641680/235659
const showYesNo = payload => ({
type: SHOW_MODAL,
modalType: MODAL_TYPE_YES_NO,
modalProps: { ...payload },
});
const deleteNotificationEpic = (action$, store, dependencies) => {
let uid = dependencies.uid; // dependencies.uid is added here to allow passing the uid during unit test.
return merge(
// Show confirmation dialog.
action$.pipe(
ofType(NOTIFICATION_DELETE_REQUEST),
map(action => {
uid = shortid.generate();
return showYesNo({
message: 'NOTIFICATION_DELETE_CONFIRMATION',
payload: {
notificationId: action.notificationId,
uid,
},
})
}),
),
// Deletes the notification if the user clicks on Yes
action$.pipe(
ofType(MODAL_YES_CLICKED),
filter(({ payload }) => payload.uid === uid),
mergeMap(({ payload }) =>
deleteNotification$(payload.notificationId, dependencies).pipe(
mergeMap(() => of(deleteNotificationSuccess())),
catchError(error => of(deleteNotificationSuccess(error))),
),
),
),
);
};
I know I can show the confirmation dialog on the React level and only dispatch the delete action if the user clicks on Yes, but my question is a more general case where I might have some logic (calling the back-end) before deciding to show the confirmation dialog or not.

Your solution is generally good. There is a potential for weird bugs since MODAL_YES_CLICKED is always listened for even if the notification isn't displayed, though whether this matters is debatable.
When I need similar patterns I personally set up the listener only as-needed and make sure to have some way to cancel (like MODAL_NO_CLICKED) so I don't leak memory. Putting it more sequentially like this helps me understand the expected flow.
return action$.pipe(
ofType(NOTIFICATION_DELETE_REQUEST),
switchMap(action => {
uid = shortid.generate();
return action$.pipe(
ofType(MODAL_YES_CLICKED),
filter(({ payload }) => payload.uid === uid),
take(1),
mergeMap(({ payload }) =>
deleteNotification$(payload.notificationId, dependencies).pipe(
map(() => deleteNotificationSuccess()),
catchError(error => of(deleteNotificationSuccess(error))),
),
),
takeUntil(action$.pipe(ofType(MODAL_NO_CLICKED))),
startWith(
showYesNo({
message: 'NOTIFICATION_DELETE_CONFIRMATION',
payload: {
notificationId: action.notificationId,
uid,
},
})
)
)
}),
)
One interesting thing about my approach vs. yours is that mine is a bit more verbose because I need to have takeUntil as well as take(1) (so we don't leak memory).
Unit test:
it('should delete the notification when MODAL_YES_CLICKED is dispatched', () => {
const uid = 1234;
shortid.generate.mockImplementation(() => uid);
const store = null;
const dependencies = {
ajax: () => of({}),
uid,
};
const inputValues = {
a: action.deleteNotificationRequest(12345, uid),
b: buttonYesClicked({ id: 12345, uid }),
};
const expectedValues = {
a: showYesNo({
message: 'NOTIFICATION_DELETE_CONFIRMATION',
payload: {
id: 12345,
uid,
},
}),
b: showToastSuccessDeleted(),
c: action.loadNotificationsRequest(false),
};
const inputMarble = ' a---b';
const expectedMarble = '---a---(bc)';
const ts = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
const action$ = new ActionsObservable(ts.createHotObservable(inputMarble, inputValues));
const outputAction = epic.deleteNotificationEpic(action$, store, dependencies);
ts.expectObservable(outputAction).toBe(expectedMarble, expectedValues);
ts.flush();
});

As comments are restricted in length, I'm posting an answer even though it's not yet one.
I don't think I can given you guidance as the example code is missing implementations so it's not clear what exactly happens. In particular, what is showYesNo and deleteNotification$?
Btw, the unique ID you generate is only done once, when the epic starts up. That seems like it would be a bug since unique IDs are not generally reusable?
const deleteNotificationEpic = (action$, store, dependencies) => {
const uid = shortid.generate();

Related

Filter function for Array of Objects

I'm following this tutorial and made a few changes to typescript for learning purposes but got stuck when creating a filter function from react context script.
I have a working function called getCampaigns where it maps all the object from the blockchain like below:
const getCampaigns = useCallback(async () => {
const signer = accountProvider?.getSigner();
const contractWithSigner = contract?.connect(signer);
const campaigns = await contractWithSigner?.getCampaigns();
const parsedCampaigns = campaigns.map((campaign, i) => ({
owner: campaign.owner,
title: campaign.title,
description: campaign.description,
target: ethers.utils.formatEther(campaign.target.toString()),
deadline: campaign.deadline.toNumber(),
amountCollected: ethers.utils.formatEther(
campaign.amountCollected.toString()
),
image: campaign.image,
pId: i,
}));
return parsedCampaigns;
}, [contract, accountProvider]);
This is working as it should and manage to see the content like below:
[{…}]
0:
amountCollected:"0.0"
deadline:1673049600000
description: "I want to build a Robot"
image:"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAA
owner:"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
pId:0
target:"3.0"
title:"Build a Robot"
As my new function, I wanted to filter from the getCampaigns function only to display all of the owner's post and display it on a Profile page like below:
const getUserCampaigns = async () => {
const allCampaigns = await getCampaigns();
const filteredCampaigns = allCampaigns.filter(
campaign => campaign.owner === account
);
return filteredCampaigns;
};
So when I console.log filteredCampaigns, it doesnt show any result. Is there anything that I missed here? The typeof account is string and it is working if I put it like this
const filteredCampaigns = allCampaigns.filter(
campaign => campaign.owner === "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
);
Update:
So far I have been playing around with the syntax and console.log the following:
const filteredCampaigns = allCampaigns.filter(campaign => {
console.log(campaign.owner);
return campaign.owner === account;
});
it's managed to fetch the same data and the typeof campaign.owner is in fact a string (same as typeof account). But when I run it like this
const filteredCampaigns = allCampaigns.filter(campaign => {
console.log(campaign.owner === account.toString());
return campaign.owner === account;
});
It's still come out as false
It is working if I hard coded like this
console.log(campaign.owner === "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
filteredCampaign is empty, because the content of account doesn't match any content of campaign.owner.
Check the content of account.
allCampaign.filter(elementOfArray => condition)
return element only if condition is true.
The logic of your getUserCampaign, looks right for what you want to do.
Not sure if this is the case, but may have sense, to have a field/global var/state where you keep all your campaigns.
In this way when you want to filter, you can do something like
const filteredCampaign = (account: string) => {
return allCampaigns.filter(campaign => campaign.owner === account);
}
filteredCampaign is not anymore async call, because doesn't have to await and receive the
account

Recoil refresh state not working of that state has been altered

I'm trying to reset my state by calling the APi to get the latest data. The API call returns an array of object.
export const launchesState = atom<Launch[]>({
key: 'launchesStateLatest',
default: selector<Launch[]>({
key: 'launchesStateInit',
get: async () => {
const response = await getLaunches();
return response.data.map(launch => ({ ...launch, isSelected: false }));
},
}),
});
I'm using a selectorFamily to select each object when the list is rendered.
export const launchState = selectorFamily<Launch | undefined, string>({
key: 'launchState',
get:
missionName =>
({ get }) => {
return get(launchesState).find(item => item.mission_name === missionName);
},
set:
missionName =>
({ get, set }) => {
const currentState = get(launchesState);
const index = currentState.findIndex(item => item.mission_name === missionName);
set(
launchesState,
produce(currentState, draft => {
draft[index].isSelected = !currentState[index].isSelected;
}),
);
},
});
The UI contains a checkbox for each item in the array and when click it uses the set function in the selectorFamily ti update the launchesState.
I'd like to refresh the data using:
useRecoilRefresher_UNSTABLE(launchesState)
Which works ok if the data has never been altered, or is reset using useResetRecoilState(launchesState), but if I have clicked any of the checkboxes and altered the state then the state isn't refreshed.
Can someone help me understand why this is happening, is it a bug or is this happening for a reason?

How to avoid firing multiple redux actions with real time firestore listeners

Introduction
A Little Warning: I do use Redux Toolkit
I have bunch of lists, one of which should be active. And depending on some context, active list should be different. For example I have 3 lists (A, B, C) and let's look at following patterns:
List B is active and I decided to create a new list. After creating list D, list D should be active:
List D - active
List C
List B
List A
List B is active and I decided to change the page. When I come back, List B should be active as it was before changing the page.
The problem
As I initiate the setListsAction from the beginning, it always listens to the firestore and gets invoked every time I manipulate with the store (add, remove, update) and then pass all the data to the reducer. For this reason, I can't control which action was actually performed. For this case in my setListsReducer I check if there's already an active list, if so, I don't change it (covering my second pattern in the examples section). However, with such logic I can't set newly created list as active, because there'll be always an active list that's why in my createListAction I pas a newly created list to the payload and in createListReducer I set payload as the active list. However, the caveat of this approach is that both setListsAction and createListAction gets triggered, so redux state gets updated two times in a row, making my components rerender unnecessary. The cycle looks like that:
in createListAction I add list to the firestore
firestore was updated, so setListsAction gets triggered
createListAction dispatches fulfilled action.
My Code
Actions
setListsAction
export const subscribeListsAction = () => {
return async (dispatch) => {
dispatch(fetchLoadingActions.pending());
const collection = await db.collection('lists');
const unsubscribe = collection
.onSnapshot((querySnapshot) => {
const lists = querySnapshot.docs.map((doc) => {
const list = { ...doc.data(), id: doc.id };
return list;
});
dispatch(
fetchLoadingActions.fulfilled({
lists,
})
);
});
};
};
createListAction
export const createListActionAsync = (list) => {
return async (dispatch: Dispatch<PayloadAction<any>>) => {
dispatch(listsLoadingActions.pending());
const docList = await db.collection('lists').add(list);
const fbList = { ...list, id: docList.id };
dispatch(listsLoadingActions.fulfilled(fbList));
};
};
Reducers
setListsReducer
builder.addCase(fetchLoadingActions.fulfilled, (state, { payload }) => {
state.lists = payload.lists;
const activeList = state.activeList
? payload.lists.find((l) => l.id === state.activeList.id)
: payload.lists[0];
state.activeList = activeList;
});
createListReducer
builder.addCase(listsLoadingActions.fulfilled, (state, { payload }) => {
state.activeList = payload;
});
Sum
So I would like you to propose a better way to handle my problem. I tried to solve it, using change type on docChanges but when I init setListsAction, all docs' changes are type of added and workarounds may damage further implementations of the app. Probably, I need to give up real time database and use get method instead.
If you eliminate the createListReducer and listLoadingActions, you should be able to do everything from inside the ListsAction hook. Using await db.collection('lists').add(list) should refire the listener on the lists collection once it's been added to the database successfully.
export const subscribeListsAction = () => {
return async (dispatch) => {
dispatch(fetchLoadingActions.pending());
const collection = db.collection('lists'); // no need to await?
let firstLoad = true; // used to determine whether to use docs or docsChanges
const unsubscribe = collection
.onSnapshot((querySnapshot) => {
if (firstLoad) {
const lists = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
firstLoad = false;
// Get and set initial active list?
dispatch(
fetchLoadingActions.fulfilled({
lists,
})
);
} else {
// optionally fire dispatch(fetchLoadingActions.pending()) again?
const listsCopy = [...state.lists]; // copy the existing list to mutate it
let activeList = state.activeList; // store the current activeList
querySnapshot.docChanges().map((change) => {
if (change.type === "added") {
const thisList = { ...change.doc.data(), id: change.doc.id };
listsCopy.splice(change.newIndex, 0, thisList);
activeList = thisList;
} else if (change.type === "modified") {
listsCopy.splice(change.oldIndex, 1);
listsCopy.splice(change.newIndex, 0, { ...change.doc.data(), id: change.doc.id });
} else if (change.type === "removed") {
listsCopy.splice(change.oldIndex, 1);
if (activeList.id === change.doc.id) {
// the current active list was removed!
activeList = undefined;
}
}
});
dispatch(
fetchLoadingActions.fulfilled({
lists: listsCopy,
activeList: activeList || listsCopy[0] // use activeList or fallback to first list in listsCopy, could still be undefined if listsCopy is empty!
})
);
}
});
return unsubscribe;
};
};
Regarding the active list history, you could either use the URL ?list=some-id to store the selected list with the History API or you could store an array called activeListHistory in your state variable where you push() and pop() to it as necessary (make sure to handle cases where the old list no longer exists and where there are no entries in the array).

How to emit multiple actions in one epic using redux-observable?

I'm new to rxjs/redux observable and want to do two things:
1) improve this epic to be more idiomatic
2) dispatch two actions from a single epic
Most of the examples I see assume that the api library will throw an exception when a fetch fails, but i've designed mine to be a bit more predictable, and with Typescript union types I'm forced to check an ok: boolean value before I can unpack the results, so understanding how to do this in rxjs has been a bit more challenging.
What's the best way to improve the following? If the request is successful, I'd like to emit both a success action (meaning the user is authorized) and also a 'fetch account' action, which is a separate action because there may be times where I need to fetch the account outside of just 'logging in'. Any help would be greatly appreciated!
const authorizeEpic: Epic<ActionTypes, ActionTypes, RootState> = action$ =>
action$.pipe(
filter(isActionOf(actions.attemptLogin.request)),
switchMap(async val => {
if (!val.payload) {
try {
const token: Auth.Token = JSON.parse(
localStorage.getItem(LOCAL_STORAGE_KEY) || ""
);
if (!token) {
throw new Error();
}
return actions.attemptLogin.success({
token
});
} catch (e) {
return actions.attemptLogin.failure({
error: {
title: "Unable to decode JWT"
}
});
}
}
const resp = await Auth.passwordGrant(
{
email: val.payload.email,
password: val.payload.password,
totp_passcode: ""
},
{
url: "localhost:8088",
noVersion: true,
useHttp: true
}
);
if (resp.ok) {
return [
actions.attemptLogin.success({
token: resp.value
})
// EMIT action 2, etc...
];
}
return actions.attemptLogin.failure(resp);
})
);
The docs for switchMap indicate the project function (the lambda in your example) may return the following:
type ObservableInput<T> = SubscribableOrPromise<T> | ArrayLike<T> | Iterable<T>
When a Promise<T> is returned, the resolved value is simply emitted. In your example, if you return an array from your async scope, the array will be sent directly to the Redux store. Assuming you have no special Redux middlewares setup to handle an array of events, this is likely not what you want. Instead, I would recommend returning an observable in the project function. It's a slight modification to your example:
const authorizeEpic: Epic<ActionTypes, ActionTypes, RootState> = action$ =>
action$.pipe(
filter(isActionOf(actions.attemptLogin.request)), // or `ofType` from 'redux-observable'?
switchMap(action => {
if (action.payload) {
try {
const token: Auth.Token = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || "")
if (!token) {
throw new Error()
}
// return an observable that emits a single action...
return of(actions.attemptLogin.success({
token
}))
} catch (e) {
// return an observable that emits a single action...
return of(actions.attemptLogin.failure({
error: {
title: "Unable to decode JWT"
}
}))
}
}
// return an observable that eventually emits one or more actions...
return from(Auth.passwordGrant(
{
email: val.payload.email,
password: val.payload.password,
totp_passcode: ""
},
{
url: "localhost:8088",
noVersion: true,
useHttp: true
}
)).pipe(
mergeMap(response => response.ok
? of(
actions.attemptLogin.success({ token: resp.value }),
// action 2, etc...
)
: of(actions.attemptLogin.failure(resp))
),
)
}),
)
I don't have your TypeScript type definitions, so I can't verify the example above works exactly. However, I've had quite good success with the more recent versions of TypeScript, RxJS, and redux-observable. Nothing stands out in the above that makes me think you should encounter any issues.
You could zip your actions and return them.
zip(actions.attemptLogin.success({
token: resp.value
})
// EMIT action 2, etc...
So that, now both your actions will be called.

using redux-observable epics to decorate data through multiple actions

I'm struggling with redux-observable, trying to figure out how to create an epic with this flow:
listen for a GET_ITEMS_REQUEST action
send an HTTP request to get some items
grab the IDs of those items and dispatch a GET_ITEM_DETAILS_REQUEST action, which will send another HTTP request to get some more details for those items
decorate the items from the first request with the details from the second request and dispatch a final GET_ITEMS_SUCCESS action, which will update the redux state
Going from step 3 to 4 is where I'm stuck. I know how to dispatch GET_ITEM_DETAILS_REQUEST with the item IDs, but I don't know how to listen/subscribe to the GET_ITEM_DETAILS_REQUEST action to get the item details response.
So far, I have the following:
function getItemsEpic(action$) {
return action$
// step 1
.ofType('GET_ITEMS_REQUEST')
.mergeMap(() => {
// step 2
return Observable.from(Api.getItems())
})
.mergeMap((items) => {
// step 3
const itemIds = items.map((item) => item.id);
return Observable.of({
type: 'GET_ITEM_DETAILS_REQUEST',
ids: itemIds
});
})
// ... what now?
.catch(() => {
return Observable.of({
type: 'GET_ITEMS_FAILURE'
});
});
}
One approach is, after you received the items, start listening for GET_ITEM_DETAILS_FULFILLED and then immediately kick off GET_ITEM_DETAILS_REQUEST using startWith(). The other epic will look the details up and emit GET_ITEM_DETAILS_FULFILLED which our other epic is patiently waiting for and then will zip the two (items + details) together.
const getItemDetailsEpic = action$ =>
action$
.ofType('GET_ITEM_DETAILS_REQUEST')
.mergeMap(({ ids }) =>
Observable.from(Api.getItemDetails(ids))
.map(details => ({
type: 'GET_ITEM_DETAILS_FULFILLED',
details
}))
);
const getItemsEpic = action$ =>
action$
.ofType('GET_ITEMS_REQUEST')
.mergeMap(() =>
Observable.from(Api.getItems())
.mergeMap(items =>
action$.ofType('GET_ITEM_DETAILS_FULFILLED')
.take(1) // don't listen forever! IMPORTANT!
.map(({ details }) => ({
type: 'GET_ITEMS_SUCCESS',
items: items.map((item, i) => ({
...item,
detail: details[i]
// or the more "safe" `details.find(detail => detail.id === item.id)`
// if your data structure allows. Might not be necessary if the
// order is guaranteed to be the same
}))
}))
.startWith({
type: 'GET_ITEM_DETAILS_REQUEST',
ids: items.map(item => item.id)
})
)
);
Separately, I noticed you put your catch() on the outer Observable chain. This is probably not going to do entirely what you want. By the time the error reaches the top chain, your entire epic will have been terminated--it will no longer be listening for future GET_ITEMS_REQUEST! This is a very important distinction and we often call it "isolating your Observable chains". You don't want errors to propagate further than they should.
// GOOD
const somethingEpic = action$ =>
action$.ofType('SOMETHING')
.mergeMap(() =>
somethingThatMayFail()
.catch(e => Observable.of({
type: 'STUFF_BROKE_YO',
payload: e,
error: true
}))
);
// NOT THE SAME THING!
const somethingEpic = action$ =>
action$.ofType('SOMETHING')
.mergeMap(() =>
somethingThatMayFail()
)
.catch(e => Observable.of({
type: 'STUFF_BROKE_YO',
payload: e,
error: true
}));
You sometimes do want a catch on the outer chain, but that's a last ditch thing usually only for errors that are not recoverable.

Resources