Do not inject servers state or actions

This commit is contained in:
Alejandro Celaya
2025-11-14 19:23:48 +01:00
parent ae7aea0e2c
commit a7f2d3224b
38 changed files with 292 additions and 375 deletions

View File

@@ -5,10 +5,13 @@ import type { FC } from 'react';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Route, Routes, useLocation } from 'react-router'; import { Route, Routes, useLocation } from 'react-router';
import { AppUpdateBanner } from '../common/AppUpdateBanner'; import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { MainHeader } from '../common/MainHeader';
import { NotFound } from '../common/NotFound'; import { NotFound } from '../common/NotFound';
import { ShlinkVersionsContainer } from '../common/ShlinkVersionsContainer';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from '../servers/data'; import type { ServersMap } from '../servers/data';
import { EditServer } from '../servers/EditServer';
import { Settings } from '../settings/Settings'; import { Settings } from '../settings/Settings';
import { forceUpdate } from '../utils/helpers/sw'; import { forceUpdate } from '../utils/helpers/sw';
@@ -21,26 +24,20 @@ type AppProps = {
}; };
type AppDeps = { type AppDeps = {
MainHeader: FC;
Home: FC; Home: FC;
ShlinkWebComponentContainer: FC; ShlinkWebComponentContainer: FC;
CreateServer: FC; CreateServer: FC;
EditServer: FC;
ManageServers: FC; ManageServers: FC;
ShlinkVersionsContainer: FC;
}; };
const App: FCWithDeps<AppProps, AppDeps> = ( const App: FCWithDeps<AppProps, AppDeps> = (
{ fetchServers, servers, settings, appUpdated, resetAppUpdate }, { fetchServers, servers, settings, appUpdated, resetAppUpdate },
) => { ) => {
const { const {
MainHeader,
Home, Home,
ShlinkWebComponentContainer, ShlinkWebComponentContainer,
CreateServer, CreateServer,
EditServer,
ManageServers, ManageServers,
ShlinkVersionsContainer,
} = useDependencies(App); } = useDependencies(App);
const location = useLocation(); const location = useLocation();
@@ -99,11 +96,8 @@ const App: FCWithDeps<AppProps, AppDeps> = (
}; };
export const AppFactory = componentFactory(App, [ export const AppFactory = componentFactory(App, [
'MainHeader',
'Home', 'Home',
'ShlinkWebComponentContainer', 'ShlinkWebComponentContainer',
'CreateServer', 'CreateServer',
'EditServer',
'ManageServers', 'ManageServers',
'ShlinkVersionsContainer',
]); ]);

View File

@@ -2,19 +2,17 @@ import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card } from '@shlinkio/shlink-frontend-kit'; import { Button, Card } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import type { ServersMap } from '../servers/data'; import { useServers } from '../servers/reducers/servers';
import { ServersListGroup } from '../servers/ServersListGroup'; import { ServersListGroup } from '../servers/ServersListGroup';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
export type HomeProps = { export const Home: FC = () => {
servers: ServersMap;
};
export const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { servers } = useServers();
const serversList = Object.values(servers); const serversList = Object.values(servers);
const hasServers = serversList.length > 0; const hasServers = serversList.length > 0;

View File

@@ -3,16 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { NavBar } from '@shlinkio/shlink-frontend-kit'; import { NavBar } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import type { FCWithDeps } from '../container/utils'; import { ServersDropdown } from '../servers/ServersDropdown';
import { componentFactory, useDependencies } from '../container/utils';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
type MainHeaderDeps = { export const MainHeader: FC = () => {
ServersDropdown: FC;
};
const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
const { ServersDropdown } = useDependencies(MainHeader);
const { pathname } = useLocation(); const { pathname } = useLocation();
const settingsPath = '/settings'; const settingsPath = '/settings';
@@ -37,5 +31,3 @@ const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
</NavBar> </NavBar>
); );
}; };
export const MainHeaderFactory = componentFactory(MainHeader, ['ServersDropdown']);

View File

@@ -9,6 +9,7 @@ import { memo } from 'react';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import { ServerError } from '../servers/helpers/ServerError';
import type { WithSelectedServerPropsDeps } from '../servers/helpers/withSelectedServer'; import type { WithSelectedServerPropsDeps } from '../servers/helpers/withSelectedServer';
import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSelectedServer } from '../servers/reducers/selectedServer'; import { useSelectedServer } from '../servers/reducers/selectedServer';
@@ -33,7 +34,6 @@ const ShlinkWebComponentContainer: FCWithDeps<
const { const {
buildShlinkApiClient, buildShlinkApiClient,
TagColorsStorage: tagColorsStorage, TagColorsStorage: tagColorsStorage,
ServerError,
} = useDependencies(ShlinkWebComponentContainer); } = useDependencies(ShlinkWebComponentContainer);
const { selectedServer } = useSelectedServer(); const { selectedServer } = useSelectedServer();
@@ -63,5 +63,4 @@ const ShlinkWebComponentContainer: FCWithDeps<
export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [ export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [
'buildShlinkApiClient', 'buildShlinkApiClient',
'TagColorsStorage', 'TagColorsStorage',
'ServerError',
]); ]);

View File

@@ -4,9 +4,7 @@ import type { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ErrorHandler } from '../ErrorHandler'; import { ErrorHandler } from '../ErrorHandler';
import { Home } from '../Home'; import { Home } from '../Home';
import { MainHeaderFactory } from '../MainHeader';
import { ScrollToTop } from '../ScrollToTop'; import { ScrollToTop } from '../ScrollToTop';
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
import { ShlinkWebComponentContainerFactory } from '../ShlinkWebComponentContainer'; import { ShlinkWebComponentContainerFactory } from '../ShlinkWebComponentContainer';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
@@ -19,16 +17,11 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('ScrollToTop', () => ScrollToTop); bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
bottle.factory('MainHeader', MainHeaderFactory);
bottle.serviceFactory('Home', () => Home); bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer); bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', connect(['servers'], []));
bottle.factory('ShlinkWebComponentContainer', ShlinkWebComponentContainerFactory); bottle.factory('ShlinkWebComponentContainer', ShlinkWebComponentContainerFactory);
bottle.decorator('ShlinkWebComponentContainer', connect(['settings'], ['selectServer'])); bottle.decorator('ShlinkWebComponentContainer', connect(['settings'], []));
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.serviceFactory('ErrorHandler', () => ErrorHandler); bottle.serviceFactory('ErrorHandler', () => ErrorHandler);
}; };

View File

@@ -36,5 +36,5 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
provideAppServices(bottle, connect); provideAppServices(bottle, connect);
provideCommonServices(bottle, connect); provideCommonServices(bottle, connect);
provideApiServices(bottle); provideApiServices(bottle);
provideServersServices(bottle, connect); provideServersServices(bottle);
provideUtilsServices(bottle); provideUtilsServices(bottle);

View File

@@ -7,19 +7,15 @@ import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import type { ServerData, ServersMap, ServerWithId } from './data'; import type { ServerData } from './data';
import { ensureUniqueIds } from './helpers'; import { ensureUniqueIds } from './helpers';
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal'; import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn'; import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import { useServers } from './reducers/servers';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
type CreateServerProps = {
createServers: (servers: ServerWithId[]) => void;
servers: ServersMap;
};
type CreateServerDeps = { type CreateServerDeps = {
ImportServersBtn: FC<ImportServersBtnProps>; ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle; useTimeoutToggle: TimeoutToggle;
@@ -34,7 +30,8 @@ const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
</div> </div>
); );
const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers, createServers }) => { const CreateServer: FCWithDeps<any, CreateServerDeps> = () => {
const { servers, createServers } = useServers();
const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer); const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer);
const navigate = useNavigate(); const navigate = useNavigate();
const goBack = useGoBack(); const goBack = useGoBack();

View File

@@ -2,21 +2,14 @@ import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import type { DeleteServerModalProps } from './DeleteServerModal'; import { DeleteServerModal } from './DeleteServerModal';
export type DeleteServerButtonProps = PropsWithChildren<{ export type DeleteServerButtonProps = PropsWithChildren<{
server: ServerWithId; server: ServerWithId;
}>; }>;
type DeleteServerButtonDeps = { export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => {
DeleteServerModal: FC<DeleteServerModalProps>;
};
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ({ server, children }) => {
const { DeleteServerModal } = useDependencies(DeleteServerButton);
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle(); const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
const navigate = useNavigate(); const navigate = useNavigate();
const onClose = useCallback((confirmed: boolean) => { const onClose = useCallback((confirmed: boolean) => {
@@ -35,5 +28,3 @@ const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButton
</> </>
); );
}; };
export const DeleteServerButtonFactory = componentFactory(DeleteServerButton, ['DeleteServerModal']);

View File

@@ -3,6 +3,7 @@ import { CardModal } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import { useServers } from './reducers/servers';
export type DeleteServerModalProps = { export type DeleteServerModalProps = {
server: ServerWithId; server: ServerWithId;
@@ -10,11 +11,8 @@ export type DeleteServerModalProps = {
open: boolean; open: boolean;
}; };
type DeleteServerModalConnectProps = DeleteServerModalProps & { export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
deleteServer: (server: ServerWithId) => void; const { deleteServer } = useServers();
};
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ({ server, onClose, open, deleteServer }) => {
const onClosed = useCallback((exitAction: ExitAction) => { const onClosed = useCallback((exitAction: ExitAction) => {
if (exitAction === 'confirm') { if (exitAction === 'confirm') {
deleteServer(server); deleteServer(server);

View File

@@ -1,7 +1,7 @@
import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit'; import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { useDependencies } from '../container/utils';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import type { ServerData } from './data'; import type { ServerData } from './data';
import { isServerWithId } from './data'; import { isServerWithId } from './data';
@@ -9,12 +9,10 @@ import { ServerForm } from './helpers/ServerForm';
import type { WithSelectedServerPropsDeps } from './helpers/withSelectedServer'; import type { WithSelectedServerPropsDeps } from './helpers/withSelectedServer';
import { withSelectedServer } from './helpers/withSelectedServer'; import { withSelectedServer } from './helpers/withSelectedServer';
import { useSelectedServer } from './reducers/selectedServer'; import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers';
type EditServerProps = { export const EditServer: FCWithDeps<any, WithSelectedServerPropsDeps> = withSelectedServer(() => {
editServer: (serverId: string, serverData: ServerData) => void; const { editServer } = useServers();
};
const EditServer: FCWithDeps<EditServerProps, WithSelectedServerPropsDeps> = withSelectedServer(({ editServer }) => {
const { buildShlinkApiClient } = useDependencies(EditServer); const { buildShlinkApiClient } = useDependencies(EditServer);
const { selectServer, selectedServer } = useSelectedServer(); const { selectServer, selectedServer } = useSelectedServer();
const goBack = useGoBack(); const goBack = useGoBack();
@@ -45,5 +43,3 @@ const EditServer: FCWithDeps<EditServerProps, WithSelectedServerPropsDeps> = wit
</NoMenuLayout> </NoMenuLayout>
); );
}); });
export const EditServerFactory = componentFactory(EditServer, ['ServerError']);

View File

@@ -7,31 +7,26 @@ import { useMemo, useState } from 'react';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from './data';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn'; import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import type { ManageServersRowProps } from './ManageServersRow'; import { ManageServersRow } from './ManageServersRow';
import { useServers } from './reducers/servers';
import type { ServersExporter } from './services/ServersExporter'; import type { ServersExporter } from './services/ServersExporter';
type ManageServersProps = {
servers: ServersMap;
};
type ManageServersDeps = { type ManageServersDeps = {
ServersExporter: ServersExporter; ServersExporter: ServersExporter;
ImportServersBtn: FC<ImportServersBtnProps>; ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle; useTimeoutToggle: TimeoutToggle;
ManageServersRow: FC<ManageServersRowProps>;
}; };
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ servers }) => { const ManageServers: FCWithDeps<unknown, ManageServersDeps> = () => {
const { const {
ServersExporter: serversExporter, ServersExporter: serversExporter,
ImportServersBtn, ImportServersBtn,
useTimeoutToggle, useTimeoutToggle,
ManageServersRow,
} = useDependencies(ManageServers); } = useDependencies(ManageServers);
const { servers } = useServers();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const allServers = useMemo(() => Object.values(servers), [servers]); const allServers = useMemo(() => Object.values(servers), [servers]);
const filteredServers = useMemo( const filteredServers = useMemo(
@@ -93,5 +88,4 @@ export const ManageServersFactory = componentFactory(ManageServers, [
'ServersExporter', 'ServersExporter',
'ImportServersBtn', 'ImportServersBtn',
'useTimeoutToggle', 'useTimeoutToggle',
'ManageServersRow',
]); ]);

View File

@@ -3,22 +3,15 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit'; import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown'; import { ManageServersRowDropdown } from './ManageServersRowDropdown';
export type ManageServersRowProps = { export type ManageServersRowProps = {
server: ServerWithId; server: ServerWithId;
hasAutoConnect: boolean; hasAutoConnect: boolean;
}; };
type ManageServersRowDeps = { export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoConnect }) => {
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>;
};
const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps> = ({ server, hasAutoConnect }) => {
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
const { anchor, tooltip } = useTooltip(); const { anchor, tooltip } = useTooltip();
return ( return (
@@ -31,6 +24,7 @@ const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps>
icon={checkIcon} icon={checkIcon}
className="text-lm-brand dark:text-dm-brand" className="text-lm-brand dark:text-dm-brand"
{...anchor} {...anchor}
data-testid="auto-connect"
/> />
<Tooltip {...tooltip}>Auto-connect to this server</Tooltip> <Tooltip {...tooltip}>Auto-connect to this server</Tooltip>
</> </>
@@ -47,5 +41,3 @@ const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps>
</Table.Row> </Table.Row>
); );
}; };
export const ManageServersRowFactory = componentFactory(ManageServersRow, ['ManageServersRowDropdown']);

View File

@@ -6,29 +6,18 @@ import {
faPlug as connectIcon, faPlug as connectIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { RowDropdown,useToggle } from '@shlinkio/shlink-frontend-kit'; import { RowDropdown, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import type { DeleteServerModalProps } from './DeleteServerModal'; import { DeleteServerModal } from './DeleteServerModal';
import { useServers } from './reducers/servers';
export type ManageServersRowDropdownProps = { export type ManageServersRowDropdownProps = {
server: ServerWithId; server: ServerWithId;
}; };
type ManageServersRowDropdownConnectProps = ManageServersRowDropdownProps & { export const ManageServersRowDropdown: FC<ManageServersRowDropdownProps> = ({ server }) => {
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void; const { setAutoConnect } = useServers();
};
type ManageServersRowDropdownDeps = {
DeleteServerModal: FC<DeleteServerModalProps>
};
const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps, ManageServersRowDropdownDeps> = (
{ server, setAutoConnect },
) => {
const { DeleteServerModal } = useDependencies(ManageServersRowDropdown);
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle(); const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
const serverUrl = `/server/${server.id}`; const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server; const { autoConnect: isAutoConnect } = server;
@@ -56,5 +45,3 @@ const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps,
</> </>
); );
}; };
export const ManageServersRowDropdownFactory = componentFactory(ManageServersRowDropdown, ['DeleteServerModal']);

View File

@@ -1,15 +1,13 @@
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons'; import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit'; import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit';
import type { ServersMap } from './data'; import type { FC } from 'react';
import { getServerId } from './data'; import { getServerId } from './data';
import { useSelectedServer } from './reducers/selectedServer'; import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers';
export interface ServersDropdownProps { export const ServersDropdown: FC = () => {
servers: ServersMap; const { servers } = useServers();
}
export const ServersDropdown = ({ servers }: ServersDropdownProps) => {
const serversList = Object.values(servers); const serversList = Object.values(servers);
const { selectedServer } = useSelectedServer(); const { selectedServer } = useSelectedServer();

View File

@@ -26,18 +26,16 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
); );
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => ( export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
<> servers.length > 0 && (
{servers.length > 0 && ( <div
<div data-testid="list"
data-testid="list" className={clsx(
className={clsx( 'w-full border-lm-border dark:border-dm-border',
'w-full border-lm-border dark:border-dm-border', 'md:max-h-56 md:overflow-y-auto -mb-1 scroll-thin',
'md:max-h-56 md:overflow-y-auto -mb-1 scroll-thin', { 'border-y': !borderless },
{ 'border-y': !borderless }, )}
)} >
> {servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)} </div>
</div> )
)}
</>
); );

View File

@@ -26,7 +26,7 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'} cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
> >
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p> <p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul className="list-disc mt-4"> <ul className="list-disc my-4 pl-5">
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? ( {duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}> <Fragment key={index}>
<li>URL: <b>{url}</b></li> <li>URL: <b>{url}</b></li>

View File

@@ -5,7 +5,8 @@ import type { ChangeEvent, PropsWithChildren } from 'react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import type { FCWithDeps } from '../../container/utils'; import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils'; import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap, ServerWithId } from '../data'; import type { ServerData } from '../data';
import { useServers } from '../reducers/servers';
import type { ServersImporter } from '../services/ServersImporter'; import type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal'; import { DuplicatedServersModal } from './DuplicatedServersModal';
import { dedupServers, ensureUniqueIds } from './index'; import { dedupServers, ensureUniqueIds } from './index';
@@ -17,24 +18,18 @@ export type ImportServersBtnProps = PropsWithChildren<{
className?: string; className?: string;
}>; }>;
type ImportServersBtnConnectProps = ImportServersBtnProps & {
createServers: (servers: ServerWithId[]) => void;
servers: ServersMap;
};
type ImportServersBtnDeps = { type ImportServersBtnDeps = {
ServersImporter: ServersImporter ServersImporter: ServersImporter
}; };
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({ const ImportServersBtn: FCWithDeps<ImportServersBtnProps, ImportServersBtnDeps> = ({
createServers,
servers,
children, children,
onImport, onImport,
onError = () => {}, onError = () => {},
tooltipPlacement = 'bottom', tooltipPlacement = 'bottom',
className = '', className = '',
}) => { }) => {
const { createServers, servers } = useServers();
const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn); const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement }); const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement });

View File

@@ -2,24 +2,14 @@ import { Card, Message } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { NoMenuLayout } from '../../common/NoMenuLayout'; import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import type { ServersMap } from '../data';
import { isServerWithId } from '../data'; import { isServerWithId } from '../data';
import type { DeleteServerButtonProps } from '../DeleteServerButton'; import { DeleteServerButton } from '../DeleteServerButton';
import { useSelectedServer } from '../reducers/selectedServer'; import { useSelectedServer } from '../reducers/selectedServer';
import { useServers } from '../reducers/servers';
import { ServersListGroup } from '../ServersListGroup'; import { ServersListGroup } from '../ServersListGroup';
type ServerErrorProps = { export const ServerError: FC = () => {
servers: ServersMap; const { servers } = useServers();
};
type ServerErrorDeps = {
DeleteServerButton: FC<DeleteServerButtonProps>;
};
const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers }) => {
const { DeleteServerButton } = useDependencies(ServerError);
const { selectedServer } = useSelectedServer(); const { selectedServer } = useSelectedServer();
return ( return (
@@ -55,5 +45,3 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers })
</NoMenuLayout> </NoMenuLayout>
); );
}; };
export const ServerErrorFactory = componentFactory(ServerError, ['DeleteServerButton']);

View File

@@ -1,5 +1,4 @@
import { Message } from '@shlinkio/shlink-frontend-kit'; import { Message } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
@@ -8,9 +7,9 @@ import type { FCWithDeps } from '../../container/utils';
import { useDependencies } from '../../container/utils'; import { useDependencies } from '../../container/utils';
import { isNotFoundServer } from '../data'; import { isNotFoundServer } from '../data';
import { useSelectedServer } from '../reducers/selectedServer'; import { useSelectedServer } from '../reducers/selectedServer';
import { ServerError } from './ServerError';
export type WithSelectedServerPropsDeps = { export type WithSelectedServerPropsDeps = {
ServerError: FC;
buildShlinkApiClient: ShlinkApiClientBuilder; buildShlinkApiClient: ShlinkApiClientBuilder;
}; };
@@ -18,7 +17,7 @@ export function withSelectedServer<T extends object>(
WrappedComponent: FCWithDeps<T, WithSelectedServerPropsDeps>, WrappedComponent: FCWithDeps<T, WithSelectedServerPropsDeps>,
) { ) {
const ComponentWrapper: FCWithDeps<T, WithSelectedServerPropsDeps> = (props) => { const ComponentWrapper: FCWithDeps<T, WithSelectedServerPropsDeps> = (props) => {
const { ServerError, buildShlinkApiClient } = useDependencies(ComponentWrapper); const { buildShlinkApiClient } = useDependencies(ComponentWrapper);
const params = useParams<{ serverId: string }>(); const params = useParams<{ serverId: string }>();
const { selectServer, selectedServer } = useSelectedServer(); const { selectServer, selectedServer } = useSelectedServer();

View File

@@ -1,21 +1,23 @@
import type { PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../store';
import type { ServerData, ServersMap, ServerWithId } from '../data'; import type { ServerData, ServersMap, ServerWithId } from '../data';
import { serversListToMap } from '../helpers'; import { serversListToMap } from '../helpers';
interface EditServer { type EditServer = {
serverId: string; serverId: string;
serverData: Partial<ServerData>; serverData: Partial<ServerData>;
} };
interface SetAutoConnect { type SetAutoConnect = {
serverId: string; serverId: string;
autoConnect: boolean; autoConnect: boolean;
} };
const initialState: ServersMap = {}; const initialState: ServersMap = {};
export const { actions, reducer } = createSlice({ export const { actions, reducer: serversReducer } = createSlice({
name: 'shlink/servers', name: 'shlink/servers',
initialState, initialState,
reducers: { reducers: {
@@ -65,4 +67,19 @@ export const { actions, reducer } = createSlice({
export const { editServer, deleteServer, setAutoConnect, createServers } = actions; export const { editServer, deleteServer, setAutoConnect, createServers } = actions;
export const serversReducer = reducer; export const useServers = () => {
const dispatch = useAppDispatch();
const servers = useAppSelector((state) => state.servers);
const editServer = useCallback(
(serverId: string, serverData: Partial<ServerData>) => dispatch(actions.editServer(serverId, serverData)),
[dispatch],
);
const deleteServer = useCallback((server: ServerWithId) => dispatch(actions.deleteServer(server)), [dispatch]);
const setAutoConnect = useCallback(
(serverData: ServerWithId, autoConnect: boolean) => dispatch(actions.setAutoConnect(serverData, autoConnect)),
[dispatch],
);
const createServers = useCallback((servers: ServerWithId[]) => dispatch(actions.createServers(servers)), [dispatch]);
return { servers, editServer, deleteServer, setAutoConnect, createServers };
};

View File

@@ -1,63 +1,26 @@
import type Bottle from 'bottlejs'; import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { CreateServerFactory } from '../CreateServer'; import { CreateServerFactory } from '../CreateServer';
import { DeleteServerButtonFactory } from '../DeleteServerButton';
import { DeleteServerModal } from '../DeleteServerModal';
import { EditServerFactory } from '../EditServer';
import { ImportServersBtnFactory } from '../helpers/ImportServersBtn'; import { ImportServersBtnFactory } from '../helpers/ImportServersBtn';
import { ServerErrorFactory } from '../helpers/ServerError';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { ManageServersFactory } from '../ManageServers'; import { ManageServersFactory } from '../ManageServers';
import { ManageServersRowFactory } from '../ManageServersRow';
import { ManageServersRowDropdownFactory } from '../ManageServersRowDropdown';
import { fetchServers } from '../reducers/remoteServers'; import { fetchServers } from '../reducers/remoteServers';
import { selectServer } from '../reducers/selectedServer';
import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { ServersDropdown } from '../ServersDropdown';
import { ServersExporter } from './ServersExporter'; import { ServersExporter } from './ServersExporter';
import { ServersImporter } from './ServersImporter'; import { ServersImporter } from './ServersImporter';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle) => {
// Components // Components
bottle.factory('ManageServers', ManageServersFactory); bottle.factory('ManageServers', ManageServersFactory);
bottle.decorator('ManageServers', withoutSelectedServer); bottle.decorator('ManageServers', withoutSelectedServer);
bottle.decorator('ManageServers', connect(['servers'], []));
bottle.factory('ManageServersRow', ManageServersRowFactory);
bottle.factory('ManageServersRowDropdown', ManageServersRowDropdownFactory);
bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect']));
bottle.factory('CreateServer', CreateServerFactory); bottle.factory('CreateServer', CreateServerFactory);
bottle.decorator('CreateServer', withoutSelectedServer); bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect(['servers'], ['createServers']));
bottle.factory('EditServer', EditServerFactory);
bottle.decorator('EditServer', connect([], ['editServer', 'selectServer']));
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect(['servers']));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', connect(null, ['deleteServer']));
bottle.factory('DeleteServerButton', DeleteServerButtonFactory);
bottle.factory('ImportServersBtn', ImportServersBtnFactory); bottle.factory('ImportServersBtn', ImportServersBtnFactory);
bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers']));
bottle.factory('ServerError', ServerErrorFactory);
bottle.decorator('ServerError', connect(['servers']));
// Services // Services
bottle.service('ServersImporter', ServersImporter, 'csvToJson'); bottle.service('ServersImporter', ServersImporter, 'csvToJson');
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv'); bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
// Actions // Actions
bottle.serviceFactory('selectServer', () => selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
bottle.serviceFactory('createServers', () => createServers);
bottle.serviceFactory('deleteServer', () => deleteServer);
bottle.serviceFactory('editServer', () => editServer);
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient'); bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient');
}; };

View File

@@ -19,10 +19,11 @@ export const renderWithStore = (
element: ReactElement, element: ReactElement,
{ initialState = {}, ...options }: RenderOptionsWithState = {}, { initialState = {}, ...options }: RenderOptionsWithState = {},
) => { ) => {
const Wrapper = ({ children }: PropsWithChildren) => ( const store = setUpStore(initialState);
<Provider store={setUpStore(initialState)}> const Wrapper = ({ children }: PropsWithChildren) => <Provider store={store}>{children}</Provider>;
{children}
</Provider> return {
); store,
return renderWithEvents(element, { ...options, wrapper: Wrapper }); ...renderWithEvents(element, { ...options, wrapper: Wrapper }),
};
}; };

View File

@@ -2,19 +2,17 @@ import { act, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import { AppFactory } from '../../src/app/App'; import { AppFactory } from '../../src/app/App';
import type { ServerWithId } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<App />', () => { describe('<App />', () => {
const App = AppFactory( const App = AppFactory(
fromPartial({ fromPartial({
MainHeader: () => <>MainHeader</>,
Home: () => <>Home</>, Home: () => <>Home</>,
ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer</>, ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer</>,
CreateServer: () => <>CreateServer</>, CreateServer: () => <>CreateServer</>,
EditServer: () => <>EditServer</>,
ManageServers: () => <>ManageServers</>, ManageServers: () => <>ManageServers</>,
ShlinkVersionsContainer: () => <>ShlinkVersions</>,
}), }),
); );
const setUp = async (activeRoute = '/') => act(() => renderWithStore( const setUp = async (activeRoute = '/') => act(() => renderWithStore(
@@ -27,24 +25,25 @@ describe('<App />', () => {
resetAppUpdate={() => {}} resetAppUpdate={() => {}}
/> />
</MemoryRouter>, </MemoryRouter>,
{
initialState: {
servers: {
abc123: fromPartial<ServerWithId>({ id: 'abc123', name: 'abc123 server' }),
def456: fromPartial<ServerWithId>({ id: 'def456', name: 'def456 server' }),
},
},
},
)); ));
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders children components', async () => {
await setUp();
expect(screen.getByText('MainHeader')).toBeInTheDocument();
expect(screen.getByText('ShlinkVersions')).toBeInTheDocument();
});
it.each([ it.each([
['/settings/general', 'User interface'], ['/settings/general', 'User interface'],
['/settings/short-urls', 'Short URLs form'], ['/settings/short-urls', 'Short URLs form'],
['/manage-servers', 'ManageServers'], ['/manage-servers', 'ManageServers'],
['/server/create', 'CreateServer'], ['/server/create', 'CreateServer'],
['/server/abc123/edit', 'EditServer'], ['/server/abc123/edit', 'Edit "abc123 server"'],
['/server/def456/edit', 'EditServer'], ['/server/def456/edit', 'Edit "def456 server"'],
['/server/abc123/foo', 'ShlinkWebComponentContainer'], ['/server/abc123/foo', 'ShlinkWebComponentContainer'],
['/server/def456/bar', 'ShlinkWebComponentContainer'], ['/server/def456/bar', 'ShlinkWebComponentContainer'],
['/other', 'Oops! We could not find requested route.'], ['/other', 'Oops! We could not find requested route.'],

View File

@@ -1,15 +1,19 @@
import { render, screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import { Home } from '../../src/common/Home'; import { Home } from '../../src/common/Home';
import type { ServersMap, ServerWithId } from '../../src/servers/data'; import type { ServersMap, ServerWithId } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest';
describe('<Home />', () => { describe('<Home />', () => {
const setUp = (servers: ServersMap = {}) => render( const setUp = (servers: ServersMap = {}) => renderWithStore(
<MemoryRouter> <MemoryRouter>
<Home servers={servers} /> <Home />
</MemoryRouter>, </MemoryRouter>,
{
initialState: { servers },
},
); );
it('passes a11y checks', () => checkAccessibility( it('passes a11y checks', () => checkAccessibility(

View File

@@ -1,21 +1,16 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { MainHeaderFactory } from '../../src/common/MainHeader'; import { MainHeader } from '../../src/common/MainHeader';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<MainHeader />', () => { describe('<MainHeader />', () => {
const MainHeader = MainHeaderFactory(fromPartial({
// Fake this component as a li[role="menuitem"], as it gets rendered inside a ul[role="menu"]
ServersDropdown: () => <li role="menuitem">ServersDropdown</li>,
}));
const setUp = (pathname = '') => { const setUp = (pathname = '') => {
const history = createMemoryHistory(); const history = createMemoryHistory();
history.push(pathname); history.push(pathname);
return renderWithEvents( return renderWithStore(
<Router location={history.location} navigator={history}> <Router location={history.location} navigator={history}>
<MainHeader /> <MainHeader />
</Router>, </Router>,
@@ -26,7 +21,7 @@ describe('<MainHeader />', () => {
it('renders ServersDropdown', () => { it('renders ServersDropdown', () => {
setUp(); setUp();
expect(screen.getByText('ServersDropdown')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Servers' })).toBeInTheDocument();
}); });
it.each([ it.each([

View File

@@ -1,5 +1,6 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router';
import { ShlinkWebComponentContainerFactory } from '../../src/common/ShlinkWebComponentContainer'; import { ShlinkWebComponentContainerFactory } from '../../src/common/ShlinkWebComponentContainer';
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../src/servers/data'; import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
@@ -15,12 +16,13 @@ describe('<ShlinkWebComponentContainer />', () => {
const ShlinkWebComponentContainer = ShlinkWebComponentContainerFactory(fromPartial({ const ShlinkWebComponentContainer = ShlinkWebComponentContainerFactory(fromPartial({
buildShlinkApiClient: vi.fn().mockReturnValue(fromPartial({})), buildShlinkApiClient: vi.fn().mockReturnValue(fromPartial({})),
TagColorsStorage: fromPartial({}), TagColorsStorage: fromPartial({}),
ServerError: () => <>ServerError</>,
})); }));
const setUp = (selectedServer: SelectedServer) => renderWithStore( const setUp = (selectedServer: SelectedServer) => renderWithStore(
<ShlinkWebComponentContainer settings={{}} />, <MemoryRouter>
<ShlinkWebComponentContainer settings={{}} />
</MemoryRouter>,
{ {
initialState: { selectedServer }, initialState: { selectedServer, servers: {} },
}, },
); );
@@ -30,18 +32,20 @@ describe('<ShlinkWebComponentContainer />', () => {
setUp(null); setUp(null);
expect(screen.getByText('Loading...')).toBeInTheDocument(); expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('ServerError')).not.toBeInTheDocument();
expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument(); expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument();
}); });
it.each([ it.each([
[fromPartial<NotFoundServer>({ serverNotFound: true })], [fromPartial<NotFoundServer>({ serverNotFound: true }), 'Could not find this Shlink server.'],
[fromPartial<NonReachableServer>({ serverNotReachable: true })], [
])('shows error for non reachable servers', (selectedServer) => { fromPartial<NonReachableServer>({ id: 'foo', serverNotReachable: true }),
/Could not connect to this Shlink server/,
],
])('shows error for non reachable servers', (selectedServer, expectedError) => {
setUp(selectedServer); setUp(selectedServer);
expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.getByText('ServerError')).toBeInTheDocument(); expect(screen.getByText(expectedError)).toBeInTheDocument();
expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument(); expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument();
}); });
@@ -49,7 +53,6 @@ describe('<ShlinkWebComponentContainer />', () => {
setUp(fromPartial({ version: '3.0.0' })); setUp(fromPartial({ version: '3.0.0' }));
expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.queryByText('ServerError')).not.toBeInTheDocument();
expect(screen.getByText('ShlinkWebComponent')).toBeInTheDocument(); expect(screen.getByText('ShlinkWebComponent')).toBeInTheDocument();
}); });
}); });

View File

@@ -5,7 +5,7 @@ import { Router } from 'react-router';
import { CreateServerFactory } from '../../src/servers/CreateServer'; import { CreateServerFactory } from '../../src/servers/CreateServer';
import type { ServersMap } from '../../src/servers/data'; import type { ServersMap } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
type SetUpOptions = { type SetUpOptions = {
serversImported?: boolean; serversImported?: boolean;
@@ -14,9 +14,8 @@ type SetUpOptions = {
}; };
describe('<CreateServer />', () => { describe('<CreateServer />', () => {
const createServersMock = vi.fn();
const defaultServers: ServersMap = { const defaultServers: ServersMap = {
foo: fromPartial({ url: 'https://existing_url.com', apiKey: 'existing_api_key' }), foo: fromPartial({ url: 'https://existing_url.com', apiKey: 'existing_api_key', id: 'foo' }),
}; };
const setUp = ({ serversImported = false, importFailed = false, servers = defaultServers }: SetUpOptions = {}) => { const setUp = ({ serversImported = false, importFailed = false, servers = defaultServers }: SetUpOptions = {}) => {
let callCount = 0; let callCount = 0;
@@ -33,10 +32,13 @@ describe('<CreateServer />', () => {
return { return {
history, history,
...renderWithEvents( ...renderWithStore(
<Router location={history.location} navigator={history}> <Router location={history.location} navigator={history}>
<CreateServer createServers={createServersMock} servers={servers} /> <CreateServer />
</Router>, </Router>,
{
initialState: { servers },
},
), ),
}; };
}; };
@@ -68,21 +70,23 @@ describe('<CreateServer />', () => {
}); });
it('creates server data when form is submitted', async () => { it('creates server data when form is submitted', async () => {
const { user, history } = setUp(); const { user, history, store } = setUp();
const expectedServerId = 'the_name-the_url.com';
expect(createServersMock).not.toHaveBeenCalled();
await user.type(screen.getByLabelText(/^Name/), 'the_name'); await user.type(screen.getByLabelText(/^Name/), 'the_name');
await user.type(screen.getByLabelText(/^URL/), 'https://the_url.com'); await user.type(screen.getByLabelText(/^URL/), 'https://the_url.com');
await user.type(screen.getByLabelText(/^API key/), 'the_api_key'); await user.type(screen.getByLabelText(/^API key/), 'the_api_key');
fireEvent.submit(screen.getByRole('form'));
expect(createServersMock).toHaveBeenCalledWith([expect.objectContaining({ expect(store.getState().servers[expectedServerId]).not.toBeDefined();
fireEvent.submit(screen.getByRole('form'));
expect(store.getState().servers[expectedServerId]).toEqual(expect.objectContaining({
id: expectedServerId,
name: 'the_name', name: 'the_name',
url: 'https://the_url.com', url: 'https://the_url.com',
apiKey: 'the_api_key', apiKey: 'the_api_key',
})]); }));
expect(history.location.pathname).toEqual(expect.stringMatching(/^\/server\//));
expect(history.location.pathname).toEqual(`/server/${expectedServerId}`);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
}); });
@@ -92,12 +96,12 @@ describe('<CreateServer />', () => {
await user.type(screen.getByLabelText(/^Name/), 'the_name'); await user.type(screen.getByLabelText(/^Name/), 'the_name');
await user.type(screen.getByLabelText(/^URL/), 'https://existing_url.com'); await user.type(screen.getByLabelText(/^URL/), 'https://existing_url.com');
await user.type(screen.getByLabelText(/^API key/), 'existing_api_key'); await user.type(screen.getByLabelText(/^API key/), 'existing_api_key');
fireEvent.submit(screen.getByRole('form')); fireEvent.submit(screen.getByRole('form'));
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'Discard' })); await user.click(screen.getByRole('button', { name: 'Discard' }));
expect(createServersMock).not.toHaveBeenCalled();
expect(history.location.pathname).toEqual('/foo'); // Goes back to first route from history's initialEntries expect(history.location.pathname).toEqual('/foo'); // Goes back to first route from history's initialEntries
}); });
}); });

View File

@@ -3,19 +3,14 @@ import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton'; import { DeleteServerButton } from '../../src/servers/DeleteServerButton';
import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal';
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<DeleteServerButton />', () => { describe('<DeleteServerButton />', () => {
const DeleteServerButton = DeleteServerButtonFactory(fromPartial({
DeleteServerModal: (props: DeleteServerModalProps) => <DeleteServerModal {...props} deleteServer={vi.fn()} />,
}));
const setUp = (children: ReactNode = 'Remove this server') => { const setUp = (children: ReactNode = 'Remove this server') => {
const history = createMemoryHistory({ initialEntries: ['/foo'] }); const history = createMemoryHistory({ initialEntries: ['/foo'] });
const result = renderWithEvents( const result = renderWithStore(
<Router location={history.location} navigator={history}> <Router location={history.location} navigator={history}>
<DeleteServerButton server={fromPartial({})}>{children}</DeleteServerButton> <DeleteServerButton server={fromPartial({})}>{children}</DeleteServerButton>
</Router>, </Router>,

View File

@@ -1,23 +1,23 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import type { ServerWithId } from '../../src/servers/data';
import { DeleteServerModal } from '../../src/servers/DeleteServerModal'; import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
import { TestModalWrapper } from '../__helpers__/TestModalWrapper'; import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
describe('<DeleteServerModal />', () => { describe('<DeleteServerModal />', () => {
const deleteServerMock = vi.fn();
const serverName = 'the_server_name'; const serverName = 'the_server_name';
const setUp = () => renderWithEvents( const server = fromPartial<ServerWithId>({ id: 'foo', name: serverName });
const setUp = () => renderWithStore(
<TestModalWrapper <TestModalWrapper
renderModal={(args) => ( renderModal={(args) => <DeleteServerModal {...args} server={server} />}
<DeleteServerModal
{...args}
server={fromPartial({ name: serverName })}
deleteServer={deleteServerMock}
/>
)}
/>, />,
{
initialState: {
servers: { foo: server },
},
},
); );
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
@@ -40,19 +40,21 @@ describe('<DeleteServerModal />', () => {
[() => screen.getByRole('button', { name: 'Cancel' })], [() => screen.getByRole('button', { name: 'Cancel' })],
[() => screen.getByLabelText('Close dialog')], [() => screen.getByLabelText('Close dialog')],
])('closes dialog when clicking cancel button', async (getButton) => { ])('closes dialog when clicking cancel button', async (getButton) => {
const { user } = setUp(); const { user, store } = setUp();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.click(getButton()); await user.click(getButton());
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(deleteServerMock).not.toHaveBeenCalled();
// No server has been deleted
expect(Object.keys(store.getState().servers)).toHaveLength(1);
}); });
it('deletes server when clicking accept button', async () => { it('deletes server when clicking accept button', async () => {
const { user } = setUp(); const { user, store } = setUp();
expect(deleteServerMock).not.toHaveBeenCalled(); expect(Object.keys(store.getState().servers)).toHaveLength(1);
await user.click(screen.getByRole('button', { name: 'Delete' })); await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(deleteServerMock).toHaveBeenCalledOnce(); expect(Object.keys(store.getState().servers)).toHaveLength(0);
}); });
}); });

View File

@@ -1,32 +1,33 @@
import { fireEvent, screen } from '@testing-library/react'; import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
import type { ReachableServer, SelectedServer } from '../../src/servers/data'; import type { ReachableServer, SelectedServer } from '../../src/servers/data';
import { EditServerFactory } from '../../src/servers/EditServer'; import { isServerWithId } from '../../src/servers/data';
import { EditServer } from '../../src/servers/EditServer';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<EditServer />', () => { describe('<EditServer />', () => {
const ServerError = vi.fn();
const editServerMock = vi.fn();
const defaultSelectedServer = fromPartial<ReachableServer>({ const defaultSelectedServer = fromPartial<ReachableServer>({
id: 'abc123', id: 'abc123',
name: 'the_name', name: 'the_name',
url: 'the_url', url: 'the_url',
apiKey: 'the_api_key', apiKey: 'the_api_key',
}); });
const EditServer = EditServerFactory(fromPartial({ ServerError }));
const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => { const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => {
const history = createMemoryHistory({ initialEntries: ['/foo', '/bar'] }); const history = createMemoryHistory({ initialEntries: ['/foo', '/bar'] });
return { return {
history, history,
...renderWithStore( ...renderWithStore(
<Router location={history.location} navigator={history}> <Router location={history.location} navigator={history}>
<EditServer editServer={editServerMock} /> <EditServer />
</Router>, </Router>,
{ {
initialState: { selectedServer }, initialState: {
selectedServer,
servers: isServerWithId(selectedServer) ? { [selectedServer.id]: selectedServer } : {},
},
}, },
), ),
}; };
@@ -56,7 +57,7 @@ describe('<EditServer />', () => {
}); });
it('edits server and redirects to it when form is submitted', async () => { it('edits server and redirects to it when form is submitted', async () => {
const { user, history } = setUp(); const { user, history, store } = setUp();
await user.type(screen.getByLabelText(/^Name/), ' edited'); await user.type(screen.getByLabelText(/^Name/), ' edited');
await user.type(screen.getByLabelText(/^URL/), ' edited'); await user.type(screen.getByLabelText(/^URL/), ' edited');
@@ -64,12 +65,10 @@ describe('<EditServer />', () => {
// await user.click(screen.getByRole('button', { name: 'Save' })); // await user.click(screen.getByRole('button', { name: 'Save' }));
fireEvent.submit(screen.getByRole('form')); fireEvent.submit(screen.getByRole('form'));
expect(editServerMock).toHaveBeenCalledWith('abc123', { expect(store.getState().servers[defaultSelectedServer.id]).toEqual(expect.objectContaining({
name: 'the_name edited', name: 'the_name edited',
url: 'the_url edited', url: 'the_url edited',
apiKey: 'the_api_key', }));
forwardCredentials: false,
});
// After saving we go back, to the first route from history's initialEntries // After saving we go back, to the first route from history's initialEntries
expect(history.location.pathname).toEqual('/foo'); expect(history.location.pathname).toEqual('/foo');
@@ -78,16 +77,15 @@ describe('<EditServer />', () => {
it.each([ it.each([
{ forwardCredentials: true }, { forwardCredentials: true },
{ forwardCredentials: false }, { forwardCredentials: false },
])('edits advanced options - forward credentials', async (serverPartial) => { ])('edits advanced options - forward credentials', async ({ forwardCredentials }) => {
const { user } = setUp({ ...defaultSelectedServer, ...serverPartial }); const { user, store } = setUp({ ...defaultSelectedServer, forwardCredentials });
await user.click(screen.getByText('Advanced options')); await user.click(screen.getByText('Advanced options'));
await user.click(screen.getByLabelText('Forward credentials to this server on every request.')); await user.click(screen.getByLabelText('Forward credentials to this server on every request.'));
fireEvent.submit(screen.getByRole('form')); fireEvent.submit(screen.getByRole('form'));
expect(editServerMock).toHaveBeenCalledWith('abc123', expect.objectContaining({ await waitFor(() => expect(store.getState().servers[defaultSelectedServer.id]).toEqual(expect.objectContaining({
forwardCredentials: !serverPartial.forwardCredentials, forwardCredentials: !forwardCredentials,
})); })));
}); });
}); });

View File

@@ -5,7 +5,7 @@ import type { ServersMap, ServerWithId } from '../../src/servers/data';
import { ManageServersFactory } from '../../src/servers/ManageServers'; import { ManageServersFactory } from '../../src/servers/ManageServers';
import type { ServersExporter } from '../../src/servers/services/ServersExporter'; import type { ServersExporter } from '../../src/servers/services/ServersExporter';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<ManageServers />', () => { describe('<ManageServers />', () => {
const exportServers = vi.fn(); const exportServers = vi.fn();
@@ -15,15 +15,15 @@ describe('<ManageServers />', () => {
ServersExporter: serversExporter, ServersExporter: serversExporter,
ImportServersBtn: () => <span>ImportServersBtn</span>, ImportServersBtn: () => <span>ImportServersBtn</span>,
useTimeoutToggle, useTimeoutToggle,
ManageServersRow: ({ hasAutoConnect }: { hasAutoConnect: boolean }) => (
<tr><td>ManageServersRow {hasAutoConnect ? '[YES]' : '[NO]'}</td></tr>
),
})); }));
const createServerMock = (value: string, autoConnect = false) => fromPartial<ServerWithId>( const createServerMock = (value: string, autoConnect = false) => fromPartial<ServerWithId>(
{ id: value, name: value, url: value, autoConnect }, { id: value, name: value, url: value, autoConnect },
); );
const setUp = (servers: ServersMap = {}) => renderWithEvents( const setUp = (servers: ServersMap = {}) => renderWithStore(
<MemoryRouter><ManageServers servers={servers} /></MemoryRouter>, <MemoryRouter><ManageServers /></MemoryRouter>,
{
initialState: { servers },
},
); );
it('passes a11y checks', () => checkAccessibility(setUp({ it('passes a11y checks', () => checkAccessibility(setUp({
@@ -42,20 +42,22 @@ describe('<ManageServers />', () => {
await user.clear(screen.getByPlaceholderText('Search...')); await user.clear(screen.getByPlaceholderText('Search...'));
await user.type(screen.getByPlaceholderText('Search...'), searchTerm); await user.type(screen.getByPlaceholderText('Search...'), searchTerm);
}; };
// Add one for the header row
const expectRows = (amount: number) => expect(screen.getAllByRole('row')).toHaveLength(amount + 1);
expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(3); expectRows(3);
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument(); expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
await search('foo'); await search('foo');
await waitFor(() => expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(1)); await waitFor(() => expectRows(1));
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument(); expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
await search('Ba'); await search('Ba');
await waitFor(() => expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(2)); await waitFor(() => expectRows(2));
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument(); expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
await search('invalid'); await search('invalid');
await waitFor(() => expect(screen.queryByText(/^ManageServersRow/)).not.toBeInTheDocument()); await waitFor(() => expectRows(1));
expect(screen.getByText('No servers found.')).toBeInTheDocument(); expect(screen.getByText('No servers found.')).toBeInTheDocument();
}); });
@@ -67,11 +69,9 @@ describe('<ManageServers />', () => {
expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCols); expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCols);
if (server.autoConnect) { if (server.autoConnect) {
expect(screen.getByText(/\[YES]/)).toBeInTheDocument(); expect(screen.getByTestId('auto-connect')).toBeInTheDocument();
expect(screen.queryByText(/\[NO]/)).not.toBeInTheDocument();
} else { } else {
expect(screen.queryByText(/\[YES]/)).not.toBeInTheDocument(); expect(screen.queryByTestId('auto-connect')).not.toBeInTheDocument();
expect(screen.getByText(/\[NO]/)).toBeInTheDocument();
} }
}); });

View File

@@ -1,22 +1,19 @@
import { Table } from '@shlinkio/shlink-frontend-kit'; import { Table } from '@shlinkio/shlink-frontend-kit';
import { render, screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import type { ServerWithId } from '../../src/servers/data'; import type { ServerWithId } from '../../src/servers/data';
import { ManageServersRowFactory } from '../../src/servers/ManageServersRow'; import { ManageServersRow } from '../../src/servers/ManageServersRow';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest';
describe('<ManageServersRow />', () => { describe('<ManageServersRow />', () => {
const ManageServersRow = ManageServersRowFactory(fromPartial({
ManageServersRowDropdown: () => <span>ManageServersRowDropdown</span>,
}));
const server: ServerWithId = { const server: ServerWithId = {
name: 'My server', name: 'My server',
url: 'https://example.com', url: 'https://example.com',
apiKey: '123', apiKey: '123',
id: 'abc', id: 'abc',
}; };
const setUp = (hasAutoConnect = false, autoConnect = false) => render( const setUp = (hasAutoConnect = false, autoConnect = false) => renderWithStore(
<MemoryRouter> <MemoryRouter>
<Table header={<Table.Row />}> <Table header={<Table.Row />}>
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} /> <ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
@@ -34,9 +31,9 @@ describe('<ManageServersRow />', () => {
expect(screen.getAllByRole('cell')).toHaveLength(expectedCols); expect(screen.getAllByRole('cell')).toHaveLength(expectedCols);
}); });
it('renders a dropdown', () => { it('renders an options dropdown', () => {
setUp(); setUp();
expect(screen.getByText('ManageServersRowDropdown')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Options' })).toBeInTheDocument();
}); });
it.each([ it.each([

View File

@@ -3,23 +3,22 @@ import type { UserEvent } from '@testing-library/user-event';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import type { ServerWithId } from '../../src/servers/data'; import type { ServerWithId } from '../../src/servers/data';
import { ManageServersRowDropdownFactory } from '../../src/servers/ManageServersRowDropdown'; import { ManageServersRowDropdown } from '../../src/servers/ManageServersRowDropdown';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<ManageServersRowDropdown />', () => { describe('<ManageServersRowDropdown />', () => {
const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({
DeleteServerModal: ({ open }: { open: boolean }) => (
<span>DeleteServerModal {open ? '[OPEN]' : '[CLOSED]'}</span>
),
}));
const setAutoConnect = vi.fn();
const setUp = (autoConnect = false) => { const setUp = (autoConnect = false) => {
const server = fromPartial<ServerWithId>({ id: 'abc123', autoConnect }); const server = fromPartial<ServerWithId>({ id: 'abc123', autoConnect });
return renderWithEvents( return renderWithStore(
<MemoryRouter> <MemoryRouter>
<ManageServersRowDropdown setAutoConnect={setAutoConnect} server={server} /> <ManageServersRowDropdown server={server} />
</MemoryRouter>, </MemoryRouter>,
{
initialState: {
servers: { [server.id]: server },
},
},
); );
}; };
const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button')); const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button'));
@@ -44,26 +43,24 @@ describe('<ManageServersRowDropdown />', () => {
expect(screen.getByRole('menuitem', { name: 'Edit server' })).toHaveAttribute('href', '/server/abc123/edit'); expect(screen.getByRole('menuitem', { name: 'Edit server' })).toHaveAttribute('href', '/server/abc123/edit');
}); });
it('allows toggling auto-connect', async () => { it.each([true, false])('allows toggling auto-connect', async (autoConnect) => {
const { user } = setUp(); const { user, store } = setUp(autoConnect);
expect(setAutoConnect).not.toHaveBeenCalled();
await toggleDropdown(user); await toggleDropdown(user);
await user.click(screen.getByRole('menuitem', { name: 'Auto-connect' })); await user.click(screen.getByRole('menuitem', { name: autoConnect ? 'Do not auto-connect' : 'Auto-connect' }));
expect(setAutoConnect).toHaveBeenCalledWith(expect.objectContaining({ id: 'abc123' }), true);
expect(Object.values(store.getState().servers)[0].autoConnect).toEqual(!autoConnect);
}); });
it('renders deletion modal', async () => { it('renders deletion modal', async () => {
const { user } = setUp(); const { user } = setUp();
expect(screen.queryByText('DeleteServerModal [OPEN]')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByText('DeleteServerModal [CLOSED]')).toBeInTheDocument();
await toggleDropdown(user); await toggleDropdown(user);
await user.click(screen.getByRole('menuitem', { name: 'Remove server' })); await user.click(screen.getByRole('menuitem', { name: 'Remove server' }));
expect(screen.getByText('DeleteServerModal [OPEN]')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.queryByText('DeleteServerModal [CLOSED]')).not.toBeInTheDocument();
}); });
it.each([[true], [false]])('renders expected size and icon', (autoConnect) => { it.each([[true], [false]])('renders expected size and icon', (autoConnect) => {

View File

@@ -15,11 +15,11 @@ describe('<ServersDropdown />', () => {
const setUp = (servers: ServersMap = fallbackServers) => renderWithStore( const setUp = (servers: ServersMap = fallbackServers) => renderWithStore(
<MemoryRouter> <MemoryRouter>
<ul role="menu"> <ul role="menu">
<ServersDropdown servers={servers} /> <ServersDropdown />
</ul> </ul>
</MemoryRouter>, </MemoryRouter>,
{ {
initialState: { selectedServer: null }, initialState: { selectedServer: null, servers },
}, },
); );

View File

@@ -27,6 +27,7 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
class="svg-inline--fa fa-check text-lm-brand dark:text-dm-brand" class="svg-inline--fa fa-check text-lm-brand dark:text-dm-brand"
data-icon="check" data-icon="check"
data-prefix="fas" data-prefix="fas"
data-testid="auto-connect"
role="img" role="img"
viewBox="0 0 448 512" viewBox="0 0 448 512"
> >
@@ -56,9 +57,32 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
<td <td
class="border-lm-border dark:border-dm-border p-2 block lg:table-cell not-last:border-b-1 lg:border-b-1 before:lg:hidden before:content-[attr(data-column)] before:font-bold before:mr-1 text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0" class="border-lm-border dark:border-dm-border p-2 block lg:table-cell not-last:border-b-1 lg:border-b-1 before:lg:hidden before:content-[attr(data-column)] before:font-bold before:mr-1 text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0"
> >
<span> <div
ManageServersRowDropdown class="relative inline-block"
</span> >
<button
aria-controls="_r_o_"
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="flex items-center rounded-md focus-ring cursor-pointer border border-lm-border dark:border-dm-border bg-lm-primary dark:bg-dm-primary group-[&]/card:bg-lm-input group-[&]/card:dark:bg-dm-input px-3 py-1.5 gap-x-2"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-ellipsis-vertical fa-width-auto"
data-icon="ellipsis-vertical"
data-prefix="fas"
role="img"
viewBox="0 0 128 512"
>
<path
d="M64 144a56 56 0 1 1 0-112 56 56 0 1 1 0 112zm0 224c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm56-112c0 30.9-25.1 56-56 56s-56-25.1-56-56 25.1-56 56-56 56 25.1 56 56z"
fill="currentColor"
/>
</svg>
</button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -108,9 +132,32 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
<td <td
class="border-lm-border dark:border-dm-border p-2 block lg:table-cell not-last:border-b-1 lg:border-b-1 before:lg:hidden before:content-[attr(data-column)] before:font-bold before:mr-1 text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0" class="border-lm-border dark:border-dm-border p-2 block lg:table-cell not-last:border-b-1 lg:border-b-1 before:lg:hidden before:content-[attr(data-column)] before:font-bold before:mr-1 text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0"
> >
<span> <div
ManageServersRowDropdown class="relative inline-block"
</span> >
<button
aria-controls="_r_t_"
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="flex items-center rounded-md focus-ring cursor-pointer border border-lm-border dark:border-dm-border bg-lm-primary dark:bg-dm-primary group-[&]/card:bg-lm-input group-[&]/card:dark:bg-dm-input px-3 py-1.5 gap-x-2"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-ellipsis-vertical fa-width-auto"
data-icon="ellipsis-vertical"
data-prefix="fas"
role="img"
viewBox="0 0 128 512"
>
<path
d="M64 144a56 56 0 1 1 0-112 56 56 0 1 1 0 112zm0 224c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm56-112c0 30.9-25.1 56-56 56s-56-25.1-56-56 25.1-56 56-56 56 25.1 56 56z"
fill="currentColor"
/>
</svg>
</button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -6,7 +6,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
class="relative inline-block" class="relative inline-block"
> >
<button <button
aria-controls="_r_1h_" aria-controls="_r_1v_"
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-label="Options" aria-label="Options"
@@ -28,10 +28,6 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
</svg> </svg>
</button> </button>
</div> </div>
<span>
DeleteServerModal
[CLOSED]
</span>
</div> </div>
`; `;
@@ -41,7 +37,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
class="relative inline-block" class="relative inline-block"
> >
<button <button
aria-controls="_r_1l_" aria-controls="_r_23_"
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-label="Options" aria-label="Options"
@@ -63,9 +59,5 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
</svg> </svg>
</button> </button>
</div> </div>
<span>
DeleteServerModal
[CLOSED]
</span>
</div> </div>
`; `;

View File

@@ -6,22 +6,19 @@ import type {
import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn'; import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn';
import type { ServersImporter } from '../../../src/servers/services/ServersImporter'; import type { ServersImporter } from '../../../src/servers/services/ServersImporter';
import { checkAccessibility } from '../../__helpers__/accessibility'; import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithEvents } from '../../__helpers__/setUpTest'; import { renderWithStore } from '../../__helpers__/setUpTest';
describe('<ImportServersBtn />', () => { describe('<ImportServersBtn />', () => {
const csvFile = new File([''], 'servers.csv', { type: 'text/csv' }); const csvFile = new File([''], 'servers.csv', { type: 'text/csv' });
const onImportMock = vi.fn(); const onImportMock = vi.fn();
const createServersMock = vi.fn();
const importServersFromFile = vi.fn().mockResolvedValue([]); const importServersFromFile = vi.fn().mockResolvedValue([]);
const serversImporterMock = fromPartial<ServersImporter>({ importServersFromFile }); const serversImporterMock = fromPartial<ServersImporter>({ importServersFromFile });
const ImportServersBtn = ImportServersBtnFactory(fromPartial({ ServersImporter: serversImporterMock })); const ImportServersBtn = ImportServersBtnFactory(fromPartial({ ServersImporter: serversImporterMock }));
const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) => renderWithEvents( const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) => renderWithStore(
<ImportServersBtn <ImportServersBtn {...props} onImport={onImportMock} />,
servers={servers} {
{...props} initialState: { servers },
createServers={createServersMock} },
onImport={onImportMock}
/>,
); );
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
@@ -57,11 +54,8 @@ describe('<ImportServersBtn />', () => {
it('imports servers when file input changes', async () => { it('imports servers when file input changes', async () => {
const { user } = setUp(); const { user } = setUp();
const input = screen.getByTestId('csv-file-input'); await user.upload(screen.getByTestId('csv-file-input'), csvFile);
await user.upload(input, csvFile);
expect(importServersFromFile).toHaveBeenCalledTimes(1); expect(importServersFromFile).toHaveBeenCalledTimes(1);
expect(createServersMock).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
@@ -78,26 +72,27 @@ describe('<ImportServersBtn />', () => {
id: 'existingserver-s.test', id: 'existingserver-s.test',
}; };
const newServer: ServerData = { name: 'newServer', url: 'http://s.test/newUrl', apiKey: 'newApiKey' }; const newServer: ServerData = { name: 'newServer', url: 'http://s.test/newUrl', apiKey: 'newApiKey' };
const { user } = setUp({}, { [existingServer.id]: existingServer }); const { user, store } = setUp({}, { [existingServer.id]: existingServer });
importServersFromFile.mockResolvedValue([existingServer, newServer]); importServersFromFile.mockResolvedValue([existingServerData, newServer]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.upload(screen.getByTestId('csv-file-input'), csvFile); await user.upload(screen.getByTestId('csv-file-input'), csvFile);
// Once the file is uploaded, non-duplicated servers are immediately created // Once the file is uploaded, non-duplicated servers are immediately created
expect(createServersMock).toHaveBeenCalledExactlyOnceWith([expect.objectContaining(newServer)]); const { servers } = store.getState();
expect(Object.keys(servers)).toHaveLength(2);
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: btnName })); await user.click(screen.getByRole('button', { name: btnName }));
// If duplicated servers are saved, there's one extra call // If duplicated servers are saved, there's one extra server creation
if (savesDuplicatedServers) { if (savesDuplicatedServers) {
expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]); const { servers } = store.getState();
expect(Object.keys(servers)).toHaveLength(3);
} }
// On import is called only once, no matter what // On import is called only once, no matter what
expect(onImportMock).toHaveBeenCalledOnce(); expect(onImportMock).toHaveBeenCalledOnce();
expect(createServersMock).toHaveBeenCalledTimes(savesDuplicatedServers ? 2 : 1);
}); });
}); });

View File

@@ -2,18 +2,17 @@ import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data'; import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data';
import { ServerErrorFactory } from '../../../src/servers/helpers/ServerError'; import { ServerError } from '../../../src/servers/helpers/ServerError';
import { checkAccessibility } from '../../__helpers__/accessibility'; import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithStore } from '../../__helpers__/setUpTest'; import { renderWithStore } from '../../__helpers__/setUpTest';
describe('<ServerError />', () => { describe('<ServerError />', () => {
const ServerError = ServerErrorFactory(fromPartial({ DeleteServerButton: () => null }));
const setUp = (selectedServer: SelectedServer) => renderWithStore( const setUp = (selectedServer: SelectedServer) => renderWithStore(
<MemoryRouter> <MemoryRouter>
<ServerError servers={{}} /> <ServerError />
</MemoryRouter>, </MemoryRouter>,
{ {
initialState: { selectedServer }, initialState: { selectedServer, servers: {} },
}, },
); );