diff --git a/src/api/services/ShlinkApiClientBuilder.ts b/src/api/services/ShlinkApiClientBuilder.ts index 0d7efc0f..33381b46 100644 --- a/src/api/services/ShlinkApiClientBuilder.ts +++ b/src/api/services/ShlinkApiClientBuilder.ts @@ -1,6 +1,5 @@ import { AxiosInstance } from 'axios'; -import { prop } from 'ramda'; -import { hasServerData, SelectedServer, ServerWithId } from '../../servers/data'; +import { hasServerData, ServerWithId } from '../../servers/data'; import { GetState } from '../../container/types'; import { ShlinkApiClient } from './ShlinkApiClient'; @@ -8,22 +7,19 @@ const apiClients: Record = {}; const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState => typeof getStateOrSelectedServer === 'function'; -const getSelectedServerFromState = (getState: GetState): SelectedServer => prop('selectedServer', getState()); - -export type ShlinkApiClientBuilder = (getStateOrSelectedServer: GetState | ServerWithId) => ShlinkApiClient; - -export const buildShlinkApiClient = (axios: AxiosInstance): ShlinkApiClientBuilder => ( - getStateOrSelectedServer: GetState | ServerWithId, -) => { - const server = isGetState(getStateOrSelectedServer) - ? getSelectedServerFromState(getStateOrSelectedServer) - : getStateOrSelectedServer; - - if (!hasServerData(server)) { +const getSelectedServerFromState = (getState: GetState): ServerWithId => { + const { selectedServer } = getState(); + if (!hasServerData(selectedServer)) { throw new Error('There\'s no selected server or it is not found'); } - const { url, apiKey } = server; + return selectedServer; +}; + +export const buildShlinkApiClient = (axios: AxiosInstance) => (getStateOrSelectedServer: GetState | ServerWithId) => { + const { url, apiKey } = isGetState(getStateOrSelectedServer) + ? getSelectedServerFromState(getStateOrSelectedServer) + : getStateOrSelectedServer; const clientKey = `${url}_${apiKey}`; if (!apiClients[clientKey]) { @@ -32,3 +28,5 @@ export const buildShlinkApiClient = (axios: AxiosInstance): ShlinkApiClientBuild return apiClients[clientKey]; }; + +export type ShlinkApiClientBuilder = ReturnType; diff --git a/src/mercure/reducers/mercureInfo.ts b/src/mercure/reducers/mercureInfo.ts index ca882138..378c6fa9 100644 --- a/src/mercure/reducers/mercureInfo.ts +++ b/src/mercure/reducers/mercureInfo.ts @@ -1,52 +1,44 @@ -import { Action, Dispatch } from 'redux'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { ShlinkMercureInfo } from '../../api/types'; -import { GetState } from '../../container/types'; -import { buildReducer } from '../../utils/helpers/redux'; +import { ShlinkState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START'; -export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR'; -export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO'; +const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO'; -export interface MercureInfo { - token?: string; - mercureHubUrl?: string; +export interface MercureInfo extends Partial { interval?: number; loading: boolean; error: boolean; } -export type GetMercureInfoAction = Action & ShlinkMercureInfo & { interval?: number }; - const initialState: MercureInfo = { loading: true, error: false, }; -export default buildReducer({ - [GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }), - [GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }), - [GET_MERCURE_INFO]: (_, action) => ({ ...action, loading: false, error: false }), -}, initialState); +export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { + const loadMercureInfo = createAsyncThunk( + GET_MERCURE_INFO, + async (_, { getState }) => { + const { settings } = getState(); + if (!settings.realTimeUpdates.enabled) { + throw new Error('Real time updates not enabled'); + } -export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) => - () => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: GET_MERCURE_INFO_START }); + return buildShlinkApiClient(getState).mercureInfo(); + }, + ); - const { settings } = getState(); - const { mercureInfo } = buildShlinkApiClient(getState); + const { reducer } = createSlice({ + name: 'mercureInfoReducer', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(loadMercureInfo.pending, (state) => ({ ...state, loading: true, error: false })); + builder.addCase(loadMercureInfo.rejected, (state) => ({ ...state, loading: false, error: true })); + builder.addCase(loadMercureInfo.fulfilled, (_, { payload }) => ({ ...payload, loading: false, error: false })); + }, + }); - if (!settings.realTimeUpdates.enabled) { - dispatch({ type: GET_MERCURE_INFO_ERROR }); - - return; - } - - try { - const info = await mercureInfo(); - - dispatch({ type: GET_MERCURE_INFO, interval: settings.realTimeUpdates.interval, ...info }); - } catch (e) { - dispatch({ type: GET_MERCURE_INFO_ERROR }); - } - }; + return { loadMercureInfo, reducer }; +}; diff --git a/src/mercure/services/provideServices.ts b/src/mercure/services/provideServices.ts index bf29b207..06c341c7 100644 --- a/src/mercure/services/provideServices.ts +++ b/src/mercure/services/provideServices.ts @@ -1,9 +1,14 @@ +import { prop } from 'ramda'; import Bottle from 'bottlejs'; -import { loadMercureInfo } from '../reducers/mercureInfo'; +import { mercureInfoReducerCreator } from '../reducers/mercureInfo'; const provideServices = (bottle: Bottle) => { + // Reducer + bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator'); + // Actions - bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient'); + bottle.serviceFactory('loadMercureInfo', prop('loadMercureInfo'), 'mercureInfoReducerCreator'); }; export default provideServices; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 35c3e5d7..02fb1ed3 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -15,7 +15,6 @@ 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'; -import mercureInfoReducer from '../mercure/reducers/mercureInfo'; import settingsReducer from '../settings/reducers/settings'; import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; @@ -38,7 +37,7 @@ export default (container: IContainer) => combineReducers({ tagsList: tagsListReducer, tagDelete: tagDeleteReducer, tagEdit: tagEditReducer, - mercureInfo: mercureInfoReducer, + mercureInfo: container.mercureInfoReducer, settings: settingsReducer, domainsList: container.domainsListReducer, visitsOverview: visitsOverviewReducer, diff --git a/test/mercure/reducers/mercureInfo.test.ts b/test/mercure/reducers/mercureInfo.test.ts index d5cb2972..2dcc71b0 100644 --- a/test/mercure/reducers/mercureInfo.test.ts +++ b/test/mercure/reducers/mercureInfo.test.ts @@ -1,12 +1,5 @@ import { Mock } from 'ts-mockery'; -import reducer, { - GET_MERCURE_INFO_START, - GET_MERCURE_INFO_ERROR, - GET_MERCURE_INFO, - loadMercureInfo, - GetMercureInfoAction, -} from '../../../src/mercure/reducers/mercureInfo'; -import { ShlinkMercureInfo } from '../../../src/api/types'; +import { mercureInfoReducerCreator } from '../../../src/mercure/reducers/mercureInfo'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { GetState } from '../../../src/container/types'; @@ -15,39 +8,35 @@ describe('mercureInfoReducer', () => { mercureHubUrl: 'http://example.com/.well-known/mercure', token: 'abc.123.def', }; + const getMercureInfo = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ mercureInfo: getMercureInfo }); + const { loadMercureInfo, reducer } = mercureInfoReducerCreator(buildShlinkApiClient); + + beforeEach(jest.resetAllMocks); describe('reducer', () => { - const action = (type: string, args: Partial = {}) => Mock.of( - { type, ...args }, - ); - it('returns loading on GET_MERCURE_INFO_START', () => { - expect(reducer(undefined, action(GET_MERCURE_INFO_START))).toEqual({ + expect(reducer(undefined, { type: loadMercureInfo.pending.toString() })).toEqual({ loading: true, error: false, }); }); it('returns error on GET_MERCURE_INFO_ERROR', () => { - expect(reducer(undefined, action(GET_MERCURE_INFO_ERROR))).toEqual({ + expect(reducer(undefined, { type: loadMercureInfo.rejected.toString() })).toEqual({ loading: false, error: true, }); }); it('returns mercure info on GET_MERCURE_INFO', () => { - expect(reducer(undefined, { type: GET_MERCURE_INFO, ...mercureInfo })).toEqual(expect.objectContaining({ - ...mercureInfo, - loading: false, - error: false, - })); + expect(reducer(undefined, { type: loadMercureInfo.fulfilled.toString(), payload: mercureInfo })).toEqual( + expect.objectContaining({ ...mercureInfo, loading: false, error: false }), + ); }); }); describe('loadMercureInfo', () => { - const createApiClientMock = (result: Promise) => Mock.of({ - mercureInfo: jest.fn().mockReturnValue(result), - }); const dispatch = jest.fn(); const createGetStateMock = (enabled: boolean): GetState => jest.fn().mockReturnValue({ settings: { @@ -55,43 +44,55 @@ describe('mercureInfoReducer', () => { }, }); - afterEach(jest.resetAllMocks); - it('dispatches error when real time updates are disabled', async () => { - const apiClientMock = createApiClientMock(Promise.resolve(mercureInfo)); + getMercureInfo.mockResolvedValue(mercureInfo); const getState = createGetStateMock(false); - await loadMercureInfo(() => apiClientMock)()(dispatch, getState); + await loadMercureInfo()(dispatch, getState, {}); - expect(apiClientMock.mercureInfo).not.toHaveBeenCalled(); + expect(getMercureInfo).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: loadMercureInfo.pending.toString(), + })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: loadMercureInfo.rejected.toString(), + })); }); it('calls API on success', async () => { - const apiClientMock = createApiClientMock(Promise.resolve(mercureInfo)); + getMercureInfo.mockResolvedValue(mercureInfo); const getState = createGetStateMock(true); - await loadMercureInfo(() => apiClientMock)()(dispatch, getState); + await loadMercureInfo()(dispatch, getState, {}); - expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1); + expect(getMercureInfo).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO, ...mercureInfo }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: loadMercureInfo.pending.toString(), + })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: loadMercureInfo.fulfilled.toString(), + payload: mercureInfo, + })); }); it('throws error on failure', async () => { const error = 'Error'; - const apiClientMock = createApiClientMock(Promise.reject(error)); const getState = createGetStateMock(true); - await loadMercureInfo(() => apiClientMock)()(dispatch, getState); + getMercureInfo.mockRejectedValue(error); - expect(apiClientMock.mercureInfo).toHaveBeenCalledTimes(1); + await loadMercureInfo()(dispatch, getState, {}); + + expect(getMercureInfo).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: GET_MERCURE_INFO_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: GET_MERCURE_INFO_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: loadMercureInfo.pending.toString(), + })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: loadMercureInfo.rejected.toString(), + })); }); }); });