Added new reducer for visits overview, and added it to overview page

This commit is contained in:
Alejandro Celaya 2020-12-07 12:12:39 +01:00
parent 032e9c53f3
commit d9e39eee2b
11 changed files with 181 additions and 91 deletions

View file

@ -15,6 +15,7 @@ import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
export interface ShlinkState {
servers: ServersMap;
@ -35,6 +36,7 @@ export interface ShlinkState {
mercureInfo: MercureInfo;
settings: Settings;
domainsList: DomainsList;
visitsOverview: VisitsOverview;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

View file

@ -17,6 +17,7 @@ import tagEditReducer from '../tags/reducers/tagEdit';
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
@ -38,4 +39,5 @@ export default combineReducers<ShlinkState>({
mercureInfo: mercureInfoReducer,
settings: settingsReducer,
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
});

View file

@ -7,6 +7,7 @@ import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { isServerWithId, SelectedServer } from './data';
import './Overview.scss';
@ -16,18 +17,28 @@ interface OverviewConnectProps {
listTags: Function;
tagsList: TagsList;
selectedServer: SelectedServer;
visitsOverview: VisitsOverview;
loadVisitsOverview: Function;
}
export const Overview = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub((
{ shortUrlsList, listShortUrls, listTags, tagsList, selectedServer }: OverviewConnectProps,
) => {
const { loading, error, shortUrls } = shortUrlsList;
export const Overview = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
shortUrlsList,
listShortUrls,
listTags,
tagsList,
selectedServer,
loadVisitsOverview,
visitsOverview,
}: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount } = visitsOverview;
const serverId = !isServerWithId(selectedServer) ? '' : selectedServer.id;
useEffect(() => {
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
listTags();
loadVisitsOverview();
}, []);
return (
@ -36,16 +47,14 @@ export const Overview = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMerc
<div className="col-sm-4">
<Card className="overview__card mb-2" body>
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
<CardText tag="h2">?</CardText>
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
</Card>
</div>
<div className="col-sm-4">
<Card className="overview__card mb-2" body>
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
<CardText tag="h2">
{loading && !error && 'Loading...'}
{error && !loading && 'Failed :('}
{!error && !loading && prettify(shortUrls?.pagination.totalItems ?? 0)}
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</CardText>
</Card>
</div>

View file

@ -46,8 +46,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable');
bottle.decorator('Overview', connect(
[ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo' ],
[ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo' ],
[ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview' ],
[ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview' ],
));
// Services

View file

@ -0,0 +1,8 @@
@import '../utils/base';
.create-short-url__save-btn {
@media (max-width: $xsMax) {
width: 100%;
display: block;
}
}

View file

@ -1,15 +1,12 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe, replace, trim } from 'ramda';
import { FC, useState } from 'react';
import { Collapse, FormGroup, Input } from 'reactstrap';
import { Button, FormGroup, Input } from 'reactstrap';
import { InputType } from 'reactstrap/lib/Input';
import * as m from 'moment';
import DateInput, { DateInputProps } from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import { versionMatch, Versions } from '../utils/helpers/version';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import { useToggle } from '../utils/helpers/hooks';
import { isReachableServer, SelectedServer } from '../servers/data';
import { formatIsoDate } from '../utils/helpers/date';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
@ -18,6 +15,7 @@ import { ShortUrlData } from './data';
import { ShortUrlCreation } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
import './CreateShortUrl.scss';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
@ -51,7 +49,6 @@ const CreateShortUrl = (
DomainSelector: FC<DomainSelectorProps>,
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlCreation(initialState);
@ -106,94 +103,89 @@ const CreateShortUrl = (
/>
</div>
<Collapse isOpen={moreOptionsVisible}>
<div className="form-group">
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
</div>
<div className="form-group">
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
</div>
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
disabled: hasValue(shortUrlCreation.shortCodeLength),
})}
</div>
<div className="col-sm-4">
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
})}
</div>
<div className="col-sm-4">
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
{showDomainSelector && (
<FormGroup>
<DomainSelector
value={shortUrlCreation.domain}
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
/>
</FormGroup>
)}
</div>
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
disabled: hasValue(shortUrlCreation.shortCodeLength),
})}
</div>
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-4">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
</div>
<div className="col-sm-4">
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
</div>
<div className="col-sm-4">
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
})}
</div>
<div className="col-sm-4">
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
{showDomainSelector && (
<FormGroup>
<DomainSelector
value={shortUrlCreation.domain}
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
/>
</FormGroup>
)}
</div>
</div>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 row">
<div className="col-sm-6 text-center text-sm-left mb-2 mb-sm-0">
<ForServerVersion minVersion="2.4.0">
<Checkbox
inline
checked={shortUrlCreation.validateUrl}
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
>
Validate URL
</Checkbox>
</ForServerVersion>
</div>
<div className="col-sm-6 text-center text-sm-right">
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-4">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
</div>
<div className="col-sm-4">
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
</div>
</div>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 row">
<div className="col-sm-6 text-center text-sm-left mb-2 mb-sm-0">
<ForServerVersion minVersion="2.4.0">
<Checkbox
inline
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
checked={shortUrlCreation.validateUrl}
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
>
Use existing URL if found
Validate URL
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForServerVersion>
</div>
</ForServerVersion>
</Collapse>
<div className="col-sm-6 text-center text-sm-right">
<Checkbox
inline
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</div>
</ForServerVersion>
<div>
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
<div className="text-right">
<Button
outline
color="primary"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
className="create-short-url__save-btn"
>
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</button>
</Button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />

View file

@ -15,6 +15,7 @@ import {
ShlinkShortUrlMeta,
ShlinkDomain,
ShlinkDomainsResponse,
ShlinkVisitsOverview,
} from './types';
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
@ -50,6 +51,10 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits);
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
.then(({ data }) => data);

View file

@ -44,6 +44,10 @@ export interface ShlinkVisits {
pagination?: ShlinkPaginator; // Is only optional in old Shlink versions
}
export interface ShlinkVisitsOverview {
visitsCount: number;
}
export interface ShlinkVisitsParams {
domain?: OptionalString;
page?: number;

View file

@ -0,0 +1,52 @@
import { Action, Dispatch } from 'redux';
import { ShlinkVisitsOverview } from '../../utils/services/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types';
import { buildReducer } from '../../utils/helpers/redux';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
/* eslint-disable padding-line-between-statements */
export const GET_OVERVIEW_START = 'shlink/visitsOverview/GET_OVERVIEW_START';
export const GET_OVERVIEW_ERROR = 'shlink/visitsOverview/GET_OVERVIEW_ERROR';
export const GET_OVERVIEW = 'shlink/visitsOverview/GET_OVERVIEW';
/* eslint-enable padding-line-between-statements */
export interface VisitsOverview {
visitsCount: number;
loading: boolean;
error: boolean;
}
type GetVisitsOverviewAction = ShlinkVisitsOverview & Action<string>;
const initialState: VisitsOverview = {
visitsCount: 0,
loading: false,
error: false,
};
export default buildReducer<VisitsOverview, GetVisitsOverviewAction & CreateVisitsAction>({
[GET_OVERVIEW_START]: () => ({ ...initialState, loading: true }),
[GET_OVERVIEW_ERROR]: () => ({ ...initialState, error: true }),
[GET_OVERVIEW]: (_, { visitsCount }) => ({ ...initialState, visitsCount }),
[CREATE_VISITS]: ({ visitsCount, ...rest }, { createdVisits }) => ({
...rest,
visitsCount: visitsCount + createdVisits.length,
}),
}, initialState);
export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
dispatch: Dispatch,
getState: GetState,
) => {
dispatch({ type: GET_OVERVIEW_START });
try {
const { getVisitsOverview } = buildShlinkApiClient(getState);
const result = await getVisitsOverview();
dispatch({ type: GET_OVERVIEW, ...result });
} catch (e) {
dispatch({ type: GET_OVERVIEW_ERROR });
}
};

View file

@ -7,6 +7,7 @@ import { createNewVisits } from '../reducers/visitCreation';
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
import TagVisits from '../TagVisits';
import { ConnectDecorator } from '../../container/types';
import { loadVisitsOverview } from '../reducers/visitsOverview';
import * as visitsParser from './VisitsParser';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
@ -35,6 +36,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
bottle.serviceFactory('createNewVisits', () => createNewVisits);
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient');
};
export default provideServices;

View file

@ -2,7 +2,7 @@ import { AxiosInstance, AxiosRequestConfig } from 'axios';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
import { OptionalString } from '../../../src/utils/utils';
import { Mock } from 'ts-mockery';
import { ShlinkDomain } from '../../../src/utils/services/types';
import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/utils/services/types';
describe('ShlinkApiClient', () => {
const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
@ -269,4 +269,18 @@ describe('ShlinkApiClient', () => {
expect(result).toEqual(expectedData);
});
});
describe('getVisitsOverview', () => {
it('returns visits overview', async () => {
const expectedData = Mock.all<ShlinkVisitsOverview>();
const resp = { visits: expectedData };
const axiosSpy = createAxiosMock({ data: resp });
const { getVisitsOverview } = new ShlinkApiClient(axiosSpy, '', '');
const result = await getVisitsOverview();
expect(axiosSpy).toHaveBeenCalled();
expect(result).toEqual(expectedData);
});
});
});