Merge pull request #722 from acelaya-forks/feature/redux-toolkit-poc

Feature/redux toolkit poc
This commit is contained in:
Alejandro Celaya 2022-11-04 19:07:32 +01:00 committed by GitHub
commit 26c3ea19f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 325 additions and 234 deletions

View file

@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Changed ### Changed
* [#713](https://github.com/shlinkio/shlink-web-client/issues/713) Updated dependencies. * [#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 ### Deprecated
* *Nothing* * *Nothing*

71
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@fortawesome/free-regular-svg-icons": "^6.2.0", "@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "^1.9.0",
"axios": "^1.1.2", "axios": "^1.1.2",
"bootstrap": "^5.2.2", "bootstrap": "^5.2.2",
"bottlejs": "^2.0.1", "bottlejs": "^2.0.1",
@ -4267,6 +4268,29 @@
"react-dom": "^18.0.0" "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": { "node_modules/@remix-run/router": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.1.tgz",
@ -12143,10 +12167,9 @@
} }
}, },
"node_modules/immer": { "node_modules/immer": {
"version": "9.0.12", "version": "9.0.16",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz",
"integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==", "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==",
"dev": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/immer" "url": "https://opencollective.com/immer"
@ -22723,9 +22746,9 @@
} }
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "2.4.1", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz",
"integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==", "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==",
"peerDependencies": { "peerDependencies": {
"redux": "^4" "redux": "^4"
} }
@ -22895,6 +22918,11 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true "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": { "node_modules/resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@ -29896,6 +29924,17 @@
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"requires": {} "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": { "@remix-run/router": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.1.tgz",
@ -35854,10 +35893,9 @@
"dev": true "dev": true
}, },
"immer": { "immer": {
"version": "9.0.12", "version": "9.0.16",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz",
"integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==", "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ=="
"dev": true
}, },
"immutable": { "immutable": {
"version": "4.0.0", "version": "4.0.0",
@ -43653,9 +43691,9 @@
} }
}, },
"redux-thunk": { "redux-thunk": {
"version": "2.4.1", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz",
"integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==", "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==",
"requires": {} "requires": {}
}, },
"regenerate": { "regenerate": {
@ -43789,6 +43827,11 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true "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": { "resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",

View file

@ -29,6 +29,7 @@
"@fortawesome/free-regular-svg-icons": "^6.2.0", "@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "^1.9.0",
"axios": "^1.1.2", "axios": "^1.1.2",
"bootstrap": "^5.2.2", "bootstrap": "^5.2.2",
"bottlejs": "^2.0.1", "bottlejs": "^2.0.1",

View file

@ -8,7 +8,11 @@ import {
RegularNotFound, RegularNotFound,
} from '../types/errors'; } from '../types/errors';
export const parseApiError = (e: AxiosError<ProblemDetailsError>) => e.response?.data; const isAxiosError = (e: unknown): e is AxiosError<ProblemDetailsError> => !!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 => export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT; error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;

View file

@ -1,14 +1,11 @@
import ReduxThunk from 'redux-thunk'; import { IContainer } from 'bottlejs';
import { applyMiddleware, compose, createStore } from 'redux';
import { save, load, RLSOptions } from 'redux-localstorage-simple'; 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 { migrateDeprecatedSettings } from '../settings/helpers';
import { ShlinkState } from './types'; import { ShlinkState } from './types';
const isProduction = process.env.NODE_ENV === 'production'; 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 = { const localStorageConfig: RLSOptions = {
states: ['settings', 'servers'], states: ['settings', 'servers'],
namespace: 'shlink', namespace: 'shlink',
@ -17,6 +14,11 @@ const localStorageConfig: RLSOptions = {
}; };
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState); const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
export const store = createStore(reducers, preloadedState, composeEnhancers( export const setUpStore = (container: IContainer) => configureStore({
applyMiddleware(save(localStorageConfig), ReduxThunk), devTools: !isProduction,
)); reducer: reducer(container),
preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) => defaultMiddlewaresIncludingReduxThunk(
{ immutableCheck: false, serializableCheck: false }, // State is too big for these
).concat(save(localStorageConfig)),
});

View file

@ -8,11 +8,12 @@ import { SelectedServer } from '../servers/data';
import { Domain } from './data'; import { Domain } from './data';
import { DomainStatusIcon } from './helpers/DomainStatusIcon'; import { DomainStatusIcon } from './helpers/DomainStatusIcon';
import { DomainDropdown } from './helpers/DomainDropdown'; import { DomainDropdown } from './helpers/DomainDropdown';
import { EditDomainRedirects } from './reducers/domainRedirects';
interface DomainRowProps { interface DomainRowProps {
domain: Domain; domain: Domain;
defaultRedirects?: ShlinkDomainRedirects; defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void; checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }

View file

@ -4,7 +4,7 @@ import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { SearchField } from '../utils/SearchField'; import { SearchField } from '../utils/SearchField';
import { ShlinkDomainRedirects } from '../api/types'; import { EditDomainRedirects } from './reducers/domainRedirects';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { DomainsList } from './reducers/domainsList'; import { DomainsList } from './reducers/domainsList';
import { DomainRow } from './DomainRow'; import { DomainRow } from './DomainRow';
@ -12,7 +12,7 @@ import { DomainRow } from './DomainRow';
interface ManageDomainsProps { interface ManageDomainsProps {
listDomains: Function; listDomains: Function;
filterDomains: (searchTerm: string) => void; filterDomains: (searchTerm: string) => void;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void; checkDomainHealth: (domain: string) => void;
domainsList: DomainsList; domainsList: DomainsList;
selectedServer: SelectedServer; selectedServer: SelectedServer;

View file

@ -7,14 +7,14 @@ import { useToggle } from '../../utils/helpers/hooks';
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu'; import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal'; import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
import { Domain } from '../data'; import { Domain } from '../data';
import { ShlinkDomainRedirects } from '../../api/types'; import { EditDomainRedirects } from '../reducers/domainRedirects';
import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features'; import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features';
import { getServerId, SelectedServer } from '../../servers/data'; import { getServerId, SelectedServer } from '../../servers/data';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
interface DomainDropdownProps { interface DomainDropdownProps {
domain: Domain; domain: Domain;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }

View file

@ -1,15 +1,16 @@
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; 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 { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils'; import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
import { InfoTooltip } from '../../utils/InfoTooltip'; import { InfoTooltip } from '../../utils/InfoTooltip';
import { EditDomainRedirects } from '../reducers/domainRedirects';
interface EditDomainRedirectsModalProps { interface EditDomainRedirectsModalProps {
domain: ShlinkDomain; domain: ShlinkDomain;
isOpen: boolean; isOpen: boolean;
toggle: () => void; toggle: () => void;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
} }
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => ( const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
@ -30,10 +31,13 @@ export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState( const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
domain.redirects?.invalidShortUrlRedirect ?? '', domain.redirects?.invalidShortUrlRedirect ?? '',
); );
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, { const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects({
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect), domain: domain.domain,
regular404Redirect: nonEmptyValueOrNull(regular404Redirect), redirects: {
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect), baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
},
}).then(toggle)); }).then(toggle));
return ( return (

View file

@ -1,31 +1,23 @@
import { Action, Dispatch } from 'redux'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkDomainRedirects } from '../../api/types'; import { ShlinkDomainRedirects } from '../../api/types';
import { GetState } from '../../container/types'; import { ShlinkState } from '../../container/types';
import { ApiErrorAction } from '../../api/types/actions';
import { parseApiError } from '../../api/utils';
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START'; const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
export interface EditDomainRedirectsAction extends Action<string> { export interface EditDomainRedirects {
domain: string; domain: string;
redirects: ShlinkDomainRedirects; redirects: ShlinkDomainRedirects;
} }
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const editDomainRedirects = (
domain: string, buildShlinkApiClient: ShlinkApiClientBuilder,
domainRedirects: Partial<ShlinkDomainRedirects>, ) => createAsyncThunk<EditDomainRedirects, EditDomainRedirects, { state: ShlinkState }>(
) => async (dispatch: Dispatch, getState: GetState) => { EDIT_DOMAIN_REDIRECTS,
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START }); async ({ domain, redirects: domainRedirects }, { getState }) => {
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState); const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
try {
const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects }); const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); return { domain, redirects };
} catch (e: any) { },
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) }); );
}
};

View file

@ -1,21 +1,17 @@
import { Action, Dispatch } from 'redux'; import { createSlice, createAsyncThunk, createAction, SliceCaseReducers, AsyncThunk } from '@reduxjs/toolkit';
import { ShlinkDomainRedirects } from '../../api/types'; import { ShlinkDomainRedirects } from '../../api/types';
import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types'; import { ShlinkState } from '../../container/types';
import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
import { Domain, DomainStatus } from '../data'; import { Domain, DomainStatus } from '../data';
import { hasServerData } from '../../servers/data'; import { hasServerData } from '../../servers/data';
import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
import { ProblemDetailsError } from '../../api/types/errors'; 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'; const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
export interface DomainsList { export interface DomainsList {
domains: Domain[]; domains: Domain[];
@ -26,16 +22,12 @@ export interface DomainsList {
errorData?: ProblemDetailsError; errorData?: ProblemDetailsError;
} }
export interface ListDomainsAction extends Action<string> { interface ListDomains {
domains: Domain[]; domains: Domain[];
defaultRedirects?: ShlinkDomainRedirects; defaultRedirects?: ShlinkDomainRedirects;
} }
interface FilterDomainsAction extends Action<string> { interface ValidateDomain {
searchTerm: string;
}
interface ValidateDomain extends Action<string> {
domain: string; domain: string;
status: DomainStatus; status: DomainStatus;
} }
@ -47,83 +39,92 @@ const initialState: DomainsList = {
error: false, error: false,
}; };
export type DomainsCombinedAction = ListDomainsAction export const replaceRedirectsOnDomain = ({ domain, redirects }: EditDomainRedirects) =>
& ApiErrorAction
& FilterDomainsAction
& EditDomainRedirectsAction
& ValidateDomain;
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects }); (d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status }); (d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
export default buildReducer<DomainsList, DomainsCombinedAction>({ export const domainsListReducerCreator = (
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), buildShlinkApiClient: ShlinkApiClientBuilder,
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }), editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
[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,
) => { ) => {
dispatch({ type: LIST_DOMAINS_START }); const listDomains = createAsyncThunk<ListDomains, void, { state: ShlinkState }>(
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); LIST_DOMAINS,
async (_, { getState }) => {
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
const { data, defaultRedirects } = await shlinkListDomains();
try { return {
const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({ domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), defaultRedirects,
defaultRedirects, };
})); },
);
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp }); const checkDomainHealth = createAsyncThunk<ValidateDomain, string, { state: ShlinkState }>(
} catch (e: any) { VALIDATE_DOMAIN,
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); async (domain: string, { getState }) => {
} const { selectedServer } = getState();
};
if (!hasServerData(selectedServer)) {
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); return { domain, status: 'invalid' };
}
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
dispatch: Dispatch, try {
getState: GetState, const { url, ...rest } = selectedServer;
) => { const { health } = buildShlinkApiClient({
const { selectedServer } = getState(); ...rest,
url: replaceAuthorityFromUri(url, domain),
if (!hasServerData(selectedServer)) { });
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
const { status } = await health();
return;
} return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
} catch (e) {
try { return { domain, status: 'invalid' };
const { url, ...rest } = selectedServer; }
const { health } = buildShlinkApiClient({ },
...rest, );
url: replaceAuthorityFromUri(url, domain),
}); const filterDomains = createAction<string>(FILTER_DOMAINS);
const { status } = await health(); const { reducer } = createSlice<DomainsList, SliceCaseReducers<DomainsList>>({
name: 'domainsList',
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' }); initialState,
} catch (e) { reducers: {},
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); 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,
};
}; };

View file

@ -1,6 +1,7 @@
import { prop } from 'ramda';
import Bottle from 'bottlejs'; import Bottle from 'bottlejs';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList'; import { domainsListReducerCreator } from '../reducers/domainsList';
import { DomainSelector } from '../DomainSelector'; import { DomainSelector } from '../DomainSelector';
import { ManageDomains } from '../ManageDomains'; import { ManageDomains } from '../ManageDomains';
import { editDomainRedirects } from '../reducers/domainRedirects'; import { editDomainRedirects } from '../reducers/domainRedirects';
@ -16,11 +17,20 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'], ['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
)); ));
// Reducer
bottle.serviceFactory(
'domainsListReducerCreator',
domainsListReducerCreator,
'buildShlinkApiClient',
'editDomainRedirects',
);
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
// Actions // Actions
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
bottle.serviceFactory('filterDomains', () => filterDomains); bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient'); bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
}; };
export default provideServices; export default provideServices;

View file

@ -3,7 +3,7 @@ import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import pack from '../package.json'; import pack from '../package.json';
import { container } from './container'; import { container } from './container';
import { store } from './container/store'; import { setUpStore } from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet'; import { fixLeafletIcons } from './utils/helpers/leaflet';
import { register as registerServiceWorker } from './serviceWorkerRegistration'; 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 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 // This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
fixLeafletIcons(); fixLeafletIcons();
const store = setUpStore(container);
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container; const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion

View file

@ -1,3 +1,4 @@
import { IContainer } from 'bottlejs';
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import serversReducer from '../servers/reducers/servers'; import serversReducer from '../servers/reducers/servers';
import selectedServerReducer from '../servers/reducers/selectedServer'; import selectedServerReducer from '../servers/reducers/selectedServer';
@ -16,13 +17,12 @@ import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit'; import tagEditReducer from '../tags/reducers/tagEdit';
import mercureInfoReducer from '../mercure/reducers/mercureInfo'; import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings'; import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import appUpdatesReducer from '../app/reducers/appUpdates'; import appUpdatesReducer from '../app/reducers/appUpdates';
import sidebarReducer from '../common/reducers/sidebar'; import sidebarReducer from '../common/reducers/sidebar';
import { ShlinkState } from '../container/types'; import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({ export default (container: IContainer) => combineReducers<ShlinkState>({
servers: serversReducer, servers: serversReducer,
selectedServer: selectedServerReducer, selectedServer: selectedServerReducer,
shortUrlsList: shortUrlsListReducer, shortUrlsList: shortUrlsListReducer,
@ -40,7 +40,7 @@ export default combineReducers<ShlinkState>({
tagEdit: tagEditReducer, tagEdit: tagEditReducer,
mercureInfo: mercureInfoReducer, mercureInfo: mercureInfoReducer,
settings: settingsReducer, settings: settingsReducer,
domainsList: domainsListReducer, domainsList: container.domainsListReducer,
visitsOverview: visitsOverviewReducer, visitsOverview: visitsOverviewReducer,
appUpdated: appUpdatesReducer, appUpdated: appUpdatesReducer,
sidebar: sidebarReducer, sidebar: sidebarReducer,

View file

@ -71,7 +71,7 @@ describe('<DomainDropdown />', () => {
expect(editDomainRedirects).not.toHaveBeenCalled(); expect(editDomainRedirects).not.toHaveBeenCalled();
await user.click(screen.getByText('Save')); await user.click(screen.getByText('Save'));
expect(editDomainRedirects).toHaveBeenCalledWith(domain, expect.anything()); expect(editDomainRedirects).toHaveBeenCalledWith(expect.objectContaining({ domain }));
await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
}); });

View file

@ -40,37 +40,49 @@ describe('<EditDomainRedirectsModal />', () => {
expect(editDomainRedirects).not.toHaveBeenCalled(); expect(editDomainRedirects).not.toHaveBeenCalled();
submitForm(); submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
baseUrlRedirect: 'baz', domain: 'foo.com',
regular404Redirect: null, redirects: {
invalidShortUrlRedirect: null, baseUrlRedirect: 'baz',
regular404Redirect: null,
invalidShortUrlRedirect: null,
},
})); }));
await user.clear(screen.getByDisplayValue('baz')); await user.clear(screen.getByDisplayValue('baz'));
await user.type(screen.getAllByPlaceholderText('No redirect')[0], 'new_base_url'); await user.type(screen.getAllByPlaceholderText('No redirect')[0], 'new_base_url');
await user.type(screen.getAllByPlaceholderText('No redirect')[2], 'new_invalid_short_url'); await user.type(screen.getAllByPlaceholderText('No redirect')[2], 'new_invalid_short_url');
submitForm(); submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
baseUrlRedirect: 'new_base_url', domain: 'foo.com',
regular404Redirect: null, redirects: {
invalidShortUrlRedirect: 'new_invalid_short_url', baseUrlRedirect: 'new_base_url',
regular404Redirect: null,
invalidShortUrlRedirect: 'new_invalid_short_url',
},
})); }));
await user.type(screen.getAllByPlaceholderText('No redirect')[1], 'new_regular_404'); await user.type(screen.getAllByPlaceholderText('No redirect')[1], 'new_regular_404');
await user.clear(screen.getByDisplayValue('new_invalid_short_url')); await user.clear(screen.getByDisplayValue('new_invalid_short_url'));
submitForm(); submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
baseUrlRedirect: 'new_base_url', domain: 'foo.com',
regular404Redirect: 'new_regular_404', redirects: {
invalidShortUrlRedirect: null, baseUrlRedirect: 'new_base_url',
regular404Redirect: 'new_regular_404',
invalidShortUrlRedirect: null,
},
})); }));
await Promise.all(screen.getAllByPlaceholderText('No redirect').map((element) => user.clear(element))); await Promise.all(screen.getAllByPlaceholderText('No redirect').map((element) => user.clear(element)));
submitForm(); submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
baseUrlRedirect: null, domain: 'foo.com',
regular404Redirect: null, redirects: {
invalidShortUrlRedirect: null, baseUrlRedirect: null,
regular404Redirect: null,
invalidShortUrlRedirect: null,
},
})); }));
}); });
}); });

View file

@ -1,11 +1,6 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient';
import { import { EditDomainRedirects, editDomainRedirects } from '../../../src/domains/reducers/domainRedirects';
EDIT_DOMAIN_REDIRECTS,
EDIT_DOMAIN_REDIRECTS_ERROR,
EDIT_DOMAIN_REDIRECTS_START,
editDomainRedirects as editDomainRedirectsAction,
} from '../../../src/domains/reducers/domainRedirects';
import { ShlinkDomainRedirects } from '../../../src/api/types'; import { ShlinkDomainRedirects } from '../../../src/api/types';
describe('domainRedirectsReducer', () => { describe('domainRedirectsReducer', () => {
@ -16,29 +11,33 @@ describe('domainRedirectsReducer', () => {
const redirects = Mock.all<ShlinkDomainRedirects>(); const redirects = Mock.all<ShlinkDomainRedirects>();
const dispatch = jest.fn(); const dispatch = jest.fn();
const getState = jest.fn(); const getState = jest.fn();
const editDomainRedirects = jest.fn(); const editDomainRedirectsCall = jest.fn();
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ editDomainRedirects }); const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ editDomainRedirects: editDomainRedirectsCall });
const editDomainRedirectsAction = editDomainRedirects(buildShlinkApiClient);
it('dispatches error when loading domains fails', async () => { 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<EditDomainRedirects>({ domain }))(dispatch, getState, {});
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_DOMAIN_REDIRECTS_START }); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_DOMAIN_REDIRECTS_ERROR }); type: editDomainRedirectsAction.rejected.toString(),
expect(editDomainRedirects).toHaveBeenCalledTimes(1); }));
expect(editDomainRedirectsCall).toHaveBeenCalledTimes(1);
}); });
it('dispatches domain and redirects once loaded', async () => { it('dispatches domain and redirects once loaded', async () => {
editDomainRedirects.mockResolvedValue(redirects); editDomainRedirectsCall.mockResolvedValue(redirects);
await editDomainRedirectsAction(buildShlinkApiClient)(domain, {})(dispatch, getState); await editDomainRedirectsAction(Mock.of<EditDomainRedirects>({ domain }))(dispatch, getState, {});
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_DOMAIN_REDIRECTS_START }); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); type: editDomainRedirectsAction.fulfilled.toString(),
expect(editDomainRedirects).toHaveBeenCalledTimes(1); payload: { domain, redirects },
}));
expect(editDomainRedirectsCall).toHaveBeenCalledTimes(1);
}); });
}); });
}); });

View file

@ -1,24 +1,18 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import reducer, { import { AxiosError } from 'axios';
LIST_DOMAINS, import {
LIST_DOMAINS_ERROR,
LIST_DOMAINS_START,
FILTER_DOMAINS,
VALIDATE_DOMAIN,
DomainsCombinedAction,
DomainsList, DomainsList,
listDomains as listDomainsAction,
filterDomains as filterDomainsAction,
replaceRedirectsOnDomain, replaceRedirectsOnDomain,
checkDomainHealth,
replaceStatusOnDomain, replaceStatusOnDomain,
domainsListReducerCreator,
} from '../../../src/domains/reducers/domainsList'; } 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 { ShlinkDomainRedirects } from '../../../src/api/types';
import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient';
import { Domain } from '../../../src/domains/data'; import { Domain } from '../../../src/domains/data';
import { ShlinkState } from '../../../src/container/types'; import { ShlinkState } from '../../../src/container/types';
import { SelectedServer, ServerData } from '../../../src/servers/data'; import { SelectedServer, ServerData } from '../../../src/servers/data';
import { parseApiError } from '../../../src/api/utils';
describe('domainsListReducer', () => { describe('domainsListReducer', () => {
const dispatch = jest.fn(); const dispatch = jest.fn();
@ -31,36 +25,42 @@ describe('domainsListReducer', () => {
Mock.of<Domain>({ domain: 'Boo', status: 'validating' }), Mock.of<Domain>({ domain: 'Boo', status: 'validating' }),
]; ];
const domains = [...filteredDomains, Mock.of<Domain>({ domain: 'bar', status: 'validating' })]; const domains = [...filteredDomains, Mock.of<Domain>({ domain: 'bar', status: 'validating' })];
const error = Mock.of<AxiosError>({
response: {
data: { type: 'NOT_FOUND', status: 404 },
},
});
const editDomainRedirectsThunk = editDomainRedirects(buildShlinkApiClient);
const { reducer, listDomains: listDomainsAction, checkDomainHealth, filterDomains } = domainsListReducerCreator(
buildShlinkApiClient,
editDomainRedirectsThunk,
);
beforeEach(jest.clearAllMocks); beforeEach(jest.clearAllMocks);
describe('reducer', () => { describe('reducer', () => {
const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>(
{ type, ...args },
);
it('returns loading on LIST_DOMAINS_START', () => { 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 }, { domains: [], filteredDomains: [], loading: true, error: false },
); );
}); });
it('returns error on LIST_DOMAINS_ERROR', () => { it('returns error on LIST_DOMAINS_ERROR', () => {
expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual( expect(reducer(undefined, { type: listDomainsAction.rejected.toString(), error })).toEqual(
{ domains: [], filteredDomains: [], loading: false, error: true }, { domains: [], filteredDomains: [], loading: false, error: true, errorData: parseApiError(error) },
); );
}); });
it('returns domains on LIST_DOMAINS', () => { it('returns domains on LIST_DOMAINS', () => {
expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual( expect(
{ domains, filteredDomains: domains, loading: false, error: false }, reducer(undefined, { type: listDomainsAction.fulfilled.toString(), payload: { domains } }),
); ).toEqual({ domains, filteredDomains: domains, loading: false, error: false });
}); });
it('filters domains on FILTER_DOMAINS', () => { it('filters domains on FILTER_DOMAINS', () => {
expect(reducer(Mock.of<DomainsList>({ domains }), action(FILTER_DOMAINS, { searchTerm: 'oO' }))).toEqual( expect(
{ domains, filteredDomains }, reducer(Mock.of<DomainsList>({ domains }), { type: filterDomains.toString(), payload: 'oO' }),
); ).toEqual({ domains, filteredDomains });
}); });
it.each([ it.each([
@ -74,12 +74,12 @@ describe('domainsListReducer', () => {
invalidShortUrlRedirect: null, invalidShortUrlRedirect: null,
}; };
expect(reducer( expect(reducer(Mock.of<DomainsList>({ domains, filteredDomains }), {
Mock.of<DomainsList>({ domains, filteredDomains }), type: editDomainRedirectsThunk.fulfilled.toString(),
action(EDIT_DOMAIN_REDIRECTS, { domain, redirects }), payload: { domain, redirects },
)).toEqual({ })).toEqual({
domains: domains.map(replaceRedirectsOnDomain(domain, redirects)), domains: domains.map(replaceRedirectsOnDomain({ domain, redirects })),
filteredDomains: filteredDomains.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) => { ])('replaces status on proper domain on VALIDATE_DOMAIN', (domain) => {
expect(reducer( expect(reducer(
Mock.of<DomainsList>({ domains, filteredDomains }), Mock.of<DomainsList>({ domains, filteredDomains }),
action(VALIDATE_DOMAIN, { domain, status: 'valid' }), {
type: checkDomainHealth.fulfilled.toString(),
payload: { domain, status: 'valid' },
},
)).toEqual({ )).toEqual({
domains: domains.map(replaceStatusOnDomain(domain, 'valid')), domains: domains.map(replaceStatusOnDomain(domain, 'valid')),
filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')), filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')),
@ -102,22 +105,31 @@ describe('domainsListReducer', () => {
it('dispatches error when loading domains fails', async () => { it('dispatches error when loading domains fails', async () => {
listDomains.mockRejectedValue(new Error('error')); listDomains.mockRejectedValue(new Error('error'));
await listDomainsAction(buildShlinkApiClient)()(dispatch, getState); await listDomainsAction()(dispatch, getState, {});
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START }); expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS_ERROR }); type: listDomainsAction.pending.toString(),
}));
expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: listDomainsAction.rejected.toString(),
}));
expect(listDomains).toHaveBeenCalledTimes(1); expect(listDomains).toHaveBeenCalledTimes(1);
}); });
it('dispatches domains once loaded', async () => { it('dispatches domains once loaded', async () => {
listDomains.mockResolvedValue({ data: domains }); listDomains.mockResolvedValue({ data: domains });
await listDomainsAction(buildShlinkApiClient)()(dispatch, getState); await listDomainsAction()(dispatch, getState, {});
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START }); expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS, domains, defaultRedirects: undefined }); type: listDomainsAction.pending.toString(),
}));
expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: listDomainsAction.fulfilled.toString(),
payload: { domains },
}));
expect(listDomains).toHaveBeenCalledTimes(1); expect(listDomains).toHaveBeenCalledTimes(1);
}); });
}); });
@ -128,7 +140,9 @@ describe('domainsListReducer', () => {
['bar'], ['bar'],
['something'], ['something'],
])('creates action as expected', (searchTerm) => { ])('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<SelectedServer>(), selectedServer: Mock.all<SelectedServer>(),
})); }));
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); await checkDomainHealth(domain)(dispatch, getState, {});
expect(getState).toHaveBeenCalledTimes(1); expect(getState).toHaveBeenCalledTimes(1);
expect(health).not.toHaveBeenCalled(); expect(health).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); type: checkDomainHealth.fulfilled.toString(),
payload: { domain, status: 'invalid' },
}));
}); });
it('dispatches invalid status when health endpoint returns an error', async () => { it('dispatches invalid status when health endpoint returns an error', async () => {
@ -157,12 +173,14 @@ describe('domainsListReducer', () => {
})); }));
health.mockRejectedValue({}); health.mockRejectedValue({});
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); await checkDomainHealth(domain)(dispatch, getState, {});
expect(getState).toHaveBeenCalledTimes(1); expect(getState).toHaveBeenCalledTimes(1);
expect(health).toHaveBeenCalledTimes(1); expect(health).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); type: checkDomainHealth.fulfilled.toString(),
payload: { domain, status: 'invalid' },
}));
}); });
it.each([ it.each([
@ -180,12 +198,14 @@ describe('domainsListReducer', () => {
})); }));
health.mockResolvedValue({ status: healthStatus }); health.mockResolvedValue({ status: healthStatus });
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); await checkDomainHealth(domain)(dispatch, getState, {});
expect(getState).toHaveBeenCalledTimes(1); expect(getState).toHaveBeenCalledTimes(1);
expect(health).toHaveBeenCalledTimes(1); expect(health).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: expectedStatus }); type: checkDomainHealth.fulfilled.toString(),
payload: { domain, status: expectedStatus },
}));
}); });
}); });
}); });