mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-14 03:23:49 +00:00
Move stuff belonging to the component to shlink-web-component
This commit is contained in:
65
src/shlink-web-component/domains/DomainRow.tsx
Normal file
65
src/shlink-web-component/domains/DomainRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
src/shlink-web-component/domains/DomainSelector.scss
Normal file
19
src/shlink-web-component/domains/DomainSelector.scss
Normal 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);
|
||||
}
|
||||
74
src/shlink-web-component/domains/DomainSelector.tsx
Normal file
74
src/shlink-web-component/domains/DomainSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
src/shlink-web-component/domains/ManageDomains.tsx
Normal file
77
src/shlink-web-component/domains/ManageDomains.tsx
Normal 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()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
src/shlink-web-component/domains/data/index.ts
Normal file
7
src/shlink-web-component/domains/data/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ShlinkDomain } from '../../../api/types';
|
||||
|
||||
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||
|
||||
export interface Domain extends ShlinkDomain {
|
||||
status: DomainStatus;
|
||||
}
|
||||
51
src/shlink-web-component/domains/helpers/DomainDropdown.tsx
Normal file
51
src/shlink-web-component/domains/helpers/DomainDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
22
src/shlink-web-component/domains/reducers/domainRedirects.ts
Normal file
22
src/shlink-web-component/domains/reducers/domainRedirects.ts
Normal 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 };
|
||||
},
|
||||
);
|
||||
126
src/shlink-web-component/domains/reducers/domainsList.ts
Normal file
126
src/shlink-web-component/domains/reducers/domainsList.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
34
src/shlink-web-component/domains/services/provideServices.ts
Normal file
34
src/shlink-web-component/domains/services/provideServices.ts
Normal 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');
|
||||
};
|
||||
119
src/shlink-web-component/overview/Overview.tsx
Normal file
119
src/shlink-web-component/overview/Overview.tsx
Normal 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 »</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 »</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]);
|
||||
69
src/shlink-web-component/short-urls/CreateShortUrl.tsx
Normal file
69
src/shlink-web-component/short-urls/CreateShortUrl.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
101
src/shlink-web-component/short-urls/EditShortUrl.tsx
Normal file
101
src/shlink-web-component/short-urls/EditShortUrl.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
52
src/shlink-web-component/short-urls/Paginator.tsx
Normal file
52
src/shlink-web-component/short-urls/Paginator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
9
src/shlink-web-component/short-urls/ShortUrlForm.scss
Normal file
9
src/shlink-web-component/short-urls/ShortUrlForm.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.short-url-form p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.short-url-form .card {
|
||||
height: 100%;
|
||||
}
|
||||
274
src/shlink-web-component/short-urls/ShortUrlForm.tsx
Normal file
274
src/shlink-web-component/short-urls/ShortUrlForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
.short-urls-filtering-bar__tags-icon {
|
||||
vertical-align: bottom;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
125
src/shlink-web-component/short-urls/ShortUrlsFilteringBar.tsx
Normal file
125
src/shlink-web-component/short-urls/ShortUrlsFilteringBar.tsx
Normal 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>;
|
||||
123
src/shlink-web-component/short-urls/ShortUrlsList.tsx
Normal file
123
src/shlink-web-component/short-urls/ShortUrlsList.tsx
Normal 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]);
|
||||
7
src/shlink-web-component/short-urls/ShortUrlsTable.scss
Normal file
7
src/shlink-web-component/short-urls/ShortUrlsTable.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.short-urls-table.short-urls-table {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.short-urls-table__header-cell--with-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
94
src/shlink-web-component/short-urls/ShortUrlsTable.tsx
Normal file
94
src/shlink-web-component/short-urls/ShortUrlsTable.tsx
Normal 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>
|
||||
/
|
||||
<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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
<b><i>"Use existing URL if found"</i></b>
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
93
src/shlink-web-component/short-urls/data/index.ts
Normal file
93
src/shlink-web-component/short-urls/data/index.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.create-short-url-result__copy-btn {
|
||||
margin-left: 10px;
|
||||
vertical-align: inherit;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
.qr-code-modal__img {
|
||||
max-width: 100%;
|
||||
box-shadow: 0 0 .25rem rgb(0 0 0 / .2);
|
||||
}
|
||||
104
src/shlink-web-component/short-urls/helpers/QrCodeModal.tsx
Normal file
104
src/shlink-web-component/short-urls/helpers/QrCodeModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
95
src/shlink-web-component/short-urls/helpers/ShortUrlsRow.tsx
Normal file
95
src/shlink-web-component/short-urls/helpers/ShortUrlsRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
29
src/shlink-web-component/short-urls/helpers/Tags.tsx
Normal file
29
src/shlink-web-component/short-urls/helpers/Tags.tsx
Normal 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)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
77
src/shlink-web-component/short-urls/helpers/hooks.ts
Normal file
77
src/shlink-web-component/short-urls/helpers/hooks.ts
Normal 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];
|
||||
};
|
||||
49
src/shlink-web-component/short-urls/helpers/index.ts
Normal file
49
src/shlink-web-component/short-urls/helpers/index.ts
Normal 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, '/');
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 }),
|
||||
);
|
||||
},
|
||||
});
|
||||
115
src/shlink-web-component/short-urls/reducers/shortUrlsList.ts
Normal file
115
src/shlink-web-component/short-urls/reducers/shortUrlsList.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
100
src/shlink-web-component/short-urls/services/provideServices.ts
Normal file
100
src/shlink-web-component/short-urls/services/provideServices.ts
Normal 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');
|
||||
};
|
||||
103
src/shlink-web-component/tags/TagsList.tsx
Normal file
103
src/shlink-web-component/tags/TagsList.tsx
Normal 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]);
|
||||
10
src/shlink-web-component/tags/TagsTable.scss
Normal file
10
src/shlink-web-component/tags/TagsTable.scss
Normal 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;
|
||||
}
|
||||
71
src/shlink-web-component/tags/TagsTable.tsx
Normal file
71
src/shlink-web-component/tags/TagsTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
59
src/shlink-web-component/tags/TagsTableRow.tsx
Normal file
59
src/shlink-web-component/tags/TagsTableRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
src/shlink-web-component/tags/data/TagsListChildrenProps.ts
Normal file
18
src/shlink-web-component/tags/data/TagsListChildrenProps.ts
Normal 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;
|
||||
}
|
||||
15
src/shlink-web-component/tags/data/index.ts
Normal file
15
src/shlink-web-component/tags/data/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
11
src/shlink-web-component/tags/helpers/EditTagModal.scss
Normal file
11
src/shlink-web-component/tags/helpers/EditTagModal.scss
Normal 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;
|
||||
}
|
||||
82
src/shlink-web-component/tags/helpers/EditTagModal.tsx
Normal file
82
src/shlink-web-component/tags/helpers/EditTagModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/shlink-web-component/tags/helpers/Tag.scss
Normal file
24
src/shlink-web-component/tags/helpers/Tag.scss
Normal 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;
|
||||
}
|
||||
26
src/shlink-web-component/tags/helpers/Tag.tsx
Normal file
26
src/shlink-web-component/tags/helpers/Tag.tsx
Normal 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}>×</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
10
src/shlink-web-component/tags/helpers/TagBullet.scss
Normal file
10
src/shlink-web-component/tags/helpers/TagBullet.scss
Normal 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;
|
||||
}
|
||||
14
src/shlink-web-component/tags/helpers/TagBullet.tsx
Normal file
14
src/shlink-web-component/tags/helpers/TagBullet.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
71
src/shlink-web-component/tags/helpers/TagsSelector.tsx
Normal file
71
src/shlink-web-component/tags/helpers/TagsSelector.tsx
Normal 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(',')])],
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
45
src/shlink-web-component/tags/reducers/tagDelete.ts
Normal file
45
src/shlink-web-component/tags/reducers/tagDelete.ts
Normal 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 };
|
||||
};
|
||||
67
src/shlink-web-component/tags/reducers/tagEdit.ts
Normal file
67
src/shlink-web-component/tags/reducers/tagEdit.ts
Normal 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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
155
src/shlink-web-component/tags/reducers/tagsList.ts
Normal file
155
src/shlink-web-component/tags/reducers/tagsList.ts
Normal 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(...) ]
|
||||
}));
|
||||
},
|
||||
});
|
||||
60
src/shlink-web-component/tags/services/provideServices.ts
Normal file
60
src/shlink-web-component/tags/services/provideServices.ts
Normal 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);
|
||||
};
|
||||
44
src/shlink-web-component/visits/DomainVisits.tsx
Normal file
44
src/shlink-web-component/visits/DomainVisits.tsx
Normal 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]);
|
||||
40
src/shlink-web-component/visits/NonOrphanVisits.tsx
Normal file
40
src/shlink-web-component/visits/NonOrphanVisits.tsx
Normal 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]);
|
||||
43
src/shlink-web-component/visits/OrphanVisits.tsx
Normal file
43
src/shlink-web-component/visits/OrphanVisits.tsx
Normal 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]);
|
||||
63
src/shlink-web-component/visits/ShortUrlVisits.tsx
Normal file
63
src/shlink-web-component/visits/ShortUrlVisits.tsx
Normal 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))] : []));
|
||||
@@ -0,0 +1,3 @@
|
||||
.short-url-visits-header__created-at {
|
||||
cursor: default;
|
||||
}
|
||||
45
src/shlink-web-component/visits/ShortUrlVisitsHeader.tsx
Normal file
45
src/shlink-web-component/visits/ShortUrlVisitsHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
src/shlink-web-component/visits/TagVisits.tsx
Normal file
44
src/shlink-web-component/visits/TagVisits.tsx
Normal 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]);
|
||||
23
src/shlink-web-component/visits/TagVisitsHeader.tsx
Normal file
23
src/shlink-web-component/visits/TagVisitsHeader.tsx
Normal 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} />;
|
||||
};
|
||||
38
src/shlink-web-component/visits/VisitsHeader.tsx
Normal file
38
src/shlink-web-component/visits/VisitsHeader.tsx
Normal 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>
|
||||
);
|
||||
336
src/shlink-web-component/visits/VisitsStats.tsx
Normal file
336
src/shlink-web-component/visits/VisitsStats.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
39
src/shlink-web-component/visits/VisitsTable.scss
Normal file
39
src/shlink-web-component/visits/VisitsTable.scss
Normal 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;
|
||||
}
|
||||
216
src/shlink-web-component/visits/VisitsTable.tsx
Normal file
216
src/shlink-web-component/visits/VisitsTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
4
src/shlink-web-component/visits/charts/ChartCard.scss
Normal file
4
src/shlink-web-component/visits/charts/ChartCard.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.chart-card__footer--sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
16
src/shlink-web-component/visits/charts/ChartCard.tsx
Normal file
16
src/shlink-web-component/visits/charts/ChartCard.tsx
Normal 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>
|
||||
);
|
||||
73
src/shlink-web-component/visits/charts/DoughnutChart.tsx
Normal file
73
src/shlink-web-component/visits/charts/DoughnutChart.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
15
src/shlink-web-component/visits/charts/DoughnutChartCard.tsx
Normal file
15
src/shlink-web-component/visits/charts/DoughnutChartCard.tsx
Normal 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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
134
src/shlink-web-component/visits/charts/HorizontalBarChart.tsx
Normal file
134
src/shlink-web-component/visits/charts/HorizontalBarChart.tsx
Normal 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)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
@import '../../../utils/base';
|
||||
|
||||
.line-chart-card__body canvas {
|
||||
height: 300px !important;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
height: 400px !important;
|
||||
}
|
||||
}
|
||||
270
src/shlink-web-component/visits/charts/LineChartCard.tsx
Normal file
270
src/shlink-web-component/visits/charts/LineChartCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
141
src/shlink-web-component/visits/charts/SortableBarChartCard.tsx
Normal file
141
src/shlink-web-component/visits/charts/SortableBarChartCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
45
src/shlink-web-component/visits/helpers/MapModal.scss
Normal file
45
src/shlink-web-component/visits/helpers/MapModal.scss
Normal 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;
|
||||
}
|
||||
56
src/shlink-web-component/visits/helpers/MapModal.tsx
Normal file
56
src/shlink-web-component/visits/helpers/MapModal.tsx
Normal 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='&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>
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
.open-map-modal-btn__btn.open-map-modal-btn__btn {
|
||||
padding: 0;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
55
src/shlink-web-component/visits/helpers/OpenMapModalBtn.tsx
Normal file
55
src/shlink-web-component/visits/helpers/OpenMapModalBtn.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
67
src/shlink-web-component/visits/helpers/hooks.ts
Normal file
67
src/shlink-web-component/visits/helpers/hooks.ts
Normal 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];
|
||||
};
|
||||
144
src/shlink-web-component/visits/reducers/common.ts
Normal file
144
src/shlink-web-component/visits/reducers/common.ts
Normal 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 };
|
||||
};
|
||||
59
src/shlink-web-component/visits/reducers/domainVisits.ts
Normal file
59
src/shlink-web-component/visits/reducers/domainVisits.ts
Normal 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),
|
||||
);
|
||||
},
|
||||
});
|
||||
41
src/shlink-web-component/visits/reducers/nonOrphanVisits.ts
Normal file
41
src/shlink-web-component/visits/reducers/nonOrphanVisits.ts
Normal 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
Reference in New Issue
Block a user