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 { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { TagVisits } from '../visits/reducers/tagVisits'; import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList'; import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
export interface ShlinkState { export interface ShlinkState {
servers: ServersMap; servers: ServersMap;
@ -35,6 +36,7 @@ export interface ShlinkState {
mercureInfo: MercureInfo; mercureInfo: MercureInfo;
settings: Settings; settings: Settings;
domainsList: DomainsList; domainsList: DomainsList;
visitsOverview: VisitsOverview;
} }
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any; 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 mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings'; import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList'; import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import { ShlinkState } from '../container/types'; import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({ export default combineReducers<ShlinkState>({
@ -38,4 +39,5 @@ export default combineReducers<ShlinkState>({
mercureInfo: mercureInfoReducer, mercureInfo: mercureInfoReducer,
settings: settingsReducer, settings: settingsReducer,
domainsList: domainsListReducer, domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
}); });

View file

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

View file

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

View file

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

View file

@ -44,6 +44,10 @@ export interface ShlinkVisits {
pagination?: ShlinkPaginator; // Is only optional in old Shlink versions pagination?: ShlinkPaginator; // Is only optional in old Shlink versions
} }
export interface ShlinkVisitsOverview {
visitsCount: number;
}
export interface ShlinkVisitsParams { export interface ShlinkVisitsParams {
domain?: OptionalString; domain?: OptionalString;
page?: number; 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 { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
import TagVisits from '../TagVisits'; import TagVisits from '../TagVisits';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { loadVisitsOverview } from '../reducers/visitsOverview';
import * as visitsParser from './VisitsParser'; import * as visitsParser from './VisitsParser';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
@ -35,6 +36,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
bottle.serviceFactory('createNewVisits', () => createNewVisits); bottle.serviceFactory('createNewVisits', () => createNewVisits);
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient');
}; };
export default provideServices; export default provideServices;

View file

@ -2,7 +2,7 @@ import { AxiosInstance, AxiosRequestConfig } from 'axios';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
import { OptionalString } from '../../../src/utils/utils'; import { OptionalString } from '../../../src/utils/utils';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { ShlinkDomain } from '../../../src/utils/services/types'; import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/utils/services/types';
describe('ShlinkApiClient', () => { describe('ShlinkApiClient', () => {
const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance; const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
@ -269,4 +269,18 @@ describe('ShlinkApiClient', () => {
expect(result).toEqual(expectedData); 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);
});
});
}); });