mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-25 03:06:36 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ad6d9e1b | ||
|
|
169c69df2c | ||
|
|
0e8631ae9d | ||
|
|
812e391e34 | ||
|
|
4c1a044fd3 | ||
|
|
bb17dbe680 | ||
|
|
160de66b44 | ||
|
|
02b38cf84a | ||
|
|
2101dadfd7 | ||
|
|
782a5c1d35 | ||
|
|
de9f20b7a6 | ||
|
|
644caf7dfb | ||
|
|
f26deb51eb |
17
.travis.yml
17
.travis.yml
@@ -1,5 +1,7 @@
|
||||
language: node_js
|
||||
|
||||
sudo: false
|
||||
|
||||
node_js:
|
||||
- "stable"
|
||||
|
||||
@@ -16,7 +18,18 @@ script:
|
||||
- yarn test:ci
|
||||
- yarn build # Make sure the app can be built without errors
|
||||
|
||||
after_script:
|
||||
after_success:
|
||||
- yarn ocular coverage/clover.xml
|
||||
|
||||
sudo: false
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- yarn build ${TRAVIS_TAG#?}
|
||||
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
|
||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
|
||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -1,5 +1,34 @@
|
||||
# CHANGELOG
|
||||
|
||||
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).
|
||||
|
||||
## 1.1.1 - 2018-10-20
|
||||
|
||||
#### Added
|
||||
|
||||
* [#57](https://github.com/shlinkio/shlink-web-client/issues/57) Automated release generation in travis build.
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#63](https://github.com/shlinkio/shlink-web-client/issues/63) Improved how bar charts are rendered in stats page, making them try to calculate a bigger height for big data sets.
|
||||
* [#56](https://github.com/shlinkio/shlink-web-client/issues/56) Ensured `ColorGenerator` matches keys in a case insensitive way.
|
||||
* [#53](https://github.com/shlinkio/shlink-web-client/issues/53) Fixed missing margin between date fields in visits page for mobile devices.
|
||||
|
||||
|
||||
## 1.1.0 - 2018-09-16
|
||||
|
||||
#### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
||||
[](https://scrutinizer-ci.com/gshlinkio/shlink-web-client/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:css:fix": "yarn lint:css --fix",
|
||||
"start": "node scripts/start.js",
|
||||
"serve:build": "yarn serve ./build",
|
||||
"build": "node scripts/build.js",
|
||||
"test": "node scripts/test.js --env=jsdom --colors",
|
||||
"test:ci": "yarn test --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||
|
||||
@@ -11,6 +11,7 @@ const buildRandomColor = () =>
|
||||
.map(() => letters[floor(random() * letters.length)])
|
||||
.join('')
|
||||
}`;
|
||||
const normalizeKey = (key) => key.toLowerCase().trim();
|
||||
|
||||
export class ColorGenerator {
|
||||
constructor(storage) {
|
||||
@@ -19,21 +20,24 @@ export class ColorGenerator {
|
||||
}
|
||||
|
||||
getColorForKey = (key) => {
|
||||
const color = this.colors[key];
|
||||
const normalizedKey = normalizeKey(key);
|
||||
const color = this.colors[normalizedKey];
|
||||
|
||||
// If a color has not been set yet, generate a random one and save it
|
||||
if (!color) {
|
||||
this.setColorForKey(key, buildRandomColor());
|
||||
|
||||
return this.getColorForKey(key);
|
||||
return this.setColorForKey(normalizedKey, buildRandomColor());
|
||||
}
|
||||
|
||||
return color;
|
||||
};
|
||||
|
||||
setColorForKey = (key, color) => {
|
||||
this.colors[key] = color;
|
||||
const normalizedKey = normalizeKey(key);
|
||||
|
||||
this.colors[normalizedKey] = color;
|
||||
this.storage.set('colors', this.colors);
|
||||
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,51 +8,86 @@ const propTypes = {
|
||||
title: PropTypes.string,
|
||||
isBarChart: PropTypes.bool,
|
||||
stats: PropTypes.object,
|
||||
matchMedia: PropTypes.func,
|
||||
};
|
||||
const defaultProps = {
|
||||
matchMedia: global.window ? global.window.matchMedia : () => {},
|
||||
};
|
||||
|
||||
export function GraphCard({ title, isBarChart, stats }) {
|
||||
const generateGraphData = (stats) => ({
|
||||
labels: keys(stats),
|
||||
datasets: [
|
||||
{
|
||||
title,
|
||||
data: values(stats),
|
||||
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
||||
'#97BBCD',
|
||||
'#DCDCDC',
|
||||
'#F7464A',
|
||||
'#46BFBD',
|
||||
'#FDB45C',
|
||||
'#949FB1',
|
||||
'#4D5360',
|
||||
],
|
||||
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
const renderGraph = () => {
|
||||
const Component = isBarChart ? HorizontalBar : Doughnut;
|
||||
const options = {
|
||||
legend: isBarChart ? { display: false } : { position: 'right' },
|
||||
scales: isBarChart ? {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: { beginAtZero: true },
|
||||
},
|
||||
],
|
||||
} : null,
|
||||
};
|
||||
const generateGraphData = (title, isBarChart, labels, data) => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
title,
|
||||
data,
|
||||
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
||||
'#97BBCD',
|
||||
'#DCDCDC',
|
||||
'#F7464A',
|
||||
'#46BFBD',
|
||||
'#FDB45C',
|
||||
'#949FB1',
|
||||
'#4D5360',
|
||||
],
|
||||
borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return <Component data={generateGraphData(stats)} options={options} />;
|
||||
const determineGraphAspectRatio = (barsCount, isBarChart, matchMedia) => {
|
||||
const determineAspectRationModifier = () => {
|
||||
switch (true) {
|
||||
case matchMedia('(max-width: 1200px)').matches:
|
||||
return 1.5; // eslint-disable-line no-magic-numbers
|
||||
case matchMedia('(max-width: 992px)').matches:
|
||||
return 1.75; // eslint-disable-line no-magic-numbers
|
||||
case matchMedia('(max-width: 768px)').matches:
|
||||
return 2; // eslint-disable-line no-magic-numbers
|
||||
case matchMedia('(max-width: 576px)').matches:
|
||||
return 2.25; // eslint-disable-line no-magic-numbers
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>{title}</CardHeader>
|
||||
<CardBody>{renderGraph()}</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const MAX_BARS_WITHOUT_HEIGHT = 20;
|
||||
const DEFAULT_ASPECT_RATION = 2;
|
||||
const shouldCalculateAspectRatio = isBarChart && barsCount > MAX_BARS_WITHOUT_HEIGHT;
|
||||
|
||||
return shouldCalculateAspectRatio
|
||||
? MAX_BARS_WITHOUT_HEIGHT / determineAspectRationModifier() * DEFAULT_ASPECT_RATION / barsCount
|
||||
: DEFAULT_ASPECT_RATION;
|
||||
};
|
||||
|
||||
const renderGraph = (title, isBarChart, stats, matchMedia) => {
|
||||
const Component = isBarChart ? HorizontalBar : Doughnut;
|
||||
const labels = keys(stats);
|
||||
const data = values(stats);
|
||||
const aspectRatio = determineGraphAspectRatio(labels.length, isBarChart, matchMedia);
|
||||
const options = {
|
||||
aspectRatio,
|
||||
legend: isBarChart ? { display: false } : { position: 'right' },
|
||||
scales: isBarChart ? {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: { beginAtZero: true },
|
||||
},
|
||||
],
|
||||
} : null,
|
||||
};
|
||||
|
||||
return <Component data={generateGraphData(title, isBarChart, labels, data)} options={options} height={null} />;
|
||||
};
|
||||
|
||||
const GraphCard = ({ title, isBarChart, stats, matchMedia }) => (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>{title}</CardHeader>
|
||||
<CardBody>{renderGraph(title, isBarChart, stats, matchMedia)}</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
GraphCard.propTypes = propTypes;
|
||||
GraphCard.defaultProps = defaultProps;
|
||||
|
||||
export default GraphCard;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
processReferrersStats,
|
||||
} from './services/VisitsParser';
|
||||
import { VisitsHeader } from './VisitsHeader';
|
||||
import { GraphCard } from './GraphCard';
|
||||
import GraphCard from './GraphCard';
|
||||
import { getShortUrlDetail, shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||
import './ShortUrlVisits.scss';
|
||||
|
||||
@@ -121,6 +121,7 @@ export class ShortUrlsVisitsComponent extends React.Component {
|
||||
</div>
|
||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||
<DateInput
|
||||
className="short-url-visits__date-input"
|
||||
selected={this.state.endDate}
|
||||
placeholderText="Until"
|
||||
isClearable
|
||||
|
||||
48
test/utils/ColorGenerator.test.js
Normal file
48
test/utils/ColorGenerator.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as sinon from 'sinon';
|
||||
import { ColorGenerator } from '../../src/utils/ColorGenerator';
|
||||
|
||||
describe('ColorGenerator', () => {
|
||||
let colorGenerator;
|
||||
const storageMock = {
|
||||
set: sinon.fake(),
|
||||
get: sinon.fake.returns(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
storageMock.set.resetHistory();
|
||||
storageMock.get.resetHistory();
|
||||
|
||||
colorGenerator = new ColorGenerator(storageMock);
|
||||
});
|
||||
|
||||
it('sets a color in the storage and makes it available after that', () => {
|
||||
const color = '#ff0000';
|
||||
|
||||
colorGenerator.setColorForKey('foo', color);
|
||||
|
||||
expect(colorGenerator.getColorForKey('foo')).toEqual(color);
|
||||
expect(storageMock.set.callCount).toEqual(1);
|
||||
expect(storageMock.get.callCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('generates a random color when none is available for requested key', () => {
|
||||
expect(colorGenerator.getColorForKey('bar')).toEqual(expect.stringMatching(/^#(?:[0-9a-fA-F]{6})$/));
|
||||
expect(storageMock.set.callCount).toEqual(1);
|
||||
expect(storageMock.get.callCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('trims and lower cases keys before trying to match', () => {
|
||||
const color = '#ff0000';
|
||||
|
||||
colorGenerator.setColorForKey('foo', color);
|
||||
|
||||
expect(colorGenerator.getColorForKey(' foo')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey('foO')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey('FoO')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey('FOO')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey('FOO ')).toEqual(color);
|
||||
expect(colorGenerator.getColorForKey(' FoO ')).toEqual(color);
|
||||
expect(storageMock.set.callCount).toEqual(1);
|
||||
expect(storageMock.get.callCount).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||
import { keys, values } from 'ramda';
|
||||
import { GraphCard } from '../../src/visits/GraphCard';
|
||||
import GraphCard from '../../src/visits/GraphCard';
|
||||
|
||||
describe('<GraphCard />', () => {
|
||||
let wrapper;
|
||||
@@ -10,6 +10,7 @@ describe('<GraphCard />', () => {
|
||||
foo: 123,
|
||||
bar: 456,
|
||||
};
|
||||
const matchMedia = () => ({ matches: false });
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
@@ -18,7 +19,7 @@ describe('<GraphCard />', () => {
|
||||
});
|
||||
|
||||
it('renders Doughnut when is not a bar chart', () => {
|
||||
wrapper = shallow(<GraphCard title="The chart" stats={stats} />);
|
||||
wrapper = shallow(<GraphCard matchMedia={matchMedia} title="The chart" stats={stats} />);
|
||||
const doughnut = wrapper.find(Doughnut);
|
||||
const horizontal = wrapper.find(HorizontalBar);
|
||||
|
||||
@@ -46,7 +47,7 @@ describe('<GraphCard />', () => {
|
||||
});
|
||||
|
||||
it('renders HorizontalBar when is not a bar chart', () => {
|
||||
wrapper = shallow(<GraphCard isBarChart title="The chart" stats={stats} />);
|
||||
wrapper = shallow(<GraphCard matchMedia={matchMedia} isBarChart title="The chart" stats={stats} />);
|
||||
const doughnut = wrapper.find(Doughnut);
|
||||
const horizontal = wrapper.find(HorizontalBar);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Card } from 'reactstrap';
|
||||
import * as sinon from 'sinon';
|
||||
import { ShortUrlsVisitsComponent as ShortUrlsVisits } from '../../src/visits/ShortUrlVisits';
|
||||
import MutedMessage from '../../src/utils/MuttedMessage';
|
||||
import { GraphCard } from '../../src/visits/GraphCard';
|
||||
import GraphCard from '../../src/visits/GraphCard';
|
||||
import DateInput from '../../src/common/DateInput';
|
||||
|
||||
describe('<ShortUrlVisits />', () => {
|
||||
|
||||
Reference in New Issue
Block a user