diff --git a/src/container/store.ts b/src/container/store.ts index 5f214f59..7af11755 100644 --- a/src/container/store.ts +++ b/src/container/store.ts @@ -20,5 +20,5 @@ export const setUpStore = (container: IContainer) => configureStore({ preloadedState, middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk( { immutableCheck: false, serializableCheck: false }, // State is too big for these - ).concat(save(localStorageConfig)), + ).prepend(container.selectServerListener.middleware).concat(save(localStorageConfig)), }); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 66ff189c..6d0d71fd 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,5 +1,5 @@ import { IContainer } from 'bottlejs'; -import { combineReducers } from 'redux'; +import { combineReducers } from '@reduxjs/toolkit'; import { serversReducer } from '../servers/reducers/servers'; import selectedServerReducer from '../servers/reducers/selectedServer'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; diff --git a/src/servers/reducers/selectedServer.ts b/src/servers/reducers/selectedServer.ts index 78c0d69d..f6ef4500 100644 --- a/src/servers/reducers/selectedServer.ts +++ b/src/servers/reducers/selectedServer.ts @@ -1,15 +1,13 @@ -import { PayloadAction } from '@reduxjs/toolkit'; +import { createAction, createListenerMiddleware, PayloadAction } from '@reduxjs/toolkit'; import { identity, memoizeWith, pipe } from 'ramda'; -import { Action, Dispatch } from 'redux'; import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version'; -import { SelectedServer } from '../data'; -import { GetState } from '../../container/types'; +import { isReachableServer, SelectedServer } from '../data'; import { ShlinkHealth } from '../../api/types'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { buildReducer, createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; -export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER'; +export const SELECT_SERVER = 'shlink/selectedServer/selectServer'; +export const RESET_SELECTED_SERVER = 'shlink/selectedServer/resetSelectedServer'; export const MIN_FALLBACK_VERSION = '1.0.0'; export const MAX_FALLBACK_VERSION = '999.999.999'; @@ -35,50 +33,49 @@ const initialState: SelectedServer = null; export default buildReducer({ [RESET_SELECTED_SERVER]: () => initialState, [SELECT_SERVER]: (_, { payload }) => payload, + [`${SELECT_SERVER}/fulfilled`]: (_, { payload }) => payload, }, initialState); -export const resetSelectedServer = buildActionCreator(RESET_SELECTED_SERVER); +export const resetSelectedServer = createAction(RESET_SELECTED_SERVER); export const selectServer = ( buildShlinkApiClient: ShlinkApiClientBuilder, - loadMercureInfo: () => Action, -) => ( - serverId: string, -) => async ( - dispatch: Dispatch, - getState: GetState, -) => { +) => createAsyncThunk(SELECT_SERVER, async (serverId: string, { dispatch, getState }): Promise => { dispatch(resetSelectedServer()); const { servers } = getState(); const selectedServer = servers[serverId]; if (!selectedServer) { - dispatch({ - type: SELECT_SERVER, - payload: { serverNotFound: true }, - }); - - return; + return { serverNotFound: true }; } try { const { health } = buildShlinkApiClient(selectedServer); const { version, printableVersion } = await getServerVersion(serverId, health); - dispatch({ - type: SELECT_SERVER, - payload: { - ...selectedServer, - version, - printableVersion, - }, - }); - dispatch(loadMercureInfo()); + return { + ...selectedServer, + version, + printableVersion, + }; } catch (e) { - dispatch({ - type: SELECT_SERVER, - payload: { ...selectedServer, serverNotReachable: true }, - }); + return { ...selectedServer, serverNotReachable: true }; } +}); + +export const selectServerListener = ( + selectServerThunk: ReturnType, + loadMercureInfo: () => PayloadAction, // TODO Consider setting actual type, if relevant +) => { + const listener = createListenerMiddleware(); + + listener.startListening({ + actionCreator: selectServerThunk.fulfilled, + effect: ({ payload }, { dispatch }) => { + isReachableServer(payload) && dispatch(loadMercureInfo()); + }, + }); + + return listener; }; diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index f6e03f88..e9acf638 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -5,7 +5,7 @@ import { DeleteServerModal } from '../DeleteServerModal'; import { DeleteServerButton } from '../DeleteServerButton'; import { EditServer } from '../EditServer'; import { ImportServersBtn } from '../helpers/ImportServersBtn'; -import { resetSelectedServer, selectServer } from '../reducers/selectedServer'; +import { resetSelectedServer, selectServer, selectServerListener } from '../reducers/selectedServer'; import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers'; import { fetchServers } from '../reducers/remoteServers'; import { ServerError } from '../helpers/ServerError'; @@ -77,6 +77,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('fetchServers', fetchServers, 'axios'); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); + + // Reducers + bottle.serviceFactory('selectServerListener', selectServerListener, 'selectServer', 'loadMercureInfo'); }; export default provideServices; diff --git a/src/visits/reducers/visitCreation.ts b/src/visits/reducers/visitCreation.ts index 3f7f9137..eb037bd7 100644 --- a/src/visits/reducers/visitCreation.ts +++ b/src/visits/reducers/visitCreation.ts @@ -1,13 +1,11 @@ import { createAction, PayloadAction } from '@reduxjs/toolkit'; import { CreateVisit } from '../types'; -const CREATE_VISITS = 'shlink/visitCreation/CREATE_VISITS'; - export type CreateVisitsAction = PayloadAction<{ createdVisits: CreateVisit[]; }>; export const createNewVisits = createAction( - CREATE_VISITS, + 'shlink/visitCreation/createNewVisits', (createdVisits: CreateVisit[]) => ({ payload: { createdVisits } }), ); diff --git a/test/servers/reducers/selectedServer.test.ts b/test/servers/reducers/selectedServer.test.ts index f04a4eed..313ab639 100644 --- a/test/servers/reducers/selectedServer.test.ts +++ b/test/servers/reducers/selectedServer.test.ts @@ -1,31 +1,36 @@ import { v4 as uuid } from 'uuid'; import { Mock } from 'ts-mockery'; import reducer, { - selectServer, + selectServer as selectServerCreator, resetSelectedServer, - RESET_SELECTED_SERVER, - SELECT_SERVER, MAX_FALLBACK_VERSION, MIN_FALLBACK_VERSION, } from '../../../src/servers/reducers/selectedServer'; import { ShlinkState } from '../../../src/container/types'; import { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data'; +import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; describe('selectedServerReducer', () => { + const health = jest.fn(); + const buildApiClient = jest.fn().mockReturnValue(Mock.of({ health })); + const selectServer = selectServerCreator(buildApiClient); + + afterEach(jest.clearAllMocks); + describe('reducer', () => { it('returns default when action is RESET_SELECTED_SERVER', () => - expect(reducer(null, { type: RESET_SELECTED_SERVER, payload: null })).toBeNull()); + expect(reducer(null, { type: resetSelectedServer.toString(), payload: null })).toBeNull()); it('returns selected server when action is SELECT_SERVER', () => { const payload = Mock.of({ id: 'abc123' }); - expect(reducer(null, { type: SELECT_SERVER, payload })).toEqual(payload); + expect(reducer(null, { type: selectServer.fulfilled.toString(), payload })).toEqual(payload); }); }); describe('resetSelectedServer', () => { it('returns proper action', () => { - expect(resetSelectedServer()).toEqual({ type: RESET_SELECTED_SERVER }); + expect(resetSelectedServer()).toEqual({ type: resetSelectedServer.toString() }); }); }); @@ -35,14 +40,7 @@ describe('selectedServerReducer', () => { }; const version = '1.19.0'; const createGetStateMock = (id: string) => jest.fn().mockReturnValue({ servers: { [id]: selectedServer } }); - const apiClientMock = { - health: jest.fn(), - }; - const buildApiClient = jest.fn().mockReturnValue(apiClientMock); const dispatch = jest.fn(); - const loadMercureInfo = jest.fn(); - - afterEach(jest.clearAllMocks); it.each([ [version, version, `v${version}`], @@ -57,21 +55,24 @@ describe('selectedServerReducer', () => { printableVersion: expectedPrintableVersion, }; - apiClientMock.health.mockResolvedValue({ version: serverVersion }); + health.mockResolvedValue({ version: serverVersion }); - await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState); + await selectServer(id)(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(3); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, payload: expectedSelectedServer }); - expect(loadMercureInfo).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: selectServer.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: resetSelectedServer.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({ + type: selectServer.fulfilled.toString(), + payload: expectedSelectedServer, + })); }); it('invokes dependencies', async () => { const id = uuid(); const getState = createGetStateMock(id); - await selectServer(buildApiClient, loadMercureInfo)(id)(jest.fn(), getState); + await selectServer(id)(jest.fn(), getState, {}); expect(getState).toHaveBeenCalledTimes(1); expect(buildApiClient).toHaveBeenCalledTimes(1); @@ -82,13 +83,15 @@ describe('selectedServerReducer', () => { const getState = createGetStateMock(id); const expectedSelectedServer = Mock.of({ ...selectedServer, serverNotReachable: true }); - apiClientMock.health.mockRejectedValue({}); + health.mockRejectedValue({}); - await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState); + await selectServer(id)(dispatch, getState, {}); - expect(apiClientMock.health).toHaveBeenCalled(); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, payload: expectedSelectedServer }); - expect(loadMercureInfo).not.toHaveBeenCalled(); + expect(health).toHaveBeenCalled(); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({ + type: selectServer.fulfilled.toString(), + payload: expectedSelectedServer, + })); }); it('dispatches error when server is not found', async () => { @@ -96,12 +99,14 @@ describe('selectedServerReducer', () => { const getState = jest.fn(() => Mock.of({ servers: {} })); const expectedSelectedServer: NotFoundServer = { serverNotFound: true }; - await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState); + await selectServer(id)(dispatch, getState, {}); expect(getState).toHaveBeenCalled(); - expect(apiClientMock.health).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, payload: expectedSelectedServer }); - expect(loadMercureInfo).not.toHaveBeenCalled(); + expect(health).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({ + type: selectServer.fulfilled.toString(), + payload: expectedSelectedServer, + })); }); }); });