From b7622b2b383f8edddfa353be6d89bddc90fde018 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 8 Nov 2022 21:59:17 +0100 Subject: [PATCH 1/7] Migrated filterTags action to use payload --- src/tags/reducers/tagsList.ts | 9 ++++----- test/tags/reducers/tagsList.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index 5c60ab58..c7d9570c 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -1,3 +1,4 @@ +import { createAction, PayloadAction } from '@reduxjs/toolkit'; import { isEmpty, reject } from 'ramda'; import { Action, Dispatch } from 'redux'; import { createNewVisits, CreateVisitsAction } from '../../visits/reducers/visitCreation'; @@ -35,9 +36,7 @@ interface ListTagsAction extends Action { stats: TagsStatsMap; } -interface FilterTagsAction extends Action { - searchTerm: string; -} +type FilterTagsAction = PayloadAction; type TagsCombinedAction = ListTagsAction & DeleteTagAction @@ -95,7 +94,7 @@ export default buildReducer({ tags: state.tags.map(renameTag(payload.oldName, payload.newName)).sort(), filteredTags: state.filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(), }), - [FILTER_TAGS]: (state, { searchTerm }) => ({ + [FILTER_TAGS]: (state, { payload: searchTerm }) => ({ ...state, filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())), }), @@ -136,4 +135,4 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t } }; -export const filterTags = (searchTerm: string): FilterTagsAction => ({ type: FILTER_TAGS, searchTerm }); +export const filterTags = createAction(FILTER_TAGS); diff --git a/test/tags/reducers/tagsList.test.ts b/test/tags/reducers/tagsList.test.ts index 12ed7cb5..91d8936e 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/test/tags/reducers/tagsList.test.ts @@ -77,10 +77,10 @@ describe('tagsListReducer', () => { it('filters original list of tags by provided search term on FILTER_TAGS', () => { const tags = ['foo', 'bar', 'baz', 'Foo2', 'fo']; - const searchTerm = 'Fo'; + const payload = 'Fo'; const filteredTags = ['foo', 'Foo2', 'fo']; - expect(reducer(state({ tags }), { type: FILTER_TAGS, searchTerm } as any)).toEqual({ + expect(reducer(state({ tags }), { type: FILTER_TAGS, payload } as any)).toEqual({ tags, filteredTags, }); @@ -101,7 +101,7 @@ describe('tagsListReducer', () => { }); describe('filterTags', () => { - it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: FILTER_TAGS, searchTerm: 'foo' })); + it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: FILTER_TAGS, payload: 'foo' })); }); describe('listTags', () => { From f9bfb742da354f9cdbda2d67e81df311a65840b0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 8 Nov 2022 22:48:53 +0100 Subject: [PATCH 2/7] Migrated tagsList reducer to RTK --- src/reducers/index.ts | 3 +- src/tags/reducers/tagsList.ts | 130 +++++++++++++-------------- src/tags/services/provideServices.ts | 4 +- test/tags/reducers/tagsList.test.ts | 58 ++++++------ 4 files changed, 96 insertions(+), 99 deletions(-) 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(); }); }); From 89423737e82b30951042bca7bc67057baabbf6a6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 8 Nov 2022 22:59:41 +0100 Subject: [PATCH 3/7] Removed hardcoded action references by improving dependency injection --- src/short-urls/reducers/shortUrlCreation.ts | 16 +++++++++------- src/short-urls/services/provideServices.ts | 6 +++--- src/tags/reducers/tagsList.ts | 10 ++++++---- src/tags/services/provideServices.ts | 2 +- .../short-urls/reducers/shortUrlCreation.test.ts | 4 +++- test/tags/reducers/tagsList.test.ts | 7 ++++--- 6 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/src/short-urls/reducers/shortUrlCreation.ts index f30cdcd3..faa76a6e 100644 --- a/src/short-urls/reducers/shortUrlCreation.ts +++ b/src/short-urls/reducers/shortUrlCreation.ts @@ -35,12 +35,15 @@ const initialState: ShortUrlCreation = { error: false, }; -export const shortUrlCreationReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const createShortUrl = createAsyncThunk(CREATE_SHORT_URL, (data: ShortUrlData, { getState }): Promise => { +export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + CREATE_SHORT_URL, + (data: ShortUrlData, { getState }): Promise => { const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState); return shlinkCreateShortUrl(data); - }); + }, +); +export const shortUrlCreationReducerCreator = (createShortUrlThunk: ReturnType) => { const { reducer, actions } = createSlice({ name: 'shortUrlCreationReducer', initialState: initialState as ShortUrlCreation, // Without this casting it infers type ShortUrlCreationWaiting @@ -48,13 +51,13 @@ export const shortUrlCreationReducerCreator = (buildShlinkApiClient: ShlinkApiCl resetCreateShortUrl: () => initialState, }, extraReducers: (builder) => { - builder.addCase(createShortUrl.pending, () => ({ saving: true, saved: false, error: false })); + builder.addCase(createShortUrlThunk.pending, () => ({ saving: true, saved: false, error: false })); builder.addCase( - createShortUrl.rejected, + createShortUrlThunk.rejected, (_, { error }) => ({ saving: false, saved: false, error: true, errorData: parseApiError(error) }), ); builder.addCase( - createShortUrl.fulfilled, + createShortUrlThunk.fulfilled, (_, { payload: result }) => ({ result, saving: false, saved: true, error: false }), ); }, @@ -64,7 +67,6 @@ export const shortUrlCreationReducerCreator = (buildShlinkApiClient: ShlinkApiCl return { reducer, - createShortUrl, resetCreateShortUrl, }; }; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 8feb709b..13b9e2fe 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -8,7 +8,7 @@ import { CreateShortUrl } from '../CreateShortUrl'; import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal'; import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult'; import { listShortUrls } from '../reducers/shortUrlsList'; -import { shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation'; +import { shortUrlCreationReducerCreator, createShortUrl } from '../reducers/shortUrlCreation'; import { shortUrlDeletionReducerCreator } from '../reducers/shortUrlDeletion'; import { shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition'; import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail'; @@ -57,7 +57,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer'])); // Reducers - bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'createShortUrl'); bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator'); bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'buildShlinkApiClient'); @@ -72,7 +72,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); - bottle.serviceFactory('createShortUrl', prop('createShortUrl'), 'shortUrlCreationReducerCreator'); + bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator'); bottle.serviceFactory('deleteShortUrl', prop('deleteShortUrl'), 'shortUrlDeletionReducerCreator'); diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index 59d1a2d6..a6e684b6 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -7,7 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { CreateVisit, Stats } from '../../visits/types'; import { parseApiError } from '../../api/utils'; import { TagStats } from '../data'; -import { CREATE_SHORT_URL } from '../../short-urls/reducers/shortUrlCreation'; +import { createShortUrl } from '../../short-urls/reducers/shortUrlCreation'; import { tagDeleted } from './tagDelete'; import { tagEdited } from './tagEdit'; import { ProblemDetailsError } from '../../api/types/errors'; @@ -88,7 +88,10 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t export const filterTags = createAction(FILTER_TAGS); -export const reducer = (listTagsThunk: ReturnType) => createSlice({ +export const reducer = ( + listTagsThunk: ReturnType, + createShortUrlThunk: ReturnType, +) => createSlice({ name: 'shlink/tagsList', initialState, reducers: {}, @@ -121,8 +124,7 @@ export const reducer = (listTagsThunk: ReturnType) => createSli 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) => ({ + builder.addCase(createShortUrlThunk.fulfilled, ({ tags: stateTags, ...rest }, { payload }) => ({ ...rest, tags: stateTags.concat(payload.tags.filter((tag: string) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ] })); diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index 0661288c..ce1cc554 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -44,7 +44,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator'); - bottle.serviceFactory('tagsListReducer', reducer, 'listTags'); + bottle.serviceFactory('tagsListReducer', reducer, 'listTags', 'createShortUrl'); // Actions const listTagsActionFactory = (force: boolean) => diff --git a/test/short-urls/reducers/shortUrlCreation.test.ts b/test/short-urls/reducers/shortUrlCreation.test.ts index e99e0ea2..f7815b4a 100644 --- a/test/short-urls/reducers/shortUrlCreation.test.ts +++ b/test/short-urls/reducers/shortUrlCreation.test.ts @@ -2,6 +2,7 @@ import { Mock } from 'ts-mockery'; import { CreateShortUrlAction, shortUrlCreationReducerCreator, + createShortUrl as createShortUrlCreator, } from '../../../src/short-urls/reducers/shortUrlCreation'; import { ShortUrl } from '../../../src/short-urls/data'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; @@ -11,7 +12,8 @@ describe('shortUrlCreationReducer', () => { const shortUrl = Mock.of(); const createShortUrlCall = jest.fn(); const buildShlinkApiClient = () => Mock.of({ createShortUrl: createShortUrlCall }); - const { reducer, createShortUrl, resetCreateShortUrl } = shortUrlCreationReducerCreator(buildShlinkApiClient); + const createShortUrl = createShortUrlCreator(buildShlinkApiClient); + const { reducer, resetCreateShortUrl } = shortUrlCreationReducerCreator(createShortUrl); afterEach(jest.resetAllMocks); diff --git a/test/tags/reducers/tagsList.test.ts b/test/tags/reducers/tagsList.test.ts index 4cc6ebb9..06c99828 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/test/tags/reducers/tagsList.test.ts @@ -7,7 +7,7 @@ import { } from '../../../src/tags/reducers/tagsList'; import { ShlinkState } from '../../../src/container/types'; import { ShortUrl } from '../../../src/short-urls/data'; -import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation'; +import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation'; import { tagEdited } from '../../../src/tags/reducers/tagEdit'; import { tagDeleted } from '../../../src/tags/reducers/tagDelete'; @@ -15,7 +15,8 @@ describe('tagsListReducer', () => { const state = (props: Partial) => Mock.of(props); const buildShlinkApiClient = jest.fn(); const listTags = listTagsCreator(buildShlinkApiClient, true); - const reducer = reducerCreator(listTags); + const createShortUrl = createShortUrlCreator(buildShlinkApiClient); + const reducer = reducerCreator(listTags, createShortUrl); afterEach(jest.clearAllMocks); @@ -99,7 +100,7 @@ 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 })).toEqual({ + expect(reducer(state({ tags }), { type: createShortUrl.fulfilled.toString(), payload })).toEqual({ tags: expectedTags, }); }); From fe85291772c812cf7dcbfec9b390f8684be4248d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 9 Nov 2022 18:19:07 +0100 Subject: [PATCH 4/7] Changed format on action types and reducer names for those already migrated to RTK --- src/app/reducers/appUpdates.ts | 2 +- src/common/reducers/sidebar.ts | 2 +- src/domains/reducers/domainsList.ts | 12 +++++------- src/index.scss | 2 +- src/mercure/reducers/mercureInfo.ts | 21 ++++++++++++--------- src/servers/reducers/servers.ts | 2 +- src/settings/reducers/settings.ts | 2 +- src/short-urls/reducers/shortUrlCreation.ts | 5 +++-- src/short-urls/reducers/shortUrlDeletion.ts | 5 +++-- src/short-urls/reducers/shortUrlDetail.ts | 6 +++--- src/short-urls/reducers/shortUrlEdition.ts | 5 +++-- src/tags/reducers/tagDelete.ts | 13 +++++-------- src/tags/reducers/tagEdit.ts | 9 ++++----- src/tags/reducers/tagsList.ts | 9 ++++----- src/{ => utils}/theme/theme.scss | 2 +- 15 files changed, 48 insertions(+), 49 deletions(-) rename src/{ => utils}/theme/theme.scss (98%) diff --git a/src/app/reducers/appUpdates.ts b/src/app/reducers/appUpdates.ts index 7a74d537..675b3959 100644 --- a/src/app/reducers/appUpdates.ts +++ b/src/app/reducers/appUpdates.ts @@ -1,7 +1,7 @@ import { createSlice } from '@reduxjs/toolkit'; const { actions, reducer } = createSlice({ - name: 'appUpdatesReducer', + name: 'shlink/appUpdates', initialState: false, reducers: { appUpdateAvailable: () => true, diff --git a/src/common/reducers/sidebar.ts b/src/common/reducers/sidebar.ts index c9f57a4c..2203ee43 100644 --- a/src/common/reducers/sidebar.ts +++ b/src/common/reducers/sidebar.ts @@ -9,7 +9,7 @@ const initialState: Sidebar = { }; const { actions, reducer } = createSlice({ - name: 'sidebarReducer', + name: 'shlink/sidebar', initialState, reducers: { sidebarPresent: () => ({ sidebarPresent: true }), diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 77b8fe72..f1e622e0 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -9,9 +9,7 @@ import { ProblemDetailsError } from '../../api/types/errors'; import { parseApiError } from '../../api/utils'; import { EditDomainRedirects } from './domainRedirects'; -const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; -const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; -const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; +const REDUCER_PREFIX = 'shlink/domainsList'; export interface DomainsList { domains: Domain[]; @@ -49,7 +47,7 @@ export const domainsListReducerCreator = ( buildShlinkApiClient: ShlinkApiClientBuilder, editDomainRedirects: AsyncThunk, ) => { - const listDomains = createAsyncThunk(LIST_DOMAINS, async (_: void, { getState }): Promise => { + const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise => { const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); const { data, defaultRedirects } = await shlinkListDomains(); @@ -60,7 +58,7 @@ export const domainsListReducerCreator = ( }); const checkDomainHealth = createAsyncThunk( - VALIDATE_DOMAIN, + `${REDUCER_PREFIX}/checkDomainHealth`, async (domain: string, { getState }): Promise => { const { selectedServer } = getState(); @@ -84,10 +82,10 @@ export const domainsListReducerCreator = ( }, ); - const filterDomains = createAction(FILTER_DOMAINS); + const filterDomains = createAction(`${REDUCER_PREFIX}/filterDomains`); const { reducer } = createSlice>({ - name: 'domainsList', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/index.scss b/src/index.scss index f9cb0643..ee9a8abb 100644 --- a/src/index.scss +++ b/src/index.scss @@ -3,7 +3,7 @@ @import './utils/base'; @import 'node_modules/bootstrap/scss/bootstrap.scss'; @import './common/react-tag-autocomplete.scss'; -@import './theme/theme'; +@import 'utils/theme/theme'; @import './utils/table/ResponsiveTable'; @import './utils/StickyCardPaginator'; diff --git a/src/mercure/reducers/mercureInfo.ts b/src/mercure/reducers/mercureInfo.ts index 75806cde..d9ec7974 100644 --- a/src/mercure/reducers/mercureInfo.ts +++ b/src/mercure/reducers/mercureInfo.ts @@ -3,7 +3,7 @@ import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkMercureInfo } from '../../api/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO'; +const REDUCER_PREFIX = 'shlink/mercure'; export interface MercureInfo extends Partial { interval?: number; @@ -17,17 +17,20 @@ const initialState: MercureInfo = { }; export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const loadMercureInfo = createAsyncThunk(GET_MERCURE_INFO, (_: void, { getState }): Promise => { - const { settings } = getState(); - if (!settings.realTimeUpdates.enabled) { - throw new Error('Real time updates not enabled'); - } + const loadMercureInfo = createAsyncThunk( + `${REDUCER_PREFIX}/loadMercureInfo`, + (_: void, { getState }): Promise => { + const { settings } = getState(); + if (!settings.realTimeUpdates.enabled) { + throw new Error('Real time updates not enabled'); + } - return buildShlinkApiClient(getState).mercureInfo(); - }); + return buildShlinkApiClient(getState).mercureInfo(); + }, + ); const { reducer } = createSlice({ - name: 'mercureInfoReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/servers/reducers/servers.ts b/src/servers/reducers/servers.ts index 0d5e7e0f..faa2c9c6 100644 --- a/src/servers/reducers/servers.ts +++ b/src/servers/reducers/servers.ts @@ -26,7 +26,7 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => { const serversListToMap = reduce((acc, server) => assoc(server.id, server, acc), {}); export const { actions, reducer } = createSlice({ - name: 'serversReducer', + name: 'shlink/servers', initialState, reducers: { editServer: { diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index bfc4e2aa..7f66a67c 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -82,7 +82,7 @@ const toReducer = (prepare: SettingsPrepareAction) => ({ reducer: commonReducer, const toPreparedAction: SettingsPrepareAction = (payload: Settings) => ({ payload }); const { reducer, actions } = createSlice({ - name: 'settingsReducer', + name: 'shlink/settings', initialState, reducers: { toggleRealTimeUpdates: toReducer((enabled: boolean) => toPreparedAction({ realTimeUpdates: { enabled } })), diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/src/short-urls/reducers/shortUrlCreation.ts index faa76a6e..f1dbd725 100644 --- a/src/short-urls/reducers/shortUrlCreation.ts +++ b/src/short-urls/reducers/shortUrlCreation.ts @@ -5,7 +5,8 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL'; +const REDUCER_PREFIX = 'shlink/shortUrlCreation'; +export const CREATE_SHORT_URL = `${REDUCER_PREFIX}/createShortUrl`; export type ShortUrlCreation = { saving: false; @@ -45,7 +46,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => export const shortUrlCreationReducerCreator = (createShortUrlThunk: ReturnType) => { const { reducer, actions } = createSlice({ - name: 'shortUrlCreationReducer', + name: REDUCER_PREFIX, initialState: initialState as ShortUrlCreation, // Without this casting it infers type ShortUrlCreationWaiting reducers: { resetCreateShortUrl: () => initialState, diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts index 9af657b8..bfa9406c 100644 --- a/src/short-urls/reducers/shortUrlDeletion.ts +++ b/src/short-urls/reducers/shortUrlDeletion.ts @@ -5,7 +5,8 @@ import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; import { ShortUrlIdentifier } from '../data'; -export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED'; +const REDUCER_PREFIX = 'shlink/shortUrlDeletion'; +export const SHORT_URL_DELETED = `${REDUCER_PREFIX}/deleteShortUrl`; export interface ShortUrlDeletion { shortCode: string; @@ -35,7 +36,7 @@ export const shortUrlDeletionReducerCreator = (buildShlinkApiClient: ShlinkApiCl ); const { actions, reducer } = createSlice({ - name: 'shortUrlDeletion', + name: REDUCER_PREFIX, initialState, reducers: { resetDeleteShortUrl: () => initialState, diff --git a/src/short-urls/reducers/shortUrlDetail.ts b/src/short-urls/reducers/shortUrlDetail.ts index b23591c6..4ad467f3 100644 --- a/src/short-urls/reducers/shortUrlDetail.ts +++ b/src/short-urls/reducers/shortUrlDetail.ts @@ -6,7 +6,7 @@ import { shortUrlMatches } from '../helpers'; import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; +const REDUCER_PREFIX = 'shlink/shortUrlDetail'; export interface ShortUrlDetail { shortUrl?: ShortUrl; @@ -24,7 +24,7 @@ const initialState: ShortUrlDetail = { export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { const getShortUrlDetail = createAsyncThunk( - GET_SHORT_URL_DETAIL, + `${REDUCER_PREFIX}/getShortUrlDetail`, async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { const { shortUrlsList } = getState(); const alreadyLoaded = shortUrlsList?.shortUrls?.data.find((url) => shortUrlMatches(url, shortCode, domain)); @@ -34,7 +34,7 @@ export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClie ); const { reducer } = createSlice({ - name: 'shortUrlDetailReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index fbdfac13..28d1d992 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -5,7 +5,8 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED'; +const REDUCER_PREFIX = 'shlink/shortUrlEdition'; +export const SHORT_URL_EDITED = `${REDUCER_PREFIX}/editShortUrl`; export interface ShortUrlEdition { shortUrl?: ShortUrl; @@ -37,7 +38,7 @@ export const shortUrlEditionReducerCreator = (buildShlinkApiClient: ShlinkApiCli ); const { reducer } = createSlice({ - name: 'shortUrlEditionReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/tags/reducers/tagDelete.ts b/src/tags/reducers/tagDelete.ts index bb7039bf..5bac50a8 100644 --- a/src/tags/reducers/tagDelete.ts +++ b/src/tags/reducers/tagDelete.ts @@ -1,11 +1,10 @@ -import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createAction, createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG'; -const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED'; +const REDUCER_PREFIX = 'shlink/tagDelete'; export interface TagDeletion { deleting: boolean; @@ -14,24 +13,22 @@ export interface TagDeletion { errorData?: ProblemDetailsError; } -export type DeleteTagAction = PayloadAction; - const initialState: TagDeletion = { deleting: false, deleted: false, error: false, }; -export const tagDeleted = createAction(TAG_DELETED); +export const tagDeleted = createAction(`${REDUCER_PREFIX}/tagDeleted`); export const tagDeleteReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const deleteTag = createAsyncThunk(DELETE_TAG, async (tag: string, { getState }): Promise => { + const deleteTag = createAsyncThunk(`${REDUCER_PREFIX}/deleteTag`, async (tag: string, { getState }): Promise => { const { deleteTags } = buildShlinkApiClient(getState); await deleteTags([tag]); }); const { reducer } = createSlice({ - name: 'tagDeleteReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/tags/reducers/tagEdit.ts b/src/tags/reducers/tagEdit.ts index 087a8497..7c32b3ee 100644 --- a/src/tags/reducers/tagEdit.ts +++ b/src/tags/reducers/tagEdit.ts @@ -6,8 +6,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -const EDIT_TAG = 'shlink/editTag/EDIT_TAG'; -const TAG_EDITED = 'shlink/editTag/TAG_EDITED'; +const REDUCER_PREFIX = 'shlink/tagEdit'; export interface TagEdition { oldName?: string; @@ -32,11 +31,11 @@ const initialState: TagEdition = { error: false, }; -export const tagEdited = createAction(TAG_EDITED); +export const tagEdited = createAction(`${REDUCER_PREFIX}/tagEdited`); export const tagEditReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGenerator: ColorGenerator) => { const editTag = createAsyncThunk( - EDIT_TAG, + `${REDUCER_PREFIX}/editTag`, async ({ oldName, newName, color }: EditTag, { getState }): Promise => { await buildShlinkApiClient(getState).editTag(oldName, newName); colorGenerator.setColorForKey(newName, color); @@ -46,7 +45,7 @@ export const tagEditReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuild ); const { reducer } = createSlice({ - name: 'tagEditReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index a6e684b6..4f3dc6d8 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -12,8 +12,7 @@ import { tagDeleted } from './tagDelete'; import { tagEdited } from './tagEdit'; import { ProblemDetailsError } from '../../api/types/errors'; -const LIST_TAGS = 'shlink/tagsList/LIST_TAGS'; -const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS'; +const REDUCER_PREFIX = 'shlink/tagsList'; type TagsStatsMap = Record; @@ -66,7 +65,7 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O ); export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => createAsyncThunk( - LIST_TAGS, + `${REDUCER_PREFIX}/listTags`, async (_: void, { getState }): Promise => { const { tagsList } = getState(); @@ -86,13 +85,13 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t }, ); -export const filterTags = createAction(FILTER_TAGS); +export const filterTags = createAction(`${REDUCER_PREFIX}/filterTags`); export const reducer = ( listTagsThunk: ReturnType, createShortUrlThunk: ReturnType, ) => createSlice({ - name: 'shlink/tagsList', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/theme/theme.scss b/src/utils/theme/theme.scss similarity index 98% rename from src/theme/theme.scss rename to src/utils/theme/theme.scss index 7d797202..dd4c20d1 100644 --- a/src/theme/theme.scss +++ b/src/utils/theme/theme.scss @@ -1,4 +1,4 @@ -@import '../utils/base'; +@import '../base'; // Light theme colors $lightPrimaryColor: #ffffff; From 979c16eb9c9db9cb4c0255f5d133ad02da9f024b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 9 Nov 2022 18:27:05 +0100 Subject: [PATCH 5/7] Updated listShortUrls action to use payload --- src/short-urls/reducers/shortUrlsList.ts | 13 ++++++------- test/short-urls/reducers/shortUrlsList.test.ts | 6 +++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 3e492bff..4cc56a07 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -1,5 +1,6 @@ +import { PayloadAction } from '@reduxjs/toolkit'; import { assoc, assocPath, last, pipe, reject } from 'ramda'; -import { Action, Dispatch } from 'redux'; +import { Dispatch } from 'redux'; import { shortUrlMatches } from '../helpers'; import { createNewVisits, CreateVisitsAction } from '../../visits/reducers/visitCreation'; import { buildReducer } from '../../utils/helpers/redux'; @@ -23,9 +24,7 @@ export interface ShortUrlsList { error: boolean; } -export interface ListShortUrlsAction extends Action { - shortUrls: ShlinkShortUrlsResponse; -} +export type ListShortUrlsAction = PayloadAction; export type ListShortUrlsCombinedAction = ( ListShortUrlsAction @@ -43,7 +42,7 @@ const initialState: ShortUrlsList = { 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 }), + [LIST_SHORT_URLS]: (_, { payload: shortUrls }) => ({ loading: false, error: false, shortUrls }), [`${SHORT_URL_DELETED}/fulfilled`]: pipe( // TODO Do not hardcode action type here (state: ShortUrlsList, { payload }: DeleteShortUrlAction) => (!state.shortUrls ? state : assocPath( ['shortUrls', 'data'], @@ -109,9 +108,9 @@ export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState); try { - const shortUrls = await shlinkListShortUrls(params); + const payload = await shlinkListShortUrls(params); - dispatch({ type: LIST_SHORT_URLS, shortUrls }); + dispatch({ type: LIST_SHORT_URLS, payload }); } catch (e) { dispatch({ type: LIST_SHORT_URLS_ERROR }); } diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index 3eccb8d8..954f98d6 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -24,7 +24,7 @@ describe('shortUrlsListReducer', () => { })); it('returns short URLs on LIST_SHORT_URLS', () => - expect(reducer(undefined, { type: LIST_SHORT_URLS, shortUrls: { data: [] } } as any)).toEqual({ + expect(reducer(undefined, { type: LIST_SHORT_URLS, payload: { data: [] } } as any)).toEqual({ shortUrls: { data: [] }, loading: false, error: false, @@ -194,14 +194,14 @@ describe('shortUrlsListReducer', () => { afterEach(jest.clearAllMocks); it('dispatches proper actions if API client request succeeds', async () => { - const listShortUrlsMock = jest.fn().mockResolvedValue([]); + const listShortUrlsMock = jest.fn().mockResolvedValue({}); const apiClientMock = Mock.of({ listShortUrls: listShortUrlsMock }); await listShortUrls(() => apiClientMock)()(dispatch, getState); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_SHORT_URLS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS, shortUrls: [] }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS, payload: {} }); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); From ae49090bad363b1dd933ec666e14f83b464af795 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 9 Nov 2022 18:40:51 +0100 Subject: [PATCH 6/7] Split short URL edition reducer and async thunk --- src/short-urls/reducers/shortUrlEdition.ts | 50 +++++++++---------- src/short-urls/services/provideServices.ts | 7 ++- .../reducers/shortUrlEdition.test.ts | 9 +++- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index 28d1d992..6b9d5342 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -28,31 +28,27 @@ const initialState: ShortUrlEdition = { error: false, }; -export const shortUrlEditionReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const editShortUrl = createAsyncThunk( - SHORT_URL_EDITED, - ({ shortCode, domain, data }: EditShortUrl, { getState }): Promise => { - const { updateShortUrl } = buildShlinkApiClient(getState); - return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates - }, - ); +export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + SHORT_URL_EDITED, + ({ shortCode, domain, data }: EditShortUrl, { getState }): Promise => { + const { updateShortUrl } = buildShlinkApiClient(getState); + return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates + }, +); - const { reducer } = createSlice({ - name: REDUCER_PREFIX, - initialState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(editShortUrl.pending, (state) => ({ ...state, saving: true, error: false, saved: false })); - builder.addCase( - editShortUrl.rejected, - (state, { error }) => ({ ...state, saving: false, error: true, saved: false, errorData: parseApiError(error) }), - ); - builder.addCase( - editShortUrl.fulfilled, - (_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false, saved: true }), - ); - }, - }); - - return { reducer, editShortUrl }; -}; +export const shortUrlEditionReducerCreator = (editShortUrlThunk: ReturnType) => createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(editShortUrlThunk.pending, (state) => ({ ...state, saving: true, error: false, saved: false })); + builder.addCase( + editShortUrlThunk.rejected, + (state, { error }) => ({ ...state, saving: false, error: true, saved: false, errorData: parseApiError(error) }), + ); + builder.addCase( + editShortUrlThunk.fulfilled, + (_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false, saved: true }), + ); + }, +}).reducer; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 13b9e2fe..c9911d0d 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -10,7 +10,7 @@ import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult'; import { listShortUrls } from '../reducers/shortUrlsList'; import { shortUrlCreationReducerCreator, createShortUrl } from '../reducers/shortUrlCreation'; import { shortUrlDeletionReducerCreator } from '../reducers/shortUrlDeletion'; -import { shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition'; +import { editShortUrl, shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition'; import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail'; import { ConnectDecorator } from '../../container/types'; import { ShortUrlsTable } from '../ShortUrlsTable'; @@ -60,8 +60,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'createShortUrl'); bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator'); - bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'buildShlinkApiClient'); - bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator'); + bottle.serviceFactory('shortUrlEditionReducer', shortUrlEditionReducerCreator, 'editShortUrl'); bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator'); @@ -80,7 +79,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator'); - bottle.serviceFactory('editShortUrl', prop('editShortUrl'), 'shortUrlEditionReducerCreator'); + bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient'); }; export default provideServices; diff --git a/test/short-urls/reducers/shortUrlEdition.test.ts b/test/short-urls/reducers/shortUrlEdition.test.ts index 7b0ecc43..2e6d8396 100644 --- a/test/short-urls/reducers/shortUrlEdition.test.ts +++ b/test/short-urls/reducers/shortUrlEdition.test.ts @@ -1,5 +1,9 @@ import { Mock } from 'ts-mockery'; -import { ShortUrlEditedAction, shortUrlEditionReducerCreator } from '../../../src/short-urls/reducers/shortUrlEdition'; +import { + ShortUrlEditedAction, + shortUrlEditionReducerCreator, + editShortUrl as editShortUrlCreator, +} from '../../../src/short-urls/reducers/shortUrlEdition'; import { ShlinkState } from '../../../src/container/types'; import { ShortUrl } from '../../../src/short-urls/data'; import { SelectedServer } from '../../../src/servers/data'; @@ -10,7 +14,8 @@ describe('shortUrlEditionReducer', () => { const shortUrl = Mock.of({ longUrl, shortCode }); const updateShortUrl = jest.fn().mockResolvedValue(shortUrl); const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl }); - const { reducer, editShortUrl } = shortUrlEditionReducerCreator(buildShlinkApiClient); + const editShortUrl = editShortUrlCreator(buildShlinkApiClient); + const reducer = shortUrlEditionReducerCreator(editShortUrl); afterEach(jest.clearAllMocks); From 7bfccafca88c6efced94a302e732ad509a2962d2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 9 Nov 2022 19:13:44 +0100 Subject: [PATCH 7/7] Migrated shortUrlsList reducer to RTK --- src/reducers/index.ts | 3 +- src/short-urls/reducers/shortUrlCreation.ts | 3 +- src/short-urls/reducers/shortUrlDeletion.ts | 34 ++-- src/short-urls/reducers/shortUrlEdition.ts | 5 +- src/short-urls/reducers/shortUrlsList.ts | 191 +++++++++--------- src/short-urls/services/provideServices.ts | 21 +- src/tags/reducers/tagsList.ts | 4 +- src/tags/services/provideServices.ts | 5 +- .../reducers/shortUrlDeletion.test.ts | 8 +- .../reducers/shortUrlEdition.test.ts | 2 +- .../short-urls/reducers/shortUrlsList.test.ts | 64 +++--- test/tags/reducers/tagsList.test.ts | 4 +- 12 files changed, 184 insertions(+), 160 deletions(-) diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 2053ebc2..66ff189c 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -2,7 +2,6 @@ import { IContainer } from 'bottlejs'; import { combineReducers } from 'redux'; import { serversReducer } from '../servers/reducers/servers'; import selectedServerReducer from '../servers/reducers/selectedServer'; -import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; import domainVisitsReducer from '../visits/reducers/domainVisits'; @@ -17,7 +16,7 @@ import { ShlinkState } from '../container/types'; export default (container: IContainer) => combineReducers({ servers: serversReducer, selectedServer: selectedServerReducer, - shortUrlsList: shortUrlsListReducer, + shortUrlsList: container.shortUrlsListReducer, shortUrlCreation: container.shortUrlCreationReducer, shortUrlDeletion: container.shortUrlDeletionReducer, shortUrlEdition: container.shortUrlEditionReducer, diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/src/short-urls/reducers/shortUrlCreation.ts index f1dbd725..1a48b63e 100644 --- a/src/short-urls/reducers/shortUrlCreation.ts +++ b/src/short-urls/reducers/shortUrlCreation.ts @@ -6,7 +6,6 @@ import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; const REDUCER_PREFIX = 'shlink/shortUrlCreation'; -export const CREATE_SHORT_URL = `${REDUCER_PREFIX}/createShortUrl`; export type ShortUrlCreation = { saving: false; @@ -37,7 +36,7 @@ const initialState: ShortUrlCreation = { }; export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( - CREATE_SHORT_URL, + `${REDUCER_PREFIX}/createShortUrl`, (data: ShortUrlData, { getState }): Promise => { const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState); return shlinkCreateShortUrl(data); diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts index bfa9406c..67d86499 100644 --- a/src/short-urls/reducers/shortUrlDeletion.ts +++ b/src/short-urls/reducers/shortUrlDeletion.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { parseApiError } from '../../api/utils'; @@ -6,7 +6,6 @@ import { ProblemDetailsError } from '../../api/types/errors'; import { ShortUrlIdentifier } from '../data'; const REDUCER_PREFIX = 'shlink/shortUrlDeletion'; -export const SHORT_URL_DELETED = `${REDUCER_PREFIX}/deleteShortUrl`; export interface ShortUrlDeletion { shortCode: string; @@ -16,8 +15,6 @@ export interface ShortUrlDeletion { errorData?: ProblemDetailsError; } -export type DeleteShortUrlAction = PayloadAction; - const initialState: ShortUrlDeletion = { shortCode: '', loading: false, @@ -25,16 +22,16 @@ const initialState: ShortUrlDeletion = { error: false, }; -export const shortUrlDeletionReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const deleteShortUrl = createAsyncThunk( - SHORT_URL_DELETED, - async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { - const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState); - await shlinkDeleteShortUrl(shortCode, domain); - return { shortCode, domain }; - }, - ); +export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + `${REDUCER_PREFIX}/deleteShortUrl`, + async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { + const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState); + await shlinkDeleteShortUrl(shortCode, domain); + return { shortCode, domain }; + }, +); +export const shortUrlDeletionReducerCreator = (deleteShortUrlThunk: ReturnType) => { const { actions, reducer } = createSlice({ name: REDUCER_PREFIX, initialState, @@ -42,11 +39,14 @@ export const shortUrlDeletionReducerCreator = (buildShlinkApiClient: ShlinkApiCl resetDeleteShortUrl: () => initialState, }, extraReducers: (builder) => { - builder.addCase(deleteShortUrl.pending, (state) => ({ ...state, loading: true, error: false, deleted: false })); - builder.addCase(deleteShortUrl.rejected, (state, { error }) => ( + builder.addCase( + deleteShortUrlThunk.pending, + (state) => ({ ...state, loading: true, error: false, deleted: false }), + ); + builder.addCase(deleteShortUrlThunk.rejected, (state, { error }) => ( { ...state, errorData: parseApiError(error), loading: false, error: true, deleted: false } )); - builder.addCase(deleteShortUrl.fulfilled, (state, { payload }) => ( + builder.addCase(deleteShortUrlThunk.fulfilled, (state, { payload }) => ( { ...state, shortCode: payload.shortCode, loading: false, error: false, deleted: true } )); }, @@ -54,5 +54,5 @@ export const shortUrlDeletionReducerCreator = (buildShlinkApiClient: ShlinkApiCl const { resetDeleteShortUrl } = actions; - return { reducer, deleteShortUrl, resetDeleteShortUrl }; + return { reducer, resetDeleteShortUrl }; }; diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index 6b9d5342..79ea145d 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -6,7 +6,6 @@ import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; const REDUCER_PREFIX = 'shlink/shortUrlEdition'; -export const SHORT_URL_EDITED = `${REDUCER_PREFIX}/editShortUrl`; export interface ShortUrlEdition { shortUrl?: ShortUrl; @@ -29,7 +28,7 @@ const initialState: ShortUrlEdition = { }; export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( - SHORT_URL_EDITED, + `${REDUCER_PREFIX}/editShortUrl`, ({ shortCode, domain, data }: EditShortUrl, { getState }): Promise => { const { updateShortUrl } = buildShlinkApiClient(getState); return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates @@ -51,4 +50,4 @@ export const shortUrlEditionReducerCreator = (editShortUrlThunk: ReturnType ({ shortUrl, saving: false, error: false, saved: true }), ); }, -}).reducer; +}); diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 4cc56a07..c6885353 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -1,21 +1,16 @@ -import { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; import { assoc, assocPath, last, pipe, reject } from 'ramda'; -import { Dispatch } from 'redux'; import { shortUrlMatches } from '../helpers'; -import { createNewVisits, CreateVisitsAction } from '../../visits/reducers/visitCreation'; -import { buildReducer } from '../../utils/helpers/redux'; -import { GetState } from '../../container/types'; +import { createNewVisits } from '../../visits/reducers/visitCreation'; +import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types'; -import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion'; -import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; -import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; +import { deleteShortUrl } from './shortUrlDeletion'; +import { createShortUrl } from './shortUrlCreation'; +import { editShortUrl } from './shortUrlEdition'; import { ShortUrl } from '../data'; -export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START'; -export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR'; -export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS'; - +const REDUCER_PREFIX = 'shlink/shortUrlsList'; export const ITEMS_IN_OVERVIEW_PAGE = 5; export interface ShortUrlsList { @@ -24,94 +19,104 @@ export interface ShortUrlsList { error: boolean; } -export type ListShortUrlsAction = PayloadAction; - -export type ListShortUrlsCombinedAction = ( - ListShortUrlsAction - & CreateVisitsAction - & CreateShortUrlAction - & DeleteShortUrlAction - & ShortUrlEditedAction -); - const initialState: ShortUrlsList = { loading: true, error: false, }; -export default buildReducer({ - [LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }), - [LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }), - [LIST_SHORT_URLS]: (_, { payload: shortUrls }) => ({ loading: false, error: false, shortUrls }), - [`${SHORT_URL_DELETED}/fulfilled`]: pipe( // TODO Do not hardcode action type here - (state: ShortUrlsList, { payload }: DeleteShortUrlAction) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'data'], - reject((shortUrl) => - shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data), - state, - )), - (state) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'pagination', 'totalItems'], - state.shortUrls.pagination.totalItems - 1, - state, - )), - ), - [createNewVisits.toString()]: (state, { payload }) => assocPath( - ['shortUrls', 'data'], - state.shortUrls?.data?.map( - (currentShortUrl) => { - // Find the last of the new visit for this short URL, and pick the amount of visits from it - const lastVisit = last( - payload.createdVisits.filter( - ({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain), - ), - ); +export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + `${REDUCER_PREFIX}/listShortUrls`, + (params: ShlinkShortUrlsListParams | void, { getState }): Promise => { + const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState); + return shlinkListShortUrls(params ?? {}); + }, +); - return lastVisit?.shortUrl - ? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl) - : currentShortUrl; - }, - ), - state, - ), - [`${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, { payload }: CreateShortUrlAction) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'data'], - [payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)], - state, - )), - (state: ShortUrlsList) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'pagination', 'totalItems'], - state.shortUrls.pagination.totalItems + 1, - state, - )), - ), - // TODO Do not hardcode action type here - [`${SHORT_URL_EDITED}/fulfilled`]: (state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'data'], - state.shortUrls.data.map((shortUrl) => { - const { shortCode, domain } = editedShortUrl; +export const shortUrlsListReducerCreator = ( + listShortUrlsThunk: ReturnType, + editShortUrlThunk: ReturnType, + createShortUrlThunk: ReturnType, + deleteShortUrlThunk: ReturnType, +) => createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(listShortUrlsThunk.pending, (state) => ({ ...state, loading: true, error: false })); + builder.addCase(listShortUrlsThunk.rejected, () => ({ loading: false, error: true })); + builder.addCase( + listShortUrlsThunk.fulfilled, + (_, { payload: shortUrls }) => ({ loading: false, error: false, shortUrls }), + ); - return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl; - }), - state, - )), -}, initialState); + builder.addCase( + createShortUrlThunk.fulfilled, + pipe( + // 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, { payload }) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'data'], + [payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)], + state, + )), + (state: ShortUrlsList) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'pagination', 'totalItems'], + state.shortUrls.pagination.totalItems + 1, + state, + )), + ), + ); -export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - params: ShlinkShortUrlsListParams = {}, -) => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: LIST_SHORT_URLS_START }); - const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState); + builder.addCase( + editShortUrlThunk.fulfilled, + (state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'data'], + state.shortUrls.data.map((shortUrl) => { + const { shortCode, domain } = editedShortUrl; + return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl; + }), + state, + )), + ); - try { - const payload = await shlinkListShortUrls(params); + builder.addCase( + deleteShortUrlThunk.fulfilled, + pipe( + (state, { payload }) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'data'], + reject((shortUrl) => + shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data), + state, + )), + (state) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'pagination', 'totalItems'], + state.shortUrls.pagination.totalItems - 1, + state, + )), + ), + ); - dispatch({ type: LIST_SHORT_URLS, payload }); - } catch (e) { - dispatch({ type: LIST_SHORT_URLS_ERROR }); - } -}; + builder.addCase( + createNewVisits, + (state, { payload }) => assocPath( + ['shortUrls', 'data'], + state.shortUrls?.data?.map( + (currentShortUrl) => { + // Find the last of the new visit for this short URL, and pick the amount of visits from it + const lastVisit = last( + payload.createdVisits.filter( + ({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain), + ), + ); + + return lastVisit?.shortUrl + ? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl) + : currentShortUrl; + }, + ), + state, + ), + ); + }, +}); diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index c9911d0d..4ddb4ea7 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -7,9 +7,9 @@ import { ShortUrlsRowMenu } from '../helpers/ShortUrlsRowMenu'; import { CreateShortUrl } from '../CreateShortUrl'; import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal'; import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult'; -import { listShortUrls } from '../reducers/shortUrlsList'; +import { listShortUrls, shortUrlsListReducerCreator } from '../reducers/shortUrlsList'; import { shortUrlCreationReducerCreator, createShortUrl } from '../reducers/shortUrlCreation'; -import { shortUrlDeletionReducerCreator } from '../reducers/shortUrlDeletion'; +import { shortUrlDeletionReducerCreator, deleteShortUrl } from '../reducers/shortUrlDeletion'; import { editShortUrl, shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition'; import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail'; import { ConnectDecorator } from '../../container/types'; @@ -57,12 +57,23 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer'])); // Reducers + bottle.serviceFactory( + 'shortUrlsListReducerCreator', + shortUrlsListReducerCreator, + 'listShortUrls', + 'editShortUrl', + 'createShortUrl', + 'deleteShortUrl', + ); + bottle.serviceFactory('shortUrlsListReducer', prop('reducer'), 'shortUrlsListReducerCreator'); + bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'createShortUrl'); bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator'); - bottle.serviceFactory('shortUrlEditionReducer', shortUrlEditionReducerCreator, 'editShortUrl'); + bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'editShortUrl'); + bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator'); - bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'deleteShortUrl'); bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator'); bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'buildShlinkApiClient'); @@ -74,7 +85,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator'); - bottle.serviceFactory('deleteShortUrl', prop('deleteShortUrl'), 'shortUrlDeletionReducerCreator'); + bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator'); bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator'); diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index 4f3dc6d8..cdd55d26 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -87,7 +87,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t export const filterTags = createAction(`${REDUCER_PREFIX}/filterTags`); -export const reducer = ( +export const tagsListReducerCreator = ( listTagsThunk: ReturnType, createShortUrlThunk: ReturnType, ) => createSlice({ @@ -128,4 +128,4 @@ export const reducer = ( 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 ce1cc554..92980f72 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, reducer } from '../reducers/tagsList'; +import { filterTags, listTags, tagsListReducerCreator } from '../reducers/tagsList'; import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete'; import { tagEdited, tagEditReducerCreator } from '../reducers/tagEdit'; import { ConnectDecorator } from '../../container/types'; @@ -44,7 +44,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator'); - bottle.serviceFactory('tagsListReducer', reducer, 'listTags', 'createShortUrl'); + bottle.serviceFactory('tagsListReducerCreator', tagsListReducerCreator, 'listTags', 'createShortUrl'); + bottle.serviceFactory('tagsListReducer', prop('reducer'), 'tagsListReducerCreator'); // Actions const listTagsActionFactory = (force: boolean) => diff --git a/test/short-urls/reducers/shortUrlDeletion.test.ts b/test/short-urls/reducers/shortUrlDeletion.test.ts index fde38ff8..9a65734d 100644 --- a/test/short-urls/reducers/shortUrlDeletion.test.ts +++ b/test/short-urls/reducers/shortUrlDeletion.test.ts @@ -1,12 +1,16 @@ import { Mock } from 'ts-mockery'; -import { shortUrlDeletionReducerCreator } from '../../../src/short-urls/reducers/shortUrlDeletion'; +import { + shortUrlDeletionReducerCreator, + deleteShortUrl as deleteShortUrlCretor, +} 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); + const deleteShortUrl = deleteShortUrlCretor(buildShlinkApiClient); + const { reducer, resetDeleteShortUrl } = shortUrlDeletionReducerCreator(deleteShortUrl); beforeEach(jest.clearAllMocks); diff --git a/test/short-urls/reducers/shortUrlEdition.test.ts b/test/short-urls/reducers/shortUrlEdition.test.ts index 2e6d8396..941328c8 100644 --- a/test/short-urls/reducers/shortUrlEdition.test.ts +++ b/test/short-urls/reducers/shortUrlEdition.test.ts @@ -15,7 +15,7 @@ describe('shortUrlEditionReducer', () => { const updateShortUrl = jest.fn().mockResolvedValue(shortUrl); const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl }); const editShortUrl = editShortUrlCreator(buildShlinkApiClient); - const reducer = shortUrlEditionReducerCreator(editShortUrl); + const { reducer } = shortUrlEditionReducerCreator(editShortUrl); afterEach(jest.clearAllMocks); diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index 954f98d6..cc9e748d 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -1,37 +1,44 @@ import { Mock } from 'ts-mockery'; -import reducer, { - LIST_SHORT_URLS, - LIST_SHORT_URLS_ERROR, - LIST_SHORT_URLS_START, - listShortUrls, +import { + listShortUrls as listShortUrlsCreator, + shortUrlsListReducerCreator, } from '../../../src/short-urls/reducers/shortUrlsList'; -import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion'; +import { deleteShortUrl as deleteShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlDeletion'; +import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types'; +import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation'; +import { editShortUrl as editShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlEdition'; +import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; import { ShortUrl } from '../../../src/short-urls/data'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types'; -import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation'; -import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition'; -import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; describe('shortUrlsListReducer', () => { const shortCode = 'abc123'; + const listShortUrlsMock = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ listShortUrls: listShortUrlsMock }); + const listShortUrls = listShortUrlsCreator(buildShlinkApiClient); + const editShortUrl = editShortUrlCreator(buildShlinkApiClient); + const createShortUrl = createShortUrlCreator(buildShlinkApiClient); + const deleteShortUrl = deleteShortUrlCreator(buildShlinkApiClient); + const { reducer } = shortUrlsListReducerCreator(listShortUrls, editShortUrl, createShortUrl, deleteShortUrl); + + afterEach(jest.clearAllMocks); describe('reducer', () => { it('returns loading on LIST_SHORT_URLS_START', () => - expect(reducer(undefined, { type: LIST_SHORT_URLS_START } as any)).toEqual({ + expect(reducer(undefined, { type: listShortUrls.pending.toString() })).toEqual({ loading: true, error: false, })); it('returns short URLs on LIST_SHORT_URLS', () => - expect(reducer(undefined, { type: LIST_SHORT_URLS, payload: { data: [] } } as any)).toEqual({ + expect(reducer(undefined, { type: listShortUrls.fulfilled.toString(), payload: { data: [] } })).toEqual({ shortUrls: { data: [] }, loading: false, error: false, })); it('returns error on LIST_SHORT_URLS_ERROR', () => - expect(reducer(undefined, { type: LIST_SHORT_URLS_ERROR } as any)).toEqual({ + expect(reducer(undefined, { type: listShortUrls.rejected.toString() })).toEqual({ loading: false, error: true, })); @@ -52,7 +59,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: `${SHORT_URL_DELETED}/fulfilled`, payload: { shortCode } } as any)).toEqual({ + expect(reducer(state, { type: deleteShortUrl.fulfilled.toString(), payload: { shortCode } })).toEqual({ shortUrls: { data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }], pagination: { totalItems: 9 }, @@ -85,7 +92,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: createNewVisits.toString(), payload: { createdVisits } } as any)).toEqual({ + expect(reducer(state, { type: createNewVisits.toString(), payload: { createdVisits } })).toEqual({ shortUrls: { data: [ { shortCode, domain: 'example.com', visitsCount: 5 }, @@ -142,7 +149,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: `${CREATE_SHORT_URL}/fulfilled`, payload: newShortUrl } as any)).toEqual({ + expect(reducer(state, { type: createShortUrl.fulfilled.toString(), payload: newShortUrl })).toEqual({ shortUrls: { data: expectedData, pagination: { totalItems: 16 }, @@ -181,7 +188,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - const result = reducer(state, { type: `${SHORT_URL_EDITED}/fulfilled`, payload: editedShortUrl } as any); + const result = reducer(state, { type: editShortUrl.fulfilled.toString(), payload: editedShortUrl }); expect(result.shortUrls?.data).toEqual(expectedList); }); @@ -191,30 +198,29 @@ describe('shortUrlsListReducer', () => { const dispatch = jest.fn(); const getState = jest.fn().mockReturnValue({ selectedServer: {} }); - afterEach(jest.clearAllMocks); - it('dispatches proper actions if API client request succeeds', async () => { - const listShortUrlsMock = jest.fn().mockResolvedValue({}); - const apiClientMock = Mock.of({ listShortUrls: listShortUrlsMock }); + listShortUrlsMock.mockResolvedValue({}); - await listShortUrls(() => apiClientMock)()(dispatch, getState); + await listShortUrls()(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_SHORT_URLS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS, payload: {} }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: listShortUrls.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: listShortUrls.fulfilled.toString(), + payload: {}, + })); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); it('dispatches proper actions if API client request fails', async () => { - const listShortUrlsMock = jest.fn().mockRejectedValue(undefined); - const apiClientMock = Mock.of({ listShortUrls: listShortUrlsMock }); + listShortUrlsMock.mockRejectedValue(undefined); - await listShortUrls(() => apiClientMock)()(dispatch, getState); + await listShortUrls()(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_SHORT_URLS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: listShortUrls.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: listShortUrls.rejected.toString() })); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); diff --git a/test/tags/reducers/tagsList.test.ts b/test/tags/reducers/tagsList.test.ts index 06c99828..a3f1cbb2 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/test/tags/reducers/tagsList.test.ts @@ -3,7 +3,7 @@ import { TagsList, filterTags, listTags as listTagsCreator, - reducer as reducerCreator, + tagsListReducerCreator, } from '../../../src/tags/reducers/tagsList'; import { ShlinkState } from '../../../src/container/types'; import { ShortUrl } from '../../../src/short-urls/data'; @@ -16,7 +16,7 @@ describe('tagsListReducer', () => { const buildShlinkApiClient = jest.fn(); const listTags = listTagsCreator(buildShlinkApiClient, true); const createShortUrl = createShortUrlCreator(buildShlinkApiClient); - const reducer = reducerCreator(listTags, createShortUrl); + const { reducer } = tagsListReducerCreator(listTags, createShortUrl); afterEach(jest.clearAllMocks);