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: [ '' ] } });