Merge pull request #215 from acelaya-forks/feature/versions

Feature/versions
This commit is contained in:
Alejandro Celaya 2020-03-05 14:20:31 +01:00 committed by GitHub
commit b89bfa3c1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 298 additions and 217 deletions

View file

@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
#### Added
* *Nothing*
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
#### Changed

View file

@ -1,6 +1,8 @@
FROM node:12.14.1-alpine as node
COPY . /shlink-web-client
RUN cd /shlink-web-client && npm install && npm run build
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && npm install && npm run build -- ${VERSION} --no-dist
FROM nginx:1.17.7-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

10
hooks/build Executable file
View file

@ -0,0 +1,10 @@
#!/bin/bash
set -ex
if [[ ${SOURCE_BRANCH} == 'master' ]]; then
SHLINK_WEB_CLIENT_RELEASE='latest'
else
SHLINK_WEB_CLIENT_RELEASE=${SOURCE_BRANCH#?}
fi
docker build --build-arg VERSION=${SHLINK_WEB_CLIENT_RELEASE} -t ${IMAGE_NAME} .

View file

@ -14,7 +14,6 @@ process.on('unhandledRejection', (err) => {
// Ensure environment variables are read.
require('../config/env');
const path = require('path');
const chalk = require('chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
@ -22,7 +21,6 @@ const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
@ -30,7 +28,6 @@ const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
@ -47,6 +44,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.indexOf('--stats') !== -1;
const withoutDist = argv.indexOf('--no-dist') !== -1;
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration
const config = configFactory('production');
@ -85,6 +84,7 @@ checkBrowsers(paths.appPath, isInteractive)
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
}
console.log('File sizes after gzip:\n');
@ -96,20 +96,6 @@ checkBrowsers(paths.appPath, isInteractive)
WARN_AFTER_CHUNK_GZIP_SIZE
);
console.log();
const appPackage = require(paths.appPackageJson);
const { publicUrl } = paths;
const { output: { publicPath } } = config;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
appPackage,
publicUrl,
publicPath,
buildFolder,
useYarn
);
},
(err) => {
console.log(chalk.red('Failed to compile.\n'));
@ -117,7 +103,7 @@ checkBrowsers(paths.appPath, isInteractive)
process.exit(1);
}
)
.then(zipDist)
.then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => {
if (err && err.message) {
console.log(err.message);
@ -200,15 +186,7 @@ function copyPublicFolder() {
});
}
function zipDist() {
const minArgsToContainVersion = 3;
// If no version was provided, do nothing
if (process.argv.length < minArgsToContainVersion) {
return;
}
const [ , , version ] = process.argv;
function zipDist(version) {
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
@ -226,4 +204,24 @@ function zipDist() {
console.log(chalk.red('An error occurred while generating dist file'));
console.log(e);
}
console.log();
}
function getVersionFromArgs(argv) {
const [ version ] = argv;
return { version, hasVersion: !!version };
}
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);
fs.writeFileSync(filePath, replaced, 'utf-8');
}

View file

@ -3,14 +3,21 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import classNames from 'classnames';
import { serverType } from '../servers/prop-types';
import './AsideMenu.scss';
const defaultProps = {
className: '',
showOnMobile: false,
const AsideMenuItem = ({ children, to, ...rest }) => (
<NavLink className="aside-menu__item" activeClassName="aside-menu__item--selected" to={to} {...rest}>
{children}
</NavLink>
);
AsideMenuItem.propTypes = {
children: PropTypes.node.isRequired,
to: PropTypes.string.isRequired,
};
const propTypes = {
selectedServer: serverType,
className: PropTypes.string,
@ -20,51 +27,34 @@ const propTypes = {
const AsideMenu = (DeleteServerButton) => {
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
const asideClass = classNames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/create-short-url')}>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/manage-tags')}>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</NavLink>
</AsideMenuItem>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
server={selectedServer}
/>
<DeleteServerButton className="aside-menu__item aside-menu__item--danger" server={selectedServer} />
</nav>
</aside>
);
};
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
return AsideMenu;

View file

@ -17,7 +17,7 @@ const propTypes = {
selectedServer: serverType,
};
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) => {
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions) => {
const MenuLayoutComp = ({ match, location, selectedServer, selectServer }) => {
const [ showSideBar, setShowSidebar ] = useState(false);
@ -61,15 +61,21 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
<div className="row menu-layout__swipeable-inner">
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={showSideBar} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => setShowSidebar(false)}>
<Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
/>
</Switch>
<div className="menu-layout__container">
<Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
/>
</Switch>
</div>
<div className="menu-layout__footer text-center text-md-right">
<ShlinkVersions />
</div>
</div>
</div>
</Swipeable>

View file

@ -32,3 +32,26 @@
.menu-layout__burger-icon--active {
color: white;
}
$footer-height: 2.3rem;
$footer-margin: .8rem;
.menu-layout__container {
padding: 20px 0 ($footer-height + $footer-margin);
min-height: 100%;
margin-bottom: -($footer-height + $footer-margin);
@media (min-width: $mdMin) {
padding: 30px 15px ($footer-height + $footer-margin);
}
}
.menu-layout__footer {
height: $footer-height;
margin-top: $footer-margin;
padding: 0;
@media (min-width: $mdMin) {
padding: 0 15px;
}
}

View file

@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { pipe } from 'ramda';
import { serverType } from '../servers/prop-types';
import { versionToPrintable, versionToSemVer } from '../utils/versionHelpers';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const propTypes = {
selectedServer: serverType,
className: PropTypes.string,
clientVersion: PropTypes.string,
};
const ShlinkVersions = ({ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }) => {
const { printableVersion: serverVersion } = selectedServer;
const normalizedClientVersion = pipe(versionToSemVer(), versionToPrintable)(clientVersion);
return (
<small className={classNames('text-muted', className)}>
Client: <b>{normalizedClientVersion}</b> - Server: <b>{serverVersion}</b>
</small>
);
};
ShlinkVersions.propTypes = propTypes;
export default ShlinkVersions;

View file

@ -4,6 +4,7 @@ import Home from '../Home';
import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler';
import ShlinkVersions from '../ShlinkVersions';
const provideServices = (bottle, connect, withRouter) => {
bottle.constant('window', global.window);
@ -25,13 +26,17 @@ const provideServices = (bottle, connect, withRouter) => {
'ShortUrls',
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits'
'ShortUrlVisits',
'ShlinkVersions'
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
};

View file

@ -28,14 +28,6 @@ body,
color: inherit !important;
}
.shlink-container {
padding: 20px 0;
@media (min-width: $mdMin) {
padding: 30px 15px;
}
}
.badge-main {
color: #fff;
background-color: $mainColor;

View file

@ -1,40 +1,38 @@
import React, { useState } from 'react';
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import PropTypes from 'prop-types';
import { serverType } from './prop-types';
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
static propTypes = {
server: serverType,
className: PropTypes.string,
};
const propTypes = {
server: serverType,
className: PropTypes.string,
};
state = { isModalOpen: false };
render() {
const { server, className } = this.props;
const DeleteServerButton = (DeleteServerModal) => {
const DeleteServerButtonComp = ({ server, className }) => {
const [ isModalOpen, setModalOpen ] = useState(false);
return (
<React.Fragment>
<span
className={className}
key="deleteServerBtn"
onClick={() => this.setState({ isModalOpen: true })}
>
<span className={className} key="deleteServerBtn" onClick={() => setModalOpen(true)}>
<FontAwesomeIcon icon={deleteIcon} />
<span className="aside-menu__item-text">Delete this server</span>
<span className="aside-menu__item-text">Remove this server</span>
</span>
<DeleteServerModal
isOpen={this.state.isModalOpen}
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
isOpen={isModalOpen}
toggle={() => setModalOpen(!isModalOpen)}
server={server}
key="deleteServerModal"
/>
</React.Fragment>
);
}
};
DeleteServerButtonComp.propTypes = propTypes;
return DeleteServerButtonComp;
};
export default DeleteServerButton;

View file

@ -22,12 +22,14 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader>
<ModalHeader toggle={toggle}><span className="text-danger">Remove server</span></ModalHeader>
<ModalBody>
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p>
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
No data will be deleted, only the access to that server will be removed from this host.
You can create it again at any moment.
<i>
No data will be deleted, only the access to this server will be removed from this host.
You can create it again at any moment.
</i>
</p>
</ModalBody>
<ModalFooter>

View file

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compareVersions } from '../../utils/utils';
import { serverType } from '../prop-types';
import { compareVersions } from '../../utils/versionHelpers';
const propTypes = {
minVersion: PropTypes.string,

View file

@ -6,4 +6,5 @@ export const serverType = PropTypes.shape({
url: PropTypes.string,
apiKey: PropTypes.string,
version: PropTypes.string,
printableVersion: PropTypes.string,
});

View file

@ -1,6 +1,7 @@
import { createAction, handleActions } from 'redux-actions';
import { pipe } from 'ramda';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionIsValidSemVer } from '../../utils/utils';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/versionHelpers';
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
@ -12,6 +13,10 @@ export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
const initialState = null;
const versionToSemVer = pipe(
(version) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
toSemVer(MIN_FALLBACK_VERSION)
);
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
@ -20,16 +25,14 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve
const selectedServer = findServerById(serverId);
const { health } = buildShlinkApiClient(selectedServer);
const version = await health()
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
.catch(() => MIN_FALLBACK_VERSION);
const { version } = await health().catch(() => MIN_FALLBACK_VERSION);
dispatch({
type: SELECT_SERVER,
selectedServer: {
...selectedServer,
version,
version: versionToSemVer(version),
printableVersion: versionToPrintable(version),
},
});
};

View file

@ -7,7 +7,7 @@ import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils';
import { compareVersions } from '../utils/versionHelpers';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
@ -77,83 +77,81 @@ const CreateShortUrl = (
const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1');
return (
<div className="shlink-container">
<form onSubmit={save}>
<form onSubmit={save}>
<div className="form-group">
<input
className="form-control form-control-lg"
type="url"
placeholder="Insert the URL to be shortened"
required
value={this.state.longUrl}
onChange={(e) => this.setState({ longUrl: e.target.value })}
/>
</div>
<Collapse isOpen={this.state.moreOptionsVisible}>
<div className="form-group">
<input
className="form-control form-control-lg"
type="url"
placeholder="Insert the URL to be shortened"
required
value={this.state.longUrl}
onChange={(e) => this.setState({ longUrl: e.target.value })}
/>
<TagsSelector tags={this.state.tags} onChange={changeTags} />
</div>
<Collapse isOpen={this.state.moreOptionsVisible}>
<div className="form-group">
<TagsSelector tags={this.state.tags} onChange={changeTags} />
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('customSlug', 'Custom slug')}
</div>
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('customSlug', 'Custom slug')}
</div>
<div className="col-sm-6">
{renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
</div>
<div className="col-sm-6">
{renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
</div>
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-3">
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
</div>
<div className="col-sm-3">
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
</div>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={this.state.findIfExists}
onChange={(findIfExists) => this.setState({ findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForServerVersion>
</Collapse>
<div>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
>
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.loading || isEmpty(this.state.longUrl)}
>
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
</button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
</form>
</div>
<div className="row">
<div className="col-sm-6">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-3">
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
</div>
<div className="col-sm-3">
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
</div>
</div>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 text-right">
<Checkbox
className="mr-2"
checked={this.state.findIfExists}
onChange={(findIfExists) => this.setState({ findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</ForServerVersion>
</Collapse>
<div>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
>
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.loading || isEmpty(this.state.longUrl)}
>
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
</button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
</form>
);
}
};

View file

@ -19,11 +19,11 @@ const ShortUrls = (SearchBar, ShortUrlsList) => {
const urlsListKey = `${serverId}_${page}`;
return (
<div className="shlink-container">
<React.Fragment>
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
</React.Fragment>
);
};

View file

@ -69,14 +69,14 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
const { filterTags } = this.props;
return (
<div className="shlink-container">
<React.Fragment>
{!this.props.tagsList.loading &&
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
}
<div className="row">
{this.renderContent()}
</div>
</div>
</React.Fragment>
);
}
};

View file

@ -4,7 +4,6 @@ import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda';
import { useState } from 'react';
import { compare } from 'compare-versions';
const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000;
@ -53,20 +52,6 @@ export const useToggle = (initialValue = false) => {
return [ flag, () => setFlag(!flag) ];
};
export const compareVersions = (firstVersion, operator, secondVersion) => compare(
firstVersion,
secondVersion,
operator
);
export const versionIsValidSemVer = (version) => {
try {
return compareVersions(version, '=', version);
} catch (e) {
return false;
}
};
export const formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date;
export const formatIsoDate = (date) => date && date.format ? date.format() : date;

View file

@ -0,0 +1,21 @@
import { compare } from 'compare-versions';
import { identity, memoizeWith } from 'ramda';
export const compareVersions = (firstVersion, operator, secondVersion) => compare(
firstVersion,
secondVersion,
operator,
);
const versionIsValidSemVer = memoizeWith(identity, (version) => {
try {
return compareVersions(version, '=', version);
} catch (e) {
return false;
}
});
export const versionToPrintable = (version) => !versionIsValidSemVer(version) ? version : `v${version}`;
export const versionToSemVer = (defaultValue = 'latest') =>
(version) => versionIsValidSemVer(version) ? version : defaultValue;

View file

@ -133,7 +133,7 @@ const ShortUrlVisits = (
const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits);
return (
<div className="shlink-container">
<React.Fragment>
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
<section className="mt-4">
@ -148,7 +148,7 @@ const ShortUrlVisits = (
<section>
{renderVisitsContent()}
</section>
</div>
</React.Fragment>
);
}
};

View file

@ -1,6 +1,5 @@
import { shallow } from 'enzyme';
import React from 'react';
import { NavLink } from 'react-router-dom';
import asideMenuCreator from '../../src/common/AsideMenu';
describe('<AsideMenu />', () => {
@ -15,7 +14,7 @@ describe('<AsideMenu />', () => {
afterEach(() => wrapped.unmount());
it('contains links to different sections', () => {
const links = wrapped.find(NavLink);
const links = wrapped.find('[to]');
expect(links).toHaveLength(3);
links.forEach((link) => expect(link.prop('to')).toContain('abc123'));

View file

@ -0,0 +1,26 @@
import React from 'react';
import { shallow } from 'enzyme';
import ShlinkVersions from '../../src/common/ShlinkVersions';
describe('<ShlinkVersions />', () => {
let wrapper;
const createWrapper = (props) => {
wrapper = shallow(<ShlinkVersions {...props} />);
return wrapper;
};
afterEach(() => wrapper && wrapper.unmount());
it.each([
[ '1.2.3', 'foo', 'Client: v1.2.3 - Server: foo' ],
[ 'foo', '1.2.3', 'Client: latest - Server: 1.2.3' ],
[ 'latest', 'latest', 'Client: latest - Server: latest' ],
[ '5.5.0', '0.2.8', 'Client: v5.5.0 - Server: 0.2.8' ],
[ 'not-semver', 'something', 'Client: latest - Server: something' ],
])('displays expected versions', (clientVersion, printableVersion, expected) => {
const wrapper = createWrapper({ clientVersion, selectedServer: { printableVersion } });
expect(wrapper.text()).toEqual(expected);
});
});

View file

@ -21,16 +21,8 @@ describe('<DeleteServerButton />', () => {
it('displays modal when button is clicked', () => {
const btn = wrapper.find('.button');
expect(wrapper.state('isModalOpen')).toEqual(false);
expect(wrapper.find(DeleteServerModal).prop('isOpen')).toEqual(false);
btn.simulate('click');
expect(wrapper.state('isModalOpen')).toEqual(true);
});
it('changes modal open state when toggled', () => {
const modal = wrapper.find(DeleteServerModal);
expect(wrapper.state('isModalOpen')).toEqual(false);
modal.prop('toggle')();
expect(wrapper.state('isModalOpen')).toEqual(true);
expect(wrapper.find(DeleteServerModal).prop('isOpen')).toEqual(true);
});
});

View file

@ -38,7 +38,7 @@ describe('<DeleteServerModal />', () => {
const modalBody = wrapper.find(ModalBody);
expect(modalBody.find('p').first().text()).toEqual(
`Are you sure you want to delete server ${serverName}?`
`Are you sure you want to remove ${serverName}?`
);
});

View file

@ -44,13 +44,14 @@ describe('selectedServerReducer', () => {
afterEach(jest.clearAllMocks);
it.each([
[ version, version ],
[ 'latest', MAX_FALLBACK_VERSION ],
[ '%invalid_semver%', MIN_FALLBACK_VERSION ],
])('dispatches proper actions', async (serverVersion, expectedVersion) => {
[ version, version, `v${version}` ],
[ 'latest', MAX_FALLBACK_VERSION, 'latest' ],
[ '%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%' ],
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
const expectedSelectedServer = {
...selectedServer,
version: expectedVersion,
printableVersion: expectedPrintableVersion,
};
apiClientMock.health.mockResolvedValue({ version: serverVersion });