Merge pull request #302 from acelaya-forks/feature/number-formatting

Feature/number formatting
This commit is contained in:
Alejandro Celaya 2020-09-19 10:16:54 +02:00 committed by GitHub
commit 2951d0d75e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 80 additions and 25 deletions

View file

@ -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

View file

@ -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}>

View file

@ -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>
)); ));

View file

@ -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}`;

View file

@ -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))}`;
};

View file

@ -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;

View file

@ -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'),
},
}, },
}; };

View file

@ -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,
}, },
], ],

View file

@ -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);