mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #473 from acelaya-forks/feature/manage-domains
Feature/manage domains
This commit is contained in:
commit
75931edc33
57 changed files with 947 additions and 209 deletions
|
@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
* [#465](https://github.com/shlinkio/shlink-web-client/pull/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher.
|
||||||
* [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title.
|
* [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title.
|
||||||
* [#462](https://github.com/shlinkio/shlink-web-client/pull/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
|
* [#462](https://github.com/shlinkio/shlink-web-client/pull/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
|
||||||
* [#463](https://github.com/shlinkio/shlink-web-client/pull/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
|
* [#463](https://github.com/shlinkio/shlink-web-client/pull/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
|
||||||
|
|
|
@ -16,6 +16,8 @@ import {
|
||||||
ShlinkDomain,
|
ShlinkDomain,
|
||||||
ShlinkDomainsResponse,
|
ShlinkDomainsResponse,
|
||||||
ShlinkVisitsOverview,
|
ShlinkVisitsOverview,
|
||||||
|
ShlinkEditDomainRedirects,
|
||||||
|
ShlinkDomainRedirects,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||||
|
@ -108,6 +110,11 @@ export default class ShlinkApiClient {
|
||||||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
||||||
|
|
||||||
|
public readonly editDomainRedirects = async (
|
||||||
|
domainRedirects: ShlinkEditDomainRedirects,
|
||||||
|
): Promise<ShlinkDomainRedirects> =>
|
||||||
|
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||||
|
|
||||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
||||||
try {
|
try {
|
||||||
return await this.axios({
|
return await this.axios({
|
||||||
|
|
6
src/api/types/actions.ts
Normal file
6
src/api/types/actions.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { Action } from 'redux';
|
||||||
|
import { ProblemDetailsError } from './index';
|
||||||
|
|
||||||
|
export interface ApiErrorAction extends Action<string> {
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
|
}
|
|
@ -65,9 +65,20 @@ export interface ShlinkShortUrlData extends ShortUrlMeta {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomainRedirects {
|
||||||
|
baseUrlRedirect: string | null;
|
||||||
|
regular404Redirect: string | null;
|
||||||
|
invalidShortUrlRedirect: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects> {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShlinkDomain {
|
export interface ShlinkDomain {
|
||||||
domain: string;
|
domain: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkDomainsResponse {
|
export interface ShlinkDomainsResponse {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
faTags as tagsIcon,
|
faTags as tagsIcon,
|
||||||
faPen as editIcon,
|
faPen as editIcon,
|
||||||
faHome as overviewIcon,
|
faHome as overviewIcon,
|
||||||
|
faGlobe as domainsIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
|
@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Location } from 'history';
|
import { Location } from 'history';
|
||||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||||
import { ServerWithId } from '../servers/data';
|
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||||
|
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||||
import './AsideMenu.scss';
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
export interface AsideMenuProps {
|
export interface AsideMenuProps {
|
||||||
selectedServer: ServerWithId;
|
selectedServer: SelectedServer;
|
||||||
className?: string;
|
className?: string;
|
||||||
showOnMobile?: boolean;
|
showOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -38,7 +40,8 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
||||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||||
) => {
|
) => {
|
||||||
const serverId = selectedServer ? selectedServer.id : '';
|
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||||
|
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||||
const asideClass = classNames('aside-menu', {
|
const asideClass = classNames('aside-menu', {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
});
|
});
|
||||||
|
@ -64,15 +67,23 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
<FontAwesomeIcon icon={tagsIcon} />
|
<FontAwesomeIcon icon={tagsIcon} />
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
<span className="aside-menu__item-text">Manage tags</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
|
{addManageDomainsLink && (
|
||||||
|
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||||
|
<FontAwesomeIcon icon={domainsIcon} />
|
||||||
|
<span className="aside-menu__item-text">Manage domains</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
)}
|
||||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
<FontAwesomeIcon icon={editIcon} />
|
||||||
<span className="aside-menu__item-text">Edit this server</span>
|
<span className="aside-menu__item-text">Edit this server</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<DeleteServerButton
|
{isServerWithId(selectedServer) && (
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
<DeleteServerButton
|
||||||
textClassName="aside-menu__item-text"
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
server={selectedServer}
|
textClassName="aside-menu__item-text"
|
||||||
/>
|
server={selectedServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||||
import { supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features';
|
import { supportsDomainRedirects, supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import { AsideMenuProps } from './AsideMenu';
|
import { AsideMenuProps } from './AsideMenu';
|
||||||
|
@ -22,6 +22,7 @@ const MenuLayout = (
|
||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
Overview: FC,
|
Overview: FC,
|
||||||
EditShortUrl: FC,
|
EditShortUrl: FC,
|
||||||
|
ManageDomains: FC,
|
||||||
) => withSelectedServer(({ location, selectedServer }) => {
|
) => withSelectedServer(({ location, selectedServer }) => {
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||||
|
|
||||||
|
@ -33,6 +34,7 @@ const MenuLayout = (
|
||||||
|
|
||||||
const addTagsVisitsRoute = supportsTagVisits(selectedServer);
|
const addTagsVisitsRoute = supportsTagVisits(selectedServer);
|
||||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
||||||
|
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
|
|
||||||
|
@ -55,6 +57,7 @@ const MenuLayout = (
|
||||||
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||||
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||||
|
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
|
||||||
<Route
|
<Route
|
||||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -43,6 +43,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
'ServerError',
|
'ServerError',
|
||||||
'Overview',
|
'Overview',
|
||||||
'EditShortUrl',
|
'EditShortUrl',
|
||||||
|
'ManageDomains',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|
73
src/domains/DomainRow.tsx
Normal file
73
src/domains/DomainRow.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faBan as forbiddenIcon,
|
||||||
|
faCheck as defaultDomainIcon,
|
||||||
|
faEdit as editIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { OptionalString } from '../utils/utils';
|
||||||
|
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||||
|
|
||||||
|
interface DomainRowProps {
|
||||||
|
domain: ShlinkDomain;
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||||
|
<span className="text-muted">
|
||||||
|
{!fallback && <small>No redirect</small>}
|
||||||
|
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const DefaultDomain: FC = () => (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
|
||||||
|
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
|
||||||
|
const [ isOpen, toggle ] = useToggle();
|
||||||
|
const { domain: authority, isDefault, redirects } = domain;
|
||||||
|
const domainId = `domainEdit${authority.replace('.', '')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="responsive-table__row">
|
||||||
|
<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} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell" data-th="Regular 404 redirect">
|
||||||
|
{redirects?.regular404Redirect ?? <Nr fallback={defaultRedirects?.regular404Redirect} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||||
|
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell text-right">
|
||||||
|
<span id={domainId}>
|
||||||
|
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
|
||||||
|
<FontAwesomeIcon icon={isDefault ? forbiddenIcon : editIcon} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
{isDefault && (
|
||||||
|
<UncontrolledTooltip target={domainId} placement="left">
|
||||||
|
Redirects for default domain cannot be edited here.
|
||||||
|
<br />
|
||||||
|
Use config options or env vars directly on the server.
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<EditDomainRedirectsModal
|
||||||
|
domain={domain}
|
||||||
|
isOpen={isOpen}
|
||||||
|
toggle={toggle}
|
||||||
|
editDomainRedirects={editDomainRedirects}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
71
src/domains/ManageDomains.tsx
Normal file
71
src/domains/ManageDomains.tsx
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import Message from '../utils/Message';
|
||||||
|
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 { DomainsList } from './reducers/domainsList';
|
||||||
|
import { DomainRow } from './DomainRow';
|
||||||
|
|
||||||
|
interface ManageDomainsProps {
|
||||||
|
listDomains: Function;
|
||||||
|
filterDomains: (searchTerm: string) => void;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ];
|
||||||
|
|
||||||
|
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
|
{ listDomains, domainsList, filterDomains, editDomainRedirects },
|
||||||
|
) => {
|
||||||
|
const { filteredDomains: domains, loading, error, errorData } = domainsList;
|
||||||
|
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listDomains();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Message loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Result type="error">
|
||||||
|
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
|
||||||
|
</Result>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleCard>
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead className="responsive-table__header">
|
||||||
|
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<DomainRow
|
||||||
|
key={domain.domain}
|
||||||
|
domain={domain}
|
||||||
|
editDomainRedirects={editDomainRedirects}
|
||||||
|
defaultRedirects={defaultRedirects}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchField className="mb-3" onChange={filterDomains} />
|
||||||
|
{renderContent()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
|
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||||
|
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||||
|
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||||
|
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||||
|
|
||||||
|
interface EditDomainRedirectsModalProps {
|
||||||
|
domain: ShlinkDomain;
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||||
|
<FormGroupContainer
|
||||||
|
{...rest}
|
||||||
|
required={false}
|
||||||
|
type="url"
|
||||||
|
placeholder="No redirect"
|
||||||
|
className={isLast ? 'mb-0' : ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||||
|
{ isOpen, toggle, domain, editDomainRedirects },
|
||||||
|
) => {
|
||||||
|
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
||||||
|
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? '');
|
||||||
|
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(
|
||||||
|
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||||
|
);
|
||||||
|
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
||||||
|
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||||
|
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||||
|
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||||
|
}).then(toggle));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
||||||
|
<InfoTooltip className="mr-2" placement="bottom">
|
||||||
|
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Base URL
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
||||||
|
<InfoTooltip className="mr-2" placement="bottom">
|
||||||
|
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||||
|
will be redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Regular 404
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
||||||
|
<InfoTooltip className="mr-2" placement="bottom">
|
||||||
|
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||||
|
redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Invalid short URL
|
||||||
|
</FormGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
|
||||||
|
<Button color="primary">Save</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
33
src/domains/reducers/domainRedirects.ts
Normal file
33
src/domains/reducers/domainRedirects.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
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';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export interface EditDomainRedirectsAction extends Action<string> {
|
||||||
|
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 });
|
||||||
|
const { editDomainRedirects } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
|
||||||
|
|
||||||
|
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,35 +1,63 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ShlinkDomain } from '../../api/types';
|
import { ProblemDetailsError, ShlinkDomain, 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 { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
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';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface DomainsList {
|
export interface DomainsList {
|
||||||
domains: ShlinkDomain[];
|
domains: ShlinkDomain[];
|
||||||
|
filteredDomains: ShlinkDomain[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListDomainsAction extends Action<string> {
|
export interface ListDomainsAction extends Action<string> {
|
||||||
domains: ShlinkDomain[];
|
domains: ShlinkDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FilterDomainsAction extends Action<string> {
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: DomainsList = {
|
const initialState: DomainsList = {
|
||||||
domains: [],
|
domains: [],
|
||||||
|
filteredDomains: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<DomainsList, ListDomainsAction>({
|
export type DomainsCombinedAction = ListDomainsAction
|
||||||
|
& ApiErrorAction
|
||||||
|
& FilterDomainsAction
|
||||||
|
& EditDomainRedirectsAction;
|
||||||
|
|
||||||
|
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||||
|
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects };
|
||||||
|
|
||||||
|
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
|
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
|
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }),
|
||||||
|
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||||
|
...state,
|
||||||
|
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||||
|
}),
|
||||||
|
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
|
||||||
|
...state,
|
||||||
|
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
|
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
|
}),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
|
@ -44,6 +72,8 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
||||||
|
|
||||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: LIST_DOMAINS_ERROR });
|
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||||
|
|
|
@ -1,15 +1,25 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { listDomains } from '../reducers/domainsList';
|
import { filterDomains, listDomains } from '../reducers/domainsList';
|
||||||
import { DomainSelector } from '../DomainSelector';
|
import { DomainSelector } from '../DomainSelector';
|
||||||
|
import { ManageDomains } from '../ManageDomains';
|
||||||
|
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||||
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||||
|
bottle.decorator('ManageDomains', connect(
|
||||||
|
[ 'domainsList' ],
|
||||||
|
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
||||||
|
));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||||
|
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
@import './common/react-tag-autocomplete.scss';
|
@import './common/react-tag-autocomplete.scss';
|
||||||
@import './theme/theme';
|
@import './theme/theme';
|
||||||
|
@import './utils/table/ResponsiveTable';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
export class Topics {
|
export class Topics {
|
||||||
public static visits = () => 'https://shlink.io/new-visit';
|
public static readonly visits = 'https://shlink.io/new-visit';
|
||||||
|
|
||||||
public static shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
|
||||||
|
|
||||||
public static orphanVisits = () => 'https://shlink.io/new-orphan-visit';
|
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,4 +120,4 @@ export const Overview = (
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
|
}, () => [ Topics.visits, Topics.orphanVisits ]);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||||
import { FormGroupContainer } from '../../utils/FormGroupContainer';
|
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
import { SimpleCard } from '../../utils/SimpleCard';
|
import { SimpleCard } from '../../utils/SimpleCard';
|
||||||
|
@ -11,6 +11,9 @@ interface ServerFormProps {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
||||||
|
<FormGroupContainer {...props} labelClassName="create-server__label" />;
|
||||||
|
|
||||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||||
const [ name, setName ] = useState('');
|
const [ name, setName ] = useState('');
|
||||||
const [ url, setUrl ] = useState('');
|
const [ url, setUrl ] = useState('');
|
||||||
|
@ -26,9 +29,9 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
||||||
return (
|
return (
|
||||||
<form className="server-form" onSubmit={handleSubmit}>
|
<form className="server-form" onSubmit={handleSubmit}>
|
||||||
<SimpleCard className="mb-3" title={title}>
|
<SimpleCard className="mb-3" title={title}>
|
||||||
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
|
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
||||||
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
|
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
||||||
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
|
<FormGroup value={apiKey} onChange={setApiKey}>APIkey</FormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
||||||
<div className="text-right">{children}</div>
|
<div className="text-right">{children}</div>
|
||||||
|
|
|
@ -30,11 +30,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="search-bar-container">
|
<div className="search-bar-container">
|
||||||
<SearchField
|
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} />
|
||||||
onChange={
|
|
||||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
|
|
@ -99,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits() ]);
|
}, () => [ Topics.visits ]);
|
||||||
|
|
||||||
export default ShortUrlsList;
|
export default ShortUrlsList;
|
||||||
|
|
|
@ -1,11 +1,3 @@
|
||||||
@import '../utils/base';
|
|
||||||
|
|
||||||
.short-urls-table__header {
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-table__header-cell--with-action {
|
.short-urls-table__header-cell--with-action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,7 +60,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={tableClasses}>
|
<table className={tableClasses}>
|
||||||
<thead className="short-urls-table__header">
|
<thead className="responsive-table__header short-urls-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
||||||
Created at
|
Created at
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import { ChangeEvent, FC, useRef } from 'react';
|
import { ChangeEvent, FC } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
|
||||||
import Checkbox from '../../utils/Checkbox';
|
import Checkbox from '../../utils/Checkbox';
|
||||||
|
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||||
|
|
||||||
interface ShortUrlFormCheckboxGroupProps {
|
interface ShortUrlFormCheckboxGroupProps {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
@ -10,23 +8,6 @@ interface ShortUrlFormCheckboxGroupProps {
|
||||||
infoTooltip?: string;
|
infoTooltip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InfoTooltip: FC<{ tooltip: string }> = ({ tooltip }) => {
|
|
||||||
const ref = useRef<HTMLElement | null>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
ref.current = el;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={infoIcon} />
|
|
||||||
</span>
|
|
||||||
<UncontrolledTooltip target={(() => ref.current) as any} placement="right">{tooltip}</UncontrolledTooltip>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
||||||
{ children, infoTooltip, checked, onChange },
|
{ children, infoTooltip, checked, onChange },
|
||||||
) => (
|
) => (
|
||||||
|
@ -34,6 +15,6 @@ export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
||||||
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
||||||
{children}
|
{children}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
{infoTooltip && <InfoTooltip tooltip={infoTooltip} />}
|
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,39 +1,8 @@
|
||||||
@import '../../utils/base';
|
@import '../../utils/base';
|
||||||
@import '../../utils/mixins/vertical-align';
|
@import '../../utils/mixins/vertical-align';
|
||||||
|
|
||||||
.short-urls-row {
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-row__cell.short-urls-row__cell {
|
.short-urls-row__cell.short-urls-row__cell {
|
||||||
vertical-align: middle !important;
|
vertical-align: middle !important;
|
||||||
|
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
padding: .5rem;
|
|
||||||
font-size: .9rem;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: attr(data-th);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
position: absolute;
|
|
||||||
top: 3.5px;
|
|
||||||
right: .5rem;
|
|
||||||
width: auto;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-row__cell--break {
|
.short-urls-row__cell--break {
|
||||||
|
|
|
@ -51,11 +51,11 @@ const ShortUrlsRow = (
|
||||||
}, [ shortUrl.visitsCount ]);
|
}, [ shortUrl.visitsCount ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="short-urls-row">
|
<tr className="responsive-table__row">
|
||||||
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
|
<td className="indivisible short-urls-row__cell responsive-table__cell" data-th="Created at">
|
||||||
<Time date={shortUrl.dateCreated} />
|
<Time date={shortUrl.dateCreated} />
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell" data-th="Short URL: ">
|
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
||||||
<span className="indivisible short-urls-row__cell--relative">
|
<span className="indivisible short-urls-row__cell--relative">
|
||||||
<ExternalLink href={shortUrl.shortUrl} />
|
<ExternalLink href={shortUrl.shortUrl} />
|
||||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||||
|
@ -64,16 +64,16 @@ const ShortUrlsRow = (
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}: `}>
|
<td className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}>
|
||||||
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
||||||
</td>
|
</td>
|
||||||
{shortUrl.title && (
|
{shortUrl.title && (
|
||||||
<td className="short-urls-row__cell short-urls-row__cell--break d-lg-none" data-th="Long URL: ">
|
<td className="short-urls-row__cell responsive-table__cell short-urls-row__cell--break d-lg-none" data-th="Long URL">
|
||||||
<ExternalLink href={shortUrl.longUrl} />
|
<ExternalLink href={shortUrl.longUrl} />
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
|
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
|
||||||
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
|
<td className="responsive-table__cell short-urls-row__cell text-lg-right" data-th="Visits">
|
||||||
<ShortUrlVisitsCount
|
<ShortUrlVisitsCount
|
||||||
visitsCount={shortUrl.visitsCount}
|
visitsCount={shortUrl.visitsCount}
|
||||||
shortUrl={shortUrl}
|
shortUrl={shortUrl}
|
||||||
|
@ -81,7 +81,7 @@ const ShortUrlsRow = (
|
||||||
active={active}
|
active={active}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell">
|
<td className="responsive-table__cell short-urls-row__cell">
|
||||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||||
|
@ -24,17 +25,13 @@ export interface CreateShortUrlAction extends Action<string> {
|
||||||
result: ShortUrl;
|
result: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateShortUrlFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlCreation = {
|
const initialState: ShortUrlCreation = {
|
||||||
result: null,
|
result: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & CreateShortUrlFailedAction>({
|
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & ApiErrorAction>({
|
||||||
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||||
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||||
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
||||||
|
@ -53,7 +50,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
|
|
||||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { ProblemDetailsError } from '../../api/types';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||||
|
@ -24,17 +25,13 @@ export interface DeleteShortUrlAction extends Action<string> {
|
||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeleteShortUrlErrorAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlDeletion = {
|
const initialState: ShortUrlDeletion = {
|
||||||
shortCode: '',
|
shortCode: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & DeleteShortUrlErrorAction>({
|
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & ApiErrorAction>({
|
||||||
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
||||||
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||||
|
@ -52,7 +49,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
await deleteShortUrl(shortCode, domain);
|
await deleteShortUrl(shortCode, domain);
|
||||||
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { GetState } from '../../container/types';
|
||||||
import { shortUrlMatches } from '../helpers';
|
import { shortUrlMatches } from '../helpers';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
||||||
|
@ -25,16 +26,12 @@ export interface ShortUrlDetailAction extends Action<string> {
|
||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlDetailFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlDetail = {
|
const initialState: ShortUrlDetail = {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ShortUrlDetailFailedAction>({
|
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ApiErrorAction>({
|
||||||
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
|
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
|
||||||
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
||||||
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
||||||
|
@ -54,6 +51,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
||||||
|
|
||||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<ShortUrlDetailFailedAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { supportsTagsInPatch } from '../../utils/helpers/features';
|
import { supportsTagsInPatch } from '../../utils/helpers/features';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||||
|
@ -25,16 +26,12 @@ export interface ShortUrlEditedAction extends Action<string> {
|
||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlEditionFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlEdition = {
|
const initialState: ShortUrlEdition = {
|
||||||
saving: false,
|
saving: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEditionFailedAction>({
|
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ApiErrorAction>({
|
||||||
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||||
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||||
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
||||||
|
@ -59,7 +56,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
|
|
||||||
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
import { splitEvery } from 'ramda';
|
||||||
|
import { Row } from 'reactstrap';
|
||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
|
@ -29,11 +30,11 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||||
forceListTags();
|
forceListTags();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderContent = () => {
|
if (tagsList.loading) {
|
||||||
if (tagsList.loading) {
|
return <Message loading />;
|
||||||
return <Message loading />;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
if (tagsList.error) {
|
if (tagsList.error) {
|
||||||
return (
|
return (
|
||||||
<Result type="error">
|
<Result type="error">
|
||||||
|
@ -51,7 +52,7 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row">
|
<Row>
|
||||||
{tagsGroups.map((group, index) => (
|
{tagsGroups.map((group, index) => (
|
||||||
<div key={index} className="col-md-6 col-xl-3">
|
<div key={index} className="col-md-6 col-xl-3">
|
||||||
{group.map((tag) => (
|
{group.map((tag) => (
|
||||||
|
@ -66,16 +67,16 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
|
<SearchField className="mb-3" onChange={filterTags} />
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits() ]);
|
}, () => [ Topics.visits ]);
|
||||||
|
|
||||||
export default TagsList;
|
export default TagsList;
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||||
|
@ -22,16 +23,12 @@ export interface DeleteTagAction extends Action<string> {
|
||||||
tag: string;
|
tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteTagFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TagDeletion = {
|
const initialState: TagDeletion = {
|
||||||
deleting: false,
|
deleting: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagDeletion, DeleteTagFailedAction>({
|
export default buildReducer<TagDeletion, ApiErrorAction>({
|
||||||
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
||||||
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
||||||
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
||||||
|
@ -48,7 +45,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
|
||||||
await deleteTags([ tag ]);
|
await deleteTags([ tag ]);
|
||||||
dispatch({ type: DELETE_TAG });
|
dispatch({ type: DELETE_TAG });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<DeleteTagFailedAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError } from '../../api/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||||
|
@ -29,10 +30,6 @@ export interface EditTagAction extends Action<string> {
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditTagFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TagEdition = {
|
const initialState: TagEdition = {
|
||||||
oldName: '',
|
oldName: '',
|
||||||
newName: '',
|
newName: '',
|
||||||
|
@ -40,7 +37,7 @@ const initialState: TagEdition = {
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagEdition, EditTagAction & EditTagFailedAction>({
|
export default buildReducer<TagEdition, EditTagAction & ApiErrorAction>({
|
||||||
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
||||||
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
||||||
[EDIT_TAG]: (_, action) => ({
|
[EDIT_TAG]: (_, action) => ({
|
||||||
|
@ -63,7 +60,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
|
||||||
colorGenerator.setColorForKey(newName, color);
|
colorGenerator.setColorForKey(newName, color);
|
||||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<EditTagFailedAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
||||||
import { CreateVisit, Stats } from '../../visits/types';
|
import { CreateVisit, Stats } from '../../visits/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { TagStats } from '../data';
|
import { TagStats } from '../data';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
||||||
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
||||||
|
|
||||||
|
@ -34,20 +35,16 @@ interface ListTagsAction extends Action<string> {
|
||||||
stats: TagsStatsMap;
|
stats: TagsStatsMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListTagsFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterTagsAction extends Action<string> {
|
interface FilterTagsAction extends Action<string> {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListTagsCombinedAction = ListTagsAction
|
type TagsCombinedAction = ListTagsAction
|
||||||
& DeleteTagAction
|
& DeleteTagAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& EditTagAction
|
& EditTagAction
|
||||||
& FilterTagsAction
|
& FilterTagsAction
|
||||||
& ListTagsFailedAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
tags: [],
|
tags: [],
|
||||||
|
@ -83,7 +80,7 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O
|
||||||
}, {}),
|
}, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default buildReducer<TagsList, ListTagsCombinedAction>({
|
export default buildReducer<TagsList, TagsCombinedAction>({
|
||||||
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
[LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
[LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
||||||
|
@ -130,7 +127,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
||||||
|
|
||||||
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<ListTagsFailedAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,29 +1,37 @@
|
||||||
import { FC } from 'react';
|
import { FC, useRef } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { InputType } from 'reactstrap/lib/Input';
|
import { InputType } from 'reactstrap/lib/Input';
|
||||||
|
|
||||||
interface FormGroupContainerProps {
|
export interface FormGroupContainerProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (newValue: string) => void;
|
onChange: (newValue: string) => void;
|
||||||
id?: string;
|
id?: string;
|
||||||
type?: InputType;
|
type?: InputType;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
labelClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
||||||
{ children, value, onChange, id = uuid(), type = 'text', required = true },
|
{ children, value, onChange, id, type, required, placeholder, className, labelClassName },
|
||||||
) => (
|
) => {
|
||||||
<div className="form-group">
|
const forId = useRef<string>(id ?? uuid());
|
||||||
<label htmlFor={id} className="create-server__label">
|
|
||||||
{children}:
|
return (
|
||||||
</label>
|
<div className={`form-group ${className ?? ''}`}>
|
||||||
<input
|
<label htmlFor={forId.current} className={labelClassName ?? ''}>
|
||||||
className="form-control"
|
{children}:
|
||||||
type={type}
|
</label>
|
||||||
id={id}
|
<input
|
||||||
value={value}
|
className="form-control"
|
||||||
required={required}
|
type={type ?? 'text'}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
id={forId.current}
|
||||||
/>
|
value={value}
|
||||||
</div>
|
required={required ?? true}
|
||||||
);
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
26
src/utils/InfoTooltip.tsx
Normal file
26
src/utils/InfoTooltip.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { FC, useRef } from 'react';
|
||||||
|
import * as Popper from 'popper.js';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
|
||||||
|
interface InfoTooltipProps {
|
||||||
|
className?: string;
|
||||||
|
placement: Popper.Placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {
|
||||||
|
const ref = useRef<HTMLSpanElement | null>();
|
||||||
|
const refCallback = (el: HTMLSpanElement) => {
|
||||||
|
ref.current = el;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={className} ref={refCallback}>
|
||||||
|
<FontAwesomeIcon icon={infoIcon} />
|
||||||
|
</span>
|
||||||
|
<UncontrolledTooltip target={(() => ref.current) as any} placement={placement}>{children}</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -10,14 +10,11 @@ let timer: NodeJS.Timeout | null;
|
||||||
interface SearchFieldProps {
|
interface SearchFieldProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchField = (
|
const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => {
|
||||||
{ onChange, className, placeholder = 'Search...', large = true, noBorder = false }: SearchFieldProps,
|
|
||||||
) => {
|
|
||||||
const [ searchTerm, setSearchTerm ] = useState('');
|
const [ searchTerm, setSearchTerm ] = useState('');
|
||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
|
@ -43,7 +40,7 @@ const SearchField = (
|
||||||
'form-control-lg': large,
|
'form-control-lg': large,
|
||||||
'search-field__input--no-border': noBorder,
|
'search-field__input--no-border': noBorder,
|
||||||
})}
|
})}
|
||||||
placeholder={placeholder}
|
placeholder="Search..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => searchTermChanged(e.target.value)}
|
onChange={(e) => searchTermChanged(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -29,3 +29,5 @@ export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' });
|
||||||
export const supportsCrawlableVisits = supportsBotVisits;
|
export const supportsCrawlableVisits = supportsBotVisits;
|
||||||
|
|
||||||
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' });
|
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' });
|
||||||
|
|
||||||
|
export const supportsDomainRedirects = supportsQrErrorCorrection;
|
||||||
|
|
42
src/utils/table/ResponsiveTable.scss
Normal file
42
src/utils/table/ResponsiveTable.scss
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
@import '../../utils/base';
|
||||||
|
|
||||||
|
.responsive-table__header {
|
||||||
|
@media (max-width: $responsiveTableBreakpoint) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table__row {
|
||||||
|
@media (max-width: $responsiveTableBreakpoint) {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table__cell.responsive-table__cell {
|
||||||
|
vertical-align: middle !important;
|
||||||
|
|
||||||
|
@media (max-width: $responsiveTableBreakpoint) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding: .5rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
|
||||||
|
&[data-th]:before {
|
||||||
|
content: attr(data-th) ': ';
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
position: absolute;
|
||||||
|
top: 3.5px;
|
||||||
|
right: .5rem;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,3 +43,5 @@ export type OptionalString = Optional<string>;
|
||||||
export type RecursivePartial<T> = {
|
export type RecursivePartial<T> = {
|
||||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
[P in keyof T]?: RecursivePartial<T[P]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const nonEmptyValueOrNull = <T>(value: T): T | null => isEmpty(value) ? null : value;
|
||||||
|
|
|
@ -41,4 +41,4 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
|
||||||
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
||||||
</VisitsStats>
|
</VisitsStats>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.orphanVisits() ]);
|
}, () => [ Topics.orphanVisits ]);
|
||||||
|
|
|
@ -43,6 +43,6 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
|
||||||
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
|
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
|
||||||
</VisitsStats>
|
</VisitsStats>
|
||||||
);
|
);
|
||||||
}, () => [ Topics.visits() ]);
|
}, () => [ Topics.visits ]);
|
||||||
|
|
||||||
export default TagVisits;
|
export default TagVisits;
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { flatten, prop, range, splitEvery } from 'ramda';
|
import { flatten, prop, range, splitEvery } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ShlinkPaginator, ShlinkVisits } from '../../api/types';
|
import { ShlinkPaginator, ShlinkVisits } from '../../api/types';
|
||||||
import { Visit, VisitsLoadFailedAction } from '../types';
|
import { Visit } from '../types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 5000;
|
const ITEMS_PER_PAGE = 5000;
|
||||||
const PARALLEL_REQUESTS_COUNT = 4;
|
const PARALLEL_REQUESTS_COUNT = 4;
|
||||||
|
@ -72,6 +73,6 @@ export const getVisitsWithLoader = async <T extends Action<string> & { visits: V
|
||||||
|
|
||||||
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch<VisitsLoadFailedAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import {
|
import { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||||
OrphanVisit,
|
|
||||||
OrphanVisitType,
|
|
||||||
Visit,
|
|
||||||
VisitsInfo,
|
|
||||||
VisitsLoadFailedAction,
|
|
||||||
VisitsLoadProgressChangedAction,
|
|
||||||
} from '../types';
|
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, 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 { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
import { isOrphanVisit } from '../types/helpers';
|
import { isOrphanVisit } from '../types/helpers';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
|
@ -31,7 +25,7 @@ export interface OrphanVisitsAction extends Action<string> {
|
||||||
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& VisitsLoadFailedAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
const initialState: VisitsInfo = {
|
const initialState: VisitsInfo = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { shortUrlMatches } from '../../short-urls/helpers';
|
import { shortUrlMatches } from '../../short-urls/helpers';
|
||||||
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
|
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||||
import { ShortUrlIdentifier } from '../../short-urls/data';
|
import { ShortUrlIdentifier } from '../../short-urls/data';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, 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 { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
|
@ -27,7 +28,7 @@ interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
|
||||||
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& VisitsLoadFailedAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
const initialState: ShortUrlVisits = {
|
const initialState: ShortUrlVisits = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
|
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, 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 { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
|
@ -28,7 +29,7 @@ export interface TagVisitsAction extends Action<string> {
|
||||||
type TagsVisitsCombinedAction = TagVisitsAction
|
type TagsVisitsCombinedAction = TagVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& VisitsLoadFailedAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
const initialState: TagVisits = {
|
const initialState: TagVisits = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
|
|
@ -17,10 +17,6 @@ export interface VisitsLoadProgressChangedAction extends Action<string> {
|
||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisitsLoadFailedAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
||||||
|
|
||||||
interface VisitLocation {
|
interface VisitLocation {
|
||||||
|
|
|
@ -297,4 +297,17 @@ describe('ShlinkApiClient', () => {
|
||||||
expect(result).toEqual(expectedData);
|
expect(result).toEqual(expectedData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('editDomainRedirects', () => {
|
||||||
|
it('returns the redirects', async () => {
|
||||||
|
const resp = { baseUrlRedirect: null, regular404Redirect: 'foo', invalidShortUrlRedirect: 'bar' };
|
||||||
|
const axiosSpy = createAxiosMock({ data: resp });
|
||||||
|
const { editDomainRedirects } = new ShlinkApiClient(axiosSpy, '', '');
|
||||||
|
|
||||||
|
const result = await editDomainRedirects({ domain: 'foo' });
|
||||||
|
|
||||||
|
expect(axiosSpy).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(resp);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import asideMenuCreator from '../../src/common/AsideMenu';
|
import asideMenuCreator from '../../src/common/AsideMenu';
|
||||||
import { ServerWithId } from '../../src/servers/data';
|
import { ReachableServer } from '../../src/servers/data';
|
||||||
|
|
||||||
describe('<AsideMenu />', () => {
|
describe('<AsideMenu />', () => {
|
||||||
let wrapped: ShallowWrapper;
|
let wrapped: ShallowWrapper;
|
||||||
|
@ -10,7 +10,7 @@ describe('<AsideMenu />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const AsideMenu = asideMenuCreator(DeleteServerButton);
|
const AsideMenu = asideMenuCreator(DeleteServerButton);
|
||||||
|
|
||||||
wrapped = shallow(<AsideMenu selectedServer={Mock.of<ServerWithId>({ id: 'abc123' })} />);
|
wrapped = shallow(<AsideMenu selectedServer={Mock.of<ReachableServer>({ id: 'abc123' })} />);
|
||||||
});
|
});
|
||||||
afterEach(() => wrapped.unmount());
|
afterEach(() => wrapped.unmount());
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { SemVer } from '../../src/utils/helpers/version';
|
||||||
describe('<MenuLayout />', () => {
|
describe('<MenuLayout />', () => {
|
||||||
const ServerError = jest.fn();
|
const ServerError = jest.fn();
|
||||||
const C = jest.fn();
|
const C = jest.fn();
|
||||||
const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C, C);
|
const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C, C, C);
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (selectedServer: SelectedServer) => {
|
const createWrapper = (selectedServer: SelectedServer) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
|
|
57
test/domains/DomainRow.test.tsx
Normal file
57
test/domains/DomainRow.test.tsx
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
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 { DomainRow } from '../../src/domains/DomainRow';
|
||||||
|
|
||||||
|
describe('<DomainRow />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createWrapper = (domain: ShlinkDomain) => {
|
||||||
|
wrapper = shallow(<DomainRow domain={domain} editDomainRedirects={jest.fn()} />);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ Mock.of<ShlinkDomain>({ domain: '', isDefault: true }), 1 ],
|
||||||
|
[ Mock.of<ShlinkDomain>({ domain: '', isDefault: false }), 0 ],
|
||||||
|
])('shows proper components based on the fact that provided domain is default or not', (domain, expectedComps) => {
|
||||||
|
const wrapper = createWrapper(domain);
|
||||||
|
const defaultDomainComp = wrapper.find('td').first().find('DefaultDomain');
|
||||||
|
const tooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
const button = wrapper.find(Button);
|
||||||
|
const icon = wrapper.find(FontAwesomeIcon);
|
||||||
|
|
||||||
|
expect(defaultDomainComp).toHaveLength(expectedComps);
|
||||||
|
expect(tooltip).toHaveLength(expectedComps);
|
||||||
|
expect(button.prop('disabled')).toEqual(domain.isDefault);
|
||||||
|
expect(icon.prop('icon')).toEqual(domain.isDefault ? forbiddenIcon : editIcon);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, 3 ],
|
||||||
|
[ Mock.of<ShlinkDomainRedirects>(), 3 ],
|
||||||
|
[ Mock.of<ShlinkDomainRedirects>({ baseUrlRedirect: 'foo' }), 2 ],
|
||||||
|
[ Mock.of<ShlinkDomainRedirects>({ invalidShortUrlRedirect: 'foo' }), 2 ],
|
||||||
|
[ Mock.of<ShlinkDomainRedirects>({ baseUrlRedirect: 'foo', regular404Redirect: 'foo' }), 1 ],
|
||||||
|
[
|
||||||
|
Mock.of<ShlinkDomainRedirects>(
|
||||||
|
{ baseUrlRedirect: 'foo', regular404Redirect: 'foo', invalidShortUrlRedirect: 'foo' },
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
])('shows expected redirects', (redirects, expectedNoRedirects) => {
|
||||||
|
const wrapper = createWrapper(Mock.of<ShlinkDomain>({ domain: '', isDefault: true, redirects }));
|
||||||
|
const noRedirects = wrapper.find('Nr');
|
||||||
|
const cells = wrapper.find('td');
|
||||||
|
|
||||||
|
expect(noRedirects).toHaveLength(expectedNoRedirects);
|
||||||
|
redirects?.baseUrlRedirect && expect(cells.at(1).html()).toContain(redirects.baseUrlRedirect);
|
||||||
|
redirects?.regular404Redirect && expect(cells.at(2).html()).toContain(redirects.regular404Redirect);
|
||||||
|
redirects?.invalidShortUrlRedirect && expect(cells.at(3).html()).toContain(redirects.invalidShortUrlRedirect);
|
||||||
|
});
|
||||||
|
});
|
106
test/domains/ManageDomains.test.tsx
Normal file
106
test/domains/ManageDomains.test.tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { DomainsList } from '../../src/domains/reducers/domainsList';
|
||||||
|
import { ManageDomains } from '../../src/domains/ManageDomains';
|
||||||
|
import Message from '../../src/utils/Message';
|
||||||
|
import { Result } from '../../src/utils/Result';
|
||||||
|
import SearchField from '../../src/utils/SearchField';
|
||||||
|
import { ProblemDetailsError, ShlinkDomain } from '../../src/api/types';
|
||||||
|
import { ShlinkApiError } from '../../src/api/ShlinkApiError';
|
||||||
|
import { DomainRow } from '../../src/domains/DomainRow';
|
||||||
|
|
||||||
|
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}
|
||||||
|
domainsList={domainsList}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it('shows loading message while domains are loading', () => {
|
||||||
|
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: true, filteredDomains: [] }));
|
||||||
|
const message = wrapper.find(Message);
|
||||||
|
const searchField = wrapper.find(SearchField);
|
||||||
|
const result = wrapper.find(Result);
|
||||||
|
const apiError = wrapper.find(ShlinkApiError);
|
||||||
|
|
||||||
|
expect(message).toHaveLength(1);
|
||||||
|
expect(message.prop('loading')).toEqual(true);
|
||||||
|
expect(searchField).toHaveLength(0);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
expect(apiError).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error result when domains loading fails', () => {
|
||||||
|
const errorData = Mock.of<ProblemDetailsError>();
|
||||||
|
const wrapper = createWrapper(Mock.of<DomainsList>(
|
||||||
|
{ loading: false, error: true, errorData, filteredDomains: [] },
|
||||||
|
));
|
||||||
|
const message = wrapper.find(Message);
|
||||||
|
const searchField = wrapper.find(SearchField);
|
||||||
|
const result = wrapper.find(Result);
|
||||||
|
const apiError = wrapper.find(ShlinkApiError);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result.prop('type')).toEqual('error');
|
||||||
|
expect(apiError).toHaveLength(1);
|
||||||
|
expect(apiError.prop('errorData')).toEqual(errorData);
|
||||||
|
expect(searchField).toHaveLength(1);
|
||||||
|
expect(message).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters domains when SearchField changes', () => {
|
||||||
|
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains: [] }));
|
||||||
|
const searchField = wrapper.find(SearchField);
|
||||||
|
|
||||||
|
expect(filterDomains).not.toHaveBeenCalled();
|
||||||
|
searchField.simulate('change');
|
||||||
|
expect(filterDomains).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows expected headers', () => {
|
||||||
|
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains: [] }));
|
||||||
|
const headerCells = wrapper.find('th');
|
||||||
|
|
||||||
|
expect(headerCells).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('one row when list of domains is empty', () => {
|
||||||
|
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains: [] }));
|
||||||
|
const tableBody = wrapper.find('tbody');
|
||||||
|
const regularRows = tableBody.find('tr');
|
||||||
|
const domainRows = tableBody.find(DomainRow);
|
||||||
|
|
||||||
|
expect(regularRows).toHaveLength(1);
|
||||||
|
expect(regularRows.html()).toContain('No results found');
|
||||||
|
expect(domainRows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('as many DomainRows as domains are provided', () => {
|
||||||
|
const filteredDomains = [
|
||||||
|
Mock.of<ShlinkDomain>({ domain: 'foo' }),
|
||||||
|
Mock.of<ShlinkDomain>({ domain: 'bar' }),
|
||||||
|
Mock.of<ShlinkDomain>({ domain: 'baz' }),
|
||||||
|
];
|
||||||
|
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains }));
|
||||||
|
const tableBody = wrapper.find('tbody');
|
||||||
|
const regularRows = tableBody.find('tr');
|
||||||
|
const domainRows = tableBody.find(DomainRow);
|
||||||
|
|
||||||
|
expect(regularRows).toHaveLength(0);
|
||||||
|
expect(domainRows).toHaveLength(filteredDomains.length);
|
||||||
|
});
|
||||||
|
});
|
95
test/domains/helpers/EditDomainRedirectsModal.test.tsx
Normal file
95
test/domains/helpers/EditDomainRedirectsModal.test.tsx
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { Button, ModalHeader } from 'reactstrap';
|
||||||
|
import { ShlinkDomain } from '../../../src/api/types';
|
||||||
|
import { EditDomainRedirectsModal } from '../../../src/domains/helpers/EditDomainRedirectsModal';
|
||||||
|
import { InfoTooltip } from '../../../src/utils/InfoTooltip';
|
||||||
|
|
||||||
|
describe('<EditDomainRedirectsModal />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const editDomainRedirects = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const toggle = jest.fn();
|
||||||
|
const domain = Mock.of<ShlinkDomain>({
|
||||||
|
domain: 'foo.com',
|
||||||
|
redirects: {
|
||||||
|
baseUrlRedirect: 'baz',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<EditDomainRedirectsModal domain={domain} isOpen toggle={toggle} editDomainRedirects={editDomainRedirects} />,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it('renders domain in header', () => {
|
||||||
|
const header = wrapper.find(ModalHeader);
|
||||||
|
|
||||||
|
expect(header.html()).toContain('foo.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expected amount of form groups and tooltips', () => {
|
||||||
|
const formGroups = wrapper.find('FormGroup');
|
||||||
|
const tooltips = wrapper.find(InfoTooltip);
|
||||||
|
|
||||||
|
expect(formGroups).toHaveLength(3);
|
||||||
|
expect(tooltips).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has different handlers to toggle the modal', () => {
|
||||||
|
expect(toggle).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
(wrapper.prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||||
|
(wrapper.find(ModalHeader).prop('toggle') as Function)();
|
||||||
|
wrapper.find(Button).first().simulate('click');
|
||||||
|
|
||||||
|
expect(toggle).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves expected values when form is submitted', () => {
|
||||||
|
const formGroups = wrapper.find('FormGroup');
|
||||||
|
|
||||||
|
expect(editDomainRedirects).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
wrapper.find('form').simulate('submit', { preventDefault: jest.fn() });
|
||||||
|
expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', {
|
||||||
|
baseUrlRedirect: 'baz',
|
||||||
|
regular404Redirect: null,
|
||||||
|
invalidShortUrlRedirect: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
formGroups.at(0).simulate('change', 'new_base_url');
|
||||||
|
formGroups.at(2).simulate('change', 'new_invalid_short_url');
|
||||||
|
|
||||||
|
wrapper.find('form').simulate('submit', { preventDefault: jest.fn() });
|
||||||
|
expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', {
|
||||||
|
baseUrlRedirect: 'new_base_url',
|
||||||
|
regular404Redirect: null,
|
||||||
|
invalidShortUrlRedirect: 'new_invalid_short_url',
|
||||||
|
});
|
||||||
|
|
||||||
|
formGroups.at(1).simulate('change', 'new_regular_404');
|
||||||
|
formGroups.at(2).simulate('change', '');
|
||||||
|
|
||||||
|
wrapper.find('form').simulate('submit', { preventDefault: jest.fn() });
|
||||||
|
expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', {
|
||||||
|
baseUrlRedirect: 'new_base_url',
|
||||||
|
regular404Redirect: 'new_regular_404',
|
||||||
|
invalidShortUrlRedirect: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
formGroups.at(0).simulate('change', '');
|
||||||
|
formGroups.at(1).simulate('change', '');
|
||||||
|
formGroups.at(2).simulate('change', '');
|
||||||
|
|
||||||
|
wrapper.find('form').simulate('submit', { preventDefault: jest.fn() });
|
||||||
|
expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', {
|
||||||
|
baseUrlRedirect: null,
|
||||||
|
regular404Redirect: null,
|
||||||
|
invalidShortUrlRedirect: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
44
test/domains/reducers/domainRedirects.test.ts
Normal file
44
test/domains/reducers/domainRedirects.test.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
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 { ShlinkDomainRedirects } from '../../../src/api/types';
|
||||||
|
|
||||||
|
describe('domainRedirectsReducer', () => {
|
||||||
|
beforeEach(jest.clearAllMocks);
|
||||||
|
|
||||||
|
describe('editDomainRedirects', () => {
|
||||||
|
const domain = 'example.com';
|
||||||
|
const redirects = Mock.all<ShlinkDomainRedirects>();
|
||||||
|
const dispatch = jest.fn();
|
||||||
|
const getState = jest.fn();
|
||||||
|
const editDomainRedirects = jest.fn();
|
||||||
|
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ editDomainRedirects });
|
||||||
|
|
||||||
|
it('dispatches error when loading domains fails', async () => {
|
||||||
|
editDomainRedirects.mockRejectedValue(new Error('error'));
|
||||||
|
|
||||||
|
await editDomainRedirectsAction(buildShlinkApiClient)(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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches domain and redirects once loaded', async () => {
|
||||||
|
editDomainRedirects.mockResolvedValue(redirects);
|
||||||
|
|
||||||
|
await editDomainRedirectsAction(buildShlinkApiClient)(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,30 +3,68 @@ import reducer, {
|
||||||
LIST_DOMAINS,
|
LIST_DOMAINS,
|
||||||
LIST_DOMAINS_ERROR,
|
LIST_DOMAINS_ERROR,
|
||||||
LIST_DOMAINS_START,
|
LIST_DOMAINS_START,
|
||||||
ListDomainsAction,
|
FILTER_DOMAINS,
|
||||||
|
DomainsCombinedAction,
|
||||||
|
DomainsList,
|
||||||
listDomains as listDomainsAction,
|
listDomains as listDomainsAction,
|
||||||
|
filterDomains as filterDomainsAction,
|
||||||
|
replaceRedirectsOnDomain,
|
||||||
} from '../../../src/domains/reducers/domainsList';
|
} from '../../../src/domains/reducers/domainsList';
|
||||||
import { ShlinkDomain } from '../../../src/api/types';
|
import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects';
|
||||||
|
import { ShlinkDomain, ShlinkDomainRedirects } from '../../../src/api/types';
|
||||||
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
||||||
|
|
||||||
describe('domainsList', () => {
|
describe('domainsList', () => {
|
||||||
const domains = [ Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>() ];
|
const filteredDomains = [ Mock.of<ShlinkDomain>({ domain: 'foo' }), Mock.of<ShlinkDomain>({ domain: 'boo' }) ];
|
||||||
|
const domains = [ ...filteredDomains, Mock.of<ShlinkDomain>({ domain: 'bar' }) ];
|
||||||
|
|
||||||
describe('reducer', () => {
|
describe('reducer', () => {
|
||||||
const action = (type: string, args: Partial<ListDomainsAction> = {}) => Mock.of<ListDomainsAction>(
|
const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>(
|
||||||
{ type, ...args },
|
{ type, ...args },
|
||||||
);
|
);
|
||||||
|
|
||||||
it('returns loading on LIST_DOMAINS_START', () => {
|
it('returns loading on LIST_DOMAINS_START', () => {
|
||||||
expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual({ domains: [], loading: true, error: false });
|
expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual(
|
||||||
|
{ domains: [], filteredDomains: [], loading: true, error: false },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error on LIST_DOMAINS_ERROR', () => {
|
it('returns error on LIST_DOMAINS_ERROR', () => {
|
||||||
expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual({ domains: [], loading: false, error: true });
|
expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual(
|
||||||
|
{ domains: [], filteredDomains: [], loading: false, error: true },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns domains on LIST_DOMAINS', () => {
|
it('returns domains on LIST_DOMAINS', () => {
|
||||||
expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual({ domains, loading: false, error: false });
|
expect(reducer(undefined, action(LIST_DOMAINS, { 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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ 'foo' ],
|
||||||
|
[ 'bar' ],
|
||||||
|
[ 'does_not_exist' ],
|
||||||
|
])('replaces redirects on proper domain on EDIT_DOMAIN_REDIRECTS', (domain) => {
|
||||||
|
const redirects: ShlinkDomainRedirects = {
|
||||||
|
baseUrlRedirect: 'bar',
|
||||||
|
regular404Redirect: 'foo',
|
||||||
|
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)),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -60,4 +98,14 @@ describe('domainsList', () => {
|
||||||
expect(listDomains).toHaveBeenCalledTimes(1);
|
expect(listDomains).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('filterDomains', () => {
|
||||||
|
it.each([
|
||||||
|
[ 'foo' ],
|
||||||
|
[ 'bar' ],
|
||||||
|
[ 'something' ],
|
||||||
|
])('creates action as expected', (searchTerm) => {
|
||||||
|
expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { ServerForm } from '../../../src/servers/helpers/ServerForm';
|
import { ServerForm } from '../../../src/servers/helpers/ServerForm';
|
||||||
import { FormGroupContainer } from '../../../src/utils/FormGroupContainer';
|
|
||||||
|
|
||||||
describe('<ServerForm />', () => {
|
describe('<ServerForm />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
|
@ -14,7 +13,7 @@ describe('<ServerForm />', () => {
|
||||||
afterEach(jest.resetAllMocks);
|
afterEach(jest.resetAllMocks);
|
||||||
|
|
||||||
it('renders components', () => {
|
it('renders components', () => {
|
||||||
expect(wrapper.find(FormGroupContainer)).toHaveLength(3);
|
expect(wrapper.find('FormGroup')).toHaveLength(3);
|
||||||
expect(wrapper.find('span')).toHaveLength(1);
|
expect(wrapper.find('span')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { ShortUrlFormCheckboxGroup } from '../../../src/short-urls/helpers/ShortUrlFormCheckboxGroup';
|
import { ShortUrlFormCheckboxGroup } from '../../../src/short-urls/helpers/ShortUrlFormCheckboxGroup';
|
||||||
import Checkbox from '../../../src/utils/Checkbox';
|
import Checkbox from '../../../src/utils/Checkbox';
|
||||||
|
import { InfoTooltip } from '../../../src/utils/InfoTooltip';
|
||||||
|
|
||||||
describe('<ShortUrlFormCheckboxGroup />', () => {
|
describe('<ShortUrlFormCheckboxGroup />', () => {
|
||||||
test.each([
|
test.each([
|
||||||
|
@ -11,6 +12,6 @@ describe('<ShortUrlFormCheckboxGroup />', () => {
|
||||||
const checkbox = wrapper.find(Checkbox);
|
const checkbox = wrapper.find(Checkbox);
|
||||||
|
|
||||||
expect(checkbox.prop('className')).toEqual(expectedClassName);
|
expect(checkbox.prop('className')).toEqual(expectedClassName);
|
||||||
expect(wrapper.find('InfoTooltip')).toHaveLength(expectedAmountOfTooltips);
|
expect(wrapper.find(InfoTooltip)).toHaveLength(expectedAmountOfTooltips);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
41
test/utils/InfoTooltip.test.tsx
Normal file
41
test/utils/InfoTooltip.test.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import Popper from 'popper.js';
|
||||||
|
import { InfoTooltip } from '../../src/utils/InfoTooltip';
|
||||||
|
|
||||||
|
describe('<InfoTooltip />', () => {
|
||||||
|
it.each([
|
||||||
|
[ undefined ],
|
||||||
|
[ 'foo' ],
|
||||||
|
[ 'bar' ],
|
||||||
|
])('renders expected className on span', (className) => {
|
||||||
|
const wrapper = shallow(<InfoTooltip placement="right" className={className} />);
|
||||||
|
const span = wrapper.find('span');
|
||||||
|
|
||||||
|
expect(span.prop('className')).toEqual(className ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ <span key={1} /> ],
|
||||||
|
[ 'Foo' ],
|
||||||
|
[ 'Hello' ],
|
||||||
|
[[ 'One', 'Two', <span key={3} /> ]],
|
||||||
|
])('passes children down to the nested tooltip component', (children) => {
|
||||||
|
const wrapper = shallow(<InfoTooltip placement="right">{children}</InfoTooltip>);
|
||||||
|
const tooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
|
||||||
|
expect(tooltip.prop('children')).toEqual(children);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ 'right' as Popper.Placement ],
|
||||||
|
[ 'left' as Popper.Placement ],
|
||||||
|
[ 'top' as Popper.Placement ],
|
||||||
|
[ 'bottom' as Popper.Placement ],
|
||||||
|
])('places tooltip where requested', (placement) => {
|
||||||
|
const wrapper = shallow(<InfoTooltip placement={placement} />);
|
||||||
|
const tooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
|
||||||
|
expect(tooltip.prop('placement')).toEqual(placement);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,4 +1,4 @@
|
||||||
import { determineOrderDir, rangeOf } from '../../src/utils/utils';
|
import { determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils';
|
||||||
|
|
||||||
describe('utils', () => {
|
describe('utils', () => {
|
||||||
describe('determineOrderDir', () => {
|
describe('determineOrderDir', () => {
|
||||||
|
@ -47,4 +47,17 @@ describe('utils', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('nonEmptyValueOrNull', () => {
|
||||||
|
it.each([
|
||||||
|
[ '', null ],
|
||||||
|
[ 'Hello', 'Hello' ],
|
||||||
|
[[], null ],
|
||||||
|
[[ 1, 2, 3 ], [ 1, 2, 3 ]],
|
||||||
|
[{}, null ],
|
||||||
|
[{ foo: 'bar' }, { foo: 'bar' }],
|
||||||
|
])('returns expected value based on input', (value, expected) => {
|
||||||
|
expect(nonEmptyValueOrNull(value)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue