Implemented domain visits section

This commit is contained in:
Alejandro Celaya 2022-04-24 18:36:25 +02:00
parent 932dec3bde
commit 05254326cb
8 changed files with 176 additions and 7 deletions

View file

@ -56,6 +56,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 getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> => public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query) this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
.then(({ data }) => data.visits); .then(({ data }) => data.visits);

View file

@ -15,6 +15,7 @@ import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { VisitsInfo } from '../visits/types'; import { VisitsInfo } from '../visits/types';
import { Sidebar } from '../common/reducers/sidebar'; import { Sidebar } from '../common/reducers/sidebar';
import { DomainVisits } from '../visits/reducers/domainVisits';
export interface ShlinkState { export interface ShlinkState {
servers: ServersMap; servers: ServersMap;
@ -25,6 +26,7 @@ export interface ShlinkState {
shortUrlEdition: ShortUrlEdition; shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits; shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits; tagVisits: TagVisits;
domainVisits: DomainVisits;
orphanVisits: VisitsInfo; orphanVisits: VisitsInfo;
nonOrphanVisits: VisitsInfo; nonOrphanVisits: VisitsInfo;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;

View file

@ -27,14 +27,17 @@ export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedi
return ( return (
<DropdownBtnMenu isOpen={isOpen} toggle={toggle}> <DropdownBtnMenu isOpen={isOpen} toggle={toggle}>
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
</DropdownItem>
{withVisits && ( {withVisits && (
<DropdownItem tag={Link} to={`/server/${serverId}/domain/${isDefault ? 'DEFAULT' : domain.domain}/visits`}> <DropdownItem
tag={Link}
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? '_DEFAULT' : ''}/visits`}
>
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats <FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem> </DropdownItem>
)} )}
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
</DropdownItem>
<EditDomainRedirectsModal <EditDomainRedirectsModal
domain={domain} domain={domain}

View file

@ -7,6 +7,7 @@ import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits';
import domainVisitsReducer from '../visits/reducers/domainVisits';
import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import orphanVisitsReducer from '../visits/reducers/orphanVisits';
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits'; import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail'; import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
@ -30,6 +31,7 @@ export default combineReducers<ShlinkState>({
shortUrlEdition: shortUrlEditionReducer, shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer, shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer, tagVisits: tagVisitsReducer,
domainVisits: domainVisitsReducer,
orphanVisits: orphanVisitsReducer, orphanVisits: orphanVisitsReducer,
nonOrphanVisits: nonOrphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer,
shortUrlDetail: shortUrlDetailReducer, shortUrlDetail: shortUrlDetailReducer,

View file

@ -1,6 +1,7 @@
import { isNil } from 'ramda'; import { isNil } from 'ramda';
import { ShortUrl } from '../data'; import { ShortUrl } from '../data';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => { export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => {
if (isNil(domain)) { if (isNil(domain)) {
@ -9,3 +10,11 @@ export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: O
return shortUrl.shortCode === shortCode && shortUrl.domain === 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;
};

View file

@ -1,3 +1,46 @@
import { FC } from 'react'; import { useParams } from 'react-router-dom';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { ShlinkVisitsParams } from '../api/types';
import { DomainVisits as DomainVisitsState } from './reducers/domainVisits';
import { ReportExporter } from '../common/services/ReportExporter';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { toApiParams } from './types/helpers';
import { NormalizedVisit } from './types';
import VisitsStats from './VisitsStats';
import VisitsHeader from './VisitsHeader';
export const DomainVisits = (): FC => () => <span>DomainVisits</span>; export interface DomainVisitsProps extends CommonVisitsProps {
getDomainVisits: (domain: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
domainVisits: DomainVisitsState;
cancelGetDomainVisits: () => void;
}
export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getDomainVisits,
domainVisits,
cancelGetDomainVisits,
settings,
selectedServer,
}: DomainVisitsProps) => {
const goBack = useGoBack();
const { domain = '' } = useParams();
const [authority, domainId = authority] = domain.split('_');
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
getDomainVisits(domainId, 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}
selectedServer={selectedServer}
>
<VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} />
</VisitsStats>
);
}, () => [Topics.visits]);

View file

@ -0,0 +1,98 @@
import { Action, Dispatch } from 'redux';
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types';
import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
import { domainMatches } from '../../short-urls/helpers';
export const GET_DOMAIN_VISITS_START = 'shlink/domainVisits/GET_DOMAIN_VISITS_START';
export const GET_DOMAIN_VISITS_ERROR = 'shlink/domainVisits/GET_DOMAIN_VISITS_ERROR';
export const GET_DOMAIN_VISITS = 'shlink/domainVisits/GET_DOMAIN_VISITS';
export const GET_DOMAIN_VISITS_LARGE = 'shlink/domainVisits/GET_DOMAIN_VISITS_LARGE';
export const GET_DOMAIN_VISITS_CANCEL = 'shlink/domainVisits/GET_DOMAIN_VISITS_CANCEL';
export const GET_DOMAIN_VISITS_PROGRESS_CHANGED = 'shlink/domainVisits/GET_DOMAIN_VISITS_PROGRESS_CHANGED';
export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/domainVisits/GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL';
export const DEFAULT_DOMAIN = 'DEFAULT';
export interface DomainVisits extends VisitsInfo {
domain: string;
domainId: string;
}
export interface DomainVisitsAction extends Action<string> {
visits: Visit[];
domain: string;
query?: ShlinkVisitsParams;
}
type DomainVisitsCombinedAction = DomainVisitsAction
& VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction
& ApiErrorAction;
const initialState: DomainVisits = {
visits: [],
domain: '',
domainId: '',
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export default buildReducer<DomainVisits, DomainVisitsCombinedAction>({
[GET_DOMAIN_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_DOMAIN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_DOMAIN_VISITS]: (state, { visits, domain, query }) => (
{ ...state, visits, domain, query, loading: false, error: false }
),
[GET_DOMAIN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_DOMAIN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_DOMAIN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
[CREATE_VISITS]: (state, { createdVisits }) => {
const { domain, visits, query = {} } = state;
const { startDate, endDate } = query;
const newVisits = createdVisits
.filter(({ shortUrl, visit }) =>
shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate))
.map(({ visit }) => visit);
return { ...state, visits: [...newVisits, ...visits] };
},
}, initialState);
export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
domain: string,
query: ShlinkVisitsParams = {},
doIntervalFallback = false,
) => async (dispatch: Dispatch, getState: 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));
const shouldCancel = () => getState().domainVisits.cancelLoad;
const extraFinishActionData: Partial<DomainVisitsAction> = { domain, query };
const actionMap = {
start: GET_DOMAIN_VISITS_START,
large: GET_DOMAIN_VISITS_LARGE,
finish: GET_DOMAIN_VISITS,
error: GET_DOMAIN_VISITS_ERROR,
progress: GET_DOMAIN_VISITS_PROGRESS_CHANGED,
fallbackToInterval: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL,
};
return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
};
export const cancelGetDomainVisits = buildActionCreator(GET_DOMAIN_VISITS_CANCEL);

View file

@ -7,6 +7,7 @@ import { OrphanVisits } from '../OrphanVisits';
import { NonOrphanVisits } from '../NonOrphanVisits'; import { NonOrphanVisits } from '../NonOrphanVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
import { cancelGetDomainVisits, getDomainVisits } from '../reducers/domainVisits';
import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits';
import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
@ -30,7 +31,11 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'], ['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'],
)); ));
bottle.serviceFactory('DomainVisits', DomainVisits); bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter');
bottle.decorator('DomainVisits', connect(
['domainVisits', 'mercureInfo', 'settings', 'selectedServer'],
['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter'); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter');
bottle.decorator('OrphanVisits', connect( bottle.decorator('OrphanVisits', connect(
@ -54,6 +59,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetDomainVisits', () => cancelGetDomainVisits);
bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits);