Compare commits

...

14 Commits

Author SHA1 Message Date
Alejandro Celaya
cc88f7678c Merge pull request #768 from shlinkio/develop
Release 3.8.2
2022-12-17 10:07:37 +01:00
Alejandro Celaya
653b470fec Merge pull request #767 from acelaya-forks/feature/fixes
Feature/fixes
2022-12-17 10:03:19 +01:00
Alejandro Celaya
2603f2f987 Added missing application/json content-type when calling Shlink with payload 2022-12-17 09:57:40 +01:00
Alejandro Celaya
b106b3cd0a Updated changelog 2022-12-17 09:29:34 +01:00
Alejandro Celaya
b2b6b3af18 Fixed visits query being lost when switching between sub-sections 2022-12-17 09:28:42 +01:00
Alejandro Celaya
f911f78c95 Merge pull request #763 from shlinkio/dependabot/npm_and_yarn/express-4.18.2
Bump express from 4.17.1 to 4.18.2
2022-12-14 19:06:22 +01:00
dependabot[bot]
a7560443f3 Bump express from 4.17.1 to 4.18.2
Bumps [express](https://github.com/expressjs/express) from 4.17.1 to 4.18.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.17.1...4.18.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-14 17:59:03 +00:00
Alejandro Celaya
ab757b2f67 Merge pull request #759 from shlinkio/develop
Release 3.8.1
2022-12-06 20:21:39 +01:00
Alejandro Celaya
261cc68624 Merge pull request #758 from acelaya-forks/feature/fix-visits-interval
Feature/fix visits interval
2022-12-06 20:20:22 +01:00
Alejandro Celaya
dc2db3a463 Updated changelog 2022-12-06 20:15:44 +01:00
Alejandro Celaya
ae625e4c8a Fixed all visits interval not working after loading the default configured interval unless switching to another one first 2022-12-06 20:13:43 +01:00
Alejandro Celaya
6f5c5b122f Fixed fallback interval not working on visits pages 2022-12-06 18:09:50 +01:00
Alejandro Celaya
5d712d7d78 Created helper curried function to compare two values 2022-12-05 17:29:59 +01:00
Alejandro Celaya
1654784471 Created now function and refactored intervalToDateRange 2022-12-05 17:18:00 +01:00
18 changed files with 520 additions and 306 deletions

View File

@@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.8.2] - 2022-12-17
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#766](https://github.com/shlinkio/shlink-web-client/issues/766) Fixed visits query being lost when switching between sub-sections.
* [#765](https://github.com/shlinkio/shlink-web-client/issues/765) Added missing `"Content-Type": "application/json"` to requests with payload, making older Shlink versions fail.
## [3.8.1] - 2022-12-06
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#756](https://github.com/shlinkio/shlink-web-client/issues/756) Fixed all visits interval not working unless switching to a different interval first.
* [#757](https://github.com/shlinkio/shlink-web-client/issues/757) Fixed visits fallback interval not working until the visits view has been loaded at least twice.
## [3.8.0] - 2022-12-03
### Added
* [#708](https://github.com/shlinkio/shlink-web-client/issues/708) Added support for API v3.

625
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,27 @@
import { Fetch } from '../../utils/types';
const applicationJsonHeader = { 'Content-Type': 'application/json' };
const withJsonContentType = (options?: RequestInit): RequestInit | undefined => {
if (!options?.body) {
return options;
}
return options ? {
...options,
headers: {
...(options.headers ?? {}),
...applicationJsonHeader,
},
} : {
headers: applicationJsonHeader,
};
};
export class HttpClient {
constructor(private readonly fetch: Fetch) {}
public readonly fetchJson = <T>(url: string, options?: RequestInit): Promise<T> =>
this.fetch(url, options).then(async (resp) => {
this.fetch(url, withJsonContentType(options)).then(async (resp) => {
const json = await resp.json();
if (!resp.ok) {
@@ -15,7 +32,7 @@ export class HttpClient {
});
public readonly fetchEmpty = (url: string, options?: RequestInit): Promise<void> =>
this.fetch(url, options).then(async (resp) => {
this.fetch(url, withJsonContentType(options)).then(async (resp) => {
if (!resp.ok) {
throw await resp.json();
}

View File

@@ -9,6 +9,7 @@ import {
intervalToDateRange,
rangeIsInterval,
dateRangeIsEmpty,
ALL,
} from '../helpers/dateIntervals';
import { DateRangeRow } from './DateRangeRow';
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
@@ -31,7 +32,7 @@ export const DateRangeSelector = (
const [activeDateRange, setActiveDateRange] = useState(initialIntervalIsRange ? undefined : initialDateRange);
const updateDateRange = (dateRange: DateRange) => {
setActiveInterval(dateRangeIsEmpty(dateRange) ? 'all' : undefined);
setActiveInterval(dateRangeIsEmpty(dateRange) ? ALL : undefined);
setActiveDateRange(dateRange);
onDatesChange(dateRange);
};

View File

@@ -1,5 +1,5 @@
import { parseISO, format as formatDate, getUnixTime, formatDistance } from 'date-fns';
import { isDateObject, STANDARD_DATE_AND_TIME_FORMAT } from '../helpers/date';
import { isDateObject, now, STANDARD_DATE_AND_TIME_FORMAT } from '../helpers/date';
export interface TimeProps {
date: Date | string;
@@ -12,7 +12,7 @@ export const Time = ({ date, format = STANDARD_DATE_AND_TIME_FORMAT, relative =
return (
<time dateTime={`${getUnixTime(dateObject)}000`}>
{relative ? `${formatDistance(new Date(), dateObject)} ago` : formatDate(dateObject, format)}
{relative ? `${formatDistance(now(), dateObject)} ago` : formatDate(dateObject, format)}
</time>
);
};

View File

@@ -9,6 +9,8 @@ export type DateOrString = Date | string;
type NullableDate = DateOrString | null;
export const now = () => new Date();
export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string';
const formatDateFromFormat = (date?: NullableDate, theFormat?: string): OptionalString => {
@@ -28,7 +30,7 @@ export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date,
export const formatInternational = formatDate();
export const parseDate = (date: string, theFormat: string) => parse(date, theFormat, new Date());
export const parseDate = (date: string, theFormat: string) => parse(date, theFormat, now());
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));

View File

@@ -1,13 +1,14 @@
import { subDays, startOfDay, endOfDay } from 'date-fns';
import { cond, filter, isEmpty, T } from 'ramda';
import { dateOrNull, DateOrString, formatInternational, isBeforeOrEqual, parseISO } from './date';
import { dateOrNull, DateOrString, formatInternational, isBeforeOrEqual, now, parseISO } from './date';
import { equals } from '../utils';
export interface DateRange {
startDate?: Date | null;
endDate?: Date | null;
}
const ALL = 'all';
export const ALL = 'all';
const INTERVAL_TO_STRING_MAP = {
today: 'Today',
yesterday: 'Yesterday',
@@ -64,39 +65,25 @@ export const rangeOrIntervalToString = (range?: DateRange | DateInterval): strin
return INTERVAL_TO_STRING_MAP[range];
};
const startOfDaysAgo = (daysAgo: number) => startOfDay(subDays(new Date(), daysAgo));
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) });
const startOfDaysAgo = (daysAgo: number) => startOfDay(subDays(now(), daysAgo));
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(now()) });
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
if (!dateInterval || dateInterval === ALL) {
return {};
}
switch (dateInterval) {
case 'today':
return endingToday(startOfDay(new Date()));
case 'yesterday':
return { startDate: startOfDaysAgo(1), endDate: endOfDay(subDays(new Date(), 1)) };
case 'last7Days':
return endingToday(startOfDaysAgo(7));
case 'last30Days':
return endingToday(startOfDaysAgo(30));
case 'last90Days':
return endingToday(startOfDaysAgo(90));
case 'last180Days':
return endingToday(startOfDaysAgo(180));
case 'last365Days':
return endingToday(startOfDaysAgo(365));
}
return {};
};
export const intervalToDateRange = cond<[DateInterval | undefined], DateRange>([
[equals('today'), () => endingToday(startOfDay(now()))],
[equals('yesterday'), () => ({ startDate: startOfDaysAgo(1), endDate: endOfDay(subDays(now(), 1)) })],
[equals('last7Days'), () => endingToday(startOfDaysAgo(7))],
[equals('last30Days'), () => endingToday(startOfDaysAgo(30))],
[equals('last90Days'), () => endingToday(startOfDaysAgo(90))],
[equals('last180Days'), () => endingToday(startOfDaysAgo(180))],
[equals('last365Days'), () => endingToday(startOfDaysAgo(365))],
[T, () => ({})],
]);
export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
const theDate: Date = parseISO(date);
const theDate = parseISO(date);
return cond<never, DateInterval>([
[() => isBeforeOrEqual(startOfDay(new Date()), theDate), () => 'today'],
[() => isBeforeOrEqual(startOfDay(now()), theDate), () => 'today'],
[() => isBeforeOrEqual(startOfDaysAgo(1), theDate), () => 'yesterday'],
[() => isBeforeOrEqual(startOfDaysAgo(7), theDate), () => 'last7Days'],
[() => isBeforeOrEqual(startOfDaysAgo(30), theDate), () => 'last30Days'],

View File

@@ -24,3 +24,5 @@ export type OptionalString = Optional<string>;
export const nonEmptyValueOrNull = <T>(value: T): T | null => (isEmpty(value) ? null : value);
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
export const equals = (value: any) => (otherValue: any) => value === otherValue;

View File

@@ -55,7 +55,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu
getVisits={loadVisits}
cancelGetVisits={cancelGetShortUrlVisits}
visitsInfo={shortUrlVisits}
domain={domain}
settings={settings}
exportCsv={exportCsv}
selectedServer={selectedServer}

View File

@@ -4,7 +4,7 @@ import { Button, Progress, Row } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie } from '@fortawesome/free-solid-svg-icons';
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { Route, Routes, Navigate } from 'react-router-dom';
import { Route, Routes, Navigate, useLocation } from 'react-router-dom';
import classNames from 'classnames';
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
import { Message } from '../utils/Message';
@@ -35,7 +35,6 @@ export type VisitsStatsProps = PropsWithChildren<{
settings: Settings;
selectedServer: SelectedServer;
cancelGetVisits: () => void;
domain?: string;
exportCsv: (visits: NormalizedVisit[]) => void;
isOrphanVisits?: boolean;
}>;
@@ -62,7 +61,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
visitsInfo,
getVisits,
cancelGetVisits,
domain,
settings,
exportCsv,
selectedServer,
@@ -86,11 +84,9 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
const botsSupported = supportsBotVisits(selectedServer);
const isFirstLoad = useRef(true);
const { search } = useLocation();
const buildSectionUrl = (subPath?: string) => {
const query = domain ? `?domain=${domain}` : '';
return !subPath ? `${query}` : `${subPath}${query}`;
};
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),
@@ -122,6 +118,12 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
getVisits({ dateRange: resolvedDateRange, filter: visitsFilter }, 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) {

View File

@@ -37,7 +37,7 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
({ startDate, endDate, orphanVisitsType, excludeBots, domain }: VisitsQuery): VisitsFilteringAndDomain => ({
domain,
filtering: {
dateRange: startDate || endDate ? datesToDateRange(startDate, endDate) : undefined,
dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined,
visitsFilter: { orphanVisitsType, excludeBots: excludeBots === 'true' },
},
}),
@@ -47,8 +47,8 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
const updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
const query: VisitsQuery = {
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || undefined,
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || undefined,
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
excludeBots: visitsFilter.excludeBots ? 'true' : undefined,
orphanVisitsType: visitsFilter.orphanVisitsType,
domain: theDomain,

View File

@@ -14,13 +14,39 @@ describe('HttpClient', () => {
await expect(httpClient.fetchJson('')).rejects.toEqual(theError);
});
it('return json on failure', async () => {
it.each([
[undefined],
[{}],
[{ body: undefined }],
[{ body: '' }],
])('return json on failure', async (options) => {
const theJson = { foo: 'bar' };
fetch.mockResolvedValue({ json: () => theJson, ok: true });
const result = await httpClient.fetchJson('');
const result = await httpClient.fetchJson('the_url', options);
expect(result).toEqual(theJson);
expect(fetch).toHaveBeenCalledWith('the_url', options);
});
it.each([
[{ body: 'the_body' }],
[{
body: 'the_body',
headers: {
'Content-Type': 'text/plain',
},
}],
])('forwards JSON content-type when appropriate', async (options) => {
const theJson = { foo: 'bar' };
fetch.mockResolvedValue({ json: () => theJson, ok: true });
const result = await httpClient.fetchJson('the_url', options);
expect(result).toEqual(theJson);
expect(fetch).toHaveBeenCalledWith('the_url', expect.objectContaining({
headers: { 'Content-Type': 'application/json' },
}));
});
});

View File

@@ -8,11 +8,10 @@ import {
rangeOrIntervalToString,
toDateRange,
} from '../../../src/utils/helpers/dateIntervals';
import { parseDate } from '../../../src/utils/helpers/date';
import { parseDate, now } from '../../../src/utils/helpers/date';
describe('date-types', () => {
const now = () => new Date();
const daysBack = (days: number) => subDays(new Date(), days);
const daysBack = (days: number) => subDays(now(), days);
describe('dateRangeIsEmpty', () => {
it.each([
@@ -26,9 +25,9 @@ describe('date-types', () => {
[{ startDate: undefined, endDate: undefined }, true],
[{ startDate: undefined, endDate: null }, true],
[{ startDate: null, endDate: undefined }, true],
[{ startDate: new Date() }, false],
[{ endDate: new Date() }, false],
[{ startDate: new Date(), endDate: new Date() }, false],
[{ startDate: now() }, false],
[{ endDate: now() }, false],
[{ startDate: now(), endDate: now() }, false],
])('returns proper result', (dateRange, expectedResult) => {
expect(dateRangeIsEmpty(dateRange)).toEqual(expectedResult);
});

View File

@@ -197,12 +197,12 @@ describe('domainVisitsReducer', () => {
it.each([
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 20)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 20)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last30Days' },
3,
],
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 100)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 100)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last180Days' },
3,
],

View File

@@ -177,12 +177,12 @@ describe('nonOrphanVisitsReducer', () => {
it.each([
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 5)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 5)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last7Days' },
3,
],
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 200)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 200)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last365Days' },
3,
],

View File

@@ -175,12 +175,12 @@ describe('orphanVisitsReducer', () => {
it.each([
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 5)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 5)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last7Days' },
3,
],
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 200)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 200)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last365Days' },
3,
],

View File

@@ -219,12 +219,12 @@ describe('shortUrlVisitsReducer', () => {
it.each([
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 5)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 5)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last7Days' },
3,
],
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 200)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 200)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last365Days' },
3,
],

View File

@@ -193,12 +193,12 @@ describe('tagVisitsReducer', () => {
it.each([
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 20)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 20)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last30Days' },
3,
],
[
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 100)) })],
[Mock.of<Visit>({ date: formatISO(subDays(now, 100)) })],
{ type: fallbackToIntervalAction.toString(), payload: 'last180Days' },
3,
],