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
* [#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*

71
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -8,7 +8,11 @@ import {
RegularNotFound,
} 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 =>
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;

View file

@ -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)),
});

View file

@ -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<ShlinkDomainRedirects>) => Promise<void>;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
}

View file

@ -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<ShlinkDomainRedirects>) => Promise<void>;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void;
domainsList: DomainsList;
selectedServer: SelectedServer;

View file

@ -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<ShlinkDomainRedirects>) => Promise<void>;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
selectedServer: SelectedServer;
}

View file

@ -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<ShlinkDomainRedirects>) => Promise<void>;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
}
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
@ -30,10 +31,13 @@ export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
domain.redirects?.invalidShortUrlRedirect ?? '',
);
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects({
domain: domain.domain,
redirects: {
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
},
}).then(toggle));
return (

View file

@ -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<string> {
export interface EditDomainRedirects {
domain: string;
redirects: ShlinkDomainRedirects;
}
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
domain: string,
domainRedirects: Partial<ShlinkDomainRedirects>,
) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
export const editDomainRedirects = (
buildShlinkApiClient: ShlinkApiClientBuilder,
) => createAsyncThunk<EditDomainRedirects, EditDomainRedirects, { state: ShlinkState }>(
EDIT_DOMAIN_REDIRECTS,
async ({ domain, redirects: domainRedirects }, { getState }) => {
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
try {
const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
} catch (e: any) {
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
}
};
return { domain, redirects };
},
);

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 { 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<string> {
interface ListDomains {
domains: Domain[];
defaultRedirects?: ShlinkDomainRedirects;
}
interface FilterDomainsAction extends Action<string> {
searchTerm: string;
}
interface ValidateDomain extends Action<string> {
interface ValidateDomain {
domain: string;
status: DomainStatus;
}
@ -47,70 +39,36 @@ 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<DomainsList, DomainsCombinedAction>({
[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<EditDomainRedirects, any, any>,
) => {
dispatch({ type: LIST_DOMAINS_START });
const listDomains = createAsyncThunk<ListDomains, void, { state: ShlinkState }>(
LIST_DOMAINS,
async (_, { getState }) => {
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
const { data, defaultRedirects } = await shlinkListDomains();
try {
const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({
return {
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
defaultRedirects,
}));
};
},
);
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp });
} catch (e: any) {
dispatch<ApiErrorAction>({ 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 checkDomainHealth = createAsyncThunk<ValidateDomain, string, { state: ShlinkState }>(
VALIDATE_DOMAIN,
async (domain: string, { getState }) => {
const { selectedServer } = getState();
if (!hasServerData(selectedServer)) {
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
return;
return { domain, status: 'invalid' };
}
try {
@ -122,8 +80,51 @@ export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder)
const { status } = await health();
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
} catch (e) {
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
return { domain, status: 'invalid' };
}
},
);
const filterDomains = createAction<string>(FILTER_DOMAINS);
const { reducer } = createSlice<DomainsList, SliceCaseReducers<DomainsList>>({
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,
};
};

View file

@ -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;

View file

@ -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

View file

@ -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<ShlinkState>({
export default (container: IContainer) => combineReducers<ShlinkState>({
servers: serversReducer,
selectedServer: selectedServerReducer,
shortUrlsList: shortUrlsListReducer,
@ -40,7 +40,7 @@ export default combineReducers<ShlinkState>({
tagEdit: tagEditReducer,
mercureInfo: mercureInfoReducer,
settings: settingsReducer,
domainsList: domainsListReducer,
domainsList: container.domainsListReducer,
visitsOverview: visitsOverviewReducer,
appUpdated: appUpdatesReducer,
sidebar: sidebarReducer,

View file

@ -71,7 +71,7 @@ describe('<DomainDropdown />', () => {
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'));
});

View file

@ -40,37 +40,49 @@ describe('<EditDomainRedirectsModal />', () => {
expect(editDomainRedirects).not.toHaveBeenCalled();
submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', {
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', {
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', {
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', {
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
domain: 'foo.com',
redirects: {
baseUrlRedirect: null,
regular404Redirect: null,
invalidShortUrlRedirect: null,
},
}));
});
});

View file

@ -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<ShlinkDomainRedirects>();
const dispatch = jest.fn();
const getState = jest.fn();
const editDomainRedirects = jest.fn();
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ editDomainRedirects });
const editDomainRedirectsCall = jest.fn();
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ 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<EditDomainRedirects>({ 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<EditDomainRedirects>({ 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);
});
});
});

View file

@ -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>({ domain: 'Boo', 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);
describe('reducer', () => {
const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>(
{ 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<DomainsList>({ domains }), action(FILTER_DOMAINS, { searchTerm: 'oO' }))).toEqual(
{ domains, filteredDomains },
);
expect(
reducer(Mock.of<DomainsList>({ domains }), { type: filterDomains.toString(), payload: 'oO' }),
).toEqual({ domains, filteredDomains });
});
it.each([
@ -74,12 +74,12 @@ describe('domainsListReducer', () => {
invalidShortUrlRedirect: null,
};
expect(reducer(
Mock.of<DomainsList>({ 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<DomainsList>({ 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<DomainsList>({ 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<SelectedServer>(),
}));
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 },
}));
});
});
});