diff --git a/src/reducers/index.ts b/src/reducers/index.ts index dbbeef32..b91d1f9b 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -9,7 +9,6 @@ import domainVisitsReducer from '../visits/reducers/domainVisits'; 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 { settingsReducer } from '../settings/reducers/settings'; import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; @@ -30,7 +29,7 @@ export default (container: IContainer) => combineReducers({ orphanVisits: orphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer, tagsList: tagsListReducer, - tagDelete: tagDeleteReducer, + tagDelete: container.tagDeleteReducer, tagEdit: container.tagEditReducer, mercureInfo: container.mercureInfoReducer, settings: settingsReducer, diff --git a/src/tags/reducers/tagDelete.ts b/src/tags/reducers/tagDelete.ts index ec5e3ebe..bb7039bf 100644 --- a/src/tags/reducers/tagDelete.ts +++ b/src/tags/reducers/tagDelete.ts @@ -1,16 +1,11 @@ -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 { 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_TAG_START = 'shlink/deleteTag/DELETE_TAG_START'; -export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR'; -export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG'; -export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED'; +const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG'; +const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED'; export interface TagDeletion { deleting: boolean; @@ -27,27 +22,27 @@ const initialState: TagDeletion = { error: false, }; -export default buildReducer({ - [DELETE_TAG_START]: () => ({ deleting: true, deleted: false, error: false }), - [DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, deleted: false, error: true, errorData }), - [DELETE_TAG]: () => ({ deleting: false, deleted: true, error: false }), -}, initialState); - -export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag: string) => async ( - dispatch: Dispatch, - getState: GetState, -) => { - dispatch({ type: DELETE_TAG_START }); - const { deleteTags } = buildShlinkApiClient(getState); - - try { - await deleteTags([tag]); - dispatch({ type: DELETE_TAG }); - } catch (e: any) { - dispatch({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) }); - - throw e; - } -}; - export const tagDeleted = createAction(TAG_DELETED); + +export const tagDeleteReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { + const deleteTag = createAsyncThunk(DELETE_TAG, async (tag: string, { getState }): Promise => { + const { deleteTags } = buildShlinkApiClient(getState); + await deleteTags([tag]); + }); + + const { reducer } = createSlice({ + name: 'tagDeleteReducer', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(deleteTag.pending, () => ({ deleting: true, deleted: false, error: false })); + builder.addCase( + deleteTag.rejected, + (_, { error }) => ({ deleting: false, deleted: false, error: true, errorData: parseApiError(error) }), + ); + builder.addCase(deleteTag.fulfilled, () => ({ deleting: false, deleted: true, error: false })); + }, + }); + + return { reducer, deleteTag }; +}; diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index c4b25da7..5c60ab58 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -10,7 +10,7 @@ import { parseApiError } from '../../api/utils'; import { TagStats } from '../data'; import { ApiErrorAction } from '../../api/types/actions'; import { CREATE_SHORT_URL, CreateShortUrlAction } from '../../short-urls/reducers/shortUrlCreation'; -import { DeleteTagAction, TAG_DELETED } from './tagDelete'; +import { DeleteTagAction, tagDeleted } from './tagDelete'; import { EditTagAction, tagEdited } from './tagEdit'; import { ProblemDetailsError } from '../../api/types/errors'; @@ -85,7 +85,7 @@ export default buildReducer({ [LIST_TAGS_START]: () => ({ ...initialState, loading: true }), [LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }), - [TAG_DELETED]: (state, { payload: tag }) => ({ + [tagDeleted.toString()]: (state, { payload: tag }) => ({ ...state, tags: rejectTag(state.tags, tag), filteredTags: rejectTag(state.filteredTags, tag), diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index 04f95289..e73a8e2d 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -6,7 +6,7 @@ import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal'; import { EditTagModal } from '../helpers/EditTagModal'; import { TagsList } from '../TagsList'; import { filterTags, listTags } from '../reducers/tagsList'; -import { deleteTag, tagDeleted } from '../reducers/tagDelete'; +import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete'; import { tagEdited, tagEditReducerCreator } from '../reducers/tagEdit'; import { ConnectDecorator } from '../../container/types'; import { TagsCards } from '../TagsCards'; @@ -41,6 +41,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('tagEditReducerCreator', tagEditReducerCreator, 'buildShlinkApiClient', 'ColorGenerator'); bottle.serviceFactory('tagEditReducer', prop('reducer'), 'tagEditReducerCreator'); + bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator'); + // Actions const listTagsActionFactory = (force: boolean) => ({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force); @@ -49,7 +52,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.factory('forceListTags', listTagsActionFactory(true)); bottle.serviceFactory('filterTags', () => filterTags); - bottle.serviceFactory('deleteTag', deleteTag, 'buildShlinkApiClient'); + bottle.serviceFactory('deleteTag', prop('deleteTag'), 'tagDeleteReducerCreator'); bottle.serviceFactory('tagDeleted', () => tagDeleted); bottle.serviceFactory('editTag', prop('editTag'), 'tagEditReducerCreator'); diff --git a/test/tags/reducers/tagDelete.test.ts b/test/tags/reducers/tagDelete.test.ts index c87e2bfd..3b4bf655 100644 --- a/test/tags/reducers/tagDelete.test.ts +++ b/test/tags/reducers/tagDelete.test.ts @@ -1,19 +1,18 @@ import { Mock } from 'ts-mockery'; -import reducer, { - DELETE_TAG_START, - DELETE_TAG_ERROR, - DELETE_TAG, - TAG_DELETED, - tagDeleted, - deleteTag, -} from '../../../src/tags/reducers/tagDelete'; +import { tagDeleted, tagDeleteReducerCreator } from '../../../src/tags/reducers/tagDelete'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkState } from '../../../src/container/types'; describe('tagDeleteReducer', () => { + const deleteTagsCall = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ deleteTags: deleteTagsCall }); + const { reducer, deleteTag } = tagDeleteReducerCreator(buildShlinkApiClient); + + beforeEach(jest.clearAllMocks); + describe('reducer', () => { it('returns loading on DELETE_TAG_START', () => { - expect(reducer(undefined, { type: DELETE_TAG_START })).toEqual({ + expect(reducer(undefined, { type: deleteTag.pending.toString() })).toEqual({ deleting: true, deleted: false, error: false, @@ -21,7 +20,7 @@ describe('tagDeleteReducer', () => { }); it('returns error on DELETE_TAG_ERROR', () => { - expect(reducer(undefined, { type: DELETE_TAG_ERROR })).toEqual({ + expect(reducer(undefined, { type: deleteTag.rejected.toString() })).toEqual({ deleting: false, deleted: false, error: true, @@ -29,7 +28,7 @@ describe('tagDeleteReducer', () => { }); it('returns tag names on DELETE_TAG', () => { - expect(reducer(undefined, { type: DELETE_TAG })).toEqual({ + expect(reducer(undefined, { type: deleteTag.fulfilled.toString() })).toEqual({ deleting: false, deleted: true, error: false, @@ -40,53 +39,46 @@ describe('tagDeleteReducer', () => { describe('tagDeleted', () => { it('returns action based on provided params', () => expect(tagDeleted('foo')).toEqual({ - type: TAG_DELETED, + type: tagDeleted.toString(), payload: 'foo', })); }); describe('deleteTag', () => { - const createApiClientMock = (result: Promise) => Mock.of({ - deleteTags: jest.fn(async () => result), - }); const dispatch = jest.fn(); const getState = () => Mock.all(); - afterEach(() => dispatch.mockReset()); - it('calls API on success', async () => { const tag = 'foo'; - const apiClientMock = createApiClientMock(Promise.resolve()); - const dispatchable = deleteTag(() => apiClientMock)(tag); + deleteTagsCall.mockResolvedValue(undefined); - await dispatchable(dispatch, getState); + await deleteTag(tag)(dispatch, getState, {}); - expect(apiClientMock.deleteTags).toHaveBeenCalledTimes(1); - expect(apiClientMock.deleteTags).toHaveBeenNthCalledWith(1, [tag]); + expect(deleteTagsCall).toHaveBeenCalledTimes(1); + expect(deleteTagsCall).toHaveBeenNthCalledWith(1, [tag]); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_TAG_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_TAG }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: deleteTag.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: deleteTag.fulfilled.toString() })); }); it('throws on error', async () => { const error = 'Error'; const tag = 'foo'; - const apiClientMock = createApiClientMock(Promise.reject(error)); - const dispatchable = deleteTag(() => apiClientMock)(tag); + deleteTagsCall.mockRejectedValue(error); try { - await dispatchable(dispatch, getState); + await deleteTag(tag)(dispatch, getState, {}); } catch (e) { expect(e).toEqual(error); } - expect(apiClientMock.deleteTags).toHaveBeenCalledTimes(1); - expect(apiClientMock.deleteTags).toHaveBeenNthCalledWith(1, [tag]); + expect(deleteTagsCall).toHaveBeenCalledTimes(1); + expect(deleteTagsCall).toHaveBeenNthCalledWith(1, [tag]); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: DELETE_TAG_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: DELETE_TAG_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: deleteTag.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: deleteTag.rejected.toString() })); }); }); }); diff --git a/test/tags/reducers/tagsList.test.ts b/test/tags/reducers/tagsList.test.ts index 1fcb390e..12ed7cb5 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/test/tags/reducers/tagsList.test.ts @@ -8,11 +8,11 @@ import reducer, { listTags, TagsList, } from '../../../src/tags/reducers/tagsList'; -import { TAG_DELETED } from '../../../src/tags/reducers/tagDelete'; 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'; +import { tagDeleted } from '../../../src/tags/reducers/tagDelete'; describe('tagsListReducer', () => { const state = (props: Partial) => Mock.of(props); @@ -48,7 +48,10 @@ describe('tagsListReducer', () => { const tag = 'foo'; const expectedTags = ['bar', 'baz']; - expect(reducer(state({ tags, filteredTags: tags }), { type: TAG_DELETED, payload: tag } as any)).toEqual({ + expect(reducer( + state({ tags, filteredTags: tags }), + { type: tagDeleted.toString(), payload: tag } as any, + )).toEqual({ tags: expectedTags, filteredTags: expectedTags, });