diff --git a/src/reducers/index.ts b/src/reducers/index.ts index fc93de93..9e473c05 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 shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; import tagEditReducer from '../tags/reducers/tagEdit'; @@ -25,12 +24,12 @@ export default (container: IContainer) => combineReducers({ shortUrlCreationResult: container.shortUrlCreationReducer, shortUrlDeletion: container.shortUrlDeletionReducer, shortUrlEdition: container.shortUrlEditionReducer, + shortUrlDetail: container.shortUrlDetailReducer, shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, domainVisits: domainVisitsReducer, orphanVisits: orphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer, - shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, tagEdit: tagEditReducer, diff --git a/src/short-urls/reducers/shortUrlDetail.ts b/src/short-urls/reducers/shortUrlDetail.ts index a703d867..b23591c6 100644 --- a/src/short-urls/reducers/shortUrlDetail.ts +++ b/src/short-urls/reducers/shortUrlDetail.ts @@ -1,17 +1,12 @@ -import { PayloadAction } from '@reduxjs/toolkit'; -import { Dispatch } from 'redux'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { ShortUrl, ShortUrlIdentifier } from '../data'; -import { buildReducer } from '../../utils/helpers/redux'; +import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { GetState } from '../../container/types'; import { shortUrlMatches } from '../helpers'; import { parseApiError } from '../../api/utils'; -import { ApiErrorAction } from '../../api/types/actions'; import { ProblemDetailsError } from '../../api/types/errors'; -export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; -export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR'; -export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; +const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; export interface ShortUrlDetail { shortUrl?: ShortUrl; @@ -27,25 +22,29 @@ const initialState: ShortUrlDetail = { error: false, }; -export default buildReducer({ - [GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }), - [GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }), - [GET_SHORT_URL_DETAIL]: (_, { payload: shortUrl }) => ({ shortUrl, ...initialState }), -}, initialState); +export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { + const getShortUrlDetail = createAsyncThunk( + GET_SHORT_URL_DETAIL, + async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { + const { shortUrlsList } = getState(); + const alreadyLoaded = shortUrlsList?.shortUrls?.data.find((url) => shortUrlMatches(url, shortCode, domain)); -export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - { shortCode, domain }: ShortUrlIdentifier, -) => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: GET_SHORT_URL_DETAIL_START }); + return alreadyLoaded ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain); + }, + ); - try { - const { shortUrlsList } = getState(); - const payload = shortUrlsList?.shortUrls?.data.find( - (url) => shortUrlMatches(url, shortCode, domain), - ) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain); + const { reducer } = createSlice({ + name: 'shortUrlDetailReducer', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(getShortUrlDetail.pending, () => ({ loading: true, error: false })); + builder.addCase(getShortUrlDetail.rejected, (_, { error }) => ( + { loading: false, error: true, errorData: parseApiError(error) } + )); + builder.addCase(getShortUrlDetail.fulfilled, (_, { payload: shortUrl }) => ({ ...initialState, shortUrl })); + }, + }); - dispatch({ payload, type: GET_SHORT_URL_DETAIL }); - } catch (e: any) { - dispatch({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) }); - } + return { reducer, getShortUrlDetail }; }; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 25845ea2..d2b865a8 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -11,12 +11,12 @@ import { listShortUrls } from '../reducers/shortUrlsList'; import { shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation'; import { shortUrlDeletionReducerCreator } from '../reducers/shortUrlDeletion'; import { shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition'; +import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail'; import { ConnectDecorator } from '../../container/types'; import { ShortUrlsTable } from '../ShortUrlsTable'; -import { QrCodeModal } from '../helpers/QrCodeModal'; import { ShortUrlForm } from '../ShortUrlForm'; import { EditShortUrl } from '../EditShortUrl'; -import { getShortUrlDetail } from '../reducers/shortUrlDetail'; +import { QrCodeModal } from '../helpers/QrCodeModal'; import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { @@ -66,6 +66,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator'); + bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('shortUrlDetailReducer', prop('reducer'), 'shortUrlDetailReducerCreator'); + // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); @@ -75,7 +78,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('deleteShortUrl', prop('deleteShortUrl'), 'shortUrlDeletionReducerCreator'); bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator'); - bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); + bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator'); bottle.serviceFactory('editShortUrl', prop('editShortUrl'), 'shortUrlEditionReducerCreator'); }; diff --git a/test/short-urls/reducers/shortUrlDetail.test.ts b/test/short-urls/reducers/shortUrlDetail.test.ts index e520822e..cb745724 100644 --- a/test/short-urls/reducers/shortUrlDetail.test.ts +++ b/test/short-urls/reducers/shortUrlDetail.test.ts @@ -1,31 +1,29 @@ import { Mock } from 'ts-mockery'; -import reducer, { - getShortUrlDetail, - GET_SHORT_URL_DETAIL_START, - GET_SHORT_URL_DETAIL_ERROR, - GET_SHORT_URL_DETAIL, - ShortUrlDetailAction, -} from '../../../src/short-urls/reducers/shortUrlDetail'; +import { ShortUrlDetailAction, shortUrlDetailReducerCreator } from '../../../src/short-urls/reducers/shortUrlDetail'; import { ShortUrl } from '../../../src/short-urls/data'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkState } from '../../../src/container/types'; import { ShortUrlsList } from '../../../src/short-urls/reducers/shortUrlsList'; describe('shortUrlDetailReducer', () => { + const getShortUrlCall = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ getShortUrl: getShortUrlCall }); + const { reducer, getShortUrlDetail } = shortUrlDetailReducerCreator(buildShlinkApiClient); + beforeEach(jest.clearAllMocks); describe('reducer', () => { const action = (type: string) => Mock.of({ type }); it('returns loading on GET_SHORT_URL_DETAIL_START', () => { - const state = reducer({ loading: false, error: false }, action(GET_SHORT_URL_DETAIL_START)); + const state = reducer({ loading: false, error: false }, action(getShortUrlDetail.pending.toString())); const { loading } = state; expect(loading).toEqual(true); }); it('stops loading and returns error on GET_SHORT_URL_DETAIL_ERROR', () => { - const state = reducer({ loading: true, error: false }, action(GET_SHORT_URL_DETAIL_ERROR)); + const state = reducer({ loading: true, error: false }, action(getShortUrlDetail.rejected.toString())); const { loading, error } = state; expect(loading).toEqual(false); @@ -34,7 +32,10 @@ describe('shortUrlDetailReducer', () => { it('return short URL on GET_SHORT_URL_DETAIL', () => { const actionShortUrl = Mock.of({ longUrl: 'foo', shortCode: 'bar' }); - const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_DETAIL, payload: actionShortUrl }); + const state = reducer( + { loading: true, error: false }, + { type: getShortUrlDetail.fulfilled.toString(), payload: actionShortUrl }, + ); const { loading, error, shortUrl } = state; expect(loading).toEqual(false); @@ -44,21 +45,22 @@ describe('shortUrlDetailReducer', () => { }); describe('getShortUrlDetail', () => { - const buildApiClientMock = (returned: Promise) => Mock.of({ - getShortUrl: jest.fn(async () => returned), - }); const dispatchMock = jest.fn(); const buildGetState = (shortUrlsList?: ShortUrlsList) => () => Mock.of({ shortUrlsList }); it('dispatches start and error when promise is rejected', async () => { - const ShlinkApiClient = buildApiClientMock(Promise.reject({})); + getShortUrlCall.mockRejectedValue({}); - await getShortUrlDetail(() => ShlinkApiClient)({ shortCode: 'abc123', domain: '' })(dispatchMock, buildGetState()); + await getShortUrlDetail({ shortCode: 'abc123', domain: '' })(dispatchMock, buildGetState(), {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_DETAIL_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_DETAIL_ERROR }); - expect(ShlinkApiClient.getShortUrl).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getShortUrlDetail.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getShortUrlDetail.rejected.toString(), + })); + expect(getShortUrlCall).toHaveBeenCalledTimes(1); }); it.each([ @@ -78,33 +80,44 @@ describe('shortUrlDetailReducer', () => { ], ])('performs API call when short URL is not found in local state', async (shortUrlsList?: ShortUrlsList) => { const resolvedShortUrl = Mock.of({ longUrl: 'foo', shortCode: 'abc123' }); - const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedShortUrl)); + getShortUrlCall.mockResolvedValue(resolvedShortUrl); - await getShortUrlDetail(() => ShlinkApiClient)({ shortCode: 'abc123', domain: '' })(dispatchMock, buildGetState(shortUrlsList)); + await getShortUrlDetail({ shortCode: 'abc123', domain: '' })(dispatchMock, buildGetState(shortUrlsList), {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_DETAIL_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_DETAIL, payload: resolvedShortUrl }); - expect(ShlinkApiClient.getShortUrl).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getShortUrlDetail.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getShortUrlDetail.fulfilled.toString(), + payload: resolvedShortUrl, + })); + expect(getShortUrlCall).toHaveBeenCalledTimes(1); }); it('avoids API calls when short URL is found in local state', async () => { const foundShortUrl = Mock.of({ longUrl: 'foo', shortCode: 'abc123' }); - const ShlinkApiClient = buildApiClientMock(Promise.resolve(Mock.all())); + getShortUrlCall.mockResolvedValue(Mock.all()); - await getShortUrlDetail(() => ShlinkApiClient)(foundShortUrl)( + await getShortUrlDetail(foundShortUrl)( dispatchMock, buildGetState(Mock.of({ shortUrls: { data: [foundShortUrl], }, })), + {}, ); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_DETAIL_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_DETAIL, payload: foundShortUrl }); - expect(ShlinkApiClient.getShortUrl).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getShortUrlDetail.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getShortUrlDetail.fulfilled.toString(), + payload: foundShortUrl, + })); + expect(getShortUrlCall).not.toHaveBeenCalled(); }); }); });