diff --git a/src/container/store.ts b/src/container/store.ts index 569db995..5f214f59 100644 --- a/src/container/store.ts +++ b/src/container/store.ts @@ -1,3 +1,4 @@ +import { IContainer } from 'bottlejs'; import { save, load, RLSOptions } from 'redux-localstorage-simple'; import { configureStore } from '@reduxjs/toolkit'; import reducer from '../reducers'; @@ -13,9 +14,9 @@ const localStorageConfig: RLSOptions = { }; const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState); -export const store = configureStore({ +export const setUpStore = (container: IContainer) => configureStore({ devTools: !isProduction, - reducer, + reducer: reducer(container), preloadedState, middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk( { immutableCheck: false, serializableCheck: false }, // State is too big for these diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index b1f5e061..40e0fcd6 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -1,15 +1,17 @@ -import { Action, Dispatch } from 'redux'; +import { createSlice, PayloadAction, createAsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit'; +import { Dispatch } from 'redux'; +import { AxiosError } from 'axios'; import { ShlinkDomainRedirects } from '../../api/types'; -import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { GetState } from '../../container/types'; -import { parseApiError } from '../../api/utils'; +import { GetState, ShlinkState } from '../../container/types'; import { ApiErrorAction } from '../../api/types/actions'; import { Domain, DomainStatus } from '../data'; import { hasServerData } from '../../servers/data'; import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; import { ProblemDetailsError } from '../../api/types/errors'; +import { parseApiError } from '../../api/utils'; +import { buildReducer } from '../../utils/helpers/redux'; export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START'; export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; @@ -26,19 +28,17 @@ export interface DomainsList { errorData?: ProblemDetailsError; } -export interface ListDomainsAction extends Action { +type ListDomainsAction = PayloadAction<{ domains: Domain[]; defaultRedirects?: ShlinkDomainRedirects; -} +}>; -interface FilterDomainsAction extends Action { - searchTerm: string; -} +type FilterDomainsAction = PayloadAction; -interface ValidateDomain extends Action { +type ValidateDomain = PayloadAction<{ domain: string; status: DomainStatus; -} +}>; const initialState: DomainsList = { domains: [], @@ -59,27 +59,28 @@ export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomain export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => (d: Domain): Domain => (d.domain !== domain ? d : { ...d, status }); -export default buildReducer({ +const oldReducer = buildReducer({ [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), - [LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }), - [LIST_DOMAINS]: (_, { domains, defaultRedirects }) => - ({ ...initialState, domains, filteredDomains: domains, defaultRedirects }), - [FILTER_DOMAINS]: (state, { searchTerm }) => ({ + [LIST_DOMAINS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), + [LIST_DOMAINS]: (_, { payload }) => ({ ...initialState, searchTerm: payload, filteredDomains: payload.domains }), + [FILTER_DOMAINS]: (state, { payload }) => ({ ...state, - filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm.toLowerCase())), + filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(payload.toLowerCase())), }), [EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({ ...state, domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)), filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), }), - [VALIDATE_DOMAIN]: (state, { domain, status }) => ({ + [VALIDATE_DOMAIN]: (state, { payload }) => ({ ...state, - domains: state.domains.map(replaceStatusOnDomain(domain, status)), - filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)), + domains: state.domains.map(replaceStatusOnDomain(payload.domain, payload.status)), + filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)), }), }, initialState); +export default oldReducer; + export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( dispatch: Dispatch, getState: GetState, @@ -88,18 +89,21 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); try { - const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({ + const payload = await shlinkListDomains().then(({ data, defaultRedirects }) => ({ domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), defaultRedirects, })); - dispatch({ type: LIST_DOMAINS, ...resp }); + dispatch({ type: LIST_DOMAINS, payload }); } catch (e: any) { dispatch({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); } }; -export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); +export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ + type: FILTER_DOMAINS, + payload: searchTerm, +}); export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async ( dispatch: Dispatch, @@ -108,7 +112,10 @@ export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) const { selectedServer } = getState(); if (!hasServerData(selectedServer)) { - dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + dispatch({ + type: VALIDATE_DOMAIN, + payload: { domain, status: 'invalid' }, + }); return; } @@ -122,8 +129,102 @@ export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) const { status } = await health(); - dispatch({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' }); + dispatch({ + type: VALIDATE_DOMAIN, + payload: { domain, status: status === 'pass' ? 'valid' : 'invalid' }, + }); } catch (e) { - dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + dispatch({ + type: VALIDATE_DOMAIN, + payload: { domain, status: 'invalid' }, + }); } }; + +export const domainsReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const listDomains = createAsyncThunk<{ + domains: Domain[]; + defaultRedirects?: ShlinkDomainRedirects; + }, void, { state: ShlinkState }>( + LIST_DOMAINS, + async (_, { getState }) => { + const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); + const { data, defaultRedirects } = await shlinkListDomains(); + + return { + domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), + defaultRedirects, + }; + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-shadow + const checkDomainHealth = createAsyncThunk<{ domain: string; status: DomainStatus }, string, { state: ShlinkState }>( + VALIDATE_DOMAIN, + async (domain: string, { getState }) => { + const { selectedServer } = getState(); + + if (!hasServerData(selectedServer)) { + return { domain, status: 'invalid' }; + } + + try { + const { url, ...rest } = selectedServer; + const { health } = buildShlinkApiClient({ + ...rest, + url: replaceAuthorityFromUri(url, domain), + }); + + const { status } = await health(); + + return { domain, status: status === 'pass' ? 'valid' : 'invalid' }; + } catch (e) { + return { domain, status: 'invalid' }; + } + }, + ); + + const { actions, reducer } = createSlice>({ + name: 'domainsList', + initialState, + reducers: { + filterDomains: (state, { payload }) => { + // eslint-disable-next-line no-param-reassign + state.filteredDomains = state.domains.filter( + ({ domain }) => domain.toLowerCase().match(payload.toLowerCase()), + ); + }, + }, + extraReducers: (builder) => { + builder.addCase(listDomains.pending, () => ({ ...initialState, loading: true })); + builder.addCase(listDomains.rejected, (_, { error }) => ( + { ...initialState, error: true, errorData: parseApiError(error as AxiosError) } // TODO Fix this casting + )); + builder.addCase(listDomains.fulfilled, (_, { payload }) => ( + { ...initialState, ...payload, filteredDomains: payload.domains } + )); + + builder.addCase(checkDomainHealth.fulfilled, (state, { payload }) => { + // eslint-disable-next-line no-param-reassign + state.domains = state.domains.map(replaceStatusOnDomain(payload.domain, payload.status)); + // eslint-disable-next-line no-param-reassign + state.filteredDomains = state.filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)); + }); + + builder.addCase(EDIT_DOMAIN_REDIRECTS, (state, { domain, redirects }: any) => { // TODO Fix this "any" + // eslint-disable-next-line no-param-reassign + state.domains = state.domains.map(replaceRedirectsOnDomain(domain, redirects)); + // eslint-disable-next-line no-param-reassign + state.filteredDomains = state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)); + }); + }, + }); + + return { + reducer, + listDomains, + checkDomainHealth, + ...actions, + }; +}; diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index 9a4ba7fe..aaac9e2c 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -1,6 +1,7 @@ +import { prop } from 'ramda'; import Bottle from 'bottlejs'; import { ConnectDecorator } from '../../container/types'; -import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList'; +import { domainsReducerCreator } from '../reducers/domainsList'; import { DomainSelector } from '../DomainSelector'; import { ManageDomains } from '../ManageDomains'; import { editDomainRedirects } from '../reducers/domainRedirects'; @@ -16,11 +17,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { ['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'], )); + // Reducer + bottle.serviceFactory('domainsReducerCreator', domainsReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsReducerCreator'); // TODO Improve type checks on the prop that gets picked here + // Actions - bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); - bottle.serviceFactory('filterDomains', () => filterDomains); + bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsReducerCreator'); // TODO Improve type checks on the prop that gets picked here + bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsReducerCreator'); // TODO Improve type checks on the prop that gets picked here bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); - bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient'); + bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsReducerCreator'); // TODO Improve type checks on the prop that gets picked here }; export default provideServices; diff --git a/src/index.tsx b/src/index.tsx index dc13305e..e8d471c2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import pack from '../package.json'; import { container } from './container'; -import { store } from './container/store'; +import { setUpStore } from './container/store'; import { fixLeafletIcons } from './utils/helpers/leaflet'; import { register as registerServiceWorker } from './serviceWorkerRegistration'; import 'chart.js/auto'; // TODO Import specific ones to reduce bundle size https://react-chartjs-2.js.org/docs/migration-to-v4/#tree-shaking @@ -14,6 +14,7 @@ import './index.scss'; // This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS fixLeafletIcons(); +const store = setUpStore(container); const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container; createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion diff --git a/src/reducers/index.ts b/src/reducers/index.ts index da523b81..625fadbf 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,3 +1,4 @@ +import Bottle from 'bottlejs'; import { combineReducers } from 'redux'; import serversReducer from '../servers/reducers/servers'; import selectedServerReducer from '../servers/reducers/selectedServer'; @@ -16,13 +17,12 @@ 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 domainsListReducer from '../domains/reducers/domainsList'; import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import appUpdatesReducer from '../app/reducers/appUpdates'; import sidebarReducer from '../common/reducers/sidebar'; import { ShlinkState } from '../container/types'; -export default combineReducers({ +export default (container: Bottle.IContainer) => combineReducers({ servers: serversReducer, selectedServer: selectedServerReducer, shortUrlsList: shortUrlsListReducer, @@ -40,7 +40,7 @@ export default combineReducers({ tagEdit: tagEditReducer, mercureInfo: mercureInfoReducer, settings: settingsReducer, - domainsList: domainsListReducer, + domainsList: container.domainsListReducer, visitsOverview: visitsOverviewReducer, appUpdated: appUpdatesReducer, sidebar: sidebarReducer,