Migrated selectServer action to RTK and moved loadMercureInfo to an action listener

This commit is contained in:
Alejandro Celaya 2022-11-10 22:44:25 +01:00
parent 2e0e24d87b
commit 6221f9ed05
6 changed files with 71 additions and 68 deletions

View file

@ -20,5 +20,5 @@ export const setUpStore = (container: IContainer) => configureStore({
preloadedState, preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk( middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk(
{ immutableCheck: false, serializableCheck: false }, // State is too big for these { immutableCheck: false, serializableCheck: false }, // State is too big for these
).concat(save(localStorageConfig)), ).prepend(container.selectServerListener.middleware).concat(save(localStorageConfig)),
}); });

View file

@ -1,5 +1,5 @@
import { IContainer } from 'bottlejs'; import { IContainer } from 'bottlejs';
import { combineReducers } from 'redux'; import { combineReducers } from '@reduxjs/toolkit';
import { serversReducer } from '../servers/reducers/servers'; import { serversReducer } from '../servers/reducers/servers';
import selectedServerReducer from '../servers/reducers/selectedServer'; import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';

View file

@ -1,15 +1,13 @@
import { PayloadAction } from '@reduxjs/toolkit'; import { createAction, createListenerMiddleware, PayloadAction } from '@reduxjs/toolkit';
import { identity, memoizeWith, pipe } from 'ramda'; import { identity, memoizeWith, pipe } from 'ramda';
import { Action, Dispatch } from 'redux';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version'; import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
import { SelectedServer } from '../data'; import { isReachableServer, SelectedServer } from '../data';
import { GetState } from '../../container/types';
import { ShlinkHealth } from '../../api/types'; 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'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; export const SELECT_SERVER = 'shlink/selectedServer/selectServer';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER'; export const RESET_SELECTED_SERVER = 'shlink/selectedServer/resetSelectedServer';
export const MIN_FALLBACK_VERSION = '1.0.0'; export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999'; export const MAX_FALLBACK_VERSION = '999.999.999';
@ -35,50 +33,49 @@ const initialState: SelectedServer = null;
export default buildReducer<SelectedServer, SelectServerAction>({ export default buildReducer<SelectedServer, SelectServerAction>({
[RESET_SELECTED_SERVER]: () => initialState, [RESET_SELECTED_SERVER]: () => initialState,
[SELECT_SERVER]: (_, { payload }) => payload, [SELECT_SERVER]: (_, { payload }) => payload,
[`${SELECT_SERVER}/fulfilled`]: (_, { payload }) => payload,
}, initialState); }, initialState);
export const resetSelectedServer = buildActionCreator(RESET_SELECTED_SERVER); export const resetSelectedServer = createAction<void>(RESET_SELECTED_SERVER);
export const selectServer = ( export const selectServer = (
buildShlinkApiClient: ShlinkApiClientBuilder, buildShlinkApiClient: ShlinkApiClientBuilder,
loadMercureInfo: () => Action, ) => createAsyncThunk(SELECT_SERVER, async (serverId: string, { dispatch, getState }): Promise<SelectedServer> => {
) => (
serverId: string,
) => async (
dispatch: Dispatch,
getState: GetState,
) => {
dispatch(resetSelectedServer()); dispatch(resetSelectedServer());
const { servers } = getState(); const { servers } = getState();
const selectedServer = servers[serverId]; const selectedServer = servers[serverId];
if (!selectedServer) { if (!selectedServer) {
dispatch<SelectServerAction>({ return { serverNotFound: true };
type: SELECT_SERVER,
payload: { serverNotFound: true },
});
return;
} }
try { try {
const { health } = buildShlinkApiClient(selectedServer); const { health } = buildShlinkApiClient(selectedServer);
const { version, printableVersion } = await getServerVersion(serverId, health); const { version, printableVersion } = await getServerVersion(serverId, health);
dispatch<SelectServerAction>({ return {
type: SELECT_SERVER,
payload: {
...selectedServer, ...selectedServer,
version, version,
printableVersion, printableVersion,
};
} catch (e) {
return { ...selectedServer, serverNotReachable: true };
}
});
export const selectServerListener = (
selectServerThunk: ReturnType<typeof selectServer>,
loadMercureInfo: () => PayloadAction<any>, // TODO Consider setting actual type, if relevant
) => {
const listener = createListenerMiddleware();
listener.startListening({
actionCreator: selectServerThunk.fulfilled,
effect: ({ payload }, { dispatch }) => {
isReachableServer(payload) && dispatch(loadMercureInfo());
}, },
}); });
dispatch(loadMercureInfo());
} catch (e) { return listener;
dispatch<SelectServerAction>({
type: SELECT_SERVER,
payload: { ...selectedServer, serverNotReachable: true },
});
}
}; };

View file

@ -5,7 +5,7 @@ import { DeleteServerModal } from '../DeleteServerModal';
import { DeleteServerButton } from '../DeleteServerButton'; import { DeleteServerButton } from '../DeleteServerButton';
import { EditServer } from '../EditServer'; import { EditServer } from '../EditServer';
import { ImportServersBtn } from '../helpers/ImportServersBtn'; 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 { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { fetchServers } from '../reducers/remoteServers'; import { fetchServers } from '../reducers/remoteServers';
import { ServerError } from '../helpers/ServerError'; import { ServerError } from '../helpers/ServerError';
@ -77,6 +77,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('fetchServers', fetchServers, 'axios'); bottle.serviceFactory('fetchServers', fetchServers, 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
// Reducers
bottle.serviceFactory('selectServerListener', selectServerListener, 'selectServer', 'loadMercureInfo');
}; };
export default provideServices; export default provideServices;

View file

@ -1,13 +1,11 @@
import { createAction, PayloadAction } from '@reduxjs/toolkit'; import { createAction, PayloadAction } from '@reduxjs/toolkit';
import { CreateVisit } from '../types'; import { CreateVisit } from '../types';
const CREATE_VISITS = 'shlink/visitCreation/CREATE_VISITS';
export type CreateVisitsAction = PayloadAction<{ export type CreateVisitsAction = PayloadAction<{
createdVisits: CreateVisit[]; createdVisits: CreateVisit[];
}>; }>;
export const createNewVisits = createAction( export const createNewVisits = createAction(
CREATE_VISITS, 'shlink/visitCreation/createNewVisits',
(createdVisits: CreateVisit[]) => ({ payload: { createdVisits } }), (createdVisits: CreateVisit[]) => ({ payload: { createdVisits } }),
); );

View file

@ -1,31 +1,36 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import reducer, { import reducer, {
selectServer, selectServer as selectServerCreator,
resetSelectedServer, resetSelectedServer,
RESET_SELECTED_SERVER,
SELECT_SERVER,
MAX_FALLBACK_VERSION, MAX_FALLBACK_VERSION,
MIN_FALLBACK_VERSION, MIN_FALLBACK_VERSION,
} from '../../../src/servers/reducers/selectedServer'; } from '../../../src/servers/reducers/selectedServer';
import { ShlinkState } from '../../../src/container/types'; import { ShlinkState } from '../../../src/container/types';
import { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data'; import { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data';
import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient';
describe('selectedServerReducer', () => { describe('selectedServerReducer', () => {
const health = jest.fn();
const buildApiClient = jest.fn().mockReturnValue(Mock.of<ShlinkApiClient>({ health }));
const selectServer = selectServerCreator(buildApiClient);
afterEach(jest.clearAllMocks);
describe('reducer', () => { describe('reducer', () => {
it('returns default when action is RESET_SELECTED_SERVER', () => 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', () => { it('returns selected server when action is SELECT_SERVER', () => {
const payload = Mock.of<RegularServer>({ id: 'abc123' }); const payload = Mock.of<RegularServer>({ id: 'abc123' });
expect(reducer(null, { type: SELECT_SERVER, payload })).toEqual(payload); expect(reducer(null, { type: selectServer.fulfilled.toString(), payload })).toEqual(payload);
}); });
}); });
describe('resetSelectedServer', () => { describe('resetSelectedServer', () => {
it('returns proper action', () => { 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 version = '1.19.0';
const createGetStateMock = (id: string) => jest.fn().mockReturnValue({ servers: { [id]: selectedServer } }); 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 dispatch = jest.fn();
const loadMercureInfo = jest.fn();
afterEach(jest.clearAllMocks);
it.each([ it.each([
[version, version, `v${version}`], [version, version, `v${version}`],
@ -57,21 +55,24 @@ describe('selectedServerReducer', () => {
printableVersion: expectedPrintableVersion, 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).toHaveBeenCalledTimes(3);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER }); expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: selectServer.pending.toString() }));
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, payload: expectedSelectedServer }); expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: resetSelectedServer.toString() }));
expect(loadMercureInfo).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({
type: selectServer.fulfilled.toString(),
payload: expectedSelectedServer,
}));
}); });
it('invokes dependencies', async () => { it('invokes dependencies', async () => {
const id = uuid(); const id = uuid();
const getState = createGetStateMock(id); const getState = createGetStateMock(id);
await selectServer(buildApiClient, loadMercureInfo)(id)(jest.fn(), getState); await selectServer(id)(jest.fn(), getState, {});
expect(getState).toHaveBeenCalledTimes(1); expect(getState).toHaveBeenCalledTimes(1);
expect(buildApiClient).toHaveBeenCalledTimes(1); expect(buildApiClient).toHaveBeenCalledTimes(1);
@ -82,13 +83,15 @@ describe('selectedServerReducer', () => {
const getState = createGetStateMock(id); const getState = createGetStateMock(id);
const expectedSelectedServer = Mock.of<NonReachableServer>({ ...selectedServer, serverNotReachable: true }); const expectedSelectedServer = Mock.of<NonReachableServer>({ ...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(health).toHaveBeenCalled();
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, payload: expectedSelectedServer }); expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({
expect(loadMercureInfo).not.toHaveBeenCalled(); type: selectServer.fulfilled.toString(),
payload: expectedSelectedServer,
}));
}); });
it('dispatches error when server is not found', async () => { it('dispatches error when server is not found', async () => {
@ -96,12 +99,14 @@ describe('selectedServerReducer', () => {
const getState = jest.fn(() => Mock.of<ShlinkState>({ servers: {} })); const getState = jest.fn(() => Mock.of<ShlinkState>({ servers: {} }));
const expectedSelectedServer: NotFoundServer = { serverNotFound: true }; const expectedSelectedServer: NotFoundServer = { serverNotFound: true };
await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState); await selectServer(id)(dispatch, getState, {});
expect(getState).toHaveBeenCalled(); expect(getState).toHaveBeenCalled();
expect(apiClientMock.health).not.toHaveBeenCalled(); expect(health).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, payload: expectedSelectedServer }); expect(dispatch).toHaveBeenNthCalledWith(3, expect.objectContaining({
expect(loadMercureInfo).not.toHaveBeenCalled(); type: selectServer.fulfilled.toString(),
payload: expectedSelectedServer,
}));
}); });
}); });
}); });