From 002f28036438960c28fe5a3c84b120ca11bad146 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 17 Oct 2021 18:58:38 +0200 Subject: [PATCH 01/10] Extracted common dropdown-item style --- src/short-urls/helpers/ShortUrlsRowMenu.scss | 11 ----------- src/short-urls/helpers/ShortUrlsRowMenu.tsx | 3 +-- 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 src/short-urls/helpers/ShortUrlsRowMenu.scss diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.scss b/src/short-urls/helpers/ShortUrlsRowMenu.scss deleted file mode 100644 index cb60b9ae..00000000 --- a/src/short-urls/helpers/ShortUrlsRowMenu.scss +++ /dev/null @@ -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; - } -} diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.tsx b/src/short-urls/helpers/ShortUrlsRowMenu.tsx index 2ea3b996..9b84501f 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.tsx +++ b/src/short-urls/helpers/ShortUrlsRowMenu.tsx @@ -12,7 +12,6 @@ import { ShortUrl, ShortUrlModalProps } from '../data'; import { SelectedServer } from '../../servers/data'; import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu'; import ShortUrlDetailLink from './ShortUrlDetailLink'; -import './ShortUrlsRowMenu.scss'; export interface ShortUrlsRowMenuProps { selectedServer: SelectedServer; @@ -45,7 +44,7 @@ const ShortUrlsRowMenu = ( - + Delete short URL From 7f4263966e858abeefd3ff8fd8a7f966de07238c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 17 Oct 2021 19:13:06 +0200 Subject: [PATCH 02/10] Created new section to manage servers --- src/app/App.tsx | 2 + src/app/services/provideServices.ts | 1 + src/index.scss | 10 +++ src/servers/CreateServer.scss | 6 -- src/servers/CreateServer.tsx | 30 +++---- src/servers/DeleteServerModal.tsx | 8 +- src/servers/ManageServers.tsx | 80 +++++++++++++++++++ src/servers/ManageServersRow.tsx | 68 ++++++++++++++++ src/servers/ServersDropdown.tsx | 21 +++-- src/servers/data/index.ts | 1 + src/servers/helpers/ImportServersBtn.scss | 5 ++ src/servers/helpers/ImportServersBtn.tsx | 22 ++--- src/servers/helpers/ServerForm.tsx | 2 +- src/servers/services/provideServices.ts | 19 ++++- src/utils/FormGroupContainer.tsx | 5 +- test/app/App.test.tsx | 12 ++- test/servers/CreateServer.test.tsx | 4 +- test/servers/ServersDropdown.test.tsx | 10 +-- .../servers/helpers/ImportServersBtn.test.tsx | 4 +- 19 files changed, 249 insertions(+), 61 deletions(-) create mode 100644 src/servers/ManageServers.tsx create mode 100644 src/servers/ManageServersRow.tsx create mode 100644 src/servers/helpers/ImportServersBtn.scss diff --git a/src/app/App.tsx b/src/app/App.tsx index d8b91056..0f43ab29 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -23,6 +23,7 @@ const App = ( CreateServer: FC, EditServer: FC, Settings: FC, + ManageServers: FC, ShlinkVersionsContainer: FC, ) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => { useEffect(() => { @@ -43,6 +44,7 @@ const App = ( + diff --git a/src/app/services/provideServices.ts b/src/app/services/provideServices.ts index 4dff9f31..50bb8f12 100644 --- a/src/app/services/provideServices.ts +++ b/src/app/services/provideServices.ts @@ -14,6 +14,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { 'CreateServer', 'EditServer', 'Settings', + 'ManageServers', 'ShlinkVersionsContainer', ); bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ])); diff --git a/src/index.scss b/src/index.scss index 419f1638..f1cee307 100644 --- a/src/index.scss +++ b/src/index.scss @@ -115,6 +115,16 @@ hr { color: var(--text-color) !important; } +.dropdown-item--danger.dropdown-item--danger { + color: $dangerColor; + + &:hover, + &:active, + &.active { + color: $dangerColor !important; + } +} + .badge-main { color: #ffffff; background-color: var(--brand-color); diff --git a/src/servers/CreateServer.scss b/src/servers/CreateServer.scss index cfba848d..4861c9af 100644 --- a/src/servers/CreateServer.scss +++ b/src/servers/CreateServer.scss @@ -8,9 +8,3 @@ text-align: right; } } - -.create-server__csv-select { - position: absolute; - left: -9999px; - top: -9999px; -} diff --git a/src/servers/CreateServer.tsx b/src/servers/CreateServer.tsx index 0f87fa0d..2ee329cd 100644 --- a/src/servers/CreateServer.tsx +++ b/src/servers/CreateServer.tsx @@ -1,30 +1,35 @@ import { FC } from 'react'; import { v4 as uuid } from 'uuid'; import { RouterProps } from 'react-router'; +import { Button } from 'reactstrap'; import { Result } from '../utils/Result'; import NoMenuLayout from '../common/NoMenuLayout'; import { StateFlagTimeout } from '../utils/helpers/hooks'; import { ServerForm } from './helpers/ServerForm'; import { ImportServersBtnProps } from './helpers/ImportServersBtn'; -import { ServerData, ServerWithId } from './data'; +import { ServerData, ServersMap, ServerWithId } from './data'; import './CreateServer.scss'; const SHOW_IMPORT_MSG_TIME = 4000; interface CreateServerProps extends RouterProps { createServer: (server: ServerWithId) => void; + servers: ServersMap; } const ImportResult = ({ type }: { type: 'error' | 'success' }) => ( - - {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 === '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.'} + +
); const CreateServer = (ImportServersBtn: FC, 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 [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); const handleSubmit = (serverData: ServerData) => { @@ -37,16 +42,13 @@ const CreateServer = (ImportServersBtn: FC, useStateFlagT return ( Add new server} onSubmit={handleSubmit}> - - + {!hasServers && + } + - {(serversImported || errorImporting) && ( -
- {serversImported && } - {errorImporting && } -
- )} + {serversImported && } + {errorImporting && }
); }; diff --git a/src/servers/DeleteServerModal.tsx b/src/servers/DeleteServerModal.tsx index 4d74869e..491d2f95 100644 --- a/src/servers/DeleteServerModal.tsx +++ b/src/servers/DeleteServerModal.tsx @@ -1,3 +1,4 @@ +import { FC } from 'react'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { RouterProps } from 'react-router'; import { ServerWithId } from './data'; @@ -6,17 +7,20 @@ export interface DeleteServerModalProps { server: ServerWithId; toggle: () => void; isOpen: boolean; + redirectHome?: boolean; } interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps { deleteServer: (server: ServerWithId) => void; } -const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => { +const DeleteServerModal: FC = ( + { server, toggle, isOpen, deleteServer, history, redirectHome = true }, +) => { const closeModal = () => { deleteServer(server); toggle(); - history.push('/'); + redirectHome && history.push('/'); }; return ( diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx new file mode 100644 index 00000000..78d1259b --- /dev/null +++ b/src/servers/ManageServers.tsx @@ -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, + useStateFlagTimeout: StateFlagTimeout, + ManageServersRow: FC, +): FC => ({ 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 ( + + + +
+ + + +
+ + + + + + {hasAutoConnect && + + + + + {!serversList.length && } + {serversList.map((server) => + ) + } + +
} + NameBase URL +
No servers found.
+
+ + {errorImporting && ( +
+ The servers could not be imported. Make sure the format is correct. +
+ )} +
+ ); +}; diff --git a/src/servers/ManageServersRow.tsx b/src/servers/ManageServersRow.tsx new file mode 100644 index 00000000..306ef719 --- /dev/null +++ b/src/servers/ManageServersRow.tsx @@ -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, +): FC => ({ 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 ( + + {hasAutoConnect && ( + + {isAutoConnect && ( + <> + + Auto-connect to this server + + )} + + )} + + {server.name} + + {server.url} + + + + Connect + + + Edit server + + + {isAutoConnect ? 'Unset' : 'Set'} auto-connect + + + + Remove server + + + + + + ); +}; diff --git a/src/servers/ServersDropdown.tsx b/src/servers/ServersDropdown.tsx index 284643f6..6adb05b3 100644 --- a/src/servers/ServersDropdown.tsx +++ b/src/servers/ServersDropdown.tsx @@ -1,9 +1,8 @@ import { isEmpty, values } from 'ramda'; import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap'; 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 ServersExporter from './services/ServersExporter'; import { isServerWithId, SelectedServer, ServersMap } from './data'; export interface ServersDropdownProps { @@ -11,17 +10,16 @@ export interface ServersDropdownProps { selectedServer: SelectedServer; } -const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => { +const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => { const serversList = values(servers); - const createServerItem = ( - - Add a server - - ); const renderServers = () => { if (isEmpty(serversList)) { - return createServerItem; + return ( + + Add a server + + ); } return ( @@ -37,9 +35,8 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
))} - {createServerItem} - serversExporter.exportServers()}> - Export servers + + Manage servers ); diff --git a/src/servers/data/index.ts b/src/servers/data/index.ts index 6edee8ec..809a6636 100644 --- a/src/servers/data/index.ts +++ b/src/servers/data/index.ts @@ -8,6 +8,7 @@ export interface ServerData { export interface ServerWithId extends ServerData { id: string; + autoConnect?: boolean; } export interface ReachableServer extends ServerWithId { diff --git a/src/servers/helpers/ImportServersBtn.scss b/src/servers/helpers/ImportServersBtn.scss new file mode 100644 index 00000000..32b254a8 --- /dev/null +++ b/src/servers/helpers/ImportServersBtn.scss @@ -0,0 +1,5 @@ +.import-servers-btn__csv-select { + position: absolute; + left: -9999px; + top: -9999px; +} diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index 490d2d7e..869b661f 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -1,13 +1,17 @@ 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 { ServerData } from '../data'; +import './ImportServersBtn.scss'; type Ref = RefObject | MutableRefObject; export interface ImportServersBtnProps { onImport?: () => void; onImportError?: (error: Error) => void; + tooltipPlacement?: 'top' | 'bottom'; } interface ImportServersBtnConnectProps extends ImportServersBtnProps { @@ -20,6 +24,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({ fileRef, onImport = () => {}, onImportError = () => {}, + tooltipPlacement = 'bottom', }: ImportServersBtnConnectProps) => { const ref = fileRef ?? useRef(); const onChange = async ({ target }: ChangeEvent) => @@ -34,19 +39,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({ return ( <> - - + + You can create servers by importing a CSV file with columns name, apiKey and url. - + ); }; diff --git a/src/servers/helpers/ServerForm.tsx b/src/servers/helpers/ServerForm.tsx index 3d30b926..9370cbb3 100644 --- a/src/servers/helpers/ServerForm.tsx +++ b/src/servers/helpers/ServerForm.tsx @@ -31,7 +31,7 @@ export const ServerForm: FC = ({ onSubmit, initialValues, child Name URL - APIkey + API key
{children}
diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index 14453446..bea4bd94 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -14,19 +14,34 @@ import { ServerError } from '../helpers/ServerError'; import { ConnectDecorator } from '../../container/types'; import { withoutSelectedServer } from '../helpers/withoutSelectedServer'; import { Overview } from '../Overview'; +import { ManageServers } from '../ManageServers'; +import { ManageServersRow } from '../ManageServersRow'; import ServersImporter from './ServersImporter'; import ServersExporter from './ServersExporter'; const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { // 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.decorator('CreateServer', withoutSelectedServer); - bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ])); + bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ])); bottle.serviceFactory('EditServer', EditServer, 'ServerError'); bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ])); - bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter'); + bottle.serviceFactory('ServersDropdown', () => ServersDropdown); bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ])); bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal); diff --git a/src/utils/FormGroupContainer.tsx b/src/utils/FormGroupContainer.tsx index d522ebfb..767a4fbf 100644 --- a/src/utils/FormGroupContainer.tsx +++ b/src/utils/FormGroupContainer.tsx @@ -1,6 +1,7 @@ import { FC, useRef } from 'react'; import { v4 as uuid } from 'uuid'; import { InputType } from 'reactstrap/lib/Input'; +import { FormGroup } from 'reactstrap'; export interface FormGroupContainerProps { value: string; @@ -19,7 +20,7 @@ export const FormGroupContainer: FC = ( const forId = useRef(id ?? uuid()); return ( -
+ @@ -32,6 +33,6 @@ export const FormGroupContainer: FC = ( placeholder={placeholder} onChange={(e) => onChange(e.target.value)} /> -
+ ); }; diff --git a/test/app/App.test.tsx b/test/app/App.test.tsx index 873e70ec..1638b6fc 100644 --- a/test/app/App.test.tsx +++ b/test/app/App.test.tsx @@ -11,7 +11,16 @@ describe('', () => { const ShlinkVersions = () => null; beforeEach(() => { - const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, ShlinkVersions); + const App = appFactory( + MainHeader, + () => null, + () => null, + () => null, + () => null, + () => null, + () => null, + ShlinkVersions, + ); wrapper = shallow( ', () => { const expectedPaths = [ '/', '/settings', + '/manage-servers', '/server/create', '/server/:serverId/edit', '/server/:serverId', diff --git a/test/servers/CreateServer.test.tsx b/test/servers/CreateServer.test.tsx index dcc90006..7cb2e388 100644 --- a/test/servers/CreateServer.test.tsx +++ b/test/servers/CreateServer.test.tsx @@ -3,6 +3,7 @@ import { Mock } from 'ts-mockery'; import { History } from 'history'; import createServerConstruct from '../../src/servers/CreateServer'; import { ServerForm } from '../../src/servers/helpers/ServerForm'; +import { ServerWithId } from '../../src/servers/data'; describe('', () => { let wrapper: ShallowWrapper; @@ -10,13 +11,14 @@ describe('', () => { const createServerMock = jest.fn(); const push = jest.fn(); const historyMock = Mock.of({ push }); + const servers = { foo: Mock.all() }; const createWrapper = (serversImported = false, importFailed = false) => { const useStateFlagTimeout = jest.fn() .mockReturnValueOnce([ serversImported, () => '' ]) .mockReturnValueOnce([ importFailed, () => '' ]); const CreateServer = createServerConstruct(ImportServersBtn, useStateFlagTimeout); - wrapper = shallow(); + wrapper = shallow(); return wrapper; }; diff --git a/test/servers/ServersDropdown.test.tsx b/test/servers/ServersDropdown.test.tsx index 2730979b..eab1eca2 100644 --- a/test/servers/ServersDropdown.test.tsx +++ b/test/servers/ServersDropdown.test.tsx @@ -1,15 +1,12 @@ import { values } from 'ramda'; import { Mock } from 'ts-mockery'; -import { FC } from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; 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 ServersExporter from '../../src/servers/services/ServersExporter'; describe('', () => { let wrapped: ShallowWrapper; - let ServersDropdown: FC; const servers = { '1a': Mock.of({ name: 'foo', id: '1a' }), '2b': Mock.of({ name: 'bar', id: '2b' }), @@ -17,13 +14,12 @@ describe('', () => { }; beforeEach(() => { - ServersDropdown = serversDropdownCreator(Mock.of()); wrapped = shallow(); }); afterEach(() => wrapped.unmount()); 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', () => expect(wrapped.find(DropdownToggle)).toHaveLength(1)); @@ -32,7 +28,7 @@ describe('', () => { const items = wrapped.find(DropdownItem); 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', () => { diff --git a/test/servers/helpers/ImportServersBtn.test.tsx b/test/servers/helpers/ImportServersBtn.test.tsx index 261e6e72..da5b4941 100644 --- a/test/servers/helpers/ImportServersBtn.test.tsx +++ b/test/servers/helpers/ImportServersBtn.test.tsx @@ -30,7 +30,7 @@ describe('', () => { it('renders a button, a tooltip and a file input', () => { expect(wrapper.find('#importBtn')).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', () => { @@ -42,7 +42,7 @@ describe('', () => { }); 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: [ '' ] } }); From 478209f50dea49adf675c9c449db3b8f64e0882e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Oct 2021 18:53:00 +0200 Subject: [PATCH 03/10] Improvements on ManageServers --- src/servers/CreateServer.tsx | 3 +- src/servers/EditServer.tsx | 4 +-- src/servers/ManageServers.tsx | 24 +++++++------ src/servers/ManageServersRow.tsx | 2 +- src/servers/helpers/ImportServersBtn.tsx | 4 ++- test/servers/EditServer.test.tsx | 6 ++-- .../servers/helpers/ImportServersBtn.test.tsx | 35 ++++++++++++++----- 7 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/servers/CreateServer.tsx b/src/servers/CreateServer.tsx index 2ee329cd..e24e548c 100644 --- a/src/servers/CreateServer.tsx +++ b/src/servers/CreateServer.tsx @@ -27,7 +27,7 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => ( ); const CreateServer = (ImportServersBtn: FC, useStateFlagTimeout: StateFlagTimeout) => ( - { servers, 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); @@ -44,6 +44,7 @@ const CreateServer = (ImportServersBtn: FC, useStateFlagT Add new server} onSubmit={handleSubmit}> {!hasServers && } + {hasServers && } diff --git a/src/servers/EditServer.tsx b/src/servers/EditServer.tsx index f18c788d..f6576066 100644 --- a/src/servers/EditServer.tsx +++ b/src/servers/EditServer.tsx @@ -10,7 +10,7 @@ interface EditServerProps { } export const EditServer = (ServerError: FC) => withSelectedServer(( - { editServer, selectedServer, history: { push, goBack } }, + { editServer, selectedServer, history: { goBack } }, ) => { if (!isServerWithId(selectedServer)) { return null; @@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer { editServer(selectedServer.id, serverData); - push(`/server/${selectedServer.id}`); + goBack(); }; return ( diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx index 78d1259b..3f69e43a 100644 --- a/src/servers/ManageServers.tsx +++ b/src/servers/ManageServers.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react'; -import { Button } from 'reactstrap'; +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'; @@ -41,15 +41,19 @@ export const ManageServers = ( -
- - - -
+ +
+ + +
+
+ +
+
diff --git a/src/servers/ManageServersRow.tsx b/src/servers/ManageServersRow.tsx index 306ef719..03ca9cae 100644 --- a/src/servers/ManageServersRow.tsx +++ b/src/servers/ManageServersRow.tsx @@ -54,7 +54,7 @@ export const ManageServersRow = ( Edit server - {isAutoConnect ? 'Unset' : 'Set'} auto-connect + {isAutoConnect ? 'Do not a' : 'A'}uto-connect diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index 869b661f..c9e150a5 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -12,6 +12,7 @@ export interface ImportServersBtnProps { onImport?: () => void; onImportError?: (error: Error) => void; tooltipPlacement?: 'top' | 'bottom'; + className?: string; } interface ImportServersBtnConnectProps extends ImportServersBtnProps { @@ -25,6 +26,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({ onImport = () => {}, onImportError = () => {}, tooltipPlacement = 'bottom', + className = '', }: ImportServersBtnConnectProps) => { const ref = fileRef ?? useRef(); const onChange = async ({ target }: ChangeEvent) => @@ -39,7 +41,7 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({ return ( <> - diff --git a/test/servers/EditServer.test.tsx b/test/servers/EditServer.test.tsx index c74cb72c..4f6a65da 100644 --- a/test/servers/EditServer.test.tsx +++ b/test/servers/EditServer.test.tsx @@ -10,8 +10,8 @@ describe('', () => { let wrapper: ReactWrapper; const ServerError = jest.fn(); const editServerMock = jest.fn(); - const push = jest.fn(); - const historyMock = Mock.of({ push }); + const goBack = jest.fn(); + const historyMock = Mock.of({ goBack }); const match = Mock.of>({ params: { serverId: 'abc123' }, }); @@ -50,6 +50,6 @@ describe('', () => { form.simulate('submit', {}); expect(editServerMock).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledTimes(1); + expect(goBack).toHaveBeenCalledTimes(1); }); }); diff --git a/test/servers/helpers/ImportServersBtn.test.tsx b/test/servers/helpers/ImportServersBtn.test.tsx index da5b4941..dd8471be 100644 --- a/test/servers/helpers/ImportServersBtn.test.tsx +++ b/test/servers/helpers/ImportServersBtn.test.tsx @@ -15,25 +15,43 @@ describe('', () => { const fileRef = { current: Mock.of({ click }), }; - - beforeEach(() => { - jest.clearAllMocks(); - - const ImportServersBtn = importServersBtnConstruct(serversImporterMock); - + const ImportServersBtn = importServersBtnConstruct(serversImporterMock); + const createWrapper = (className?: string) => { wrapper = shallow( - , + , ); - }); + + return wrapper; + }; + + afterEach(jest.clearAllMocks); afterEach(() => wrapper.unmount()); it('renders a button, a tooltip and a file input', () => { + const wrapper = createWrapper(); + expect(wrapper.find('#importBtn')).toHaveLength(1); expect(wrapper.find(UncontrolledTooltip)).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('triggers click on file ref when button is clicked', () => { + const wrapper = createWrapper(); const btn = wrapper.find('#importBtn'); btn.simulate('click'); @@ -42,6 +60,7 @@ describe('', () => { }); it('imports servers when file input changes', (done) => { + const wrapper = createWrapper(); const file = wrapper.find('.import-servers-btn__csv-select'); file.simulate('change', { target: { files: [ '' ] } }); From ada5488a6c7550f2102f11efd81af3dbe5383df7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Oct 2021 19:03:12 +0200 Subject: [PATCH 04/10] Ensured export servers btn is not displayed when there are no servers --- src/servers/ManageServers.tsx | 10 ++++++---- src/servers/helpers/ImportServersBtn.tsx | 9 +++++---- .../servers/helpers/ImportServersBtn.test.tsx | 19 ++++++++++++++++++- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx index 3f69e43a..ad1195d0 100644 --- a/src/servers/ManageServers.tsx +++ b/src/servers/ManageServers.tsx @@ -43,10 +43,12 @@ export const ManageServers = (
- - + Import servers + {allServers.length > 0 && ( + + )}
You can create servers by importing a CSV file with columns name, apiKey and url. diff --git a/test/servers/helpers/ImportServersBtn.test.tsx b/test/servers/helpers/ImportServersBtn.test.tsx index dd8471be..fd8c41af 100644 --- a/test/servers/helpers/ImportServersBtn.test.tsx +++ b/test/servers/helpers/ImportServersBtn.test.tsx @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { UncontrolledTooltip } from 'reactstrap'; import { Mock } from 'ts-mockery'; @@ -16,12 +17,13 @@ describe('', () => { current: Mock.of({ click }), }; const ImportServersBtn = importServersBtnConstruct(serversImporterMock); - const createWrapper = (className?: string) => { + const createWrapper = (className?: string, children?: ReactNode) => { wrapper = shallow( , ); @@ -50,6 +52,21 @@ describe('', () => { 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', () => { const wrapper = createWrapper(); const btn = wrapper.find('#importBtn'); From 7637ce3107f2a3905145774c2fd42a94941e47be Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Oct 2021 20:13:23 +0200 Subject: [PATCH 05/10] Added logic to toggle auto-connect on servers --- src/servers/ManageServers.tsx | 4 +- src/servers/ManageServersRow.tsx | 20 ++++--- src/servers/ServersDropdown.tsx | 9 +-- src/servers/reducers/servers.ts | 40 +++++++++++-- src/servers/services/provideServices.ts | 5 +- test/servers/reducers/servers.test.ts | 77 ++++++++++++++++++++++++- 6 files changed, 132 insertions(+), 23 deletions(-) diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx index ad1195d0..9554f2e8 100644 --- a/src/servers/ManageServers.tsx +++ b/src/servers/ManageServers.tsx @@ -57,11 +57,11 @@ export const ManageServers = (
- +
- {hasAutoConnect && {hasAutoConnect && ( - @@ -53,7 +59,7 @@ export const ManageServersRow = ( Edit server - + setAutoConnect(server, !server.autoConnect)}> {isAutoConnect ? 'Do not a' : 'A'}uto-connect diff --git a/src/servers/ServersDropdown.tsx b/src/servers/ServersDropdown.tsx index 6adb05b3..c109d7bd 100644 --- a/src/servers/ServersDropdown.tsx +++ b/src/servers/ServersDropdown.tsx @@ -3,7 +3,7 @@ import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from import { Link } from 'react-router-dom'; import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { isServerWithId, SelectedServer, ServersMap } from './data'; +import { getServerId, SelectedServer, ServersMap } from './data'; export interface ServersDropdownProps { servers: ServersMap; @@ -25,12 +25,7 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => { return ( <> {serversList.map(({ name, id }) => ( - + {name} ))} diff --git a/src/servers/reducers/servers.ts b/src/servers/reducers/servers.ts index 057f5531..e433b9e1 100644 --- a/src/servers/reducers/servers.ts +++ b/src/servers/reducers/servers.ts @@ -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 { Action } from 'redux'; 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 DELETE_SERVER = 'shlink/servers/DELETE_SERVER'; export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS'; +export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT'; /* eslint-enable padding-line-between-statements */ export interface CreateServersAction extends Action { newServers: ServersMap; } +interface DeleteServerAction extends Action { + serverId: string; +} + +interface SetAutoConnectAction extends Action { + serverId: string; + autoConnect: boolean; +} + const initialState: ServersMap = {}; const serverWithId = (server: ServerWithId | ServerData): ServerWithId => { @@ -24,12 +34,28 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => { return assoc('id', uuid(), server); }; -export default buildReducer({ +export default buildReducer({ [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] ? 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); const serversListToMap = reduce((acc, server) => assoc(server.id, server, acc), {}); @@ -48,4 +74,10 @@ export const editServer = (serverId: string, serverData: Partial) => 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, +}); diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index bea4bd94..afa4cd76 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -7,7 +7,7 @@ import DeleteServerButton from '../DeleteServerButton'; import { EditServer } from '../EditServer'; import ImportServersBtn from '../helpers/ImportServersBtn'; 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 ForServerVersion from '../helpers/ForServerVersion'; import { ServerError } from '../helpers/ServerError'; @@ -32,7 +32,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: bottle.decorator('ManageServers', connect([ 'servers' ])); bottle.serviceFactory('ManageServersRow', ManageServersRow, 'DeleteServerModal'); - bottle.decorator('ManageServers', connect([ 'servers' ])); + bottle.decorator('ManageServersRow', connect(null, [ 'setAutoConnect' ])); bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout'); bottle.decorator('CreateServer', withoutSelectedServer); @@ -77,6 +77,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: bottle.serviceFactory('createServers', () => createServers); bottle.serviceFactory('deleteServer', () => deleteServer); bottle.serviceFactory('editServer', () => editServer); + bottle.serviceFactory('setAutoConnect', () => setAutoConnect); bottle.serviceFactory('fetchServers', fetchServers, 'axios'); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); diff --git a/test/servers/reducers/servers.test.ts b/test/servers/reducers/servers.test.ts index e4ad7e58..c781eb54 100644 --- a/test/servers/reducers/servers.test.ts +++ b/test/servers/reducers/servers.test.ts @@ -1,13 +1,15 @@ -import { values } from 'ramda'; +import { dissoc, values } from 'ramda'; import { Mock } from 'ts-mockery'; import reducer, { createServer, deleteServer, createServers, editServer, + setAutoConnect, EDIT_SERVER, DELETE_SERVER, CREATE_SERVERS, + SET_AUTO_CONNECT, } from '../../../src/servers/reducers/servers'; import { RegularServer } from '../../../src/servers/data'; @@ -29,6 +31,15 @@ describe('serverReducer', () => { 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', () => expect(reducer(list, { type: DELETE_SERVER, serverId: 'abc123' } as any)).toEqual({ def456: { id: 'def456' }, @@ -45,6 +56,51 @@ describe('serverReducer', () => { def456: { id: 'def456' }, 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', () => { @@ -82,6 +138,25 @@ describe('serverReducer', () => { 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({ id: 'abc123' }); + const result = setAutoConnect(serverToEdit, autoConnect); + + expect(result).toEqual({ type: SET_AUTO_CONNECT, serverId: 'abc123', autoConnect }); + }); }); }); }); From ec9fd67b8a9c2ac9c021fb52bcc38d6ea1c46504 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Oct 2021 20:26:11 +0200 Subject: [PATCH 06/10] Extracted ManageServersRowDropdown to its own component --- src/servers/ManageServersRow.tsx | 84 ++++++++---------------- src/servers/ManageServersRowDropdown.tsx | 53 +++++++++++++++ src/servers/services/provideServices.ts | 7 +- 3 files changed, 84 insertions(+), 60 deletions(-) create mode 100644 src/servers/ManageServersRowDropdown.tsx diff --git a/src/servers/ManageServersRow.tsx b/src/servers/ManageServersRow.tsx index d3f70483..c881a5fe 100644 --- a/src/servers/ManageServersRow.tsx +++ b/src/servers/ManageServersRow.tsx @@ -1,19 +1,10 @@ import { FC } from 'react'; -import { DropdownItem, UncontrolledTooltip } from 'reactstrap'; +import { 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, - faBan as toggleOffIcon, -} 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 { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons'; import { ServerWithId } from './data'; -import { DeleteServerModalProps } from './DeleteServerModal'; +import { ManageServersRowDropdownProps } from './ManageServersRowDropdown'; export interface ManageServersRowProps { server: ServerWithId; @@ -25,50 +16,27 @@ interface ManageServersRowPropsConnectProps extends ManageServersRowProps { } export const ManageServersRow = ( - DeleteServerModal: FC, -): FC => ({ server, hasAutoConnect, 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 ( - - {hasAutoConnect && ( - - )} - - - + {hasAutoConnect && ( + - - ); -}; + )} + + + + +); diff --git a/src/servers/ManageServersRowDropdown.tsx b/src/servers/ManageServersRowDropdown.tsx new file mode 100644 index 00000000..7c85e49f --- /dev/null +++ b/src/servers/ManageServersRowDropdown.tsx @@ -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, +): FC => ({ 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 ( + + + Connect + + + Edit server + + setAutoConnect(server, !server.autoConnect)}> + {isAutoConnect ? 'Do not a' : 'A'}uto-connect + + + + Remove server + + + + + ); +}; diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index afa4cd76..07dd9361 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -16,6 +16,7 @@ import { withoutSelectedServer } from '../helpers/withoutSelectedServer'; import { Overview } from '../Overview'; import { ManageServers } from '../ManageServers'; import { ManageServersRow } from '../ManageServersRow'; +import { ManageServersRowDropdown } from '../ManageServersRowDropdown'; import ServersImporter from './ServersImporter'; import ServersExporter from './ServersExporter'; @@ -31,8 +32,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: ); bottle.decorator('ManageServers', connect([ 'servers' ])); - bottle.serviceFactory('ManageServersRow', ManageServersRow, 'DeleteServerModal'); - bottle.decorator('ManageServersRow', connect(null, [ 'setAutoConnect' ])); + bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown'); + + bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal'); + bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ])); bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout'); bottle.decorator('CreateServer', withoutSelectedServer); From c7c32b494e60556b70cce5ca5911670879d2de1a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Oct 2021 10:34:20 +0200 Subject: [PATCH 07/10] Created ManageServersRow test --- src/servers/ManageServersRow.tsx | 6 +-- test/servers/ManageServersRow.test.tsx | 66 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 test/servers/ManageServersRow.test.tsx diff --git a/src/servers/ManageServersRow.tsx b/src/servers/ManageServersRow.tsx index c881a5fe..396c30e0 100644 --- a/src/servers/ManageServersRow.tsx +++ b/src/servers/ManageServersRow.tsx @@ -11,13 +11,9 @@ export interface ManageServersRowProps { hasAutoConnect: boolean; } -interface ManageServersRowPropsConnectProps extends ManageServersRowProps { - setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void; -} - export const ManageServersRow = ( ManageServersRowDropdown: FC, -): FC => ({ server, hasAutoConnect }) => ( +): FC => ({ server, hasAutoConnect }) => ( {hasAutoConnect && (
} + {hasAutoConnect && } Name Base URL diff --git a/src/servers/ManageServersRow.tsx b/src/servers/ManageServersRow.tsx index 03ca9cae..d3f70483 100644 --- a/src/servers/ManageServersRow.tsx +++ b/src/servers/ManageServersRow.tsx @@ -7,9 +7,9 @@ import { faEdit as editIcon, faMinusCircle as deleteIcon, faPlug as connectIcon, - faToggleOn as toggleOnIcon, - faToggleOff as toggleOffIcon, + faBan as toggleOffIcon, } 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 { ServerWithId } from './data'; @@ -20,23 +20,29 @@ export interface ManageServersRowProps { hasAutoConnect: boolean; } +interface ManageServersRowPropsConnectProps extends ManageServersRowProps { + setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void; +} + export const ManageServersRow = ( DeleteServerModal: FC, -): FC => ({ server, hasAutoConnect }) => { +): FC => ({ server, hasAutoConnect, setAutoConnect }) => { const [ isMenuOpen, toggleMenu ] = useToggle(); const [ isModalOpen,, showModal, hideModal ] = useToggle(); const serverUrl = `/server/${server.id}`; const { autoConnect: isAutoConnect } = server; - const autoConnectIcon = isAutoConnect ? toggleOnIcon : toggleOffIcon; + const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon; return (
+ {isAutoConnect && ( <> - Auto-connect to this server + + Auto-connect to this server + )}
- {isAutoConnect && ( - <> - - - Auto-connect to this server - - - )} - - {server.name} - {server.url} - - - Connect - - - Edit server - - setAutoConnect(server, !server.autoConnect)}> - {isAutoConnect ? 'Do not a' : 'A'}uto-connect - - - - Remove server - - - + ManageServersRowDropdown: FC, +): FC => ({ server, hasAutoConnect }) => ( +
+ {server.autoConnect && ( + <> + + + Auto-connect to this server + + + )}
+ {server.name} + {server.url} + +
diff --git a/test/servers/ManageServersRow.test.tsx b/test/servers/ManageServersRow.test.tsx new file mode 100644 index 00000000..7a1a7658 --- /dev/null +++ b/test/servers/ManageServersRow.test.tsx @@ -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('', () => { + 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(); + + 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); + }); +}); From 8618519b6be99083cf006fdce41802207c812f1a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Oct 2021 10:55:52 +0200 Subject: [PATCH 08/10] Created ManageServersRowDropdown test --- .eslintrc | 3 +- test/common/AppUpdateBanner.test.tsx | 2 +- .../helpers/EditDomainRedirectsModal.test.tsx | 2 +- .../servers/ManageServersRowDropdown.test.tsx | 84 +++++++++++++++++++ test/settings/Settings.test.tsx | 2 +- test/short-urls/ShortUrlsList.test.tsx | 2 +- test/tags/TagsCards.test.tsx | 2 +- test/tags/TagsTableRow.test.tsx | 4 +- 8 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 test/servers/ManageServersRowDropdown.test.tsx diff --git a/.eslintrc b/.eslintrc index 78ada1c2..48aab946 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,6 +16,7 @@ }, "ignorePatterns": ["src/service*.ts"], "rules": { - "complexity": "off" + "complexity": "off", + "@typescript-eslint/no-unnecessary-type-assertion": "off" } } diff --git a/test/common/AppUpdateBanner.test.tsx b/test/common/AppUpdateBanner.test.tsx index b03458b8..6231e8e4 100644 --- a/test/common/AppUpdateBanner.test.tsx +++ b/test/common/AppUpdateBanner.test.tsx @@ -24,7 +24,7 @@ describe('', () => { }); 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(); }); diff --git a/test/domains/helpers/EditDomainRedirectsModal.test.tsx b/test/domains/helpers/EditDomainRedirectsModal.test.tsx index 2293a807..067f5f3b 100644 --- a/test/domains/helpers/EditDomainRedirectsModal.test.tsx +++ b/test/domains/helpers/EditDomainRedirectsModal.test.tsx @@ -42,7 +42,7 @@ describe('', () => { it('has different handlers to toggle the modal', () => { 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(Button).first().simulate('click'); diff --git a/test/servers/ManageServersRowDropdown.test.tsx b/test/servers/ManageServersRowDropdown.test.tsx new file mode 100644 index 00000000..e8dd609a --- /dev/null +++ b/test/servers/ManageServersRowDropdown.test.tsx @@ -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('', () => { + const DeleteServerModal = () => null; + const ManageServersRowDropdown = createManageServersRowDropdown(DeleteServerModal); + const setAutoConnect = jest.fn(); + let wrapper: ShallowWrapper; + const createWrapper = (autoConnect = false) => { + const server = Mock.of({ id: 'abc123', autoConnect }); + + wrapper = shallow(); + + 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); + }); +}); diff --git a/test/settings/Settings.test.tsx b/test/settings/Settings.test.tsx index cd718662..1f34137d 100644 --- a/test/settings/Settings.test.tsx +++ b/test/settings/Settings.test.tsx @@ -13,6 +13,6 @@ describe('', () => { expect(layout).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); }); }); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index cc0406ce..f7286649 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -77,7 +77,7 @@ describe('', () => { it('invokes order icon rendering', () => { 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); diff --git a/test/tags/TagsCards.test.tsx b/test/tags/TagsCards.test.tsx index 0ef3fed9..8b5d8db3 100644 --- a/test/tags/TagsCards.test.tsx +++ b/test/tags/TagsCards.test.tsx @@ -31,7 +31,7 @@ describe('', () => { const card = () => wrapper.find(TagCard).at(5); 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); }); }); diff --git a/test/tags/TagsTableRow.test.tsx b/test/tags/TagsTableRow.test.tsx index b5ae6a2d..9787fbf9 100644 --- a/test/tags/TagsTableRow.test.tsx +++ b/test/tags/TagsTableRow.test.tsx @@ -68,11 +68,11 @@ describe('', () => { const wrapper = createWrapper(); 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(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); }); }); From 41398f659ed53824b09decf7f99825b06d9ff2f7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Oct 2021 11:33:32 +0200 Subject: [PATCH 09/10] Created ManageServers test --- test/servers/ManageServers.test.tsx | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 test/servers/ManageServers.test.tsx diff --git a/test/servers/ManageServers.test.tsx b/test/servers/ManageServers.test.tsx new file mode 100644 index 00000000..c860bb9e --- /dev/null +++ b/test/servers/ManageServers.test.tsx @@ -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('', () => { + const exportServers = jest.fn(); + const serversExporter = Mock.of({ 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( + { id: value, name: value, url: value, autoConnect }, + ); + const createWrapper = (servers: ServersMap = {}) => { + wrapper = shallow(); + + 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'); + }); +}); From c0e33d6a6af45b70207a23dce473bda7f5251590 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Oct 2021 11:34:39 +0200 Subject: [PATCH 10/10] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a03fa78c..55c3f84f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added * [#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. +* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section. ### Changed * Moved ci workflow to external repo and reused