mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Merge pull request #550 from acelaya-forks/feature/domain-health-checks
Feature/domain health checks
This commit is contained in:
commit
af0d2d3cdc
15 changed files with 366 additions and 45 deletions
|
@ -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.
|
||||
|
||||
* [#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.
|
||||
* [#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.
|
||||
|
|
|
@ -1,22 +1,25 @@
|
|||
import { FC } from 'react';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBan as forbiddenIcon,
|
||||
faCheck as defaultDomainIcon,
|
||||
faDotCircle as defaultDomainIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
|
||||
import { ShlinkDomainRedirects } from '../api/types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { OptionalString } from '../utils/utils';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
|
||||
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||
import { Domain } from './data';
|
||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||
|
||||
interface DomainRowProps {
|
||||
domain: ShlinkDomain;
|
||||
domain: Domain;
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
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 { domain: authority, isDefault, redirects } = domain;
|
||||
const { domain: authority, isDefault, redirects, status } = domain;
|
||||
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
|
||||
|
||||
useEffect(() => {
|
||||
checkDomainHealth(domain.domain);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||
{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">
|
||||
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-center" data-th="Status">
|
||||
<DomainStatusIcon status={status} />
|
||||
</td>
|
||||
<td className="responsive-table__cell text-right">
|
||||
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
|
||||
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
|
||||
|
|
|
@ -13,14 +13,15 @@ interface ManageDomainsProps {
|
|||
listDomains: Function;
|
||||
filterDomains: (searchTerm: string) => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
domainsList: DomainsList;
|
||||
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> = (
|
||||
{ listDomains, domainsList, filterDomains, editDomainRedirects, selectedServer },
|
||||
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
||||
) => {
|
||||
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
||||
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||
|
@ -55,6 +56,7 @@ export const ManageDomains: FC<ManageDomainsProps> = (
|
|||
key={domain.domain}
|
||||
domain={domain}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
checkDomainHealth={checkDomainHealth}
|
||||
defaultRedirects={resolvedDefaultRedirects}
|
||||
selectedServer={selectedServer}
|
||||
/>
|
||||
|
|
7
src/domains/data/index.ts
Normal file
7
src/domains/data/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { ShlinkDomain } from '../../api/types';
|
||||
|
||||
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||
|
||||
export interface Domain extends ShlinkDomain {
|
||||
status: DomainStatus;
|
||||
}
|
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
62
src/domains/helpers/DomainStatusIcon.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,10 +1,13 @@
|
|||
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 { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
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';
|
||||
|
||||
/* 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 = 'shlink/domainsList/LIST_DOMAINS';
|
||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface DomainsList {
|
||||
domains: ShlinkDomain[];
|
||||
filteredDomains: ShlinkDomain[];
|
||||
domains: Domain[];
|
||||
filteredDomains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
|
@ -24,7 +28,7 @@ export interface DomainsList {
|
|||
}
|
||||
|
||||
export interface ListDomainsAction extends Action<string> {
|
||||
domains: ShlinkDomain[];
|
||||
domains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
|
@ -32,6 +36,11 @@ interface FilterDomainsAction extends Action<string> {
|
|||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface ValidateDomain extends Action<string> {
|
||||
domain: string;
|
||||
status: DomainStatus;
|
||||
}
|
||||
|
||||
const initialState: DomainsList = {
|
||||
domains: [],
|
||||
filteredDomains: [],
|
||||
|
@ -42,10 +51,14 @@ const initialState: DomainsList = {
|
|||
export type DomainsCombinedAction = ListDomainsAction
|
||||
& ApiErrorAction
|
||||
& FilterDomainsAction
|
||||
& EditDomainRedirectsAction;
|
||||
& EditDomainRedirectsAction
|
||||
& ValidateDomain;
|
||||
|
||||
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>({
|
||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||
|
@ -61,6 +74,11 @@ export default buildReducer<DomainsList, DomainsCombinedAction>({
|
|||
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 (
|
||||
|
@ -71,7 +89,10 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
|||
const { listDomains } = buildShlinkApiClient(getState);
|
||||
|
||||
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 });
|
||||
} catch (e: any) {
|
||||
|
@ -80,3 +101,30 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
|||
};
|
||||
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Bottle from 'bottlejs';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { filterDomains, listDomains } from '../reducers/domainsList';
|
||||
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
|
||||
import { DomainSelector } from '../DomainSelector';
|
||||
import { ManageDomains } from '../ManageDomains';
|
||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
@ -13,13 +13,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||
bottle.decorator('ManageDomains', connect(
|
||||
[ 'domainsList', 'selectedServer' ],
|
||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ],
|
||||
));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
|
7
src/utils/helpers/uri.ts
Normal file
7
src/utils/helpers/uri.ts
Normal 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
1
src/utils/types.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type MediaMatcher = (query: string) => MediaQueryList;
|
|
@ -12,6 +12,7 @@ import { supportsBotVisits } from '../utils/helpers/features';
|
|||
import { SelectedServer } from '../servers/data';
|
||||
import { Time } from '../utils/Time';
|
||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||
import { MediaMatcher } from '../utils/types';
|
||||
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
||||
import './VisitsTable.scss';
|
||||
|
||||
|
@ -19,7 +20,7 @@ export interface VisitsTableProps {
|
|||
visits: NormalizedVisit[];
|
||||
selectedVisits?: NormalizedVisit[];
|
||||
setSelectedVisits: (visits: NormalizedVisit[]) => void;
|
||||
matchMedia?: (query: string) => MediaQueryList;
|
||||
matchMedia?: MediaMatcher;
|
||||
isOrphanVisits?: boolean;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
|
|
@ -3,14 +3,22 @@ import { Mock } from 'ts-mockery';
|
|||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
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 { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||
import { Domain } from '../../src/domains/data';
|
||||
|
||||
describe('<DomainRow />', () => {
|
||||
let wrapper: ShallowWrapper;
|
||||
const createWrapper = (domain: ShlinkDomain, selectedServer = Mock.all<SelectedServer>()) => {
|
||||
wrapper = shallow(<DomainRow domain={domain} editDomainRedirects={jest.fn()} selectedServer={selectedServer} />);
|
||||
const createWrapper = (domain: Domain, selectedServer = Mock.all<SelectedServer>()) => {
|
||||
wrapper = shallow(
|
||||
<DomainRow
|
||||
domain={domain}
|
||||
selectedServer={selectedServer}
|
||||
editDomainRedirects={jest.fn()}
|
||||
checkDomainHealth={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
@ -18,34 +26,34 @@ describe('<DomainRow />', () => {
|
|||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it.each([
|
||||
[ Mock.of<ShlinkDomain>({ domain: '', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
|
||||
[ Mock.of<ShlinkDomain>({ domain: '', isDefault: false }), undefined, 0, 0, undefined ],
|
||||
[ Mock.of<ShlinkDomain>({ domain: 'foo.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
|
||||
[ Mock.of<ShlinkDomain>({ 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: '', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
|
||||
[ Mock.of<Domain>({ domain: '', isDefault: false }), undefined, 0, 0, undefined ],
|
||||
[ Mock.of<Domain>({ domain: 'foo.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
|
||||
[ Mock.of<Domain>({ domain: 'foo.bar.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
|
||||
[ 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' }),
|
||||
1,
|
||||
0,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: true }),
|
||||
Mock.of<Domain>({ domain: 'foo.baz', isDefault: true }),
|
||||
Mock.of<ReachableServer>({ version: '2.9.0' }),
|
||||
1,
|
||||
1,
|
||||
'defaultDomainBtn',
|
||||
],
|
||||
[
|
||||
Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: false }),
|
||||
Mock.of<Domain>({ domain: 'foo.baz', isDefault: false }),
|
||||
Mock.of<ReachableServer>({ version: '2.9.0' }),
|
||||
0,
|
||||
0,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: false }),
|
||||
Mock.of<Domain>({ domain: 'foo.baz', isDefault: false }),
|
||||
Mock.of<ReachableServer>({ version: '2.10.0' }),
|
||||
0,
|
||||
0,
|
||||
|
@ -89,7 +97,7 @@ describe('<DomainRow />', () => {
|
|||
0,
|
||||
],
|
||||
])('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 cells = wrapper.find('td');
|
||||
|
||||
|
|
|
@ -13,14 +13,14 @@ import { SelectedServer } from '../../src/servers/data';
|
|||
describe('<ManageDomains />', () => {
|
||||
const listDomains = jest.fn();
|
||||
const filterDomains = jest.fn();
|
||||
const editDomainRedirects = jest.fn();
|
||||
let wrapper: ShallowWrapper;
|
||||
const createWrapper = (domainsList: DomainsList) => {
|
||||
wrapper = shallow(
|
||||
<ManageDomains
|
||||
listDomains={listDomains}
|
||||
filterDomains={filterDomains}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
editDomainRedirects={jest.fn()}
|
||||
checkDomainHealth={jest.fn()}
|
||||
domainsList={domainsList}
|
||||
selectedServer={Mock.all<SelectedServer>()}
|
||||
/>,
|
||||
|
@ -77,7 +77,7 @@ describe('<ManageDomains />', () => {
|
|||
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains: [] }));
|
||||
const headerCells = wrapper.find('th');
|
||||
|
||||
expect(headerCells).toHaveLength(6);
|
||||
expect(headerCells).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('one row when list of domains is empty', () => {
|
||||
|
|
73
test/domains/helpers/DomainStatusIcon.test.tsx
Normal file
73
test/domains/helpers/DomainStatusIcon.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
|
@ -4,19 +4,35 @@ import reducer, {
|
|||
LIST_DOMAINS_ERROR,
|
||||
LIST_DOMAINS_START,
|
||||
FILTER_DOMAINS,
|
||||
VALIDATE_DOMAIN,
|
||||
DomainsCombinedAction,
|
||||
DomainsList,
|
||||
listDomains as listDomainsAction,
|
||||
filterDomains as filterDomainsAction,
|
||||
replaceRedirectsOnDomain,
|
||||
checkDomainHealth,
|
||||
replaceStatusOnDomain,
|
||||
} from '../../../src/domains/reducers/domainsList';
|
||||
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 { Domain } from '../../../src/domains/data';
|
||||
import { ShlinkState } from '../../../src/container/types';
|
||||
import { SelectedServer, ServerData } from '../../../src/servers/data';
|
||||
|
||||
describe('domainsListReducer', () => {
|
||||
const filteredDomains = [ Mock.of<ShlinkDomain>({ domain: 'foo' }), Mock.of<ShlinkDomain>({ domain: 'boo' }) ];
|
||||
const domains = [ ...filteredDomains, Mock.of<ShlinkDomain>({ domain: 'bar' }) ];
|
||||
const dispatch = jest.fn();
|
||||
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', () => {
|
||||
const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>(
|
||||
|
@ -66,16 +82,23 @@ describe('domainsListReducer', () => {
|
|||
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', () => {
|
||||
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 () => {
|
||||
listDomains.mockRejectedValue(new Error('error'));
|
||||
|
||||
|
@ -108,4 +131,61 @@ describe('domainsListReducer', () => {
|
|||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
13
test/utils/helpers/uri.test.ts
Normal file
13
test/utils/helpers/uri.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue