Added extra info and new label to highlight disabled short URLs

This commit is contained in:
Alejandro Celaya 2022-12-18 13:17:49 +01:00
parent 90837546ab
commit 187fee46f4
10 changed files with 117 additions and 39 deletions

View file

@ -0,0 +1,21 @@
import { FC, useRef } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLinkSlash } from '@fortawesome/free-solid-svg-icons';
import { mutableRefToElementRef } from '../../utils/helpers/components';
export const DisabledLabel: FC = () => {
const tooltipRef = useRef<HTMLElement | undefined>();
return (
<>
<span style={{ cursor: 'help' }} ref={mutableRefToElementRef(tooltipRef)} className="badge text-bg-danger">
<FontAwesomeIcon icon={faLinkSlash} className="me-1" />
Disabled
</span>
<UncontrolledTooltip target={(() => tooltipRef.current) as any} placement="left">
This short URL cannot be currently visited because of some of its limits.
</UncontrolledTooltip>
</>
);
};

View file

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

View file

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

View file

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

View file

@ -1,15 +1,16 @@
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 { shortUrlIsDisabled } from './index';
import { DisabledLabel } from './DisabledLabel';
import './ShortUrlsRow.scss'; import './ShortUrlsRow.scss';
interface ShortUrlsRowProps { interface ShortUrlsRowProps {
@ -19,28 +20,14 @@ interface ShortUrlsRowProps {
} }
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) => {
const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle(); const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle();
const [active, setActive] = useTimeoutToggle(false, 500); const [active, setActive] = useTimeoutToggle(false, 500);
const isFirstRun = useRef(true); const isFirstRun = useRef(true);
const isDisabled = shortUrlIsDisabled(shortUrl);
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();
@ -53,7 +40,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>
@ -67,6 +54,11 @@ export const ShortUrlsRow = (
className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break" className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break"
data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`} data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}
> >
{isDisabled && (
<div className="float-end ms-2">
<DisabledLabel />
</div>
)}
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink> <ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
</td> </td>
{shortUrl.title && ( {shortUrl.title && (
@ -74,7 +66,9 @@ 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} disabled={isDisabled} />
</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.visitsCount}

View file

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

View file

@ -0,0 +1,30 @@
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;
disabled?: boolean;
}
export const Tags: FC<TagsProps> = ({ tags, onTagClick, colorGenerator, disabled = false }) => {
if (isEmpty(tags)) {
return disabled ? null : <i className="indivisible"><small>No tags</small></i>;
}
return (
<>
{tags.map((tag) => (
<Tag
key={tag}
text={tag}
colorGenerator={colorGenerator}
onClick={() => onTagClick?.(tag)}
/>
))}
</>
);
};

View file

@ -3,6 +3,8 @@ import { ShortUrl, ShortUrlData } from '../data';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import { ShortUrlCreationSettings } from '../../settings/reducers/settings'; import { ShortUrlCreationSettings } from '../../settings/reducers/settings';
import { isBefore, parseISO } from 'date-fns';
import { now } from '../../utils/helpers/date';
export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => { export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => {
if (isNil(domain)) { if (isNil(domain)) {
@ -20,6 +22,14 @@ export const domainMatches = (shortUrl: ShortUrl, domain: string): boolean => {
return shortUrl.domain === domain; return shortUrl.domain === domain;
}; };
export const shortUrlIsDisabled = (shortUrl: ShortUrl): boolean => (
!!shortUrl.meta.maxVisits && shortUrl.visitsCount >= shortUrl.meta.maxVisits
) || (
!!shortUrl.meta.validSince && isBefore(now(), parseISO(shortUrl.meta.validSince))
) || (
!!shortUrl.meta.validUntil && isBefore(parseISO(shortUrl.meta.validUntil), now())
);
export const shortUrlDataFromShortUrl = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => { export const shortUrlDataFromShortUrl = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => {
const validateUrl = settings?.validateUrls ?? false; const validateUrl = settings?.validateUrls ?? false;

View file

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

View file

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