Move stuff belonging to the component to shlink-web-component

This commit is contained in:
Alejandro Celaya
2023-07-16 08:47:10 +02:00
parent cbbb679dfc
commit d82c0dc75e
204 changed files with 568 additions and 563 deletions

View File

@@ -0,0 +1,65 @@
import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { useEffect } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import type { ShlinkDomainRedirects } from '../../api/types';
import type { SelectedServer } from '../../servers/data';
import type { OptionalString } from '../../utils/utils';
import type { Domain } from './data';
import { DomainDropdown } from './helpers/DomainDropdown';
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
import type { EditDomainRedirects } from './reducers/domainRedirects';
interface DomainRowProps {
domain: Domain;
defaultRedirects?: ShlinkDomainRedirects;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void;
selectedServer: SelectedServer;
}
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
<span className="text-muted">
{!fallback && <small>No redirect</small>}
{fallback && <>{fallback} <small>(as fallback)</small></>}
</span>
);
const DefaultDomain: FC = () => (
<>
<FontAwesomeIcon fixedWidth icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
</>
);
export const DomainRow: FC<DomainRowProps> = (
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
) => {
const { domain: authority, isDefault, redirects, status } = domain;
useEffect(() => {
checkDomainHealth(domain.domain);
}, []);
return (
<tr className="responsive-table__row">
<td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
<td className="responsive-table__cell" data-th="Base path redirect">
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
</td>
<td className="responsive-table__cell" data-th="Regular 404 redirect">
{redirects?.regular404Redirect ?? <Nr fallback={defaultRedirects?.regular404Redirect} />}
</td>
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
</td>
<td className="responsive-table__cell text-lg-center" data-th="Status">
<DomainStatusIcon status={status} />
</td>
<td className="responsive-table__cell text-end">
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
</td>
</tr>
);
};

View File

@@ -0,0 +1,19 @@
@import '../../utils/base';
@import '../../utils/mixins/vertical-align';
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover,
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active {
color: $textPlaceholder !important;
}
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
color: var(--input-text-color) !important;
}
.domains-dropdown__back-btn.domains-dropdown__back-btn,
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
border-color: var(--border-color);
}

View File

@@ -0,0 +1,74 @@
import { faUndo } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe } from 'ramda';
import { useEffect } from 'react';
import type { InputProps } from 'reactstrap';
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip } from 'reactstrap';
import { DropdownBtn } from '../../utils/DropdownBtn';
import { useToggle } from '../../utils/helpers/hooks';
import type { DomainsList } from './reducers/domainsList';
import './DomainSelector.scss';
export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {
value?: string;
onChange: (domain: string) => void;
}
interface DomainSelectorConnectProps extends DomainSelectorProps {
listDomains: Function;
domainsList: DomainsList;
}
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
const [inputDisplayed,, showInput, hideInput] = useToggle();
const { domains } = domainsList;
const valueIsEmpty = isEmpty(value);
const unselectDomain = () => onChange('');
useEffect(() => {
listDomains();
}, []);
return inputDisplayed ? (
<InputGroup>
<Input
value={value ?? ''}
placeholder="Domain"
onChange={(e) => onChange(e.target.value)}
/>
<Button
id="backToDropdown"
outline
type="button"
className="domains-dropdown__back-btn"
aria-label="Back to domains list"
onClick={pipe(unselectDomain, hideInput)}
>
<FontAwesomeIcon icon={faUndo} />
</Button>
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
Existing domains
</UncontrolledTooltip>
</InputGroup>
) : (
<DropdownBtn
text={valueIsEmpty ? 'Domain' : `Domain: ${value}`}
className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : 'domains-dropdown__toggle-btn'}
>
{domains.map(({ domain, isDefault }) => (
<DropdownItem
key={domain}
active={(value === domain || isDefault) && valueIsEmpty}
onClick={() => onChange(domain)}
>
{domain}
{isDefault && <span className="float-end text-muted">default</span>}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
<i>New domain</i>
</DropdownItem>
</DropdownBtn>
);
};

View File

@@ -0,0 +1,77 @@
import type { FC } from 'react';
import { useEffect } from 'react';
import { ShlinkApiError } from '../../api/ShlinkApiError';
import type { SelectedServer } from '../../servers/data';
import { Message } from '../../utils/Message';
import { Result } from '../../utils/Result';
import { SearchField } from '../../utils/SearchField';
import { SimpleCard } from '../../utils/SimpleCard';
import { DomainRow } from './DomainRow';
import type { EditDomainRedirects } from './reducers/domainRedirects';
import type { DomainsList } from './reducers/domainsList';
interface ManageDomainsProps {
listDomains: Function;
filterDomains: (searchTerm: string) => void;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
checkDomainHealth: (domain: string) => void;
domainsList: DomainsList;
selectedServer: SelectedServer;
}
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
export const ManageDomains: FC<ManageDomainsProps> = (
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
) => {
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
useEffect(() => {
listDomains();
}, []);
if (loading) {
return <Message loading />;
}
const renderContent = () => {
if (error) {
return (
<Result type="error">
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
</Result>
);
}
return (
<SimpleCard>
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
</thead>
<tbody>
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
{domains.map((domain) => (
<DomainRow
key={domain.domain}
domain={domain}
editDomainRedirects={editDomainRedirects}
checkDomainHealth={checkDomainHealth}
defaultRedirects={resolvedDefaultRedirects}
selectedServer={selectedServer}
/>
))}
</tbody>
</table>
</SimpleCard>
);
};
return (
<>
<SearchField className="mb-3" onChange={filterDomains} />
{renderContent()}
</>
);
};

View File

@@ -0,0 +1,7 @@
import type { ShlinkDomain } from '../../../api/types';
export type DomainStatus = 'validating' | 'valid' | 'invalid';
export interface Domain extends ShlinkDomain {
status: DomainStatus;
}

View File

@@ -0,0 +1,51 @@
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../../servers/data';
import { getServerId } from '../../../servers/data';
import { useFeature } from '../../../utils/helpers/features';
import { useToggle } from '../../../utils/helpers/hooks';
import { RowDropdownBtn } from '../../../utils/RowDropdownBtn';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { Domain } from '../data';
import type { EditDomainRedirects } from '../reducers/domainRedirects';
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
interface DomainDropdownProps {
domain: Domain;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
selectedServer: SelectedServer;
}
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
const [isModalOpen, toggleModal] = useToggle();
const { isDefault } = domain;
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
const withVisits = useFeature('domainVisits', selectedServer);
const serverId = getServerId(selectedServer);
return (
<RowDropdownBtn>
{withVisits && (
<DropdownItem
tag={Link}
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
>
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
)}
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
</DropdownItem>
<EditDomainRedirectsModal
domain={domain}
isOpen={isModalOpen}
toggle={toggleModal}
editDomainRedirects={editDomainRedirects}
/>
</RowDropdownBtn>
);
};

View File

@@ -0,0 +1,60 @@
import {
faCheck as checkIcon,
faCircleNotch as loadingStatusIcon,
faTimes as invalidIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { ExternalLink } from 'react-external-link';
import { UncontrolledTooltip } from 'reactstrap';
import { useElementRef } from '../../../utils/helpers/hooks';
import type { MediaMatcher } from '../../../utils/types';
import type { DomainStatus } from '../data';
interface DomainStatusIconProps {
status: DomainStatus;
matchMedia?: MediaMatcher;
}
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
const ref = useElementRef<HTMLSpanElement>();
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
const [isMobile, setIsMobile] = useState<boolean>(matchesMobile());
useEffect(() => {
const listener = () => setIsMobile(matchesMobile());
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, []);
if (status === 'validating') {
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
}
return (
<>
<span ref={ref}>
{status === 'valid'
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
</span>
<UncontrolledTooltip
target={ref}
placement={isMobile ? 'top-start' : 'left'}
autohide={status === 'valid'}
>
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
<span>
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
<br />
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
find out what is missing.
</span>
)}
</UncontrolledTooltip>
</>
);
};

View File

@@ -0,0 +1,78 @@
import type { FC } from 'react';
import { useState } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ShlinkDomain } from '../../../api/types';
import type { InputFormGroupProps } from '../../../utils/forms/InputFormGroup';
import { InputFormGroup } from '../../../utils/forms/InputFormGroup';
import { InfoTooltip } from '../../../utils/InfoTooltip';
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../../utils/utils';
import type { EditDomainRedirects } from '../reducers/domainRedirects';
interface EditDomainRedirectsModalProps {
domain: ShlinkDomain;
isOpen: boolean;
toggle: () => void;
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
}
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
<InputFormGroup
{...rest}
required={false}
type="url"
placeholder="No redirect"
className={isLast ? 'mb-0' : ''}
/>
);
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
{ isOpen, toggle, domain, editDomainRedirects },
) => {
const [baseUrlRedirect, setBaseUrlRedirect] = useState(domain.redirects?.baseUrlRedirect ?? '');
const [regular404Redirect, setRegular404Redirect] = useState(domain.redirects?.regular404Redirect ?? '');
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
domain.redirects?.invalidShortUrlRedirect ?? '',
);
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects({
domain: domain.domain,
redirects: {
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
},
}).then(toggle));
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<form name="domainRedirectsModal" onSubmit={handleSubmit}>
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
<ModalBody>
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
<InfoTooltip className="me-2" placement="bottom">
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
</InfoTooltip>
Base URL
</FormGroup>
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
<InfoTooltip className="me-2" placement="bottom">
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
will be redirected to this URL.
</InfoTooltip>
Regular 404
</FormGroup>
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
<InfoTooltip className="me-2" placement="bottom">
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
redirected to this URL.
</InfoTooltip>
Invalid short URL
</FormGroup>
</ModalBody>
<ModalFooter>
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
<Button color="primary">Save</Button>
</ModalFooter>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,22 @@
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ShlinkDomainRedirects } from '../../../api/types';
import { createAsyncThunk } from '../../../utils/helpers/redux';
const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
export interface EditDomainRedirects {
domain: string;
redirects: ShlinkDomainRedirects;
}
export const editDomainRedirects = (
buildShlinkApiClient: ShlinkApiClientBuilder,
) => createAsyncThunk(
EDIT_DOMAIN_REDIRECTS,
async ({ domain, redirects: providedRedirects }: EditDomainRedirects, { getState }): Promise<EditDomainRedirects> => {
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
const redirects = await shlinkEditDomainRedirects({ domain, ...providedRedirects });
return { domain, redirects };
},
);

View File

@@ -0,0 +1,126 @@
import type { AsyncThunk, SliceCaseReducers } from '@reduxjs/toolkit';
import { createAction, createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ShlinkDomainRedirects } from '../../../api/types';
import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils';
import { hasServerData } from '../../../servers/data';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import { replaceAuthorityFromUri } from '../../../utils/helpers/uri';
import type { Domain, DomainStatus } from '../data';
import type { EditDomainRedirects } from './domainRedirects';
const REDUCER_PREFIX = 'shlink/domainsList';
export interface DomainsList {
domains: Domain[];
filteredDomains: Domain[];
defaultRedirects?: ShlinkDomainRedirects;
loading: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
interface ListDomains {
domains: Domain[];
defaultRedirects?: ShlinkDomainRedirects;
}
interface ValidateDomain {
domain: string;
status: DomainStatus;
}
const initialState: DomainsList = {
domains: [],
filteredDomains: [],
loading: false,
error: false,
};
export const replaceRedirectsOnDomain = ({ domain, redirects }: EditDomainRedirects) =>
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
export const domainsListReducerCreator = (
buildShlinkApiClient: ShlinkApiClientBuilder,
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
) => {
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise<ListDomains> => {
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
const { data, defaultRedirects } = await shlinkListDomains();
return {
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
defaultRedirects,
};
});
const checkDomainHealth = createAsyncThunk(
`${REDUCER_PREFIX}/checkDomainHealth`,
async (domain: string, { getState }): Promise<ValidateDomain> => {
const { selectedServer } = getState();
if (!hasServerData(selectedServer)) {
return { domain, status: 'invalid' };
}
try {
const { url, ...rest } = selectedServer;
const { health } = buildShlinkApiClient({
...rest,
url: replaceAuthorityFromUri(url, domain),
});
const { status } = await health();
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
} catch (e) {
return { domain, status: 'invalid' };
}
},
);
const filterDomains = createAction<string>(`${REDUCER_PREFIX}/filterDomains`);
const { reducer } = createSlice<DomainsList, SliceCaseReducers<DomainsList>>({
name: REDUCER_PREFIX,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(listDomains.pending, () => ({ ...initialState, loading: true }));
builder.addCase(listDomains.rejected, (_, { error }) => (
{ ...initialState, error: true, errorData: parseApiError(error) }
));
builder.addCase(listDomains.fulfilled, (_, { payload }) => (
{ ...initialState, ...payload, filteredDomains: payload.domains }
));
builder.addCase(checkDomainHealth.fulfilled, ({ domains, filteredDomains, ...rest }, { payload }) => ({
...rest,
domains: domains.map(replaceStatusOnDomain(payload.domain, payload.status)),
filteredDomains: filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)),
}));
builder.addCase(filterDomains, (state, { payload }) => ({
...state,
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(payload.toLowerCase())),
}));
builder.addCase(editDomainRedirects.fulfilled, (state, { payload }) => ({
...state,
domains: state.domains.map(replaceRedirectsOnDomain(payload)),
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(payload)),
}));
},
});
return {
reducer,
listDomains,
checkDomainHealth,
filterDomains,
};
};

View File

@@ -0,0 +1,34 @@
import type Bottle from 'bottlejs';
import { prop } from 'ramda';
import type { ConnectDecorator } from '../../../container/types';
import { DomainSelector } from '../DomainSelector';
import { ManageDomains } from '../ManageDomains';
import { editDomainRedirects } from '../reducers/domainRedirects';
import { domainsListReducerCreator } from '../reducers/domainsList';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('DomainSelector', () => DomainSelector);
bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
bottle.serviceFactory('ManageDomains', () => ManageDomains);
bottle.decorator('ManageDomains', connect(
['domainsList', 'selectedServer'],
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
));
// Reducer
bottle.serviceFactory(
'domainsListReducerCreator',
domainsListReducerCreator,
'buildShlinkApiClient',
'editDomainRedirects',
);
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
// Actions
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
};

View File

@@ -0,0 +1,119 @@
import type { FC } from 'react';
import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
import type { ShlinkShortUrlsListParams } from '../../api/types';
import { boundToMercureHub } from '../../mercure/helpers/boundToMercureHub';
import { Topics } from '../../mercure/helpers/Topics';
import type { SelectedServer } from '../../servers/data';
import { getServerId } from '../../servers/data';
import { HighlightCard } from '../../servers/helpers/HighlightCard';
import { VisitsHighlightCard } from '../../servers/helpers/VisitsHighlightCard';
import type { Settings } from '../../settings/reducers/settings';
import { useFeature } from '../../utils/helpers/features';
import { prettify } from '../../utils/helpers/numbers';
import type { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import type { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { ITEMS_IN_OVERVIEW_PAGE } from '../short-urls/reducers/shortUrlsList';
import type { ShortUrlsTableType } from '../short-urls/ShortUrlsTable';
import type { TagsList } from '../tags/reducers/tagsList';
import type { VisitsOverview } from '../visits/reducers/visitsOverview';
interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
listTags: Function;
tagsList: TagsList;
selectedServer: SelectedServer;
visitsOverview: VisitsOverview;
loadVisitsOverview: Function;
settings: Settings;
}
export const Overview = (
ShortUrlsTable: ShortUrlsTableType,
CreateShortUrl: FC<CreateShortUrlProps>,
) => boundToMercureHub(({
shortUrlsList,
listShortUrls,
listTags,
tagsList,
selectedServer,
loadVisitsOverview,
visitsOverview,
settings: { visits },
}: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, nonOrphanVisits, orphanVisits } = visitsOverview;
const serverId = getServerId(selectedServer);
const linkToNonOrphanVisits = useFeature('nonOrphanVisits', selectedServer);
const navigate = useNavigate();
useEffect(() => {
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
listTags();
loadVisitsOverview();
}, []);
return (
<>
<Row>
<div className="col-lg-6 col-xl-3 mb-3">
<VisitsHighlightCard
title="Visits"
link={linkToNonOrphanVisits ? `/server/${serverId}/non-orphan-visits` : undefined}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={nonOrphanVisits}
/>
</div>
<div className="col-lg-6 col-xl-3 mb-3">
<VisitsHighlightCard
title="Orphan visits"
link={`/server/${serverId}/orphan-visits`}
excludeBots={visits?.excludeBots ?? false}
loading={loadingVisits}
visitsSummary={orphanVisits}
/>
</div>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</HighlightCard>
</div>
<div className="col-lg-6 col-xl-3 mb-3">
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
</HighlightCard>
</div>
</Row>
<Card className="mb-3">
<CardHeader>
<span className="d-sm-none">Create a short URL</span>
<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>
</CardHeader>
<CardBody>
<CreateShortUrl basicMode />
</CardBody>
</Card>
<Card>
<CardHeader>
<span className="d-sm-none">Recently created URLs</span>
<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>
</CardHeader>
<CardBody>
<ShortUrlsTable
shortUrlsList={shortUrlsList}
selectedServer={selectedServer}
className="mb-0"
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
/>
</CardBody>
</Card>
</>
);
}, () => [Topics.visits, Topics.orphanVisits]);

View File

@@ -0,0 +1,69 @@
import type { FC } from 'react';
import { useMemo } from 'react';
import type { SelectedServer } from '../../servers/data';
import type { Settings, ShortUrlCreationSettings } from '../../settings/reducers/settings';
import type { ShortUrlData } from './data';
import type { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
import type { ShortUrlCreation } from './reducers/shortUrlCreation';
import type { ShortUrlFormProps } from './ShortUrlForm';
export interface CreateShortUrlProps {
basicMode?: boolean;
}
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
settings: Settings;
shortUrlCreation: ShortUrlCreation;
selectedServer: SelectedServer;
createShortUrl: (data: ShortUrlData) => Promise<void>;
resetCreateShortUrl: () => void;
}
const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({
longUrl: '',
tags: [],
customSlug: '',
title: undefined,
shortCodeLength: undefined,
domain: '',
validSince: undefined,
validUntil: undefined,
maxVisits: undefined,
findIfExists: false,
validateUrl: settings?.validateUrls ?? false,
forwardQuery: settings?.forwardQuery ?? true,
});
export const CreateShortUrl = (
ShortUrlForm: FC<ShortUrlFormProps>,
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
) => ({
createShortUrl,
shortUrlCreation,
resetCreateShortUrl,
selectedServer,
basicMode = false,
settings: { shortUrlCreation: shortUrlCreationSettings },
}: CreateShortUrlConnectProps) => {
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [shortUrlCreationSettings]);
return (
<>
<ShortUrlForm
initialState={initialState}
saving={shortUrlCreation.saving}
selectedServer={selectedServer}
mode={basicMode ? 'create-basic' : 'create'}
onSave={async (data: ShortUrlData) => {
resetCreateShortUrl();
return createShortUrl(data);
}}
/>
<CreateShortUrlResult
creation={shortUrlCreation}
resetCreateShortUrl={resetCreateShortUrl}
canBeClosed={basicMode}
/>
</>
);
};

View File

@@ -0,0 +1,101 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { useEffect, useMemo } from 'react';
import { ExternalLink } from 'react-external-link';
import { useLocation, useParams } from 'react-router-dom';
import { Button, Card } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError';
import type { SelectedServer } from '../../servers/data';
import type { Settings } from '../../settings/reducers/settings';
import { useGoBack } from '../../utils/helpers/hooks';
import { parseQuery } from '../../utils/helpers/query';
import { Message } from '../../utils/Message';
import { Result } from '../../utils/Result';
import type { ShortUrlIdentifier } from './data';
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
import type { ShortUrlDetail } from './reducers/shortUrlDetail';
import type { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition';
import type { ShortUrlFormProps } from './ShortUrlForm';
interface EditShortUrlConnectProps {
settings: Settings;
selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail;
shortUrlEdition: ShortUrlEdition;
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
}
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer,
shortUrlDetail,
getShortUrlDetail,
shortUrlEdition,
editShortUrl,
}: EditShortUrlConnectProps) => {
const { search } = useLocation();
const params = useParams<{ shortCode: string }>();
const goBack = useGoBack();
const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search);
const initialState = useMemo(
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
[shortUrl, shortUrlCreationSettings],
);
useEffect(() => {
params.shortCode && getShortUrlDetail({ shortCode: urlDecodeShortCode(params.shortCode), domain });
}, []);
if (loading) {
return <Message loading />;
}
if (error) {
return (
<Result type="error">
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while loading short URL detail :(" />
</Result>
);
}
return (
<>
<header className="mb-3">
<Card body>
<h2 className="d-sm-flex justify-content-between align-items-center mb-0">
<Button color="link" size="lg" className="p-0 me-3" onClick={goBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</Button>
<span className="text-center">
<small>Edit <ExternalLink href={shortUrl?.shortUrl ?? ''} /></small>
</span>
<span />
</h2>
</Card>
</header>
<ShortUrlForm
initialState={initialState}
saving={saving}
selectedServer={selectedServer}
mode="edit"
onSave={async (shortUrlData) => {
if (!shortUrl) {
return;
}
editShortUrl({ ...shortUrl, data: shortUrlData });
}}
/>
{saved && savingError && (
<Result type="error" className="mt-3">
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
</Result>
)}
{saved && !savingError && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
</>
);
};

View File

@@ -0,0 +1,52 @@
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import type { ShlinkPaginator } from '../../api/types';
import type {
NumberOrEllipsis } from '../../utils/helpers/pagination';
import {
keyForPage,
pageIsEllipsis,
prettifyPageNumber,
progressivePagination,
} from '../../utils/helpers/pagination';
interface PaginatorProps {
paginator?: ShlinkPaginator;
serverId: string;
currentQueryString?: string;
}
export const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
const urlForPage = (pageNumber: NumberOrEllipsis) =>
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
if (pagesCount <= 1) {
return <div className="pb-3" />; // Return some space
}
const renderPages = () =>
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
<PaginationItem
key={keyForPage(pageNumber, index)}
disabled={pageIsEllipsis(pageNumber)}
active={currentPage === pageNumber}
>
<PaginationLink tag={Link} to={urlForPage(pageNumber)}>
{prettifyPageNumber(pageNumber)}
</PaginationLink>
</PaginationItem>
));
return (
<Pagination className="sticky-card-paginator py-3" listClassName="flex-wrap justify-content-center mb-0">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
</PaginationItem>
{renderPages()}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink next tag={Link} to={urlForPage(currentPage + 1)} />
</PaginationItem>
</Pagination>
);
};

View File

@@ -0,0 +1,9 @@
@import '../../utils/base';
.short-url-form p:last-child {
margin-bottom: 0;
}
.short-url-form .card {
height: 100%;
}

View File

@@ -0,0 +1,274 @@
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons';
import { faDesktop } from '@fortawesome/free-solid-svg-icons';
import classNames from 'classnames';
import { parseISO } from 'date-fns';
import { isEmpty, pipe, replace, trim } from 'ramda';
import type { ChangeEvent, FC } from 'react';
import { useEffect, useState } from 'react';
import { Button, FormGroup, Input, Row } from 'reactstrap';
import type { InputType } from 'reactstrap/types/lib/Input';
import type { SelectedServer } from '../../servers/data';
import { Checkbox } from '../../utils/Checkbox';
import type { DateTimeInputProps } from '../../utils/dates/DateTimeInput';
import { DateTimeInput } from '../../utils/dates/DateTimeInput';
import { formatIsoDate } from '../../utils/helpers/date';
import { useFeature } from '../../utils/helpers/features';
import { IconInput } from '../../utils/IconInput';
import { SimpleCard } from '../../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue } from '../../utils/utils';
import type { DomainSelectorProps } from '../domains/DomainSelector';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import type { DeviceLongUrls, ShortUrlData } from './data';
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
import './ShortUrlForm.scss';
export type Mode = 'create' | 'create-basic' | 'edit';
type DateFields = 'validSince' | 'validUntil';
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title';
export interface ShortUrlFormProps {
mode: Mode;
saving: boolean;
initialState: ShortUrlData;
onSave: (shortUrlData: ShortUrlData) => Promise<unknown>;
selectedServer: SelectedServer;
}
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>,
DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
const [shortUrlData, setShortUrlData] = useState(initialState);
const reset = () => setShortUrlData(initialState);
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer);
const isEdit = mode === 'edit';
const isBasicMode = mode === 'create-basic';
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const setResettableValue = (value: string, initialValue?: any) => {
if (hasValue(value)) {
return value;
}
// If an initial value was provided for this when the input is "emptied", explicitly set it to null so that the
// value gets removed. Otherwise, set undefined so that it gets ignored.
return hasValue(initialValue) ? null : undefined;
};
const submit = handleEventPreventingDefault(async () => onSave({
...shortUrlData,
validSince: formatIsoDate(shortUrlData.validSince) ?? null,
validUntil: formatIsoDate(shortUrlData.validUntil) ?? null,
maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits),
}).then(() => !isEdit && reset()).catch(() => {}));
useEffect(() => {
setShortUrlData(initialState);
}, [initialState]);
// TODO Consider extracting these functions to local components
const renderOptionalInput = (
id: NonDateFields,
placeholder: string,
type: InputType = 'text',
props: any = {},
fromGroupProps = {},
) => (
<FormGroup {...fromGroupProps}>
<Input
id={id}
type={type}
placeholder={placeholder}
value={shortUrlData[id] ?? ''}
onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
{...props}
/>
</FormGroup>
);
const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => (
<IconInput
icon={icon}
id={id}
type="url"
placeholder={placeholder}
value={shortUrlData.deviceLongUrls?.[id] ?? ''}
onChange={(e) => setShortUrlData({
...shortUrlData,
deviceLongUrls: {
...(shortUrlData.deviceLongUrls ?? {}),
[id]: setResettableValue(e.target.value, initialState.deviceLongUrls?.[id]),
},
})}
/>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => (
<DateTimeInput
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
{...props}
/>
);
const basicComponents = (
<>
<FormGroup>
<Input
bsSize="lg"
type="url"
placeholder="URL to be shortened"
required
value={shortUrlData.longUrl}
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
/>
</FormGroup>
<Row>
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
<div className={isBasicMode ? 'col-lg-6 mb-3' : 'col-12'}>
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
</div>
</Row>
</>
);
const showForwardQueryControl = useFeature('forwardQuery', selectedServer);
return (
<form name="shortUrlForm" className="short-url-form" onSubmit={submit}>
{isBasicMode && basicComponents}
{!isBasicMode && (
<>
<Row>
<div
className={classNames('mb-3', { 'col-sm-6': supportsDeviceLongUrls, 'col-12': !supportsDeviceLongUrls })}
>
<SimpleCard title="Main options" className="mb-3">
{basicComponents}
</SimpleCard>
</div>
{supportsDeviceLongUrls && (
<div className="col-sm-6 mb-3">
<SimpleCard title="Device-specific long URLs">
<FormGroup>
{renderDeviceLongUrlInput('android', 'Android-specific redirection', faAndroid)}
</FormGroup>
<FormGroup>
{renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faApple)}
</FormGroup>
{renderDeviceLongUrlInput('desktop', 'Desktop-specific redirection', faDesktop)}
</SimpleCard>
</div>
)}
</Row>
<Row>
<div className="col-sm-6 mb-3">
<SimpleCard title="Customize the short URL">
{renderOptionalInput('title', 'Title', 'text', {
onChange: ({ target }: ChangeEvent<HTMLInputElement>) => setShortUrlData({
...shortUrlData,
title: setResettableValue(target.value, initialState.title),
}),
})}
{!isEdit && (
<>
<Row>
<div className="col-lg-6">
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
disabled: hasValue(shortUrlData.shortCodeLength),
})}
</div>
<div className="col-lg-6">
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: hasValue(shortUrlData.customSlug),
})}
</div>
</Row>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</>
)}
</SimpleCard>
</div>
<div className="col-sm-6 mb-3">
<SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
<div className="mb-3">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })}
</div>
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
</SimpleCard>
</div>
</Row>
<Row>
<div className="col-sm-6 mb-3">
<SimpleCard title="Extra checks">
<ShortUrlFormCheckboxGroup
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
checked={shortUrlData.validateUrl}
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
>
Validate URL
</ShortUrlFormCheckboxGroup>
{!isEdit && (
<p>
<Checkbox
inline
className="me-2"
checked={shortUrlData.findIfExists}
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
)}
</SimpleCard>
</div>
<div className="col-sm-6 mb-3">
<SimpleCard title="Configure behavior">
<ShortUrlFormCheckboxGroup
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
checked={shortUrlData.crawlable}
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
>
Make it crawlable
</ShortUrlFormCheckboxGroup>
{showForwardQueryControl && (
<ShortUrlFormCheckboxGroup
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
checked={shortUrlData.forwardQuery}
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
>
Forward query params on redirect
</ShortUrlFormCheckboxGroup>
)}
</SimpleCard>
</div>
</Row>
</>
)}
<div className="text-center">
<Button
outline
color="primary"
disabled={saving || isEmpty(shortUrlData.longUrl)}
className="btn-xs-block"
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,4 @@
.short-urls-filtering-bar__tags-icon {
vertical-align: bottom;
font-size: 1.6rem;
}

View File

@@ -0,0 +1,125 @@
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { isEmpty, pipe } from 'ramda';
import type { FC } from 'react';
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
import type { SelectedServer } from '../../servers/data';
import type { Settings } from '../../settings/reducers/settings';
import { DateRangeSelector } from '../../utils/dates/DateRangeSelector';
import { formatIsoDate } from '../../utils/helpers/date';
import type { DateRange } from '../../utils/helpers/dateIntervals';
import { datesToDateRange } from '../../utils/helpers/dateIntervals';
import { useFeature } from '../../utils/helpers/features';
import type { OrderDir } from '../../utils/helpers/ordering';
import { OrderingDropdown } from '../../utils/OrderingDropdown';
import { SearchField } from '../../utils/SearchField';
import type { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { SHORT_URLS_ORDERABLE_FIELDS } from './data';
import type { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn';
import { useShortUrlsQuery } from './helpers/hooks';
import { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown';
import './ShortUrlsFilteringBar.scss';
interface ShortUrlsFilteringProps {
selectedServer: SelectedServer;
order: ShortUrlsOrder;
settings: Settings;
handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void;
className?: string;
shortUrlsAmount?: number;
}
export const ShortUrlsFilteringBar = (
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
TagsSelector: FC<TagsSelectorProps>,
): FC<ShortUrlsFilteringProps> => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => {
const [filter, toFirstPage] = useShortUrlsQuery();
const {
search,
tags,
startDate,
endDate,
excludeBots,
excludeMaxVisitsReached,
excludePastValidUntil,
tagsMode = 'any',
} = filter;
const supportsDisabledFiltering = useFeature('filterDisabledUrls', selectedServer);
const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
startDate: formatIsoDate(theStartDate) ?? undefined,
endDate: formatIsoDate(theEndDate) ?? undefined,
}),
toFirstPage,
);
const setSearch = pipe(
(searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm),
(searchTerm) => toFirstPage({ search: searchTerm }),
);
const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags });
const canChangeTagsMode = useFeature('allTagsFiltering', selectedServer);
const toggleTagsMode = pipe(
() => (tagsMode === 'any' ? 'all' : 'any'),
(mode) => toFirstPage({ tagsMode: mode }),
);
return (
<div className={classNames('short-urls-filtering-bar-container', className)}>
<SearchField initialValue={search} onChange={setSearch} />
<InputGroup className="mt-3">
<TagsSelector allowNew={false} placeholder="With tags..." selectedTags={tags} onChange={changeTagSelection} />
{canChangeTagsMode && tags.length > 1 && (
<>
<Button outline color="secondary" onClick={toggleTagsMode} id="tagsModeBtn" aria-label="Change tags mode">
<FontAwesomeIcon className="short-urls-filtering-bar__tags-icon" icon={tagsMode === 'all' ? faTags : faTag} />
</Button>
<UncontrolledTooltip target="tagsModeBtn" placement="left">
{tagsMode === 'all' ? 'With all the tags.' : 'With any of the tags.'}
</UncontrolledTooltip>
</>
)}
</InputGroup>
<Row className="flex-lg-row-reverse">
<div className="col-lg-8 col-xl-6 mt-3">
<div className="d-md-flex">
<div className="flex-fill">
<DateRangeSelector
defaultText="All short URLs"
initialDateRange={datesToDateRange(startDate, endDate)}
onDatesChange={setDates}
/>
</div>
<ShortUrlsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0"
selected={{
excludeBots: excludeBots ?? settings.visits?.excludeBots,
excludeMaxVisitsReached,
excludePastValidUntil,
}}
onChange={toFirstPage}
supportsDisabledFiltering={supportsDisabledFiltering}
/>
</div>
</div>
<div className="col-6 col-lg-4 col-xl-6 mt-3">
<ExportShortUrlsBtn amount={shortUrlsAmount} />
</div>
<div className="col-6 d-lg-none mt-3">
<OrderingDropdown
prefixed={false}
items={SHORT_URLS_ORDERABLE_FIELDS}
order={order}
onChange={handleOrderBy}
/>
</div>
</Row>
</div>
);
};
export type ShortUrlsFilteringBarType = ReturnType<typeof ShortUrlsFilteringBar>;

View File

@@ -0,0 +1,123 @@
import { pipe } from 'ramda';
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Card } from 'reactstrap';
import type { ShlinkShortUrlsListParams, ShlinkShortUrlsOrder } from '../../api/types';
import { boundToMercureHub } from '../../mercure/helpers/boundToMercureHub';
import { Topics } from '../../mercure/helpers/Topics';
import type { SelectedServer } from '../../servers/data';
import { getServerId } from '../../servers/data';
import type { Settings } from '../../settings/reducers/settings';
import { DEFAULT_SHORT_URLS_ORDERING } from '../../settings/reducers/settings';
import { useFeature } from '../../utils/helpers/features';
import type { OrderDir } from '../../utils/helpers/ordering';
import { determineOrderDir } from '../../utils/helpers/ordering';
import { TableOrderIcon } from '../../utils/table/TableOrderIcon';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from './data';
import { useShortUrlsQuery } from './helpers/hooks';
import { Paginator } from './Paginator';
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import type { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar';
import type { ShortUrlsTableType } from './ShortUrlsTable';
interface ShortUrlsListProps {
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
settings: Settings;
}
export const ShortUrlsList = (
ShortUrlsTable: ShortUrlsTableType,
ShortUrlsFilteringBar: ShortUrlsFilteringBarType,
) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => {
const serverId = getServerId(selectedServer);
const { page } = useParams();
const location = useLocation();
const [filter, toFirstPage] = useShortUrlsQuery();
const {
tags,
search,
startDate,
endDate,
orderBy,
tagsMode,
excludeBots,
excludePastValidUntil,
excludeMaxVisitsReached,
} = filter;
const [actualOrderBy, setActualOrderBy] = useState(
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
);
const { pagination } = shortUrlsList?.shortUrls ?? {};
const doExcludeBots = excludeBots ?? settings.visits?.excludeBots;
const supportsExcludingBots = useFeature('excludeBotsOnShortUrls', selectedServer);
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
toFirstPage({ orderBy: { field, dir } });
setActualOrderBy({ field, dir });
};
const orderByColumn = (field: ShortUrlsOrderableFields) => () =>
handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir));
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
const addTag = pipe(
(newTag: string) => [...new Set([...tags, newTag])],
(updatedTags) => toFirstPage({ tags: updatedTags }),
);
const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => {
if (supportsExcludingBots && doExcludeBots && field === 'visits') {
return { field: 'nonBotVisits', dir };
}
return { field, dir };
};
useEffect(() => {
listShortUrls({
page,
searchTerm: search,
tags,
startDate,
endDate,
orderBy: parseOrderByForShlink(actualOrderBy),
tagsMode,
excludePastValidUntil,
excludeMaxVisitsReached,
});
}, [
page,
search,
tags,
startDate,
endDate,
actualOrderBy.field,
actualOrderBy.dir,
tagsMode,
excludePastValidUntil,
excludeMaxVisitsReached,
]);
return (
<>
<ShortUrlsFilteringBar
selectedServer={selectedServer}
shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems}
order={actualOrderBy}
handleOrderBy={handleOrderBy}
settings={settings}
className="mb-3"
/>
<Card body className="pb-0">
<ShortUrlsTable
selectedServer={selectedServer}
shortUrlsList={shortUrlsList}
orderByColumn={orderByColumn}
renderOrderIcon={renderOrderIcon}
onTagClick={addTag}
/>
<Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} />
</Card>
</>
);
}, () => [Topics.visits]);

View File

@@ -0,0 +1,7 @@
.short-urls-table.short-urls-table {
margin-bottom: -1px;
}
.short-urls-table__header-cell--with-action {
cursor: pointer;
}

View File

@@ -0,0 +1,94 @@
import classNames from 'classnames';
import { isEmpty } from 'ramda';
import type { ReactNode } from 'react';
import type { SelectedServer } from '../../servers/data';
import type { ShortUrlsOrderableFields } from './data';
import type { ShortUrlsRowType } from './helpers/ShortUrlsRow';
import type { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import './ShortUrlsTable.scss';
interface ShortUrlsTableProps {
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
shortUrlsList: ShortUrlsListState;
selectedServer: SelectedServer;
onTagClick?: (tag: string) => void;
className?: string;
}
export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({
orderByColumn,
renderOrderIcon,
shortUrlsList,
onTagClick,
selectedServer,
className,
}: ShortUrlsTableProps) => {
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 short-urls-table', className);
const renderShortUrls = () => {
if (error) {
return (
<tr>
<td colSpan={6} className="text-center table-danger text-dark">
Something went wrong while loading short URLs :(
</td>
</tr>
);
}
if (loading) {
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
}
if (!loading && isEmpty(shortUrls?.data)) {
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
}
return shortUrls?.data.map((shortUrl) => (
<ShortUrlsRow
key={shortUrl.shortUrl}
shortUrl={shortUrl}
selectedServer={selectedServer}
onTagClick={onTagClick}
/>
));
};
return (
<table className={tableClasses}>
<thead className="responsive-table__header short-urls-table__header">
<tr>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
Created at {renderOrderIcon?.('dateCreated')}
</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
Short URL {renderOrderIcon?.('shortCode')}
</th>
<th className="short-urls-table__header-cell">
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
Title {renderOrderIcon?.('title')}
</span>
&nbsp;&nbsp;/&nbsp;&nbsp;
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
</span>
</th>
<th className="short-urls-table__header-cell">Tags</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
</th>
<th className="short-urls-table__header-cell" colSpan={2} />
</tr>
</thead>
<tbody>
{renderShortUrls()}
</tbody>
</table>
);
};
export type ShortUrlsTableType = ReturnType<typeof ShortUrlsTable>;

View File

@@ -0,0 +1,7 @@
.use-existing-if-found-info-icon__modal-quote {
margin-bottom: 0;
padding: 10px 15px;
font-size: 17.5px;
border-left: 5px solid #eeeeee;
background-color: #f9f9f9;
}

View File

@@ -0,0 +1,50 @@
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import { useToggle } from '../../utils/helpers/hooks';
import './UseExistingIfFoundInfoIcon.scss';
const InfoModal = ({ isOpen, toggle }: { isOpen: boolean; toggle: () => void }) => (
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
<ModalHeader toggle={toggle}>Info</ModalHeader>
<ModalBody>
<p>
When the&nbsp;
<b><i>&quot;Use existing URL if found&quot;</i></b>
&nbsp;checkbox is checked, the server will return an existing short URL if it matches provided params.
</p>
<p>
These are the checks performed by Shlink in order to determine if an existing short URL should be returned:
</p>
<ul>
<li>
When only the long URL is provided: The most recent match will be returned, or a new short URL will be created
if none is found.
</li>
<li>
When long URL and custom slug and/or domain are provided: Same as in previous case, but it will try to match
the short URL using both the long URL and the slug, the long URL and the domain, or the three of them.
<br />
If the slug is being used by another long URL, an error will be returned.
</li>
<li>
When other params are provided: Same as in previous cases, but it will try to match existing short URLs with
all provided data. If any of them does not match, a new short URL will be created
</li>
</ul>
</ModalBody>
</Modal>
);
export const UseExistingIfFoundInfoIcon = () => {
const [isModalOpen, toggleModal] = useToggle();
return (
<>
<span title="What does this mean?">
<FontAwesomeIcon icon={infoIcon} style={{ cursor: 'pointer' }} onClick={toggleModal} />
</span>
<InfoModal isOpen={isModalOpen} toggle={toggleModal} />
</>
);
};

View File

@@ -0,0 +1,93 @@
import type { ShlinkVisitsSummary } from '../../../api/types';
import type { Order } from '../../../utils/helpers/ordering';
import type { Nullable, OptionalString } from '../../../utils/utils';
export interface DeviceLongUrls {
android?: OptionalString;
ios?: OptionalString;
desktop?: OptionalString;
}
export interface EditShortUrlData {
longUrl?: string;
deviceLongUrls?: DeviceLongUrls;
tags?: string[];
title?: string | null;
validSince?: Date | string | null;
validUntil?: Date | string | null;
maxVisits?: number | null;
validateUrl?: boolean;
crawlable?: boolean;
forwardQuery?: boolean;
}
export interface ShortUrlData extends EditShortUrlData {
longUrl: string;
customSlug?: string;
shortCodeLength?: number;
domain?: string;
findIfExists?: boolean;
}
export interface ShortUrlIdentifier {
shortCode: string;
domain?: OptionalString;
}
export interface ShortUrl {
shortCode: string;
shortUrl: string;
longUrl: string;
deviceLongUrls?: Required<DeviceLongUrls>, // Optional only before Shlink 3.5.0
dateCreated: string;
/** @deprecated */
visitsCount: number; // Deprecated since Shlink 3.4.0
visitsSummary?: ShlinkVisitsSummary; // Optional only before Shlink 3.4.0
meta: Required<Nullable<ShortUrlMeta>>;
tags: string[];
domain: string | null;
title?: string | null;
crawlable?: boolean;
forwardQuery?: boolean;
}
export interface ShortUrlMeta {
validSince?: string;
validUntil?: string;
maxVisits?: number;
}
export interface ShortUrlModalProps {
shortUrl: ShortUrl;
isOpen: boolean;
toggle: () => void;
}
export const SHORT_URLS_ORDERABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
longUrl: 'Long URL',
title: 'Title',
visits: 'Visits',
};
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
export interface ExportableShortUrl {
createdAt: string;
title: string;
shortUrl: string;
domain?: string;
shortCode: string;
longUrl: string;
tags: string;
visits: number;
}
export interface ShortUrlsFilter {
excludeBots?: boolean;
excludeMaxVisitsReached?: boolean;
excludePastValidUntil?: boolean;
}

View File

@@ -0,0 +1,4 @@
.create-short-url-result__copy-btn {
margin-left: 10px;
vertical-align: inherit;
}

View File

@@ -0,0 +1,64 @@
import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useEffect } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { Tooltip } from 'reactstrap';
import { ShlinkApiError } from '../../../api/ShlinkApiError';
import type { TimeoutToggle } from '../../../utils/helpers/hooks';
import { Result } from '../../../utils/Result';
import type { ShortUrlCreation } from '../reducers/shortUrlCreation';
import './CreateShortUrlResult.scss';
export interface CreateShortUrlResultProps {
creation: ShortUrlCreation;
resetCreateShortUrl: () => void;
canBeClosed?: boolean;
}
export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => (
{ creation, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
) => {
const [showCopyTooltip, setShowCopyTooltip] = useTimeoutToggle();
const { error, saved } = creation;
useEffect(() => {
resetCreateShortUrl();
}, []);
if (error) {
return (
<Result type="error" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
<ShlinkApiError errorData={creation.errorData} fallbackMessage="An error occurred while creating the URL :(" />
</Result>
);
}
if (!saved) {
return null;
}
const { shortUrl } = creation.result;
return (
<Result type="success" className="mt-3">
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
<span><b>Great!</b> The short URL is <b>{shortUrl}</b></span>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
<button
className="btn btn-light btn-sm create-short-url-result__copy-btn"
id="copyBtn"
type="button"
>
<FontAwesomeIcon icon={copyIcon} /> Copy
</button>
</CopyToClipboard>
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
Copied!
</Tooltip>
</Result>
);
};

View File

@@ -0,0 +1,75 @@
import { pipe } from 'ramda';
import { useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkApiError } from '../../../api/ShlinkApiError';
import { isInvalidDeletionError } from '../../../api/utils';
import { Result } from '../../../utils/Result';
import { handleEventPreventingDefault } from '../../../utils/utils';
import type { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
import type { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
shortUrlDeletion: ShortUrlDeletion;
deleteShortUrl: (shortUrl: ShortUrlIdentifier) => Promise<void>;
shortUrlDeleted: (shortUrl: ShortUrlIdentifier) => void;
resetDeleteShortUrl: () => void;
}
const DELETION_PATTERN = 'delete';
export const DeleteShortUrlModal = ({
shortUrl,
toggle,
isOpen,
shortUrlDeletion,
resetDeleteShortUrl,
deleteShortUrl,
shortUrlDeleted,
}: DeleteShortUrlModalConnectProps) => {
const [inputValue, setInputValue] = useState('');
useEffect(() => resetDeleteShortUrl, []);
const { loading, error, deleted, errorData } = shortUrlDeletion;
const close = pipe(resetDeleteShortUrl, toggle);
const handleDeleteUrl = handleEventPreventingDefault(() => deleteShortUrl(shortUrl).then(toggle));
return (
<Modal isOpen={isOpen} toggle={close} centered onClosed={() => deleted && shortUrlDeleted(shortUrl)}>
<form onSubmit={handleDeleteUrl}>
<ModalHeader toggle={close}>
<span className="text-danger">Delete short URL</span>
</ModalHeader>
<ModalBody>
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
<p>Write <b>{DELETION_PATTERN}</b> to confirm deletion.</p>
<input
type="text"
className="form-control"
placeholder={`Insert ${DELETION_PATTERN}`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
{error && (
<Result type={isInvalidDeletionError(errorData) ? 'warning' : 'error'} small className="mt-2">
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the URL :(" />
</Result>
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
<button
type="submit"
className="btn btn-danger"
disabled={inputValue !== DELETION_PATTERN || loading}
>
{loading ? 'Deleting...' : 'Delete'}
</button>
</ModalFooter>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,70 @@
import type { FC } from 'react';
import { useCallback } from 'react';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ReportExporter } from '../../../common/services/ReportExporter';
import type { SelectedServer } from '../../../servers/data';
import { isServerWithId } from '../../../servers/data';
import { ExportBtn } from '../../../utils/ExportBtn';
import { useToggle } from '../../../utils/helpers/hooks';
import type { ShortUrl } from '../data';
import { useShortUrlsQuery } from './hooks';
export interface ExportShortUrlsBtnProps {
amount?: number;
}
interface ExportShortUrlsBtnConnectProps extends ExportShortUrlsBtnProps {
selectedServer: SelectedServer;
}
const itemsPerPage = 20;
export const ExportShortUrlsBtn = (
buildShlinkApiClient: ShlinkApiClientBuilder,
{ exportShortUrls }: ReportExporter,
): FC<ExportShortUrlsBtnConnectProps> => ({ amount = 0, selectedServer }) => {
const [{ tags, search, startDate, endDate, orderBy, tagsMode }] = useShortUrlsQuery();
const [loading,, startLoading, stopLoading] = useToggle();
const exportAllUrls = useCallback(async () => {
if (!isServerWithId(selectedServer)) {
return;
}
const totalPages = amount / itemsPerPage;
const { listShortUrls } = buildShlinkApiClient(selectedServer);
const loadAllUrls = async (page = 1): Promise<ShortUrl[]> => {
const { data } = await listShortUrls(
{ page: `${page}`, tags, searchTerm: search, startDate, endDate, orderBy, tagsMode, itemsPerPage },
);
if (page >= totalPages) {
return data;
}
// TODO Support paralelization
return data.concat(await loadAllUrls(page + 1));
};
startLoading();
const shortUrls = await loadAllUrls();
exportShortUrls(shortUrls.map((shortUrl) => {
const { hostname: domain, pathname } = new URL(shortUrl.shortUrl);
const shortCode = pathname.substring(1); // Remove trailing slash
return {
createdAt: shortUrl.dateCreated,
domain,
shortCode,
shortUrl: shortUrl.shortUrl,
longUrl: shortUrl.longUrl,
title: shortUrl.title ?? '',
tags: shortUrl.tags.join('|'),
visits: shortUrl?.visitsSummary?.total ?? shortUrl.visitsCount,
};
}));
stopLoading();
}, [selectedServer]);
return <ExportBtn loading={loading} className="btn-md-block" amount={amount} onClick={exportAllUrls} />;
};

View File

@@ -0,0 +1,4 @@
.qr-code-modal__img {
max-width: 100%;
box-shadow: 0 0 .25rem rgb(0 0 0 / .2);
}

View File

@@ -0,0 +1,104 @@
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useMemo, useState } from 'react';
import { ExternalLink } from 'react-external-link';
import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap';
import type { ImageDownloader } from '../../../common/services/ImageDownloader';
import type { SelectedServer } from '../../../servers/data';
import { CopyToClipboardIcon } from '../../../utils/CopyToClipboardIcon';
import { useFeature } from '../../../utils/helpers/features';
import type { QrCodeFormat, QrErrorCorrection } from '../../../utils/helpers/qrCodes';
import { buildQrCodeUrl } from '../../../utils/helpers/qrCodes';
import type { ShortUrlModalProps } from '../data';
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
import './QrCodeModal.scss';
interface QrCodeModalConnectProps extends ShortUrlModalProps {
selectedServer: SelectedServer;
}
export const QrCodeModal = (imageDownloader: ImageDownloader) => (
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
) => {
const [size, setSize] = useState(300);
const [margin, setMargin] = useState(0);
const [format, setFormat] = useState<QrCodeFormat>('png');
const [errorCorrection, setErrorCorrection] = useState<QrErrorCorrection>('L');
const displayDownloadBtn = useFeature('nonRestCors', selectedServer);
const qrCodeUrl = useMemo(
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }),
[shortUrl, size, format, margin, errorCorrection],
);
const totalSize = useMemo(() => size + margin, [size, margin]);
const modalSize = useMemo(() => {
if (totalSize < 500) {
return undefined;
}
return totalSize < 800 ? 'lg' : 'xl';
}, [totalSize]);
return (
<Modal isOpen={isOpen} toggle={toggle} centered size={modalSize}>
<ModalHeader toggle={toggle}>
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
</ModalHeader>
<ModalBody>
<Row>
<FormGroup className="d-grid col-md-4">
<label>Size: {size}px</label>
<input
type="range"
className="form-control-range"
value={size}
step={10}
min={50}
max={1000}
onChange={(e) => setSize(Number(e.target.value))}
/>
</FormGroup>
<FormGroup className="d-grid col-md-4">
<label htmlFor="marginControl">Margin: {margin}px</label>
<input
id="marginControl"
type="range"
className="form-control-range"
value={margin}
step={1}
min={0}
max={100}
onChange={(e) => setMargin(Number(e.target.value))}
/>
</FormGroup>
<FormGroup className="d-grid col-md-4">
<QrFormatDropdown format={format} setFormat={setFormat} />
</FormGroup>
<FormGroup className="col-md-6">
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
</FormGroup>
</Row>
<div className="text-center">
<div className="mb-3">
<ExternalLink href={qrCodeUrl} />
<CopyToClipboardIcon text={qrCodeUrl} />
</div>
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
{displayDownloadBtn && (
<div className="mt-3">
<Button
block
color="primary"
onClick={() => {
imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`).catch(() => {});
}}
>
Download <FontAwesomeIcon icon={downloadIcon} className="ms-1" />
</Button>
</div>
)}
</div>
</ModalBody>
</Modal>
);
};

View File

@@ -0,0 +1,29 @@
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import type { SelectedServer, ServerWithId } from '../../../servers/data';
import { isServerWithId } from '../../../servers/data';
import type { ShortUrl } from '../data';
import { urlEncodeShortCode } from './index';
export type LinkSuffix = 'visits' | 'edit';
export interface ShortUrlDetailLinkProps {
shortUrl?: ShortUrl | null;
selectedServer?: SelectedServer;
suffix: LinkSuffix;
}
const buildUrl = ({ id }: ServerWithId, { shortCode, domain }: ShortUrl, suffix: LinkSuffix) => {
const query = domain ? `?domain=${domain}` : '';
return `/server/${id}/short-code/${urlEncodeShortCode(shortCode)}/${suffix}${query}`;
};
export const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, any>> = (
{ selectedServer, shortUrl, suffix, children, ...rest },
) => {
if (!selectedServer || !isServerWithId(selectedServer) || !shortUrl) {
return <span {...rest}>{children}</span>;
}
return <Link to={buildUrl(selectedServer, shortUrl, suffix)} {...rest}>{children}</Link>;
};

View File

@@ -0,0 +1,20 @@
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { Checkbox } from '../../../utils/Checkbox';
import { InfoTooltip } from '../../../utils/InfoTooltip';
type ShortUrlFormCheckboxGroupProps = PropsWithChildren<{
checked?: boolean;
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
infoTooltip?: string;
}>;
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
{ children, infoTooltip, checked, onChange },
) => (
<p>
<Checkbox inline checked={checked} className={infoTooltip ? 'me-2' : ''} onChange={onChange}>
{children}
</Checkbox>
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
</p>
);

View File

@@ -0,0 +1,86 @@
import type { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { faCalendarXmark, faCheck, faLinkSlash } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isBefore } from 'date-fns';
import type { FC, ReactNode } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { formatHumanFriendly, now, parseISO } from '../../../utils/helpers/date';
import { useElementRef } from '../../../utils/helpers/hooks';
import type { ShortUrl } from '../data';
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 = useElementRef<HTMLElement>();
const { icon, className, description } = resolveShortUrlStatus(shortUrl);
return (
<>
<span style={{ cursor: !description ? undefined : 'help' }} ref={tooltipRef}>
<FontAwesomeIcon icon={icon} className={className} />
</span>
<UncontrolledTooltip target={tooltipRef} placement="bottom">
{description}
</UncontrolledTooltip>
</>
);
};

View File

@@ -0,0 +1,16 @@
.short-urls-visits-count__max-visits-control {
cursor: help;
}
.short-url-visits-count__amount {
transition: transform .3s ease;
display: inline-block;
}
.short-url-visits-count__amount--big {
transform: scale(1.5);
}
.short-url-visits-count__tooltip-list-item:not(:last-child) {
margin-bottom: .5rem;
}

View File

@@ -0,0 +1,75 @@
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { UncontrolledTooltip } from 'reactstrap';
import type { SelectedServer } from '../../../servers/data';
import { formatHumanFriendly, parseISO } from '../../../utils/helpers/date';
import { useElementRef } from '../../../utils/helpers/hooks';
import { prettify } from '../../../utils/helpers/numbers';
import type { ShortUrl } from '../data';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
import './ShortUrlVisitsCount.scss';
interface ShortUrlVisitsCountProps {
shortUrl?: ShortUrl | null;
selectedServer?: SelectedServer;
visitsCount: number;
active?: boolean;
}
export const ShortUrlVisitsCount = (
{ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps,
) => {
const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {};
const hasLimit = !!maxVisits || !!validSince || !!validUntil;
const visitsLink = (
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<strong
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
>
{prettify(visitsCount)}
</strong>
</ShortUrlDetailLink>
);
if (!hasLimit) {
return visitsLink;
}
const tooltipRef = useElementRef<HTMLElement>();
return (
<>
<span className="indivisible">
{visitsLink}
<small className="short-urls-visits-count__max-visits-control" ref={tooltipRef}>
{maxVisits && <> / {prettify(maxVisits)}</>}
<sup className="ms-1">
<FontAwesomeIcon icon={infoIcon} />
</sup>
</small>
</span>
<UncontrolledTooltip target={tooltipRef} placement="bottom">
<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>
</>
);
};

View File

@@ -0,0 +1,46 @@
import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../../../utils/DropdownBtn';
import { hasValue } from '../../../utils/utils';
import type { ShortUrlsFilter } from '../data';
interface ShortUrlsFilterDropdownProps {
onChange: (filters: ShortUrlsFilter) => void;
supportsDisabledFiltering: boolean;
selected?: ShortUrlsFilter;
className?: string;
}
export const ShortUrlsFilterDropdown = (
{ onChange, selected = {}, className, supportsDisabledFiltering }: ShortUrlsFilterDropdownProps,
) => {
const { excludeBots = false, excludeMaxVisitsReached = false, excludePastValidUntil = false } = selected;
const onFilterClick = (key: keyof ShortUrlsFilter) => () => onChange({ ...selected, [key]: !selected?.[key] });
return (
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
<DropdownItem header>Visits:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onFilterClick('excludeBots')}>Ignore visits from bots</DropdownItem>
{supportsDisabledFiltering && (
<>
<DropdownItem divider />
<DropdownItem header>Short URLs:</DropdownItem>
<DropdownItem active={excludeMaxVisitsReached} onClick={onFilterClick('excludeMaxVisitsReached')}>
Exclude with visits reached
</DropdownItem>
<DropdownItem active={excludePastValidUntil} onClick={onFilterClick('excludePastValidUntil')}>
Exclude enabled in the past
</DropdownItem>
</>
)}
<DropdownItem divider />
<DropdownItem
disabled={!hasValue(selected)}
onClick={() => onChange({ excludeBots: false, excludeMaxVisitsReached: false, excludePastValidUntil: false })}
>
<i>Clear filters</i>
</DropdownItem>
</DropdownBtn>
);
};

View File

@@ -0,0 +1,41 @@
@import '../../../utils/base';
@import '../../../utils/mixins/text-ellipsis';
@import '../../../utils/mixins/vertical-align';
.short-urls-row__cell.short-urls-row__cell {
vertical-align: middle !important;
}
.short-urls-row__cell--break {
word-break: break-all;
}
.short-urls-row__cell--indivisible {
@media (min-width: $lgMin) {
white-space: nowrap;
}
}
.short-urls-row__short-url-wrapper {
@media (max-width: $mdMax) {
word-break: break-all;
}
@media (min-width: $lgMin) {
@include text-ellipsis();
vertical-align: bottom;
display: inline-block;
max-width: 18rem;
}
}
.short-urls-row__copy-hint {
@include vertical-align(translateX(10px));
box-shadow: 0 3px 15px rgba(0, 0, 0, .25);
@media (max-width: $responsiveTableBreakpoint) {
@include vertical-align(translateX(calc(-100% - 20px)));
}
}

View File

@@ -0,0 +1,95 @@
import type { FC } from 'react';
import { useEffect, useRef } from 'react';
import { ExternalLink } from 'react-external-link';
import type { SelectedServer } from '../../../servers/data';
import type { Settings } from '../../../settings/reducers/settings';
import { CopyToClipboardIcon } from '../../../utils/CopyToClipboardIcon';
import { Time } from '../../../utils/dates/Time';
import type { TimeoutToggle } from '../../../utils/helpers/hooks';
import type { ColorGenerator } from '../../../utils/services/ColorGenerator';
import type { ShortUrl } from '../data';
import { useShortUrlsQuery } from './hooks';
import type { ShortUrlsRowMenuType } from './ShortUrlsRowMenu';
import { ShortUrlStatus } from './ShortUrlStatus';
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
import { Tags } from './Tags';
import './ShortUrlsRow.scss';
interface ShortUrlsRowProps {
onTagClick?: (tag: string) => void;
selectedServer: SelectedServer;
shortUrl: ShortUrl;
}
interface ShortUrlsRowConnectProps extends ShortUrlsRowProps {
settings: Settings;
}
export type ShortUrlsRowType = FC<ShortUrlsRowProps>;
export const ShortUrlsRow = (
ShortUrlsRowMenu: ShortUrlsRowMenuType,
colorGenerator: ColorGenerator,
useTimeoutToggle: TimeoutToggle,
) => ({ shortUrl, selectedServer, onTagClick, settings }: ShortUrlsRowConnectProps) => {
const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle();
const [active, setActive] = useTimeoutToggle(false, 500);
const isFirstRun = useRef(true);
const [{ excludeBots }] = useShortUrlsQuery();
const { visits } = settings;
const doExcludeBots = excludeBots ?? visits?.excludeBots;
useEffect(() => {
!isFirstRun.current && setActive();
isFirstRun.current = false;
}, [shortUrl.visitsSummary?.total, shortUrl.visitsSummary?.nonBots, shortUrl.visitsCount]);
return (
<tr className="responsive-table__row">
<td className="indivisible short-urls-row__cell responsive-table__cell" data-th="Created at">
<Time date={shortUrl.dateCreated} />
</td>
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
<span className="position-relative short-urls-row__cell--indivisible">
<span className="short-urls-row__short-url-wrapper">
<ExternalLink href={shortUrl.shortUrl} />
</span>
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
<span className="badge bg-warning text-black short-urls-row__copy-hint" hidden={!copiedToClipboard}>
Copied short URL!
</span>
</span>
</td>
<td
className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break"
data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}
>
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
</td>
{shortUrl.title && (
<td className="short-urls-row__cell responsive-table__cell short-urls-row__cell--break d-lg-none" data-th="Long URL">
<ExternalLink href={shortUrl.longUrl} />
</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">
<ShortUrlVisitsCount
visitsCount={(
doExcludeBots ? shortUrl.visitsSummary?.nonBots : shortUrl.visitsSummary?.total
) ?? shortUrl.visitsCount}
shortUrl={shortUrl}
selectedServer={selectedServer}
active={active}
/>
</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 text-end">
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
</td>
</tr>
);
};

View File

@@ -0,0 +1,54 @@
import {
faChartPie as pieChartIcon,
faEdit as editIcon,
faMinusCircle as deleteIcon,
faQrcode as qrIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../../servers/data';
import { useToggle } from '../../../utils/helpers/hooks';
import { RowDropdownBtn } from '../../../utils/RowDropdownBtn';
import type { ShortUrl, ShortUrlModalProps } from '../data';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
interface ShortUrlsRowMenuProps {
selectedServer: SelectedServer;
shortUrl: ShortUrl;
}
type ShortUrlModal = FC<ShortUrlModalProps>;
export const ShortUrlsRowMenu = (
DeleteShortUrlModal: ShortUrlModal,
QrCodeModal: ShortUrlModal,
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
const [isQrModalOpen,, openQrCodeModal, closeQrCodeModal] = useToggle();
const [isDeleteModalOpen,, openDeleteModal, closeDeleteModal] = useToggle();
return (
<RowDropdownBtn minWidth={190}>
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
</DropdownItem>
<DropdownItem onClick={openQrCodeModal}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
</DropdownItem>
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={closeQrCodeModal} />
<DropdownItem divider />
<DropdownItem className="dropdown-item--danger" onClick={openDeleteModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
</DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={closeDeleteModal} />
</RowDropdownBtn>
);
};
export type ShortUrlsRowMenuType = ReturnType<typeof ShortUrlsRowMenu>;

View File

@@ -0,0 +1,29 @@
import { isEmpty } from 'ramda';
import type { FC } from 'react';
import type { ColorGenerator } from '../../../utils/services/ColorGenerator';
import { Tag } from '../../tags/helpers/Tag';
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)}
/>
))}
</>
);
};

View File

@@ -0,0 +1,77 @@
import { isEmpty, pipe } from 'ramda';
import { useMemo } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import type { TagsFilteringMode } from '../../../api/types';
import { orderToString, stringToOrder } from '../../../utils/helpers/ordering';
import { parseQuery, stringifyQuery } from '../../../utils/helpers/query';
import type { BooleanString } from '../../../utils/utils';
import { parseOptionalBooleanToString } from '../../../utils/utils';
import type { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
interface ShortUrlsQueryCommon {
search?: string;
startDate?: string;
endDate?: string;
tagsMode?: TagsFilteringMode;
}
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
orderBy?: string;
tags?: string;
excludeBots?: BooleanString;
excludeMaxVisitsReached?: BooleanString;
excludePastValidUntil?: BooleanString;
}
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
orderBy?: ShortUrlsOrder;
tags: string[];
excludeBots?: boolean;
excludeMaxVisitsReached?: boolean;
excludePastValidUntil?: boolean;
}
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
const navigate = useNavigate();
const { search } = useLocation();
const { serverId = '' } = useParams<{ serverId: string }>();
const filtering = useMemo(
pipe(
() => parseQuery<ShortUrlsQuery>(search),
({ orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...rest }): ShortUrlsFiltering => {
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
const parsedTags = tags?.split(',') ?? [];
return {
...rest,
orderBy: parsedOrderBy,
tags: parsedTags,
excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined,
excludeMaxVisitsReached: excludeMaxVisitsReached !== undefined ? excludeMaxVisitsReached === 'true' : undefined,
excludePastValidUntil: excludePastValidUntil !== undefined ? excludePastValidUntil === 'true' : undefined,
};
},
),
[search],
);
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
const merged = { ...filtering, ...extra };
const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...mergedFiltering } = merged;
const query: ShortUrlsQuery = {
...mergedFiltering,
orderBy: orderBy && orderToString(orderBy),
tags: tags.length > 0 ? tags.join(',') : undefined,
excludeBots: parseOptionalBooleanToString(excludeBots),
excludeMaxVisitsReached: parseOptionalBooleanToString(excludeMaxVisitsReached),
excludePastValidUntil: parseOptionalBooleanToString(excludePastValidUntil),
};
const stringifiedQuery = stringifyQuery(query);
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
navigate(`/server/${serverId}/list-short-urls/1${queryString}`);
};
return [filtering, toFirstPageWithExtra];
};

View File

@@ -0,0 +1,49 @@
import { isNil } from 'ramda';
import type { ShortUrlCreationSettings } from '../../../settings/reducers/settings';
import type { OptionalString } from '../../../utils/utils';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { ShortUrl, ShortUrlData } from '../data';
export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => {
if (isNil(domain)) {
return shortUrl.shortCode === shortCode && !shortUrl.domain;
}
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
};
export const domainMatches = (shortUrl: ShortUrl, domain: string): boolean => {
if (!shortUrl.domain && domain === DEFAULT_DOMAIN) {
return true;
}
return shortUrl.domain === domain;
};
export const shortUrlDataFromShortUrl = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => {
const validateUrl = settings?.validateUrls ?? false;
if (!shortUrl) {
return { longUrl: '', validateUrl };
}
return {
longUrl: shortUrl.longUrl,
tags: shortUrl.tags,
title: shortUrl.title ?? undefined,
domain: shortUrl.domain ?? undefined,
validSince: shortUrl.meta.validSince ?? undefined,
validUntil: shortUrl.meta.validUntil ?? undefined,
maxVisits: shortUrl.meta.maxVisits ?? undefined,
crawlable: shortUrl.crawlable,
forwardQuery: shortUrl.forwardQuery,
deviceLongUrls: shortUrl.deviceLongUrls,
validateUrl,
};
};
const MULTI_SEGMENT_SEPARATOR = '__';
export const urlEncodeShortCode = (shortCode: string): string => shortCode.replaceAll('/', MULTI_SEGMENT_SEPARATOR);
export const urlDecodeShortCode = (shortCode: string): string => shortCode.replaceAll(MULTI_SEGMENT_SEPARATOR, '/');

View File

@@ -0,0 +1,28 @@
import type { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../../../../utils/DropdownBtn';
import type { QrErrorCorrection } from '../../../../utils/helpers/qrCodes';
interface QrErrorCorrectionDropdownProps {
errorCorrection: QrErrorCorrection;
setErrorCorrection: (errorCorrection: QrErrorCorrection) => void;
}
export const QrErrorCorrectionDropdown: FC<QrErrorCorrectionDropdownProps> = (
{ errorCorrection, setErrorCorrection },
) => (
<DropdownBtn text={`Error correction (${errorCorrection})`}>
<DropdownItem active={errorCorrection === 'L'} onClick={() => setErrorCorrection('L')}>
<b>L</b>ow
</DropdownItem>
<DropdownItem active={errorCorrection === 'M'} onClick={() => setErrorCorrection('M')}>
<b>M</b>edium
</DropdownItem>
<DropdownItem active={errorCorrection === 'Q'} onClick={() => setErrorCorrection('Q')}>
<b>Q</b>uartile
</DropdownItem>
<DropdownItem active={errorCorrection === 'H'} onClick={() => setErrorCorrection('H')}>
<b>H</b>igh
</DropdownItem>
</DropdownBtn>
);

View File

@@ -0,0 +1,16 @@
import type { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../../../../utils/DropdownBtn';
import type { QrCodeFormat } from '../../../../utils/helpers/qrCodes';
interface QrFormatDropdownProps {
format: QrCodeFormat;
setFormat: (format: QrCodeFormat) => void;
}
export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, setFormat }) => (
<DropdownBtn text={`Format (${format})`}>
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
</DropdownBtn>
);

View File

@@ -0,0 +1,70 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { ShortUrl, ShortUrlData } from '../data';
const REDUCER_PREFIX = 'shlink/shortUrlCreation';
export type ShortUrlCreation = {
saving: false;
saved: false;
error: false;
} | {
saving: true;
saved: false;
error: false;
} | {
saving: false;
saved: false;
error: true;
errorData?: ProblemDetailsError;
} | {
result: ShortUrl;
saving: false;
saved: true;
error: false;
};
export type CreateShortUrlAction = PayloadAction<ShortUrl>;
const initialState: ShortUrlCreation = {
saving: false,
saved: false,
error: false,
};
export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
`${REDUCER_PREFIX}/createShortUrl`,
(data: ShortUrlData, { getState }): Promise<ShortUrl> => buildShlinkApiClient(getState).createShortUrl(data),
);
export const shortUrlCreationReducerCreator = (createShortUrlThunk: ReturnType<typeof createShortUrl>) => {
const { reducer, actions } = createSlice({
name: REDUCER_PREFIX,
initialState: initialState as ShortUrlCreation, // Without this casting it infers type ShortUrlCreationWaiting
reducers: {
resetCreateShortUrl: () => initialState,
},
extraReducers: (builder) => {
builder.addCase(createShortUrlThunk.pending, () => ({ saving: true, saved: false, error: false }));
builder.addCase(
createShortUrlThunk.rejected,
(_, { error }) => ({ saving: false, saved: false, error: true, errorData: parseApiError(error) }),
);
builder.addCase(
createShortUrlThunk.fulfilled,
(_, { payload: result }) => ({ result, saving: false, saved: true, error: false }),
);
},
});
const { resetCreateShortUrl } = actions;
return {
reducer,
resetCreateShortUrl,
};
};

View File

@@ -0,0 +1,60 @@
import { createAction, createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { ShortUrl, ShortUrlIdentifier } from '../data';
const REDUCER_PREFIX = 'shlink/shortUrlDeletion';
export interface ShortUrlDeletion {
shortCode: string;
loading: boolean;
deleted: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlDeletion = {
shortCode: '',
loading: false,
deleted: false,
error: false,
};
export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
`${REDUCER_PREFIX}/deleteShortUrl`,
async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise<ShortUrlIdentifier> => {
const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState);
await shlinkDeleteShortUrl(shortCode, domain);
return { shortCode, domain };
},
);
export const shortUrlDeleted = createAction<ShortUrl>(`${REDUCER_PREFIX}/shortUrlDeleted`);
export const shortUrlDeletionReducerCreator = (deleteShortUrlThunk: ReturnType<typeof deleteShortUrl>) => {
const { actions, reducer } = createSlice({
name: REDUCER_PREFIX,
initialState,
reducers: {
resetDeleteShortUrl: () => initialState,
},
extraReducers: (builder) => {
builder.addCase(
deleteShortUrlThunk.pending,
(state) => ({ ...state, loading: true, error: false, deleted: false }),
);
builder.addCase(deleteShortUrlThunk.rejected, (state, { error }) => (
{ ...state, errorData: parseApiError(error), loading: false, error: true, deleted: false }
));
builder.addCase(deleteShortUrlThunk.fulfilled, (state, { payload }) => (
{ ...state, shortCode: payload.shortCode, loading: false, error: false, deleted: true }
));
},
});
const { resetDeleteShortUrl } = actions;
return { reducer, resetDeleteShortUrl };
};

View File

@@ -0,0 +1,51 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { ShortUrl, ShortUrlIdentifier } from '../data';
import { shortUrlMatches } from '../helpers';
const REDUCER_PREFIX = 'shlink/shortUrlDetail';
export interface ShortUrlDetail {
shortUrl?: ShortUrl;
loading: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export type ShortUrlDetailAction = PayloadAction<ShortUrl>;
const initialState: ShortUrlDetail = {
loading: false,
error: false,
};
export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
const getShortUrlDetail = createAsyncThunk(
`${REDUCER_PREFIX}/getShortUrlDetail`,
async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise<ShortUrl> => {
const { shortUrlsList } = getState();
const alreadyLoaded = shortUrlsList?.shortUrls?.data.find((url) => shortUrlMatches(url, shortCode, domain));
return alreadyLoaded ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
},
);
const { reducer } = createSlice({
name: REDUCER_PREFIX,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(getShortUrlDetail.pending, () => ({ loading: true, error: false }));
builder.addCase(getShortUrlDetail.rejected, (_, { error }) => (
{ loading: false, error: true, errorData: parseApiError(error) }
));
builder.addCase(getShortUrlDetail.fulfilled, (_, { payload: shortUrl }) => ({ ...initialState, shortUrl }));
},
});
return { reducer, getShortUrlDetail };
};

View File

@@ -0,0 +1,54 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { EditShortUrlData, ShortUrl, ShortUrlIdentifier } from '../data';
const REDUCER_PREFIX = 'shlink/shortUrlEdition';
export interface ShortUrlEdition {
shortUrl?: ShortUrl;
saving: boolean;
saved: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface EditShortUrl extends ShortUrlIdentifier {
data: EditShortUrlData;
}
export type ShortUrlEditedAction = PayloadAction<ShortUrl>;
const initialState: ShortUrlEdition = {
saving: false,
saved: false,
error: false,
};
export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
`${REDUCER_PREFIX}/editShortUrl`,
({ shortCode, domain, data }: EditShortUrl, { getState }): Promise<ShortUrl> => {
const { updateShortUrl } = buildShlinkApiClient(getState);
return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates
},
);
export const shortUrlEditionReducerCreator = (editShortUrlThunk: ReturnType<typeof editShortUrl>) => createSlice({
name: REDUCER_PREFIX,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(editShortUrlThunk.pending, (state) => ({ ...state, saving: true, error: false, saved: false }));
builder.addCase(
editShortUrlThunk.rejected,
(state, { error }) => ({ ...state, saving: false, error: true, saved: false, errorData: parseApiError(error) }),
);
builder.addCase(
editShortUrlThunk.fulfilled,
(_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false, saved: true }),
);
},
});

View File

@@ -0,0 +1,115 @@
import { createSlice } from '@reduxjs/toolkit';
import { assocPath, last, pipe, reject } from 'ramda';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../../api/types';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import { createNewVisits } from '../../visits/reducers/visitCreation';
import type { ShortUrl } from '../data';
import { shortUrlMatches } from '../helpers';
import type { createShortUrl } from './shortUrlCreation';
import { shortUrlDeleted } from './shortUrlDeletion';
import type { editShortUrl } from './shortUrlEdition';
const REDUCER_PREFIX = 'shlink/shortUrlsList';
export const ITEMS_IN_OVERVIEW_PAGE = 5;
export interface ShortUrlsList {
shortUrls?: ShlinkShortUrlsResponse;
loading: boolean;
error: boolean;
}
const initialState: ShortUrlsList = {
loading: true,
error: false,
};
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
`${REDUCER_PREFIX}/listShortUrls`,
(params: ShlinkShortUrlsListParams | void, { getState }): Promise<ShlinkShortUrlsResponse> => {
const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState);
return shlinkListShortUrls(params ?? {});
},
);
export const shortUrlsListReducerCreator = (
listShortUrlsThunk: ReturnType<typeof listShortUrls>,
editShortUrlThunk: ReturnType<typeof editShortUrl>,
createShortUrlThunk: ReturnType<typeof createShortUrl>,
) => createSlice({
name: REDUCER_PREFIX,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(listShortUrlsThunk.pending, (state) => ({ ...state, loading: true, error: false }));
builder.addCase(listShortUrlsThunk.rejected, () => ({ loading: false, error: true }));
builder.addCase(
listShortUrlsThunk.fulfilled,
(_, { payload: shortUrls }) => ({ loading: false, error: false, shortUrls }),
);
builder.addCase(
createShortUrlThunk.fulfilled,
pipe(
// The only place where the list and the creation form coexist is the overview page.
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
// We can also remove the items above the amount that is displayed there.
(state, { payload }) => (!state.shortUrls ? state : assocPath(
['shortUrls', 'data'],
[payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)],
state,
)),
(state: ShortUrlsList) => (!state.shortUrls ? state : assocPath(
['shortUrls', 'pagination', 'totalItems'],
state.shortUrls.pagination.totalItems + 1,
state,
)),
),
);
builder.addCase(
editShortUrlThunk.fulfilled,
(state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath(
['shortUrls', 'data'],
state.shortUrls.data.map((shortUrl) => {
const { shortCode, domain } = editedShortUrl;
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
}),
state,
)),
);
builder.addCase(
shortUrlDeleted,
pipe(
(state, { payload }) => (!state.shortUrls ? state : assocPath(
['shortUrls', 'data'],
reject<ShortUrl, ShortUrl[]>((shortUrl) =>
shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data),
state,
)),
(state) => (!state.shortUrls ? state : assocPath(
['shortUrls', 'pagination', 'totalItems'],
state.shortUrls.pagination.totalItems - 1,
state,
)),
),
);
builder.addCase(
createNewVisits,
(state, { payload }) => assocPath(
['shortUrls', 'data'],
state.shortUrls?.data?.map(
// Find the last of the new visit for this short URL, and pick its short URL. It will have an up-to-date amount of visits.
(currentShortUrl) => last(
payload.createdVisits.filter(
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
),
)?.shortUrl ?? currentShortUrl,
),
state,
),
);
},
});

View File

@@ -0,0 +1,100 @@
import type Bottle from 'bottlejs';
import { prop } from 'ramda';
import type { ConnectDecorator } from '../../../container/types';
import { CreateShortUrl } from '../CreateShortUrl';
import { EditShortUrl } from '../EditShortUrl';
import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult';
import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal';
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
import { QrCodeModal } from '../helpers/QrCodeModal';
import { ShortUrlsRow } from '../helpers/ShortUrlsRow';
import { ShortUrlsRowMenu } from '../helpers/ShortUrlsRowMenu';
import { createShortUrl, shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation';
import { deleteShortUrl, shortUrlDeleted, shortUrlDeletionReducerCreator } from '../reducers/shortUrlDeletion';
import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail';
import { editShortUrl, shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition';
import { listShortUrls, shortUrlsListReducerCreator } from '../reducers/shortUrlsList';
import { ShortUrlForm } from '../ShortUrlForm';
import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar';
import { ShortUrlsList } from '../ShortUrlsList';
import { ShortUrlsTable } from '../ShortUrlsTable';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar');
bottle.decorator('ShortUrlsList', connect(
['selectedServer', 'mercureInfo', 'shortUrlsList', 'settings'],
['listShortUrls', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle');
bottle.decorator('ShortUrlsRow', connect(['settings']));
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle');
bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');
bottle.decorator(
'CreateShortUrl',
connect(['shortUrlCreation', 'selectedServer', 'settings'], ['createShortUrl', 'resetCreateShortUrl']),
);
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
bottle.decorator('EditShortUrl', connect(
['shortUrlDetail', 'shortUrlEdition', 'selectedServer', 'settings'],
['getShortUrlDetail', 'editShortUrl'],
));
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect(
['shortUrlDeletion'],
['deleteShortUrl', 'shortUrlDeleted', 'resetDeleteShortUrl'],
));
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
bottle.decorator('QrCodeModal', connect(['selectedServer']));
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector');
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer']));
// Reducers
bottle.serviceFactory(
'shortUrlsListReducerCreator',
shortUrlsListReducerCreator,
'listShortUrls',
'editShortUrl',
'createShortUrl',
);
bottle.serviceFactory('shortUrlsListReducer', prop('reducer'), 'shortUrlsListReducerCreator');
bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'createShortUrl');
bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator');
bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'editShortUrl');
bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator');
bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'deleteShortUrl');
bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator');
bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'buildShlinkApiClient');
bottle.serviceFactory('shortUrlDetailReducer', prop('reducer'), 'shortUrlDetailReducerCreator');
// Actions
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator');
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator');
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator');
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
};

View File

@@ -0,0 +1,103 @@
import { pipe } from 'ramda';
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Row } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError';
import { boundToMercureHub } from '../../mercure/helpers/boundToMercureHub';
import { Topics } from '../../mercure/helpers/Topics';
import type { SelectedServer } from '../../servers/data';
import type { Settings } from '../../settings/reducers/settings';
import { determineOrderDir, sortList } from '../../utils/helpers/ordering';
import { Message } from '../../utils/Message';
import { OrderingDropdown } from '../../utils/OrderingDropdown';
import { Result } from '../../utils/Result';
import { SearchField } from '../../utils/SearchField';
import type { SimplifiedTag } from './data';
import type { TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps';
import { TAGS_ORDERABLE_FIELDS } from './data/TagsListChildrenProps';
import type { TagsList as TagsListState } from './reducers/tagsList';
import type { TagsTableProps } from './TagsTable';
export interface TagsListProps {
filterTags: (searchTerm: string) => void;
forceListTags: Function;
tagsList: TagsListState;
selectedServer: SelectedServer;
settings: Settings;
}
export const TagsList = (TagsTable: FC<TagsTableProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => {
const [order, setOrder] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
const resolveSortedTags = pipe(
() => tagsList.filteredTags.map((tag): SimplifiedTag => {
const theTag = tagsList.stats[tag];
const visits = (
settings.visits?.excludeBots ? theTag?.visitsSummary?.nonBots : theTag?.visitsSummary?.total
) ?? theTag?.visitsCount ?? 0;
return {
tag,
visits,
shortUrls: theTag?.shortUrlsCount ?? 0,
};
}),
(simplifiedTags) => sortList<SimplifiedTag>(simplifiedTags, order),
);
useEffect(() => {
forceListTags();
}, []);
if (tagsList.loading) {
return <Message loading />;
}
if (tagsList.error) {
return (
<Result type="error">
<ShlinkApiError errorData={tagsList.errorData} fallbackMessage="Error loading tags :(" />
</Result>
);
}
const orderByColumn = (field: TagsOrderableFields) => () => {
const dir = determineOrderDir(field, order.field, order.dir);
setOrder({ field: dir ? field : undefined, dir });
};
const renderContent = () => {
if (tagsList.filteredTags.length < 1) {
return <Message>No tags found</Message>;
}
const sortedTags = resolveSortedTags();
return (
<TagsTable
sortedTags={sortedTags}
selectedServer={selectedServer}
currentOrder={order}
orderByColumn={orderByColumn}
/>
);
};
return (
<>
<SearchField className="mb-3" onChange={filterTags} />
<Row className="mb-3">
<div className="col-lg-6 offset-lg-6">
<OrderingDropdown
items={TAGS_ORDERABLE_FIELDS}
order={order}
onChange={(field, dir) => setOrder({ field, dir })}
/>
</div>
</Row>
{renderContent()}
</>
);
}, () => [Topics.visits]);

View File

@@ -0,0 +1,10 @@
@import '../../utils/base';
@import '../../utils/mixins/sticky-cell';
.tags-table__header-cell.tags-table__header-cell {
@include sticky-cell(false);
top: $headerHeight;
position: sticky;
cursor: pointer;
}

View File

@@ -0,0 +1,71 @@
import { splitEvery } from 'ramda';
import type { FC } from 'react';
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { SimplePaginator } from '../../common/SimplePaginator';
import { useQueryState } from '../../utils/helpers/hooks';
import { parseQuery } from '../../utils/helpers/query';
import { SimpleCard } from '../../utils/SimpleCard';
import { TableOrderIcon } from '../../utils/table/TableOrderIcon';
import type { TagsListChildrenProps, TagsOrder, TagsOrderableFields } from './data/TagsListChildrenProps';
import type { TagsTableRowProps } from './TagsTableRow';
import './TagsTable.scss';
export interface TagsTableProps extends TagsListChildrenProps {
orderByColumn: (field: TagsOrderableFields) => () => void;
currentOrder: TagsOrder;
}
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
{ sortedTags, selectedServer, orderByColumn, currentOrder }: TagsTableProps,
) => {
const isFirstLoad = useRef(true);
const { search } = useLocation();
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(search);
const [page, setPage] = useQueryState<number>('page', Number(pageFromQuery));
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
const showPaginator = pages.length > 1;
const currentPage = pages[page - 1] ?? [];
useEffect(() => {
!isFirstLoad.current && setPage(1);
isFirstLoad.current = false;
}, [sortedTags]);
useEffect(() => {
scrollTo(0, 0);
}, [page]);
return (
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>
Tag <TableOrderIcon currentOrder={currentOrder} field="tag" />
</th>
<th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('shortUrls')}>
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
</th>
<th className="tags-table__header-cell text-lg-end" onClick={orderByColumn('visits')}>
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
</th>
<th aria-label="Options" className="tags-table__header-cell" />
</tr>
<tr><th aria-label="Separator" colSpan={4} className="p-0 border-top-0" /></tr>
</thead>
<tbody>
{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} />)}
</tbody>
</table>
{showPaginator && (
<div className="sticky-card-paginator">
<SimplePaginator pagesCount={pages.length} currentPage={page} setCurrentPage={setPage} />
</div>
)}
</SimpleCard>
);
};

View File

@@ -0,0 +1,59 @@
import { faPencilAlt as editIcon, faTrash as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../servers/data';
import { getServerId } from '../../servers/data';
import { useToggle } from '../../utils/helpers/hooks';
import { prettify } from '../../utils/helpers/numbers';
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
import type { SimplifiedTag, TagModalProps } from './data';
import { TagBullet } from './helpers/TagBullet';
export interface TagsTableRowProps {
tag: SimplifiedTag;
selectedServer: SelectedServer;
}
export const TagsTableRow = (
DeleteTagConfirmModal: FC<TagModalProps>,
EditTagModal: FC<TagModalProps>,
colorGenerator: ColorGenerator,
) => ({ tag, selectedServer }: TagsTableRowProps) => {
const [isDeleteModalOpen, toggleDelete] = useToggle();
const [isEditModalOpen, toggleEdit] = useToggle();
const serverId = getServerId(selectedServer);
return (
<tr className="responsive-table__row">
<th className="responsive-table__cell" data-th="Tag">
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
</th>
<td className="responsive-table__cell text-lg-end" data-th="Short URLs">
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
{prettify(tag.shortUrls)}
</Link>
</td>
<td className="responsive-table__cell text-lg-end" data-th="Visits">
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}>
{prettify(tag.visits)}
</Link>
</td>
<td className="responsive-table__cell text-lg-end">
<RowDropdownBtn>
<DropdownItem onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} fixedWidth className="me-1" /> Edit
</DropdownItem>
<DropdownItem onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="me-1" /> Delete
</DropdownItem>
</RowDropdownBtn>
</td>
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
</tr>
);
};

View File

@@ -0,0 +1,18 @@
import type { SelectedServer } from '../../../servers/data';
import type { Order } from '../../../utils/helpers/ordering';
import type { SimplifiedTag } from './index';
export const TAGS_ORDERABLE_FIELDS = {
tag: 'Tag',
shortUrls: 'Short URLs',
visits: 'Visits',
};
export type TagsOrderableFields = keyof typeof TAGS_ORDERABLE_FIELDS;
export type TagsOrder = Order<TagsOrderableFields>;
export interface TagsListChildrenProps {
sortedTags: SimplifiedTag[];
selectedServer: SelectedServer;
}

View File

@@ -0,0 +1,15 @@
import type { ShlinkTagsStats } from '../../../api/types';
export type TagStats = Omit<ShlinkTagsStats, 'tag'>;
export interface TagModalProps {
tag: string;
isOpen: boolean;
toggle: () => void;
}
export interface SimplifiedTag {
tag: string;
shortUrls: number;
visits: number;
}

View File

@@ -0,0 +1,41 @@
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ShlinkApiError } from '../../../api/ShlinkApiError';
import { Result } from '../../../utils/Result';
import type { TagModalProps } from '../data';
import type { TagDeletion } from '../reducers/tagDelete';
interface DeleteTagConfirmModalProps extends TagModalProps {
deleteTag: (tag: string) => Promise<void>;
tagDeleted: (tag: string) => void;
tagDelete: TagDeletion;
}
export const DeleteTagConfirmModal = (
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
) => {
const { deleting, error, deleted, errorData } = tagDelete;
const doDelete = async () => {
await deleteTag(tag);
toggle();
};
return (
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={() => deleted && tagDeleted(tag)}>
<ModalHeader toggle={toggle} className="text-danger">Delete tag</ModalHeader>
<ModalBody>
Are you sure you want to delete tag <b>{tag}</b>?
{error && (
<Result type="error" small className="mt-2">
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while deleting the tag :(" />
</Result>
)}
</ModalBody>
<ModalFooter>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="danger" disabled={deleting} onClick={doDelete}>
{deleting ? 'Deleting tag...' : 'Delete tag'}
</Button>
</ModalFooter>
</Modal>
);
};

View File

@@ -0,0 +1,11 @@
.edit-tag-modal__color-picker-toggle {
cursor: pointer;
}
.edit-tag-modal__color-icon {
color: #fff;
}
.edit-tag-modal__popover.edit-tag-modal__popover {
border-radius: .6rem;
}

View File

@@ -0,0 +1,82 @@
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { pipe } from 'ramda';
import { useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import { Button, Input, InputGroup, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { ShlinkApiError } from '../../../api/ShlinkApiError';
import { useToggle } from '../../../utils/helpers/hooks';
import { Result } from '../../../utils/Result';
import type { ColorGenerator } from '../../../utils/services/ColorGenerator';
import { handleEventPreventingDefault } from '../../../utils/utils';
import type { TagModalProps } from '../data';
import type { EditTag, TagEdition } from '../reducers/tagEdit';
import './EditTagModal.scss';
interface EditTagModalProps extends TagModalProps {
tagEdit: TagEdition;
editTag: (editTag: EditTag) => Promise<void>;
tagEdited: (tagEdited: EditTag) => void;
}
export const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
{ tag, editTag, toggle, tagEdited, isOpen, tagEdit }: EditTagModalProps,
) => {
const [newTagName, setNewTagName] = useState(tag);
const [color, setColor] = useState(getColorForKey(tag));
const [showColorPicker, toggleColorPicker, , hideColorPicker] = useToggle();
const { editing, error, edited, errorData } = tagEdit;
const saveTag = handleEventPreventingDefault(
async () => {
await editTag({ oldName: tag, newName: newTagName, color });
toggle();
},
);
const onClosed = pipe(hideColorPicker, () => edited && tagEdited({ oldName: tag, newName: newTagName, color }));
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
<form name="editTag" onSubmit={saveTag}>
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
<ModalBody>
<InputGroup>
<div
id="colorPickerBtn"
className="input-group-text edit-tag-modal__color-picker-toggle"
style={{ backgroundColor: color, borderColor: color }}
onClick={toggleColorPicker}
>
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
</div>
<Popover
isOpen={showColorPicker}
toggle={toggleColorPicker}
target="colorPickerBtn"
placement="right"
hideArrow
popperClassName="edit-tag-modal__popover"
>
<HexColorPicker color={color} onChange={setColor} />
</Popover>
<Input
value={newTagName}
placeholder="Tag"
required
onChange={({ target }) => setNewTagName(target.value)}
/>
</InputGroup>
{error && (
<Result type="error" small className="mt-2">
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while editing the tag :(" />
</Result>
)}
</ModalBody>
<ModalFooter>
<Button type="button" color="link" onClick={toggle}>Cancel</Button>
<Button color="primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</Button>
</ModalFooter>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,24 @@
.tag {
color: #fff;
}
.tag--light-bg {
color: #222 !important;
}
.tag:not(:last-child) {
margin-right: 3px;
}
.tag__close-selected-tag.tag__close-selected-tag {
font-size: inherit;
color: inherit;
opacity: 1;
cursor: pointer;
margin-left: 5px;
}
.tag__close-selected-tag.tag__close-selected-tag:hover {
color: inherit !important;
opacity: 1 !important;
}

View File

@@ -0,0 +1,26 @@
import classNames from 'classnames';
import type { FC, MouseEventHandler, PropsWithChildren } from 'react';
import type { ColorGenerator } from '../../../utils/services/ColorGenerator';
import './Tag.scss';
type TagProps = PropsWithChildren<{
colorGenerator: ColorGenerator;
text: string;
className?: string;
clearable?: boolean;
onClick?: MouseEventHandler;
onClose?: MouseEventHandler;
}>;
export const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
<span
className={classNames('badge tag', className, { 'tag--light-bg': colorGenerator.isColorLightForKey(text) })}
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
onClick={onClick}
>
{children ?? text}
{clearable && (
<span aria-label={`Remove ${text}`} className="close tag__close-selected-tag" onClick={onClose}>&times;</span>
)}
</span>
);

View File

@@ -0,0 +1,10 @@
.tag-bullet {
$width: 20px;
border-radius: 50%;
width: $width;
height: $width;
display: inline-block;
vertical-align: -4px;
margin-right: 7px;
}

View File

@@ -0,0 +1,14 @@
import type { ColorGenerator } from '../../../utils/services/ColorGenerator';
import './TagBullet.scss';
interface TagBulletProps {
tag: string;
colorGenerator: ColorGenerator;
}
export const TagBullet = ({ tag, colorGenerator }: TagBulletProps) => (
<div
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
className="tag-bullet"
/>
);

View File

@@ -0,0 +1,71 @@
import { useEffect } from 'react';
import type { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
import ReactTags from 'react-tag-autocomplete';
import type { Settings } from '../../../settings/reducers/settings';
import type { ColorGenerator } from '../../../utils/services/ColorGenerator';
import type { TagsList } from '../reducers/tagsList';
import { Tag } from './Tag';
import { TagBullet } from './TagBullet';
export interface TagsSelectorProps {
selectedTags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
allowNew?: boolean;
}
interface TagsSelectorConnectProps extends TagsSelectorProps {
listTags: () => void;
tagsList: TagsList;
settings: Settings;
}
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
export const TagsSelector = (colorGenerator: ColorGenerator) => (
{ selectedTags, onChange, placeholder, listTags, tagsList, settings, allowNew = true }: TagsSelectorConnectProps,
) => {
useEffect(() => {
listTags();
}, []);
const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith';
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
<>
<TagBullet tag={`${item.name}`} colorGenerator={colorGenerator} />
{item.name}
</>
);
return (
<ReactTags
tags={selectedTags.map(toComponentTag)}
tagComponent={ReactTagsTag}
suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toComponentTag)}
suggestionComponent={ReactTagsSuggestion}
allowNew={allowNew}
addOnBlur
placeholderText={placeholder ?? 'Add tags to the URL'}
minQueryLength={1}
delimiters={['Enter', 'Tab', ',']}
suggestionsTransform={
searchMode === 'includes'
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
: undefined
}
onDelete={(removedTagIndex) => {
const tagsCopy = [...selectedTags];
tagsCopy.splice(removedTagIndex, 1);
onChange(tagsCopy);
}}
onAddition={({ name: newTag }) => onChange(
// * Avoid duplicated tags (thanks to the Set),
// * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once.
[...new Set([...selectedTags, ...newTag.toLowerCase().split(',')])],
)}
/>
);
};

View File

@@ -0,0 +1,45 @@
import { createAction, createSlice } from '@reduxjs/toolkit';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils';
import { createAsyncThunk } from '../../../utils/helpers/redux';
const REDUCER_PREFIX = 'shlink/tagDelete';
export interface TagDeletion {
deleting: boolean;
deleted: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
const initialState: TagDeletion = {
deleting: false,
deleted: false,
error: false,
};
export const tagDeleted = createAction<string>(`${REDUCER_PREFIX}/tagDeleted`);
export const tagDeleteReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
const deleteTag = createAsyncThunk(`${REDUCER_PREFIX}/deleteTag`, async (tag: string, { getState }): Promise<void> => {
const { deleteTags } = buildShlinkApiClient(getState);
await deleteTags([tag]);
});
const { reducer } = createSlice({
name: REDUCER_PREFIX,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(deleteTag.pending, () => ({ deleting: true, deleted: false, error: false }));
builder.addCase(
deleteTag.rejected,
(_, { error }) => ({ deleting: false, deleted: false, error: true, errorData: parseApiError(error) }),
);
builder.addCase(deleteTag.fulfilled, () => ({ deleting: false, deleted: true, error: false }));
},
});
return { reducer, deleteTag };
};

View File

@@ -0,0 +1,67 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createAction, createSlice } from '@reduxjs/toolkit';
import { pick } from 'ramda';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { ColorGenerator } from '../../../utils/services/ColorGenerator';
const REDUCER_PREFIX = 'shlink/tagEdit';
export interface TagEdition {
oldName?: string;
newName?: string;
editing: boolean;
edited: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface EditTag {
oldName: string;
newName: string;
color: string;
}
export type EditTagAction = PayloadAction<EditTag>;
const initialState: TagEdition = {
editing: false,
edited: false,
error: false,
};
export const tagEdited = createAction<EditTag>(`${REDUCER_PREFIX}/tagEdited`);
export const editTag = (
buildShlinkApiClient: ShlinkApiClientBuilder,
colorGenerator: ColorGenerator,
) => createAsyncThunk(
`${REDUCER_PREFIX}/editTag`,
async ({ oldName, newName, color }: EditTag, { getState }): Promise<EditTag> => {
await buildShlinkApiClient(getState).editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color);
return { oldName, newName, color };
},
);
export const tagEditReducerCreator = (editTagThunk: ReturnType<typeof editTag>) => createSlice({
name: REDUCER_PREFIX,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(editTagThunk.pending, () => ({ editing: true, edited: false, error: false }));
builder.addCase(
editTagThunk.rejected,
(_, { error }) => ({ editing: false, edited: false, error: true, errorData: parseApiError(error) }),
);
builder.addCase(editTagThunk.fulfilled, (_, { payload }) => ({
...pick(['oldName', 'newName'], payload),
editing: false,
edited: true,
error: false,
}));
},
});

View File

@@ -0,0 +1,155 @@
import { createAction, createSlice } from '@reduxjs/toolkit';
import { isEmpty, reject } from 'ramda';
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import type { ShlinkTags } from '../../../api/types';
import type { ProblemDetailsError } from '../../../api/types/errors';
import { parseApiError } from '../../../api/utils';
import { supportedFeatures } from '../../../utils/helpers/features';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { createShortUrl } from '../../short-urls/reducers/shortUrlCreation';
import { createNewVisits } from '../../visits/reducers/visitCreation';
import type { CreateVisit } from '../../visits/types';
import type { TagStats } from '../data';
import { tagDeleted } from './tagDelete';
import { tagEdited } from './tagEdit';
const REDUCER_PREFIX = 'shlink/tagsList';
type TagsStatsMap = Record<string, TagStats>;
export interface TagsList {
tags: string[];
filteredTags: string[];
stats: TagsStatsMap;
loading: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
interface ListTags {
tags: string[];
stats: TagsStatsMap;
}
const initialState: TagsList = {
tags: [],
filteredTags: [],
stats: {},
loading: false,
error: false,
};
type TagIncreaseRecord = Record<string, { bots: number; nonBots: number }>;
type TagIncrease = [string, { bots: number; nonBots: number }];
const renameTag = (oldName: string, newName: string) => (tag: string) => (tag === oldName ? newName : tag);
const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags);
const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags.reduce((theStats, [tag, increase]) => {
if (!theStats[tag]) {
return theStats;
}
const { bots, nonBots } = increase;
const tagStats = theStats[tag];
return {
...theStats,
[tag]: {
...tagStats,
visitsSummary: tagStats.visitsSummary && {
total: tagStats.visitsSummary.total + bots + nonBots,
bots: tagStats.visitsSummary.bots + bots,
nonBots: tagStats.visitsSummary.nonBots + nonBots,
},
visitsCount: tagStats.visitsCount + bots + nonBots,
},
};
}, { ...stats });
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
createdVisits.reduce<TagIncreaseRecord>((acc, { shortUrl, visit }) => {
shortUrl?.tags.forEach((tag) => {
if (!acc[tag]) {
acc[tag] = { bots: 0, nonBots: 0 };
}
if (visit.potentialBot) {
acc[tag].bots += 1;
} else {
acc[tag].nonBots += 1;
}
});
return acc;
}, {}),
);
export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => createAsyncThunk(
`${REDUCER_PREFIX}/listTags`,
async (_: void, { getState }): Promise<ListTags> => {
const { tagsList, selectedServer } = getState();
if (!force && !isEmpty(tagsList.tags)) {
return tagsList;
}
const { listTags: shlinkListTags, tagsStats } = buildShlinkApiClient(getState);
const { tags, stats }: ShlinkTags = await (
supportedFeatures.tagsStats(selectedServer) ? tagsStats() : shlinkListTags()
);
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, ...rest }) => {
acc[tag] = rest;
return acc;
}, {});
return { tags, stats: processedStats };
},
);
export const filterTags = createAction<string>(`${REDUCER_PREFIX}/filterTags`);
export const tagsListReducerCreator = (
listTagsThunk: ReturnType<typeof listTags>,
createShortUrlThunk: ReturnType<typeof createShortUrl>,
) => createSlice({
name: REDUCER_PREFIX,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(filterTags, (state, { payload: searchTerm }) => ({
...state,
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())),
}));
builder.addCase(listTagsThunk.pending, (state) => ({ ...state, loading: true, error: false }));
builder.addCase(listTagsThunk.rejected, (_, { error }) => (
{ ...initialState, error: true, errorData: parseApiError(error) }
));
builder.addCase(listTagsThunk.fulfilled, (_, { payload }) => (
{ ...initialState, stats: payload.stats, tags: payload.tags, filteredTags: payload.tags }
));
builder.addCase(tagDeleted, ({ tags, filteredTags, ...rest }, { payload: tag }) => ({
...rest,
tags: rejectTag(tags, tag),
filteredTags: rejectTag(filteredTags, tag),
}));
builder.addCase(tagEdited, ({ tags, filteredTags, stats, ...rest }, { payload }) => ({
...rest,
stats: {
...stats,
[payload.newName]: stats[payload.oldName],
},
tags: tags.map(renameTag(payload.oldName, payload.newName)).sort(),
filteredTags: filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(),
}));
builder.addCase(createNewVisits, (state, { payload }) => ({
...state,
stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats),
}));
builder.addCase(createShortUrlThunk.fulfilled, ({ tags: stateTags, ...rest }, { payload }) => ({
...rest,
tags: stateTags.concat(payload.tags.filter((tag: string) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ]
}));
},
});

View File

@@ -0,0 +1,60 @@
import type { IContainer } from 'bottlejs';
import type Bottle from 'bottlejs';
import { prop } from 'ramda';
import type { ConnectDecorator } from '../../../container/types';
import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal';
import { EditTagModal } from '../helpers/EditTagModal';
import { TagsSelector } from '../helpers/TagsSelector';
import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete';
import { editTag, tagEdited, tagEditReducerCreator } from '../reducers/tagEdit';
import { filterTags, listTags, tagsListReducerCreator } from '../reducers/tagsList';
import { TagsList } from '../TagsList';
import { TagsTable } from '../TagsTable';
import { TagsTableRow } from '../TagsTableRow';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect(['tagsList', 'settings'], ['listTags']));
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect(['tagDelete'], ['deleteTag', 'tagDeleted']));
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited']));
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.decorator('TagsTableRow', connect(['settings']));
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
bottle.serviceFactory('TagsList', TagsList, 'TagsTable');
bottle.decorator('TagsList', connect(
['tagsList', 'selectedServer', 'mercureInfo', 'settings'],
['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'],
));
// Reducers
bottle.serviceFactory('tagEditReducerCreator', tagEditReducerCreator, 'editTag');
bottle.serviceFactory('tagEditReducer', prop('reducer'), 'tagEditReducerCreator');
bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient');
bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator');
bottle.serviceFactory('tagsListReducerCreator', tagsListReducerCreator, 'listTags', 'createShortUrl');
bottle.serviceFactory('tagsListReducer', prop('reducer'), 'tagsListReducerCreator');
// Actions
const listTagsActionFactory = (force: boolean) =>
({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force);
bottle.factory('listTags', listTagsActionFactory(false));
bottle.factory('forceListTags', listTagsActionFactory(true));
bottle.serviceFactory('filterTags', () => filterTags);
bottle.serviceFactory('deleteTag', prop('deleteTag'), 'tagDeleteReducerCreator');
bottle.serviceFactory('tagDeleted', () => tagDeleted);
bottle.serviceFactory('editTag', editTag, 'buildShlinkApiClient', 'ColorGenerator');
bottle.serviceFactory('tagEdited', () => tagEdited);
};

View File

@@ -0,0 +1,44 @@
import { useParams } from 'react-router-dom';
import type { ShlinkVisitsParams } from '../../api/types';
import type { ReportExporter } from '../../common/services/ReportExporter';
import { boundToMercureHub } from '../../mercure/helpers/boundToMercureHub';
import { Topics } from '../../mercure/helpers/Topics';
import { useGoBack } from '../../utils/helpers/hooks';
import type { DomainVisits as DomainVisitsState, LoadDomainVisits } from './reducers/domainVisits';
import type { NormalizedVisit } from './types';
import type { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader';
import { VisitsStats } from './VisitsStats';
export interface DomainVisitsProps extends CommonVisitsProps {
getDomainVisits: (params: LoadDomainVisits) => void;
domainVisits: DomainVisitsState;
cancelGetDomainVisits: () => void;
}
export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getDomainVisits,
domainVisits,
cancelGetDomainVisits,
settings,
}: DomainVisitsProps) => {
const goBack = useGoBack();
const { domain = '' } = useParams();
const [authority, domainId = authority] = domain.split('_');
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
getDomainVisits({ domain: domainId, query: toApiParams(params), doIntervalFallback });
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits);
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetDomainVisits}
visitsInfo={domainVisits}
settings={settings}
exportCsv={exportCsv}
>
<VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} />
</VisitsStats>
);
}, () => [Topics.visits]);

View File

@@ -0,0 +1,40 @@
import type { ReportExporter } from '../../common/services/ReportExporter';
import { boundToMercureHub } from '../../mercure/helpers/boundToMercureHub';
import { Topics } from '../../mercure/helpers/Topics';
import { useGoBack } from '../../utils/helpers/hooks';
import type { LoadVisits, VisitsInfo } from './reducers/types';
import type { NormalizedVisit, VisitsParams } from './types';
import type { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader';
import { VisitsStats } from './VisitsStats';
export interface NonOrphanVisitsProps extends CommonVisitsProps {
getNonOrphanVisits: (params: LoadVisits) => void;
nonOrphanVisits: VisitsInfo;
cancelGetNonOrphanVisits: () => void;
}
export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getNonOrphanVisits,
nonOrphanVisits,
cancelGetNonOrphanVisits,
settings,
}: NonOrphanVisitsProps) => {
const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
getNonOrphanVisits({ query: toApiParams(params), doIntervalFallback });
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetNonOrphanVisits}
visitsInfo={nonOrphanVisits}
settings={settings}
exportCsv={exportCsv}
>
<VisitsHeader title="Non-orphan visits" goBack={goBack} visits={nonOrphanVisits.visits} />
</VisitsStats>
);
}, () => [Topics.visits]);

View File

@@ -0,0 +1,43 @@
import type { ReportExporter } from '../../common/services/ReportExporter';
import { boundToMercureHub } from '../../mercure/helpers/boundToMercureHub';
import { Topics } from '../../mercure/helpers/Topics';
import { useGoBack } from '../../utils/helpers/hooks';
import type { LoadOrphanVisits } from './reducers/orphanVisits';
import type { VisitsInfo } from './reducers/types';
import type { NormalizedVisit, VisitsParams } from './types';
import type { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader';
import { VisitsStats } from './VisitsStats';
export interface OrphanVisitsProps extends CommonVisitsProps {
getOrphanVisits: (params: LoadOrphanVisits) => void;
orphanVisits: VisitsInfo;
cancelGetOrphanVisits: () => void;
}
export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getOrphanVisits,
orphanVisits,
cancelGetOrphanVisits,
settings,
}: OrphanVisitsProps) => {
const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getOrphanVisits(
{ query: toApiParams(params), orphanVisitsType: params.filter?.orphanVisitsType, doIntervalFallback },
);
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetOrphanVisits}
visitsInfo={orphanVisits}
settings={settings}
exportCsv={exportCsv}
isOrphanVisits
>
<VisitsHeader title="Orphan visits" goBack={goBack} visits={orphanVisits.visits} />
</VisitsStats>
);
}, () => [Topics.orphanVisits]);

View File

@@ -0,0 +1,63 @@
import { useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import type { ReportExporter } from '../../common/services/ReportExporter';
import { boundToMercureHub } from '../../mercure/helpers/boundToMercureHub';
import { Topics } from '../../mercure/helpers/Topics';
import { useGoBack } from '../../utils/helpers/hooks';
import { parseQuery } from '../../utils/helpers/query';
import type { ShortUrlIdentifier } from '../short-urls/data';
import { urlDecodeShortCode } from '../short-urls/helpers';
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import type { LoadShortUrlVisits, ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader';
import type { NormalizedVisit, VisitsParams } from './types';
import type { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import { VisitsStats } from './VisitsStats';
export interface ShortUrlVisitsProps extends CommonVisitsProps {
getShortUrlVisits: (params: LoadShortUrlVisits) => void;
shortUrlVisits: ShortUrlVisitsState;
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
shortUrlDetail: ShortUrlDetail;
cancelGetShortUrlVisits: () => void;
}
export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
shortUrlVisits,
shortUrlDetail,
getShortUrlVisits,
getShortUrlDetail,
cancelGetShortUrlVisits,
settings,
}: ShortUrlVisitsProps) => {
const { shortCode = '' } = useParams<{ shortCode: string }>();
const { search } = useLocation();
const goBack = useGoBack();
const { domain } = parseQuery<{ domain?: string }>(search);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getShortUrlVisits({
shortCode: urlDecodeShortCode(shortCode),
query: { ...toApiParams(params), domain },
doIntervalFallback,
});
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
visits,
);
useEffect(() => {
getShortUrlDetail({ shortCode: urlDecodeShortCode(shortCode), domain });
}, []);
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetShortUrlVisits}
visitsInfo={shortUrlVisits}
settings={settings}
exportCsv={exportCsv}
>
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats>
);
}, (_, params) => (params.shortCode ? [Topics.shortUrlVisits(urlDecodeShortCode(params.shortCode))] : []));

View File

@@ -0,0 +1,3 @@
.short-url-visits-header__created-at {
cursor: default;
}

View File

@@ -0,0 +1,45 @@
import { ExternalLink } from 'react-external-link';
import { UncontrolledTooltip } from 'reactstrap';
import { Time } from '../../utils/dates/Time';
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import type { ShortUrlVisits } from './reducers/shortUrlVisits';
import { VisitsHeader } from './VisitsHeader';
import './ShortUrlVisitsHeader.scss';
interface ShortUrlVisitsHeaderProps {
shortUrlDetail: ShortUrlDetail;
shortUrlVisits: ShortUrlVisits;
goBack: () => void;
}
export const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortUrlVisitsHeaderProps) => {
const { shortUrl, loading } = shortUrlDetail;
const { visits } = shortUrlVisits;
const shortLink = shortUrl?.shortUrl ?? '';
const longLink = shortUrl?.longUrl ?? '';
const title = shortUrl?.title;
const renderDate = () => (!shortUrl ? <small>Loading...</small> : (
<span>
<b id="created" className="short-url-visits-header__created-at">
<Time date={shortUrl.dateCreated} relative />
</b>
<UncontrolledTooltip placement="bottom" target="created">
<Time date={shortUrl.dateCreated} />
</UncontrolledTooltip>
</span>
));
const visitsStatsTitle = <>Visits for <ExternalLink href={shortLink} /></>;
return (
<VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} shortUrl={shortUrl}>
<hr />
<div>Created: {renderDate()}</div>
<div className="long-url-container">
{`${title ? 'Title' : 'Long URL'}: `}
{loading && <small>Loading...</small>}
{!loading && <ExternalLink href={longLink}>{title ?? longLink}</ExternalLink>}
</div>
</VisitsHeader>
);
};

View File

@@ -0,0 +1,44 @@
import { useParams } from 'react-router-dom';
import type { ShlinkVisitsParams } from '../../api/types';
import type { ReportExporter } from '../../common/services/ReportExporter';
import { boundToMercureHub } from '../../mercure/helpers/boundToMercureHub';
import { Topics } from '../../mercure/helpers/Topics';
import { useGoBack } from '../../utils/helpers/hooks';
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
import type { LoadTagVisits, TagVisits as TagVisitsState } from './reducers/tagVisits';
import { TagVisitsHeader } from './TagVisitsHeader';
import type { NormalizedVisit } from './types';
import type { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import { VisitsStats } from './VisitsStats';
export interface TagVisitsProps extends CommonVisitsProps {
getTagVisits: (params: LoadTagVisits) => void;
tagVisits: TagVisitsState;
cancelGetTagVisits: () => void;
}
export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: ReportExporter) => boundToMercureHub(({
getTagVisits,
tagVisits,
cancelGetTagVisits,
settings,
}: TagVisitsProps) => {
const goBack = useGoBack();
const { tag = '' } = useParams();
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
getTagVisits({ tag, query: toApiParams(params), doIntervalFallback });
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetTagVisits}
visitsInfo={tagVisits}
settings={settings}
exportCsv={exportCsv}
>
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats>
);
}, () => [Topics.visits]);

View File

@@ -0,0 +1,23 @@
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
import { Tag } from '../tags/helpers/Tag';
import type { TagVisits } from './reducers/tagVisits';
import { VisitsHeader } from './VisitsHeader';
import './ShortUrlVisitsHeader.scss';
interface TagVisitsHeaderProps {
tagVisits: TagVisits;
goBack: () => void;
colorGenerator: ColorGenerator;
}
export const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }: TagVisitsHeaderProps) => {
const { visits, tag } = tagVisits;
const visitsStatsTitle = (
<span className="d-flex align-items-center justify-content-center">
<span className="me-2">Visits for</span>
<Tag text={tag} colorGenerator={colorGenerator} />
</span>
);
return <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} />;
};

View File

@@ -0,0 +1,38 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Button, Card } from 'reactstrap';
import type { ShortUrl } from '../short-urls/data';
import { ShortUrlVisitsCount } from '../short-urls/helpers/ShortUrlVisitsCount';
import type { Visit } from './types';
type VisitsHeaderProps = PropsWithChildren<{
visits: Visit[];
goBack: () => void;
title: ReactNode;
shortUrl?: ShortUrl;
}>;
export const VisitsHeader: FC<VisitsHeaderProps> = ({ visits, goBack, shortUrl, children, title }) => (
<header>
<Card body>
<h2 className="d-flex justify-content-between align-items-center mb-0">
<Button color="link" size="lg" className="p-0 me-3" onClick={goBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</Button>
<span className="text-center d-none d-sm-block">
<small>{title}</small>
</span>
<span className="badge badge-main ms-3">
Visits:{' '}
<ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
</span>
</h2>
<h3 className="text-center d-block d-sm-none mb-0 mt-3">
<small>{title}</small>
</h3>
{children && <div className="mt-md-2">{children}</div>}
</Card>
</header>
);

View File

@@ -0,0 +1,336 @@
import type { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { faCalendarAlt, faChartPie, faList, faMapMarkedAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { isEmpty, pipe, propEq, values } from 'ramda';
import type { FC, PropsWithChildren } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { Button, Progress, Row } from 'reactstrap';
import { ShlinkApiError } from '../../api/ShlinkApiError';
import type { Settings } from '../../settings/reducers/settings';
import { DateRangeSelector } from '../../utils/dates/DateRangeSelector';
import { ExportBtn } from '../../utils/ExportBtn';
import type { DateInterval, DateRange } from '../../utils/helpers/dateIntervals';
import { toDateRange } from '../../utils/helpers/dateIntervals';
import { prettify } from '../../utils/helpers/numbers';
import { Message } from '../../utils/Message';
import { NavPillItem, NavPills } from '../../utils/NavPills';
import { Result } from '../../utils/Result';
import { DoughnutChartCard } from './charts/DoughnutChartCard';
import { LineChartCard } from './charts/LineChartCard';
import { SortableBarChartCard } from './charts/SortableBarChartCard';
import { useVisitsQuery } from './helpers/hooks';
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
import type { VisitsInfo } from './reducers/types';
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
import type { NormalizedOrphanVisit, NormalizedVisit, VisitsParams } from './types';
import type { HighlightableProps } from './types/helpers';
import { highlightedVisitsToStats } from './types/helpers';
import { VisitsTable } from './VisitsTable';
export type VisitsStatsProps = PropsWithChildren<{
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
visitsInfo: VisitsInfo;
settings: Settings;
cancelGetVisits: () => void;
exportCsv: (visits: NormalizedVisit[]) => void;
isOrphanVisits?: boolean;
}>;
interface VisitsNavLinkProps {
title: string;
subPath: string;
icon: IconDefinition;
}
type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
const sections: Record<Section, VisitsNavLinkProps> = {
byTime: { title: 'By time', subPath: 'by-time', icon: faCalendarAlt },
byContext: { title: 'By context', subPath: 'by-context', icon: faChartPie },
byLocation: { title: 'By location', subPath: 'by-location', icon: faMapMarkedAlt },
list: { title: 'List', subPath: 'list', icon: faList },
};
let selectedBar: string | undefined;
export const VisitsStats: FC<VisitsStatsProps> = ({
children,
visitsInfo,
getVisits,
cancelGetVisits,
settings,
exportCsv,
isOrphanVisits = false,
}) => {
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
const [{ dateRange, visitsFilter }, updateFiltering] = useVisitsQuery();
const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
dateRange: {
startDate: theStartDate ?? undefined,
endDate: theEndDate ?? undefined,
},
}),
updateFiltering,
);
const initialInterval = useRef<DateRange | DateInterval>(
dateRange ?? fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
);
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
const isFirstLoad = useRef(true);
const { search } = useLocation();
const buildSectionUrl = (subPath?: string) => (!subPath ? search : `${subPath}${search}`);
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
() => processStatsFromVisits(normalizedVisits),
[normalizedVisits],
);
const resolvedFilter = useMemo(() => ({
...visitsFilter,
excludeBots: visitsFilter.excludeBots ?? settings.visits?.excludeBots,
}), [visitsFilter]);
const mapLocations = values(citiesForMap);
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
selectedBar = undefined;
setHighlightedVisits(selectedVisits);
};
const highlightVisitsForProp = (prop: HighlightableProps<NormalizedOrphanVisit>) => (value: string) => {
const newSelectedBar = `${prop}_${value}`;
if (selectedBar === newSelectedBar) {
setHighlightedVisits([]);
setHighlightedLabel(undefined);
selectedBar = undefined;
} else {
setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value)));
setHighlightedLabel(value);
selectedBar = newSelectedBar;
}
};
useEffect(() => cancelGetVisits, []);
useEffect(() => {
const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current));
getVisits({ dateRange: resolvedDateRange, filter: resolvedFilter }, isFirstLoad.current);
isFirstLoad.current = false;
}, [dateRange, visitsFilter]);
useEffect(() => {
// As soon as the fallback is loaded, if the initial interval used the settings one, we do fall back
if (fallbackInterval && initialInterval.current === (settings.visits?.defaultInterval ?? 'last30Days')) {
initialInterval.current = fallbackInterval;
}
}, [fallbackInterval]);
const renderVisitsContent = () => {
if (loadingLarge) {
return (
<Message loading>
This is going to take a while... :S
<Progress value={progress} striped={progress === 100} className="mt-3" />
</Message>
);
}
if (loading) {
return <Message loading />;
}
if (error) {
return (
<Result type="error">
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while loading visits :(" />
</Result>
);
}
if (isEmpty(visits)) {
return <Message>There are no visits matching current filter</Message>;
}
return (
<>
<NavPills fill>
{Object.values(sections).map(({ title, icon, subPath }, index) => (
<NavPillItem key={index} to={buildSectionUrl(subPath)} replace>
<FontAwesomeIcon icon={icon} />
<span className="ms-2 d-none d-sm-inline">{title}</span>
</NavPillItem>
))}
</NavPills>
<Row>
<Routes>
<Route
path={sections.byTime.subPath}
element={(
<div className="col-12 mt-3">
<LineChartCard
title="Visits during time"
visits={normalizedVisits}
highlightedVisits={highlightedVisits}
highlightedLabel={highlightedLabel}
setSelectedVisits={setSelectedVisits}
/>
</div>
)}
/>
<Route
path={sections.byContext.subPath}
element={(
<>
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
<DoughnutChartCard title="Operating systems" stats={os} />
</div>
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
<DoughnutChartCard title="Browsers" stats={browsers} />
</div>
<div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
<SortableBarChartCard
title="Referrers"
stats={referrers}
withPagination={false}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('referer')}
/>
</div>
{isOrphanVisits && (
<div className="mt-3 col-lg-6">
<SortableBarChartCard
title="Visited URLs"
stats={visitedUrls}
highlightedLabel={highlightedLabel}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'visitedUrl')}
sortingItems={{
visitedUrl: 'Visited URL',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('visitedUrl')}
/>
</div>
)}
</>
)}
/>
<Route
path={sections.byLocation.subPath}
element={(
<>
<div className="col-lg-6 mt-3">
<SortableBarChartCard
title="Countries"
stats={countries}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Country name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('country')}
/>
</div>
<div className="col-lg-6 mt-3">
<SortableBarChartCard
title="Cities"
stats={cities}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
highlightedLabel={highlightedLabel}
extraHeaderContent={(activeCities) => mapLocations.length > 0 && (
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
)}
sortingItems={{
name: 'City name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('city')}
/>
</div>
</>
)}
/>
<Route
path={sections.list.subPath}
element={(
<div className="col-12">
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isOrphanVisits={isOrphanVisits}
/>
</div>
)}
/>
<Route path="*" element={<Navigate replace to={buildSectionUrl(sections.byTime.subPath)} />} />
</Routes>
</Row>
</>
);
};
return (
<>
{children}
<section className="mt-3">
<div className="row flex-md-row-reverse">
<div className="col-lg-7 col-xl-6">
<div className="d-md-flex">
<div className="flex-fill">
<DateRangeSelector
updatable
disabled={loading}
initialDateRange={initialInterval.current}
defaultText="All visits"
onDatesChange={setDates}
/>
</div>
<VisitsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0"
isOrphanVisits={isOrphanVisits}
selected={resolvedFilter}
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
/>
</div>
</div>
{visits.length > 0 && (
<div className="col-lg-5 col-xl-6 mt-3 mt-lg-0">
<div className="d-flex">
<ExportBtn
className="btn-md-block"
amount={normalizedVisits.length}
onClick={() => exportCsv(normalizedVisits)}
/>
<Button
outline
disabled={highlightedVisits.length === 0}
className="btn-md-block ms-2"
onClick={() => setSelectedVisits([])}
>
Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
</Button>
</div>
</div>
)}
</div>
</section>
<section className="mt-3">
{renderVisitsContent()}
</section>
</>
);
};

View File

@@ -0,0 +1,39 @@
@import '../../utils/base';
@import '../../utils/mixins/sticky-cell';
.visits-table {
margin: 1.5rem 0 0;
position: relative;
background-color: var(--primary-color);
overflow-y: hidden;
}
.visits-table__header-cell {
cursor: pointer;
margin-bottom: 55px;
@include sticky-cell();
@media (min-width: $mdMin) {
&.visits-table__sticky {
top: $headerHeight + 40px;
}
}
}
.visits-table__header-icon {
float: right;
margin-top: 3px;
}
.visits-table__footer-cell.visits-table__footer-cell {
bottom: 0;
margin-top: 34px;
padding: .5rem;
@include sticky-cell();
}
.visits-table__sticky.visits-table__sticky {
position: sticky;
}

View File

@@ -0,0 +1,216 @@
import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { min, splitEvery } from 'ramda';
import { useEffect, useMemo, useRef, useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { SimplePaginator } from '../../common/SimplePaginator';
import { Time } from '../../utils/dates/Time';
import { prettify } from '../../utils/helpers/numbers';
import type { Order } from '../../utils/helpers/ordering';
import { determineOrderDir, sortList } from '../../utils/helpers/ordering';
import { SearchField } from '../../utils/SearchField';
import { TableOrderIcon } from '../../utils/table/TableOrderIcon';
import type { MediaMatcher } from '../../utils/types';
import type { NormalizedOrphanVisit, NormalizedVisit } from './types';
import './VisitsTable.scss';
export interface VisitsTableProps {
visits: NormalizedVisit[];
selectedVisits?: NormalizedVisit[];
setSelectedVisits: (visits: NormalizedVisit[]) => void;
matchMedia?: MediaMatcher;
isOrphanVisits?: boolean;
}
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
type VisitsOrder = Order<OrderableFields>;
const PAGE_SIZE = 20;
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
`${browser} ${os} ${referer} ${country} ${city} ${(rest as NormalizedOrphanVisit).visitedUrl}`.toLowerCase().includes(
searchTerm.toLowerCase(),
);
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList<NormalizedVisit>(visits, order as any);
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => {
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [...allVisits];
const sortedVisits = sortVisits(order, filteredVisits);
const total = sortedVisits.length;
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
return { visitsGroups, total };
};
export const VisitsTable = ({
visits,
selectedVisits = [],
setSelectedVisits,
matchMedia = window.matchMedia,
isOrphanVisits = false,
}: VisitsTableProps) => {
const headerCellsClass = 'visits-table__header-cell visits-table__sticky';
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
const [isMobileDevice, setIsMobileDevice] = useState(matchMobile());
const [searchTerm, setSearchTerm] = useState<string | undefined>(undefined);
const [order, setOrder] = useState<VisitsOrder>({});
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [searchTerm, order]);
const isFirstLoad = useRef(true);
const [page, setPage] = useState(1);
const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE;
const fullSizeColSpan = 8 + Number(isOrphanVisits);
const orderByColumn = (field: OrderableFields) =>
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
const renderOrderIcon = (field: OrderableFields) =>
<TableOrderIcon currentOrder={order} field={field} className="visits-table__header-icon" />;
useEffect(() => {
const listener = () => setIsMobileDevice(matchMobile());
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, []);
useEffect(() => {
setPage(1);
!isFirstLoad.current && setSelectedVisits([]);
isFirstLoad.current = false;
}, [searchTerm]);
return (
<div className="table-responsive-md">
<table className="table table-bordered table-hover table-sm visits-table">
<thead className="visits-table__header">
<tr>
<th
className={`${headerCellsClass} text-center`}
onClick={() => setSelectedVisits(
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [],
)}
>
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th>
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
<FontAwesomeIcon icon={botIcon} />
{renderOrderIcon('potentialBot')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('date')}>
Date
{renderOrderIcon('date')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('country')}>
Country
{renderOrderIcon('country')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('city')}>
City
{renderOrderIcon('city')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('browser')}>
Browser
{renderOrderIcon('browser')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('os')}>
OS
{renderOrderIcon('os')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('referer')}>
Referrer
{renderOrderIcon('referer')}
</th>
{isOrphanVisits && (
<th className={headerCellsClass} onClick={orderByColumn('visitedUrl')}>
Visited URL
{renderOrderIcon('visitedUrl')}
</th>
)}
</tr>
<tr>
<td colSpan={fullSizeColSpan} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} />
</td>
</tr>
</thead>
<tbody>
{!resultSet.visitsGroups[page - 1]?.length && (
<tr>
<td colSpan={fullSizeColSpan} className="text-center">
No visits found with current filtering
</td>
</tr>
)}
{resultSet.visitsGroups[page - 1]?.map((visit, index) => {
const isSelected = selectedVisits.includes(visit);
return (
<tr
key={index}
style={{ cursor: 'pointer' }}
className={classNames({ 'table-active': isSelected })}
onClick={() => setSelectedVisits(
isSelected ? selectedVisits.filter((v) => v !== visit) : [...selectedVisits, visit],
)}
>
<td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td>
<td className="text-center">
{visit.potentialBot && (
<>
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
Potentially a visit from a bot or crawler
</UncontrolledTooltip>
</>
)}
</td>
<td><Time date={visit.date} /></td>
<td>{visit.country}</td>
<td>{visit.city}</td>
<td>{visit.browser}</td>
<td>{visit.os}</td>
<td>{visit.referer}</td>
{isOrphanVisits && <td>{(visit as NormalizedOrphanVisit).visitedUrl}</td>}
</tr>
);
})}
</tbody>
{resultSet.total > PAGE_SIZE && (
<tfoot>
<tr>
<td colSpan={fullSizeColSpan} className="visits-table__footer-cell visits-table__sticky">
<div className="row">
<div className="col-md-6">
<SimplePaginator
pagesCount={Math.ceil(resultSet.total / PAGE_SIZE)}
currentPage={page}
setCurrentPage={setPage}
centered={isMobileDevice}
/>
</div>
<div
className={classNames('col-md-6', {
'd-flex align-items-center flex-row-reverse': !isMobileDevice,
'text-center mt-3': isMobileDevice,
})}
>
<div>
Visits <b>{prettify(start + 1)}</b> to{' '}
<b>{prettify(min(end, resultSet.total))}</b> of{' '}
<b>{prettify(resultSet.total)}</b>
</div>
</div>
</div>
</td>
</tr>
</tfoot>
)}
</table>
</div>
);
};

View File

@@ -0,0 +1,4 @@
.chart-card__footer--sticky {
position: sticky;
bottom: 0;
}

View File

@@ -0,0 +1,16 @@
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Card, CardBody, CardFooter, CardHeader } from 'reactstrap';
import './ChartCard.scss';
type ChartCardProps = PropsWithChildren<{
title: Function | string;
footer?: ReactNode;
}>;
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
<Card role="document">
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{children}</CardBody>
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}
</Card>
);

View File

@@ -0,0 +1,73 @@
import type { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
import { keys, values } from 'ramda';
import type { FC } from 'react';
import { memo, useState } from 'react';
import { Doughnut } from 'react-chartjs-2';
import { renderPieChartLabel } from '../../../utils/helpers/charts';
import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../../utils/theme';
import type { Stats } from '../types';
import { DoughnutChartLegend } from './DoughnutChartLegend';
interface DoughnutChartProps {
stats: Stats;
}
const generateChartDatasets = (data: number[]): ChartDataset[] => [
{
data,
backgroundColor: [
'#97BBCD',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#57A773',
'#414066',
'#08B2E3',
'#B6C454',
'#DCDCDC',
'#463730',
],
borderColor: isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
borderWidth: 2,
},
];
const generateChartData = (labels: string[], data: number[]): ChartData => ({
labels,
datasets: generateChartDatasets(data),
});
export const DoughnutChart: FC<DoughnutChartProps> = memo(({ stats }) => {
const [chartRef, setChartRef] = useState<Chart | undefined>(); // Cannot use useRef here
const labels = keys(stats);
const data = values(stats);
const options: ChartOptions = {
plugins: {
legend: { display: false },
tooltip: {
intersect: true,
callbacks: { label: renderPieChartLabel },
},
},
};
const chartData = generateChartData(labels, data);
return (
<div className="row">
<div className="col-sm-12 col-md-7">
<Doughnut
height={300}
data={chartData as any}
options={options as any}
ref={(element) => {
setChartRef(element ?? undefined);
}}
/>
</div>
<div className="col-sm-12 col-md-5">
{chartRef && <DoughnutChartLegend chart={chartRef} />}
</div>
</div>
);
});

View File

@@ -0,0 +1,15 @@
import type { FC } from 'react';
import type { Stats } from '../types';
import { ChartCard } from './ChartCard';
import { DoughnutChart } from './DoughnutChart';
interface DoughnutChartCardProps {
title: string;
stats: Stats;
}
export const DoughnutChartCard: FC<DoughnutChartCardProps> = ({ title, stats }) => (
<ChartCard title={title}>
<DoughnutChart stats={stats} />
</ChartCard>
);

View File

@@ -0,0 +1,29 @@
@import '../../../utils/base';
.doughnut-chart-legend {
list-style-type: none;
padding: 0;
margin: 0;
@media (max-width: $smMax) {
margin-top: 1rem;
}
}
.doughnut-chart-legend__item:not(:first-child) {
margin-top: .3rem;
}
.doughnut-chart-legend__item-color {
width: 20px;
min-width: 20px;
height: 20px;
margin-right: 5px;
border-radius: 10px;
}
.doughnut-chart-legend__item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,28 @@
import type { Chart } from 'chart.js';
import type { FC } from 'react';
import './DoughnutChartLegend.scss';
interface DoughnutChartLegendProps {
chart: Chart;
}
export const DoughnutChartLegend: FC<DoughnutChartLegendProps> = ({ chart }) => {
const { config } = chart;
const { labels = [], datasets = [] } = config.data ?? {};
const [{ backgroundColor: colors }] = datasets;
const { defaultColor } = config.options ?? {} as any;
return (
<ul className="doughnut-chart-legend">
{(labels as string[]).map((label, index) => (
<li key={label} className="doughnut-chart-legend__item d-flex">
<div
className="doughnut-chart-legend__item-color"
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
/>
<small className="doughnut-chart-legend__item-text flex-fill">{label}</small>
</li>
))}
</ul>
);
};

View File

@@ -0,0 +1,134 @@
import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js';
import { keys, values } from 'ramda';
import type { FC, MutableRefObject } from 'react';
import { useRef } from 'react';
import { Bar, getElementAtEvent } from 'react-chartjs-2';
import { pointerOnHover, renderChartLabel } from '../../../utils/helpers/charts';
import { prettify } from '../../../utils/helpers/numbers';
import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../../utils/theme';
import type { Stats } from '../types';
import { fillTheGaps } from '../utils';
export interface HorizontalBarChartProps {
stats: Stats;
max?: number;
highlightedStats?: Stats;
highlightedLabel?: string;
onClick?: (label: string) => void;
}
const dropLabelIfHidden = (label: string) => (label.startsWith('hidden') ? '' : label);
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
const determineHeight = (labels: string[]): number | undefined => (labels.length > 20 ? labels.length * 10 : undefined);
const generateChartDatasets = (
data: number[],
highlightedData: number[],
highlightedLabel?: string,
): ChartDataset[] => {
const mainDataset: ChartDataset = {
data,
label: highlightedLabel ? 'Non-selected' : 'Visits',
backgroundColor: MAIN_COLOR_ALPHA,
borderColor: MAIN_COLOR,
borderWidth: 2,
};
if (highlightedData.every((value) => value === 0)) {
return [mainDataset];
}
const highlightedDataset: ChartDataset = {
label: highlightedLabel ?? 'Selected',
data: highlightedData,
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
borderColor: HIGHLIGHTED_COLOR,
borderWidth: 2,
};
return [mainDataset, highlightedDataset];
};
const generateChartData = (
labels: string[],
data: number[],
highlightedData: number[],
highlightedLabel?: string,
): ChartData => ({
labels,
datasets: generateChartDatasets(data, highlightedData, highlightedLabel),
});
const chartElementAtEvent = (labels: string[], [chart]: InteractionItem[], onClick?: (label: string) => void) => {
if (!onClick || !chart) {
return;
}
onClick(labels[chart.index]);
};
export const HorizontalBarChart: FC<HorizontalBarChartProps> = (
{ stats, highlightedStats, highlightedLabel, onClick, max },
) => {
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
if (acc[highlightedKey]) {
acc[highlightedKey] -= highlightedStats[highlightedKey];
}
return acc;
}, { ...stats }),
);
const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
const refWithStats = useRef(null);
const refWithoutStats = useRef(null);
const options: ChartOptions = {
plugins: {
legend: { display: false },
tooltip: {
mode: 'y',
// Do not show tooltip on items with empty label when in a bar chart
filter: ({ label }) => label !== '',
callbacks: { label: renderChartLabel },
},
},
scales: {
x: {
beginAtZero: true,
stacked: true,
max,
ticks: {
precision: 0,
callback: prettify,
},
},
y: { stacked: true },
},
onHover: pointerOnHover,
indexAxis: 'y',
};
const chartData = generateChartData(labels, data, highlightedData, highlightedLabel);
const height = determineHeight(labels);
// Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination)
const renderChartComponent = (customKey: string, theRef: MutableRefObject<any>) => (
<Bar
ref={theRef}
key={`${height}_${customKey}`}
data={chartData as any}
options={options as any}
height={height}
onClick={(e) => chartElementAtEvent(labels, getElementAtEvent(theRef.current, e), onClick)}
/>
);
return (
<>
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
{highlightedStats !== undefined && renderChartComponent('with_stats', refWithStats)}
{highlightedStats === undefined && renderChartComponent('without_stats', refWithoutStats)}
</>
);
};

View File

@@ -0,0 +1,9 @@
@import '../../../utils/base';
.line-chart-card__body canvas {
height: 300px !important;
@media (min-width: $mdMin) {
height: 400px !important;
}
}

View File

@@ -0,0 +1,270 @@
import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js';
import {
add,
differenceInDays,
differenceInHours,
differenceInMonths,
differenceInWeeks,
endOfISOWeek,
format,
parseISO,
startOfISOWeek,
} from 'date-fns';
import { always, cond, countBy, reverse } from 'ramda';
import type { MutableRefObject } from 'react';
import { useMemo, useRef, useState } from 'react';
import { getElementAtEvent, Line } from 'react-chartjs-2';
import {
Card,
CardBody,
CardHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
UncontrolledDropdown,
} from 'reactstrap';
import { pointerOnHover, renderChartLabel } from '../../../utils/helpers/charts';
import { STANDARD_DATE_FORMAT } from '../../../utils/helpers/date';
import { useToggle } from '../../../utils/helpers/hooks';
import { prettify } from '../../../utils/helpers/numbers';
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../../utils/theme';
import { ToggleSwitch } from '../../../utils/ToggleSwitch';
import { rangeOf } from '../../../utils/utils';
import type { NormalizedVisit, Stats } from '../types';
import { fillTheGaps } from '../utils';
import './LineChartCard.scss';
interface LineChartCardProps {
title: string;
highlightedLabel?: string;
visits: NormalizedVisit[];
highlightedVisits: NormalizedVisit[];
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
}
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
const STEPS_MAP: Record<Step, string> = {
monthly: 'Month',
weekly: 'Week',
daily: 'Day',
hourly: 'Hour',
};
const STEP_TO_DURATION_MAP: Record<Step, (amount: number) => Duration> = {
hourly: (hours: number) => ({ hours }),
daily: (days: number) => ({ days }),
weekly: (weeks: number) => ({ weeks }),
monthly: (months: number) => ({ months }),
};
const STEP_TO_DIFF_FUNC_MAP: Record<Step, (dateLeft: Date, dateRight: Date) => number> = {
hourly: differenceInHours,
daily: differenceInDays,
weekly: differenceInWeeks,
monthly: differenceInMonths,
};
const STEP_TO_DATE_FORMAT: Record<Step, (date: Date) => string> = {
hourly: (date) => format(date, 'yyyy-MM-dd HH:00'),
daily: (date) => format(date, STANDARD_DATE_FORMAT),
weekly(date) {
const firstWeekDay = format(startOfISOWeek(date), STANDARD_DATE_FORMAT);
const lastWeekDay = format(endOfISOWeek(date), STANDARD_DATE_FORMAT);
return `${firstWeekDay} - ${lastWeekDay}`;
},
monthly: (date) => format(date, 'yyyy-MM'),
};
const determineInitialStep = (oldestVisitDate: string): Step => {
const now = new Date();
const oldestDate = parseISO(oldestVisitDate);
const matcher = cond<never, Step | undefined>([
[() => differenceInDays(now, oldestDate) <= 2, always<Step>('hourly')], // Less than 2 days
[() => differenceInMonths(now, oldestDate) <= 1, always<Step>('daily')], // Between 2 days and 1 month
[() => differenceInMonths(now, oldestDate) <= 6, always<Step>('weekly')], // Between 1 and 6 months
]);
return matcher() ?? 'monthly';
};
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy(
(visit) => STEP_TO_DATE_FORMAT[step](parseISO(visit.date)),
visits,
);
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
visits.reduce<Record<string, NormalizedVisit[]>>(
(acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](parseISO(visit.date));
acc[key] = acc[key] ?? [];
acc[key].push(visit);
return acc;
},
{},
);
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
const diffFunc = STEP_TO_DIFF_FUNC_MAP[step];
const formatter = STEP_TO_DATE_FORMAT[step];
const newerDate = parseISO(visits[0].date);
const oldestDate = parseISO(visits[visits.length - 1].date);
const size = diffFunc(newerDate, oldestDate);
const duration = STEP_TO_DURATION_MAP[step];
return [
formatter(oldestDate),
...rangeOf(size, (num) => formatter(add(oldestDate, duration(num)))),
];
};
const generateLabelsAndGroupedVisits = (
visits: NormalizedVisit[],
groupedVisitsWithGaps: Stats,
step: Step,
skipNoElements: boolean,
): [string[], number[]] => {
if (skipNoElements) {
return [Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps)];
}
const labels = generateLabels(step, visits);
return [labels, fillTheGaps(groupedVisitsWithGaps, labels)];
};
const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({
label,
data,
fill: false,
tension: 0.2,
borderColor: color,
backgroundColor: color,
});
let selectedLabel: string | null = null;
const chartElementAtEvent = (
labels: string[],
datasetsByPoint: Record<string, NormalizedVisit[]>,
[chart]: InteractionItem[],
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
) => {
if (!setSelectedVisits || !chart) {
return;
}
const { index } = chart;
if (selectedLabel === labels[index]) {
setSelectedVisits([]);
selectedLabel = null;
} else {
setSelectedVisits(labels[index] && datasetsByPoint[labels[index]] ? datasetsByPoint[labels[index]] : []);
selectedLabel = labels[index] ?? null;
}
};
export const LineChartCard = (
{ title, visits, highlightedVisits, highlightedLabel = 'Selected', setSelectedVisits }: LineChartCardProps,
) => {
const [step, setStep] = useState<Step>(
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
);
const [skipNoVisits, toggleSkipNoVisits] = useToggle(true);
const refWithHighlightedVisits = useRef(null);
const refWithoutHighlightedVisits = useRef(null);
const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [step, visits]);
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [step, visits]);
const [labels, groupedVisits] = useMemo(
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
[visits, step, skipNoVisits],
);
const groupedHighlighted = useMemo(
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
[highlightedVisits, step, labels],
);
const generateChartDatasets = (): ChartDataset[] => {
const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR);
if (highlightedVisits.length === 0) {
return [mainDataset];
}
const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR);
return [mainDataset, highlightedDataset];
};
const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() });
const options: ChartOptions = {
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
intersect: false,
axis: 'x',
callbacks: { label: renderChartLabel },
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
callback: prettify,
},
},
x: {
title: { display: true, text: STEPS_MAP[step] },
},
},
onHover: pointerOnHover,
};
const renderLineChart = (theRef: MutableRefObject<any>) => (
<Line
ref={theRef}
data={generateChartData() as any}
options={options as any}
onClick={(e) =>
chartElementAtEvent(labels, datasetsByPoint, getElementAtEvent(theRef.current, e), setSelectedVisits)}
/>
);
return (
<Card>
<CardHeader role="heading">
{title}
<div className="float-end">
<UncontrolledDropdown>
<DropdownToggle caret color="link" className="btn-sm p-0">
Group by
</DropdownToggle>
<DropdownMenu end>
{Object.entries(STEPS_MAP).map(([value, menuText]) => (
<DropdownItem key={value} active={step === value} onClick={() => setStep(value as Step)}>
{menuText}
</DropdownItem>
))}
</DropdownMenu>
</UncontrolledDropdown>
</div>
<div className="float-end me-2">
<ToggleSwitch checked={skipNoVisits} onChange={toggleSkipNoVisits}>
<small>Skip dates with no visits</small>
</ToggleSwitch>
</div>
</CardHeader>
<CardBody className="line-chart-card__body">
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
{highlightedVisits.length > 0 && renderLineChart(refWithHighlightedVisits)}
{highlightedVisits.length === 0 && renderLineChart(refWithoutHighlightedVisits)}
</CardBody>
</Card>
);
};

View File

@@ -0,0 +1,141 @@
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import type { FC, ReactNode } from 'react';
import { useState } from 'react';
import { SimplePaginator } from '../../../common/SimplePaginator';
import { roundTen } from '../../../utils/helpers/numbers';
import type { Order } from '../../../utils/helpers/ordering';
import { OrderingDropdown } from '../../../utils/OrderingDropdown';
import { PaginationDropdown } from '../../../utils/PaginationDropdown';
import { rangeOf } from '../../../utils/utils';
import type { Stats, StatsRow } from '../types';
import { ChartCard } from './ChartCard';
import type { HorizontalBarChartProps } from './HorizontalBarChart';
import { HorizontalBarChart } from './HorizontalBarChart';
interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
title: Function | string;
sortingItems: Record<string, string>;
withPagination?: boolean;
extraHeaderContent?: (activeCities?: string[]) => ReactNode;
}
const toLowerIfString = (value: any) => (type(value) === 'String' ? toLower(value) : value);
const pickKeyFromPair = ([key]: StatsRow) => key;
const pickValueFromPair = ([, value]: StatsRow) => value;
export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
stats,
highlightedStats,
title,
sortingItems,
extraHeaderContent,
withPagination = true,
...rest
}) => {
const [order, setOrder] = useState<Order<string>>({});
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(50);
const getSortedPairsForStats = (statsToSort: Stats, sorting: Record<string, string>) => {
const pairs = toPairs(statsToSort);
const sortedPairs = !order.field ? pairs : sortBy(
pipe<StatsRow[], string | number, string | number>(
order.field === Object.keys(sorting)[0] ? pickKeyFromPair : pickValueFromPair,
toLowerIfString,
),
pairs,
);
return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs);
};
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
const page = pages[currentPage - 1];
if (currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [...page, ...rangeOf(firstPageLength - page.length, (i): StatsRow => [`hidden_${i}`, 0])];
};
const renderPagination = (pagesCount: number) =>
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
const determineStats = (statsToSort: Stats, sorting: Record<string, string>, theHighlightedStats?: Stats) => {
const sortedPairs = getSortedPairsForStats(statsToSort, sorting);
const sortedKeys = sortedPairs.map(pickKeyFromPair);
// The highlighted stats have to be ordered based on the regular stats, not on its own values
const sortedHighlightedPairs = theHighlightedStats && toPairs(
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...theHighlightedStats },
);
if (sortedPairs.length <= itemsPerPage) {
return {
currentPageStats: fromPairs(sortedPairs),
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
};
}
const pages = splitEvery(itemsPerPage, sortedPairs);
const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs);
return {
currentPageStats: fromPairs(determineCurrentPagePairs(pages)),
currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)),
pagination: renderPagination(pages.length),
max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
};
};
const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
stats,
sortingItems,
highlightedStats && Object.keys(highlightedStats).length > 0 ? highlightedStats : undefined,
);
const activeCities = Object.keys(currentPageStats);
const computeTitle = () => (
<>
{title}
<div className="float-end">
<OrderingDropdown
isButton={false}
right
items={sortingItems}
order={order}
onChange={(field, dir) => {
setOrder({ field, dir });
setCurrentPage(1);
}}
/>
</div>
{withPagination && Object.keys(stats).length > 50 && (
<div className="float-end">
<PaginationDropdown
toggleClassName="btn-sm p-0 me-3"
ranges={[50, 100, 200, 500]}
value={itemsPerPage}
setValue={(value) => {
setItemsPerPage(value);
setCurrentPage(1);
}}
/>
</div>
)}
{extraHeaderContent && (
<div className="float-end">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</>
);
return (
<ChartCard
title={computeTitle}
footer={pagination}
>
<HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
</ChartCard>
);
};

View File

@@ -0,0 +1,45 @@
@import '../../../utils/base';
@import '../../../utils/mixins/fit-with-margin';
.map-modal__modal.map-modal__modal {
@media (min-width: $mdMin) {
$margin: 20px;
@include fit-with-margin($margin);
}
@media (max-width: $smMax) {
$margin: 10px;
@include fit-with-margin($margin);
}
}
.map-modal__modal-content.map-modal__modal-content {
height: 100%;
}
.map-modal__modal-title.map-modal__modal-title {
position: absolute;
width: 100%;
z-index: 1001;
padding: .5rem 1rem 1rem;
margin: 0;
color: #fff;
background: linear-gradient(rgba(0, 0, 0, .5), rgba(0, 0, 0, 0));
}
.map-modal__modal-body.map-modal__modal-body {
padding: 0;
display: flex;
overflow: hidden;
}
.map-modal__modal.map-modal__modal .leaflet-container.leaflet-container {
flex: 1 1 auto;
border-radius: .3rem;
}
.map-modal__modal.map-modal__modal .leaflet-top.leaflet-top .leaflet-control.leaflet-control {
margin-top: 60px;
}

View File

@@ -0,0 +1,56 @@
import { prop } from 'ramda';
import type { FC } from 'react';
import type { MapContainerProps } from 'react-leaflet';
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
import { Modal, ModalBody } from 'reactstrap';
import type { CityStats } from '../types';
import './MapModal.scss';
interface MapModalProps {
toggle: () => void;
isOpen: boolean;
title: string;
locations?: CityStats[];
}
const OpenStreetMapTile: FC = () => (
<TileLayer
attribution='&amp;copy <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
);
const calculateMapProps = (locations: CityStats[]): MapContainerProps => {
if (locations.length === 0) {
return {};
}
if (locations.length > 1) {
return { bounds: locations.map(prop('latLong')) };
}
// When there's only one location, an error is thrown if trying to calculate the bounds.
// When that happens, we use "zoom" and "center" as a workaround
const [{ latLong: center }] = locations;
return { zoom: 10, center };
};
export const MapModal = ({ toggle, isOpen, title, locations = [] }: MapModalProps) => (
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
<ModalBody className="map-modal__modal-body">
<h3 className="map-modal__modal-title">
{title}
<button type="button" className="btn-close float-end" aria-label="Close" onClick={toggle} />
</h3>
<MapContainer {...calculateMapProps(locations)}>
<OpenStreetMapTile />
{locations.map(({ cityName, latLong, count }, index) => (
<Marker key={index} position={latLong}>
<Popup><b>{count}</b> visit{count > 1 ? 's' : ''} from <b>{cityName}</b></Popup>
</Marker>
))}
</MapContainer>
</ModalBody>
</Modal>
);

View File

@@ -0,0 +1,4 @@
.open-map-modal-btn__btn.open-map-modal-btn__btn {
padding: 0;
margin-right: 1rem;
}

View File

@@ -0,0 +1,55 @@
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useState } from 'react';
import { Button, Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
import { useDomId, useToggle } from '../../../utils/helpers/hooks';
import type { CityStats } from '../types';
import { MapModal } from './MapModal';
import './OpenMapModalBtn.scss';
interface OpenMapModalBtnProps {
modalTitle: string;
activeCities?: string[];
locations?: CityStats[];
}
export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: OpenMapModalBtnProps) => {
const [mapIsOpened, , openMap, closeMap] = useToggle();
const [dropdownIsOpened, toggleDropdown, openDropdown] = useToggle();
const [locationsToShow, setLocationsToShow] = useState<CityStats[]>([]);
const id = useDomId();
const filterLocations = (cities: CityStats[]) => (
!activeCities ? cities : cities.filter(({ cityName }) => activeCities?.includes(cityName))
);
const onClick = () => {
if (!activeCities) {
setLocationsToShow(locations);
openMap();
return;
}
openDropdown();
};
const openMapWithLocations = (filtered: boolean) => () => {
setLocationsToShow(filtered ? filterLocations(locations) : locations);
openMap();
};
return (
<>
<Button color="link" className="open-map-modal-btn__btn" id={id} onClick={onClick}>
<FontAwesomeIcon icon={mapIcon} />
</Button>
<UncontrolledTooltip placement="left" target={id}>Show in map</UncontrolledTooltip>
<Dropdown isOpen={dropdownIsOpened} toggle={toggleDropdown} inNavbar>
<DropdownMenu end>
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
</DropdownMenu>
</Dropdown>
<MapModal toggle={closeMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
</>
);
};

View File

@@ -0,0 +1,48 @@
import type { DropdownItemProps } from 'reactstrap';
import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../../../utils/DropdownBtn';
import { hasValue } from '../../../utils/utils';
import type { OrphanVisitType, VisitsFilter } from '../types';
interface VisitsFilterDropdownProps {
onChange: (filters: VisitsFilter) => void;
selected?: VisitsFilter;
className?: string;
isOrphanVisits: boolean;
}
export const VisitsFilterDropdown = (
{ onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps,
) => {
const { orphanVisitsType, excludeBots = false } = selected;
const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({
active: orphanVisitsType === type,
onClick: () => onChange({ ...selected, orphanVisitsType: type === selected?.orphanVisitsType ? undefined : type }),
});
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
return (
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
{isOrphanVisits && (
<>
<DropdownItem divider />
<DropdownItem header>Orphan visits type:</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('regular_404')}>Regular 404</DropdownItem>
</>
)}
<DropdownItem divider />
<DropdownItem
disabled={!hasValue(selected)}
onClick={() => onChange({ excludeBots: false, orphanVisitsType: undefined })}
>
<i>Clear filters</i>
</DropdownItem>
</DropdownBtn>
);
};

View File

@@ -0,0 +1,67 @@
import type { DeepPartial } from '@reduxjs/toolkit';
import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda';
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatIsoDate } from '../../../utils/helpers/date';
import type { DateRange } from '../../../utils/helpers/dateIntervals';
import { datesToDateRange } from '../../../utils/helpers/dateIntervals';
import { parseQuery, stringifyQuery } from '../../../utils/helpers/query';
import type { BooleanString } from '../../../utils/utils';
import { parseBooleanToString } from '../../../utils/utils';
import type { OrphanVisitType, VisitsFilter } from '../types';
interface VisitsQuery {
startDate?: string;
endDate?: string;
orphanVisitsType?: OrphanVisitType;
excludeBots?: BooleanString;
domain?: string;
}
interface VisitsFiltering {
dateRange?: DateRange;
visitsFilter: VisitsFilter;
}
interface VisitsFilteringAndDomain {
filtering: VisitsFiltering;
domain?: string;
}
type UpdateFiltering = (extra: DeepPartial<VisitsFiltering>) => void;
export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
const navigate = useNavigate();
const { search } = useLocation();
const { filtering, domain: theDomain } = useMemo(
pipe(
() => parseQuery<VisitsQuery>(search),
({ startDate, endDate, orphanVisitsType, excludeBots, domain }: VisitsQuery): VisitsFilteringAndDomain => ({
domain,
filtering: {
dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined,
visitsFilter: { orphanVisitsType, excludeBots: !isNil(excludeBots) ? excludeBots === 'true' : undefined },
},
}),
),
[search],
);
const updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
const { excludeBots, orphanVisitsType } = visitsFilter;
const query: VisitsQuery = {
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots),
orphanVisitsType,
domain: theDomain,
};
const stringifiedQuery = stringifyQuery(query);
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
navigate(queryString, { replace: true, relative: 'route' });
};
return [filtering, updateFiltering];
};

View File

@@ -0,0 +1,144 @@
import { createAction, createSlice } from '@reduxjs/toolkit';
import { flatten, prop, range, splitEvery } from 'ramda';
import type { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../../api/types';
import { parseApiError } from '../../../api/utils';
import type { ShlinkState } from '../../../container/types';
import type { DateInterval } from '../../../utils/helpers/dateIntervals';
import { dateToMatchingInterval } from '../../../utils/helpers/dateIntervals';
import { createAsyncThunk } from '../../../utils/helpers/redux';
import type { CreateVisit, Visit } from '../types';
import type { LoadVisits, VisitsInfo, VisitsLoaded } from './types';
import { createNewVisits } from './visitCreation';
const ITEMS_PER_PAGE = 5000;
const PARALLEL_REQUESTS_COUNT = 4;
const PARALLEL_STARTING_PAGE = 2;
const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => currentPage >= pagesCount;
const calcProgress = (total: number, current: number): number => (current * 100) / total;
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
type LastVisitLoader = (excludeBots?: boolean) => Promise<Visit | undefined>;
interface VisitsAsyncThunkOptions<T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded> {
typePrefix: string;
createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader];
getExtraFulfilledPayload: (params: T) => Partial<R>;
shouldCancel: (getState: () => ShlinkState) => boolean;
}
export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded>(
{ typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>,
) => {
const progressChanged = createAction<number>(`${typePrefix}/progressChanged`);
const large = createAction<void>(`${typePrefix}/large`);
const fallbackToInterval = createAction<DateInterval>(`${typePrefix}/fallbackToInterval`);
const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise<Partial<R>> => {
const [visitsLoader, lastVisitLoader] = createLoaders(params, getState);
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten);
const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise<Visit[]> => {
if (shouldCancel(getState)) {
return [];
}
const data = await loadVisitsInParallel(pagesBlocks[index]);
dispatch(progressChanged(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE)));
if (index < pagesBlocks.length - 1) {
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
}
return data;
};
const loadVisits = async (page = 1) => {
const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE);
// If pagination was not returned, then this is an old shlink version. Just return data
if (!pagination || isLastPage(pagination)) {
return data;
}
// If there are more pages, make requests in blocks of 4
const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1);
const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) {
dispatch(large());
}
return data.concat(await loadPagesBlocks(pagesBlocks));
};
const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader(params.query?.excludeBots)]);
if (!visits.length && lastVisit) {
dispatch(fallbackToInterval(dateToMatchingInterval(lastVisit.date)));
}
return { ...getExtraFulfilledPayload(params), visits };
});
// Enhance the async thunk with extra actions
return Object.assign(asyncThunk, { progressChanged, large, fallbackToInterval });
};
export const lastVisitLoaderForLoader = (
doIntervalFallback: boolean,
loader: (params: ShlinkVisitsParams) => Promise<ShlinkVisits>,
): LastVisitLoader => async (excludeBots?: boolean) => (
!doIntervalFallback
? Promise.resolve(undefined)
: loader({ page: 1, itemsPerPage: 1, excludeBots }).then(({ data }) => data[0])
);
interface VisitsReducerOptions<State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>> {
name: string;
asyncThunkCreator: AT;
initialState: State;
filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[];
}
export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>>(
{ name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions<State, AT>,
) => {
const { pending, rejected, fulfilled, large, progressChanged, fallbackToInterval } = asyncThunkCreator;
const { reducer, actions } = createSlice({
name,
initialState,
reducers: {
cancelGetVisits: (state) => ({ ...state, cancelLoad: true }),
},
extraReducers: (builder) => {
builder.addCase(pending, () => ({ ...initialState, loading: true }));
builder.addCase(rejected, (_, { error }) => (
{ ...initialState, error: true, errorData: parseApiError(error) }
));
builder.addCase(fulfilled, (state, { payload }) => (
{ ...state, ...payload, loading: false, loadingLarge: false, error: false }
));
builder.addCase(large, (state) => ({ ...state, loadingLarge: true }));
builder.addCase(progressChanged, (state, { payload: progress }) => ({ ...state, progress }));
builder.addCase(fallbackToInterval, (state, { payload: fallbackInterval }) => (
{ ...state, fallbackInterval }
));
builder.addCase(createNewVisits, (state, { payload }) => {
const { visits } = state;
// @ts-expect-error TODO Fix type inference
const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit);
return !newVisits.length ? state : { ...state, visits: [...newVisits, ...visits] };
});
},
});
const { cancelGetVisits } = actions;
return { reducer, cancelGetVisits };
};

View File

@@ -0,0 +1,59 @@
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import { isBetween } from '../../../utils/helpers/date';
import { domainMatches } from '../../short-urls/helpers';
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import type { LoadVisits, VisitsInfo } from './types';
const REDUCER_PREFIX = 'shlink/domainVisits';
export const DEFAULT_DOMAIN = 'DEFAULT';
interface WithDomain {
domain: string;
}
export interface DomainVisits extends VisitsInfo, WithDomain {}
export interface LoadDomainVisits extends LoadVisits, WithDomain {}
const initialState: DomainVisits = {
visits: [],
domain: '',
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
typePrefix: `${REDUCER_PREFIX}/getDomainVisits`,
createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, getState) => {
const { getDomainVisits: getVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
domain,
{ ...query, page, itemsPerPage },
);
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params));
return [visitsLoader, lastVisitLoader];
},
getExtraFulfilledPayload: ({ domain, query = {} }: LoadDomainVisits) => ({ domain, query }),
shouldCancel: (getState) => getState().domainVisits.cancelLoad,
});
export const domainVisitsReducerCreator = (
asyncThunkCreator: ReturnType<typeof getDomainVisits>,
) => createVisitsReducer({
name: REDUCER_PREFIX,
initialState,
// @ts-expect-error TODO Fix type inference
asyncThunkCreator,
filterCreatedVisits: ({ domain, query = {} }, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(
({ shortUrl, visit }) =>
shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate),
);
},
});

View File

@@ -0,0 +1,41 @@
import type { ShlinkApiClientBuilder } from '../../../api/services/ShlinkApiClientBuilder';
import { isBetween } from '../../../utils/helpers/date';
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import type { VisitsInfo } from './types';
const REDUCER_PREFIX = 'shlink/orphanVisits';
const initialState: VisitsInfo = {
visits: [],
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
typePrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`,
createLoaders: ({ query = {}, doIntervalFallback = false }, getState) => {
const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) =>
shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage });
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits);
return [visitsLoader, lastVisitLoader];
},
getExtraFulfilledPayload: ({ query = {} }) => ({ query }),
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
});
export const nonOrphanVisitsReducerCreator = (
asyncThunkCreator: ReturnType<typeof getNonOrphanVisits>,
) => createVisitsReducer({
name: REDUCER_PREFIX,
initialState,
asyncThunkCreator,
filterCreatedVisits: ({ query = {} }, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate));
},
});

Some files were not shown because too many files have changed in this diff Show More