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
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 #### 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 #### Changed

View File

@@ -1,6 +1,8 @@
FROM node:12.14.1-alpine as node FROM node:12.14.1-alpine as node
COPY . /shlink-web-client 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 FROM nginx:1.17.7-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>" 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. // Ensure environment variables are read.
require('../config/env'); require('../config/env');
const path = require('path');
const chalk = require('chalk'); const chalk = require('chalk');
const fs = require('fs-extra'); const fs = require('fs-extra');
const webpack = require('webpack'); const webpack = require('webpack');
@@ -22,7 +21,6 @@ const bfj = require('bfj');
const AdmZip = require('adm-zip'); const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError'); const printBuildError = require('react-dev-utils/printBuildError');
const { checkBrowsers } = require('react-dev-utils/browsersHelper'); const { checkBrowsers } = require('react-dev-utils/browsersHelper');
@@ -30,7 +28,6 @@ const paths = require('../config/paths');
const configFactory = require('../config/webpack.config'); const configFactory = require('../config/webpack.config');
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter; const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them. // These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line 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 argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart); const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.indexOf('--stats') !== -1; const writeStatsJson = argv.indexOf('--stats') !== -1;
const withoutDist = argv.indexOf('--no-dist') !== -1;
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration // Generate configuration
const config = configFactory('production'); const config = configFactory('production');
@@ -85,6 +84,7 @@ checkBrowsers(paths.appPath, isInteractive)
); );
} else { } else {
console.log(chalk.green('Compiled successfully.\n')); console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
} }
console.log('File sizes after gzip:\n'); console.log('File sizes after gzip:\n');
@@ -96,20 +96,6 @@ checkBrowsers(paths.appPath, isInteractive)
WARN_AFTER_CHUNK_GZIP_SIZE WARN_AFTER_CHUNK_GZIP_SIZE
); );
console.log(); 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) => { (err) => {
console.log(chalk.red('Failed to compile.\n')); console.log(chalk.red('Failed to compile.\n'));
@@ -117,7 +103,7 @@ checkBrowsers(paths.appPath, isInteractive)
process.exit(1); process.exit(1);
} }
) )
.then(zipDist) .then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => { .catch((err) => {
if (err && err.message) { if (err && err.message) {
console.log(err.message); console.log(err.message);
@@ -200,15 +186,7 @@ function copyPublicFolder() {
}); });
} }
function zipDist() { function zipDist(version) {
const minArgsToContainVersion = 3;
// If no version was provided, do nothing
if (process.argv.length < minArgsToContainVersion) {
return;
}
const [ , , version ] = process.argv;
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`; const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`)); 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(chalk.red('An error occurred while generating dist file'));
console.log(e); 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 React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classNames from 'classnames';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import './AsideMenu.scss'; import './AsideMenu.scss';
const defaultProps = { const AsideMenuItem = ({ children, to, ...rest }) => (
className: '', <NavLink className="aside-menu__item" activeClassName="aside-menu__item--selected" to={to} {...rest}>
showOnMobile: false, {children}
</NavLink>
);
AsideMenuItem.propTypes = {
children: PropTypes.node.isRequired,
to: PropTypes.string.isRequired,
}; };
const propTypes = { const propTypes = {
selectedServer: serverType, selectedServer: serverType,
className: PropTypes.string, className: PropTypes.string,
@@ -20,51 +27,34 @@ const propTypes = {
const AsideMenu = (DeleteServerButton) => { const AsideMenu = (DeleteServerButton) => {
const AsideMenu = ({ selectedServer, className, showOnMobile }) => { const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const serverId = selectedServer ? selectedServer.id : ''; const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, { const asideClass = classNames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile, 'aside-menu--hidden': !showOnMobile,
}); });
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls'); const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
return ( return (
<aside className={asideClass}> <aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav"> <nav className="nav flex-column aside-menu__nav">
<NavLink <AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<FontAwesomeIcon icon={listIcon} /> <FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span> <span className="aside-menu__item-text">List short URLs</span>
</NavLink> </AsideMenuItem>
<NavLink <AsideMenuItem to={buildPath('/create-short-url')}>
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
<FontAwesomeIcon icon={createIcon} flip="horizontal" /> <FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span> <span className="aside-menu__item-text">Create short URL</span>
</NavLink> </AsideMenuItem>
<AsideMenuItem to={buildPath('/manage-tags')}>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
<FontAwesomeIcon icon={tagsIcon} /> <FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span> <span className="aside-menu__item-text">Manage tags</span>
</NavLink> </AsideMenuItem>
<DeleteServerButton <DeleteServerButton className="aside-menu__item aside-menu__item--danger" server={selectedServer} />
className="aside-menu__item aside-menu__item--danger"
server={selectedServer}
/>
</nav> </nav>
</aside> </aside>
); );
}; };
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes; AsideMenu.propTypes = propTypes;
return AsideMenu; return AsideMenu;

View File

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

View File

@@ -32,3 +32,26 @@
.menu-layout__burger-icon--active { .menu-layout__burger-icon--active {
color: white; 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 MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu'; import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler'; import ErrorHandler from '../ErrorHandler';
import ShlinkVersions from '../ShlinkVersions';
const provideServices = (bottle, connect, withRouter) => { const provideServices = (bottle, connect, withRouter) => {
bottle.constant('window', global.window); bottle.constant('window', global.window);
@@ -25,13 +26,17 @@ const provideServices = (bottle, connect, withRouter) => {
'ShortUrls', 'ShortUrls',
'AsideMenu', 'AsideMenu',
'CreateShortUrl', 'CreateShortUrl',
'ShortUrlVisits' 'ShortUrlVisits',
'ShlinkVersions'
); );
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ])); bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter); bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton'); bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console'); bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
}; };

View File

@@ -28,14 +28,6 @@ body,
color: inherit !important; color: inherit !important;
} }
.shlink-container {
padding: 20px 0;
@media (min-width: $mdMin) {
padding: 30px 15px;
}
}
.badge-main { .badge-main {
color: #fff; color: #fff;
background-color: $mainColor; 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 { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { serverType } from './prop-types'; import { serverType } from './prop-types';
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component { const propTypes = {
static propTypes = { server: serverType,
server: serverType, className: PropTypes.string,
className: PropTypes.string, };
};
state = { isModalOpen: false }; const DeleteServerButton = (DeleteServerModal) => {
const DeleteServerButtonComp = ({ server, className }) => {
render() { const [ isModalOpen, setModalOpen ] = useState(false);
const { server, className } = this.props;
return ( return (
<React.Fragment> <React.Fragment>
<span <span className={className} key="deleteServerBtn" onClick={() => setModalOpen(true)}>
className={className}
key="deleteServerBtn"
onClick={() => this.setState({ isModalOpen: true })}
>
<FontAwesomeIcon icon={deleteIcon} /> <FontAwesomeIcon icon={deleteIcon} />
<span className="aside-menu__item-text">Delete this server</span> <span className="aside-menu__item-text">Remove this server</span>
</span> </span>
<DeleteServerModal <DeleteServerModal
isOpen={this.state.isModalOpen} isOpen={isModalOpen}
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))} toggle={() => setModalOpen(!isModalOpen)}
server={server} server={server}
key="deleteServerModal" key="deleteServerModal"
/> />
</React.Fragment> </React.Fragment>
); );
} };
DeleteServerButtonComp.propTypes = propTypes;
return DeleteServerButtonComp;
}; };
export default DeleteServerButton; export default DeleteServerButton;

View File

@@ -22,12 +22,14 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered> <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> <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> <p>
No data will be deleted, only the access to that server will be removed from this host. <i>
You can create it again at any moment. 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> </p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { createAction, handleActions } from 'redux-actions'; import { createAction, handleActions } from 'redux-actions';
import { pipe } from 'ramda';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams'; 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 */ /* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER'; export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
@@ -12,6 +13,10 @@ export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
const initialState = null; 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); export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
@@ -20,16 +25,14 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve
const selectedServer = findServerById(serverId); const selectedServer = findServerById(serverId);
const { health } = buildShlinkApiClient(selectedServer); const { health } = buildShlinkApiClient(selectedServer);
const version = await health() const { version } = await health().catch(() => MIN_FALLBACK_VERSION);
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
.catch(() => MIN_FALLBACK_VERSION);
dispatch({ dispatch({
type: SELECT_SERVER, type: SELECT_SERVER,
selectedServer: { selectedServer: {
...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 DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox'; import Checkbox from '../utils/Checkbox';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/utils'; import { compareVersions } from '../utils/versionHelpers';
import { createShortUrlResultType } from './reducers/shortUrlCreation'; import { createShortUrlResultType } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
@@ -77,83 +77,81 @@ const CreateShortUrl = (
const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1'); const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1');
return ( 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"> <div className="form-group">
<input <TagsSelector tags={this.state.tags} onChange={changeTags} />
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> </div>
<Collapse isOpen={this.state.moreOptionsVisible}> <div className="row">
<div className="form-group"> <div className="col-sm-6">
<TagsSelector tags={this.state.tags} onChange={changeTags} /> {renderOptionalInput('customSlug', 'Custom slug')}
</div> </div>
<div className="col-sm-6">
<div className="row"> {renderOptionalInput('domain', 'Domain', 'text', {
<div className="col-sm-6"> disabled: disableDomain,
{renderOptionalInput('customSlug', 'Custom slug')} ...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> </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> </div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} /> <div className="row">
</form> <div className="col-sm-6">
</div> {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}`; const urlsListKey = `${serverId}_${page}`;
return ( return (
<div className="shlink-container"> <React.Fragment>
<div className="form-group"><SearchBar /></div> <div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} /> <ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} /> <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; const { filterTags } = this.props;
return ( return (
<div className="shlink-container"> <React.Fragment>
{!this.props.tagsList.loading && {!this.props.tagsList.loading &&
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} /> <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
} }
<div className="row"> <div className="row">
{this.renderContent()} {this.renderContent()}
</div> </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 markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda'; import { range } from 'ramda';
import { useState } from 'react'; import { useState } from 'react';
import { compare } from 'compare-versions';
const TEN_ROUNDING_NUMBER = 10; const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000; const DEFAULT_TIMEOUT_DELAY = 2000;
@@ -53,20 +52,6 @@ export const useToggle = (initialValue = false) => {
return [ flag, () => setFlag(!flag) ]; 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 formatDate = (format = 'YYYY-MM-DD') => (date) => date && date.format ? date.format(format) : date;
export const formatIsoDate = (date) => date && date.format ? date.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); const setDate = (dateField) => (date) => this.setState({ [dateField]: date }, this.loadVisits);
return ( return (
<div className="shlink-container"> <React.Fragment>
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} /> <VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
<section className="mt-4"> <section className="mt-4">
@@ -148,7 +148,7 @@ const ShortUrlVisits = (
<section> <section>
{renderVisitsContent()} {renderVisitsContent()}
</section> </section>
</div> </React.Fragment>
); );
} }
}; };

View File

@@ -1,6 +1,5 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom';
import asideMenuCreator from '../../src/common/AsideMenu'; import asideMenuCreator from '../../src/common/AsideMenu';
describe('<AsideMenu />', () => { describe('<AsideMenu />', () => {
@@ -15,7 +14,7 @@ describe('<AsideMenu />', () => {
afterEach(() => wrapped.unmount()); afterEach(() => wrapped.unmount());
it('contains links to different sections', () => { it('contains links to different sections', () => {
const links = wrapped.find(NavLink); const links = wrapped.find('[to]');
expect(links).toHaveLength(3); expect(links).toHaveLength(3);
links.forEach((link) => expect(link.prop('to')).toContain('abc123')); 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', () => { it('displays modal when button is clicked', () => {
const btn = wrapper.find('.button'); const btn = wrapper.find('.button');
expect(wrapper.state('isModalOpen')).toEqual(false); expect(wrapper.find(DeleteServerModal).prop('isOpen')).toEqual(false);
btn.simulate('click'); btn.simulate('click');
expect(wrapper.state('isModalOpen')).toEqual(true); expect(wrapper.find(DeleteServerModal).prop('isOpen')).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);
}); });
}); });

View File

@@ -38,7 +38,7 @@ describe('<DeleteServerModal />', () => {
const modalBody = wrapper.find(ModalBody); const modalBody = wrapper.find(ModalBody);
expect(modalBody.find('p').first().text()).toEqual( 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); afterEach(jest.clearAllMocks);
it.each([ it.each([
[ version, version ], [ version, version, `v${version}` ],
[ 'latest', MAX_FALLBACK_VERSION ], [ 'latest', MAX_FALLBACK_VERSION, 'latest' ],
[ '%invalid_semver%', MIN_FALLBACK_VERSION ], [ '%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%' ],
])('dispatches proper actions', async (serverVersion, expectedVersion) => { ])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
const expectedSelectedServer = { const expectedSelectedServer = {
...selectedServer, ...selectedServer,
version: expectedVersion, version: expectedVersion,
printableVersion: expectedPrintableVersion,
}; };
apiClientMock.health.mockResolvedValue({ version: serverVersion }); apiClientMock.health.mockResolvedValue({ version: serverVersion });