diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 9c412690..dbbeef32 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -10,7 +10,6 @@ import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; -import tagEditReducer from '../tags/reducers/tagEdit'; import { settingsReducer } from '../settings/reducers/settings'; import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; @@ -32,7 +31,7 @@ export default (container: IContainer) => combineReducers({ nonOrphanVisits: nonOrphanVisitsReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, - tagEdit: tagEditReducer, + tagEdit: container.tagEditReducer, mercureInfo: container.mercureInfoReducer, settings: settingsReducer, domainsList: container.domainsListReducer, diff --git a/src/tags/reducers/tagEdit.ts b/src/tags/reducers/tagEdit.ts index b8e9e6ea..087a8497 100644 --- a/src/tags/reducers/tagEdit.ts +++ b/src/tags/reducers/tagEdit.ts @@ -1,18 +1,13 @@ import { pick } from 'ramda'; -import { createAction, PayloadAction } from '@reduxjs/toolkit'; -import { Dispatch } from 'redux'; -import { buildReducer } from '../../utils/helpers/redux'; -import { GetState } from '../../container/types'; +import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk } from '../../utils/helpers/redux'; import { ColorGenerator } from '../../utils/services/ColorGenerator'; 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 EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START'; -export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR'; -export const EDIT_TAG = 'shlink/editTag/EDIT_TAG'; -export const TAG_EDITED = 'shlink/editTag/TAG_EDITED'; +const EDIT_TAG = 'shlink/editTag/EDIT_TAG'; +const TAG_EDITED = 'shlink/editTag/TAG_EDITED'; export interface TagEdition { oldName?: string; @@ -37,35 +32,37 @@ const initialState: TagEdition = { error: false, }; -export default buildReducer({ - [EDIT_TAG_START]: () => ({ editing: true, edited: false, error: false }), - [EDIT_TAG_ERROR]: (_, { errorData }) => ({ editing: false, edited: false, error: true, errorData }), - [EDIT_TAG]: (_, { payload }) => ({ - ...pick(['oldName', 'newName'], payload), - editing: false, - edited: true, - error: false, - }), -}, initialState); - -export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGenerator: ColorGenerator) => ( - { oldName, newName, color }: EditTag, -) => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: EDIT_TAG_START }); - const { editTag: shlinkEditTag } = buildShlinkApiClient(getState); - - try { - await shlinkEditTag(oldName, newName); - colorGenerator.setColorForKey(newName, color); - dispatch({ - type: EDIT_TAG, - payload: { oldName, newName, color }, - }); - } catch (e: any) { - dispatch({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) }); - - throw e; - } -}; - export const tagEdited = createAction(TAG_EDITED); + +export const tagEditReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGenerator: ColorGenerator) => { + const editTag = createAsyncThunk( + EDIT_TAG, + async ({ oldName, newName, color }: EditTag, { getState }): Promise => { + await buildShlinkApiClient(getState).editTag(oldName, newName); + colorGenerator.setColorForKey(newName, color); + + return { oldName, newName, color }; + }, + ); + + const { reducer } = createSlice({ + name: 'tagEditReducer', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(editTag.pending, () => ({ editing: true, edited: false, error: false })); + builder.addCase( + editTag.rejected, + (_, { error }) => ({ editing: false, edited: false, error: true, errorData: parseApiError(error) }), + ); + builder.addCase(editTag.fulfilled, (_, { payload }) => ({ + ...pick(['oldName', 'newName'], payload), + editing: false, + edited: true, + error: false, + })); + }, + }); + + return { reducer, editTag }; +}; diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index e7d58453..04f95289 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -1,3 +1,4 @@ +import { prop } from 'ramda'; import Bottle, { IContainer } from 'bottlejs'; import { TagsSelector } from '../helpers/TagsSelector'; import { TagCard } from '../TagCard'; @@ -6,7 +7,7 @@ import { EditTagModal } from '../helpers/EditTagModal'; import { TagsList } from '../TagsList'; import { filterTags, listTags } from '../reducers/tagsList'; import { deleteTag, tagDeleted } from '../reducers/tagDelete'; -import { editTag, tagEdited } from '../reducers/tagEdit'; +import { tagEdited, tagEditReducerCreator } from '../reducers/tagEdit'; import { ConnectDecorator } from '../../container/types'; import { TagsCards } from '../TagsCards'; import { TagsTable } from '../TagsTable'; @@ -36,6 +37,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { ['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'], )); + // Reducers + bottle.serviceFactory('tagEditReducerCreator', tagEditReducerCreator, 'buildShlinkApiClient', 'ColorGenerator'); + bottle.serviceFactory('tagEditReducer', prop('reducer'), 'tagEditReducerCreator'); + // Actions const listTagsActionFactory = (force: boolean) => ({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force); @@ -43,11 +48,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.factory('listTags', listTagsActionFactory(false)); bottle.factory('forceListTags', listTagsActionFactory(true)); bottle.serviceFactory('filterTags', () => filterTags); - bottle.serviceFactory('tagDeleted', () => tagDeleted); - bottle.serviceFactory('tagEdited', () => tagEdited); bottle.serviceFactory('deleteTag', deleteTag, 'buildShlinkApiClient'); - bottle.serviceFactory('editTag', editTag, 'buildShlinkApiClient', 'ColorGenerator'); + bottle.serviceFactory('tagDeleted', () => tagDeleted); + + bottle.serviceFactory('editTag', prop('editTag'), 'tagEditReducerCreator'); + bottle.serviceFactory('tagEdited', () => tagEdited); }; export default provideServices; diff --git a/test/tags/reducers/tagEdit.test.ts b/test/tags/reducers/tagEdit.test.ts index c7707480..7f496dd1 100644 --- a/test/tags/reducers/tagEdit.test.ts +++ b/test/tags/reducers/tagEdit.test.ts @@ -1,13 +1,5 @@ import { Mock } from 'ts-mockery'; -import reducer, { - EDIT_TAG_START, - EDIT_TAG_ERROR, - EDIT_TAG, - TAG_EDITED, - tagEdited, - editTag, - EditTagAction, -} from '../../../src/tags/reducers/tagEdit'; +import { tagEdited, EditTagAction, tagEditReducerCreator } from '../../../src/tags/reducers/tagEdit'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ColorGenerator } from '../../../src/utils/services/ColorGenerator'; import { ShlinkState } from '../../../src/container/types'; @@ -16,10 +8,14 @@ describe('tagEditReducer', () => { const oldName = 'foo'; const newName = 'bar'; const color = '#ff0000'; + const editTagCall = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ editTag: editTagCall }); + const colorGenerator = Mock.of({ setColorForKey: jest.fn() }); + const { reducer, editTag } = tagEditReducerCreator(buildShlinkApiClient, colorGenerator); describe('reducer', () => { it('returns loading on EDIT_TAG_START', () => { - expect(reducer(undefined, Mock.of({ type: EDIT_TAG_START }))).toEqual({ + expect(reducer(undefined, Mock.of({ type: editTag.pending.toString() }))).toEqual({ editing: true, edited: false, error: false, @@ -27,7 +23,7 @@ describe('tagEditReducer', () => { }); it('returns error on EDIT_TAG_ERROR', () => { - expect(reducer(undefined, Mock.of({ type: EDIT_TAG_ERROR }))).toEqual({ + expect(reducer(undefined, Mock.of({ type: editTag.rejected.toString() }))).toEqual({ editing: false, edited: false, error: true, @@ -36,7 +32,7 @@ describe('tagEditReducer', () => { it('returns tag names on EDIT_TAG', () => { expect(reducer(undefined, { - type: EDIT_TAG, + type: editTag.fulfilled.toString(), payload: { oldName, newName, color }, })).toEqual({ editing: false, @@ -51,7 +47,7 @@ describe('tagEditReducer', () => { describe('tagEdited', () => { it('returns action based on provided params', () => expect(tagEdited({ oldName: 'foo', newName: 'bar', color: '#ff0000' })).toEqual({ - type: TAG_EDITED, + type: tagEdited.toString(), payload: { oldName: 'foo', newName: 'bar', @@ -61,56 +57,48 @@ describe('tagEditReducer', () => { }); describe('editTag', () => { - const createApiClientMock = (result: Promise) => Mock.of({ - editTag: jest.fn(async () => result), - }); - const colorGenerator = Mock.of({ - setColorForKey: jest.fn(), - }); const dispatch = jest.fn(); const getState = () => Mock.of(); afterEach(jest.clearAllMocks); it('calls API on success', async () => { - const apiClientMock = createApiClientMock(Promise.resolve()); - const dispatchable = editTag(() => apiClientMock, colorGenerator)({ oldName, newName, color }); + editTagCall.mockResolvedValue(undefined); - await dispatchable(dispatch, getState); + await editTag({ oldName, newName, color })(dispatch, getState, {}); - expect(apiClientMock.editTag).toHaveBeenCalledTimes(1); - expect(apiClientMock.editTag).toHaveBeenCalledWith(oldName, newName); + expect(editTagCall).toHaveBeenCalledTimes(1); + expect(editTagCall).toHaveBeenCalledWith(oldName, newName); expect(colorGenerator.setColorForKey).toHaveBeenCalledTimes(1); expect(colorGenerator.setColorForKey).toHaveBeenCalledWith(newName, color); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_TAG_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { - type: EDIT_TAG, + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: editTag.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: editTag.fulfilled.toString(), payload: { oldName, newName, color }, - }); + })); }); it('throws on error', async () => { const error = 'Error'; - const apiClientMock = createApiClientMock(Promise.reject(error)); - const dispatchable = editTag(() => apiClientMock, colorGenerator)({ oldName, newName, color }); + editTagCall.mockRejectedValue(error); try { - await dispatchable(dispatch, getState); + await editTag({ oldName, newName, color })(dispatch, getState, {}); } catch (e) { expect(e).toEqual(error); } - expect(apiClientMock.editTag).toHaveBeenCalledTimes(1); - expect(apiClientMock.editTag).toHaveBeenCalledWith(oldName, newName); + expect(editTagCall).toHaveBeenCalledTimes(1); + expect(editTagCall).toHaveBeenCalledWith(oldName, newName); expect(colorGenerator.setColorForKey).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_TAG_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_TAG_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: editTag.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: editTag.rejected.toString() })); }); }); }); diff --git a/test/tags/reducers/tagsList.test.ts b/test/tags/reducers/tagsList.test.ts index 5a40dd54..bce325b0 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/test/tags/reducers/tagsList.test.ts @@ -9,10 +9,10 @@ import reducer, { TagsList, } from '../../../src/tags/reducers/tagsList'; import { TAG_DELETED } from '../../../src/tags/reducers/tagDelete'; -import { TAG_EDITED } from '../../../src/tags/reducers/tagEdit'; import { ShlinkState } from '../../../src/container/types'; import { ShortUrl } from '../../../src/short-urls/data'; import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation'; +import { tagEdited } from '../../../src/tags/reducers/tagEdit'; describe('tagsListReducer', () => { const state = (props: Partial) => Mock.of(props); @@ -63,7 +63,7 @@ describe('tagsListReducer', () => { expect(reducer( state({ tags, filteredTags: tags }), { - type: TAG_EDITED, + type: tagEdited.toString(), payload: { oldName, newName }, } as any, )).toEqual({