mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-04-20 05:26:20 +00:00
Merge pull request #510 from acelaya-forks/feature/improved-servers-management
Feature/improved servers management
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"ignorePatterns": ["src/service*.ts"],
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"complexity": "off"
|
"complexity": "off",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-assertion": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
### Added
|
### Added
|
||||||
* [#496](https://github.com/shlinkio/shlink-web-client/issues/496) Allowed to select "all visits" as the default interval for visits.
|
* [#496](https://github.com/shlinkio/shlink-web-client/issues/496) Allowed to select "all visits" as the default interval for visits.
|
||||||
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
|
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
|
||||||
|
* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* Moved ci workflow to external repo and reused
|
* Moved ci workflow to external repo and reused
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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' ]));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -8,9 +8,3 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-server__csv-select {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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' }) => (
|
||||||
|
<div className="mt-3">
|
||||||
<Result type={type}>
|
<Result type={type}>
|
||||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||||
</Result>
|
</Result>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||||
{ createServer, history: { push } }: CreateServerProps,
|
{ servers, createServer, history: { push, goBack } }: 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,14 @@ 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} />}
|
||||||
|
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||||
|
<Button outline color="primary" className="ml-2">Create server</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
|
|
||||||
{(serversImported || errorImporting) && (
|
|
||||||
<div className="mt-3">
|
|
||||||
{serversImported && <ImportResult type="success" />}
|
{serversImported && <ImportResult type="success" />}
|
||||||
{errorImporting && <ImportResult type="error" />}
|
{errorImporting && <ImportResult type="error" />}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface EditServerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||||
{ editServer, selectedServer, history: { push, goBack } },
|
{ editServer, selectedServer, history: { goBack } },
|
||||||
) => {
|
) => {
|
||||||
if (!isServerWithId(selectedServer)) {
|
if (!isServerWithId(selectedServer)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
|||||||
|
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
editServer(selectedServer.id, serverData);
|
editServer(selectedServer.id, serverData);
|
||||||
push(`/server/${selectedServer.id}`);
|
goBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
86
src/servers/ManageServers.tsx
Normal file
86
src/servers/ManageServers.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
import { Button, Row } 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} />
|
||||||
|
|
||||||
|
<Row className="mb-3">
|
||||||
|
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||||
|
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||||
|
{allServers.length > 0 && (
|
||||||
|
<Button outline className="ml-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||||
|
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6 text-md-right d-flex d-md-block">
|
||||||
|
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||||
|
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<SimpleCard>
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead className="responsive-table__header">
|
||||||
|
<tr>
|
||||||
|
{hasAutoConnect && <th style={{ width: '50px' }} />}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
src/servers/ManageServersRow.tsx
Normal file
38
src/servers/ManageServersRow.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { ServerWithId } from './data';
|
||||||
|
import { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
|
||||||
|
|
||||||
|
export interface ManageServersRowProps {
|
||||||
|
server: ServerWithId;
|
||||||
|
hasAutoConnect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManageServersRow = (
|
||||||
|
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>,
|
||||||
|
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => (
|
||||||
|
<tr className="responsive-table__row">
|
||||||
|
{hasAutoConnect && (
|
||||||
|
<td className="responsive-table__cell" data-th="Auto-connect">
|
||||||
|
{server.autoConnect && (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
|
||||||
|
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
||||||
|
Auto-connect to this server
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<th className="responsive-table__cell" data-th="Name">
|
||||||
|
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||||
|
</th>
|
||||||
|
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
||||||
|
<td className="responsive-table__cell text-right">
|
||||||
|
<ManageServersRowDropdown server={server} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
53
src/servers/ManageServersRowDropdown.tsx
Normal file
53
src/servers/ManageServersRowDropdown.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faBan as toggleOffIcon,
|
||||||
|
faEdit as editIcon,
|
||||||
|
faMinusCircle as deleteIcon,
|
||||||
|
faPlug as connectIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { faCircle as toggleOnIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
|
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { DeleteServerModalProps } from './DeleteServerModal';
|
||||||
|
import { ServerWithId } from './data';
|
||||||
|
|
||||||
|
export interface ManageServersRowDropdownProps {
|
||||||
|
server: ServerWithId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownProps {
|
||||||
|
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManageServersRowDropdown = (
|
||||||
|
DeleteServerModal: FC<DeleteServerModalProps>,
|
||||||
|
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
|
||||||
|
const [ isMenuOpen, toggleMenu ] = useToggle();
|
||||||
|
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
||||||
|
const serverUrl = `/server/${server.id}`;
|
||||||
|
const { autoConnect: isAutoConnect } = server;
|
||||||
|
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 onClick={() => setAutoConnect(server, !server.autoConnect)}>
|
||||||
|
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,45 +1,37 @@
|
|||||||
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 { getServerId, SelectedServer, ServersMap } from './data';
|
||||||
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
|
||||||
|
|
||||||
export interface ServersDropdownProps {
|
export interface ServersDropdownProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
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 = (
|
|
||||||
|
const renderServers = () => {
|
||||||
|
if (isEmpty(serversList)) {
|
||||||
|
return (
|
||||||
<DropdownItem tag={Link} to="/server/create">
|
<DropdownItem tag={Link} to="/server/create">
|
||||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
|
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderServers = () => {
|
|
||||||
if (isEmpty(serversList)) {
|
|
||||||
return createServerItem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{serversList.map(({ name, id }) => (
|
{serversList.map(({ name, id }) => (
|
||||||
<DropdownItem
|
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
|
||||||
key={id}
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${id}`}
|
|
||||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
|
||||||
>
|
|
||||||
{name}
|
{name}
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
5
src/servers/helpers/ImportServersBtn.scss
Normal file
5
src/servers/helpers/ImportServersBtn.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.import-servers-btn__csv-select {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } 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';
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
@@ -15,12 +20,15 @@ interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
|||||||
fileRef: Ref<HTMLInputElement>;
|
fileRef: Ref<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||||
createServers,
|
createServers,
|
||||||
fileRef,
|
fileRef,
|
||||||
|
children,
|
||||||
onImport = () => {},
|
onImport = () => {},
|
||||||
onImportError = () => {},
|
onImportError = () => {},
|
||||||
}: ImportServersBtnConnectProps) => {
|
tooltipPlacement = 'bottom',
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
importServersFromFile(target.files?.[0])
|
importServersFromFile(target.files?.[0])
|
||||||
@@ -34,19 +42,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||||
type="button"
|
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? '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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { assoc, dissoc, map, pipe, reduce } from 'ramda';
|
import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
@@ -8,12 +8,22 @@ import { buildReducer } from '../../utils/helpers/redux';
|
|||||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||||
|
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface CreateServersAction extends Action<string> {
|
export interface CreateServersAction extends Action<string> {
|
||||||
newServers: ServersMap;
|
newServers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeleteServerAction extends Action<string> {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetAutoConnectAction extends Action<string> {
|
||||||
|
serverId: string;
|
||||||
|
autoConnect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: ServersMap = {};
|
const initialState: ServersMap = {};
|
||||||
|
|
||||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||||
@@ -24,12 +34,28 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
|||||||
return assoc('id', uuid(), server);
|
return assoc('id', uuid(), server);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ServersMap, CreateServersAction>({
|
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
||||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||||
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
|
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
||||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
||||||
? state
|
? state
|
||||||
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
||||||
|
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
|
||||||
|
if (!state[serverId]) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autoConnect) {
|
||||||
|
return assoc(serverId, { ...state[serverId], autoConnect }, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromPairs(
|
||||||
|
toPairs(state).map(([ evaluatedServerId, server ]) => [
|
||||||
|
evaluatedServerId,
|
||||||
|
{ ...server, autoConnect: evaluatedServerId === serverId },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
||||||
@@ -48,4 +74,10 @@ export const editServer = (serverId: string, serverData: Partial<ServerData>) =>
|
|||||||
serverData,
|
serverData,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });
|
export const deleteServer = ({ id }: ServerWithId): DeleteServerAction => ({ type: DELETE_SERVER, serverId: id });
|
||||||
|
|
||||||
|
export const setAutoConnect = ({ id }: ServerWithId, autoConnect: boolean): SetAutoConnectAction => ({
|
||||||
|
type: SET_AUTO_CONNECT,
|
||||||
|
serverId: id,
|
||||||
|
autoConnect,
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,26 +7,44 @@ import DeleteServerButton from '../DeleteServerButton';
|
|||||||
import { EditServer } from '../EditServer';
|
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, editServer } from '../reducers/servers';
|
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||||
import { fetchServers } from '../reducers/remoteServers';
|
import { fetchServers } from '../reducers/remoteServers';
|
||||||
import ForServerVersion from '../helpers/ForServerVersion';
|
import ForServerVersion from '../helpers/ForServerVersion';
|
||||||
import { ServerError } from '../helpers/ServerError';
|
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 { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
||||||
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, 'ManageServersRowDropdown');
|
||||||
|
|
||||||
|
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
|
||||||
|
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ]));
|
||||||
|
|
||||||
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);
|
||||||
@@ -62,6 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
bottle.serviceFactory('createServers', () => createServers);
|
bottle.serviceFactory('createServers', () => createServers);
|
||||||
bottle.serviceFactory('deleteServer', () => deleteServer);
|
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||||
bottle.serviceFactory('editServer', () => editServer);
|
bottle.serviceFactory('editServer', () => editServer);
|
||||||
|
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
|
||||||
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||||
|
|
||||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
@import '../../utils/base';
|
|
||||||
|
|
||||||
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
|
||||||
color: $dangerColor;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&.active {
|
|
||||||
color: $dangerColor !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import { ShortUrl, ShortUrlModalProps } from '../data';
|
|||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
||||||
import './ShortUrlsRowMenu.scss';
|
|
||||||
|
|
||||||
export interface ShortUrlsRowMenuProps {
|
export interface ShortUrlsRowMenuProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
@@ -45,7 +44,7 @@ const ShortUrlsRowMenu = (
|
|||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|
||||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
<DropdownItem className="dropdown-item--danger" onClick={toggleDelete}>
|
||||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('<AppUpdateBanner />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('invokes toggle when alert is toggled', () => {
|
it('invokes toggle when alert is toggled', () => {
|
||||||
(wrapper.prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
(wrapper.prop('toggle') as Function)();
|
||||||
|
|
||||||
expect(toggle).toHaveBeenCalled();
|
expect(toggle).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('<EditDomainRedirectsModal />', () => {
|
|||||||
it('has different handlers to toggle the modal', () => {
|
it('has different handlers to toggle the modal', () => {
|
||||||
expect(toggle).not.toHaveBeenCalled();
|
expect(toggle).not.toHaveBeenCalled();
|
||||||
|
|
||||||
(wrapper.prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
(wrapper.prop('toggle') as Function)();
|
||||||
(wrapper.find(ModalHeader).prop('toggle') as Function)();
|
(wrapper.find(ModalHeader).prop('toggle') as Function)();
|
||||||
wrapper.find(Button).first().simulate('click');
|
wrapper.find(Button).first().simulate('click');
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ describe('<EditServer />', () => {
|
|||||||
let wrapper: ReactWrapper;
|
let wrapper: ReactWrapper;
|
||||||
const ServerError = jest.fn();
|
const ServerError = jest.fn();
|
||||||
const editServerMock = jest.fn();
|
const editServerMock = jest.fn();
|
||||||
const push = jest.fn();
|
const goBack = jest.fn();
|
||||||
const historyMock = Mock.of<History>({ push });
|
const historyMock = Mock.of<History>({ goBack });
|
||||||
const match = Mock.of<match<{ serverId: string }>>({
|
const match = Mock.of<match<{ serverId: string }>>({
|
||||||
params: { serverId: 'abc123' },
|
params: { serverId: 'abc123' },
|
||||||
});
|
});
|
||||||
@@ -50,6 +50,6 @@ describe('<EditServer />', () => {
|
|||||||
form.simulate('submit', {});
|
form.simulate('submit', {});
|
||||||
|
|
||||||
expect(editServerMock).toHaveBeenCalledTimes(1);
|
expect(editServerMock).toHaveBeenCalledTimes(1);
|
||||||
expect(push).toHaveBeenCalledTimes(1);
|
expect(goBack).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
93
test/servers/ManageServers.test.tsx
Normal file
93
test/servers/ManageServers.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Button } from 'reactstrap';
|
||||||
|
import ServersExporter from '../../src/servers/services/ServersExporter';
|
||||||
|
import { ManageServers as createManageServers } from '../../src/servers/ManageServers';
|
||||||
|
import { ServersMap, ServerWithId } from '../../src/servers/data';
|
||||||
|
import SearchField from '../../src/utils/SearchField';
|
||||||
|
import { Result } from '../../src/utils/Result';
|
||||||
|
|
||||||
|
describe('<ManageServers />', () => {
|
||||||
|
const exportServers = jest.fn();
|
||||||
|
const serversExporter = Mock.of<ServersExporter>({ exportServers });
|
||||||
|
const ImportServersBtn = () => null;
|
||||||
|
const ManageServersRow = () => null;
|
||||||
|
const useStateFlagTimeout = jest.fn().mockReturnValue([ false, jest.fn() ]);
|
||||||
|
const ManageServers = createManageServers(serversExporter, ImportServersBtn, useStateFlagTimeout, ManageServersRow);
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createServerMock = (value: string, autoConnect = false) => Mock.of<ServerWithId>(
|
||||||
|
{ id: value, name: value, url: value, autoConnect },
|
||||||
|
);
|
||||||
|
const createWrapper = (servers: ServersMap = {}) => {
|
||||||
|
wrapper = shallow(<ManageServers servers={servers} />);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it('shows search field which allows searching servers, affecting te amount of rendered rows', () => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
foo: createServerMock('foo'),
|
||||||
|
bar: createServerMock('bar'),
|
||||||
|
baz: createServerMock('baz'),
|
||||||
|
});
|
||||||
|
const searchBar = wrapper.find(SearchField);
|
||||||
|
|
||||||
|
expect(wrapper.find(ManageServersRow)).toHaveLength(3);
|
||||||
|
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
||||||
|
|
||||||
|
searchBar.simulate('change', 'foo');
|
||||||
|
expect(wrapper.find(ManageServersRow)).toHaveLength(1);
|
||||||
|
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
||||||
|
|
||||||
|
searchBar.simulate('change', 'ba');
|
||||||
|
expect(wrapper.find(ManageServersRow)).toHaveLength(2);
|
||||||
|
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
||||||
|
|
||||||
|
searchBar.simulate('change', 'invalid');
|
||||||
|
expect(wrapper.find(ManageServersRow)).toHaveLength(0);
|
||||||
|
expect(wrapper.find('tbody').find('tr')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ createServerMock('foo'), 3 ],
|
||||||
|
[ createServerMock('foo', true), 4 ],
|
||||||
|
])('shows different amount of columns if there are at least one auto-connect server', (server, expectedCols) => {
|
||||||
|
const wrapper = createWrapper({ server });
|
||||||
|
const row = wrapper.find(ManageServersRow);
|
||||||
|
|
||||||
|
expect(wrapper.find('th')).toHaveLength(expectedCols);
|
||||||
|
expect(row.prop('hasAutoConnect')).toEqual(server.autoConnect);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{}, 1 ],
|
||||||
|
[{ foo: createServerMock('foo') }, 2 ],
|
||||||
|
])('shows export button if the list of servers is not empty', (servers, expectedButtons) => {
|
||||||
|
const wrapper = createWrapper(servers);
|
||||||
|
const exportBtn = wrapper.find(Button);
|
||||||
|
|
||||||
|
expect(exportBtn).toHaveLength(expectedButtons);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows exporting servers when clicking on button', () => {
|
||||||
|
const wrapper = createWrapper({ foo: createServerMock('foo') });
|
||||||
|
const exportBtn = wrapper.find(Button).first();
|
||||||
|
|
||||||
|
expect(exportServers).not.toHaveBeenCalled();
|
||||||
|
exportBtn.simulate('click');
|
||||||
|
expect(exportServers).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error message if an error occurs while importing servers', () => {
|
||||||
|
useStateFlagTimeout.mockReturnValue([ true, jest.fn() ]);
|
||||||
|
|
||||||
|
const wrapper = createWrapper({ foo: createServerMock('foo') });
|
||||||
|
const result = wrapper.find(Result);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result.prop('type')).toEqual('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
test/servers/ManageServersRow.test.tsx
Normal file
66
test/servers/ManageServersRow.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ManageServersRow as createManageServersRow } from '../../src/servers/ManageServersRow';
|
||||||
|
import { ServerWithId } from '../../src/servers/data';
|
||||||
|
|
||||||
|
describe('<ManageServersRow />', () => {
|
||||||
|
const ManageServersRowDropdown = () => null;
|
||||||
|
const ManageServersRow = createManageServersRow(ManageServersRowDropdown);
|
||||||
|
const server: ServerWithId = {
|
||||||
|
name: 'My server',
|
||||||
|
url: 'https://example.com',
|
||||||
|
apiKey: '123',
|
||||||
|
id: 'abc',
|
||||||
|
};
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createWrapper = (hasAutoConnect = false, autoConnect = false) => {
|
||||||
|
wrapper = shallow(<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ true, 4 ],
|
||||||
|
[ false, 3 ],
|
||||||
|
])('renders expected amount of columns', (hasAutoConnect, expectedCols) => {
|
||||||
|
const wrapper = createWrapper(hasAutoConnect);
|
||||||
|
const td = wrapper.find('td');
|
||||||
|
const th = wrapper.find('th');
|
||||||
|
|
||||||
|
expect(td.length + th.length).toEqual(expectedCols);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a dropdown', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const dropdown = wrapper.find(ManageServersRowDropdown);
|
||||||
|
|
||||||
|
expect(dropdown).toHaveLength(1);
|
||||||
|
expect(dropdown.prop('server')).toEqual(expect.objectContaining(server));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ true, 1 ],
|
||||||
|
[ false, 0 ],
|
||||||
|
])('renders auto-connect icon only if server is autoConnect', (autoConnect, expectedIcons) => {
|
||||||
|
const wrapper = createWrapper(true, autoConnect);
|
||||||
|
const icon = wrapper.find(FontAwesomeIcon);
|
||||||
|
const iconTooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
|
||||||
|
expect(icon).toHaveLength(expectedIcons);
|
||||||
|
expect(iconTooltip).toHaveLength(expectedIcons);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders server props where appropriate', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const link = wrapper.find(Link);
|
||||||
|
const td = wrapper.find('td').first();
|
||||||
|
|
||||||
|
expect(link.prop('to')).toEqual(`/server/${server.id}`);
|
||||||
|
expect(link.prop('children')).toEqual(server.name);
|
||||||
|
expect(td.prop('children')).toEqual(server.url);
|
||||||
|
});
|
||||||
|
});
|
||||||
84
test/servers/ManageServersRowDropdown.test.tsx
Normal file
84
test/servers/ManageServersRowDropdown.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { ServerWithId } from '../../src/servers/data';
|
||||||
|
import { ManageServersRowDropdown as createManageServersRowDropdown } from '../../src/servers/ManageServersRowDropdown';
|
||||||
|
|
||||||
|
describe('<ManageServersRowDropdown />', () => {
|
||||||
|
const DeleteServerModal = () => null;
|
||||||
|
const ManageServersRowDropdown = createManageServersRowDropdown(DeleteServerModal);
|
||||||
|
const setAutoConnect = jest.fn();
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createWrapper = (autoConnect = false) => {
|
||||||
|
const server = Mock.of<ServerWithId>({ id: 'abc123', autoConnect });
|
||||||
|
|
||||||
|
wrapper = shallow(<ManageServersRowDropdown setAutoConnect={setAutoConnect} server={server} />);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
|
||||||
|
it('renders expected amount of dropdown items', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const items = wrapper.find(DropdownItem);
|
||||||
|
|
||||||
|
expect(items).toHaveLength(5);
|
||||||
|
expect(items.find('[divider]')).toHaveLength(1);
|
||||||
|
expect(items.at(0).prop('to')).toEqual('/server/abc123');
|
||||||
|
expect(items.at(1).prop('to')).toEqual('/server/abc123/edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows toggling auto-connect', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
|
expect(setAutoConnect).not.toHaveBeenCalled();
|
||||||
|
wrapper.find(DropdownItem).at(2).simulate('click');
|
||||||
|
expect(setAutoConnect).toHaveBeenCalledWith(expect.objectContaining({ id: 'abc123' }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a modal', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const modal = wrapper.find(DeleteServerModal);
|
||||||
|
|
||||||
|
expect(modal).toHaveLength(1);
|
||||||
|
expect(modal.prop('redirectHome')).toEqual(false);
|
||||||
|
expect(modal.prop('server')).toEqual(expect.objectContaining({ id: 'abc123' }));
|
||||||
|
expect(modal.prop('isOpen')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows toggling the modal', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const modalToggle = wrapper.find(DropdownItem).last();
|
||||||
|
|
||||||
|
expect(wrapper.find(DeleteServerModal).prop('isOpen')).toEqual(false);
|
||||||
|
|
||||||
|
modalToggle.simulate('click');
|
||||||
|
expect(wrapper.find(DeleteServerModal).prop('isOpen')).toEqual(true);
|
||||||
|
|
||||||
|
(wrapper.find(DeleteServerModal).prop('toggle') as Function)();
|
||||||
|
expect(wrapper.find(DeleteServerModal).prop('isOpen')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be toggled', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
|
expect(wrapper.prop('isOpen')).toEqual(false);
|
||||||
|
|
||||||
|
(wrapper.prop('toggle') as Function)();
|
||||||
|
expect(wrapper.prop('isOpen')).toEqual(true);
|
||||||
|
|
||||||
|
(wrapper.prop('toggle') as Function)();
|
||||||
|
expect(wrapper.prop('isOpen')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ true, 'Do not auto-connect' ],
|
||||||
|
[ false, 'Auto-connect' ],
|
||||||
|
])('shows different auto-connect toggle text depending on current server status', (autoConnect, expectedText) => {
|
||||||
|
const wrapper = createWrapper(autoConnect);
|
||||||
|
const item = wrapper.find(DropdownItem).at(2);
|
||||||
|
|
||||||
|
expect(item.html()).toContain(expectedText);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
@@ -15,25 +16,59 @@ describe('<ImportServersBtn />', () => {
|
|||||||
const fileRef = {
|
const fileRef = {
|
||||||
current: Mock.of<HTMLInputElement>({ click }),
|
current: Mock.of<HTMLInputElement>({ click }),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
const ImportServersBtn = importServersBtnConstruct(serversImporterMock);
|
const ImportServersBtn = importServersBtnConstruct(serversImporterMock);
|
||||||
|
const createWrapper = (className?: string, children?: ReactNode) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<ImportServersBtn createServers={createServersMock} fileRef={fileRef} onImport={onImportMock} />,
|
<ImportServersBtn
|
||||||
|
createServers={createServersMock}
|
||||||
|
className={className}
|
||||||
|
fileRef={fileRef}
|
||||||
|
children={children}
|
||||||
|
onImport={onImportMock}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
afterEach(() => wrapper.unmount());
|
afterEach(() => wrapper.unmount());
|
||||||
|
|
||||||
it('renders a button, a tooltip and a file input', () => {
|
it('renders a button, a tooltip and a file input', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
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.each([
|
||||||
|
[ undefined, '' ],
|
||||||
|
[ 'foo', 'foo' ],
|
||||||
|
[ 'bar', 'bar' ],
|
||||||
|
])('allows a class name to be provided', (providedClassName, expectedClassName) => {
|
||||||
|
const wrapper = createWrapper(providedClassName);
|
||||||
|
|
||||||
|
expect(wrapper.find('#importBtn').prop('className')).toEqual(expectedClassName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, true ],
|
||||||
|
[ 'foo', false ],
|
||||||
|
[ 'bar', false ],
|
||||||
|
])('has expected text', (children, expectToHaveDefaultText) => {
|
||||||
|
const wrapper = createWrapper(undefined, children);
|
||||||
|
|
||||||
|
if (expectToHaveDefaultText) {
|
||||||
|
expect(wrapper.find('#importBtn').html()).toContain('Import from file');
|
||||||
|
} else {
|
||||||
|
expect(wrapper.find('#importBtn').html()).toContain(children);
|
||||||
|
expect(wrapper.find('#importBtn').html()).not.toContain('Import from file');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('triggers click on file ref when button is clicked', () => {
|
it('triggers click on file ref when button is clicked', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
const btn = wrapper.find('#importBtn');
|
const btn = wrapper.find('#importBtn');
|
||||||
|
|
||||||
btn.simulate('click');
|
btn.simulate('click');
|
||||||
@@ -42,7 +77,8 @@ 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 wrapper = createWrapper();
|
||||||
|
const file = wrapper.find('.import-servers-btn__csv-select');
|
||||||
|
|
||||||
file.simulate('change', { target: { files: [ '' ] } });
|
file.simulate('change', { target: { files: [ '' ] } });
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { values } from 'ramda';
|
import { dissoc, values } from 'ramda';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import reducer, {
|
import reducer, {
|
||||||
createServer,
|
createServer,
|
||||||
deleteServer,
|
deleteServer,
|
||||||
createServers,
|
createServers,
|
||||||
editServer,
|
editServer,
|
||||||
|
setAutoConnect,
|
||||||
EDIT_SERVER,
|
EDIT_SERVER,
|
||||||
DELETE_SERVER,
|
DELETE_SERVER,
|
||||||
CREATE_SERVERS,
|
CREATE_SERVERS,
|
||||||
|
SET_AUTO_CONNECT,
|
||||||
} from '../../../src/servers/reducers/servers';
|
} from '../../../src/servers/reducers/servers';
|
||||||
import { RegularServer } from '../../../src/servers/data';
|
import { RegularServer } from '../../../src/servers/data';
|
||||||
|
|
||||||
@@ -29,6 +31,15 @@ describe('serverReducer', () => {
|
|||||||
def456: { id: 'def456' },
|
def456: { id: 'def456' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('returns as it is when action is EDIT_SERVER and server does not exist', () =>
|
||||||
|
expect(reducer(
|
||||||
|
list,
|
||||||
|
{ type: EDIT_SERVER, serverId: 'invalid', serverData: { foo: 'foo' } } as any,
|
||||||
|
)).toEqual({
|
||||||
|
abc123: { id: 'abc123' },
|
||||||
|
def456: { id: 'def456' },
|
||||||
|
}));
|
||||||
|
|
||||||
it('removes server when action is DELETE_SERVER', () =>
|
it('removes server when action is DELETE_SERVER', () =>
|
||||||
expect(reducer(list, { type: DELETE_SERVER, serverId: 'abc123' } as any)).toEqual({
|
expect(reducer(list, { type: DELETE_SERVER, serverId: 'abc123' } as any)).toEqual({
|
||||||
def456: { id: 'def456' },
|
def456: { id: 'def456' },
|
||||||
@@ -45,6 +56,51 @@ describe('serverReducer', () => {
|
|||||||
def456: { id: 'def456' },
|
def456: { id: 'def456' },
|
||||||
ghi789: { id: 'ghi789' },
|
ghi789: { id: 'ghi789' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ true ],
|
||||||
|
[ false ],
|
||||||
|
])('returns state as it is when trying to set auto-connect on invalid server', (autoConnect) =>
|
||||||
|
expect(reducer(list, {
|
||||||
|
type: SET_AUTO_CONNECT,
|
||||||
|
serverId: 'invalid',
|
||||||
|
autoConnect,
|
||||||
|
} as any)).toEqual({
|
||||||
|
abc123: { id: 'abc123' },
|
||||||
|
def456: { id: 'def456' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('disables auto-connect on a server which is already set to auto-connect', () => {
|
||||||
|
const listWithDisabledAutoConnect = {
|
||||||
|
...list,
|
||||||
|
abc123: { ...list.abc123, autoConnect: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(listWithDisabledAutoConnect, {
|
||||||
|
type: SET_AUTO_CONNECT,
|
||||||
|
serverId: 'abc123',
|
||||||
|
autoConnect: false,
|
||||||
|
} as any)).toEqual({
|
||||||
|
abc123: { id: 'abc123', autoConnect: false },
|
||||||
|
def456: { id: 'def456' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables auto-connect on all servers except selected one', () => {
|
||||||
|
const listWithEnabledAutoConnect = {
|
||||||
|
...list,
|
||||||
|
abc123: { ...list.abc123, autoConnect: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(listWithEnabledAutoConnect, {
|
||||||
|
type: SET_AUTO_CONNECT,
|
||||||
|
serverId: 'def456',
|
||||||
|
autoConnect: true,
|
||||||
|
} as any)).toEqual({
|
||||||
|
abc123: { id: 'abc123', autoConnect: false },
|
||||||
|
def456: { id: 'def456', autoConnect: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('action creators', () => {
|
describe('action creators', () => {
|
||||||
@@ -82,6 +138,25 @@ describe('serverReducer', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(expect.objectContaining({ type: CREATE_SERVERS }));
|
expect(result).toEqual(expect.objectContaining({ type: CREATE_SERVERS }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generates an id for every provided server if they do not have it', () => {
|
||||||
|
const servers = values(list).map(dissoc('id'));
|
||||||
|
const { newServers } = createServers(servers);
|
||||||
|
|
||||||
|
expect(values(newServers).every(({ id }) => !!id)).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setAutoConnect', () => {
|
||||||
|
it.each([
|
||||||
|
[ true ],
|
||||||
|
[ false ],
|
||||||
|
])('returns expected action', (autoConnect) => {
|
||||||
|
const serverToEdit = Mock.of<RegularServer>({ id: 'abc123' });
|
||||||
|
const result = setAutoConnect(serverToEdit, autoConnect);
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: SET_AUTO_CONNECT, serverId: 'abc123', autoConnect });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ describe('<Settings />', () => {
|
|||||||
|
|
||||||
expect(layout).toHaveLength(1);
|
expect(layout).toHaveLength(1);
|
||||||
expect(sections).toHaveLength(1);
|
expect(sections).toHaveLength(1);
|
||||||
expect((sections.prop('items') as any[]).flat()).toHaveLength(4); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
expect((sections.prop('items') as any[]).flat()).toHaveLength(4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe('<ShortUrlsList />', () => {
|
|||||||
|
|
||||||
it('invokes order icon rendering', () => {
|
it('invokes order icon rendering', () => {
|
||||||
const renderIcon = (field: OrderableFields) =>
|
const renderIcon = (field: OrderableFields) =>
|
||||||
(wrapper.find(ShortUrlsTable).prop('renderOrderIcon') as (field: OrderableFields) => ReactElement | null)(field); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
(wrapper.find(ShortUrlsTable).prop('renderOrderIcon') as (field: OrderableFields) => ReactElement | null)(field);
|
||||||
|
|
||||||
expect(renderIcon('visits')).toEqual(null);
|
expect(renderIcon('visits')).toEqual(null);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('<TagsCards />', () => {
|
|||||||
const card = () => wrapper.find(TagCard).at(5);
|
const card = () => wrapper.find(TagCard).at(5);
|
||||||
|
|
||||||
expect(card().prop('displayed')).toEqual(false);
|
expect(card().prop('displayed')).toEqual(false);
|
||||||
(card().prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
(card().prop('toggle') as Function)();
|
||||||
expect(card().prop('displayed')).toEqual(true);
|
expect(card().prop('displayed')).toEqual(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ describe('<TagsTableRow />', () => {
|
|||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(false);
|
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(false);
|
||||||
(wrapper.find(EditTagModal).prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
(wrapper.find(EditTagModal).prop('toggle') as Function)();
|
||||||
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(true);
|
expect(wrapper.find(EditTagModal).prop('isOpen')).toEqual(true);
|
||||||
|
|
||||||
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(false);
|
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(false);
|
||||||
(wrapper.find(DeleteTagConfirmModal).prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
(wrapper.find(DeleteTagConfirmModal).prop('toggle') as Function)();
|
||||||
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(true);
|
expect(wrapper.find(DeleteTagConfirmModal).prop('isOpen')).toEqual(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user