Merge pull request #550 from acelaya-forks/feature/domain-health-checks

Feature/domain health checks
This commit is contained in:
Alejandro Celaya
2021-12-28 23:33:33 +01:00
committed by GitHub
15 changed files with 366 additions and 45 deletions

View File

@@ -12,6 +12,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval. If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured.
Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed.
The warning includes a link to the documentation, explaining what are the steps to get it fixed.
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. * [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs. * [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.

View File

@@ -1,22 +1,25 @@
import { FC } from 'react'; import { FC, useEffect } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faBan as forbiddenIcon, faBan as forbiddenIcon,
faCheck as defaultDomainIcon, faDotCircle as defaultDomainIcon,
faEdit as editIcon, faEdit as editIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types'; import { ShlinkDomainRedirects } from '../api/types';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { OptionalString } from '../utils/utils'; import { OptionalString } from '../utils/utils';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features'; import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal'; import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
import { Domain } from './data';
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
interface DomainRowProps { interface DomainRowProps {
domain: ShlinkDomain; domain: Domain;
defaultRedirects?: ShlinkDomainRedirects; defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }
@@ -33,14 +36,20 @@ const DefaultDomain: FC = () => (
</> </>
); );
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects, selectedServer }) => { export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
) => {
const [ isOpen, toggle ] = useToggle(); const [ isOpen, toggle ] = useToggle();
const { domain: authority, isDefault, redirects } = domain; const { domain: authority, isDefault, redirects, status } = domain;
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer); const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
useEffect(() => {
checkDomainHealth(domain.domain);
}, []);
return ( return (
<tr className="responsive-table__row"> <tr className="responsive-table__row">
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td> <td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
<th className="responsive-table__cell" data-th="Domain">{authority}</th> <th className="responsive-table__cell" data-th="Domain">{authority}</th>
<td className="responsive-table__cell" data-th="Base path redirect"> <td className="responsive-table__cell" data-th="Base path redirect">
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />} {redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
@@ -51,6 +60,9 @@ export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, def
<td className="responsive-table__cell" data-th="Invalid short URL redirect"> <td className="responsive-table__cell" data-th="Invalid short URL redirect">
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />} {redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
</td> </td>
<td className="responsive-table__cell text-lg-center" data-th="Status">
<DomainStatusIcon status={status} />
</td>
<td className="responsive-table__cell text-right"> <td className="responsive-table__cell text-right">
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}> <span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}> <Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>

View File

@@ -13,14 +13,15 @@ interface ManageDomainsProps {
listDomains: Function; listDomains: Function;
filterDomains: (searchTerm: string) => void; filterDomains: (searchTerm: string) => void;
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>; editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
checkDomainHealth: (domain: string) => void;
domainsList: DomainsList; domainsList: DomainsList;
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ]; const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ];
export const ManageDomains: FC<ManageDomainsProps> = ( export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects, selectedServer }, { listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
) => { ) => {
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList; const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects; const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
@@ -55,6 +56,7 @@ export const ManageDomains: FC<ManageDomainsProps> = (
key={domain.domain} key={domain.domain}
domain={domain} domain={domain}
editDomainRedirects={editDomainRedirects} editDomainRedirects={editDomainRedirects}
checkDomainHealth={checkDomainHealth}
defaultRedirects={resolvedDefaultRedirects} defaultRedirects={resolvedDefaultRedirects}
selectedServer={selectedServer} selectedServer={selectedServer}
/> />

View File

@@ -0,0 +1,7 @@
import { ShlinkDomain } from '../../api/types';
export type DomainStatus = 'validating' | 'valid' | 'invalid';
export interface Domain extends ShlinkDomain {
status: DomainStatus;
}

View File

@@ -0,0 +1,62 @@
import { FC, useEffect, useRef, useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faTimes as invalidIcon,
faCheck as checkIcon,
faCircleNotch as loadingStatusIcon,
} from '@fortawesome/free-solid-svg-icons';
import { MediaMatcher } from '../../utils/types';
import { DomainStatus } from '../data';
interface DomainStatusIconProps {
status: DomainStatus;
matchMedia?: MediaMatcher;
}
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
const ref = useRef<HTMLSpanElement>();
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile());
useEffect(() => {
const listener = () => setIsMobile(matchesMobile());
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, []);
if (status === 'validating') {
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
}
return (
<>
<span
ref={(el: HTMLSpanElement) => {
ref.current = el;
}}
>
{status === 'valid'
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
</span>
<UncontrolledTooltip
target={(() => ref.current) as any}
placement={isMobile ? 'top-start' : 'left'}
autohide={status === 'valid'}
>
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
<span>
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
<br />
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
find out what is missing.
</span>
)}
</UncontrolledTooltip>
</>
);
};

View File

@@ -1,10 +1,13 @@
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { Domain, DomainStatus } from '../data';
import { hasServerData } from '../../servers/data';
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
@@ -12,11 +15,12 @@ export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export interface DomainsList { export interface DomainsList {
domains: ShlinkDomain[]; domains: Domain[];
filteredDomains: ShlinkDomain[]; filteredDomains: Domain[];
defaultRedirects?: ShlinkDomainRedirects; defaultRedirects?: ShlinkDomainRedirects;
loading: boolean; loading: boolean;
error: boolean; error: boolean;
@@ -24,7 +28,7 @@ export interface DomainsList {
} }
export interface ListDomainsAction extends Action<string> { export interface ListDomainsAction extends Action<string> {
domains: ShlinkDomain[]; domains: Domain[];
defaultRedirects?: ShlinkDomainRedirects; defaultRedirects?: ShlinkDomainRedirects;
} }
@@ -32,6 +36,11 @@ interface FilterDomainsAction extends Action<string> {
searchTerm: string; searchTerm: string;
} }
interface ValidateDomain extends Action<string> {
domain: string;
status: DomainStatus;
}
const initialState: DomainsList = { const initialState: DomainsList = {
domains: [], domains: [],
filteredDomains: [], filteredDomains: [],
@@ -42,10 +51,14 @@ const initialState: DomainsList = {
export type DomainsCombinedAction = ListDomainsAction export type DomainsCombinedAction = ListDomainsAction
& ApiErrorAction & ApiErrorAction
& FilterDomainsAction & FilterDomainsAction
& EditDomainRedirectsAction; & EditDomainRedirectsAction
& ValidateDomain;
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects }; (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>({ export default buildReducer<DomainsList, DomainsCombinedAction>({
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
@@ -61,6 +74,11 @@ export default buildReducer<DomainsList, DomainsCombinedAction>({
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)), domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
filteredDomains: state.filteredDomains.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); }, initialState);
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
@@ -71,7 +89,10 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
const { listDomains } = buildShlinkApiClient(getState); const { listDomains } = buildShlinkApiClient(getState);
try { try {
const { data: domains, defaultRedirects } = await listDomains(); const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
defaultRedirects,
}));
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects }); dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects });
} catch (e: any) { } catch (e: any) {
@@ -80,3 +101,30 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
}; };
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); 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<ValidateDomain>({ 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<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
} catch (e) {
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
}
};

View File

@@ -1,6 +1,6 @@
import Bottle from 'bottlejs'; import Bottle from 'bottlejs';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { filterDomains, listDomains } from '../reducers/domainsList'; import { checkDomainHealth, filterDomains, listDomains } 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';
@@ -13,13 +13,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ManageDomains', () => ManageDomains); bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect( bottle.decorator('ManageDomains', connect(
[ 'domainsList', 'selectedServer' ], [ 'domainsList', 'selectedServer' ],
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ], [ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ],
)); ));
// Actions // Actions
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
bottle.serviceFactory('filterDomains', () => filterDomains); bottle.serviceFactory('filterDomains', () => filterDomains);
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
}; };
export default provideServices; export default provideServices;

7
src/utils/helpers/uri.ts Normal file
View File

@@ -0,0 +1,7 @@
export const replaceAuthorityFromUri = (uri: string, newAuthority: string): string => {
const [ schema, rest ] = uri.split('://');
const [ , ...pathParts ] = rest.split('/');
const normalizedPath = pathParts.length ? `/${pathParts.join('/')}` : '';
return `${schema}://${newAuthority}${normalizedPath}`;
};

1
src/utils/types.ts Normal file
View File

@@ -0,0 +1 @@
export type MediaMatcher = (query: string) => MediaQueryList;

View File

@@ -12,6 +12,7 @@ import { supportsBotVisits } from '../utils/helpers/features';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { Time } from '../utils/Time'; import { Time } from '../utils/Time';
import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { TableOrderIcon } from '../utils/table/TableOrderIcon';
import { MediaMatcher } from '../utils/types';
import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import { NormalizedOrphanVisit, NormalizedVisit } from './types';
import './VisitsTable.scss'; import './VisitsTable.scss';
@@ -19,7 +20,7 @@ export interface VisitsTableProps {
visits: NormalizedVisit[]; visits: NormalizedVisit[];
selectedVisits?: NormalizedVisit[]; selectedVisits?: NormalizedVisit[];
setSelectedVisits: (visits: NormalizedVisit[]) => void; setSelectedVisits: (visits: NormalizedVisit[]) => void;
matchMedia?: (query: string) => MediaQueryList; matchMedia?: MediaMatcher;
isOrphanVisits?: boolean; isOrphanVisits?: boolean;
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }

View File

@@ -3,14 +3,22 @@ import { Mock } from 'ts-mockery';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBan as forbiddenIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons'; import { faBan as forbiddenIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
import { ShlinkDomain, ShlinkDomainRedirects } from '../../src/api/types'; import { ShlinkDomainRedirects } from '../../src/api/types';
import { DomainRow } from '../../src/domains/DomainRow'; import { DomainRow } from '../../src/domains/DomainRow';
import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { Domain } from '../../src/domains/data';
describe('<DomainRow />', () => { describe('<DomainRow />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const createWrapper = (domain: ShlinkDomain, selectedServer = Mock.all<SelectedServer>()) => { const createWrapper = (domain: Domain, selectedServer = Mock.all<SelectedServer>()) => {
wrapper = shallow(<DomainRow domain={domain} editDomainRedirects={jest.fn()} selectedServer={selectedServer} />); wrapper = shallow(
<DomainRow
domain={domain}
selectedServer={selectedServer}
editDomainRedirects={jest.fn()}
checkDomainHealth={jest.fn()}
/>,
);
return wrapper; return wrapper;
}; };
@@ -18,34 +26,34 @@ describe('<DomainRow />', () => {
afterEach(() => wrapper?.unmount()); afterEach(() => wrapper?.unmount());
it.each([ it.each([
[ Mock.of<ShlinkDomain>({ domain: '', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ], [ Mock.of<Domain>({ domain: '', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
[ Mock.of<ShlinkDomain>({ domain: '', isDefault: false }), undefined, 0, 0, undefined ], [ Mock.of<Domain>({ domain: '', isDefault: false }), undefined, 0, 0, undefined ],
[ Mock.of<ShlinkDomain>({ domain: 'foo.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ], [ Mock.of<Domain>({ domain: 'foo.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
[ Mock.of<ShlinkDomain>({ domain: 'foo.bar.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ], [ Mock.of<Domain>({ domain: 'foo.bar.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
[ Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: false }), undefined, 0, 0, undefined ], [ Mock.of<Domain>({ domain: 'foo.baz', isDefault: false }), undefined, 0, 0, undefined ],
[ [
Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: true }), Mock.of<Domain>({ domain: 'foo.baz', isDefault: true }),
Mock.of<ReachableServer>({ version: '2.10.0' }), Mock.of<ReachableServer>({ version: '2.10.0' }),
1, 1,
0, 0,
undefined, undefined,
], ],
[ [
Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: true }), Mock.of<Domain>({ domain: 'foo.baz', isDefault: true }),
Mock.of<ReachableServer>({ version: '2.9.0' }), Mock.of<ReachableServer>({ version: '2.9.0' }),
1, 1,
1, 1,
'defaultDomainBtn', 'defaultDomainBtn',
], ],
[ [
Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: false }), Mock.of<Domain>({ domain: 'foo.baz', isDefault: false }),
Mock.of<ReachableServer>({ version: '2.9.0' }), Mock.of<ReachableServer>({ version: '2.9.0' }),
0, 0,
0, 0,
undefined, undefined,
], ],
[ [
Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: false }), Mock.of<Domain>({ domain: 'foo.baz', isDefault: false }),
Mock.of<ReachableServer>({ version: '2.10.0' }), Mock.of<ReachableServer>({ version: '2.10.0' }),
0, 0,
0, 0,
@@ -89,7 +97,7 @@ describe('<DomainRow />', () => {
0, 0,
], ],
])('shows expected redirects', (redirects, expectedNoRedirects) => { ])('shows expected redirects', (redirects, expectedNoRedirects) => {
const wrapper = createWrapper(Mock.of<ShlinkDomain>({ domain: '', isDefault: true, redirects })); const wrapper = createWrapper(Mock.of<Domain>({ domain: '', isDefault: true, redirects }));
const noRedirects = wrapper.find('Nr'); const noRedirects = wrapper.find('Nr');
const cells = wrapper.find('td'); const cells = wrapper.find('td');

View File

@@ -13,14 +13,14 @@ import { SelectedServer } from '../../src/servers/data';
describe('<ManageDomains />', () => { describe('<ManageDomains />', () => {
const listDomains = jest.fn(); const listDomains = jest.fn();
const filterDomains = jest.fn(); const filterDomains = jest.fn();
const editDomainRedirects = jest.fn();
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const createWrapper = (domainsList: DomainsList) => { const createWrapper = (domainsList: DomainsList) => {
wrapper = shallow( wrapper = shallow(
<ManageDomains <ManageDomains
listDomains={listDomains} listDomains={listDomains}
filterDomains={filterDomains} filterDomains={filterDomains}
editDomainRedirects={editDomainRedirects} editDomainRedirects={jest.fn()}
checkDomainHealth={jest.fn()}
domainsList={domainsList} domainsList={domainsList}
selectedServer={Mock.all<SelectedServer>()} selectedServer={Mock.all<SelectedServer>()}
/>, />,
@@ -77,7 +77,7 @@ describe('<ManageDomains />', () => {
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains: [] })); const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains: [] }));
const headerCells = wrapper.find('th'); const headerCells = wrapper.find('th');
expect(headerCells).toHaveLength(6); expect(headerCells).toHaveLength(7);
}); });
it('one row when list of domains is empty', () => { it('one row when list of domains is empty', () => {

View File

@@ -0,0 +1,73 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Mock } from 'ts-mockery';
import { faTimes, faCheck, faCircleNotch } from '@fortawesome/free-solid-svg-icons';
import { DomainStatus } from '../../../src/domains/data';
import { DomainStatusIcon } from '../../../src/domains/helpers/DomainStatusIcon';
describe('<DomainStatusIcon />', () => {
const matchMedia = jest.fn().mockReturnValue(Mock.of<MediaQueryList>({ matches: false }));
let wrapper: ShallowWrapper;
const createWrapper = (status: DomainStatus) => {
wrapper = shallow(<DomainStatusIcon status={status} matchMedia={matchMedia} />);
return wrapper;
};
beforeEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it('renders loading icon when status is "validating"', () => {
const wrapper = createWrapper('validating');
const tooltip = wrapper.find(UncontrolledTooltip);
const faIcon = wrapper.find(FontAwesomeIcon);
expect(tooltip).toHaveLength(0);
expect(faIcon).toHaveLength(1);
expect(faIcon.prop('icon')).toEqual(faCircleNotch);
expect(faIcon.prop('spin')).toEqual(true);
});
it.each([
[
'invalid' as DomainStatus,
faTimes,
'Oops! There is some missing configuration, and short URLs shared with this domain will not work.',
],
[ 'valid' as DomainStatus, faCheck, 'Congratulations! This domain is properly configured.' ],
])('renders expected icon and tooltip when status is not validating', (status, expectedIcon, expectedText) => {
const wrapper = createWrapper(status);
const tooltip = wrapper.find(UncontrolledTooltip);
const faIcon = wrapper.find(FontAwesomeIcon);
const getTooltipText = (): string => {
const children = tooltip.prop('children');
if (typeof children === 'string') {
return children;
}
return tooltip.find('span').html();
};
expect(tooltip).toHaveLength(1);
expect(tooltip.prop('autohide')).toEqual(status === 'valid');
expect(getTooltipText()).toContain(expectedText);
expect(faIcon).toHaveLength(1);
expect(faIcon.prop('icon')).toEqual(expectedIcon);
expect(faIcon.prop('spin')).toEqual(false);
});
it.each([
[ true, 'top-start' ],
[ false, 'left' ],
])('places the tooltip properly based on query match', (isMobile, expectedPlacement) => {
matchMedia.mockReturnValue(Mock.of<MediaQueryList>({ matches: isMobile }));
const wrapper = createWrapper('valid');
const tooltip = wrapper.find(UncontrolledTooltip);
expect(tooltip).toHaveLength(1);
expect(tooltip.prop('placement')).toEqual(expectedPlacement);
});
});

View File

@@ -4,19 +4,35 @@ import reducer, {
LIST_DOMAINS_ERROR, LIST_DOMAINS_ERROR,
LIST_DOMAINS_START, LIST_DOMAINS_START,
FILTER_DOMAINS, FILTER_DOMAINS,
VALIDATE_DOMAIN,
DomainsCombinedAction, DomainsCombinedAction,
DomainsList, DomainsList,
listDomains as listDomainsAction, listDomains as listDomainsAction,
filterDomains as filterDomainsAction, filterDomains as filterDomainsAction,
replaceRedirectsOnDomain, replaceRedirectsOnDomain,
checkDomainHealth,
replaceStatusOnDomain,
} from '../../../src/domains/reducers/domainsList'; } from '../../../src/domains/reducers/domainsList';
import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects'; import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects';
import { ShlinkDomain, 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 { ShlinkState } from '../../../src/container/types';
import { SelectedServer, ServerData } from '../../../src/servers/data';
describe('domainsListReducer', () => { describe('domainsListReducer', () => {
const filteredDomains = [ Mock.of<ShlinkDomain>({ domain: 'foo' }), Mock.of<ShlinkDomain>({ domain: 'boo' }) ]; const dispatch = jest.fn();
const domains = [ ...filteredDomains, Mock.of<ShlinkDomain>({ domain: 'bar' }) ]; const getState = jest.fn();
const listDomains = jest.fn();
const health = jest.fn();
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ listDomains, health });
const filteredDomains = [
Mock.of<Domain>({ domain: 'foo', status: 'validating' }),
Mock.of<Domain>({ domain: 'boo', status: 'validating' }),
];
const domains = [ ...filteredDomains, Mock.of<Domain>({ domain: 'bar', status: 'validating' }) ];
beforeEach(jest.clearAllMocks);
describe('reducer', () => { describe('reducer', () => {
const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>( const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>(
@@ -66,16 +82,23 @@ describe('domainsListReducer', () => {
filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
}); });
}); });
it.each([
[ 'foo' ],
[ 'bar' ],
[ 'does_not_exist' ],
])('replaces status on proper domain on VALIDATE_DOMAIN', (domain) => {
expect(reducer(
Mock.of<DomainsList>({ domains, filteredDomains }),
action(VALIDATE_DOMAIN, { domain, status: 'valid' }),
)).toEqual({
domains: domains.map(replaceStatusOnDomain(domain, 'valid')),
filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')),
});
});
}); });
describe('listDomains', () => { describe('listDomains', () => {
const dispatch = jest.fn();
const getState = jest.fn();
const listDomains = jest.fn();
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ listDomains });
beforeEach(jest.clearAllMocks);
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'));
@@ -108,4 +131,61 @@ describe('domainsListReducer', () => {
expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm }); expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm });
}); });
}); });
describe('checkDomainHealth', () => {
const domain = 'example.com';
it('dispatches invalid status when selected server does not have all required data', async () => {
getState.mockReturnValue(Mock.of<ShlinkState>({
selectedServer: Mock.all<SelectedServer>(),
}));
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState);
expect(getState).toHaveBeenCalledTimes(1);
expect(health).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
});
it('dispatches invalid status when health endpoint returns an error', async () => {
getState.mockReturnValue(Mock.of<ShlinkState>({
selectedServer: Mock.of<ServerData>({
url: 'https://myerver.com',
apiKey: '123',
}),
}));
health.mockRejectedValue({});
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState);
expect(getState).toHaveBeenCalledTimes(1);
expect(health).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
});
it.each([
[ 'pass', 'valid' ],
[ 'fail', 'invalid' ],
])('dispatches proper status based on status returned from health endpoint', async (
healthStatus,
expectedStatus,
) => {
getState.mockReturnValue(Mock.of<ShlinkState>({
selectedServer: Mock.of<ServerData>({
url: 'https://myerver.com',
apiKey: '123',
}),
}));
health.mockResolvedValue({ status: healthStatus });
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState);
expect(getState).toHaveBeenCalledTimes(1);
expect(health).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: expectedStatus });
});
});
}); });

View File

@@ -0,0 +1,13 @@
import { replaceAuthorityFromUri } from '../../../src/utils/helpers/uri';
describe('uri-helper', () => {
describe('replaceAuthorityFromUri', () => {
it.each([
[ 'http://something.com/foo/bar', 'www.new.to', 'http://www.new.to/foo/bar' ],
[ 'https://www.authori.ty:8000/', 'doma.in', 'https://doma.in/' ],
[ 'http://localhost:8080/this/is-a-long/path', 'somewhere:8888', 'http://somewhere:8888/this/is-a-long/path' ],
])('replaces authority as expected', (uri, newAuthority, expectedResult) => {
expect(replaceAuthorityFromUri(uri, newAuthority)).toEqual(expectedResult);
});
});
});