diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3afe33..399364b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed * [#713](https://github.com/shlinkio/shlink-web-client/issues/713) Updated dependencies. +* [#620](https://github.com/shlinkio/shlink-web-client/issues/620) Migrated domains-related reducers to redux toolkit as a POC. ### Deprecated * *Nothing* diff --git a/package-lock.json b/package-lock.json index f24f90ed..411c80d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fortawesome/free-regular-svg-icons": "^6.2.0", "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@reduxjs/toolkit": "^1.9.0", "axios": "^1.1.2", "bootstrap": "^5.2.2", "bottlejs": "^2.0.1", @@ -4267,6 +4268,29 @@ "react-dom": "^18.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.0.tgz", + "integrity": "sha512-ak11IrjYcUXRqlhNPwnz6AcvA2ynJTu8PzDbbqQw4a3xR4KZtgiqbNblQD+10CRbfK4+5C79SOyxnT9dhBqFnA==", + "dependencies": { + "immer": "^9.0.16", + "redux": "^4.2.0", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.7" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.1.tgz", @@ -12143,10 +12167,9 @@ } }, "node_modules/immer": { - "version": "9.0.12", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", - "integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==", - "dev": true, + "version": "9.0.16", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz", + "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==", "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -22723,9 +22746,9 @@ } }, "node_modules/redux-thunk": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", - "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", "peerDependencies": { "redux": "^4" } @@ -22895,6 +22918,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "node_modules/reselect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.7.tgz", + "integrity": "sha512-Zu1xbUt3/OPwsXL46hvOOoQrap2azE7ZQbokq61BQfiXvhewsKDwhMeZjTX9sX0nvw1t/U5Audyn1I9P/m9z0A==" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -29896,6 +29924,17 @@ "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", "requires": {} }, + "@reduxjs/toolkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.0.tgz", + "integrity": "sha512-ak11IrjYcUXRqlhNPwnz6AcvA2ynJTu8PzDbbqQw4a3xR4KZtgiqbNblQD+10CRbfK4+5C79SOyxnT9dhBqFnA==", + "requires": { + "immer": "^9.0.16", + "redux": "^4.2.0", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.7" + } + }, "@remix-run/router": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.1.tgz", @@ -35854,10 +35893,9 @@ "dev": true }, "immer": { - "version": "9.0.12", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", - "integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==", - "dev": true + "version": "9.0.16", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz", + "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==" }, "immutable": { "version": "4.0.0", @@ -43653,9 +43691,9 @@ } }, "redux-thunk": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", - "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", "requires": {} }, "regenerate": { @@ -43789,6 +43827,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "reselect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.7.tgz", + "integrity": "sha512-Zu1xbUt3/OPwsXL46hvOOoQrap2azE7ZQbokq61BQfiXvhewsKDwhMeZjTX9sX0nvw1t/U5Audyn1I9P/m9z0A==" + }, "resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", diff --git a/package.json b/package.json index cb026101..db375266 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@fortawesome/free-regular-svg-icons": "^6.2.0", "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@reduxjs/toolkit": "^1.9.0", "axios": "^1.1.2", "bootstrap": "^5.2.2", "bottlejs": "^2.0.1", diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 2156a410..64b7c0a1 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -8,7 +8,11 @@ import { RegularNotFound, } from '../types/errors'; -export const parseApiError = (e: AxiosError) => e.response?.data; +const isAxiosError = (e: unknown): e is AxiosError => !!e && typeof e === 'object' && 'response' in e; + +export const parseApiError = (e: unknown): ProblemDetailsError | undefined => ( + isAxiosError(e) ? e.response?.data : undefined +); export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError => error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT; diff --git a/src/container/store.ts b/src/container/store.ts index f4c80820..5f214f59 100644 --- a/src/container/store.ts +++ b/src/container/store.ts @@ -1,14 +1,11 @@ -import ReduxThunk from 'redux-thunk'; -import { applyMiddleware, compose, createStore } from 'redux'; +import { IContainer } from 'bottlejs'; import { save, load, RLSOptions } from 'redux-localstorage-simple'; -import reducers from '../reducers'; +import { configureStore } from '@reduxjs/toolkit'; +import reducer from '../reducers'; import { migrateDeprecatedSettings } from '../settings/helpers'; import { ShlinkState } from './types'; const isProduction = process.env.NODE_ENV === 'production'; -// eslint-disable-next-line no-mixed-operators -const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; - const localStorageConfig: RLSOptions = { states: ['settings', 'servers'], namespace: 'shlink', @@ -17,6 +14,11 @@ const localStorageConfig: RLSOptions = { }; const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState); -export const store = createStore(reducers, preloadedState, composeEnhancers( - applyMiddleware(save(localStorageConfig), ReduxThunk), -)); +export const setUpStore = (container: IContainer) => configureStore({ + devTools: !isProduction, + reducer: reducer(container), + preloadedState, + middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk( + { immutableCheck: false, serializableCheck: false }, // State is too big for these + ).concat(save(localStorageConfig)), +}); diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index f3ca7426..e57b0f36 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -8,11 +8,12 @@ import { SelectedServer } from '../servers/data'; import { Domain } from './data'; import { DomainStatusIcon } from './helpers/DomainStatusIcon'; import { DomainDropdown } from './helpers/DomainDropdown'; +import { EditDomainRedirects } from './reducers/domainRedirects'; interface DomainRowProps { domain: Domain; defaultRedirects?: ShlinkDomainRedirects; - editDomainRedirects: (domain: string, redirects: Partial) => Promise; + editDomainRedirects: (redirects: EditDomainRedirects) => Promise; checkDomainHealth: (domain: string) => void; selectedServer: SelectedServer; } diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx index a6170595..f7c52992 100644 --- a/src/domains/ManageDomains.tsx +++ b/src/domains/ManageDomains.tsx @@ -4,7 +4,7 @@ import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { SimpleCard } from '../utils/SimpleCard'; import { SearchField } from '../utils/SearchField'; -import { ShlinkDomainRedirects } from '../api/types'; +import { EditDomainRedirects } from './reducers/domainRedirects'; import { SelectedServer } from '../servers/data'; import { DomainsList } from './reducers/domainsList'; import { DomainRow } from './DomainRow'; @@ -12,7 +12,7 @@ import { DomainRow } from './DomainRow'; interface ManageDomainsProps { listDomains: Function; filterDomains: (searchTerm: string) => void; - editDomainRedirects: (domain: string, redirects: Partial) => Promise; + editDomainRedirects: (redirects: EditDomainRedirects) => Promise; checkDomainHealth: (domain: string) => void; domainsList: DomainsList; selectedServer: SelectedServer; diff --git a/src/domains/helpers/DomainDropdown.tsx b/src/domains/helpers/DomainDropdown.tsx index 423c4db5..a91b40a8 100644 --- a/src/domains/helpers/DomainDropdown.tsx +++ b/src/domains/helpers/DomainDropdown.tsx @@ -7,14 +7,14 @@ import { useToggle } from '../../utils/helpers/hooks'; import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu'; import { EditDomainRedirectsModal } from './EditDomainRedirectsModal'; import { Domain } from '../data'; -import { ShlinkDomainRedirects } from '../../api/types'; +import { EditDomainRedirects } from '../reducers/domainRedirects'; import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features'; import { getServerId, SelectedServer } from '../../servers/data'; import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; interface DomainDropdownProps { domain: Domain; - editDomainRedirects: (domain: string, redirects: Partial) => Promise; + editDomainRedirects: (redirects: EditDomainRedirects) => Promise; selectedServer: SelectedServer; } diff --git a/src/domains/helpers/EditDomainRedirectsModal.tsx b/src/domains/helpers/EditDomainRedirectsModal.tsx index 0bc86bf8..26d11be5 100644 --- a/src/domains/helpers/EditDomainRedirectsModal.tsx +++ b/src/domains/helpers/EditDomainRedirectsModal.tsx @@ -1,15 +1,16 @@ import { FC, useState } from 'react'; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; -import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; +import { ShlinkDomain } from '../../api/types'; import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup'; import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils'; import { InfoTooltip } from '../../utils/InfoTooltip'; +import { EditDomainRedirects } from '../reducers/domainRedirects'; interface EditDomainRedirectsModalProps { domain: ShlinkDomain; isOpen: boolean; toggle: () => void; - editDomainRedirects: (domain: string, redirects: Partial) => Promise; + editDomainRedirects: (redirects: EditDomainRedirects) => Promise; } const FormGroup: FC = ({ isLast, ...rest }) => ( @@ -30,10 +31,13 @@ export const EditDomainRedirectsModal: FC = ( const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState( domain.redirects?.invalidShortUrlRedirect ?? '', ); - const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, { - baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect), - regular404Redirect: nonEmptyValueOrNull(regular404Redirect), - invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect), + const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects({ + domain: domain.domain, + redirects: { + baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect), + regular404Redirect: nonEmptyValueOrNull(regular404Redirect), + invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect), + }, }).then(toggle)); return ( diff --git a/src/domains/reducers/domainRedirects.ts b/src/domains/reducers/domainRedirects.ts index 8cb65d93..aa97871b 100644 --- a/src/domains/reducers/domainRedirects.ts +++ b/src/domains/reducers/domainRedirects.ts @@ -1,31 +1,23 @@ -import { Action, Dispatch } from 'redux'; +import { createAsyncThunk } from '@reduxjs/toolkit'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkDomainRedirects } from '../../api/types'; -import { GetState } from '../../container/types'; -import { ApiErrorAction } from '../../api/types/actions'; -import { parseApiError } from '../../api/utils'; +import { ShlinkState } from '../../container/types'; -export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START'; -export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR'; -export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS'; +const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS'; -export interface EditDomainRedirectsAction extends Action { +export interface EditDomainRedirects { domain: string; redirects: ShlinkDomainRedirects; } -export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - domain: string, - domainRedirects: Partial, -) => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: EDIT_DOMAIN_REDIRECTS_START }); - const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState); - - try { +export const editDomainRedirects = ( + buildShlinkApiClient: ShlinkApiClientBuilder, +) => createAsyncThunk( + EDIT_DOMAIN_REDIRECTS, + async ({ domain, redirects: domainRedirects }, { getState }) => { + const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState); const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects }); - dispatch({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); - } catch (e: any) { - dispatch({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) }); - } -}; + return { domain, redirects }; + }, +); diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index b1f5e061..bf7f8758 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -1,21 +1,17 @@ -import { Action, Dispatch } from 'redux'; +import { createSlice, createAsyncThunk, createAction, SliceCaseReducers, AsyncThunk } from '@reduxjs/toolkit'; 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 { ApiErrorAction } from '../../api/types/actions'; +import { ShlinkState } from '../../container/types'; 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 { EditDomainRedirects } from './domainRedirects'; -export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START'; -export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; -export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; -export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; -export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; +const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; +const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; +const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; export interface DomainsList { domains: Domain[]; @@ -26,16 +22,12 @@ export interface DomainsList { errorData?: ProblemDetailsError; } -export interface ListDomainsAction extends Action { +interface ListDomains { domains: Domain[]; defaultRedirects?: ShlinkDomainRedirects; } -interface FilterDomainsAction extends Action { - searchTerm: string; -} - -interface ValidateDomain extends Action { +interface ValidateDomain { domain: string; status: DomainStatus; } @@ -47,83 +39,92 @@ const initialState: DomainsList = { error: false, }; -export type DomainsCombinedAction = ListDomainsAction -& ApiErrorAction -& FilterDomainsAction -& EditDomainRedirectsAction -& ValidateDomain; - -export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => +export const replaceRedirectsOnDomain = ({ domain, redirects }: EditDomainRedirects) => (d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects }); export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => (d: Domain): Domain => (d.domain !== domain ? d : { ...d, status }); -export default 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 }) => ({ - ...state, - filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm.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 }) => ({ - ...state, - domains: state.domains.map(replaceStatusOnDomain(domain, status)), - filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)), - }), -}, initialState); - -export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( - dispatch: Dispatch, - getState: GetState, +export const domainsListReducerCreator = ( + buildShlinkApiClient: ShlinkApiClientBuilder, + editDomainRedirects: AsyncThunk, ) => { - dispatch({ type: LIST_DOMAINS_START }); - const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); + const listDomains = createAsyncThunk( + LIST_DOMAINS, + async (_, { getState }) => { + const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); + const { data, defaultRedirects } = await shlinkListDomains(); - try { - const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({ - domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), - defaultRedirects, - })); + return { + domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), + defaultRedirects, + }; + }, + ); - dispatch({ type: LIST_DOMAINS, ...resp }); - } catch (e: any) { - dispatch({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); - } -}; - -export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); - -export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async ( - dispatch: Dispatch, - getState: GetState, -) => { - const { selectedServer } = getState(); - - if (!hasServerData(selectedServer)) { - dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); - - return; - } - - try { - const { url, ...rest } = selectedServer; - const { health } = buildShlinkApiClient({ - ...rest, - url: replaceAuthorityFromUri(url, domain), - }); - - const { status } = await health(); - - dispatch({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' }); - } catch (e) { - dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); - } + const checkDomainHealth = createAsyncThunk( + 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 filterDomains = createAction(FILTER_DOMAINS); + + const { reducer } = createSlice>({ + name: 'domainsList', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(listDomains.pending, () => ({ ...initialState, loading: true })); + builder.addCase(listDomains.rejected, (_, { error }) => ( + { ...initialState, error: true, errorData: parseApiError(error) } + )); + builder.addCase(listDomains.fulfilled, (_, { payload }) => ( + { ...initialState, ...payload, filteredDomains: payload.domains } + )); + + builder.addCase(checkDomainHealth.fulfilled, ({ domains, filteredDomains, ...rest }, { payload }) => ({ + ...rest, + domains: domains.map(replaceStatusOnDomain(payload.domain, payload.status)), + filteredDomains: filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)), + })); + + builder.addCase(filterDomains, (state, { payload }) => ({ + ...state, + filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(payload.toLowerCase())), + })); + + builder.addCase(editDomainRedirects.fulfilled, (state, { payload }) => ({ + ...state, + domains: state.domains.map(replaceRedirectsOnDomain(payload)), + filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(payload)), + })); + }, + }); + + return { + reducer, + listDomains, + checkDomainHealth, + filterDomains, + }; }; diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index 9a4ba7fe..827302a3 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 { domainsListReducerCreator } from '../reducers/domainsList'; import { DomainSelector } from '../DomainSelector'; import { ManageDomains } from '../ManageDomains'; import { editDomainRedirects } from '../reducers/domainRedirects'; @@ -16,11 +17,20 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { ['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'], )); + // Reducer + bottle.serviceFactory( + 'domainsListReducerCreator', + domainsListReducerCreator, + 'buildShlinkApiClient', + 'editDomainRedirects', + ); + bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator'); + // Actions - bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); - bottle.serviceFactory('filterDomains', () => filterDomains); + bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator'); + bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator'); bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); - bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient'); + bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator'); }; 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..2d3e41ee 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,3 +1,4 @@ +import { IContainer } 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: 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, diff --git a/test/domains/helpers/DomainDropdown.test.tsx b/test/domains/helpers/DomainDropdown.test.tsx index 90b5658b..1efd1a5e 100644 --- a/test/domains/helpers/DomainDropdown.test.tsx +++ b/test/domains/helpers/DomainDropdown.test.tsx @@ -71,7 +71,7 @@ describe('', () => { expect(editDomainRedirects).not.toHaveBeenCalled(); await user.click(screen.getByText('Save')); - expect(editDomainRedirects).toHaveBeenCalledWith(domain, expect.anything()); + expect(editDomainRedirects).toHaveBeenCalledWith(expect.objectContaining({ domain })); await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); }); diff --git a/test/domains/helpers/EditDomainRedirectsModal.test.tsx b/test/domains/helpers/EditDomainRedirectsModal.test.tsx index 6f402350..a83ddcbb 100644 --- a/test/domains/helpers/EditDomainRedirectsModal.test.tsx +++ b/test/domains/helpers/EditDomainRedirectsModal.test.tsx @@ -40,37 +40,49 @@ describe('', () => { expect(editDomainRedirects).not.toHaveBeenCalled(); submitForm(); - await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { - baseUrlRedirect: 'baz', - regular404Redirect: null, - invalidShortUrlRedirect: null, + await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({ + domain: 'foo.com', + redirects: { + baseUrlRedirect: 'baz', + regular404Redirect: null, + invalidShortUrlRedirect: null, + }, })); await user.clear(screen.getByDisplayValue('baz')); await user.type(screen.getAllByPlaceholderText('No redirect')[0], 'new_base_url'); await user.type(screen.getAllByPlaceholderText('No redirect')[2], 'new_invalid_short_url'); submitForm(); - await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { - baseUrlRedirect: 'new_base_url', - regular404Redirect: null, - invalidShortUrlRedirect: 'new_invalid_short_url', + await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({ + domain: 'foo.com', + redirects: { + baseUrlRedirect: 'new_base_url', + regular404Redirect: null, + invalidShortUrlRedirect: 'new_invalid_short_url', + }, })); await user.type(screen.getAllByPlaceholderText('No redirect')[1], 'new_regular_404'); await user.clear(screen.getByDisplayValue('new_invalid_short_url')); submitForm(); - await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { - baseUrlRedirect: 'new_base_url', - regular404Redirect: 'new_regular_404', - invalidShortUrlRedirect: null, + await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({ + domain: 'foo.com', + redirects: { + baseUrlRedirect: 'new_base_url', + regular404Redirect: 'new_regular_404', + invalidShortUrlRedirect: null, + }, })); await Promise.all(screen.getAllByPlaceholderText('No redirect').map((element) => user.clear(element))); submitForm(); - await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { - baseUrlRedirect: null, - regular404Redirect: null, - invalidShortUrlRedirect: null, + await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({ + domain: 'foo.com', + redirects: { + baseUrlRedirect: null, + regular404Redirect: null, + invalidShortUrlRedirect: null, + }, })); }); }); diff --git a/test/domains/reducers/domainRedirects.test.ts b/test/domains/reducers/domainRedirects.test.ts index f77f8377..567e0c1f 100644 --- a/test/domains/reducers/domainRedirects.test.ts +++ b/test/domains/reducers/domainRedirects.test.ts @@ -1,11 +1,6 @@ import { Mock } from 'ts-mockery'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import { - EDIT_DOMAIN_REDIRECTS, - EDIT_DOMAIN_REDIRECTS_ERROR, - EDIT_DOMAIN_REDIRECTS_START, - editDomainRedirects as editDomainRedirectsAction, -} from '../../../src/domains/reducers/domainRedirects'; +import { EditDomainRedirects, editDomainRedirects } from '../../../src/domains/reducers/domainRedirects'; import { ShlinkDomainRedirects } from '../../../src/api/types'; describe('domainRedirectsReducer', () => { @@ -16,29 +11,33 @@ describe('domainRedirectsReducer', () => { const redirects = Mock.all(); const dispatch = jest.fn(); const getState = jest.fn(); - const editDomainRedirects = jest.fn(); - const buildShlinkApiClient = () => Mock.of({ editDomainRedirects }); + const editDomainRedirectsCall = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ editDomainRedirects: editDomainRedirectsCall }); + const editDomainRedirectsAction = editDomainRedirects(buildShlinkApiClient); it('dispatches error when loading domains fails', async () => { - editDomainRedirects.mockRejectedValue(new Error('error')); + editDomainRedirectsCall.mockRejectedValue(new Error('error')); - await editDomainRedirectsAction(buildShlinkApiClient)(domain, {})(dispatch, getState); + await editDomainRedirectsAction(Mock.of({ domain }))(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_DOMAIN_REDIRECTS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_DOMAIN_REDIRECTS_ERROR }); - expect(editDomainRedirects).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ + type: editDomainRedirectsAction.rejected.toString(), + })); + expect(editDomainRedirectsCall).toHaveBeenCalledTimes(1); }); it('dispatches domain and redirects once loaded', async () => { - editDomainRedirects.mockResolvedValue(redirects); + editDomainRedirectsCall.mockResolvedValue(redirects); - await editDomainRedirectsAction(buildShlinkApiClient)(domain, {})(dispatch, getState); + await editDomainRedirectsAction(Mock.of({ domain }))(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_DOMAIN_REDIRECTS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); - expect(editDomainRedirects).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ + type: editDomainRedirectsAction.fulfilled.toString(), + payload: { domain, redirects }, + })); + expect(editDomainRedirectsCall).toHaveBeenCalledTimes(1); }); }); }); diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index b00704bd..7efb6b00 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -1,24 +1,18 @@ import { Mock } from 'ts-mockery'; -import reducer, { - LIST_DOMAINS, - LIST_DOMAINS_ERROR, - LIST_DOMAINS_START, - FILTER_DOMAINS, - VALIDATE_DOMAIN, - DomainsCombinedAction, +import { AxiosError } from 'axios'; +import { DomainsList, - listDomains as listDomainsAction, - filterDomains as filterDomainsAction, replaceRedirectsOnDomain, - checkDomainHealth, replaceStatusOnDomain, + domainsListReducerCreator, } from '../../../src/domains/reducers/domainsList'; -import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects'; +import { editDomainRedirects } from '../../../src/domains/reducers/domainRedirects'; import { ShlinkDomainRedirects } from '../../../src/api/types'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { Domain } from '../../../src/domains/data'; import { ShlinkState } from '../../../src/container/types'; import { SelectedServer, ServerData } from '../../../src/servers/data'; +import { parseApiError } from '../../../src/api/utils'; describe('domainsListReducer', () => { const dispatch = jest.fn(); @@ -31,36 +25,42 @@ describe('domainsListReducer', () => { Mock.of({ domain: 'Boo', status: 'validating' }), ]; const domains = [...filteredDomains, Mock.of({ domain: 'bar', status: 'validating' })]; + const error = Mock.of({ + response: { + data: { type: 'NOT_FOUND', status: 404 }, + }, + }); + const editDomainRedirectsThunk = editDomainRedirects(buildShlinkApiClient); + const { reducer, listDomains: listDomainsAction, checkDomainHealth, filterDomains } = domainsListReducerCreator( + buildShlinkApiClient, + editDomainRedirectsThunk, + ); beforeEach(jest.clearAllMocks); describe('reducer', () => { - const action = (type: string, args: Partial = {}) => Mock.of( - { type, ...args }, - ); - it('returns loading on LIST_DOMAINS_START', () => { - expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual( + expect(reducer(undefined, { type: listDomainsAction.pending.toString() })).toEqual( { domains: [], filteredDomains: [], loading: true, error: false }, ); }); it('returns error on LIST_DOMAINS_ERROR', () => { - expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual( - { domains: [], filteredDomains: [], loading: false, error: true }, + expect(reducer(undefined, { type: listDomainsAction.rejected.toString(), error })).toEqual( + { domains: [], filteredDomains: [], loading: false, error: true, errorData: parseApiError(error) }, ); }); it('returns domains on LIST_DOMAINS', () => { - expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual( - { domains, filteredDomains: domains, loading: false, error: false }, - ); + expect( + reducer(undefined, { type: listDomainsAction.fulfilled.toString(), payload: { domains } }), + ).toEqual({ domains, filteredDomains: domains, loading: false, error: false }); }); it('filters domains on FILTER_DOMAINS', () => { - expect(reducer(Mock.of({ domains }), action(FILTER_DOMAINS, { searchTerm: 'oO' }))).toEqual( - { domains, filteredDomains }, - ); + expect( + reducer(Mock.of({ domains }), { type: filterDomains.toString(), payload: 'oO' }), + ).toEqual({ domains, filteredDomains }); }); it.each([ @@ -74,12 +74,12 @@ describe('domainsListReducer', () => { invalidShortUrlRedirect: null, }; - expect(reducer( - Mock.of({ domains, filteredDomains }), - action(EDIT_DOMAIN_REDIRECTS, { domain, redirects }), - )).toEqual({ - domains: domains.map(replaceRedirectsOnDomain(domain, redirects)), - filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), + expect(reducer(Mock.of({ domains, filteredDomains }), { + type: editDomainRedirectsThunk.fulfilled.toString(), + payload: { domain, redirects }, + })).toEqual({ + domains: domains.map(replaceRedirectsOnDomain({ domain, redirects })), + filteredDomains: filteredDomains.map(replaceRedirectsOnDomain({ domain, redirects })), }); }); @@ -90,7 +90,10 @@ describe('domainsListReducer', () => { ])('replaces status on proper domain on VALIDATE_DOMAIN', (domain) => { expect(reducer( Mock.of({ domains, filteredDomains }), - action(VALIDATE_DOMAIN, { domain, status: 'valid' }), + { + type: checkDomainHealth.fulfilled.toString(), + payload: { domain, status: 'valid' }, + }, )).toEqual({ domains: domains.map(replaceStatusOnDomain(domain, 'valid')), filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')), @@ -102,22 +105,31 @@ describe('domainsListReducer', () => { it('dispatches error when loading domains fails', async () => { listDomains.mockRejectedValue(new Error('error')); - await listDomainsAction(buildShlinkApiClient)()(dispatch, getState); + await listDomainsAction()(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: listDomainsAction.pending.toString(), + })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: listDomainsAction.rejected.toString(), + })); expect(listDomains).toHaveBeenCalledTimes(1); }); it('dispatches domains once loaded', async () => { listDomains.mockResolvedValue({ data: domains }); - await listDomainsAction(buildShlinkApiClient)()(dispatch, getState); + await listDomainsAction()(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS, domains, defaultRedirects: undefined }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: listDomainsAction.pending.toString(), + })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: listDomainsAction.fulfilled.toString(), + payload: { domains }, + })); expect(listDomains).toHaveBeenCalledTimes(1); }); }); @@ -128,7 +140,9 @@ describe('domainsListReducer', () => { ['bar'], ['something'], ])('creates action as expected', (searchTerm) => { - expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm }); + expect(filterDomains(searchTerm)).toEqual( + expect.objectContaining({ type: filterDomains.toString(), payload: searchTerm }), + ); }); }); @@ -140,12 +154,14 @@ describe('domainsListReducer', () => { selectedServer: Mock.all(), })); - await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + await checkDomainHealth(domain)(dispatch, getState, {}); expect(getState).toHaveBeenCalledTimes(1); expect(health).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ + type: checkDomainHealth.fulfilled.toString(), + payload: { domain, status: 'invalid' }, + })); }); it('dispatches invalid status when health endpoint returns an error', async () => { @@ -157,12 +173,14 @@ describe('domainsListReducer', () => { })); health.mockRejectedValue({}); - await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + await checkDomainHealth(domain)(dispatch, getState, {}); expect(getState).toHaveBeenCalledTimes(1); expect(health).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ + type: checkDomainHealth.fulfilled.toString(), + payload: { domain, status: 'invalid' }, + })); }); it.each([ @@ -180,12 +198,14 @@ describe('domainsListReducer', () => { })); health.mockResolvedValue({ status: healthStatus }); - await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + await checkDomainHealth(domain)(dispatch, getState, {}); expect(getState).toHaveBeenCalledTimes(1); expect(health).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: expectedStatus }); + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ + type: checkDomainHealth.fulfilled.toString(), + payload: { domain, status: expectedStatus }, + })); }); }); });