Created new section to manage servers

This commit is contained in:
Alejandro Celaya 2021-10-17 19:13:06 +02:00
parent 002f280364
commit 7f4263966e
19 changed files with 249 additions and 61 deletions

View file

@ -23,6 +23,7 @@ const App = (
CreateServer: FC, CreateServer: FC,
EditServer: FC, EditServer: FC,
Settings: FC, Settings: FC,
ManageServers: FC,
ShlinkVersionsContainer: FC, ShlinkVersionsContainer: FC,
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => { ) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
useEffect(() => { useEffect(() => {
@ -43,6 +44,7 @@ const App = (
<Switch> <Switch>
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
<Route exact path="/settings" component={Settings} /> <Route exact path="/settings" component={Settings} />
<Route exact path="/manage-servers" component={ManageServers} />
<Route exact path="/server/create" component={CreateServer} /> <Route exact path="/server/create" component={CreateServer} />
<Route exact path="/server/:serverId/edit" component={EditServer} /> <Route exact path="/server/:serverId/edit" component={EditServer} />
<Route path="/server/:serverId" component={MenuLayout} /> <Route path="/server/:serverId" component={MenuLayout} />

View file

@ -14,6 +14,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
'CreateServer', 'CreateServer',
'EditServer', 'EditServer',
'Settings', 'Settings',
'ManageServers',
'ShlinkVersionsContainer', 'ShlinkVersionsContainer',
); );
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ])); bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));

View file

@ -115,6 +115,16 @@ hr {
color: var(--text-color) !important; color: var(--text-color) !important;
} }
.dropdown-item--danger.dropdown-item--danger {
color: $dangerColor;
&:hover,
&:active,
&.active {
color: $dangerColor !important;
}
}
.badge-main { .badge-main {
color: #ffffff; color: #ffffff;
background-color: var(--brand-color); background-color: var(--brand-color);

View file

@ -8,9 +8,3 @@
text-align: right; text-align: right;
} }
} }
.create-server__csv-select {
position: absolute;
left: -9999px;
top: -9999px;
}

View file

@ -1,30 +1,35 @@
import { FC } from 'react'; import { FC } from 'react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { RouterProps } from 'react-router'; import { RouterProps } from 'react-router';
import { Button } from 'reactstrap';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import NoMenuLayout from '../common/NoMenuLayout'; import NoMenuLayout from '../common/NoMenuLayout';
import { StateFlagTimeout } from '../utils/helpers/hooks'; import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerData, ServerWithId } from './data'; import { ServerData, ServersMap, ServerWithId } from './data';
import './CreateServer.scss'; import './CreateServer.scss';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
interface CreateServerProps extends RouterProps { interface CreateServerProps extends RouterProps {
createServer: (server: ServerWithId) => void; createServer: (server: ServerWithId) => void;
servers: ServersMap;
} }
const ImportResult = ({ type }: { type: 'error' | 'success' }) => ( const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
<Result type={type}> <div className="mt-3">
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'} <Result type={type}>
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'} {type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
</Result> {type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result>
</div>
); );
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => ( const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
{ createServer, history: { push } }: CreateServerProps, { servers, createServer, history: { push } }: CreateServerProps,
) => { ) => {
const hasServers = !!Object.keys(servers).length;
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const handleSubmit = (serverData: ServerData) => { const handleSubmit = (serverData: ServerData) => {
@ -37,16 +42,13 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}> <ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} /> {!hasServers &&
<button className="btn btn-outline-primary">Create server</button> <ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
<Button outline color="primary" className="ml-2">Create server</Button>
</ServerForm> </ServerForm>
{(serversImported || errorImporting) && ( {serversImported && <ImportResult type="success" />}
<div className="mt-3"> {errorImporting && <ImportResult type="error" />}
{serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult type="error" />}
</div>
)}
</NoMenuLayout> </NoMenuLayout>
); );
}; };

View file

@ -1,3 +1,4 @@
import { FC } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { RouterProps } from 'react-router'; import { RouterProps } from 'react-router';
import { ServerWithId } from './data'; import { ServerWithId } from './data';
@ -6,17 +7,20 @@ export interface DeleteServerModalProps {
server: ServerWithId; server: ServerWithId;
toggle: () => void; toggle: () => void;
isOpen: boolean; isOpen: boolean;
redirectHome?: boolean;
} }
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps { interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
deleteServer: (server: ServerWithId) => void; deleteServer: (server: ServerWithId) => void;
} }
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => { const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
{ server, toggle, isOpen, deleteServer, history, redirectHome = true },
) => {
const closeModal = () => { const closeModal = () => {
deleteServer(server); deleteServer(server);
toggle(); toggle();
history.push('/'); redirectHome && history.push('/');
}; };
return ( return (

View file

@ -0,0 +1,80 @@
import { FC, useEffect, useState } from 'react';
import { Button } from 'reactstrap';
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
import NoMenuLayout from '../common/NoMenuLayout';
import { SimpleCard } from '../utils/SimpleCard';
import SearchField from '../utils/SearchField';
import { Result } from '../utils/Result';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServersMap } from './data';
import { ManageServersRowProps } from './ManageServersRow';
import ServersExporter from './services/ServersExporter';
interface ManageServersProps {
servers: ServersMap;
}
const SHOW_IMPORT_MSG_TIME = 4000;
export const ManageServers = (
serversExporter: ServersExporter,
ImportServersBtn: FC<ImportServersBtnProps>,
useStateFlagTimeout: StateFlagTimeout,
ManageServersRow: FC<ManageServersRowProps>,
): FC<ManageServersProps> => ({ servers }) => {
const allServers = Object.values(servers);
const [ serversList, setServersList ] = useState(allServers);
const filterServers = (searchTerm: string) => setServersList(
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
);
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
useEffect(() => {
setServersList(Object.values(servers));
}, [ servers ]);
return (
<NoMenuLayout>
<SearchField className="mb-3" onChange={filterServers} />
<div className="mb-3 d-flex flex-row">
<ImportServersBtn onImportError={setErrorImporting} />
<Button outline className="ml-2" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button>
<Button outline color="primary" className="ml-auto" tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</Button>
</div>
<SimpleCard title="Shlink servers">
<table className="table table-hover mb-0">
<thead className="responsive-table__header">
<tr>
{hasAutoConnect && <th />}
<th>Name</th>
<th>Base URL</th>
<th />
</tr>
</thead>
<tbody>
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
{serversList.map((server) =>
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />)
}
</tbody>
</table>
</SimpleCard>
{errorImporting && (
<div className="mt-3">
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
</div>
)}
</NoMenuLayout>
);
};

View file

@ -0,0 +1,68 @@
import { FC } from 'react';
import { DropdownItem, UncontrolledTooltip } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCheck as checkIcon,
faEdit as editIcon,
faMinusCircle as deleteIcon,
faPlug as connectIcon,
faToggleOn as toggleOnIcon,
faToggleOff as toggleOffIcon,
} from '@fortawesome/free-solid-svg-icons';
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
import { useToggle } from '../utils/helpers/hooks';
import { ServerWithId } from './data';
import { DeleteServerModalProps } from './DeleteServerModal';
export interface ManageServersRowProps {
server: ServerWithId;
hasAutoConnect: boolean;
}
export const ManageServersRow = (
DeleteServerModal: FC<DeleteServerModalProps>,
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => {
const [ isMenuOpen, toggleMenu ] = useToggle();
const [ isModalOpen,, showModal, hideModal ] = useToggle();
const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOnIcon : toggleOffIcon;
return (
<tr className="responsive-table__row">
{hasAutoConnect && (
<td className="responsive-table__cell text-lg-right" data-th="Auto-connect">
{isAutoConnect && (
<>
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
<UncontrolledTooltip target="autoConnectIcon">Auto-connect to this server</UncontrolledTooltip>
</>
)}
</td>
)}
<th className="responsive-table__cell" data-th="Name">
<Link to={serverUrl}>{server.name}</Link>
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-right">
<DropdownBtnMenu isOpen={isMenuOpen} toggle={toggleMenu}>
<DropdownItem tag={Link} to={serverUrl}>
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
</DropdownItem>
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
</DropdownItem>
<DropdownItem>
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Unset' : 'Set'} auto-connect
</DropdownItem>
<DropdownItem divider />
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
</DropdownItem>
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
</DropdownBtnMenu>
</td>
</tr>
);
};

View file

@ -1,9 +1,8 @@
import { isEmpty, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap'; import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons'; import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ServersExporter from './services/ServersExporter';
import { isServerWithId, SelectedServer, ServersMap } from './data'; import { isServerWithId, SelectedServer, ServersMap } from './data';
export interface ServersDropdownProps { export interface ServersDropdownProps {
@ -11,17 +10,16 @@ export interface ServersDropdownProps {
selectedServer: SelectedServer; selectedServer: SelectedServer;
} }
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => { const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = values(servers); const serversList = values(servers);
const createServerItem = (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
</DropdownItem>
);
const renderServers = () => { const renderServers = () => {
if (isEmpty(serversList)) { if (isEmpty(serversList)) {
return createServerItem; return (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
</DropdownItem>
);
} }
return ( return (
@ -37,9 +35,8 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
</DropdownItem> </DropdownItem>
))} ))}
<DropdownItem divider /> <DropdownItem divider />
{createServerItem} <DropdownItem tag={Link} to="/manage-servers">
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}> <FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span>
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
</DropdownItem> </DropdownItem>
</> </>
); );

View file

@ -8,6 +8,7 @@ export interface ServerData {
export interface ServerWithId extends ServerData { export interface ServerWithId extends ServerData {
id: string; id: string;
autoConnect?: boolean;
} }
export interface ReachableServer extends ServerWithId { export interface ReachableServer extends ServerWithId {

View file

@ -0,0 +1,5 @@
.import-servers-btn__csv-select {
position: absolute;
left: -9999px;
top: -9999px;
}

View file

@ -1,13 +1,17 @@
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react'; import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
import { UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ServersImporter from '../services/ServersImporter'; import ServersImporter from '../services/ServersImporter';
import { ServerData } from '../data'; import { ServerData } from '../data';
import './ImportServersBtn.scss';
type Ref<T> = RefObject<T> | MutableRefObject<T>; type Ref<T> = RefObject<T> | MutableRefObject<T>;
export interface ImportServersBtnProps { export interface ImportServersBtnProps {
onImport?: () => void; onImport?: () => void;
onImportError?: (error: Error) => void; onImportError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom';
} }
interface ImportServersBtnConnectProps extends ImportServersBtnProps { interface ImportServersBtnConnectProps extends ImportServersBtnProps {
@ -20,6 +24,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
fileRef, fileRef,
onImport = () => {}, onImport = () => {},
onImportError = () => {}, onImportError = () => {},
tooltipPlacement = 'bottom',
}: ImportServersBtnConnectProps) => { }: ImportServersBtnConnectProps) => {
const ref = fileRef ?? useRef<HTMLInputElement>(); const ref = fileRef ?? useRef<HTMLInputElement>();
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) => const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
@ -34,19 +39,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
return ( return (
<> <>
<button <Button outline id="importBtn" onClick={() => ref.current?.click()}>
type="button" <FontAwesomeIcon icon={importIcon} fixedWidth /> Import from file
className="btn btn-outline-secondary mr-2" </Button>
id="importBtn" <UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
onClick={() => ref.current?.click()}
>
Import from file
</button>
<UncontrolledTooltip placement="top" target="importBtn">
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>. You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
</UncontrolledTooltip> </UncontrolledTooltip>
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} /> <input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onChange} />
</> </>
); );
}; };

View file

@ -31,7 +31,7 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
<SimpleCard className="mb-3" title={title}> <SimpleCard className="mb-3" title={title}>
<FormGroup value={name} onChange={setName}>Name</FormGroup> <FormGroup value={name} onChange={setName}>Name</FormGroup>
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup> <FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
<FormGroup value={apiKey} onChange={setApiKey}>APIkey</FormGroup> <FormGroup value={apiKey} onChange={setApiKey}>API key</FormGroup>
</SimpleCard> </SimpleCard>
<div className="text-right">{children}</div> <div className="text-right">{children}</div>

View file

@ -14,19 +14,34 @@ import { ServerError } from '../helpers/ServerError';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { Overview } from '../Overview'; import { Overview } from '../Overview';
import { ManageServers } from '../ManageServers';
import { ManageServersRow } from '../ManageServersRow';
import ServersImporter from './ServersImporter'; import ServersImporter from './ServersImporter';
import ServersExporter from './ServersExporter'; import ServersExporter from './ServersExporter';
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Components // Components
bottle.serviceFactory(
'ManageServers',
ManageServers,
'ServersExporter',
'ImportServersBtn',
'useStateFlagTimeout',
'ManageServersRow',
);
bottle.decorator('ManageServers', connect([ 'servers' ]));
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'DeleteServerModal');
bottle.decorator('ManageServers', connect([ 'servers' ]));
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout'); bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', withoutSelectedServer); bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ])); bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('EditServer', EditServer, 'ServerError'); bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ])); bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter'); bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ])); bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal); bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);

View file

@ -1,6 +1,7 @@
import { FC, useRef } from 'react'; import { FC, useRef } from 'react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { InputType } from 'reactstrap/lib/Input'; import { InputType } from 'reactstrap/lib/Input';
import { FormGroup } from 'reactstrap';
export interface FormGroupContainerProps { export interface FormGroupContainerProps {
value: string; value: string;
@ -19,7 +20,7 @@ export const FormGroupContainer: FC<FormGroupContainerProps> = (
const forId = useRef<string>(id ?? uuid()); const forId = useRef<string>(id ?? uuid());
return ( return (
<div className={`form-group ${className ?? ''}`}> <FormGroup className={className ?? ''}>
<label htmlFor={forId.current} className={labelClassName ?? ''}> <label htmlFor={forId.current} className={labelClassName ?? ''}>
{children}: {children}:
</label> </label>
@ -32,6 +33,6 @@ export const FormGroupContainer: FC<FormGroupContainerProps> = (
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
</div> </FormGroup>
); );
}; };

View file

@ -11,7 +11,16 @@ describe('<App />', () => {
const ShlinkVersions = () => null; const ShlinkVersions = () => null;
beforeEach(() => { beforeEach(() => {
const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, ShlinkVersions); const App = appFactory(
MainHeader,
() => null,
() => null,
() => null,
() => null,
() => null,
() => null,
ShlinkVersions,
);
wrapper = shallow( wrapper = shallow(
<App <App
@ -36,6 +45,7 @@ describe('<App />', () => {
const expectedPaths = [ const expectedPaths = [
'/', '/',
'/settings', '/settings',
'/manage-servers',
'/server/create', '/server/create',
'/server/:serverId/edit', '/server/:serverId/edit',
'/server/:serverId', '/server/:serverId',

View file

@ -3,6 +3,7 @@ import { Mock } from 'ts-mockery';
import { History } from 'history'; import { History } from 'history';
import createServerConstruct from '../../src/servers/CreateServer'; import createServerConstruct from '../../src/servers/CreateServer';
import { ServerForm } from '../../src/servers/helpers/ServerForm'; import { ServerForm } from '../../src/servers/helpers/ServerForm';
import { ServerWithId } from '../../src/servers/data';
describe('<CreateServer />', () => { describe('<CreateServer />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -10,13 +11,14 @@ describe('<CreateServer />', () => {
const createServerMock = jest.fn(); const createServerMock = jest.fn();
const push = jest.fn(); const push = jest.fn();
const historyMock = Mock.of<History>({ push }); const historyMock = Mock.of<History>({ push });
const servers = { foo: Mock.all<ServerWithId>() };
const createWrapper = (serversImported = false, importFailed = false) => { const createWrapper = (serversImported = false, importFailed = false) => {
const useStateFlagTimeout = jest.fn() const useStateFlagTimeout = jest.fn()
.mockReturnValueOnce([ serversImported, () => '' ]) .mockReturnValueOnce([ serversImported, () => '' ])
.mockReturnValueOnce([ importFailed, () => '' ]); .mockReturnValueOnce([ importFailed, () => '' ]);
const CreateServer = createServerConstruct(ImportServersBtn, useStateFlagTimeout); const CreateServer = createServerConstruct(ImportServersBtn, useStateFlagTimeout);
wrapper = shallow(<CreateServer createServer={createServerMock} history={historyMock} />); wrapper = shallow(<CreateServer createServer={createServerMock} history={historyMock} servers={servers} />);
return wrapper; return wrapper;
}; };

View file

@ -1,15 +1,12 @@
import { values } from 'ramda'; import { values } from 'ramda';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { FC } from 'react';
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { DropdownItem, DropdownToggle } from 'reactstrap'; import { DropdownItem, DropdownToggle } from 'reactstrap';
import serversDropdownCreator, { ServersDropdownProps } from '../../src/servers/ServersDropdown'; import ServersDropdown from '../../src/servers/ServersDropdown';
import { ServerWithId } from '../../src/servers/data'; import { ServerWithId } from '../../src/servers/data';
import ServersExporter from '../../src/servers/services/ServersExporter';
describe('<ServersDropdown />', () => { describe('<ServersDropdown />', () => {
let wrapped: ShallowWrapper; let wrapped: ShallowWrapper;
let ServersDropdown: FC<ServersDropdownProps>;
const servers = { const servers = {
'1a': Mock.of<ServerWithId>({ name: 'foo', id: '1a' }), '1a': Mock.of<ServerWithId>({ name: 'foo', id: '1a' }),
'2b': Mock.of<ServerWithId>({ name: 'bar', id: '2b' }), '2b': Mock.of<ServerWithId>({ name: 'bar', id: '2b' }),
@ -17,13 +14,12 @@ describe('<ServersDropdown />', () => {
}; };
beforeEach(() => { beforeEach(() => {
ServersDropdown = serversDropdownCreator(Mock.of<ServersExporter>());
wrapped = shallow(<ServersDropdown servers={servers} selectedServer={null} />); wrapped = shallow(<ServersDropdown servers={servers} selectedServer={null} />);
}); });
afterEach(() => wrapped.unmount()); afterEach(() => wrapped.unmount());
it('contains the list of servers, the divider, the create button and the export button', () => it('contains the list of servers, the divider, the create button and the export button', () =>
expect(wrapped.find(DropdownItem)).toHaveLength(values(servers).length + 3)); expect(wrapped.find(DropdownItem)).toHaveLength(values(servers).length + 2));
it('contains a toggle with proper title', () => it('contains a toggle with proper title', () =>
expect(wrapped.find(DropdownToggle)).toHaveLength(1)); expect(wrapped.find(DropdownToggle)).toHaveLength(1));
@ -32,7 +28,7 @@ describe('<ServersDropdown />', () => {
const items = wrapped.find(DropdownItem); const items = wrapped.find(DropdownItem);
expect(items.filter('[divider]')).toHaveLength(1); expect(items.filter('[divider]')).toHaveLength(1);
expect(items.filter('.servers-dropdown__export-item')).toHaveLength(1); expect(items.filterWhere((item) => item.prop('to') === '/manage-servers')).toHaveLength(1);
}); });
it('shows only create link when no servers exist yet', () => { it('shows only create link when no servers exist yet', () => {

View file

@ -30,7 +30,7 @@ describe('<ImportServersBtn />', () => {
it('renders a button, a tooltip and a file input', () => { it('renders a button, a tooltip and a file input', () => {
expect(wrapper.find('#importBtn')).toHaveLength(1); expect(wrapper.find('#importBtn')).toHaveLength(1);
expect(wrapper.find(UncontrolledTooltip)).toHaveLength(1); expect(wrapper.find(UncontrolledTooltip)).toHaveLength(1);
expect(wrapper.find('.create-server__csv-select')).toHaveLength(1); expect(wrapper.find('.import-servers-btn__csv-select')).toHaveLength(1);
}); });
it('triggers click on file ref when button is clicked', () => { it('triggers click on file ref when button is clicked', () => {
@ -42,7 +42,7 @@ describe('<ImportServersBtn />', () => {
}); });
it('imports servers when file input changes', (done) => { it('imports servers when file input changes', (done) => {
const file = wrapper.find('.create-server__csv-select'); const file = wrapper.find('.import-servers-btn__csv-select');
file.simulate('change', { target: { files: [ '' ] } }); file.simulate('change', { target: { files: [ '' ] } });