mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #302 from acelaya-forks/feature/number-formatting
Feature/number formatting
This commit is contained in:
commit
2951d0d75e
9 changed files with 80 additions and 25 deletions
|
@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
#### Changed
|
#### Changed
|
||||||
|
|
||||||
* [#150](https://github.com/shlinkio/shlink-web-client/issues/150) The list of short URLs is now ordered by the creation date, showing newest results first.
|
* [#150](https://github.com/shlinkio/shlink-web-client/issues/150) The list of short URLs is now ordered by the creation date, showing newest results first.
|
||||||
|
* [#248](https://github.com/shlinkio/shlink-web-client/issues/248) Numbers displayed application-wide are now prettified.
|
||||||
* [#40](https://github.com/shlinkio/shlink-web-client/issues/40) Migrated project to TypeScript.
|
* [#40](https://github.com/shlinkio/shlink-web-client/issues/40) Migrated project to TypeScript.
|
||||||
|
|
||||||
#### Deprecated
|
#### Deprecated
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { pageIsEllipsis, keyForPage, NumberOrEllipsis, progressivePagination } from '../utils/helpers/pagination';
|
import {
|
||||||
|
pageIsEllipsis,
|
||||||
|
keyForPage,
|
||||||
|
NumberOrEllipsis,
|
||||||
|
progressivePagination,
|
||||||
|
prettifyPageNumber,
|
||||||
|
} from '../utils/helpers/pagination';
|
||||||
import './SimplePaginator.scss';
|
import './SimplePaginator.scss';
|
||||||
|
|
||||||
interface SimplePaginatorProps {
|
interface SimplePaginatorProps {
|
||||||
|
@ -29,7 +35,7 @@ const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, se
|
||||||
disabled={pageIsEllipsis(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
|
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{prettifyPageNumber(pageNumber)}</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { pageIsEllipsis, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||||
import { ShlinkPaginator } from '../utils/services/types';
|
import { ShlinkPaginator } from '../utils/services/types';
|
||||||
import './Paginator.scss';
|
import './Paginator.scss';
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||||
tag={Link}
|
tag={Link}
|
||||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
||||||
>
|
>
|
||||||
{pageNumber}
|
{prettifyPageNumber(pageNumber)}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
));
|
));
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { max, min, range } from 'ramda';
|
import { max, min, range } from 'ramda';
|
||||||
|
import { prettify } from './numbers';
|
||||||
|
|
||||||
const DELTA = 2;
|
const DELTA = 2;
|
||||||
|
|
||||||
|
@ -29,4 +30,7 @@ export const progressivePagination = (currentPage: number, pageCount: number): N
|
||||||
|
|
||||||
export const pageIsEllipsis = (pageNumber: NumberOrEllipsis): pageNumber is Ellipsis => pageNumber === ELLIPSIS;
|
export const pageIsEllipsis = (pageNumber: NumberOrEllipsis): pageNumber is Ellipsis => pageNumber === ELLIPSIS;
|
||||||
|
|
||||||
|
export const prettifyPageNumber = (pageNumber: NumberOrEllipsis): string =>
|
||||||
|
pageIsEllipsis(pageNumber) ? pageNumber : prettify(pageNumber);
|
||||||
|
|
||||||
export const keyForPage = (pageNumber: NumberOrEllipsis, index: number) => !pageIsEllipsis(pageNumber) ? `${pageNumber}` : `${pageNumber}_${index}`;
|
export const keyForPage = (pageNumber: NumberOrEllipsis, index: number) => !pageIsEllipsis(pageNumber) ? `${pageNumber}` : `${pageNumber}_${index}`;
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import bowser from 'bowser';
|
import bowser from 'bowser';
|
||||||
import { zipObj } from 'ramda';
|
import { zipObj } from 'ramda';
|
||||||
|
import { ChartData, ChartTooltipItem } from 'chart.js';
|
||||||
import { Empty, hasValue } from '../utils';
|
import { Empty, hasValue } from '../utils';
|
||||||
import { Stats, UserAgent } from '../../visits/types';
|
import { Stats, UserAgent } from '../../visits/types';
|
||||||
|
import { prettify } from './numbers';
|
||||||
|
|
||||||
const DEFAULT = 'Others';
|
const DEFAULT = 'Others';
|
||||||
const BROWSERS_WHITELIST = [
|
const BROWSERS_WHITELIST = [
|
||||||
|
@ -33,10 +35,31 @@ export const extractDomain = (url: string | Empty): string => {
|
||||||
return 'Direct';
|
return 'Direct';
|
||||||
}
|
}
|
||||||
|
|
||||||
const domain = url.includes('://') ? url.split('/')[2] : url.split('/')[0];
|
return url.split('/')[url.includes('://') ? 2 : 0]?.split(':')[0] ?? '';
|
||||||
|
|
||||||
return domain.split(':')[0];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fillTheGaps = (stats: Stats, labels: string[]): number[] =>
|
export const fillTheGaps = (stats: Stats, labels: string[]): number[] =>
|
||||||
Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats });
|
Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats });
|
||||||
|
|
||||||
|
export const renderDoughnutChartLabel = (
|
||||||
|
{ datasetIndex, index }: ChartTooltipItem,
|
||||||
|
{ labels, datasets }: ChartData,
|
||||||
|
) => {
|
||||||
|
const datasetLabel = index !== undefined && labels?.[index] || '';
|
||||||
|
const value = datasetIndex !== undefined && index !== undefined
|
||||||
|
&& datasets?.[datasetIndex]?.data?.[index]
|
||||||
|
|| '';
|
||||||
|
|
||||||
|
return `${datasetLabel}: ${prettify(Number(value))}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderNonDoughnutChartLabel = (labelToPick: 'yLabel' | 'xLabel') => (
|
||||||
|
item: ChartTooltipItem,
|
||||||
|
{ datasets }: ChartData,
|
||||||
|
) => {
|
||||||
|
const { datasetIndex } = item;
|
||||||
|
const value = item[labelToPick];
|
||||||
|
const datasetLabel = datasetIndex !== undefined && datasets?.[datasetIndex]?.label || '';
|
||||||
|
|
||||||
|
return `${datasetLabel}: ${prettify(Number(value))}`;
|
||||||
|
};
|
||||||
|
|
|
@ -3,8 +3,9 @@ import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||||
import { keys, values } from 'ramda';
|
import { keys, values } from 'ramda';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
||||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
import { fillTheGaps, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/visits';
|
||||||
import { Stats } from '../types';
|
import { Stats } from '../types';
|
||||||
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
import './DefaultChart.scss';
|
import './DefaultChart.scss';
|
||||||
|
|
||||||
export interface DefaultChartProps {
|
export interface DefaultChartProps {
|
||||||
|
@ -124,7 +125,13 @@ const DefaultChart = (
|
||||||
scales: !isBarChart ? undefined : {
|
scales: !isBarChart ? undefined : {
|
||||||
xAxes: [
|
xAxes: [
|
||||||
{
|
{
|
||||||
ticks: { beginAtZero: true, precision: 0, max } as any,
|
ticks: {
|
||||||
|
beginAtZero: true,
|
||||||
|
// @ts-expect-error
|
||||||
|
precision: 0,
|
||||||
|
callback: prettify,
|
||||||
|
max,
|
||||||
|
},
|
||||||
stacked: true,
|
stacked: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -132,9 +139,11 @@ const DefaultChart = (
|
||||||
},
|
},
|
||||||
tooltips: {
|
tooltips: {
|
||||||
intersect: !isBarChart,
|
intersect: !isBarChart,
|
||||||
|
|
||||||
// Do not show tooltip on items with empty label when in a bar chart
|
// Do not show tooltip on items with empty label when in a bar chart
|
||||||
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
|
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
|
||||||
|
callbacks: {
|
||||||
|
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onHover: !isBarChart ? undefined : ((e: ChangeEvent<HTMLElement>, chartElement: HorizontalBar[] | Doughnut[]) => {
|
onHover: !isBarChart ? undefined : ((e: ChangeEvent<HTMLElement>, chartElement: HorizontalBar[] | Doughnut[]) => {
|
||||||
const { target } = e;
|
const { target } = e;
|
||||||
|
|
|
@ -11,12 +11,13 @@ import {
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { always, cond, reverse } from 'ramda';
|
import { always, cond, reverse } from 'ramda';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { ChartData, ChartDataSets } from 'chart.js';
|
import { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
||||||
import { NormalizedVisit, Stats } from '../types';
|
import { NormalizedVisit, Stats } from '../types';
|
||||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
import { fillTheGaps, renderNonDoughnutChartLabel } from '../../utils/helpers/visits';
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import { rangeOf } from '../../utils/utils';
|
import { rangeOf } from '../../utils/utils';
|
||||||
import ToggleSwitch from '../../utils/ToggleSwitch';
|
import ToggleSwitch from '../../utils/ToggleSwitch';
|
||||||
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
import './LineChartCard.scss';
|
import './LineChartCard.scss';
|
||||||
|
|
||||||
interface LineChartCardProps {
|
interface LineChartCardProps {
|
||||||
|
@ -137,13 +138,18 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
|
||||||
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, '#F77F28'),
|
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, '#F77F28'),
|
||||||
].filter(Boolean) as ChartDataSets[],
|
].filter(Boolean) as ChartDataSets[],
|
||||||
};
|
};
|
||||||
const options = {
|
const options: ChartOptions = {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
scales: {
|
scales: {
|
||||||
yAxes: [
|
yAxes: [
|
||||||
{
|
{
|
||||||
ticks: { beginAtZero: true, precision: 0 },
|
ticks: {
|
||||||
|
beginAtZero: true,
|
||||||
|
// @ts-expect-error
|
||||||
|
precision: 0,
|
||||||
|
callback: prettify,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
xAxes: [
|
xAxes: [
|
||||||
|
@ -154,7 +160,11 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
|
||||||
},
|
},
|
||||||
tooltips: {
|
tooltips: {
|
||||||
intersect: false,
|
intersect: false,
|
||||||
|
// @ts-expect-error
|
||||||
axis: 'x',
|
axis: 'x',
|
||||||
|
callbacks: {
|
||||||
|
label: renderNonDoughnutChartLabel('yLabel'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||||
import { keys, values } from 'ramda';
|
import { keys, values } from 'ramda';
|
||||||
import DefaultChart from '../../../src/visits/helpers/DefaultChart';
|
import DefaultChart from '../../../src/visits/helpers/DefaultChart';
|
||||||
|
import { prettify } from '../../../src/utils/helpers/numbers';
|
||||||
|
|
||||||
describe('<DefaultChart />', () => {
|
describe('<DefaultChart />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
|
@ -69,7 +70,7 @@ describe('<DefaultChart />', () => {
|
||||||
expect(scales).toEqual({
|
expect(scales).toEqual({
|
||||||
xAxes: [
|
xAxes: [
|
||||||
{
|
{
|
||||||
ticks: { beginAtZero: true, precision: 0 },
|
ticks: { beginAtZero: true, precision: 0, callback: prettify },
|
||||||
stacked: true,
|
stacked: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -6,11 +6,12 @@ import moment from 'moment';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import LineChartCard from '../../../src/visits/helpers/LineChartCard';
|
import LineChartCard from '../../../src/visits/helpers/LineChartCard';
|
||||||
import ToggleSwitch from '../../../src/utils/ToggleSwitch';
|
import ToggleSwitch from '../../../src/utils/ToggleSwitch';
|
||||||
import { Visit } from '../../../src/visits/types';
|
import { NormalizedVisit } from '../../../src/visits/types';
|
||||||
|
import { prettify } from '../../../src/utils/helpers/numbers';
|
||||||
|
|
||||||
describe('<LineChartCard />', () => {
|
describe('<LineChartCard />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (visits: Visit[] = [], highlightedVisits: Visit[] = []) => {
|
const createWrapper = (visits: NormalizedVisit[] = [], highlightedVisits: NormalizedVisit[] = []) => {
|
||||||
wrapper = shallow(<LineChartCard title="Cool title" visits={visits} highlightedVisits={highlightedVisits} />);
|
wrapper = shallow(<LineChartCard title="Cool title" visits={visits} highlightedVisits={highlightedVisits} />);
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
|
@ -34,7 +35,7 @@ describe('<LineChartCard />', () => {
|
||||||
[[{ date: moment().subtract(7, 'month').format() }], 'monthly' ],
|
[[{ date: moment().subtract(7, 'month').format() }], 'monthly' ],
|
||||||
[[{ date: moment().subtract(1, 'year').format() }], 'monthly' ],
|
[[{ date: moment().subtract(1, 'year').format() }], 'monthly' ],
|
||||||
])('renders group menu and selects proper grouping item based on visits dates', (visits, expectedActiveItem) => {
|
])('renders group menu and selects proper grouping item based on visits dates', (visits, expectedActiveItem) => {
|
||||||
const wrapper = createWrapper(visits.map((visit) => Mock.of<Visit>(visit)));
|
const wrapper = createWrapper(visits.map((visit) => Mock.of<NormalizedVisit>(visit)));
|
||||||
const items = wrapper.find(DropdownItem);
|
const items = wrapper.find(DropdownItem);
|
||||||
|
|
||||||
expect(items).toHaveLength(4);
|
expect(items).toHaveLength(4);
|
||||||
|
@ -58,7 +59,7 @@ describe('<LineChartCard />', () => {
|
||||||
scales: {
|
scales: {
|
||||||
yAxes: [
|
yAxes: [
|
||||||
{
|
{
|
||||||
ticks: { beginAtZero: true, precision: 0 },
|
ticks: { beginAtZero: true, precision: 0, callback: prettify },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
xAxes: [
|
xAxes: [
|
||||||
|
@ -67,16 +68,16 @@ describe('<LineChartCard />', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
tooltips: {
|
tooltips: expect.objectContaining({
|
||||||
intersect: false,
|
intersect: false,
|
||||||
axis: 'x',
|
axis: 'x',
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[[ Mock.of<Visit>({}) ], [], 1 ],
|
[[ Mock.of<NormalizedVisit>({}) ], [], 1 ],
|
||||||
[[ Mock.of<Visit>({}) ], [ Mock.of<Visit>({}) ], 2 ],
|
[[ Mock.of<NormalizedVisit>({}) ], [ Mock.of<NormalizedVisit>({}) ], 2 ],
|
||||||
])('renders chart with expected data', (visits, highlightedVisits, expectedLines) => {
|
])('renders chart with expected data', (visits, highlightedVisits, expectedLines) => {
|
||||||
const wrapper = createWrapper(visits, highlightedVisits);
|
const wrapper = createWrapper(visits, highlightedVisits);
|
||||||
const chart = wrapper.find(Line);
|
const chart = wrapper.find(Line);
|
||||||
|
@ -87,8 +88,8 @@ describe('<LineChartCard />', () => {
|
||||||
|
|
||||||
it('includes stats for visits with no dates if selected', () => {
|
it('includes stats for visits with no dates if selected', () => {
|
||||||
const wrapper = createWrapper([
|
const wrapper = createWrapper([
|
||||||
Mock.of<Visit>({ date: '2016-04-01' }),
|
Mock.of<NormalizedVisit>({ date: '2016-04-01' }),
|
||||||
Mock.of<Visit>({ date: '2016-01-01' }),
|
Mock.of<NormalizedVisit>({ date: '2016-01-01' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect((wrapper.find(Line).prop('data') as any).labels).toHaveLength(2);
|
expect((wrapper.find(Line).prop('data') as any).labels).toHaveLength(2);
|
||||||
|
|
Loading…
Reference in a new issue