Skip to content

Commit

Permalink
chore: [IOBP-1020] Using optimistic UI for remove payment method acti…
Browse files Browse the repository at this point in the history
…on (#6446)

## Short description
This pull request introduces several changes to include an optimistic UI
logic for the handling of deleted wallet cards in the wallet management
system


## List of changes proposed in this pull request
- Introduced a new `DeletedCard` type and updated the `WalletCardsState`
type to include an optional `deletedCard` property.
- Added logic to handle the `paymentsDeleteMethodAction` request,
cancel, and failure actions, ensuring that deleted cards can be restored
if necessary.
- Modified the `selectWalletCards` selector to exclude the `deletedCard`
from the list of wallet cards.
- Removed the call to `walletRemoveCards` and added logic to dispatch a
failure action with network error details if the deletion fails

## How to test
- Try to remove a payment method with a backend error (_add a delay to
the dev-server `addPaymentWalletHandler` delete function with 400
response_)
- Check if the deleted method is coming back at its original position


## Preview

https://github.com/user-attachments/assets/29120b17-5b40-440b-80d1-1ab0e42a5c97

---------

Co-authored-by: Alessandro <[email protected]>
  • Loading branch information
LeleDallas and Hantex9 authored Dec 5, 2024
1 parent b420196 commit 274fa6f
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 28 deletions.
25 changes: 9 additions & 16 deletions ts/features/payments/details/saga/handleDeleteWalletDetails.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
import { put } from "typed-redux-saga/macro";
import * as E from "fp-ts/lib/Either";
import { put } from "typed-redux-saga/macro";
import { ActionType } from "typesafe-actions";
import { getGenericError, getNetworkError } from "../../../../utils/errors";
import { readablePrivacyReport } from "../../../../utils/reporters";
import { WalletClient } from "../../common/api/client";
import { withPaymentsSessionToken } from "../../common/utils/withPaymentsSessionToken";
import {
paymentsDeleteMethodAction,
paymentsGetMethodDetailsAction
} from "../store/actions";
import { readablePrivacyReport } from "../../../../utils/reporters";
import { getGenericError, getNetworkError } from "../../../../utils/errors";
import { WalletClient } from "../../common/api/client";
import { walletRemoveCards } from "../../../wallet/store/actions/cards";
import { mapWalletIdToCardKey } from "../../common/utils";
import { withPaymentsSessionToken } from "../../common/utils/withPaymentsSessionToken";

/**
* Handle the remote call to start Wallet onboarding payment methods list
* @param getPaymentMethods
* @param action
*/
export function* handleDeleteWalletDetails(
deleteWalletById: WalletClient["deleteIOPaymentWalletById"],
action: ActionType<(typeof paymentsDeleteMethodAction)["request"]>
Expand All @@ -33,10 +26,6 @@ export function* handleDeleteWalletDetails(

if (E.isRight(deleteWalletResult)) {
if (deleteWalletResult.right.status === 204) {
yield* put(
walletRemoveCards([mapWalletIdToCardKey(action.payload.walletId)])
);

// handled success
const successAction = paymentsDeleteMethodAction.success(
action.payload.walletId
Expand Down Expand Up @@ -66,6 +55,10 @@ export function* handleDeleteWalletDetails(
action.payload.onFailure?.();
}
} catch (e) {
const failureAction = paymentsDeleteMethodAction.failure({
...getNetworkError(e)
});
yield* put(failureAction);
yield* put(
paymentsGetMethodDetailsAction.failure({ ...getNetworkError(e) })
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,17 @@ const T_CARDS: WalletCardsState = {
}
};

const T_PLACEHOLDERS: WalletCardsState = _.mapValues(
T_CARDS,
card =>
({
type: "placeholder",
category: card.category,
key: card.key
} as WalletCard)
const T_PLACEHOLDERS: WalletCardsState = _.omit(
_.mapValues(
T_CARDS,
card =>
({
type: "placeholder",
category: card.category,
key: card.key
} as WalletCard)
),
"deletedCard"
);

describe("WalletCardsContainer", () => {
Expand Down
90 changes: 90 additions & 0 deletions ts/features/wallet/store/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
walletUpsertCard
} from "../actions/cards";
import { selectWalletCards } from "../selectors";
import { walletResetPlaceholders } from "../actions/placeholders";
import { paymentsDeleteMethodAction } from "../../../payments/details/store/actions";
import { getNetworkError } from "../../../../utils/errors";

const T_CARD_1: WalletCard = {
category: "bonus",
Expand Down Expand Up @@ -130,4 +133,91 @@ describe("Wallet cards reducer", () => {
[T_CARD_1.key]: T_CARD_1
});
});

it("should remove placeholder cards from the store", () => {
const globalState = appReducer(undefined, applicationChangeState("active"));
const store = createStore(appReducer, globalState as any);

const placeholderCard: WalletCard = { ...T_CARD_1, type: "placeholder" };

store.dispatch(walletAddCards([placeholderCard, T_CARD_2, T_CARD_3]));

expect(store.getState().features.wallet.cards).toStrictEqual({
[placeholderCard.key]: placeholderCard,
[T_CARD_2.key]: T_CARD_2,
[T_CARD_3.key]: T_CARD_3
});

store.dispatch(walletResetPlaceholders([placeholderCard]));

expect(store.getState().features.wallet.cards).toStrictEqual({
[T_CARD_2.key]: T_CARD_2,
[T_CARD_3.key]: T_CARD_3
});
});

it("should handle paymentsDeleteMethodAction request", () => {
const globalState = appReducer(undefined, applicationChangeState("active"));
const store = createStore(appReducer, globalState as any);

const cardKey = {
...T_CARD_1,
key: "method_1234"
};

store.dispatch(walletAddCards([cardKey]));

expect(store.getState().features.wallet.cards).toStrictEqual({
[cardKey.key]: cardKey
});

store.dispatch(
paymentsDeleteMethodAction.request({
walletId: "1234"
})
);

expect(store.getState().features.wallet.cards).toStrictEqual({
deletedCard: { ...cardKey, index: 0 }
});
});

it("should handle paymentsDeleteMethodAction cancel and failure", () => {
const globalState = appReducer(undefined, applicationChangeState("active"));
const store = createStore(appReducer, globalState as any);
const networkError = getNetworkError(new Error("test"));

const cardKey = {
...T_CARD_1,
key: "method_1234"
};

store.dispatch(walletAddCards([cardKey, T_CARD_2, T_CARD_3]));

expect(store.getState().features.wallet.cards).toStrictEqual({
[cardKey.key]: cardKey,
[T_CARD_2.key]: T_CARD_2,
[T_CARD_3.key]: T_CARD_3
});

store.dispatch(
paymentsDeleteMethodAction.request({
walletId: "1234"
})
);

expect(store.getState().features.wallet.cards).toStrictEqual({
deletedCard: { ...cardKey, index: 2 },
[T_CARD_2.key]: T_CARD_2,
[T_CARD_3.key]: T_CARD_3
});

store.dispatch(paymentsDeleteMethodAction.failure(networkError));

expect(store.getState().features.wallet.cards).toStrictEqual({
[cardKey.key]: { ...cardKey, index: 2 },
[T_CARD_2.key]: T_CARD_2,
[T_CARD_3.key]: T_CARD_3
});
});
});
44 changes: 43 additions & 1 deletion ts/features/wallet/store/reducers/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ import {
walletUpsertCard
} from "../actions/cards";
import { walletResetPlaceholders } from "../actions/placeholders";
import { paymentsDeleteMethodAction } from "../../../payments/details/store/actions";
import { mapWalletIdToCardKey } from "../../../payments/common/utils";

export type WalletCardsState = { [key: string]: WalletCard };
type DeletedCard = WalletCard & { index: number };

export type WalletCardsState = {
[key: string]: WalletCard;
} & {
deletedCard?: DeletedCard;
};

const INITIAL_STATE: WalletCardsState = {};

Expand Down Expand Up @@ -47,6 +55,40 @@ const reducer = (
return Object.fromEntries(
Object.entries(state).filter(([, { type }]) => type !== action.payload)
);

case getType(paymentsDeleteMethodAction.request): {
const cardKey = mapWalletIdToCardKey(action.payload.walletId);
const deletedCard = {
...state[cardKey],
index: Object.keys(state).indexOf(cardKey)
};

const newState = Object.fromEntries(
Object.entries(state).filter(([key]) => key !== cardKey)
);

return {
...newState,
deletedCard
};
}

case getType(paymentsDeleteMethodAction.cancel):
case getType(paymentsDeleteMethodAction.failure): {
if (!state.deletedCard) {
return state; // No deletedCard to restore
}

const { deletedCard, ...rest } = state;
// Reconstruct state with deletedCard in its original position
const restoredEntries = [
...Object.entries(rest).slice(0, deletedCard.index),
[deletedCard.key, deletedCard],
...Object.entries(rest).slice(deletedCard.index)
];

return Object.fromEntries(restoredEntries);
}
}
return state;
};
Expand Down
7 changes: 4 additions & 3 deletions ts/features/wallet/store/selectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ export const selectWalletPlaceholders = createSelector(
)
);

export const selectWalletCards = createSelector(selectWalletFeature, wallet =>
Object.values(wallet.cards)
);
export const selectWalletCards = createSelector(selectWalletFeature, wallet => {
const { deletedCard, ...cards } = wallet.cards;
return Object.values(cards);
});

/**
* Gets the cards sorted by their category order, specified in the {@see walletCardCategories} array
Expand Down

0 comments on commit 274fa6f

Please sign in to comment.