diff --git a/CHANGELOG.md b/CHANGELOG.md index 534d1d80..85189cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [3.5.0] - 2022-01-01 ### Added * [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter". @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The warning includes a link to the documentation, explaining what are the steps to get it fixed. +* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, dispaying a warning when creating or importing servers that already exist. * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. * [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. * [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs. diff --git a/src/common/NoMenuLayout.tsx b/src/common/NoMenuLayout.tsx index dfddde05..ea3862f2 100644 --- a/src/common/NoMenuLayout.tsx +++ b/src/common/NoMenuLayout.tsx @@ -1,6 +1,4 @@ import { FC } from 'react'; import './NoMenuLayout.scss'; -const NoMenuLayout: FC = ({ children }) =>
{children}
; - -export default NoMenuLayout; +export const NoMenuLayout: FC = ({ children }) =>
{children}
; diff --git a/src/servers/CreateServer.scss b/src/servers/CreateServer.scss deleted file mode 100644 index 4861c9af..00000000 --- a/src/servers/CreateServer.scss +++ /dev/null @@ -1,10 +0,0 @@ -@import '../utils/base'; - -.create-server__label { - font-weight: 700; - cursor: pointer; - - @media (min-width: $mdMin) { - text-align: right; - } -} diff --git a/src/servers/CreateServer.tsx b/src/servers/CreateServer.tsx index e24e548c..0412016a 100644 --- a/src/servers/CreateServer.tsx +++ b/src/servers/CreateServer.tsx @@ -1,14 +1,14 @@ -import { FC } from 'react'; +import { FC, useEffect, useState } 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 { NoMenuLayout } from '../common/NoMenuLayout'; +import { StateFlagTimeout, useToggle } from '../utils/helpers/hooks'; import { ServerForm } from './helpers/ServerForm'; import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ServerData, ServersMap, ServerWithId } from './data'; -import './CreateServer.scss'; +import { DuplicatedServersModal } from './helpers/DuplicatedServersModal'; const SHOW_IMPORT_MSG_TIME = 4000; @@ -32,16 +32,30 @@ const CreateServer = (ImportServersBtn: FC, useStateFlagT 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) => { + const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle(); + const [ serverData, setServerData ] = useState(); + const save = () => { + if (!serverData) { + return; + } + const id = uuid(); createServer({ ...serverData, id }); push(`/server/${id}`); }; + useEffect(() => { + const serverExists = Object.values(servers).some( + ({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey, + ); + + serverExists ? toggleConfirmModal() : save(); + }, [ serverData ]); + return ( - Add new server} onSubmit={handleSubmit}> + Add new server} onSubmit={setServerData}> {!hasServers && } {hasServers && } @@ -50,6 +64,13 @@ const CreateServer = (ImportServersBtn: FC, useStateFlagT {serversImported && } {errorImporting && } + + ); }; diff --git a/src/servers/EditServer.tsx b/src/servers/EditServer.tsx index f6576066..514ebcba 100644 --- a/src/servers/EditServer.tsx +++ b/src/servers/EditServer.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { Button } from 'reactstrap'; -import NoMenuLayout from '../common/NoMenuLayout'; +import { NoMenuLayout } from '../common/NoMenuLayout'; import { ServerForm } from './helpers/ServerForm'; import { withSelectedServer } from './helpers/withSelectedServer'; import { isServerWithId, ServerData } from './data'; diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx index 9554f2e8..532d1329 100644 --- a/src/servers/ManageServers.tsx +++ b/src/servers/ManageServers.tsx @@ -3,7 +3,7 @@ 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 { NoMenuLayout } from '../common/NoMenuLayout'; import { SimpleCard } from '../utils/SimpleCard'; import SearchField from '../utils/SearchField'; import { Result } from '../utils/Result'; diff --git a/src/servers/helpers/DuplicatedServersModal.tsx b/src/servers/helpers/DuplicatedServersModal.tsx new file mode 100644 index 00000000..b2fb3d78 --- /dev/null +++ b/src/servers/helpers/DuplicatedServersModal.tsx @@ -0,0 +1,40 @@ +import { FC, Fragment } from 'react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { ServerData } from '../data'; + +interface DuplicatedServersModalProps { + duplicatedServers: ServerData[]; + isOpen: boolean; + onDiscard: () => void; + onSave: () => void; +} + +export const DuplicatedServersModal: FC = ( + { isOpen, duplicatedServers, onDiscard, onSave }, +) => { + const hasMultipleServers = duplicatedServers.length > 1; + + return ( + + Duplicated server{hasMultipleServers && 's'} + +

{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}

+
    + {duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? ( + +
  • URL: {url}
  • +
  • API key: {apiKey}
  • +
    + ) :
  • {url} - {apiKey}
  • )} +
+ + {hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}? + +
+ + + + +
+ ); +}; diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index 3f27cfc2..2a91e71c 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -1,10 +1,12 @@ -import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react'; +import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react'; import { Button, UncontrolledTooltip } from 'reactstrap'; -import { pipe } from 'ramda'; +import { complement, pipe } from 'ramda'; 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 { useToggle } from '../../utils/helpers/hooks'; +import { ServersImporter } from '../services/ServersImporter'; +import { ServerData, ServersMap } from '../data'; +import { DuplicatedServersModal } from './DuplicatedServersModal'; import './ImportServersBtn.scss'; type Ref = RefObject | MutableRefObject; @@ -18,11 +20,16 @@ export interface ImportServersBtnProps { interface ImportServersBtnConnectProps extends ImportServersBtnProps { createServers: (servers: ServerData[]) => void; + servers: ServersMap; fileRef: Ref; } +const serversFiltering = (servers: ServerData[]) => + ({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey); + const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC => ({ createServers, + servers, fileRef, children, onImport = () => {}, @@ -31,15 +38,37 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC { const ref = fileRef ?? useRef(); - const onChange = async ({ target }: ChangeEvent) => + const [ serversToCreate, setServersToCreate ] = useState(); + const [ duplicatedServers, setDuplicatedServers ] = useState([]); + const [ isModalOpen,, showModal, hideModal ] = useToggle(); + const create = pipe(createServers, onImport); + const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal); + const createNonDuplicatedServers = pipe( + () => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))), + hideModal, + ); + const onFile = async ({ target }: ChangeEvent) => importServersFromFile(target.files?.[0]) - .then(pipe(createServers, onImport)) + .then(setServersToCreate) .then(() => { // Reset input after processing file (target as { value: string | null }).value = null; }) .catch(onImportError); + useEffect(() => { + if (!serversToCreate) { + return; + } + + const existingServers = Object.values(servers); + const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers)); + const hasDuplicatedServers = !!duplicatedServers.length; + + !hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers); + hasDuplicatedServers && showModal(); + }, [ serversToCreate ]); + return ( <>