Merge pull request #227 from acelaya-forks/feature/edit-servers

Feature/edit servers
This commit is contained in:
Alejandro Celaya 2020-03-15 14:32:30 +01:00 committed by GitHub
commit 25c67f1c3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 501 additions and 213 deletions

View file

@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* [#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. * [#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.
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded. * [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
#### Changed #### Changed

14
package-lock.json generated
View file

@ -5340,7 +5340,7 @@
}, },
"discontinuous-range": { "discontinuous-range": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", "resolved": "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
"integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=", "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=",
"dev": true "dev": true
}, },
@ -9346,7 +9346,7 @@
}, },
"is-subset": { "is-subset": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", "resolved": "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz",
"integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=", "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=",
"dev": true "dev": true
}, },
@ -10557,13 +10557,13 @@
}, },
"lodash.escape": { "lodash.escape": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", "resolved": "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz",
"integrity": "sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=", "integrity": "sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=",
"dev": true "dev": true
}, },
"lodash.flattendeep": { "lodash.flattendeep": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", "resolved": "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
"integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
"dev": true "dev": true
}, },
@ -10575,7 +10575,7 @@
}, },
"lodash.isequal": { "lodash.isequal": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "resolved": "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
"dev": true "dev": true
}, },
@ -13817,7 +13817,7 @@
}, },
"railroad-diagrams": { "railroad-diagrams": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", "resolved": "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
"integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=", "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=",
"dev": true "dev": true
}, },
@ -14977,7 +14977,7 @@
}, },
"rst-selector-parser": { "rst-selector-parser": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", "resolved": "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz",
"integrity": "sha1-gbIw6i/MYGbInjRy3nlChdmwPZE=", "integrity": "sha1-gbIw6i/MYGbInjRy3nlChdmwPZE=",
"dev": true, "dev": true,
"requires": { "requires": {

View file

@ -3,14 +3,15 @@ import { Route, Switch } from 'react-router-dom';
import './App.scss'; import './App.scss';
import NotFound from './common/NotFound'; import NotFound from './common/NotFound';
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => ( const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer) => () => (
<div className="container-fluid app-container"> <div className="container-fluid app-container">
<MainHeader /> <MainHeader />
<div className="app"> <div className="app">
<Switch> <Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/server/:serverId/edit" component={EditServer} />
<Route path="/server/:serverId" component={MenuLayout} /> <Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>

View file

@ -1,4 +1,9 @@
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons'; import {
faList as listIcon,
faLink as createIcon,
faTags as tagsIcon,
faPen as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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';
@ -7,8 +12,13 @@ import classNames from 'classnames';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import './AsideMenu.scss'; import './AsideMenu.scss';
const AsideMenuItem = ({ children, to, ...rest }) => ( const AsideMenuItem = ({ children, to, className, ...rest }) => (
<NavLink className="aside-menu__item" activeClassName="aside-menu__item--selected" to={to} {...rest}> <NavLink
className={classNames('aside-menu__item', className)}
activeClassName="aside-menu__item--selected"
to={to}
{...rest}
>
{children} {children}
</NavLink> </NavLink>
); );
@ -16,6 +26,7 @@ const AsideMenuItem = ({ children, to, ...rest }) => (
AsideMenuItem.propTypes = { AsideMenuItem.propTypes = {
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
to: PropTypes.string.isRequired, to: PropTypes.string.isRequired,
className: PropTypes.string,
}; };
const propTypes = { const propTypes = {
@ -48,8 +59,15 @@ const AsideMenu = (DeleteServerButton) => {
<FontAwesomeIcon icon={tagsIcon} /> <FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span> <span className="aside-menu__item-text">Manage tags</span>
</AsideMenuItem> </AsideMenuItem>
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<DeleteServerButton className="aside-menu__item aside-menu__item--danger" server={selectedServer} /> <FontAwesomeIcon icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span>
</AsideMenuItem>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"
server={selectedServer}
/>
</nav> </nav>
</aside> </aside>
); );

View file

@ -67,6 +67,9 @@ $asideMenuMobileWidth: 280px;
.aside-menu__item--danger { .aside-menu__item--danger {
color: $dangerColor; color: $dangerColor;
}
.aside-menu__item--push {
margin-top: auto; margin-top: auto;
} }

View file

@ -6,35 +6,23 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames'; import classNames from 'classnames';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import Message from '../utils/Message'; import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import NotFound from './NotFound'; import NotFound from './NotFound';
import './MenuLayout.scss'; import './MenuLayout.scss';
const propTypes = { const propTypes = {
match: PropTypes.object, match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object, location: PropTypes.object,
selectedServer: serverType, selectedServer: serverType,
}; };
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => { const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => {
const MenuLayoutComp = ({ match, location, selectedServer, selectServer }) => { const MenuLayoutComp = ({ match, location, selectedServer }) => {
const [ showSideBar, setShowSidebar ] = useState(false); const [ showSideBar, setShowSidebar ] = useState(false);
const { params: { serverId } } = match; const { params: { serverId } } = match;
useEffect(() => {
selectServer(serverId);
}, [ serverId ]);
useEffect(() => setShowSidebar(false), [ location ]); useEffect(() => setShowSidebar(false), [ location ]);
if (!selectedServer) {
return <Message loading />;
}
if (selectedServer.serverNotFound) {
return <ServerError type="not-found" />;
}
if (selectedServer.serverNotReachable) { if (selectedServer.serverNotReachable) {
return <ServerError type="not-reachable" />; return <ServerError type="not-reachable" />;
} }
@ -91,7 +79,7 @@ const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisi
MenuLayoutComp.propTypes = propTypes; MenuLayoutComp.propTypes = propTypes;
return MenuLayoutComp; return withSelectedServer(MenuLayoutComp, ServerError);
}; };
export default MenuLayout; export default MenuLayout;

View file

@ -26,7 +26,7 @@ const connect = (propsFromState, actionServiceNames = []) =>
actionServiceNames.reduce(mapActionService, {}) actionServiceNames.reduce(mapActionService, {})
); );
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer'); bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer');
provideCommonServices(bottle, connect, withRouter); provideCommonServices(bottle, connect, withRouter);
provideShortUrlsServices(bottle, connect); provideShortUrlsServices(bottle, connect);

View file

@ -1,79 +1,41 @@
import { assoc, dissoc, pipe } from 'ramda'; import React, { useEffect } from 'react';
import React from 'react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './CreateServer.scss'; import './CreateServer.scss';
import { ServerForm } from './helpers/ServerForm';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
const propTypes = {
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component {
static propTypes = {
createServer: PropTypes.func, createServer: PropTypes.func,
history: PropTypes.shape({ history: PropTypes.shape({
push: PropTypes.func, push: PropTypes.func,
}), }),
resetSelectedServer: PropTypes.func, resetSelectedServer: PropTypes.func,
}; };
state = { const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
name: '', const CreateServerComp = ({ createServer, history: { push }, resetSelectedServer }) => {
url: '', const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
apiKey: '', const handleSubmit = (serverData) => {
serversImported: false, const id = uuid();
}; const server = { id, ...serverData };
handleSubmit = (e) => {
e.preventDefault();
const { createServer, history: { push } } = this.props;
const server = pipe(
assoc('id', uuid()),
dissoc('serversImported')
)(this.state);
createServer(server); createServer(server);
push(`/server/${server.id}/list-short-urls/1`); push(`/server/${id}/list-short-urls/1`);
}; };
componentDidMount() { useEffect(() => {
this.props.resetSelectedServer(); resetSelectedServer();
} }, []);
render() {
const renderInputGroup = (id, placeholder, type = 'text') => (
<div className="form-group row">
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
{placeholder}:
</label>
<div className="col-lg-11 col-md-10">
<input
type={type}
className="form-control"
id={id}
placeholder={placeholder}
value={this.state[id]}
required
onChange={(e) => this.setState({ [id]: e.target.value })}
/>
</div>
</div>
);
return ( return (
<div className="create-server"> <div className="create-server">
<form onSubmit={this.handleSubmit}> <ServerForm onSubmit={handleSubmit}>
{renderInputGroup('name', 'Name')} <ImportServersBtn onImport={setServersImported} />
{renderInputGroup('url', 'URL', 'url')}
{renderInputGroup('apiKey', 'API key')}
<div className="text-right">
<ImportServersBtn
onImport={() => stateFlagTimeout(this.setState.bind(this), 'serversImported', true, SHOW_IMPORT_MSG_TIME)}
/>
<button className="btn btn-outline-primary">Create server</button> <button className="btn btn-outline-primary">Create server</button>
</div> </ServerForm>
{this.state.serversImported && ( {serversImported && (
<div className="row create-server__import-success-msg"> <div className="row create-server__import-success-msg">
<div className="col-md-10 offset-md-1"> <div className="col-md-10 offset-md-1">
<div className="p-2 mt-3 bg-main text-white text-center"> <div className="p-2 mt-3 bg-main text-white text-center">
@ -82,10 +44,13 @@ const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer
</div> </div>
</div> </div>
)} )}
</form>
</div> </div>
); );
} };
CreateServerComp.propTypes = propTypes;
return CreateServerComp;
}; };
export default CreateServer; export default CreateServer;

View file

@ -7,25 +7,22 @@ import { serverType } from './prop-types';
const propTypes = { const propTypes = {
server: serverType, server: serverType,
className: PropTypes.string, className: PropTypes.string,
textClassName: PropTypes.string,
children: PropTypes.node,
}; };
const DeleteServerButton = (DeleteServerModal) => { const DeleteServerButton = (DeleteServerModal) => {
const DeleteServerButtonComp = ({ server, className }) => { const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
const [ isModalOpen, setModalOpen ] = useState(false); const [ isModalOpen, setModalOpen ] = useState(false);
return ( return (
<React.Fragment> <React.Fragment>
<span className={className} key="deleteServerBtn" onClick={() => setModalOpen(true)}> <span className={className} onClick={() => setModalOpen(true)}>
<FontAwesomeIcon icon={deleteIcon} /> {!children && <FontAwesomeIcon icon={deleteIcon} />}
<span className="aside-menu__item-text">Remove this server</span> <span className={textClassName}>{children || 'Remove this server'}</span>
</span> </span>
<DeleteServerModal <DeleteServerModal server={server} isOpen={isModalOpen} toggle={() => setModalOpen(!isModalOpen)} />
isOpen={isModalOpen}
toggle={() => setModalOpen(!isModalOpen)}
server={server}
key="deleteServerModal"
/>
</React.Fragment> </React.Fragment>
); );
}; };

34
src/servers/EditServer.js Normal file
View file

@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer';
import { serverType } from './prop-types';
const propTypes = {
editServer: PropTypes.func,
selectedServer: serverType,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
export const EditServer = (ServerError) => {
const EditServerComp = ({ editServer, selectedServer, history: { push } }) => {
const handleSubmit = (serverData) => {
editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}/list-short-urls/1`);
};
return (
<div className="create-server">
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
<button className="btn btn-outline-primary">Save</button>
</ServerForm>
</div>
);
};
EditServerComp.propTypes = propTypes;
return withSelectedServer(EditServerComp, ServerError);
};

View file

@ -26,7 +26,9 @@ ServerListItem.propTypes = {
const ServersListGroup = ({ servers, children }) => ( const ServersListGroup = ({ servers, children }) => (
<React.Fragment> <React.Fragment>
<div className="container">
<h5>{children}</h5> <h5>{children}</h5>
</div>
{servers.length > 0 && ( {servers.length > 0 && (
<ListGroup className="servers-list__list-group mt-md-3"> <ListGroup className="servers-list__list-group mt-md-3">
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)} {servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}

View file

@ -3,15 +3,18 @@ import PropTypes from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Message from '../../utils/Message'; import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup'; import ServersListGroup from '../ServersListGroup';
import { serverType } from '../prop-types';
import './ServerError.scss'; import './ServerError.scss';
const propTypes = { const propTypes = {
servers: PropTypes.object, servers: PropTypes.object,
selectedServer: serverType,
type: PropTypes.oneOf([ 'not-found', 'not-reachable' ]).isRequired, type: PropTypes.oneOf([ 'not-found', 'not-reachable' ]).isRequired,
}; };
export const ServerError = ({ type, servers: { list } }) => ( export const ServerError = (DeleteServerButton) => {
<div className="server-error-container flex-column"> const ServerErrorComp = ({ type, servers: { list }, selectedServer }) => (
<div className="server-error__container flex-column">
<div className="row w-100 mb-3 mb-md-5"> <div className="row w-100 mb-3 mb-md-5">
<Message type="error"> <Message type="error">
{type === 'not-found' && 'Could not find this Shlink server.'} {type === 'not-found' && 'Could not find this Shlink server.'}
@ -23,11 +26,25 @@ export const ServerError = ({ type, servers: { list } }) => (
)} )}
</Message> </Message>
</div> </div>
<ServersListGroup servers={Object.values(list)}> <ServersListGroup servers={Object.values(list)}>
These are the {type === 'not-reachable' ? 'other' : ''} Shlink servers currently configured. Choose one of These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>. them or <Link to="/server/create">add a new one</Link>.
</ServersListGroup> </ServersListGroup>
</div>
);
ServerError.propTypes = propTypes; {type === 'not-reachable' && (
<div className="container mt-3 mt-md-5">
<h5>
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
</h5>
</div>
)}
</div>
);
ServerErrorComp.propTypes = propTypes;
return ServerErrorComp;
};

View file

@ -1,6 +1,17 @@
.server-error-container { @import '../../utils/base';
.server-error__container {
text-align: center; text-align: center;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.server-error__delete-btn {
color: $dangerColor;
cursor: pointer;
}
.server-error__delete-btn:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
const propTypes = {
onSubmit: PropTypes.func.isRequired,
initialValues: PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
apiKey: PropTypes.string.isRequired,
}),
children: PropTypes.node.isRequired,
};
export const ServerForm = ({ onSubmit, initialValues, children }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
const [ apiKey, setApiKey ] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ name, url, apiKey });
};
useEffect(() => {
initialValues && setName(initialValues.name);
initialValues && setUrl(initialValues.url);
initialValues && setApiKey(initialValues.apiKey);
}, [ initialValues ]);
return (
<form onSubmit={handleSubmit}>
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
<div className="text-right">{children}</div>
</form>
);
};
ServerForm.propTypes = propTypes;

View file

@ -0,0 +1,35 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import Message from '../../utils/Message';
import { serverType } from '../prop-types';
const propTypes = {
selectServer: PropTypes.func,
selectedServer: serverType,
match: PropTypes.object,
};
export const withSelectedServer = (WrappedComponent, ServerError) => {
const Component = (props) => {
const { selectServer, selectedServer, match } = props;
const { params: { serverId } } = match;
useEffect(() => {
selectServer(serverId);
}, [ serverId ]);
if (!selectedServer) {
return <Message loading />;
}
if (selectedServer.serverNotFound) {
return <ServerError type="not-found" />;
}
return <WrappedComponent {...props} />;
};
Component.propTypes = propTypes;
return Component;
};

View file

@ -7,18 +7,14 @@ const regularServerType = PropTypes.shape({
apiKey: PropTypes.string, apiKey: PropTypes.string,
version: PropTypes.string, version: PropTypes.string,
printableVersion: PropTypes.string, printableVersion: PropTypes.string,
serverNotReachable: PropTypes.bool,
}); });
const notFoundServerType = PropTypes.shape({ const notFoundServerType = PropTypes.shape({
serverNotFound: PropTypes.bool.isRequired, serverNotFound: PropTypes.bool.isRequired,
}); });
const notReachableServerType = PropTypes.shape({
serverNotReachable: PropTypes.bool.isRequired,
});
export const serverType = PropTypes.oneOfType([ export const serverType = PropTypes.oneOfType([
regularServerType, regularServerType,
notFoundServerType, notFoundServerType,
notReachableServerType,
]); ]);

View file

@ -49,7 +49,7 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: SELECT_SERVER, type: SELECT_SERVER,
selectedServer: { serverNotReachable: true }, selectedServer: { ...selectedServer, serverNotReachable: true },
}); });
} }
}; };

View file

@ -52,6 +52,8 @@ export const listServers = ({ listServers, createServers }, { get }) => () => as
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction); export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
export const editServer = ({ editServer }, listServersAction) => pipe(editServer, listServersAction);
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction); export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
export const createServers = ({ createServers }, listServersAction) => pipe( export const createServers = ({ createServers }, listServersAction) => pipe(

View file

@ -25,4 +25,14 @@ export default class ServersService {
deleteServer = ({ id }) => deleteServer = ({ id }) =>
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers())); this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
editServer = (id, serverData) => {
const allServers = this.listServers();
if (!allServers[id]) {
return;
}
this.storage.set(SERVERS_STORAGE_KEY, assoc(id, { ...allServers[id], ...serverData }, allServers));
}
} }

View file

@ -3,9 +3,10 @@ import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown'; import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal'; import DeleteServerModal from '../DeleteServerModal';
import DeleteServerButton from '../DeleteServerButton'; import DeleteServerButton from '../DeleteServerButton';
import { EditServer } from '../EditServer';
import ImportServersBtn from '../helpers/ImportServersBtn'; import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer'; import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server'; import { createServer, createServers, deleteServer, editServer, listServers } from '../reducers/server';
import ForServerVersion from '../helpers/ForServerVersion'; import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError'; import { ServerError } from '../helpers/ServerError';
import ServersImporter from './ServersImporter'; import ServersImporter from './ServersImporter';
@ -14,9 +15,12 @@ import ServersExporter from './ServersExporter';
const provideServices = (bottle, connect, withRouter) => { const provideServices = (bottle, connect, withRouter) => {
// Components // Components
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout'); bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ])); bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter'); bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.decorator('ServersDropdown', withRouter); bottle.decorator('ServersDropdown', withRouter);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers' ])); bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers' ]));
@ -33,8 +37,8 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('ForServerVersion', () => ForServerVersion); bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ])); bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
bottle.serviceFactory('ServerError', () => ServerError); bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
bottle.decorator('ServerError', connect([ 'servers' ])); bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
// Services // Services
bottle.constant('csvjson', csvjson); bottle.constant('csvjson', csvjson);
@ -47,6 +51,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers'); bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers'); bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers'); bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('editServer', editServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios'); bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);

View file

@ -0,0 +1,32 @@
import React from 'react';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
const propTypes = {
children: PropTypes.node.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
id: PropTypes.string,
type: PropTypes.string,
required: PropTypes.bool,
};
export const HorizontalFormGroup = ({ children, value, onChange, id = uuid(), type = 'text', required = true }) => (
<div className="form-group row">
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
{children}:
</label>
<div className="col-lg-11 col-md-10">
<input
className="form-control"
type={type}
id={id}
value={value}
required={required}
onChange={(e) => onChange(e.target.value)}
/>
</div>
</div>
);
HorizontalFormGroup.propTypes = propTypes;

View file

@ -9,7 +9,7 @@ describe('<App />', () => {
const MainHeader = () => ''; const MainHeader = () => '';
beforeEach(() => { beforeEach(() => {
const App = appFactory(MainHeader, identity, identity, identity); const App = appFactory(MainHeader, identity, identity, identity, identity);
wrapper = shallow(<App />); wrapper = shallow(<App />);
}); });
@ -20,13 +20,14 @@ describe('<App />', () => {
it('renders app main routes', () => { it('renders app main routes', () => {
const routes = wrapper.find(Route); const routes = wrapper.find(Route);
const expectedPaths = [ const expectedPaths = [
'/server/create',
'/', '/',
'/server/create',
'/server/:serverId/edit',
'/server/:serverId', '/server/:serverId',
]; ];
expect.assertions(expectedPaths.length + 1); expect.assertions(expectedPaths.length + 1);
expect(routes).toHaveLength(4); expect(routes).toHaveLength(expectedPaths.length + 1);
expectedPaths.forEach((path, index) => { expectedPaths.forEach((path, index) => {
expect(routes.at(index).prop('path')).toEqual(path); expect(routes.at(index).prop('path')).toEqual(path);
}); });

View file

@ -16,7 +16,7 @@ describe('<AsideMenu />', () => {
it('contains links to different sections', () => { it('contains links to different sections', () => {
const links = wrapped.find('[to]'); const links = wrapped.find('[to]');
expect(links).toHaveLength(3); expect(links).toHaveLength(4);
links.forEach((link) => expect(link.prop('to')).toContain('abc123')); links.forEach((link) => expect(link.prop('to')).toContain('abc123'));
}); });

View file

@ -2,6 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { identity } from 'ramda'; import { identity } from 'ramda';
import createServerConstruct from '../../src/servers/CreateServer'; import createServerConstruct from '../../src/servers/CreateServer';
import { ServerForm } from '../../src/servers/helpers/ServerForm';
describe('<CreateServer />', () => { describe('<CreateServer />', () => {
let wrapper; let wrapper;
@ -10,53 +11,41 @@ describe('<CreateServer />', () => {
const historyMock = { const historyMock = {
push: jest.fn(), push: jest.fn(),
}; };
const createWrapper = (serversImported = false) => {
beforeEach(() => { const CreateServer = createServerConstruct(ImportServersBtn, () => [ serversImported, () => '' ]);
createServerMock.mockReset();
const CreateServer = createServerConstruct(ImportServersBtn);
wrapper = shallow( wrapper = shallow(
<CreateServer createServer={createServerMock} resetSelectedServer={identity} history={historyMock} /> <CreateServer createServer={createServerMock} resetSelectedServer={identity} history={historyMock} />
); );
return wrapper;
};
afterEach(() => {
jest.resetAllMocks();
wrapper && wrapper.unmount();
}); });
afterEach(() => wrapper.unmount());
it('renders components', () => { it('renders components', () => {
expect(wrapper.find('#name')).toHaveLength(1); const wrapper = createWrapper();
expect(wrapper.find('#url')).toHaveLength(1);
expect(wrapper.find('#apiKey')).toHaveLength(1); expect(wrapper.find(ServerForm)).toHaveLength(1);
expect(wrapper.find(ImportServersBtn)).toHaveLength(1);
expect(wrapper.find('.create-server__import-success-msg')).toHaveLength(0); expect(wrapper.find('.create-server__import-success-msg')).toHaveLength(0);
}); });
it('shows success message when imported is true', () => { it('shows success message when imported is true', () => {
wrapper.setState({ serversImported: true }); const wrapper = createWrapper(true);
expect(wrapper.find('.create-server__import-success-msg')).toHaveLength(1); expect(wrapper.find('.create-server__import-success-msg')).toHaveLength(1);
}); });
it('creates server and redirects to it when form is submitted', () => { it('creates server and redirects to it when form is submitted', () => {
const form = wrapper.find('form'); const wrapper = createWrapper();
const form = wrapper.find(ServerForm);
form.simulate('submit', { preventDefault() { form.simulate('submit', {});
return '';
} });
expect(createServerMock).toHaveBeenCalledTimes(1); expect(createServerMock).toHaveBeenCalledTimes(1);
expect(historyMock.push).toHaveBeenCalledTimes(1); expect(historyMock.push).toHaveBeenCalledTimes(1);
}); });
it('updates state when inputs are changed', () => {
const nameInput = wrapper.find('#name');
const urlInput = wrapper.find('#url');
const apiKeyInput = wrapper.find('#apiKey');
nameInput.simulate('change', { target: { value: 'the_name' } });
urlInput.simulate('change', { target: { value: 'the_url' } });
apiKeyInput.simulate('change', { target: { value: 'the_api_key' } });
expect(wrapper.state('name')).toEqual('the_name');
expect(wrapper.state('url')).toEqual('the_url');
expect(wrapper.state('apiKey')).toEqual('the_api_key');
});
}); });

View file

@ -0,0 +1,52 @@
import React from 'react';
import { mount } from 'enzyme';
import { EditServer as editServerConstruct } from '../../src/servers/EditServer';
import { ServerForm } from '../../src/servers/helpers/ServerForm';
describe('<EditServer />', () => {
let wrapper;
const ServerError = jest.fn();
const editServerMock = jest.fn();
const historyMock = { push: jest.fn() };
const match = {
params: { serverId: 'abc123' },
};
const selectedServer = {
id: 'abc123',
name: 'name',
url: 'url',
apiKey: 'apiKey',
};
beforeEach(() => {
const EditServer = editServerConstruct(ServerError);
wrapper = mount(
<EditServer
editServer={editServerMock}
history={historyMock}
match={match}
selectedServer={selectedServer}
selectServer={jest.fn()}
/>
);
});
afterEach(() => {
jest.resetAllMocks();
wrapper && wrapper.unmount();
});
it('renders components', () => {
expect(wrapper.find(ServerForm)).toHaveLength(1);
});
it('edits server and redirects to it when form is submitted', () => {
const form = wrapper.find(ServerForm);
form.simulate('submit', {});
expect(editServerMock).toHaveBeenCalledTimes(1);
expect(historyMock.push).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,35 +1,49 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { ServerError } from '../../../src/servers/helpers/ServerError'; import { ServerError as createServerError } from '../../../src/servers/helpers/ServerError';
describe('<ServerError />', () => { describe('<ServerError />', () => {
let wrapper; let wrapper;
const selectedServer = { id: '' };
const ServerError = createServerError(() => '');
afterEach(() => wrapper && wrapper.unmount()); afterEach(() => wrapper && wrapper.unmount());
it.each([ it.each([
[ [
'not-found', 'not-found',
[ 'Could not find this Shlink server.' ], {
'These are the Shlink servers', 'Could not find this Shlink server.': true,
'Oops! Could not connect to this Shlink server.': false,
'Make sure you have internet connection, and the server is properly configured and on-line.': false,
'Alternatively, if you think you may have miss-configured this server': false,
},
], ],
[ [
'not-reachable', 'not-reachable',
[ {
'Oops! Could not connect to this Shlink server.', 'Could not find this Shlink server.': false,
'Make sure you have internet connection, and the server is properly configured and on-line.', 'Oops! Could not connect to this Shlink server.': true,
'Make sure you have internet connection, and the server is properly configured and on-line.': true,
'Alternatively, if you think you may have miss-configured this server': true,
},
], ],
'These are the other Shlink servers', ])('renders expected information for type "%s"', (type, textsToFind) => {
], wrapper = shallow(
])('renders expected information based on type', (type, expectedTitleParts, expectedBody) => { <BrowserRouter>
wrapper = shallow(<BrowserRouter><ServerError type={type} servers={{ list: [] }} /></BrowserRouter>); <ServerError type={type} servers={{ list: [] }} selectedServer={selectedServer} />
</BrowserRouter>
);
const wrapperText = wrapper.html(); const wrapperText = wrapper.html();
const textsToFind = [ ...expectedTitleParts, ...expectedBody ]; const textPairs = Object.entries(textsToFind);
expect.assertions(textsToFind.length); textPairs.forEach(([ text, shouldBeFound ]) => {
textsToFind.forEach((text) => { if (shouldBeFound) {
expect(wrapperText).toContain(text); expect(wrapperText).toContain(text);
} else {
expect(wrapperText).not.toContain(text);
}
}); });
}); });
}); });

View file

@ -0,0 +1,33 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ServerForm } from '../../../src/servers/helpers/ServerForm';
import { HorizontalFormGroup } from '../../../src/utils/HorizontalFormGroup';
describe('<ServerForm />', () => {
let wrapper;
const onSubmit = jest.fn();
beforeEach(() => {
wrapper = shallow(<ServerForm onSubmit={onSubmit}><span>Something</span></ServerForm>);
});
afterEach(() => {
jest.resetAllMocks();
wrapper && wrapper.unmount();
});
it('renders components', () => {
expect(wrapper.find(HorizontalFormGroup)).toHaveLength(3);
expect(wrapper.find('span')).toHaveLength(1);
});
it('invokes submit callback when submit event is triggered', () => {
const form = wrapper.find('form');
const preventDefault = jest.fn();
form.simulate('submit', { preventDefault });
expect(preventDefault).toHaveBeenCalled();
expect(onSubmit).toHaveBeenCalled();
});
});

View file

@ -72,7 +72,7 @@ describe('selectedServerReducer', () => {
}); });
it('dispatches error when health endpoint fails', async () => { it('dispatches error when health endpoint fails', async () => {
const expectedSelectedServer = { serverNotReachable: true }; const expectedSelectedServer = { ...selectedServer, serverNotReachable: true };
apiClientMock.health.mockRejectedValue({}); apiClientMock.health.mockRejectedValue({});

View file

@ -4,7 +4,7 @@ import reducer, {
deleteServer, deleteServer,
listServers, listServers,
createServers, createServers,
FETCH_SERVERS, FETCH_SERVERS_START, FETCH_SERVERS, FETCH_SERVERS_START, editServer,
} from '../../../src/servers/reducers/server'; } from '../../../src/servers/reducers/server';
describe('serverReducer', () => { describe('serverReducer', () => {
@ -16,6 +16,7 @@ describe('serverReducer', () => {
const ServersServiceMock = { const ServersServiceMock = {
listServers: jest.fn(() => list), listServers: jest.fn(() => list),
createServer: jest.fn(), createServer: jest.fn(),
editServer: jest.fn(),
deleteServer: jest.fn(), deleteServer: jest.fn(),
createServers: jest.fn(), createServers: jest.fn(),
}; };
@ -41,6 +42,7 @@ describe('serverReducer', () => {
expect(dispatch).toHaveBeenNthCalledWith(2, expectedFetchServersResult); expect(dispatch).toHaveBeenNthCalledWith(2, expectedFetchServersResult);
expect(ServersServiceMock.listServers).toHaveBeenCalledTimes(1); expect(ServersServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.createServer).not.toHaveBeenCalled(); expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
expect(ServersServiceMock.editServer).not.toHaveBeenCalled();
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled(); expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).not.toHaveBeenCalled(); expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
expect(axios.get).not.toHaveBeenCalled(); expect(axios.get).not.toHaveBeenCalled();
@ -91,6 +93,7 @@ describe('serverReducer', () => {
expect(dispatch).toHaveBeenNthCalledWith(2, { type: FETCH_SERVERS, list: expectedList }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: FETCH_SERVERS, list: expectedList });
expect(NoListServersServiceMock.listServers).toHaveBeenCalledTimes(1); expect(NoListServersServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(NoListServersServiceMock.createServer).not.toHaveBeenCalled(); expect(NoListServersServiceMock.createServer).not.toHaveBeenCalled();
expect(NoListServersServiceMock.editServer).not.toHaveBeenCalled();
expect(NoListServersServiceMock.deleteServer).not.toHaveBeenCalled(); expect(NoListServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(NoListServersServiceMock.createServers).toHaveBeenCalledTimes(1); expect(NoListServersServiceMock.createServers).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledTimes(1); expect(axios.get).toHaveBeenCalledTimes(1);
@ -103,9 +106,25 @@ describe('serverReducer', () => {
const result = createServer(ServersServiceMock, () => expectedFetchServersResult)(serverToCreate); const result = createServer(ServersServiceMock, () => expectedFetchServersResult)(serverToCreate);
expect(result).toEqual(expectedFetchServersResult); expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
expect(ServersServiceMock.createServer).toHaveBeenCalledTimes(1); expect(ServersServiceMock.createServer).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.createServer).toHaveBeenCalledWith(serverToCreate); expect(ServersServiceMock.createServer).toHaveBeenCalledWith(serverToCreate);
expect(ServersServiceMock.editServer).not.toHaveBeenCalled();
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
});
});
describe('editServer', () => {
it('edits existing server and then fetches servers again', () => {
const serverToEdit = { name: 'edited' };
const result = editServer(ServersServiceMock, () => expectedFetchServersResult)('123', serverToEdit);
expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers).not.toHaveBeenCalled(); expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
expect(ServersServiceMock.editServer).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.editServer).toHaveBeenCalledWith('123', serverToEdit);
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled(); expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).not.toHaveBeenCalled(); expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
}); });
@ -120,12 +139,13 @@ describe('serverReducer', () => {
expect(ServersServiceMock.listServers).not.toHaveBeenCalled(); expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
expect(ServersServiceMock.createServer).not.toHaveBeenCalled(); expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).not.toHaveBeenCalled(); expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
expect(ServersServiceMock.editServer).not.toHaveBeenCalled();
expect(ServersServiceMock.deleteServer).toHaveBeenCalledTimes(1); expect(ServersServiceMock.deleteServer).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.deleteServer).toHaveBeenCalledWith(serverToDelete); expect(ServersServiceMock.deleteServer).toHaveBeenCalledWith(serverToDelete);
}); });
}); });
describe('createServer', () => { describe('createServers', () => {
it('creates multiple servers and then fetches servers again', () => { it('creates multiple servers and then fetches servers again', () => {
const serversToCreate = values(list); const serversToCreate = values(list);
const result = createServers(ServersServiceMock, () => expectedFetchServersResult)(serversToCreate); const result = createServers(ServersServiceMock, () => expectedFetchServersResult)(serversToCreate);
@ -133,9 +153,10 @@ describe('serverReducer', () => {
expect(result).toEqual(expectedFetchServersResult); expect(result).toEqual(expectedFetchServersResult);
expect(ServersServiceMock.listServers).not.toHaveBeenCalled(); expect(ServersServiceMock.listServers).not.toHaveBeenCalled();
expect(ServersServiceMock.createServer).not.toHaveBeenCalled(); expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
expect(ServersServiceMock.editServer).not.toHaveBeenCalled();
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).toHaveBeenCalledTimes(1); expect(ServersServiceMock.createServers).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.createServers).toHaveBeenCalledWith(serversToCreate); expect(ServersServiceMock.createServers).toHaveBeenCalledWith(serversToCreate);
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
}); });
}); });
}); });

View file

@ -5,15 +5,19 @@ describe('ServersService', () => {
abc123: { id: 'abc123' }, abc123: { id: 'abc123' },
def456: { id: 'def456' }, def456: { id: 'def456' },
}; };
const createStorageMock = (returnValue) => ({ const createService = (withServers = true) => {
const storageMock = {
set: jest.fn(), set: jest.fn(),
get: jest.fn(() => returnValue), get: jest.fn(() => withServers ? servers : undefined),
}); };
const service = new ServersService(storageMock);
return [ service, storageMock ];
};
describe('listServers', () => { describe('listServers', () => {
it('returns an empty object when servers are not found in storage', () => { it('returns an empty object when servers are not found in storage', () => {
const storageMock = createStorageMock(); const [ service, storageMock ] = createService(false);
const service = new ServersService(storageMock);
const result = service.listServers(); const result = service.listServers();
@ -23,8 +27,7 @@ describe('ServersService', () => {
}); });
it('returns value from storage when found', () => { it('returns value from storage when found', () => {
const storageMock = createStorageMock(servers); const [ service, storageMock ] = createService();
const service = new ServersService(storageMock);
const result = service.listServers(); const result = service.listServers();
@ -36,8 +39,7 @@ describe('ServersService', () => {
describe('findServerById', () => { describe('findServerById', () => {
it('returns undefined when requested server is not found', () => { it('returns undefined when requested server is not found', () => {
const storageMock = createStorageMock(servers); const [ service, storageMock ] = createService();
const service = new ServersService(storageMock);
const result = service.findServerById('ghi789'); const result = service.findServerById('ghi789');
@ -47,8 +49,7 @@ describe('ServersService', () => {
}); });
it('returns server from list when found', () => { it('returns server from list when found', () => {
const storageMock = createStorageMock(servers); const [ service, storageMock ] = createService();
const service = new ServersService(storageMock);
const result = service.findServerById('abc123'); const result = service.findServerById('abc123');
@ -60,8 +61,7 @@ describe('ServersService', () => {
describe('createServer', () => { describe('createServer', () => {
it('adds one server to the list', () => { it('adds one server to the list', () => {
const storageMock = createStorageMock(servers); const [ service, storageMock ] = createService();
const service = new ServersService(storageMock);
service.createServer({ id: 'ghi789' }); service.createServer({ id: 'ghi789' });
@ -77,8 +77,7 @@ describe('ServersService', () => {
describe('createServers', () => { describe('createServers', () => {
it('adds multiple servers to the list', () => { it('adds multiple servers to the list', () => {
const storageMock = createStorageMock(servers); const [ service, storageMock ] = createService();
const service = new ServersService(storageMock);
service.createServers([{ id: 'ghi789' }, { id: 'jkl123' }]); service.createServers([{ id: 'ghi789' }, { id: 'jkl123' }]);
@ -95,8 +94,7 @@ describe('ServersService', () => {
describe('deleteServer', () => { describe('deleteServer', () => {
it('removes one server from the list', () => { it('removes one server from the list', () => {
const storageMock = createStorageMock(servers); const [ service, storageMock ] = createService();
const service = new ServersService(storageMock);
service.deleteServer({ id: 'abc123' }); service.deleteServer({ id: 'abc123' });
@ -107,4 +105,26 @@ describe('ServersService', () => {
}); });
}); });
}); });
describe('editServer', () => {
it('dos nothing is provided server does not exist', () => {
const [ service, storageMock ] = createService();
service.editServer('notFound', {});
expect(storageMock.set).not.toHaveBeenCalled();
});
it('updates the list with provided server data', () => {
const [ service, storageMock ] = createService();
const serverData = { name: 'foo', apiKey: 'bar' };
service.editServer('abc123', serverData);
expect(storageMock.set).toHaveBeenCalledWith(expect.anything(), {
abc123: { id: 'abc123', ...serverData },
def456: { id: 'def456' },
});
});
});
}); });