From a316366ae9c93b73a1ab7be69561d46155a27821 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 6 Nov 2022 10:11:44 +0100 Subject: [PATCH] Migrated shortUrlCreation reducer to RTK --- src/reducers/index.ts | 3 +- src/short-urls/reducers/shortUrlCreation.ts | 61 ++++++++-------- src/short-urls/reducers/shortUrlsList.ts | 6 +- src/short-urls/services/provideServices.ts | 11 ++- src/tags/reducers/tagsList.ts | 4 +- .../reducers/shortUrlCreation.test.ts | 72 +++++++++---------- .../short-urls/reducers/shortUrlsList.test.ts | 2 +- test/tags/reducers/tagsList.test.ts | 4 +- 8 files changed, 80 insertions(+), 83 deletions(-) diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 61bd0139..885033ce 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 shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; @@ -25,7 +24,7 @@ export default (container: IContainer) => combineReducers({ servers: serversReducer, selectedServer: selectedServerReducer, shortUrlsList: shortUrlsListReducer, - shortUrlCreationResult: shortUrlCreationReducer, + shortUrlCreationResult: container.shortUrlCreationReducer, shortUrlDeletion: shortUrlDeletionReducer, shortUrlEdition: shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/src/short-urls/reducers/shortUrlCreation.ts index af7c1146..7bdc91f8 100644 --- a/src/short-urls/reducers/shortUrlCreation.ts +++ b/src/short-urls/reducers/shortUrlCreation.ts @@ -1,16 +1,11 @@ -import { Action, Dispatch } from 'redux'; -import { GetState } from '../../container/types'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { ShortUrl, ShortUrlData } from '../data'; -import { buildReducer, buildActionCreator } from '../../utils/helpers/redux'; +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 CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; -export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR'; export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL'; -export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL'; export interface ShortUrlCreation { result: ShortUrl | null; @@ -19,9 +14,7 @@ export interface ShortUrlCreation { errorData?: ProblemDetailsError; } -export interface CreateShortUrlAction extends Action { - result: ShortUrl; -} +export type CreateShortUrlAction = PayloadAction; const initialState: ShortUrlCreation = { result: null, @@ -29,29 +22,33 @@ const initialState: ShortUrlCreation = { error: false, }; -export default buildReducer({ - [CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), - [CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), - [CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }), - [RESET_CREATE_SHORT_URL]: () => initialState, -}, initialState); +export const shortUrlCreationReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { + const createShortUrl = createAsyncThunk(CREATE_SHORT_URL, (data: ShortUrlData, { getState }): Promise => { + const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState); + return shlinkCreateShortUrl(data); + }); -export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (data: ShortUrlData) => async ( - dispatch: Dispatch, - getState: GetState, -) => { - dispatch({ type: CREATE_SHORT_URL_START }); - const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState); + const { reducer, actions } = createSlice({ + name: 'shortUrlCreationReducer', + initialState, + reducers: { + resetCreateShortUrl: () => initialState, + }, + extraReducers: (builder) => { + builder.addCase(createShortUrl.pending, (state) => ({ ...state, saving: true, error: false })); + builder.addCase( + createShortUrl.rejected, + (state, { error }) => ({ ...state, saving: false, error: true, errorData: parseApiError(error) }), + ); + builder.addCase(createShortUrl.fulfilled, (_, { payload: result }) => ({ result, saving: false, error: false })); + }, + }); - try { - const result = await shlinkCreateShortUrl(data); + const { resetCreateShortUrl } = actions; - dispatch({ type: CREATE_SHORT_URL, result }); - } catch (e: any) { - dispatch({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) }); - - throw e; - } + return { + reducer, + createShortUrl, + resetCreateShortUrl, + }; }; - -export const resetCreateShortUrl = buildActionCreator(RESET_CREATE_SHORT_URL); diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 7137a41d..7f826ad7 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -74,13 +74,13 @@ export default buildReducer({ ), state, ), - [CREATE_SHORT_URL]: pipe( + [`${CREATE_SHORT_URL}/fulfilled`]: pipe( // TODO Do not hardcode action type here // The only place where the list and the creation form coexist is the overview page. // There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL. // We can also remove the items above the amount that is displayed there. - (state: ShortUrlsList, { result }: CreateShortUrlAction) => (!state.shortUrls ? state : assocPath( + (state: ShortUrlsList, { payload }: CreateShortUrlAction) => (!state.shortUrls ? state : assocPath( ['shortUrls', 'data'], - [result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)], + [payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)], state, )), (state: ShortUrlsList) => (!state.shortUrls ? state : assocPath( diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index a19ca9b6..d357e0fb 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -1,4 +1,5 @@ import Bottle from 'bottlejs'; +import { prop } from 'ramda'; import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar'; import { ShortUrlsList } from '../ShortUrlsList'; import { ShortUrlsRow } from '../helpers/ShortUrlsRow'; @@ -7,7 +8,7 @@ import { CreateShortUrl } from '../CreateShortUrl'; import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal'; import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult'; import { listShortUrls } from '../reducers/shortUrlsList'; -import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation'; +import { shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation'; import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion'; import { editShortUrl } from '../reducers/shortUrlEdition'; import { ConnectDecorator } from '../../container/types'; @@ -55,11 +56,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter'); bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer'])); + // Reducers + bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator'); + // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); - bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient'); - bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl); + bottle.serviceFactory('createShortUrl', prop('createShortUrl'), 'shortUrlCreationReducerCreator'); + bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator'); bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl); diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index a31c060d..485b55f4 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -103,9 +103,9 @@ export default buildReducer({ ...state, stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats), }), - [CREATE_SHORT_URL]: ({ tags: stateTags, ...rest }, { result }) => ({ + [`${CREATE_SHORT_URL}/fulfilled`]: ({ tags: stateTags, ...rest }, { payload }) => ({ // TODO Do not hardcode action type here ...rest, - tags: stateTags.concat(result.tags.filter((tag) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ] + tags: stateTags.concat(payload.tags.filter((tag) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ] }), }, initialState); diff --git a/test/short-urls/reducers/shortUrlCreation.test.ts b/test/short-urls/reducers/shortUrlCreation.test.ts index 25128bc4..94b5f02b 100644 --- a/test/short-urls/reducers/shortUrlCreation.test.ts +++ b/test/short-urls/reducers/shortUrlCreation.test.ts @@ -1,19 +1,19 @@ import { Mock } from 'ts-mockery'; -import reducer, { - CREATE_SHORT_URL_START, - CREATE_SHORT_URL_ERROR, - CREATE_SHORT_URL, - RESET_CREATE_SHORT_URL, - createShortUrl, - resetCreateShortUrl, +import { CreateShortUrlAction, + shortUrlCreationReducerCreator, } from '../../../src/short-urls/reducers/shortUrlCreation'; import { ShortUrl } from '../../../src/short-urls/data'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkState } from '../../../src/container/types'; describe('shortUrlCreationReducer', () => { - const shortUrl = Mock.all(); + const shortUrl = Mock.of(); + const createShortUrlCall = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ createShortUrl: createShortUrlCall }); + const { reducer, createShortUrl, resetCreateShortUrl } = shortUrlCreationReducerCreator(buildShlinkApiClient); + + afterEach(jest.resetAllMocks); describe('reducer', () => { const action = (type: string, args: Partial = {}) => Mock.of( @@ -21,7 +21,7 @@ describe('shortUrlCreationReducer', () => { ); it('returns loading on CREATE_SHORT_URL_START', () => { - expect(reducer(undefined, action(CREATE_SHORT_URL_START))).toEqual({ + expect(reducer(undefined, action(createShortUrl.pending.toString()))).toEqual({ result: null, saving: true, error: false, @@ -29,7 +29,7 @@ describe('shortUrlCreationReducer', () => { }); it('returns error on CREATE_SHORT_URL_ERROR', () => { - expect(reducer(undefined, action(CREATE_SHORT_URL_ERROR))).toEqual({ + expect(reducer(undefined, action(createShortUrl.rejected.toString()))).toEqual({ result: null, saving: false, error: true, @@ -37,7 +37,7 @@ describe('shortUrlCreationReducer', () => { }); it('returns result on CREATE_SHORT_URL', () => { - expect(reducer(undefined, action(CREATE_SHORT_URL, { result: shortUrl }))).toEqual({ + expect(reducer(undefined, action(createShortUrl.fulfilled.toString(), { payload: shortUrl }))).toEqual({ result: shortUrl, saving: false, error: false, @@ -45,7 +45,7 @@ describe('shortUrlCreationReducer', () => { }); it('returns default state on RESET_CREATE_SHORT_URL', () => { - expect(reducer(undefined, action(RESET_CREATE_SHORT_URL))).toEqual({ + expect(reducer(undefined, action(resetCreateShortUrl.toString()))).toEqual({ result: null, saving: false, error: false, @@ -54,47 +54,43 @@ describe('shortUrlCreationReducer', () => { }); describe('resetCreateShortUrl', () => { - it('returns proper action', () => expect(resetCreateShortUrl()).toEqual({ type: RESET_CREATE_SHORT_URL })); + it('returns proper action', () => expect(resetCreateShortUrl()).toEqual({ type: resetCreateShortUrl.toString() })); }); describe('createShortUrl', () => { - const createApiClientMock = (result: Promise) => Mock.of({ - createShortUrl: jest.fn().mockReturnValue(result), - }); const dispatch = jest.fn(); const getState = () => Mock.all(); - afterEach(jest.resetAllMocks); - it('calls API on success', async () => { - const apiClientMock = createApiClientMock(Promise.resolve(shortUrl)); - const dispatchable = createShortUrl(() => apiClientMock)({ longUrl: 'foo' }); + createShortUrlCall.mockResolvedValue(shortUrl); + await createShortUrl({ longUrl: 'foo' })(dispatch, getState, {}); - await dispatchable(dispatch, getState); - - expect(apiClientMock.createShortUrl).toHaveBeenCalledTimes(1); + expect(createShortUrlCall).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: CREATE_SHORT_URL_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: CREATE_SHORT_URL, result: shortUrl }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: createShortUrl.pending.toString(), + })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: createShortUrl.fulfilled.toString(), + payload: shortUrl, + })); }); it('throws on error', async () => { - const error = 'Error'; - const apiClientMock = createApiClientMock(Promise.reject(error)); - const dispatchable = createShortUrl(() => apiClientMock)({ longUrl: 'foo' }); + const error = new Error('Error message'); + createShortUrlCall.mockRejectedValue(error); - expect.assertions(5); + await createShortUrl({ longUrl: 'foo' })(dispatch, getState, {}); - try { - await dispatchable(dispatch, getState); - } catch (e) { - expect(e).toEqual(error); - } - - expect(apiClientMock.createShortUrl).toHaveBeenCalledTimes(1); + expect(createShortUrlCall).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: CREATE_SHORT_URL_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: CREATE_SHORT_URL_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: createShortUrl.pending.toString(), + })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: createShortUrl.rejected.toString(), + error: expect.objectContaining({ message: 'Error message' }), + })); }); }); }); diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index a4648121..54349fb5 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -142,7 +142,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: CREATE_SHORT_URL, result: newShortUrl } as any)).toEqual({ + expect(reducer(state, { type: `${CREATE_SHORT_URL}/fulfilled`, payload: newShortUrl } as any)).toEqual({ shortUrls: { data: expectedData, pagination: { totalItems: 16 }, diff --git a/test/tags/reducers/tagsList.test.ts b/test/tags/reducers/tagsList.test.ts index 767cdc3d..277d3c28 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/test/tags/reducers/tagsList.test.ts @@ -83,9 +83,9 @@ describe('tagsListReducer', () => { [['new', 'tag'], ['foo', 'bar', 'baz', 'foo2', 'fo', 'new', 'tag']], ])('appends new short URL\'s tags to the list of tags on CREATE_SHORT_URL', (shortUrlTags, expectedTags) => { const tags = ['foo', 'bar', 'baz', 'foo2', 'fo']; - const result = Mock.of({ tags: shortUrlTags }); + const payload = Mock.of({ tags: shortUrlTags }); - expect(reducer(state({ tags }), { type: CREATE_SHORT_URL, result } as any)).toEqual({ + expect(reducer(state({ tags }), { type: `${CREATE_SHORT_URL}/fulfilled`, payload } as any)).toEqual({ tags: expectedTags, }); });