diff --git a/src/reducers/index.ts b/src/reducers/index.ts index b91d1f9b..2053ebc2 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -8,7 +8,6 @@ import tagVisitsReducer from '../visits/reducers/tagVisits'; 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 { settingsReducer } from '../settings/reducers/settings'; import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; @@ -28,7 +27,7 @@ export default (container: IContainer) => combineReducers({ domainVisits: domainVisitsReducer, orphanVisits: orphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer, - tagsList: tagsListReducer, + tagsList: container.tagsListReducer, tagDelete: container.tagDeleteReducer, tagEdit: container.tagEditReducer, mercureInfo: container.mercureInfoReducer, diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index c7d9570c..59d1a2d6 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -1,24 +1,19 @@ -import { createAction, PayloadAction } from '@reduxjs/toolkit'; +import { createAction, createSlice } from '@reduxjs/toolkit'; import { isEmpty, reject } from 'ramda'; -import { Action, Dispatch } from 'redux'; -import { createNewVisits, CreateVisitsAction } from '../../visits/reducers/visitCreation'; -import { buildReducer } from '../../utils/helpers/redux'; +import { createNewVisits } from '../../visits/reducers/visitCreation'; +import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkTags } from '../../api/types'; -import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { CreateVisit, Stats } from '../../visits/types'; 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, tagDeleted } from './tagDelete'; -import { EditTagAction, tagEdited } from './tagEdit'; +import { CREATE_SHORT_URL } from '../../short-urls/reducers/shortUrlCreation'; +import { tagDeleted } from './tagDelete'; +import { tagEdited } from './tagEdit'; import { ProblemDetailsError } from '../../api/types/errors'; -export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START'; -export const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR'; -export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS'; -export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS'; +const LIST_TAGS = 'shlink/tagsList/LIST_TAGS'; +const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS'; type TagsStatsMap = Record; @@ -31,22 +26,12 @@ export interface TagsList { errorData?: ProblemDetailsError; } -interface ListTagsAction extends Action { +interface ListTags { tags: string[]; stats: TagsStatsMap; } -type FilterTagsAction = PayloadAction; - -type TagsCombinedAction = ListTagsAction -& DeleteTagAction -& CreateVisitsAction -& CreateShortUrlAction -& EditTagAction -& FilterTagsAction -& ApiErrorAction; - -const initialState = { +const initialState: TagsList = { tags: [], filteredTags: [], stats: {}, @@ -80,47 +65,15 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O }, {}), ); -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 }), - [tagDeleted.toString()]: (state, { payload: tag }) => ({ - ...state, - tags: rejectTag(state.tags, tag), - filteredTags: rejectTag(state.filteredTags, tag), - }), - [tagEdited.toString()]: (state, { payload }) => ({ - ...state, - tags: state.tags.map(renameTag(payload.oldName, payload.newName)).sort(), - filteredTags: state.filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(), - }), - [FILTER_TAGS]: (state, { payload: searchTerm }) => ({ - ...state, - filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())), - }), - [createNewVisits.toString()]: (state, { payload }) => ({ - ...state, - stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats), - }), - [`${CREATE_SHORT_URL}/fulfilled`]: ({ tags: stateTags, ...rest }, { payload }) => ({ // TODO Do not hardcode action type here - ...rest, - tags: stateTags.concat(payload.tags.filter((tag) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ] - }), -}, initialState); +export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => createAsyncThunk( + LIST_TAGS, + async (_: void, { getState }): Promise => { + const { tagsList } = getState(); -export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => () => async ( - dispatch: Dispatch, - getState: GetState, -) => { - const { tagsList } = getState(); + if (!force && !isEmpty(tagsList.tags)) { + return tagsList; + } - if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) { - return; - } - - dispatch({ type: LIST_TAGS_START }); - - try { const { listTags: shlinkListTags } = buildShlinkApiClient(getState); const { tags, stats = [] }: ShlinkTags = await shlinkListTags(); const processedStats = stats.reduce((acc, { tag, shortUrlsCount, visitsCount }) => { @@ -129,10 +82,49 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t return acc; }, {}); - dispatch({ tags, stats: processedStats, type: LIST_TAGS }); - } catch (e: any) { - dispatch({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) }); - } -}; + return { tags, stats: processedStats }; + }, +); export const filterTags = createAction(FILTER_TAGS); + +export const reducer = (listTagsThunk: ReturnType) => createSlice({ + name: 'shlink/tagsList', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(filterTags, (state, { payload: searchTerm }) => ({ + ...state, + filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())), + })); + + builder.addCase(listTagsThunk.pending, (state) => ({ ...state, loading: true, error: false })); + builder.addCase(listTagsThunk.rejected, (_, { error }) => ( + { ...initialState, error: true, errorData: parseApiError(error) } + )); + builder.addCase(listTagsThunk.fulfilled, (_, { payload }) => ( + { ...initialState, stats: payload.stats, tags: payload.tags, filteredTags: payload.tags } + )); + + builder.addCase(tagDeleted, (state, { payload: tag }) => ({ + ...state, + tags: rejectTag(state.tags, tag), + filteredTags: rejectTag(state.filteredTags, tag), + })); + builder.addCase(tagEdited, (state, { payload }) => ({ + ...state, + tags: state.tags.map(renameTag(payload.oldName, payload.newName)).sort(), + filteredTags: state.filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(), + })); + builder.addCase(createNewVisits, (state, { payload }) => ({ + ...state, + stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats), + })); + + // TODO Do not hardcode action type here. Inject async thunk instead + builder.addCase(`${CREATE_SHORT_URL}/fulfilled`, ({ tags: stateTags, ...rest }, { payload }: any) => ({ + ...rest, + tags: stateTags.concat(payload.tags.filter((tag: string) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ] + })); + }, +}).reducer; diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index e73a8e2d..0661288c 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -5,7 +5,7 @@ import { TagCard } from '../TagCard'; import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal'; import { EditTagModal } from '../helpers/EditTagModal'; import { TagsList } from '../TagsList'; -import { filterTags, listTags } from '../reducers/tagsList'; +import { filterTags, listTags, reducer } from '../reducers/tagsList'; import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete'; import { tagEdited, tagEditReducerCreator } from '../reducers/tagEdit'; import { ConnectDecorator } from '../../container/types'; @@ -44,6 +44,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator'); + bottle.serviceFactory('tagsListReducer', reducer, 'listTags'); + // Actions const listTagsActionFactory = (force: boolean) => ({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force); diff --git a/test/tags/reducers/tagsList.test.ts b/test/tags/reducers/tagsList.test.ts index 91d8936e..4cc6ebb9 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/test/tags/reducers/tagsList.test.ts @@ -1,12 +1,9 @@ import { Mock } from 'ts-mockery'; -import reducer, { - FILTER_TAGS, - filterTags, - LIST_TAGS, - LIST_TAGS_ERROR, - LIST_TAGS_START, - listTags, +import { TagsList, + filterTags, + listTags as listTagsCreator, + reducer as reducerCreator, } from '../../../src/tags/reducers/tagsList'; import { ShlinkState } from '../../../src/container/types'; import { ShortUrl } from '../../../src/short-urls/data'; @@ -16,17 +13,22 @@ import { tagDeleted } from '../../../src/tags/reducers/tagDelete'; describe('tagsListReducer', () => { const state = (props: Partial) => Mock.of(props); + const buildShlinkApiClient = jest.fn(); + const listTags = listTagsCreator(buildShlinkApiClient, true); + const reducer = reducerCreator(listTags); + + afterEach(jest.clearAllMocks); describe('reducer', () => { it('returns loading on LIST_TAGS_START', () => { - expect(reducer(undefined, { type: LIST_TAGS_START } as any)).toEqual(expect.objectContaining({ + expect(reducer(undefined, { type: listTags.pending.toString() })).toEqual(expect.objectContaining({ loading: true, error: false, })); }); it('returns error on LIST_TAGS_ERROR', () => { - expect(reducer(undefined, { type: LIST_TAGS_ERROR } as any)).toEqual(expect.objectContaining({ + expect(reducer(undefined, { type: listTags.rejected.toString() })).toEqual(expect.objectContaining({ loading: false, error: true, })); @@ -35,7 +37,10 @@ describe('tagsListReducer', () => { it('returns provided tags as filtered and regular tags on LIST_TAGS', () => { const tags = ['foo', 'bar', 'baz']; - expect(reducer(undefined, { type: LIST_TAGS, tags } as any)).toEqual({ + expect(reducer(undefined, { + type: listTags.fulfilled.toString(), + payload: { tags }, + })).toEqual({ tags, filteredTags: tags, loading: false, @@ -50,7 +55,7 @@ describe('tagsListReducer', () => { expect(reducer( state({ tags, filteredTags: tags }), - { type: tagDeleted.toString(), payload: tag } as any, + { type: tagDeleted.toString(), payload: tag }, )).toEqual({ tags: expectedTags, filteredTags: expectedTags, @@ -68,7 +73,7 @@ describe('tagsListReducer', () => { { type: tagEdited.toString(), payload: { oldName, newName }, - } as any, + }, )).toEqual({ tags: expectedTags, filteredTags: expectedTags, @@ -80,7 +85,7 @@ describe('tagsListReducer', () => { const payload = 'Fo'; const filteredTags = ['foo', 'Foo2', 'fo']; - expect(reducer(state({ tags }), { type: FILTER_TAGS, payload } as any)).toEqual({ + expect(reducer(state({ tags }), { type: filterTags.toString(), payload })).toEqual({ tags, filteredTags, }); @@ -94,31 +99,28 @@ describe('tagsListReducer', () => { const tags = ['foo', 'bar', 'baz', 'foo2', 'fo']; const payload = Mock.of({ tags: shortUrlTags }); - expect(reducer(state({ tags }), { type: `${CREATE_SHORT_URL}/fulfilled`, payload } as any)).toEqual({ + expect(reducer(state({ tags }), { type: `${CREATE_SHORT_URL}/fulfilled`, payload })).toEqual({ tags: expectedTags, }); }); }); describe('filterTags', () => { - it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: FILTER_TAGS, payload: 'foo' })); + it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: filterTags.toString(), payload: 'foo' })); }); describe('listTags', () => { const dispatch = jest.fn(); const getState = jest.fn(() => Mock.all()); - const buildShlinkApiClient = jest.fn(); const listTagsMock = jest.fn(); - afterEach(jest.clearAllMocks); - const assertNoAction = async (tagsList: TagsList) => { getState.mockReturnValue(Mock.of({ tagsList })); - await listTags(buildShlinkApiClient, false)()(dispatch, getState); + await listTagsCreator(buildShlinkApiClient, false)()(dispatch, getState, {}); expect(buildShlinkApiClient).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(2); expect(getState).toHaveBeenCalledTimes(1); }; @@ -134,23 +136,26 @@ describe('tagsListReducer', () => { listTagsMock.mockResolvedValue({ tags, stats: [] }); buildShlinkApiClient.mockReturnValue({ listTags: listTagsMock }); - await listTags(buildShlinkApiClient, true)()(dispatch, getState); + await listTags()(dispatch, getState, {}); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(getState).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_TAGS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_TAGS, tags, stats: {} }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: listTags.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: listTags.fulfilled.toString(), + payload: { tags, stats: {} }, + })); }); const assertErrorResult = async () => { - await listTags(buildShlinkApiClient, true)()(dispatch, getState); + await listTags()(dispatch, getState, {}); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(getState).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_TAGS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_TAGS_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: listTags.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: listTags.rejected.toString() })); }; it('dispatches error when error occurs on list call', async () => { @@ -168,7 +173,6 @@ describe('tagsListReducer', () => { }); await assertErrorResult(); - expect(listTagsMock).not.toHaveBeenCalled(); }); });