diff --git a/CHANGELOG.md b/CHANGELOG.md index dfab074a..825f3a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### 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 * *Nothing* diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 8903709d..d751a534 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -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 { prettify } from '../utils/helpers/numbers'; 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 { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import { VisitsOverview } from '../visits/reducers/visitsOverview'; @@ -25,7 +25,7 @@ interface OverviewConnectProps { } export const Overview = ( - ShortUrlsTable: FC, + ShortUrlsTable: ShortUrlsTableType, CreateShortUrl: FC, ) => boundToMercureHub(({ shortUrlsList, diff --git a/src/short-urls/Paginator.tsx b/src/short-urls/Paginator.tsx index 45c2fd10..5e7d488e 100644 --- a/src/short-urls/Paginator.tsx +++ b/src/short-urls/Paginator.tsx @@ -21,7 +21,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi `/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`; if (pagesCount <= 1) { - return null; + return
; // Return some space } const renderPages = () => @@ -38,7 +38,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi )); return ( - + diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 1a5ec29d..b49280f7 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -18,7 +18,7 @@ import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import './ShortUrlsFilteringBar.scss'; -export interface ShortUrlsFilteringProps { +interface ShortUrlsFilteringProps { selectedServer: SelectedServer; order: ShortUrlsOrder; handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; @@ -90,3 +90,5 @@ export const ShortUrlsFilteringBar = (
); }; + +export type ShortUrlsFilteringBarType = ReturnType; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 6b3f7a82..c41a4f7d 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -1,5 +1,5 @@ import { pipe } from 'ramda'; -import { FC, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Card } from 'reactstrap'; import { useLocation, useParams } from 'react-router-dom'; import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; @@ -10,11 +10,11 @@ import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { ShlinkShortUrlsListParams } from '../api/types'; import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; -import { ShortUrlsTableProps } from './ShortUrlsTable'; +import { ShortUrlsTableType } from './ShortUrlsTable'; import { Paginator } from './Paginator'; import { useShortUrlsQuery } from './helpers/hooks'; import { ShortUrlsOrderableFields } from './data'; -import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar'; +import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar'; interface ShortUrlsListProps { selectedServer: SelectedServer; @@ -24,8 +24,8 @@ interface ShortUrlsListProps { } export const ShortUrlsList = ( - ShortUrlsTable: FC, - ShortUrlsFilteringBar: FC, + ShortUrlsTable: ShortUrlsTableType, + ShortUrlsFilteringBar: ShortUrlsFilteringBarType, ) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => { const serverId = getServerId(selectedServer); const { page } = useParams(); @@ -70,7 +70,7 @@ export const ShortUrlsList = ( handleOrderBy={handleOrderBy} className="mb-3" /> - + () => void; renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode; shortUrlsList: ShortUrlsListState; @@ -16,7 +16,7 @@ export interface ShortUrlsTableProps { className?: string; } -export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ +export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({ orderByColumn, renderOrderIcon, shortUrlsList, @@ -27,7 +27,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ const { error, loading, shortUrls } = shortUrlsList; const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn }); 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 = () => { if (error) { @@ -81,7 +81,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ Visits {renderOrderIcon?.('visits')} -   + @@ -90,3 +90,5 @@ export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ ); }; + +export type ShortUrlsTableType = ReturnType; diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index 88f5b22b..8c22bdec 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -31,7 +31,9 @@ export interface ShortUrl { shortUrl: string; longUrl: 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>; tags: string[]; domain: string | null; @@ -46,6 +48,12 @@ export interface ShortUrlMeta { maxVisits?: number; } +export interface ShortUrlVisitsSummary { + total: number; + nonBots: number; + bots: number; +} + export interface ShortUrlModalProps { shortUrl: ShortUrl; isOpen: boolean; diff --git a/src/short-urls/helpers/ShortUrlStatus.tsx b/src/short-urls/helpers/ShortUrlStatus.tsx new file mode 100644 index 00000000..7fcc2a6d --- /dev/null +++ b/src/short-urls/helpers/ShortUrlStatus.tsx @@ -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 {maxVisits} visit{maxVisits > 1 ? 's' : ''}. + + ), + }; + } + + if (validUntil && isBefore(parseISO(validUntil), now())) { + return { + icon: faCalendarXmark, + className: 'text-danger', + description: ( + <> + This short URL cannot be visited + since {formatHumanFriendly(parseISO(validUntil))}. + + ), + }; + } + + if (validSince && isBefore(now(), parseISO(validSince))) { + return { + icon: faCalendarXmark, + className: 'text-warning', + description: ( + <> + This short URL will start working + on {formatHumanFriendly(parseISO(validSince))}. + + ), + }; + } + + return { + icon: faCheck, + className: 'text-primary', + description: 'This short URL can be visited normally.', + }; +}; + +export const ShortUrlStatus: FC = ({ shortUrl }) => { + const tooltipRef = useRef(); + const { icon, className, description } = resolveShortUrlStatus(shortUrl); + + return ( + <> + + + + tooltipRef.current) as any} placement="bottom"> + {description} + + + ); +}; diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.scss b/src/short-urls/helpers/ShortUrlVisitsCount.scss index 2910381a..7ae9b852 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.scss +++ b/src/short-urls/helpers/ShortUrlVisitsCount.scss @@ -10,3 +10,7 @@ .short-url-visits-count__amount--big { transform: scale(1.5); } + +.short-url-visits-count__tooltip-list-item:not(:last-child) { + margin-bottom: .5rem; +} diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.tsx b/src/short-urls/helpers/ShortUrlVisitsCount.tsx index 3b76bda4..dfa0acf3 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.tsx +++ b/src/short-urls/helpers/ShortUrlVisitsCount.tsx @@ -7,8 +7,9 @@ import { prettify } from '../../utils/helpers/numbers'; import { ShortUrl } from '../data'; import { SelectedServer } from '../../servers/data'; import { ShortUrlDetailLink } from './ShortUrlDetailLink'; -import './ShortUrlVisitsCount.scss'; import { mutableRefToElementRef } from '../../utils/helpers/components'; +import { formatHumanFriendly, parseISO } from '../../utils/helpers/date'; +import './ShortUrlVisitsCount.scss'; interface ShortUrlVisitsCountProps { shortUrl?: ShortUrl | null; @@ -20,7 +21,8 @@ interface ShortUrlVisitsCountProps { export const ShortUrlVisitsCount = ( { visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps, ) => { - const maxVisits = shortUrl?.meta?.maxVisits; + const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {}; + const hasLimit = !!maxVisits || !!validSince || !!validUntil; const visitsLink = ( ); - if (!maxVisits) { + if (!hasLimit) { return visitsLink; } - const prettifiedMaxVisits = prettify(maxVisits); const tooltipRef = useRef(); return ( <> {visitsLink} - - {' '}/ {prettifiedMaxVisits}{' '} - + + {maxVisits && <> / {prettify(maxVisits)}} + tooltipRef.current) as any} placement="bottom"> - This short URL will not accept more than {prettifiedMaxVisits} visits. +
    + {maxVisits && ( +
  • + This short URL will not accept more than {prettify(maxVisits)} visit{maxVisits === 1 ? '' : 's'}. +
  • + )} + {validSince && ( +
  • + This short URL will not accept visits + before {formatHumanFriendly(parseISO(validSince))}. +
  • + )} + {validUntil && ( +
  • + This short URL will not accept visits + after {formatHumanFriendly(parseISO(validUntil))}. +
  • + )} +
); diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss index 4666be1a..64af7818 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/src/short-urls/helpers/ShortUrlsRow.scss @@ -10,10 +10,6 @@ word-break: break-all; } -.short-urls-row__cell--relative { - position: relative; -} - .short-urls-row__cell--indivisible { @media (min-width: $lgMin) { white-space: nowrap; diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index 1b7fafb1..e962da53 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -1,25 +1,25 @@ -import { FC, useEffect, useRef } from 'react'; -import { isEmpty } from 'ramda'; +import { useEffect, useRef } from 'react'; import { ExternalLink } from 'react-external-link'; import { ColorGenerator } from '../../utils/services/ColorGenerator'; import { TimeoutToggle } from '../../utils/helpers/hooks'; -import { Tag } from '../../tags/helpers/Tag'; import { SelectedServer } from '../../servers/data'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { ShortUrl } from '../data'; import { Time } from '../../utils/dates/Time'; import { ShortUrlVisitsCount } from './ShortUrlVisitsCount'; -import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu'; +import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu'; +import { Tags } from './Tags'; +import { ShortUrlStatus } from './ShortUrlStatus'; import './ShortUrlsRow.scss'; -export interface ShortUrlsRowProps { +interface ShortUrlsRowProps { onTagClick?: (tag: string) => void; selectedServer: SelectedServer; shortUrl: ShortUrl; } export const ShortUrlsRow = ( - ShortUrlsRowMenu: FC, + ShortUrlsRowMenu: ShortUrlsRowMenuType, colorGenerator: ColorGenerator, useTimeoutToggle: TimeoutToggle, ) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => { @@ -27,21 +27,6 @@ export const ShortUrlsRow = ( const [active, setActive] = useTimeoutToggle(false, 500); const isFirstRun = useRef(true); - const renderTags = (tags: string[]) => { - if (isEmpty(tags)) { - return No tags; - } - - return tags.map((tag) => ( - onTagClick?.(tag)} - /> - )); - }; - useEffect(() => { !isFirstRun.current && setActive(); isFirstRun.current = false; @@ -53,7 +38,7 @@ export const ShortUrlsRow = (