diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 707fe5ca..fc93de93 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -3,7 +3,6 @@ import { combineReducers } from 'redux'; import { serversReducer } from '../servers/reducers/servers'; import selectedServerReducer from '../servers/reducers/selectedServer'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; -import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; import domainVisitsReducer from '../visits/reducers/domainVisits'; @@ -24,7 +23,7 @@ export default (container: IContainer) => combineReducers({ selectedServer: selectedServerReducer, shortUrlsList: shortUrlsListReducer, shortUrlCreationResult: container.shortUrlCreationReducer, - shortUrlDeletion: shortUrlDeletionReducer, + shortUrlDeletion: container.shortUrlDeletionReducer, shortUrlEdition: container.shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts index 2e9cecc5..87fceed9 100644 --- a/src/short-urls/reducers/shortUrlDeletion.ts +++ b/src/short-urls/reducers/shortUrlDeletion.ts @@ -1,20 +1,15 @@ -import { PayloadAction } from '@reduxjs/toolkit'; -import { Dispatch } from 'redux'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; -import { GetState } from '../../container/types'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { parseApiError } from '../../api/utils'; -import { ApiErrorAction } from '../../api/types/actions'; import { ProblemDetailsError } from '../../api/types/errors'; -export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START'; -export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR'; export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED'; -export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL'; export interface ShortUrlDeletion { shortCode: string; loading: boolean; + deleted: boolean; error: boolean; errorData?: ProblemDetailsError; } @@ -29,35 +24,38 @@ export type DeleteShortUrlAction = PayloadAction; const initialState: ShortUrlDeletion = { shortCode: '', loading: false, + deleted: false, error: false, }; -export default buildReducer({ - [DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }), - [DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }), - [SHORT_URL_DELETED]: (state, { payload }) => ( - { ...state, shortCode: payload.shortCode, loading: false, error: false } - ), - [RESET_DELETE_SHORT_URL]: () => initialState, -}, initialState); +export const shortUrlDeletionReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { + const deleteShortUrl = createAsyncThunk( + SHORT_URL_DELETED, + async ({ shortCode, domain }: DeleteShortUrl, { getState }): Promise => { + const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState); + await shlinkDeleteShortUrl(shortCode, domain); + return { shortCode, domain }; + }, + ); -export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - { shortCode, domain }: DeleteShortUrl, -) => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: DELETE_SHORT_URL_START }); - const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState); + const { actions, reducer } = createSlice({ + name: 'shortUrlDeletion', + initialState, + reducers: { + resetDeleteShortUrl: () => initialState, + }, + extraReducers: (builder) => { + builder.addCase(deleteShortUrl.pending, (state) => ({ ...state, loading: true, error: false, deleted: false })); + builder.addCase(deleteShortUrl.rejected, (state, { error }) => ( + { ...state, errorData: parseApiError(error), loading: false, error: true, deleted: false } + )); + builder.addCase(deleteShortUrl.fulfilled, (state, { payload }) => ( + { ...state, shortCode: payload.shortCode, loading: false, error: false, deleted: true } + )); + }, + }); - try { - await shlinkDeleteShortUrl(shortCode, domain); - dispatch({ - type: SHORT_URL_DELETED, - payload: { shortCode, domain }, - }); - } catch (e: any) { - dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) }); + const { resetDeleteShortUrl } = actions; - throw e; - } + return { reducer, deleteShortUrl, resetDeleteShortUrl }; }; - -export const resetDeleteShortUrl = buildActionCreator(RESET_DELETE_SHORT_URL); diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 317cc523..54b2b4cc 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -44,8 +44,7 @@ export default buildReducer({ [LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }), [LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }), [LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }), - // [`${SHORT_URL_DELETED}/fulfilled`]: pipe( // TODO Do not hardcode action type here - [SHORT_URL_DELETED]: pipe( + [`${SHORT_URL_DELETED}/fulfilled`]: pipe( // TODO Do not hardcode action type here (state: ShortUrlsList, { payload }: DeleteShortUrlAction) => (!state.shortUrls ? state : assocPath( ['shortUrls', 'data'], reject((shortUrl) => diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index aab487c4..25845ea2 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -9,7 +9,7 @@ import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal'; import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult'; import { listShortUrls } from '../reducers/shortUrlsList'; import { shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation'; -import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion'; +import { shortUrlDeletionReducerCreator } from '../reducers/shortUrlDeletion'; import { shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition'; import { ConnectDecorator } from '../../container/types'; import { ShortUrlsTable } from '../ShortUrlsTable'; @@ -63,14 +63,17 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator'); + bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator'); + // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); bottle.serviceFactory('createShortUrl', prop('createShortUrl'), 'shortUrlCreationReducerCreator'); bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator'); - bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient'); - bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl); + bottle.serviceFactory('deleteShortUrl', prop('deleteShortUrl'), 'shortUrlDeletionReducerCreator'); + bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator'); bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); diff --git a/test/short-urls/reducers/shortUrlDeletion.test.ts b/test/short-urls/reducers/shortUrlDeletion.test.ts index 9346cc45..6ec4b661 100644 --- a/test/short-urls/reducers/shortUrlDeletion.test.ts +++ b/test/short-urls/reducers/shortUrlDeletion.test.ts @@ -1,48 +1,52 @@ import { Mock } from 'ts-mockery'; -import reducer, { - DELETE_SHORT_URL_ERROR, - DELETE_SHORT_URL_START, - RESET_DELETE_SHORT_URL, - SHORT_URL_DELETED, - resetDeleteShortUrl, - deleteShortUrl, -} from '../../../src/short-urls/reducers/shortUrlDeletion'; +import { shortUrlDeletionReducerCreator } from '../../../src/short-urls/reducers/shortUrlDeletion'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ProblemDetailsError } from '../../../src/api/types/errors'; describe('shortUrlDeletionReducer', () => { + const deleteShortUrlCall = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ deleteShortUrl: deleteShortUrlCall }); + const { reducer, resetDeleteShortUrl, deleteShortUrl } = shortUrlDeletionReducerCreator(buildShlinkApiClient); + + beforeEach(jest.clearAllMocks); + describe('reducer', () => { it('returns loading on DELETE_SHORT_URL_START', () => - expect(reducer(undefined, { type: DELETE_SHORT_URL_START } as any)).toEqual({ + expect(reducer(undefined, { type: deleteShortUrl.pending.toString() } as any)).toEqual({ shortCode: '', loading: true, error: false, + deleted: false, })); it('returns default on RESET_DELETE_SHORT_URL', () => - expect(reducer(undefined, { type: RESET_DELETE_SHORT_URL } as any)).toEqual({ + expect(reducer(undefined, { type: resetDeleteShortUrl.toString() } as any)).toEqual({ shortCode: '', loading: false, error: false, + deleted: false, })); it('returns shortCode on SHORT_URL_DELETED', () => expect(reducer(undefined, { - type: SHORT_URL_DELETED, + type: deleteShortUrl.fulfilled.toString(), payload: { shortCode: 'foo' }, } as any)).toEqual({ shortCode: 'foo', loading: false, error: false, + deleted: true, })); it('returns errorData on DELETE_SHORT_URL_ERROR', () => { const errorData = Mock.of({ type: 'bar' }); + const error = { response: { data: errorData } }; - expect(reducer(undefined, { type: DELETE_SHORT_URL_ERROR, errorData } as any)).toEqual({ + expect(reducer(undefined, { type: deleteShortUrl.rejected.toString(), error } as any)).toEqual({ shortCode: '', loading: false, error: true, + deleted: false, errorData, }); }); @@ -50,59 +54,47 @@ describe('shortUrlDeletionReducer', () => { describe('resetDeleteShortUrl', () => { it('returns expected action', () => - expect(resetDeleteShortUrl()).toEqual({ type: RESET_DELETE_SHORT_URL })); + expect(resetDeleteShortUrl()).toEqual({ type: resetDeleteShortUrl.toString() })); }); describe('deleteShortUrl', () => { const dispatch = jest.fn(); const getState = jest.fn().mockReturnValue({ selectedServer: {} }); - afterEach(() => { - dispatch.mockReset(); - getState.mockClear(); - }); - it.each( [[undefined], [null], ['example.com']], )('dispatches proper actions if API client request succeeds', async (domain) => { - const apiClientMock = Mock.of({ - deleteShortUrl: jest.fn(() => ''), - }); const shortCode = 'abc123'; - await deleteShortUrl(() => apiClientMock)({ shortCode, domain })(dispatch, getState); + await deleteShortUrl({ shortCode, domain })(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_SHORT_URL_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { - type: SHORT_URL_DELETED, + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: deleteShortUrl.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: deleteShortUrl.fulfilled.toString(), payload: { shortCode, domain }, - }); + })); - expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1); - expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode, domain); + expect(deleteShortUrlCall).toHaveBeenCalledTimes(1); + expect(deleteShortUrlCall).toHaveBeenCalledWith(shortCode, domain); }); it('dispatches proper actions if API client request fails', async () => { const data = { foo: 'bar' }; - const error = { response: { data } }; - const apiClientMock = Mock.of({ - deleteShortUrl: jest.fn(async () => Promise.reject(error)), - }); const shortCode = 'abc123'; - try { - await deleteShortUrl(() => apiClientMock)({ shortCode })(dispatch, getState); - } catch (e) { - expect(e).toEqual(error); - } + deleteShortUrlCall.mockRejectedValue({ response: { data } }); + + await deleteShortUrl({ shortCode })(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_SHORT_URL_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_SHORT_URL_ERROR, errorData: data }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: deleteShortUrl.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: deleteShortUrl.rejected.toString(), + })); - expect(apiClientMock.deleteShortUrl).toHaveBeenCalledTimes(1); - expect(apiClientMock.deleteShortUrl).toHaveBeenCalledWith(shortCode, undefined); + expect(deleteShortUrlCall).toHaveBeenCalledTimes(1); + expect(deleteShortUrlCall).toHaveBeenCalledWith(shortCode, undefined); }); }); }); diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index fd62a6a4..05b500ea 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -52,7 +52,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: SHORT_URL_DELETED, payload: { shortCode } } as any)).toEqual({ + expect(reducer(state, { type: `${SHORT_URL_DELETED}/fulfilled`, payload: { shortCode } } as any)).toEqual({ shortUrls: { data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }], pagination: { totalItems: 9 },