From d9e39eee2b7c080c1d3c0cba57e5c61580a99f7e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 7 Dec 2020 12:12:39 +0100 Subject: [PATCH] Added new reducer for visits overview, and added it to overview page --- src/container/types.ts | 2 + src/reducers/index.ts | 2 + src/servers/Overview.tsx | 25 ++-- src/servers/services/provideServices.ts | 4 +- src/short-urls/CreateShortUrl.scss | 8 ++ src/short-urls/CreateShortUrl.tsx | 152 ++++++++++---------- src/utils/services/ShlinkApiClient.ts | 5 + src/utils/services/types.ts | 4 + src/visits/reducers/visitsOverview.ts | 52 +++++++ src/visits/services/provideServices.ts | 2 + test/utils/services/ShlinkApiClient.test.ts | 16 ++- 11 files changed, 181 insertions(+), 91 deletions(-) create mode 100644 src/short-urls/CreateShortUrl.scss create mode 100644 src/visits/reducers/visitsOverview.ts diff --git a/src/container/types.ts b/src/container/types.ts index d5c4d8e0..f6197b54 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -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; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 6ec599e1..705129ba 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -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({ @@ -38,4 +39,5 @@ export default combineReducers({ mercureInfo: mercureInfoReducer, settings: settingsReducer, domainsList: domainsListReducer, + visitsOverview: visitsOverviewReducer, }); diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index fa85b2dc..8cbad585 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -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) => boundToMercureHub(( - { shortUrlsList, listShortUrls, listTags, tagsList, selectedServer }: OverviewConnectProps, -) => { - const { loading, error, shortUrls } = shortUrlsList; +export const Overview = (ShortUrlsTable: FC) => 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) => boundToMerc
Visits - ? + {loadingVisits ? 'Loading...' : prettify(visitsCount)}
Short URLs - {loading && !error && 'Loading...'} - {error && !loading && 'Failed :('} - {!error && !loading && prettify(shortUrls?.pagination.totalItems ?? 0)} + {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index 7101a102..ebf127bf 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -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 diff --git a/src/short-urls/CreateShortUrl.scss b/src/short-urls/CreateShortUrl.scss new file mode 100644 index 00000000..e1a8202c --- /dev/null +++ b/src/short-urls/CreateShortUrl.scss @@ -0,0 +1,8 @@ +@import '../utils/base'; + +.create-short-url__save-btn { + @media (max-width: $xsMax) { + width: 100%; + display: block; + } +} diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index 442ddd87..fa26689e 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -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, ) => ({ 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 = ( /> - -
- -
+
+ +
-
-
- {renderOptionalInput('customSlug', 'Custom slug', 'text', { - disabled: hasValue(shortUrlCreation.shortCodeLength), - })} -
-
- {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', - }, - })} -
-
- {!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 && ( - - setShortUrlCreation({ ...shortUrlCreation, domain })} - /> - - )} -
+
+
+ {renderOptionalInput('customSlug', 'Custom slug', 'text', { + disabled: hasValue(shortUrlCreation.shortCodeLength), + })}
- -
-
- {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} -
-
- {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })} -
-
- {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })} -
+
+ {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', + }, + })}
+
+ {!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 && ( + + setShortUrlCreation({ ...shortUrlCreation, domain })} + /> + + )} +
+
- -
-
- - setShortUrlCreation({ ...shortUrlCreation, validateUrl })} - > - Validate URL - - -
-
+
+
+ {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} +
+
+ {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })} +
+
+ {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })} +
+
+ + +
+
+ setShortUrlCreation({ ...shortUrlCreation, findIfExists })} + checked={shortUrlCreation.validateUrl} + onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })} > - Use existing URL if found + Validate URL - -
+
-
- +
+ setShortUrlCreation({ ...shortUrlCreation, findIfExists })} + > + Use existing URL if found + + +
+
+ -
- - +
diff --git a/src/utils/services/ShlinkApiClient.ts b/src/utils/services/ShlinkApiClient.ts index 03f5fa71..089ffc35 100644 --- a/src/utils/services/ShlinkApiClient.ts +++ b/src/utils/services/ShlinkApiClient.ts @@ -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 => + this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET') + .then(({ data }) => data.visits); + public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise => this.performRequest(`/short-urls/${shortCode}`, 'GET', { domain }) .then(({ data }) => data); diff --git a/src/utils/services/types.ts b/src/utils/services/types.ts index 6e70bd36..365bc6d7 100644 --- a/src/utils/services/types.ts +++ b/src/utils/services/types.ts @@ -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; diff --git a/src/visits/reducers/visitsOverview.ts b/src/visits/reducers/visitsOverview.ts new file mode 100644 index 00000000..26e47238 --- /dev/null +++ b/src/visits/reducers/visitsOverview.ts @@ -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; + +const initialState: VisitsOverview = { + visitsCount: 0, + loading: false, + error: false, +}; + +export default buildReducer({ + [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 }); + } +}; diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 5f51c685..40809d0e 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -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; diff --git a/test/utils/services/ShlinkApiClient.test.ts b/test/utils/services/ShlinkApiClient.test.ts index 4838712f..09ff6a90 100644 --- a/test/utils/services/ShlinkApiClient.test.ts +++ b/test/utils/services/ShlinkApiClient.test.ts @@ -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(); + 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); + }); + }); });