Decouple shlink-web-component from the concept of servers

This commit is contained in:
Alejandro Celaya 2023-07-24 18:03:59 +02:00
parent 5f6dc186e3
commit 21525ef945
22 changed files with 55 additions and 82 deletions

View file

@ -3,9 +3,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import type { ShlinkDomainRedirects } from '../../api/types';
import type { SelectedServer } from '../../servers/data';
import type { OptionalString } from '../../utils/utils'; import type { OptionalString } from '../../utils/utils';
import type { ShlinkDomainRedirects } from '../api-contract';
import type { Domain } from './data'; import type { Domain } from './data';
import { DomainDropdown } from './helpers/DomainDropdown'; import { DomainDropdown } from './helpers/DomainDropdown';
import { DomainStatusIcon } from './helpers/DomainStatusIcon'; import { DomainStatusIcon } from './helpers/DomainStatusIcon';
@ -16,7 +15,6 @@ interface DomainRowProps {
defaultRedirects?: ShlinkDomainRedirects; defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void; checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
} }
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => ( const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
@ -33,7 +31,7 @@ const DefaultDomain: FC = () => (
); );
export const DomainRow: FC<DomainRowProps> = ( export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer }, { domain, editDomainRedirects, checkDomainHealth, defaultRedirects },
) => { ) => {
const { domain: authority, isDefault, redirects, status } = domain; const { domain: authority, isDefault, redirects, status } = domain;
@ -58,7 +56,7 @@ export const DomainRow: FC<DomainRowProps> = (
<DomainStatusIcon status={status} /> <DomainStatusIcon status={status} />
</td> </td>
<td className="responsive-table__cell text-end"> <td className="responsive-table__cell text-end">
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} /> <DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} />
</td> </td>
</tr> </tr>
); );

View file

@ -1,7 +1,6 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ShlinkApiError } from '../../api/ShlinkApiError'; import { ShlinkApiError } from '../../api/ShlinkApiError';
import type { SelectedServer } from '../../servers/data';
import { Message } from '../../utils/Message'; import { Message } from '../../utils/Message';
import { Result } from '../../utils/Result'; import { Result } from '../../utils/Result';
import { SearchField } from '../../utils/SearchField'; import { SearchField } from '../../utils/SearchField';
@ -16,13 +15,12 @@ interface ManageDomainsProps {
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void; checkDomainHealth: (domain: string) => void;
domainsList: DomainsList; domainsList: DomainsList;
selectedServer: SelectedServer;
} }
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '']; const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
export const ManageDomains: FC<ManageDomainsProps> = ( export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer }, { listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth },
) => { ) => {
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList; const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects; const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
@ -59,7 +57,6 @@ export const ManageDomains: FC<ManageDomainsProps> = (
editDomainRedirects={editDomainRedirects} editDomainRedirects={editDomainRedirects}
checkDomainHealth={checkDomainHealth} checkDomainHealth={checkDomainHealth}
defaultRedirects={resolvedDefaultRedirects} defaultRedirects={resolvedDefaultRedirects}
selectedServer={selectedServer}
/> />
))} ))}
</tbody> </tbody>

View file

@ -1,4 +1,4 @@
import type { ShlinkDomain } from '../../../api/types'; import type { ShlinkDomain } from '../../api-contract';
export type DomainStatus = 'validating' | 'valid' | 'invalid'; export type DomainStatus = 'validating' | 'valid' | 'invalid';

View file

@ -3,11 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap'; import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../../servers/data';
import { getServerId } from '../../../servers/data';
import { useToggle } from '../../../utils/helpers/hooks'; import { useToggle } from '../../../utils/helpers/hooks';
import { RowDropdownBtn } from '../../../utils/RowDropdownBtn'; import { RowDropdownBtn } from '../../../utils/RowDropdownBtn';
import { useFeature } from '../../utils/features'; import { useFeature } from '../../utils/features';
import { useRoutesPrefix } from '../../utils/routesPrefix';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { Domain } from '../data'; import type { Domain } from '../data';
import type { EditDomainRedirects } from '../reducers/domainRedirects'; import type { EditDomainRedirects } from '../reducers/domainRedirects';
@ -16,22 +15,21 @@ import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
interface DomainDropdownProps { interface DomainDropdownProps {
domain: Domain; domain: Domain;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>; editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
selectedServer: SelectedServer;
} }
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => { export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects }) => {
const [isModalOpen, toggleModal] = useToggle(); const [isModalOpen, toggleModal] = useToggle();
const { isDefault } = domain; const { isDefault } = domain;
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition'); const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition');
const withVisits = useFeature('domainVisits'); const withVisits = useFeature('domainVisits');
const serverId = getServerId(selectedServer); const routesPrefix = useRoutesPrefix();
return ( return (
<RowDropdownBtn> <RowDropdownBtn>
{withVisits && ( {withVisits && (
<DropdownItem <DropdownItem
tag={Link} tag={Link}
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`} to={`${routesPrefix}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
> >
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats <FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem> </DropdownItem>

View file

@ -13,7 +13,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ManageDomains', () => ManageDomains); bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect( bottle.decorator('ManageDomains', connect(
['domainsList', 'selectedServer'], ['domainsList'],
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'], ['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
)); ));

View file

@ -2,12 +2,8 @@ import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Card, CardBody, CardHeader, Row } from 'reactstrap'; import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import type { ShlinkShortUrlsListParams } from '../../api/types';
import type { SelectedServer } from '../../servers/data';
import { getServerId } from '../../servers/data';
import { HighlightCard } from '../../servers/helpers/HighlightCard';
import { VisitsHighlightCard } from '../../servers/helpers/VisitsHighlightCard';
import { prettify } from '../../utils/helpers/numbers'; import { prettify } from '../../utils/helpers/numbers';
import type { ShlinkShortUrlsListParams } from '../api-contract';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
@ -16,15 +12,17 @@ import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable'; import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
import type { TagsList } from '../tags/reducers/tagsList'; import type { TagsList } from '../tags/reducers/tagsList';
import { useFeature } from '../utils/features'; import { useFeature } from '../utils/features';
import { useRoutesPrefix } from '../utils/routesPrefix';
import { useSetting } from '../utils/settings'; import { useSetting } from '../utils/settings';
import type { VisitsOverview } from '../visits/reducers/visitsOverview'; import type { VisitsOverview } from '../visits/reducers/visitsOverview';
import { HighlightCard } from './helpers/HighlightCard';
import { VisitsHighlightCard } from './helpers/VisitsHighlightCard';
interface OverviewConnectProps { interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState; shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShlinkShortUrlsListParams) => void; listShortUrls: (params: ShlinkShortUrlsListParams) => void;
listTags: Function; listTags: Function;
tagsList: TagsList; tagsList: TagsList;
selectedServer: SelectedServer;
visitsOverview: VisitsOverview; visitsOverview: VisitsOverview;
loadVisitsOverview: Function; loadVisitsOverview: Function;
} }
@ -37,14 +35,13 @@ export const Overview = (
listShortUrls, listShortUrls,
listTags, listTags,
tagsList, tagsList,
selectedServer,
loadVisitsOverview, loadVisitsOverview,
visitsOverview, visitsOverview,
}: OverviewConnectProps) => { }: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList; const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList; const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview; const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer); const routesPrefix = useRoutesPrefix();
const linkToNonOrphanVisits = useFeature('nonOrphanVisits'); const linkToNonOrphanVisits = useFeature('nonOrphanVisits');
const navigate = useNavigate(); const navigate = useNavigate();
const visits = useSetting('visits'); const visits = useSetting('visits');
@ -61,7 +58,7 @@ export const Overview = (
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<VisitsHighlightCard <VisitsHighlightCard
title="Visits" title="Visits"
link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined} link={linkToNonOrphanVisits ? `${routesPrefix}/non-orphan-visits` : undefined}
excludeBots={visits?.excludeBots ?? false} excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits} loading={loadingVisits}
visitsSummary={nonOrphanVisits} visitsSummary={nonOrphanVisits}
@ -70,19 +67,19 @@ export const Overview = (
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<VisitsHighlightCard <VisitsHighlightCard
title="Orphan visits" title="Orphan visits"
link={`/server/${serverId}/orphan-visits`} link={`${routesPrefix}/orphan-visits`}
excludeBots={visits?.excludeBots ?? false} excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits} loading={loadingVisits}
visitsSummary={orphanVisits} visitsSummary={orphanVisits}
/> />
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}> <HighlightCard title="Short URLs" link={`${routesPrefix}/list-short-urls/1`}>
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)} {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</HighlightCard> </HighlightCard>
</div> </div>
<div className="col-lg-6 col-xl-3 mb-3"> <div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}> <HighlightCard title="Tags" link={`${routesPrefix}/manage-tags`}>
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)} {loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
</HighlightCard> </HighlightCard>
</div> </div>
@ -92,7 +89,7 @@ export const Overview = (
<CardHeader> <CardHeader>
<span className="d-sm-none">Create a short URL</span> <span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5> <h5 className="d-none d-sm-inline">Create a short URL</h5>
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link> <Link className="float-end" to={`${routesPrefix}/create-short-url`}>Advanced options &raquo;</Link>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<CreateShortUrl basicMode /> <CreateShortUrl basicMode />
@ -102,13 +99,13 @@ export const Overview = (
<CardHeader> <CardHeader>
<span className="d-sm-none">Recently created URLs</span> <span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5> <h5 className="d-none d-sm-inline">Recently created URLs</h5>
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link> <Link className="float-end" to={`${routesPrefix}/list-short-urls/1`}>See all &raquo;</Link>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<ShortUrlsTable <ShortUrlsTable
shortUrlsList={shortUrlsList} shortUrlsList={shortUrlsList}
className="mb-0" className="mb-0"
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)} onTagClick={(tag) => navigate(`${routesPrefix}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/> />
</CardBody> </CardBody>
</Card> </Card>

View file

@ -1,4 +1,4 @@
@import '../../utils/base'; @import '../../../utils/base';
.highlight-card.highlight-card { .highlight-card.highlight-card {
text-align: center; text-align: center;

View file

@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC, PropsWithChildren, ReactNode } from 'react'; import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap'; import { Card, CardText, CardTitle, UncontrolledTooltip } from 'reactstrap';
import { useElementRef } from '../../utils/helpers/hooks'; import { useElementRef } from '../../../utils/helpers/hooks';
import './HighlightCard.scss'; import './HighlightCard.scss';
export type HighlightCardProps = PropsWithChildren<{ export type HighlightCardProps = PropsWithChildren<{

View file

@ -1,6 +1,6 @@
import type { FC } from 'react'; import type { FC } from 'react';
import type { PartialVisitsSummary } from '../../shlink-web-component/visits/reducers/visitsOverview'; import { prettify } from '../../../utils/helpers/numbers';
import { prettify } from '../../utils/helpers/numbers'; import type { PartialVisitsSummary } from '../../visits/reducers/visitsOverview';
import type { HighlightCardProps } from './HighlightCard'; import type { HighlightCardProps } from './HighlightCard';
import { HighlightCard } from './HighlightCard'; import { HighlightCard } from './HighlightCard';

View file

@ -5,7 +5,7 @@ import { Overview } from '../Overview';
export function provideServices(bottle: Bottle, connect: ConnectDecorator) { export function provideServices(bottle: Bottle, connect: ConnectDecorator) {
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl'); bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
bottle.decorator('Overview', connect( bottle.decorator('Overview', connect(
['shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview'], ['shortUrlsList', 'tagsList', 'mercureInfo', 'visitsOverview'],
['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'], ['listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview'],
)); ));
} }

View file

@ -1,8 +1,6 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { ReportExporter } from '../../../common/services/ReportExporter'; import type { ReportExporter } from '../../../common/services/ReportExporter';
import type { SelectedServer } from '../../../servers/data';
import { isServerWithId } from '../../../servers/data';
import { ExportBtn } from '../../../utils/ExportBtn'; import { ExportBtn } from '../../../utils/ExportBtn';
import { useToggle } from '../../../utils/helpers/hooks'; import { useToggle } from '../../../utils/helpers/hooks';
import type { ShlinkApiClient } from '../../api-contract'; import type { ShlinkApiClient } from '../../api-contract';
@ -13,23 +11,15 @@ export interface ExportShortUrlsBtnProps {
amount?: number; amount?: number;
} }
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
selectedServer: SelectedServer;
}
const itemsPerPage = 20; const itemsPerPage = 20;
export const ExportShortUrlsBtn = ( export const ExportShortUrlsBtn = (
apiClient: ShlinkApiClient, apiClient: ShlinkApiClient,
{ exportShortUrls }: ReportExporter, { exportShortUrls }: ReportExporter,
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => { ): FC<ExportShortUrlsBtnProps> => ({ amount = 0 }) => {
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery(); const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
const [loading,, startLoading, stopLoading] = useToggle(); const [loading,, startLoading, stopLoading] = useToggle();
const exportAllUrls = useCallback(async () => { const exportAllUrls = useCallback(async () => {
if (!isServerWithId(selectedServer)) {
return;
}
const totalPages = amount / itemsPerPage; const totalPages = amount / itemsPerPage;
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => { const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
const { data } = await apiClient.listShortUrls( const { data } = await apiClient.listShortUrls(
@ -63,7 +53,7 @@ export const ExportShortUrlsBtn = (
}; };
})); }));
stopLoading(); stopLoading();
}, [selectedServer]); }, []);
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />; return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
}; };

View file

@ -1,11 +1,12 @@
import { isEmpty, pipe } from 'ramda'; import { isEmpty, pipe } from 'ramda';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import type { TagsFilteringMode } from '../../../api/types';
import { orderToString, stringToOrder } from '../../../utils/helpers/ordering'; import { orderToString, stringToOrder } from '../../../utils/helpers/ordering';
import { parseQuery, stringifyQuery } from '../../../utils/helpers/query'; import { parseQuery, stringifyQuery } from '../../../utils/helpers/query';
import type { BooleanString } from '../../../utils/utils'; import type { BooleanString } from '../../../utils/utils';
import { parseOptionalBooleanToString } from '../../../utils/utils'; import { parseOptionalBooleanToString } from '../../../utils/utils';
import type { TagsFilteringMode } from '../../api-contract';
import { useRoutesPrefix } from '../../utils/routesPrefix';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data'; import type { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
interface ShortUrlsQueryCommon { interface ShortUrlsQueryCommon {
@ -36,7 +37,7 @@ type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const navigate = useNavigate(); const navigate = useNavigate();
const { search } = useLocation(); const { search } = useLocation();
const { serverId = '' } = useParams<{ serverId: string }>(); const routesPrefix = useRoutesPrefix();
const filtering = useMemo( const filtering = useMemo(
pipe( pipe(
@ -70,7 +71,7 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const stringifiedQuery = stringifyQuery(query); const stringifiedQuery = stringifyQuery(query);
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`; const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
navigate(`/server/${serverId}/list-short-urls/1${queryString}`); navigate(`${routesPrefix}/list-short-urls/1${queryString}`);
}; };
return [filtering, toFirstPageWithExtra]; return [filtering, toFirstPageWithExtra];

View file

@ -55,7 +55,6 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector'); bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector');
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'apiClient', 'ReportExporter'); bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'apiClient', 'ReportExporter');
bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer']));
// Reducers // Reducers
bottle.serviceFactory( bottle.serviceFactory(

View file

@ -3,7 +3,6 @@ import type { FC } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Row } from 'reactstrap'; import { Row } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError'; import { ShlinkApiError } from '../../api/ShlinkApiError';
import type { SelectedServer } from '../../servers/data';
import { determineOrderDir, sortList } from '../../utils/helpers/ordering'; import { determineOrderDir, sortList } from '../../utils/helpers/ordering';
import { Message } from '../../utils/Message'; import { Message } from '../../utils/Message';
import { OrderingDropdown } from '../../utils/OrderingDropdown'; import { OrderingDropdown } from '../../utils/OrderingDropdown';
@ -22,11 +21,10 @@ export interface TagsListProps {
filterTags: (searchTerm: string) => void; filterTags: (searchTerm: string) => void;
forceListTags: Function; forceListTags: Function;
tagsList: TagsListState; tagsList: TagsListState;
selectedServer: SelectedServer;
} }
export const TagsList = (TagsTable: FC<TagsTableProps>) => boundToMercureHub(( export const TagsList = (TagsTable: FC<TagsTableProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps, { filterTags, forceListTags, tagsList }: TagsListProps,
) => { ) => {
const settings = useSettings(); const settings = useSettings();
const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {}); const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
@ -78,7 +76,6 @@ export const TagsList = (TagsTable: FC<TagsTableProps>) => boundToMercureHub((
return ( return (
<TagsTable <TagsTable
sortedTags={sortedTags} sortedTags={sortedTags}
selectedServer={selectedServer}
currentOrder={order} currentOrder={order}
orderByColumn={orderByColumn} orderByColumn={orderByColumn}
/> />

View file

@ -19,7 +19,7 @@ export interface TagsTableProps extends TagsListChildrenProps {
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => ( export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
{ sortedTags, selectedServer, orderByColumn, currentOrder }: TagsTableProps, { sortedTags, orderByColumn, currentOrder }: TagsTableProps,
) => { ) => {
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
const { search } = useLocation(); const { search } = useLocation();
@ -57,7 +57,7 @@ export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
</thead> </thead>
<tbody> <tbody>
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>} {currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
{currentPage.map((tag) => <TagsTableRow key={tag.tag} tag={tag} selectedServer={selectedServer} />)} {currentPage.map((tag) => <TagsTableRow key={tag.tag} tag={tag} />)}
</tbody> </tbody>
</table> </table>

View file

@ -3,28 +3,26 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap'; import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../servers/data';
import { getServerId } from '../../servers/data';
import { useToggle } from '../../utils/helpers/hooks'; import { useToggle } from '../../utils/helpers/hooks';
import { prettify } from '../../utils/helpers/numbers'; import { prettify } from '../../utils/helpers/numbers';
import { RowDropdownBtn } from '../../utils/RowDropdownBtn'; import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
import type { ColorGenerator } from '../../utils/services/ColorGenerator'; import type { ColorGenerator } from '../../utils/services/ColorGenerator';
import { useRoutesPrefix } from '../utils/routesPrefix';
import type { SimplifiedTag, TagModalProps } from './data'; import type { SimplifiedTag, TagModalProps } from './data';
import { TagBullet } from './helpers/TagBullet'; import { TagBullet } from './helpers/TagBullet';
export interface TagsTableRowProps { export interface TagsTableRowProps {
tag: SimplifiedTag; tag: SimplifiedTag;
selectedServer: SelectedServer;
} }
export const TagsTableRow = ( export const TagsTableRow = (
DeleteTagConfirmModal: FC<TagModalProps>, DeleteTagConfirmModal: FC<TagModalProps>,
EditTagModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator, colorGenerator: ColorGenerator,
) => ({ tag, selectedServer }: TagsTableRowProps) => { ) => ({ tag }: TagsTableRowProps) => {
const [isDeleteModalOpen, toggleDelete] = useToggle(); const [isDeleteModalOpen, toggleDelete] = useToggle();
const [isEditModalOpen, toggleEdit] = useToggle(); const [isEditModalOpen, toggleEdit] = useToggle();
const serverId = getServerId(selectedServer); const routesPrefix = useRoutesPrefix();
return ( return (
<tr className="responsive-table__row"> <tr className="responsive-table__row">
@ -32,12 +30,12 @@ export const TagsTableRow = (
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag} <TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
</th> </th>
<td className="responsive-table__cell text-lg-end" data-th="Short URLs"> <td className="responsive-table__cell text-lg-end" data-th="Short URLs">
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}> <Link to={`${routesPrefix}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
{prettify(tag.shortUrls)} {prettify(tag.shortUrls)}
</Link> </Link>
</td> </td>
<td className="responsive-table__cell text-lg-end" data-th="Visits"> <td className="responsive-table__cell text-lg-end" data-th="Visits">
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}> <Link to={`${routesPrefix}/tag/${tag.tag}/visits`}>
{prettify(tag.visits)} {prettify(tag.visits)}
</Link> </Link>
</td> </td>

View file

@ -1,4 +1,3 @@
import type { SelectedServer } from '../../../servers/data';
import type { Order } from '../../../utils/helpers/ordering'; import type { Order } from '../../../utils/helpers/ordering';
import type { SimplifiedTag } from './index'; import type { SimplifiedTag } from './index';
@ -14,5 +13,4 @@ export type TagsOrder = Order<TagsOrderableFields>;
export interface TagsListChildrenProps { export interface TagsListChildrenProps {
sortedTags: SimplifiedTag[]; sortedTags: SimplifiedTag[];
selectedServer: SelectedServer;
} }

View file

@ -1,11 +1,9 @@
import { createAction, createSlice } from '@reduxjs/toolkit'; import { createAction, createSlice } from '@reduxjs/toolkit';
import { isEmpty, reject } from 'ramda'; import { isEmpty, reject } from 'ramda';
import { isReachableServer } from '../../../servers/data';
import { createAsyncThunk } from '../../../utils/helpers/redux'; import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { ProblemDetailsError, ShlinkApiClient, ShlinkTags } from '../../api-contract'; import type { ProblemDetailsError, ShlinkApiClient, ShlinkTags } from '../../api-contract';
import { parseApiError } from '../../api-contract/utils'; import { parseApiError } from '../../api-contract/utils';
import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation'; import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation';
import { isFeatureEnabledForVersion } from '../../utils/features';
import { createNewVisits } from '../../visits/reducers/visitCreation'; import { createNewVisits } from '../../visits/reducers/visitCreation';
import type { CreateVisit } from '../../visits/types'; import type { CreateVisit } from '../../visits/types';
import type { TagStats } from '../data'; import type { TagStats } from '../data';
@ -85,17 +83,13 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O
export const listTags = (apiClient: ShlinkApiClient, force = true) => createAsyncThunk( export const listTags = (apiClient: ShlinkApiClient, force = true) => createAsyncThunk(
`${REDUCER_PREFIX}/listTags`, `${REDUCER_PREFIX}/listTags`,
async (_: void, { getState }): Promise<ListTags> => { async (_: void, { getState }): Promise<ListTags> => {
const { tagsList, selectedServer } = getState(); const { tagsList } = getState();
if (!force && !isEmpty(tagsList.tags)) { if (!force && !isEmpty(tagsList.tags)) {
return tagsList; return tagsList;
} }
const { tags, stats }: ShlinkTags = await ( const { tags, stats }: ShlinkTags = await apiClient.tagsStats();
isReachableServer(selectedServer) && isFeatureEnabledForVersion('tagsStats', selectedServer.version)
? apiClient.tagsStats()
: apiClient.listTags()
);
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, ...rest }) => { const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, ...rest }) => {
acc[tag] = rest; acc[tag] = rest;
return acc; return acc;

View file

@ -28,7 +28,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('TagsList', TagsList, 'TagsTable'); bottle.serviceFactory('TagsList', TagsList, 'TagsTable');
bottle.decorator('TagsList', connect( bottle.decorator('TagsList', connect(
['tagsList', 'selectedServer', 'mercureInfo'], ['tagsList', 'mercureInfo'],
['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'], ['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'],
)); ));

View file

@ -3,12 +3,15 @@ import type { SemVer } from '../../utils/helpers/version';
import { versionMatch } from '../../utils/helpers/version'; import { versionMatch } from '../../utils/helpers/version';
const supportedFeatures = { const supportedFeatures = {
// Deprecated
forwardQuery: '2.9.0', forwardQuery: '2.9.0',
nonRestCors: '2.9.0', nonRestCors: '2.9.0',
defaultDomainRedirectsEdition: '2.10.0', defaultDomainRedirectsEdition: '2.10.0',
nonOrphanVisits: '3.0.0', nonOrphanVisits: '3.0.0',
allTagsFiltering: '3.0.0', allTagsFiltering: '3.0.0',
tagsStats: '3.0.0', tagsStats: '3.0.0',
// End deprecated
domainVisits: '3.1.0', domainVisits: '3.1.0',
excludeBotsOnShortUrls: '3.4.0', excludeBotsOnShortUrls: '3.4.0',
filterDisabledUrls: '3.4.0', filterDisabledUrls: '3.4.0',
@ -23,12 +26,15 @@ export const isFeatureEnabledForVersion = (feature: Feature, serverVersion: SemV
versionMatch(serverVersion, { minVersion: supportedFeatures[feature] }); versionMatch(serverVersion, { minVersion: supportedFeatures[feature] });
const getFeaturesForVersion = (serverVersion: SemVer): Record<Feature, boolean> => ({ const getFeaturesForVersion = (serverVersion: SemVer): Record<Feature, boolean> => ({
// Deprecated
forwardQuery: isFeatureEnabledForVersion('forwardQuery', serverVersion), forwardQuery: isFeatureEnabledForVersion('forwardQuery', serverVersion),
nonRestCors: isFeatureEnabledForVersion('nonRestCors', serverVersion), nonRestCors: isFeatureEnabledForVersion('nonRestCors', serverVersion),
defaultDomainRedirectsEdition: isFeatureEnabledForVersion('defaultDomainRedirectsEdition', serverVersion), defaultDomainRedirectsEdition: isFeatureEnabledForVersion('defaultDomainRedirectsEdition', serverVersion),
nonOrphanVisits: isFeatureEnabledForVersion('nonOrphanVisits', serverVersion), nonOrphanVisits: isFeatureEnabledForVersion('nonOrphanVisits', serverVersion),
allTagsFiltering: isFeatureEnabledForVersion('allTagsFiltering', serverVersion), allTagsFiltering: isFeatureEnabledForVersion('allTagsFiltering', serverVersion),
tagsStats: isFeatureEnabledForVersion('tagsStats', serverVersion), tagsStats: isFeatureEnabledForVersion('tagsStats', serverVersion),
// End
domainVisits: isFeatureEnabledForVersion('domainVisits', serverVersion), domainVisits: isFeatureEnabledForVersion('domainVisits', serverVersion),
excludeBotsOnShortUrls: isFeatureEnabledForVersion('excludeBotsOnShortUrls', serverVersion), excludeBotsOnShortUrls: isFeatureEnabledForVersion('excludeBotsOnShortUrls', serverVersion),
filterDisabledUrls: isFeatureEnabledForVersion('filterDisabledUrls', serverVersion), filterDisabledUrls: isFeatureEnabledForVersion('filterDisabledUrls', serverVersion),

View file

@ -1,8 +1,8 @@
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { HighlightCardProps } from '../../../src/servers/helpers/HighlightCard'; import type { HighlightCardProps } from '../../../src/shlink-web-component/overview/helpers/HighlightCard';
import { HighlightCard } from '../../../src/servers/helpers/HighlightCard'; import { HighlightCard } from '../../../src/shlink-web-component/overview/helpers/HighlightCard';
import { renderWithEvents } from '../../__helpers__/setUpTest'; import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<HighlightCard />', () => { describe('<HighlightCard />', () => {

View file

@ -1,6 +1,6 @@
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import type { VisitsHighlightCardProps } from '../../../src/servers/helpers/VisitsHighlightCard'; import type { VisitsHighlightCardProps } from '../../../src/shlink-web-component/overview/helpers/VisitsHighlightCard';
import { VisitsHighlightCard } from '../../../src/servers/helpers/VisitsHighlightCard'; import { VisitsHighlightCard } from '../../../src/shlink-web-component/overview/helpers/VisitsHighlightCard';
import { renderWithEvents } from '../../__helpers__/setUpTest'; import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<VisitsHighlightCard />', () => { describe('<VisitsHighlightCard />', () => {