mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #771 from acelaya-forks/feature/highlight-disabled
Feature/highlight disabled
This commit is contained in:
commit
662573d940
24 changed files with 509 additions and 83 deletions
|
@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { TagsList } from '../tags/reducers/tagsList';
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
import { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
|
@ -25,7 +25,7 @@ interface OverviewConnectProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Overview = (
|
export const Overview = (
|
||||||
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
ShortUrlsTable: ShortUrlsTableType,
|
||||||
CreateShortUrl: FC<CreateShortUrlProps>,
|
CreateShortUrl: FC<CreateShortUrlProps>,
|
||||||
) => boundToMercureHub(({
|
) => boundToMercureHub(({
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
|
|
|
@ -21,7 +21,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi
|
||||||
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||||
|
|
||||||
if (pagesCount <= 1) {
|
if (pagesCount <= 1) {
|
||||||
return null;
|
return <div className="pb-3" />; // Return some space
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPages = () =>
|
const renderPages = () =>
|
||||||
|
@ -38,7 +38,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi
|
||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
<Pagination className="sticky-card-paginator py-3" listClassName="flex-wrap justify-content-center mb-0">
|
||||||
<PaginationItem disabled={currentPage === 1}>
|
<PaginationItem disabled={currentPage === 1}>
|
||||||
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
|
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
|
||||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
import './ShortUrlsFilteringBar.scss';
|
import './ShortUrlsFilteringBar.scss';
|
||||||
|
|
||||||
export interface ShortUrlsFilteringProps {
|
interface ShortUrlsFilteringProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
order: ShortUrlsOrder;
|
order: ShortUrlsOrder;
|
||||||
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
|
||||||
|
@ -90,3 +90,5 @@ export const ShortUrlsFilteringBar = (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShortUrlsFilteringBarType = ReturnType<typeof ShortUrlsFilteringBar>;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
|
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
|
||||||
|
@ -10,11 +10,11 @@ import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
|
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
import { ShortUrlsTableType } from './ShortUrlsTable';
|
||||||
import { Paginator } from './Paginator';
|
import { Paginator } from './Paginator';
|
||||||
import { useShortUrlsQuery } from './helpers/hooks';
|
import { useShortUrlsQuery } from './helpers/hooks';
|
||||||
import { ShortUrlsOrderableFields } from './data';
|
import { ShortUrlsOrderableFields } from './data';
|
||||||
import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar';
|
import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
|
||||||
|
|
||||||
interface ShortUrlsListProps {
|
interface ShortUrlsListProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
@ -24,8 +24,8 @@ interface ShortUrlsListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShortUrlsList = (
|
export const ShortUrlsList = (
|
||||||
ShortUrlsTable: FC<ShortUrlsTableProps>,
|
ShortUrlsTable: ShortUrlsTableType,
|
||||||
ShortUrlsFilteringBar: FC<ShortUrlsFilteringProps>,
|
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
|
||||||
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
|
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
|
||||||
const serverId = getServerId(selectedServer);
|
const serverId = getServerId(selectedServer);
|
||||||
const { page } = useParams();
|
const { page } = useParams();
|
||||||
|
@ -70,7 +70,7 @@ export const ShortUrlsList = (
|
||||||
handleOrderBy={handleOrderBy}
|
handleOrderBy={handleOrderBy}
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
/>
|
/>
|
||||||
<Card body className="pb-1">
|
<Card body className="pb-0">
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
.short-urls-table.short-urls-table {
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
.short-urls-table__header-cell--with-action {
|
.short-urls-table__header-cell--with-action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { FC, ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { isEmpty } from 'ramda';
|
import { isEmpty } from 'ramda';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
import { ShortUrlsRowType } from './helpers/ShortUrlsRow';
|
||||||
import { ShortUrlsOrderableFields } from './data';
|
import { ShortUrlsOrderableFields } from './data';
|
||||||
import './ShortUrlsTable.scss';
|
import './ShortUrlsTable.scss';
|
||||||
|
|
||||||
export interface ShortUrlsTableProps {
|
interface ShortUrlsTableProps {
|
||||||
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
||||||
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
|
@ -16,7 +16,7 @@ export interface ShortUrlsTableProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
|
||||||
orderByColumn,
|
orderByColumn,
|
||||||
renderOrderIcon,
|
renderOrderIcon,
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
|
@ -27,7 +27,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
const { error, loading, shortUrls } = shortUrlsList;
|
const { error, loading, shortUrls } = shortUrlsList;
|
||||||
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
|
const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn });
|
||||||
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
|
const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses);
|
||||||
const tableClasses = classNames('table table-hover responsive-table', className);
|
const tableClasses = classNames('table table-hover responsive-table short-urls-table', className);
|
||||||
|
|
||||||
const renderShortUrls = () => {
|
const renderShortUrls = () => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -81,7 +81,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||||
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th className="short-urls-table__header-cell"> </th>
|
<th className="short-urls-table__header-cell" colSpan={2} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -90,3 +90,5 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShortUrlsTableType = ReturnType<typeof ShortUrlsTable>;
|
||||||
|
|
|
@ -31,7 +31,9 @@ export interface ShortUrl {
|
||||||
shortUrl: string;
|
shortUrl: string;
|
||||||
longUrl: string;
|
longUrl: string;
|
||||||
dateCreated: string;
|
dateCreated: string;
|
||||||
visitsCount: number;
|
/** @deprecated */
|
||||||
|
visitsCount: number; // Deprecated since Shlink 3.4.0
|
||||||
|
visitsSummary?: ShortUrlVisitsSummary; // Optional only before Shlink 3.4.0
|
||||||
meta: Required<Nullable<ShortUrlMeta>>;
|
meta: Required<Nullable<ShortUrlMeta>>;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
domain: string | null;
|
domain: string | null;
|
||||||
|
@ -46,6 +48,12 @@ export interface ShortUrlMeta {
|
||||||
maxVisits?: number;
|
maxVisits?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlVisitsSummary {
|
||||||
|
total: number;
|
||||||
|
nonBots: number;
|
||||||
|
bots: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShortUrlModalProps {
|
export interface ShortUrlModalProps {
|
||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
86
src/short-urls/helpers/ShortUrlStatus.tsx
Normal file
86
src/short-urls/helpers/ShortUrlStatus.tsx
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { FC, ReactNode, useRef } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
||||||
|
import { faLinkSlash, faCalendarXmark, faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { isBefore } from 'date-fns';
|
||||||
|
import { mutableRefToElementRef } from '../../utils/helpers/components';
|
||||||
|
import { ShortUrl } from '../data';
|
||||||
|
import { formatHumanFriendly, now, parseISO } from '../../utils/helpers/date';
|
||||||
|
|
||||||
|
interface ShortUrlStatusProps {
|
||||||
|
shortUrl: ShortUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusResult {
|
||||||
|
icon: IconDefinition;
|
||||||
|
className: string;
|
||||||
|
description: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveShortUrlStatus = (shortUrl: ShortUrl): StatusResult => {
|
||||||
|
const { meta, visitsCount, visitsSummary } = shortUrl;
|
||||||
|
const { maxVisits, validSince, validUntil } = meta;
|
||||||
|
const totalVisits = visitsSummary?.total ?? visitsCount;
|
||||||
|
|
||||||
|
if (maxVisits && totalVisits >= maxVisits) {
|
||||||
|
return {
|
||||||
|
icon: faLinkSlash,
|
||||||
|
className: 'text-danger',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
This short URL cannot be currently visited because it has reached the maximum
|
||||||
|
amount of <b>{maxVisits}</b> visit{maxVisits > 1 ? 's' : ''}.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validUntil && isBefore(parseISO(validUntil), now())) {
|
||||||
|
return {
|
||||||
|
icon: faCalendarXmark,
|
||||||
|
className: 'text-danger',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
This short URL cannot be visited
|
||||||
|
since <b className="indivisible">{formatHumanFriendly(parseISO(validUntil))}</b>.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validSince && isBefore(now(), parseISO(validSince))) {
|
||||||
|
return {
|
||||||
|
icon: faCalendarXmark,
|
||||||
|
className: 'text-warning',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
This short URL will start working
|
||||||
|
on <b className="indivisible">{formatHumanFriendly(parseISO(validSince))}</b>.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: faCheck,
|
||||||
|
className: 'text-primary',
|
||||||
|
description: 'This short URL can be visited normally.',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShortUrlStatus: FC<ShortUrlStatusProps> = ({ shortUrl }) => {
|
||||||
|
const tooltipRef = useRef<HTMLElement | undefined>();
|
||||||
|
const { icon, className, description } = resolveShortUrlStatus(shortUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span style={{ cursor: !description ? undefined : 'help' }} ref={mutableRefToElementRef(tooltipRef)}>
|
||||||
|
<FontAwesomeIcon icon={icon} className={className} />
|
||||||
|
</span>
|
||||||
|
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="bottom">
|
||||||
|
{description}
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -10,3 +10,7 @@
|
||||||
.short-url-visits-count__amount--big {
|
.short-url-visits-count__amount--big {
|
||||||
transform: scale(1.5);
|
transform: scale(1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.short-url-visits-count__tooltip-list-item:not(:last-child) {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
|
@ -7,8 +7,9 @@ import { prettify } from '../../utils/helpers/numbers';
|
||||||
import { ShortUrl } from '../data';
|
import { ShortUrl } from '../data';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
||||||
import './ShortUrlVisitsCount.scss';
|
|
||||||
import { mutableRefToElementRef } from '../../utils/helpers/components';
|
import { mutableRefToElementRef } from '../../utils/helpers/components';
|
||||||
|
import { formatHumanFriendly, parseISO } from '../../utils/helpers/date';
|
||||||
|
import './ShortUrlVisitsCount.scss';
|
||||||
|
|
||||||
interface ShortUrlVisitsCountProps {
|
interface ShortUrlVisitsCountProps {
|
||||||
shortUrl?: ShortUrl | null;
|
shortUrl?: ShortUrl | null;
|
||||||
|
@ -20,7 +21,8 @@ interface ShortUrlVisitsCountProps {
|
||||||
export const ShortUrlVisitsCount = (
|
export const ShortUrlVisitsCount = (
|
||||||
{ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps,
|
{ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps,
|
||||||
) => {
|
) => {
|
||||||
const maxVisits = shortUrl?.meta?.maxVisits;
|
const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {};
|
||||||
|
const hasLimit = !!maxVisits || !!validSince || !!validUntil;
|
||||||
const visitsLink = (
|
const visitsLink = (
|
||||||
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||||
<strong
|
<strong
|
||||||
|
@ -31,29 +33,43 @@ export const ShortUrlVisitsCount = (
|
||||||
</ShortUrlDetailLink>
|
</ShortUrlDetailLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!maxVisits) {
|
if (!hasLimit) {
|
||||||
return visitsLink;
|
return visitsLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prettifiedMaxVisits = prettify(maxVisits);
|
|
||||||
const tooltipRef = useRef<HTMLElement | undefined>();
|
const tooltipRef = useRef<HTMLElement | undefined>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="indivisible">
|
<span className="indivisible">
|
||||||
{visitsLink}
|
{visitsLink}
|
||||||
<small
|
<small className="short-urls-visits-count__max-visits-control" ref={mutableRefToElementRef(tooltipRef)}>
|
||||||
className="short-urls-visits-count__max-visits-control"
|
{maxVisits && <> / {prettify(maxVisits)}</>}
|
||||||
ref={mutableRefToElementRef(tooltipRef)}
|
<sup className="ms-1">
|
||||||
>
|
|
||||||
{' '}/ {prettifiedMaxVisits}{' '}
|
|
||||||
<sup>
|
|
||||||
<FontAwesomeIcon icon={infoIcon} />
|
<FontAwesomeIcon icon={infoIcon} />
|
||||||
</sup>
|
</sup>
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="bottom">
|
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="bottom">
|
||||||
This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
|
<ul className="list-unstyled mb-0">
|
||||||
|
{maxVisits && (
|
||||||
|
<li className="short-url-visits-count__tooltip-list-item">
|
||||||
|
This short URL will not accept more than <b>{prettify(maxVisits)}</b> visit{maxVisits === 1 ? '' : 's'}.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{validSince && (
|
||||||
|
<li className="short-url-visits-count__tooltip-list-item">
|
||||||
|
This short URL will not accept visits
|
||||||
|
before <b className="indivisible">{formatHumanFriendly(parseISO(validSince))}</b>.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{validUntil && (
|
||||||
|
<li className="short-url-visits-count__tooltip-list-item">
|
||||||
|
This short URL will not accept visits
|
||||||
|
after <b className="indivisible">{formatHumanFriendly(parseISO(validUntil))}</b>.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -10,10 +10,6 @@
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-row__cell--relative {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.short-urls-row__cell--indivisible {
|
.short-urls-row__cell--indivisible {
|
||||||
@media (min-width: $lgMin) {
|
@media (min-width: $lgMin) {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
import { FC, useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { isEmpty } from 'ramda';
|
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||||
import { TimeoutToggle } from '../../utils/helpers/hooks';
|
import { TimeoutToggle } from '../../utils/helpers/hooks';
|
||||||
import { Tag } from '../../tags/helpers/Tag';
|
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||||
import { ShortUrl } from '../data';
|
import { ShortUrl } from '../data';
|
||||||
import { Time } from '../../utils/dates/Time';
|
import { Time } from '../../utils/dates/Time';
|
||||||
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
|
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
|
||||||
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
|
import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu';
|
||||||
|
import { Tags } from './Tags';
|
||||||
|
import { ShortUrlStatus } from './ShortUrlStatus';
|
||||||
import './ShortUrlsRow.scss';
|
import './ShortUrlsRow.scss';
|
||||||
|
|
||||||
export interface ShortUrlsRowProps {
|
interface ShortUrlsRowProps {
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShortUrlsRow = (
|
export const ShortUrlsRow = (
|
||||||
ShortUrlsRowMenu: FC<ShortUrlsRowMenuProps>,
|
ShortUrlsRowMenu: ShortUrlsRowMenuType,
|
||||||
colorGenerator: ColorGenerator,
|
colorGenerator: ColorGenerator,
|
||||||
useTimeoutToggle: TimeoutToggle,
|
useTimeoutToggle: TimeoutToggle,
|
||||||
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
|
) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => {
|
||||||
|
@ -27,21 +27,6 @@ export const ShortUrlsRow = (
|
||||||
const [active, setActive] = useTimeoutToggle(false, 500);
|
const [active, setActive] = useTimeoutToggle(false, 500);
|
||||||
const isFirstRun = useRef(true);
|
const isFirstRun = useRef(true);
|
||||||
|
|
||||||
const renderTags = (tags: string[]) => {
|
|
||||||
if (isEmpty(tags)) {
|
|
||||||
return <i className="indivisible"><small>No tags</small></i>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags.map((tag) => (
|
|
||||||
<Tag
|
|
||||||
colorGenerator={colorGenerator}
|
|
||||||
key={tag}
|
|
||||||
text={tag}
|
|
||||||
onClick={() => onTagClick?.(tag)}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
!isFirstRun.current && setActive();
|
!isFirstRun.current && setActive();
|
||||||
isFirstRun.current = false;
|
isFirstRun.current = false;
|
||||||
|
@ -53,7 +38,7 @@ export const ShortUrlsRow = (
|
||||||
<Time date={shortUrl.dateCreated} />
|
<Time date={shortUrl.dateCreated} />
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
||||||
<span className="short-urls-row__cell--relative short-urls-row__cell--indivisible">
|
<span className="position-relative short-urls-row__cell--indivisible">
|
||||||
<span className="short-urls-row__short-url-wrapper">
|
<span className="short-urls-row__short-url-wrapper">
|
||||||
<ExternalLink href={shortUrl.shortUrl} />
|
<ExternalLink href={shortUrl.shortUrl} />
|
||||||
</span>
|
</span>
|
||||||
|
@ -74,18 +59,25 @@ export const ShortUrlsRow = (
|
||||||
<ExternalLink href={shortUrl.longUrl} />
|
<ExternalLink href={shortUrl.longUrl} />
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
|
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">
|
||||||
|
<Tags tags={shortUrl.tags} colorGenerator={colorGenerator} onTagClick={onTagClick} />
|
||||||
|
</td>
|
||||||
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
|
<td className="responsive-table__cell short-urls-row__cell text-lg-end" data-th="Visits">
|
||||||
<ShortUrlVisitsCount
|
<ShortUrlVisitsCount
|
||||||
visitsCount={shortUrl.visitsCount}
|
visitsCount={shortUrl.visitsSummary?.total ?? shortUrl.visitsCount}
|
||||||
shortUrl={shortUrl}
|
shortUrl={shortUrl}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
active={active}
|
active={active}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="responsive-table__cell short-urls-row__cell" data-th="Status">
|
||||||
|
<ShortUrlStatus shortUrl={shortUrl} />
|
||||||
|
</td>
|
||||||
<td className="responsive-table__cell 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShortUrlsRowType = ReturnType<typeof ShortUrlsRow>;
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { SelectedServer } from '../../servers/data';
|
||||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
|
||||||
|
|
||||||
export interface ShortUrlsRowMenuProps {
|
interface ShortUrlsRowMenuProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
|
@ -51,3 +51,5 @@ export const ShortUrlsRowMenu = (
|
||||||
</DropdownBtnMenu>
|
</DropdownBtnMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShortUrlsRowMenuType = ReturnType<typeof ShortUrlsRowMenu>;
|
||||||
|
|
29
src/short-urls/helpers/Tags.tsx
Normal file
29
src/short-urls/helpers/Tags.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { isEmpty } from 'ramda';
|
||||||
|
import { Tag } from '../../tags/helpers/Tag';
|
||||||
|
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||||
|
|
||||||
|
interface TagsProps {
|
||||||
|
tags: string[];
|
||||||
|
onTagClick?: (tag: string) => void;
|
||||||
|
colorGenerator: ColorGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tags: FC<TagsProps> = ({ tags, onTagClick, colorGenerator }) => {
|
||||||
|
if (isEmpty(tags)) {
|
||||||
|
return <i className="indivisible"><small>No tags</small></i>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Tag
|
||||||
|
key={tag}
|
||||||
|
text={tag}
|
||||||
|
colorGenerator={colorGenerator}
|
||||||
|
onClick={() => onTagClick?.(tag)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -30,6 +30,8 @@ export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date,
|
||||||
|
|
||||||
export const formatInternational = formatDate();
|
export const formatInternational = formatDate();
|
||||||
|
|
||||||
|
export const formatHumanFriendly = formatDate(STANDARD_DATE_AND_TIME_FORMAT);
|
||||||
|
|
||||||
export const parseDate = (date: string, theFormat: string) => parse(date, theFormat, now());
|
export const parseDate = (date: string, theFormat: string) => parse(date, theFormat, now());
|
||||||
|
|
||||||
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));
|
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));
|
||||||
|
|
|
@ -15,10 +15,13 @@
|
||||||
.responsive-table__row {
|
.responsive-table__row {
|
||||||
@media (max-width: $responsiveTableBreakpoint) {
|
@media (max-width: $responsiveTableBreakpoint) {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 10px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
border-top: 2px solid var(--border-color);
|
border-top: 2px solid var(--border-color);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,9 +18,11 @@ describe('<Paginator />', () => {
|
||||||
[buildPaginator()],
|
[buildPaginator()],
|
||||||
[buildPaginator(0)],
|
[buildPaginator(0)],
|
||||||
[buildPaginator(1)],
|
[buildPaginator(1)],
|
||||||
])('renders nothing if the number of pages is below 2', (paginator) => {
|
])('renders an empty gap if the number of pages is below 2', (paginator) => {
|
||||||
const { container } = setUp(paginator);
|
const { container } = setUp(paginator);
|
||||||
expect(container.firstChild).toBeNull();
|
|
||||||
|
expect(container.firstChild).toBeEmpty();
|
||||||
|
expect(container.firstChild).toHaveClass('pb-3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { FC } from 'react';
|
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { MemoryRouter, useNavigate } from 'react-router-dom';
|
import { MemoryRouter, useNavigate } from 'react-router-dom';
|
||||||
import { ShortUrlsList as createShortUrlsList } from '../../src/short-urls/ShortUrlsList';
|
import { ShortUrlsList as createShortUrlsList } from '../../src/short-urls/ShortUrlsList';
|
||||||
|
@ -8,7 +7,7 @@ import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
||||||
import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
|
||||||
import { ReachableServer } from '../../src/servers/data';
|
import { ReachableServer } from '../../src/servers/data';
|
||||||
import { Settings } from '../../src/settings/reducers/settings';
|
import { Settings } from '../../src/settings/reducers/settings';
|
||||||
import { ShortUrlsTableProps } from '../../src/short-urls/ShortUrlsTable';
|
import { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
|
@ -18,7 +17,7 @@ jest.mock('react-router-dom', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('<ShortUrlsList />', () => {
|
describe('<ShortUrlsList />', () => {
|
||||||
const ShortUrlsTable: FC<ShortUrlsTableProps> = ({ onTagClick }) => <span onClick={() => onTagClick?.('foo')}>ShortUrlsTable</span>;
|
const ShortUrlsTable: ShortUrlsTableType = ({ onTagClick }) => <span onClick={() => onTagClick?.('foo')}>ShortUrlsTable</span>;
|
||||||
const ShortUrlsFilteringBar = () => <span>ShortUrlsFilteringBar</span>;
|
const ShortUrlsFilteringBar = () => <span>ShortUrlsFilteringBar</span>;
|
||||||
const listShortUrlsMock = jest.fn();
|
const listShortUrlsMock = jest.fn();
|
||||||
const navigate = jest.fn();
|
const navigate = jest.fn();
|
||||||
|
|
48
test/short-urls/helpers/ShortUrlStatus.test.tsx
Normal file
48
test/short-urls/helpers/ShortUrlStatus.test.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus';
|
||||||
|
import { ShortUrl, ShortUrlMeta, ShortUrlVisitsSummary } from '../../../src/short-urls/data';
|
||||||
|
|
||||||
|
describe('<ShortUrlStatus />', () => {
|
||||||
|
const setUp = (shortUrl: ShortUrl) => ({
|
||||||
|
user: userEvent.setup(),
|
||||||
|
...render(<ShortUrlStatus shortUrl={shortUrl} />),
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
Mock.of<ShortUrlMeta>({ validSince: '2099-01-01T10:30:15' }),
|
||||||
|
{},
|
||||||
|
'This short URL will start working on 2099-01-01 10:30.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Mock.of<ShortUrlMeta>({ validUntil: '2020-01-01T10:30:15' }),
|
||||||
|
{},
|
||||||
|
'This short URL cannot be visited since 2020-01-01 10:30.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Mock.of<ShortUrlMeta>({ maxVisits: 10 }),
|
||||||
|
Mock.of<ShortUrlVisitsSummary>({ total: 10 }),
|
||||||
|
'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Mock.of<ShortUrlMeta>({ maxVisits: 1 }),
|
||||||
|
Mock.of<ShortUrlVisitsSummary>({ total: 1 }),
|
||||||
|
'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.',
|
||||||
|
],
|
||||||
|
[{}, {}, 'This short URL can be visited normally.'],
|
||||||
|
[Mock.of<ShortUrlMeta>({ validUntil: '2099-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'],
|
||||||
|
[Mock.of<ShortUrlMeta>({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'],
|
||||||
|
[
|
||||||
|
Mock.of<ShortUrlMeta>({ maxVisits: 10 }),
|
||||||
|
Mock.of<ShortUrlVisitsSummary>({ total: 1 }),
|
||||||
|
'This short URL can be visited normally.',
|
||||||
|
],
|
||||||
|
])('shows expected tooltip', async (meta, visitsSummary, expectedTooltip) => {
|
||||||
|
const { user } = setUp(Mock.of<ShortUrl>({ meta, visitsSummary }));
|
||||||
|
|
||||||
|
await user.hover(screen.getByRole('img', { hidden: true }));
|
||||||
|
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent(expectedTooltip));
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,14 +1,18 @@
|
||||||
import { render } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { ShortUrlVisitsCount } from '../../../src/short-urls/helpers/ShortUrlVisitsCount';
|
import { ShortUrlVisitsCount } from '../../../src/short-urls/helpers/ShortUrlVisitsCount';
|
||||||
import { ShortUrl } from '../../../src/short-urls/data';
|
import { ShortUrl } from '../../../src/short-urls/data';
|
||||||
|
|
||||||
describe('<ShortUrlVisitsCount />', () => {
|
describe('<ShortUrlVisitsCount />', () => {
|
||||||
const setUp = (visitsCount: number, shortUrl: ShortUrl) => render(
|
const setUp = (visitsCount: number, shortUrl: ShortUrl) => ({
|
||||||
<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />,
|
user: userEvent.setup(),
|
||||||
);
|
...render(
|
||||||
|
<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
it.each([undefined, {}])('just returns visits when no maxVisits is provided', (meta) => {
|
it.each([undefined, {}])('just returns visits when no limits are provided', (meta) => {
|
||||||
const visitsCount = 45;
|
const visitsCount = 45;
|
||||||
const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta }));
|
const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta }));
|
||||||
|
|
||||||
|
@ -23,6 +27,30 @@ describe('<ShortUrlVisitsCount />', () => {
|
||||||
const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta }));
|
const { container } = setUp(visitsCount, Mock.of<ShortUrl>({ meta }));
|
||||||
|
|
||||||
expect(container.firstChild).toHaveTextContent(`/ ${maxVisits}`);
|
expect(container.firstChild).toHaveTextContent(`/ ${maxVisits}`);
|
||||||
expect(container.querySelector('.short-urls-visits-count__max-visits-control')).toBeInTheDocument();
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[['This short URL will not accept more than 50 visits'], { maxVisits: 50 }],
|
||||||
|
[['This short URL will not accept more than 1 visit'], { maxVisits: 1 }],
|
||||||
|
[['This short URL will not accept visits before 2022-01-01 10:00'], { validSince: '2022-01-01T10:00:00' }],
|
||||||
|
[['This short URL will not accept visits after 2022-05-05 15:30'], { validUntil: '2022-05-05T15:30:30' }],
|
||||||
|
[[
|
||||||
|
'This short URL will not accept more than 100 visits',
|
||||||
|
'This short URL will not accept visits after 2022-05-05 15:30',
|
||||||
|
], { validUntil: '2022-05-05T15:30:30', maxVisits: 100 }],
|
||||||
|
[[
|
||||||
|
'This short URL will not accept more than 100 visits',
|
||||||
|
'This short URL will not accept visits before 2023-01-01 10:00',
|
||||||
|
'This short URL will not accept visits after 2023-05-05 15:30',
|
||||||
|
], { validSince: '2023-01-01T10:00:00', validUntil: '2023-05-05T15:30:30', maxVisits: 100 }],
|
||||||
|
])('displays proper amount of tooltip list items', async (expectedListItems, meta) => {
|
||||||
|
const { user } = setUp(100, Mock.of<ShortUrl>({ meta }));
|
||||||
|
|
||||||
|
await user.hover(screen.getByRole('img', { hidden: true }));
|
||||||
|
await waitFor(() => expect(screen.getByRole('list')));
|
||||||
|
|
||||||
|
const items = screen.getAllByRole('listitem');
|
||||||
|
expect(items).toHaveLength(expectedListItems.length);
|
||||||
|
expectedListItems.forEach((text, index) => expect(items[index]).toHaveTextContent(text));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
|
import { last } from 'ramda';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { formatISO } from 'date-fns';
|
import { addDays, formatISO, subDays } from 'date-fns';
|
||||||
import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow';
|
import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow';
|
||||||
import { TimeoutToggle } from '../../../src/utils/helpers/hooks';
|
import { TimeoutToggle } from '../../../src/utils/helpers/hooks';
|
||||||
import { ShortUrl } from '../../../src/short-urls/data';
|
import { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data';
|
||||||
import { ReachableServer } from '../../../src/servers/data';
|
import { ReachableServer } from '../../../src/servers/data';
|
||||||
import { parseDate } from '../../../src/utils/helpers/date';
|
import { parseDate, now } from '../../../src/utils/helpers/date';
|
||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
import { OptionalString } from '../../../src/utils/utils';
|
import { OptionalString } from '../../../src/utils/utils';
|
||||||
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
|
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
|
||||||
|
@ -21,6 +22,11 @@ describe('<ShortUrlsRow />', () => {
|
||||||
dateCreated: formatISO(parseDate('2018-05-23 18:30:41', 'yyyy-MM-dd HH:mm:ss')),
|
dateCreated: formatISO(parseDate('2018-05-23 18:30:41', 'yyyy-MM-dd HH:mm:ss')),
|
||||||
tags: [],
|
tags: [],
|
||||||
visitsCount: 45,
|
visitsCount: 45,
|
||||||
|
visitsSummary: {
|
||||||
|
total: 45,
|
||||||
|
nonBots: 40,
|
||||||
|
bots: 5,
|
||||||
|
},
|
||||||
domain: null,
|
domain: null,
|
||||||
meta: {
|
meta: {
|
||||||
validSince: null,
|
validSince: null,
|
||||||
|
@ -29,20 +35,26 @@ describe('<ShortUrlsRow />', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle);
|
const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle);
|
||||||
const setUp = (title?: OptionalString, tags: string[] = []) => renderWithEvents(
|
const setUp = (
|
||||||
|
{ title, tags = [], meta = {} }: { title?: OptionalString; tags?: string[]; meta?: ShortUrlMeta } = {},
|
||||||
|
) => renderWithEvents(
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<ShortUrlsRow selectedServer={server} shortUrl={{ ...shortUrl, title, tags }} onTagClick={() => null} />
|
<ShortUrlsRow
|
||||||
|
selectedServer={server}
|
||||||
|
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
|
||||||
|
onTagClick={() => null}
|
||||||
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>,
|
</table>,
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[null, 6],
|
[null, 7],
|
||||||
[undefined, 6],
|
[undefined, 7],
|
||||||
['The title', 7],
|
['The title', 8],
|
||||||
])('renders expected amount of columns', (title, expectedAmount) => {
|
])('renders expected amount of columns', (title, expectedAmount) => {
|
||||||
setUp(title);
|
setUp({ title });
|
||||||
expect(screen.getAllByRole('cell')).toHaveLength(expectedAmount);
|
expect(screen.getAllByRole('cell')).toHaveLength(expectedAmount);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -67,7 +79,7 @@ describe('<ShortUrlsRow />', () => {
|
||||||
['My super cool title', 'My super cool title'],
|
['My super cool title', 'My super cool title'],
|
||||||
[undefined, shortUrl.longUrl],
|
[undefined, shortUrl.longUrl],
|
||||||
])('renders title when short URL has it', (title, expectedContent) => {
|
])('renders title when short URL has it', (title, expectedContent) => {
|
||||||
setUp(title);
|
setUp({ title });
|
||||||
|
|
||||||
const titleSharedCol = screen.getAllByRole('cell')[2];
|
const titleSharedCol = screen.getAllByRole('cell')[2];
|
||||||
|
|
||||||
|
@ -79,7 +91,7 @@ describe('<ShortUrlsRow />', () => {
|
||||||
[[], ['No tags']],
|
[[], ['No tags']],
|
||||||
[['nodejs', 'reactjs'], ['nodejs', 'reactjs']],
|
[['nodejs', 'reactjs'], ['nodejs', 'reactjs']],
|
||||||
])('renders list of tags in fourth row', (tags, expectedContents) => {
|
])('renders list of tags in fourth row', (tags, expectedContents) => {
|
||||||
setUp(undefined, tags);
|
setUp({ tags });
|
||||||
const cell = screen.getAllByRole('cell')[3];
|
const cell = screen.getAllByRole('cell')[3];
|
||||||
|
|
||||||
expectedContents.forEach((content) => expect(cell).toHaveTextContent(content));
|
expectedContents.forEach((content) => expect(cell).toHaveTextContent(content));
|
||||||
|
@ -94,7 +106,31 @@ describe('<ShortUrlsRow />', () => {
|
||||||
const { user } = setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
expect(timeoutToggle).not.toHaveBeenCalled();
|
expect(timeoutToggle).not.toHaveBeenCalled();
|
||||||
await user.click(screen.getByRole('img', { hidden: true }));
|
await user.click(screen.getAllByRole('img', { hidden: true })[0]);
|
||||||
expect(timeoutToggle).toHaveBeenCalledTimes(1);
|
expect(timeoutToggle).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ validUntil: formatISO(subDays(now(), 1)) }, ['fa-calendar-xmark', 'text-danger']],
|
||||||
|
[{ validSince: formatISO(addDays(now(), 1)) }, ['fa-calendar-xmark', 'text-warning']],
|
||||||
|
[{ maxVisits: 45 }, ['fa-link-slash', 'text-danger']],
|
||||||
|
[{ maxVisits: 45, validSince: formatISO(addDays(now(), 1)) }, ['fa-link-slash', 'text-danger']],
|
||||||
|
[
|
||||||
|
{ validSince: formatISO(addDays(now(), 1)), validUntil: formatISO(subDays(now(), 1)) },
|
||||||
|
['fa-calendar-xmark', 'text-danger'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ validSince: formatISO(subDays(now(), 1)), validUntil: formatISO(addDays(now(), 1)) },
|
||||||
|
['fa-check', 'text-primary'],
|
||||||
|
],
|
||||||
|
[{ maxVisits: 500 }, ['fa-check', 'text-primary']],
|
||||||
|
[{}, ['fa-check', 'text-primary']],
|
||||||
|
])('displays expected status icon', (meta, expectedIconClasses) => {
|
||||||
|
setUp({ meta });
|
||||||
|
const statusIcon = last(screen.getAllByRole('img', { hidden: true }));
|
||||||
|
|
||||||
|
expect(statusIcon).toBeInTheDocument();
|
||||||
|
expectedIconClasses.forEach((expectedClass) => expect(statusIcon).toHaveClass(expectedClass));
|
||||||
|
expect(statusIcon).toMatchSnapshot();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
22
test/short-urls/helpers/Tags.test.tsx
Normal file
22
test/short-urls/helpers/Tags.test.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Tags } from '../../../src/short-urls/helpers/Tags';
|
||||||
|
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
|
||||||
|
|
||||||
|
describe('<Tags />', () => {
|
||||||
|
const setUp = (tags: string[]) => render(<Tags tags={tags} colorGenerator={colorGeneratorMock} />);
|
||||||
|
|
||||||
|
it('returns no tags when the list is empty', () => {
|
||||||
|
setUp([]);
|
||||||
|
expect(screen.getByText('No tags')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[['foo', 'bar', 'baz']],
|
||||||
|
[['one', 'two', 'three', 'four', 'five']],
|
||||||
|
])('returns expected tags based on provided list', (tags) => {
|
||||||
|
setUp(tags);
|
||||||
|
|
||||||
|
expect(screen.queryByText('No tags')).not.toBeInTheDocument();
|
||||||
|
tags.forEach((tag) => expect(screen.getByText(tag)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
145
test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap
Normal file
145
test/short-urls/helpers/__snapshots__/ShortUrlsRow.test.tsx.snap
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`<ShortUrlsRow /> displays expected status icon 1`] = `
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-calendar-xmark text-danger"
|
||||||
|
data-icon="calendar-xmark"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 448 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<ShortUrlsRow /> displays expected status icon 2`] = `
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-calendar-xmark text-warning"
|
||||||
|
data-icon="calendar-xmark"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 448 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<ShortUrlsRow /> displays expected status icon 3`] = `
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-link-slash text-danger"
|
||||||
|
data-icon="link-slash"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 640 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L489.3 358.2l90.5-90.5c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114l-96 96-31.9-25C430.9 239.6 420.1 175.1 377 132c-52.2-52.3-134.5-56.2-191.3-11.7L38.8 5.1zM239 162c30.1-14.9 67.7-9.9 92.8 15.3c20 20 27.5 48.3 21.7 74.5L239 162zM406.6 416.4L220.9 270c-2.1 39.8 12.2 80.1 42.2 110c38.9 38.9 94.4 51 143.6 36.3zm-290-228.5L60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l61.8-61.8-50.6-39.9z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<ShortUrlsRow /> displays expected status icon 4`] = `
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-link-slash text-danger"
|
||||||
|
data-icon="link-slash"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 640 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L489.3 358.2l90.5-90.5c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114l-96 96-31.9-25C430.9 239.6 420.1 175.1 377 132c-52.2-52.3-134.5-56.2-191.3-11.7L38.8 5.1zM239 162c30.1-14.9 67.7-9.9 92.8 15.3c20 20 27.5 48.3 21.7 74.5L239 162zM406.6 416.4L220.9 270c-2.1 39.8 12.2 80.1 42.2 110c38.9 38.9 94.4 51 143.6 36.3zm-290-228.5L60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l61.8-61.8-50.6-39.9z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<ShortUrlsRow /> displays expected status icon 5`] = `
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-calendar-xmark text-danger"
|
||||||
|
data-icon="calendar-xmark"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 448 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<ShortUrlsRow /> displays expected status icon 6`] = `
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-check text-primary"
|
||||||
|
data-icon="check"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<ShortUrlsRow /> displays expected status icon 7`] = `
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-check text-primary"
|
||||||
|
data-icon="check"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<ShortUrlsRow /> displays expected status icon 8`] = `
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-check text-primary"
|
||||||
|
data-icon="check"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`;
|
Loading…
Reference in a new issue