mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-09 09:47:28 +03:00
Merge pull request #889 from acelaya-forks/feature/improve-hooks
Feature/improve hooks
This commit is contained in:
commit
4677c24242
18 changed files with 118 additions and 125 deletions
|
@ -8,7 +8,6 @@
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["src/service*.ts"],
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react-hooks/rules-of-hooks": "off",
|
"react-hooks/rules-of-hooks": "off"
|
||||||
"react-hooks/exhaustive-deps": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
14
package-lock.json
generated
14
package-lock.json
generated
|
@ -15,7 +15,7 @@
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@json2csv/plainjs": "^7.0.3",
|
"@json2csv/plainjs": "^7.0.3",
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
"@shlinkio/shlink-frontend-kit": "^0.2.0",
|
"@shlinkio/shlink-frontend-kit": "^0.2.1",
|
||||||
"@shlinkio/shlink-js-sdk": "^0.1.0",
|
"@shlinkio/shlink-js-sdk": "^0.1.0",
|
||||||
"@shlinkio/shlink-web-component": "^0.3.3",
|
"@shlinkio/shlink-web-component": "^0.3.3",
|
||||||
"bootstrap": "5.2.3",
|
"bootstrap": "5.2.3",
|
||||||
|
@ -2801,9 +2801,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shlinkio/shlink-frontend-kit": {
|
"node_modules/@shlinkio/shlink-frontend-kit": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.2.1.tgz",
|
||||||
"integrity": "sha512-8kGaae0bTiGzbLzPsolLvJ5ud37BR2b1WeDy8lyXIiwoFiSAMIgWpqro0nMdBVBQXovjmMbtiS6BFYsaoBo9/g==",
|
"integrity": "sha512-5mRpQII9bGHAJQ1ghgGY+jFC5tD0y0ufgPqco9vLVGXGVf+BSNQrTiw/Cx6f9eCInqFPgFo8CdJzWUHbIzvC+Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"qs": "^6.11.2",
|
"qs": "^6.11.2",
|
||||||
|
@ -12390,9 +12390,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@shlinkio/shlink-frontend-kit": {
|
"@shlinkio/shlink-frontend-kit": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.2.1.tgz",
|
||||||
"integrity": "sha512-8kGaae0bTiGzbLzPsolLvJ5ud37BR2b1WeDy8lyXIiwoFiSAMIgWpqro0nMdBVBQXovjmMbtiS6BFYsaoBo9/g==",
|
"integrity": "sha512-5mRpQII9bGHAJQ1ghgGY+jFC5tD0y0ufgPqco9vLVGXGVf+BSNQrTiw/Cx6f9eCInqFPgFo8CdJzWUHbIzvC+Q==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"qs": "^6.11.2",
|
"qs": "^6.11.2",
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@json2csv/plainjs": "^7.0.3",
|
"@json2csv/plainjs": "^7.0.3",
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
"@shlinkio/shlink-frontend-kit": "^0.2.0",
|
"@shlinkio/shlink-frontend-kit": "^0.2.1",
|
||||||
"@shlinkio/shlink-js-sdk": "^0.1.0",
|
"@shlinkio/shlink-js-sdk": "^0.1.0",
|
||||||
"@shlinkio/shlink-web-component": "^0.3.3",
|
"@shlinkio/shlink-web-component": "^0.3.3",
|
||||||
"bootstrap": "5.2.3",
|
"bootstrap": "5.2.3",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { changeThemeInMarkup } from '@shlinkio/shlink-frontend-kit';
|
import { changeThemeInMarkup } from '@shlinkio/shlink-frontend-kit';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||||
import { NotFound } from '../common/NotFound';
|
import { NotFound } from '../common/NotFound';
|
||||||
|
@ -29,16 +29,20 @@ export const App = (
|
||||||
ShlinkVersionsContainer: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const initialServers = useRef(servers);
|
||||||
const isHome = location.pathname === '/';
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
// Try to fetch the remote servers if the list is empty at first
|
||||||
if (Object.keys(servers).length === 0) {
|
// We use a ref because we don't care if the servers list becomes empty later
|
||||||
|
if (Object.keys(initialServers.current).length === 0) {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
}
|
}
|
||||||
|
}, [fetchServers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
changeThemeInMarkup(settings.ui?.theme ?? 'light');
|
changeThemeInMarkup(settings.ui?.theme ?? 'light');
|
||||||
}, []);
|
}, [settings.ui?.theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-fluid app-container">
|
<div className="container-fluid app-container">
|
||||||
|
|
|
@ -23,7 +23,7 @@ export const Home = ({ servers }: HomeProps) => {
|
||||||
// Try to redirect to the first server marked as auto-connect
|
// Try to redirect to the first server marked as auto-connect
|
||||||
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||||
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
||||||
}, []);
|
}, [serversList, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
|
|
|
@ -10,14 +10,15 @@ import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './MainHeader.scss';
|
import './MainHeader.scss';
|
||||||
|
|
||||||
export const MainHeader = (ServersDropdown: FC) => () => {
|
export const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
const [isOpen, toggleOpen, , close] = useToggle();
|
const [isNotCollapsed, toggleCollapse, , collapse] = useToggle();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { pathname } = location;
|
const { pathname } = location;
|
||||||
|
|
||||||
useEffect(close, [location]);
|
// In mobile devices, collapse the navbar when location changes
|
||||||
|
useEffect(collapse, [location, collapse]);
|
||||||
|
|
||||||
const settingsPath = '/settings';
|
const settingsPath = '/settings';
|
||||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
|
@ -25,11 +26,11 @@ export const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|
||||||
<NavbarToggler onClick={toggleOpen}>
|
<NavbarToggler onClick={toggleCollapse}>
|
||||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||||
</NavbarToggler>
|
</NavbarToggler>
|
||||||
|
|
||||||
<Collapse navbar isOpen={isOpen}>
|
<Collapse navbar isOpen={isNotCollapsed}>
|
||||||
<Nav navbar className="ms-auto">
|
<Nav navbar className="ms-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
@ -37,25 +37,25 @@ export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTim
|
||||||
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||||
const [serverData, setServerData] = useState<ServerData | undefined>();
|
const [serverData, setServerData] = useState<ServerData>();
|
||||||
const save = () => {
|
const saveNewServer = useCallback((theServerData: ServerData) => {
|
||||||
|
const id = uuid();
|
||||||
|
|
||||||
|
createServers([{ ...theServerData, id }]);
|
||||||
|
navigate(`/server/${id}`);
|
||||||
|
}, [createServers, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (!serverData) {
|
if (!serverData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = uuid();
|
|
||||||
|
|
||||||
createServers([{ ...serverData, id }]);
|
|
||||||
navigate(`/server/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const serverExists = Object.values(servers).some(
|
const serverExists = Object.values(servers).some(
|
||||||
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
serverExists ? toggleConfirmModal() : save();
|
serverExists ? toggleConfirmModal() : saveNewServer(serverData);
|
||||||
}, [serverData]);
|
}, [saveNewServer, serverData, servers, toggleConfirmModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
|
@ -74,7 +74,7 @@ export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTim
|
||||||
isOpen={isConfirmModalOpen}
|
isOpen={isConfirmModalOpen}
|
||||||
duplicatedServers={serverData ? [serverData] : []}
|
duplicatedServers={serverData ? [serverData] : []}
|
||||||
onDiscard={goBack}
|
onDiscard={goBack}
|
||||||
onSave={save}
|
onSave={() => serverData && saveNewServer(serverData)}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
|
|
|
@ -33,7 +33,7 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||||
</span>
|
</span>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicated' : 'Discard'}</Button>
|
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button>
|
||||||
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
.import-servers-btn__csv-select {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
}
|
|
|
@ -1,14 +1,13 @@
|
||||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { complement, pipe } from 'ramda';
|
import { complement } from 'ramda';
|
||||||
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
import type { ServerData, ServersMap } from '../data';
|
import type { ServerData, ServersMap } from '../data';
|
||||||
import type { ServersImporter } from '../services/ServersImporter';
|
import type { ServersImporter } from '../services/ServersImporter';
|
||||||
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||||
import './ImportServersBtn.scss';
|
|
||||||
|
|
||||||
export type ImportServersBtnProps = PropsWithChildren<{
|
export type ImportServersBtnProps = PropsWithChildren<{
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
|
@ -25,7 +24,7 @@ interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
const serversFiltering = (servers: ServerData[]) =>
|
const serversFiltering = (servers: ServerData[]) =>
|
||||||
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
|
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
|
||||||
|
|
||||||
export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
export const ImportServersBtn = (serversImporter: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||||
createServers,
|
createServers,
|
||||||
servers,
|
servers,
|
||||||
children,
|
children,
|
||||||
|
@ -35,36 +34,43 @@ export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useElementRef<HTMLInputElement>();
|
const ref = useElementRef<HTMLInputElement>();
|
||||||
const [serversToCreate, setServersToCreate] = useState<ServerData[] | undefined>();
|
|
||||||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||||
const create = pipe(createServers, onImport);
|
|
||||||
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
|
const serversToCreate = useRef<ServerData[]>([]);
|
||||||
const createNonDuplicatedServers = pipe(
|
const create = useCallback((serversData: ServerData[]) => {
|
||||||
() => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))),
|
createServers(serversData);
|
||||||
hideModal,
|
onImport();
|
||||||
|
}, [createServers, onImport]);
|
||||||
|
const onFile = useCallback(
|
||||||
|
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
serversImporter.importServersFromFile(target.files?.[0])
|
||||||
|
.then((newServers) => {
|
||||||
|
serversToCreate.current = newServers;
|
||||||
|
|
||||||
|
const existingServers = Object.values(servers);
|
||||||
|
const dupServers = newServers.filter(serversFiltering(existingServers));
|
||||||
|
const hasDuplicatedServers = !!dupServers.length;
|
||||||
|
|
||||||
|
!hasDuplicatedServers ? create(newServers) : setDuplicatedServers(dupServers);
|
||||||
|
hasDuplicatedServers && showModal();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Reset input after processing file
|
||||||
|
(target as { value: string | null }).value = null; // eslint-disable-line no-param-reassign
|
||||||
|
})
|
||||||
|
.catch(onImportError),
|
||||||
|
[create, onImportError, servers, showModal],
|
||||||
);
|
);
|
||||||
const onFile = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
|
||||||
importServersFromFile(target.files?.[0])
|
|
||||||
.then(setServersToCreate)
|
|
||||||
.then(() => {
|
|
||||||
// Reset input after processing file
|
|
||||||
(target as { value: string | null }).value = null; // eslint-disable-line no-param-reassign
|
|
||||||
})
|
|
||||||
.catch(onImportError);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const createAllServers = useCallback(() => {
|
||||||
if (!serversToCreate) {
|
create(serversToCreate.current);
|
||||||
return;
|
hideModal();
|
||||||
}
|
}, [create, hideModal, serversToCreate]);
|
||||||
|
const createNonDuplicatedServers = useCallback(() => {
|
||||||
const existingServers = Object.values(servers);
|
create(serversToCreate.current.filter(complement(serversFiltering(duplicatedServers))));
|
||||||
const dupServers = serversToCreate.filter(serversFiltering(existingServers));
|
hideModal();
|
||||||
const hasDuplicatedServers = !!dupServers.length;
|
}, [create, duplicatedServers, hideModal]);
|
||||||
|
|
||||||
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(dupServers);
|
|
||||||
hasDuplicatedServers && showModal();
|
|
||||||
}, [serversToCreate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -72,16 +78,10 @@ export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC
|
||||||
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
||||||
</Button>
|
</Button>
|
||||||
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
||||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
|
|
||||||
<input
|
<input type="file" accept="text/csv" className="d-none" ref={ref} onChange={onFile} />
|
||||||
type="file"
|
|
||||||
accept="text/csv"
|
|
||||||
className="import-servers-btn__csv-select"
|
|
||||||
ref={ref}
|
|
||||||
onChange={onFile}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DuplicatedServersModal
|
<DuplicatedServersModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|
|
@ -18,7 +18,7 @@ export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServ
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
params.serverId && selectServer(params.serverId);
|
params.serverId && selectServer(params.serverId);
|
||||||
}, [params.serverId]);
|
}, [params.serverId, selectServer]);
|
||||||
|
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -10,7 +10,7 @@ export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelect
|
||||||
const { resetSelectedServer } = props;
|
const { resetSelectedServer } = props;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetSelectedServer();
|
resetSelectedServer();
|
||||||
}, []);
|
}, [resetSelectedServer]);
|
||||||
|
|
||||||
return <WrappedComponent {...props} />;
|
return <WrappedComponent {...props} />;
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,32 +8,20 @@ const validateServers = (servers: any): servers is ServerData[] =>
|
||||||
Array.isArray(servers) && servers.every(validateServer);
|
Array.isArray(servers) && servers.every(validateServer);
|
||||||
|
|
||||||
export class ServersImporter {
|
export class ServersImporter {
|
||||||
public constructor(private readonly csvToJson: CsvToJson, private readonly fileReaderFactory: () => FileReader) {}
|
public constructor(private readonly csvToJson: CsvToJson) {}
|
||||||
|
|
||||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
public async importServersFromFile(file: File | null | undefined): Promise<ServerData[]> {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new Error('No file provided');
|
throw new Error('No file provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = this.fileReaderFactory();
|
const content = await file.text();
|
||||||
|
const servers = await this.csvToJson(content);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
if (!validateServers(servers)) {
|
||||||
reader.addEventListener('loadend', async (e: ProgressEvent<FileReader>) => {
|
throw new Error('Provided file does not have the right format.');
|
||||||
try {
|
}
|
||||||
// TODO Read as stream, otherwise, if the file is too big, this will block the browser tab
|
|
||||||
const content = e.target?.result?.toString() ?? '';
|
|
||||||
const servers = await this.csvToJson(content);
|
|
||||||
|
|
||||||
if (!validateServers(servers)) {
|
return servers;
|
||||||
throw new Error('Provided file does not have the right format.');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
resolve(servers);
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
reader.readAsText(file);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,8 +62,7 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.decorator('ServerError', connect(['servers', 'selectedServer']));
|
bottle.decorator('ServerError', connect(['servers', 'selectedServer']));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.constant('fileReaderFactory', () => new FileReader());
|
bottle.service('ServersImporter', ServersImporter, 'csvToJson');
|
||||||
bottle.service('ServersImporter', ServersImporter, 'csvToJson', 'fileReaderFactory');
|
|
||||||
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
|
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import type { Theme } from '@shlinkio/shlink-frontend-kit';
|
import type { Theme } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { changeThemeInMarkup, SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
|
import { SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import type { AppSettings, UiSettings } from './reducers/settings';
|
import type { AppSettings, UiSettings } from './reducers/settings';
|
||||||
import './UserInterfaceSettings.scss';
|
import './UserInterfaceSettings.scss';
|
||||||
|
@ -18,9 +18,7 @@ export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }
|
||||||
checked={ui?.theme === 'dark'}
|
checked={ui?.theme === 'dark'}
|
||||||
onChange={(useDarkTheme) => {
|
onChange={(useDarkTheme) => {
|
||||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||||
|
|
||||||
setUiSettings({ ...ui, theme });
|
setUiSettings({ ...ui, theme });
|
||||||
changeThemeInMarkup(theme);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use dark theme.
|
Use dark theme.
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { CreateServer as createCreateServer } from '../../src/servers/CreateServer';
|
import { CreateServer as createCreateServer } from '../../src/servers/CreateServer';
|
||||||
import type { ServerWithId } from '../../src/servers/data';
|
import type { ServersMap } from '../../src/servers/data';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
vi.mock('react-router-dom', async () => ({
|
vi.mock('react-router-dom', async () => ({
|
||||||
|
@ -10,11 +10,19 @@ vi.mock('react-router-dom', async () => ({
|
||||||
useNavigate: vi.fn(),
|
useNavigate: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type SetUpOptions = {
|
||||||
|
serversImported?: boolean;
|
||||||
|
importFailed?: boolean;
|
||||||
|
servers?: ServersMap;
|
||||||
|
};
|
||||||
|
|
||||||
describe('<CreateServer />', () => {
|
describe('<CreateServer />', () => {
|
||||||
const createServersMock = vi.fn();
|
const createServersMock = vi.fn();
|
||||||
const navigate = vi.fn();
|
const navigate = vi.fn();
|
||||||
const servers = { foo: fromPartial<ServerWithId>({ url: 'https://existing_url.com', apiKey: 'existing_api_key' }) };
|
const defaultServers: ServersMap = {
|
||||||
const setUp = (serversImported = false, importFailed = false) => {
|
foo: fromPartial({ url: 'https://existing_url.com', apiKey: 'existing_api_key' }),
|
||||||
|
};
|
||||||
|
const setUp = ({ serversImported = false, importFailed = false, servers = defaultServers }: SetUpOptions = {}) => {
|
||||||
(useNavigate as any).mockReturnValue(navigate);
|
(useNavigate as any).mockReturnValue(navigate);
|
||||||
|
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
|
@ -29,16 +37,17 @@ describe('<CreateServer />', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
it('shows success message when imported is true', () => {
|
it('shows success message when imported is true', () => {
|
||||||
setUp(true);
|
setUp({ serversImported: true });
|
||||||
|
|
||||||
expect(screen.getByText('Servers properly imported. You can now select one from the list :)')).toBeInTheDocument();
|
expect(screen.getByText('Servers properly imported. You can now select one from the list :)')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText('The servers could not be imported. Make sure the format is correct.'),
|
screen.queryByText('The servers could not be imported. Make sure the format is correct.'),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('ImportServersBtn')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error message when import failed', () => {
|
it('shows error message when import failed', () => {
|
||||||
setUp(false, true);
|
setUp({ importFailed: true });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText('Servers properly imported. You can now select one from the list :)'),
|
screen.queryByText('Servers properly imported. You can now select one from the list :)'),
|
||||||
|
@ -46,6 +55,11 @@ describe('<CreateServer />', () => {
|
||||||
expect(screen.getByText('The servers could not be imported. Make sure the format is correct.')).toBeInTheDocument();
|
expect(screen.getByText('The servers could not be imported. Make sure the format is correct.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows import button when no servers exist yet', () => {
|
||||||
|
setUp({ servers: {} });
|
||||||
|
expect(screen.queryByText('ImportServersBtn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates server data when form is submitted', async () => {
|
it('creates server data when form is submitted', async () => {
|
||||||
const { user } = setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,7 @@ describe('<DuplicatedServersModal />', () => {
|
||||||
header: 'Duplicated servers',
|
header: 'Duplicated servers',
|
||||||
firstParagraph: 'The next servers already exist:',
|
firstParagraph: 'The next servers already exist:',
|
||||||
lastParagraph: 'Do you want to ignore duplicated servers?',
|
lastParagraph: 'Do you want to ignore duplicated servers?',
|
||||||
discardBtn: 'Ignore duplicated',
|
discardBtn: 'Ignore duplicates',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])('renders expected texts based on amount of servers', (duplicatedServers, assertions) => {
|
])('renders expected texts based on amount of servers', (duplicatedServers, assertions) => {
|
||||||
|
|
|
@ -5,18 +5,13 @@ import { ServersImporter } from '../../../src/servers/services/ServersImporter';
|
||||||
describe('ServersImporter', () => {
|
describe('ServersImporter', () => {
|
||||||
const servers: RegularServer[] = [fromPartial<RegularServer>({}), fromPartial<RegularServer>({})];
|
const servers: RegularServer[] = [fromPartial<RegularServer>({}), fromPartial<RegularServer>({})];
|
||||||
const csvjsonMock = vi.fn().mockResolvedValue(servers);
|
const csvjsonMock = vi.fn().mockResolvedValue(servers);
|
||||||
const readAsText = vi.fn();
|
const text = vi.fn().mockReturnValue('');
|
||||||
const fileReaderMock = fromPartial<FileReader>({
|
const fileMock = () => fromPartial<File>({ text });
|
||||||
readAsText,
|
const importer = new ServersImporter(csvjsonMock);
|
||||||
addEventListener: ((_eventName: string, listener: (e: ProgressEvent<FileReader>) => void) => listener(
|
|
||||||
fromPartial({ target: { result: '' } }),
|
|
||||||
)) as any,
|
|
||||||
});
|
|
||||||
const importer = new ServersImporter(csvjsonMock, () => fileReaderMock);
|
|
||||||
|
|
||||||
describe('importServersFromFile', () => {
|
describe('importServersFromFile', () => {
|
||||||
it('rejects with error if no file was provided', async () => {
|
it.each([[null], [undefined]])('rejects with error if no file was provided', async (file) => {
|
||||||
await expect(importer.importServersFromFile()).rejects.toEqual(
|
await expect(importer.importServersFromFile(file)).rejects.toEqual(
|
||||||
new Error('No file provided'),
|
new Error('No file provided'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -26,7 +21,7 @@ describe('ServersImporter', () => {
|
||||||
|
|
||||||
csvjsonMock.mockRejectedValue(expectedError);
|
csvjsonMock.mockRejectedValue(expectedError);
|
||||||
|
|
||||||
await expect(importer.importServersFromFile(fromPartial({ type: 'text/html' }))).rejects.toEqual(expectedError);
|
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(expectedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
@ -55,7 +50,7 @@ describe('ServersImporter', () => {
|
||||||
])('rejects with error if provided file does not parse to valid list of servers', async (parsedObject) => {
|
])('rejects with error if provided file does not parse to valid list of servers', async (parsedObject) => {
|
||||||
csvjsonMock.mockResolvedValue(parsedObject);
|
csvjsonMock.mockResolvedValue(parsedObject);
|
||||||
|
|
||||||
await expect(importer.importServersFromFile(fromPartial({ type: 'text/html' }))).rejects.toEqual(
|
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(
|
||||||
new Error('Provided file does not have the right format.'),
|
new Error('Provided file does not have the right format.'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -76,10 +71,10 @@ describe('ServersImporter', () => {
|
||||||
|
|
||||||
csvjsonMock.mockResolvedValue(expectedServers);
|
csvjsonMock.mockResolvedValue(expectedServers);
|
||||||
|
|
||||||
const result = await importer.importServersFromFile(fromPartial({}));
|
const result = await importer.importServersFromFile(fileMock());
|
||||||
|
|
||||||
expect(result).toEqual(expectedServers);
|
expect(result).toEqual(expectedServers);
|
||||||
expect(readAsText).toHaveBeenCalledTimes(1);
|
expect(text).toHaveBeenCalledTimes(1);
|
||||||
expect(csvjsonMock).toHaveBeenCalledTimes(1);
|
expect(csvjsonMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue