Refactor DI approach for components

This commit is contained in:
Alejandro Celaya 2023-09-05 09:08:42 +02:00
parent 046f79270a
commit 6926afbac1
30 changed files with 371 additions and 234 deletions

View file

@ -20,7 +20,7 @@ type AppProps = {
appUpdated: boolean; appUpdated: boolean;
}; };
type AppDependencies = { type AppDeps = {
MainHeader: FC; MainHeader: FC;
Home: FC; Home: FC;
ShlinkWebComponentContainer: FC; ShlinkWebComponentContainer: FC;
@ -31,7 +31,7 @@ type AppDependencies = {
ShlinkVersionsContainer: FC; ShlinkVersionsContainer: FC;
}; };
const App: FCWithDeps<AppProps, AppDependencies> = ( const App: FCWithDeps<AppProps, AppDeps> = (
{ fetchServers, servers, settings, appUpdated, resetAppUpdate }, { fetchServers, servers, settings, appUpdated, resetAppUpdate },
) => { ) => {
const { const {

View file

@ -1,17 +1,19 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit'; import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { ReactNode } from 'react'; import type { PropsWithChildren, ReactNode } from 'react';
import { Component } from 'react'; import { Component } from 'react';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
interface ErrorHandlerState { type ErrorHandlerProps = PropsWithChildren<{
hasError: boolean; location?: typeof window.location;
} console?: typeof window.console;
}>;
export const ErrorHandler = ( type ErrorHandlerState = {
{ location }: Window, hasError: boolean;
{ error }: Console, };
) => class extends Component<any, ErrorHandlerState> {
public constructor(props: object) { export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState> {
public constructor(props: ErrorHandlerProps) {
super(props); super(props);
this.state = { hasError: false }; this.state = { hasError: false };
} }
@ -21,13 +23,14 @@ export const ErrorHandler = (
} }
public componentDidCatch(e: Error): void { public componentDidCatch(e: Error): void {
if (process.env.NODE_ENV !== 'development') { const { console = globalThis.console } = this.props;
error(e); console.error(e);
}
} }
public render(): ReactNode { public render(): ReactNode {
const { hasError } = this.state; const { hasError } = this.state;
const { location = globalThis.location } = this.props;
if (hasError) { if (hasError) {
return ( return (
<div className="home"> <div className="home">
@ -44,4 +47,4 @@ export const ErrorHandler = (
const { children } = this.props; const { children } = this.props;
return children; return children;
} }
}; }

View file

@ -6,10 +6,17 @@ import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap'; import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss'; import './MainHeader.scss';
export const MainHeader = (ServersDropdown: FC) => () => { type MainHeaderDeps = {
ServersDropdown: FC;
};
const MainHeader: FCWithDeps<{}, MainHeaderDeps> = () => {
const { ServersDropdown } = useDependencies(MainHeader);
const [isNotCollapsed, toggleCollapse, , collapse] = useToggle(); const [isNotCollapsed, toggleCollapse, , collapse] = useToggle();
const location = useLocation(); const location = useLocation();
const { pathname } = location; const { pathname } = location;
@ -43,3 +50,5 @@ export const MainHeader = (ServersDropdown: FC) => () => {
</Navbar> </Navbar>
); );
}; };
export const MainHeaderFactory = componentFactory(MainHeader, ['ServersDropdown']);

View file

@ -2,7 +2,7 @@ import type { FC, PropsWithChildren } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
export const ScrollToTop: FC<PropsWithChildren<unknown>> = ({ children }) => { export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {

View file

@ -1,20 +1,34 @@
import type { Settings, ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component'; import type { Settings, ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import type { FC } from 'react'; import type { FC } from 'react';
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder'; import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import type { WithSelectedServerProps } from '../servers/helpers/withSelectedServer';
import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
interface ShlinkWebComponentContainerProps { type ShlinkWebComponentContainerProps = WithSelectedServerProps & {
settings: Settings; settings: Settings;
} };
export const ShlinkWebComponentContainer = ( type ShlinkWebComponentContainerDeps = {
buildShlinkApiClient: ShlinkApiClientBuilder, buildShlinkApiClient: ShlinkApiClientBuilder,
tagColorsStorage: TagColorsStorage, TagColorsStorage: TagColorsStorage,
ShlinkWebComponent: ShlinkWebComponentType, ShlinkWebComponent: ShlinkWebComponentType,
ServerError: FC, ServerError: FC,
) => withSelectedServer<ShlinkWebComponentContainerProps>(({ selectedServer, settings }) => { };
const ShlinkWebComponentContainer: FCWithDeps<
ShlinkWebComponentContainerProps,
ShlinkWebComponentContainerDeps
> = withSelectedServer(({ selectedServer, settings }) => {
const {
buildShlinkApiClient,
TagColorsStorage: tagColorsStorage,
ShlinkWebComponent,
ServerError,
} = useDependencies(ShlinkWebComponentContainer);
const selectedServerIsReachable = isReachableServer(selectedServer); const selectedServerIsReachable = isReachableServer(selectedServer);
const routesPrefix = selectedServerIsReachable ? `/server/${selectedServer.id}` : ''; const routesPrefix = selectedServerIsReachable ? `/server/${selectedServer.id}` : '';
@ -34,4 +48,11 @@ export const ShlinkWebComponentContainer = (
)} )}
/> />
); );
}, ServerError); });
export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [
'buildShlinkApiClient',
'TagColorsStorage',
'ShlinkWebComponent',
'ServerError',
]);

View file

@ -5,10 +5,10 @@ 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 { MainHeader } from '../MainHeader'; import { MainHeaderFactory } from '../MainHeader';
import { ScrollToTop } from '../ScrollToTop'; import { ScrollToTop } from '../ScrollToTop';
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer'; import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
import { ShlinkWebComponentContainer } from '../ShlinkWebComponentContainer'; import { ShlinkWebComponentContainerFactory } from '../ShlinkWebComponentContainer';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services // Services
@ -20,25 +20,18 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('ScrollToTop', () => ScrollToTop); bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown'); 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'], ['resetSelectedServer'])); bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
bottle.serviceFactory('ShlinkWebComponent', () => ShlinkWebComponent); bottle.serviceFactory('ShlinkWebComponent', () => ShlinkWebComponent);
bottle.serviceFactory( bottle.factory('ShlinkWebComponentContainer', ShlinkWebComponentContainerFactory);
'ShlinkWebComponentContainer',
ShlinkWebComponentContainer,
'buildShlinkApiClient',
'TagColorsStorage',
'ShlinkWebComponent',
'ServerError',
);
bottle.decorator('ShlinkWebComponentContainer', connect(['selectedServer', 'settings'], ['selectServer'])); bottle.decorator('ShlinkWebComponentContainer', connect(['selectedServer', 'settings'], ['selectServer']));
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer); bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer'])); bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer']));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console'); bottle.serviceFactory('ErrorHandler', () => ErrorHandler);
}; };

View file

@ -5,6 +5,8 @@ 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';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { TimeoutToggle } from '../utils/helpers/hooks'; import type { TimeoutToggle } from '../utils/helpers/hooks';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import type { ServerData, ServersMap, ServerWithId } from './data'; import type { ServerData, ServersMap, ServerWithId } from './data';
@ -14,10 +16,15 @@ import { ServerForm } from './helpers/ServerForm';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
interface CreateServerProps { type CreateServerProps = {
createServers: (servers: ServerWithId[]) => void; createServers: (servers: ServerWithId[]) => void;
servers: ServersMap; servers: ServersMap;
} };
type CreateServerDeps = {
ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle;
};
const ImportResult = ({ type }: { type: 'error' | 'success' }) => ( const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
<div className="mt-3"> <div className="mt-3">
@ -28,9 +35,8 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
</div> </div>
); );
export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTimeoutToggle: TimeoutToggle) => ( const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers, createServers }) => {
{ servers, createServers }: CreateServerProps, const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer);
) => {
const navigate = useNavigate(); const navigate = useNavigate();
const goBack = useGoBack(); const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length; const hasServers = !!Object.keys(servers).length;
@ -79,3 +85,5 @@ export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTim
</NoMenuLayout> </NoMenuLayout>
); );
}; };
export const CreateServerFactory = componentFactory(CreateServer, ['ImportServersBtn', 'useTimeoutToggle']);

View file

@ -3,6 +3,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit'; import { useToggle } from '@shlinkio/shlink-frontend-kit';
import classNames from 'classnames'; import classNames from 'classnames';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } 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 type { DeleteServerModalProps } from './DeleteServerModal';
@ -12,9 +14,14 @@ export type DeleteServerButtonProps = PropsWithChildren<{
textClassName?: string; textClassName?: string;
}>; }>;
export const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => ( type DeleteServerButtonDeps = {
DeleteServerModal: FC<DeleteServerModalProps>;
};
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = (
{ server, className, children, textClassName }, { server, className, children, textClassName },
) => { ) => {
const { DeleteServerModal } = useDependencies(DeleteServerButton);
const [isModalOpen, , showModal, hideModal] = useToggle(); const [isModalOpen, , showModal, hideModal] = useToggle();
return ( return (
@ -28,3 +35,5 @@ export const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>
</> </>
); );
}; };
export const DeleteServerButtonFactory = componentFactory(DeleteServerButton, ['DeleteServerModal']);

View file

@ -1,17 +1,24 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory } from '../container/utils';
import { useGoBack, useParsedQuery } from '../utils/helpers/hooks'; import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
import type { ServerData } from './data'; import type { ServerData } from './data';
import { isServerWithId } from './data'; import { isServerWithId } from './data';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import type { WithSelectedServerProps } from './helpers/withSelectedServer';
import { withSelectedServer } from './helpers/withSelectedServer'; import { withSelectedServer } from './helpers/withSelectedServer';
interface EditServerProps { type EditServerProps = WithSelectedServerProps & {
editServer: (serverId: string, serverData: ServerData) => void; editServer: (serverId: string, serverData: ServerData) => void;
} };
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(( type EditServerDeps = {
ServerError: FC;
};
const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServer((
{ editServer, selectedServer, selectServer }, { editServer, selectedServer, selectServer },
) => { ) => {
const goBack = useGoBack(); const goBack = useGoBack();
@ -39,4 +46,6 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
</ServerForm> </ServerForm>
</NoMenuLayout> </NoMenuLayout>
); );
}, ServerError); });
export const EditServerFactory = componentFactory(EditServer, ['ServerError']);

View file

@ -6,24 +6,34 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button, Row } from 'reactstrap'; import { Button, Row } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { TimeoutToggle } from '../utils/helpers/hooks'; import type { TimeoutToggle } from '../utils/helpers/hooks';
import type { ServersMap } from './data'; import type { ServersMap } from './data';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn'; import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import type { ManageServersRowProps } from './ManageServersRow'; import type { ManageServersRowProps } from './ManageServersRow';
import type { ServersExporter } from './services/ServersExporter'; import type { ServersExporter } from './services/ServersExporter';
interface ManageServersProps { type ManageServersProps = {
servers: ServersMap; servers: ServersMap;
} };
type ManageServersDeps = {
ServersExporter: ServersExporter;
ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle;
ManageServersRow: FC<ManageServersRowProps>;
};
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
export const ManageServers = ( const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ servers }) => {
serversExporter: ServersExporter, const {
ImportServersBtn: FC<ImportServersBtnProps>, ServersExporter: serversExporter,
useTimeoutToggle: TimeoutToggle, ImportServersBtn,
ManageServersRow: FC<ManageServersRowProps>, useTimeoutToggle,
): FC<ManageServersProps> => ({ servers }) => { ManageServersRow,
} = useDependencies(ManageServers);
const allServers = Object.values(servers); const allServers = Object.values(servers);
const [serversList, setServersList] = useState(allServers); const [serversList, setServersList] = useState(allServers);
const filterServers = (searchTerm: string) => setServersList( const filterServers = (searchTerm: string) => setServersList(
@ -83,3 +93,10 @@ export const ManageServers = (
</NoMenuLayout> </NoMenuLayout>
); );
}; };
export const ManageServersFactory = componentFactory(ManageServers, [
'ServersExporter',
'ImportServersBtn',
'useTimeoutToggle',
'ManageServersRow',
]);

View file

@ -3,36 +3,46 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
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 type { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
export interface ManageServersRowProps { export type ManageServersRowProps = {
server: ServerWithId; server: ServerWithId;
hasAutoConnect: boolean; hasAutoConnect: boolean;
} };
export const ManageServersRow = ( type ManageServersRowDeps = {
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>, ManageServersRowDropdown: FC<ManageServersRowDropdownProps>;
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => ( };
<tr className="responsive-table__row">
{hasAutoConnect && ( const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps> = ({ server, hasAutoConnect }) => {
<td className="responsive-table__cell" data-th="Auto-connect"> const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
{server.autoConnect && (
<> return (
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" /> <tr className="responsive-table__row">
<UncontrolledTooltip target="autoConnectIcon" placement="right"> {hasAutoConnect && (
Auto-connect to this server <td className="responsive-table__cell" data-th="Auto-connect">
</UncontrolledTooltip> {server.autoConnect && (
</> <>
)} <FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
<UncontrolledTooltip target="autoConnectIcon" placement="right">
Auto-connect to this server
</UncontrolledTooltip>
</>
)}
</td>
)}
<th className="responsive-table__cell" data-th="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link>
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-end">
<ManageServersRowDropdown server={server} />
</td> </td>
)} </tr>
<th className="responsive-table__cell" data-th="Name"> );
<Link to={`/server/${server.id}`}>{server.name}</Link> };
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td> export const ManageServersRowFactory = componentFactory(ManageServersRow, ['ManageServersRowDropdown']);
<td className="responsive-table__cell text-end">
<ManageServersRowDropdown server={server} />
</td>
</tr>
);

View file

@ -10,20 +10,27 @@ import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap'; import { DropdownItem } from 'reactstrap';
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 type { DeleteServerModalProps } from './DeleteServerModal';
export interface ManageServersRowDropdownProps { export type ManageServersRowDropdownProps = {
server: ServerWithId; server: ServerWithId;
} };
interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownProps { type ManageServersRowDropdownConnectProps = ManageServersRowDropdownProps & {
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void; setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
} };
export const ManageServersRowDropdown = ( type ManageServersRowDropdownDeps = {
DeleteServerModal: FC<DeleteServerModalProps>, DeleteServerModal: FC<DeleteServerModalProps>
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => { };
const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps, ManageServersRowDropdownDeps> = (
{ server, setAutoConnect },
) => {
const { DeleteServerModal } = useDependencies(ManageServersRowDropdown);
const [isModalOpen,, showModal, hideModal] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const serverUrl = `/server/${server.id}`; const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server; const { autoConnect: isAutoConnect } = server;
@ -49,3 +56,5 @@ export const ManageServersRowDropdown = (
</RowDropdownBtn> </RowDropdownBtn>
); );
}; };
export const ManageServersRowDropdownFactory = componentFactory(ManageServersRowDropdown, ['DeleteServerModal']);

View file

@ -2,9 +2,11 @@ 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 } from 'ramda'; import { complement } from 'ramda';
import type { ChangeEvent, FC, PropsWithChildren } from 'react'; import type { ChangeEvent, PropsWithChildren } from 'react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap'; import { Button, UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
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';
@ -16,15 +18,19 @@ export type ImportServersBtnProps = PropsWithChildren<{
className?: string; className?: string;
}>; }>;
interface ImportServersBtnConnectProps extends ImportServersBtnProps { type ImportServersBtnConnectProps = ImportServersBtnProps & {
createServers: (servers: ServerData[]) => void; createServers: (servers: ServerData[]) => void;
servers: ServersMap; servers: ServersMap;
} };
type ImportServersBtnDeps = {
ServersImporter: ServersImporter
};
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 = (serversImporter: ServersImporter): FC<ImportServersBtnConnectProps> => ({ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
createServers, createServers,
servers, servers,
children, children,
@ -33,6 +39,7 @@ export const ImportServersBtn = (serversImporter: ServersImporter): FC<ImportSer
tooltipPlacement = 'bottom', tooltipPlacement = 'bottom',
className = '', className = '',
}) => { }) => {
const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn);
const ref = useElementRef<HTMLInputElement>(); const ref = useElementRef<HTMLInputElement>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
@ -60,7 +67,7 @@ export const ImportServersBtn = (serversImporter: ServersImporter): FC<ImportSer
(target as { value: string | null }).value = null; // eslint-disable-line no-param-reassign (target as { value: string | null }).value = null; // eslint-disable-line no-param-reassign
}) })
.catch(onImportError), .catch(onImportError),
[create, onImportError, servers, showModal], [create, onImportError, servers, serversImporter, showModal],
); );
const createAllServers = useCallback(() => { const createAllServers = useCallback(() => {
@ -92,3 +99,5 @@ export const ImportServersBtn = (serversImporter: ServersImporter): FC<ImportSer
</> </>
); );
}; };
export const ImportServersBtnFactory = componentFactory(ImportServersBtn, ['ServersImporter']);

View file

@ -2,46 +2,56 @@ import { Message } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { NoMenuLayout } from '../../common/NoMenuLayout'; import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import type { SelectedServer, ServersMap } from '../data'; import type { SelectedServer, ServersMap } from '../data';
import { isServerWithId } from '../data'; import { isServerWithId } from '../data';
import type { DeleteServerButtonProps } from '../DeleteServerButton'; import type { DeleteServerButtonProps } from '../DeleteServerButton';
import { ServersListGroup } from '../ServersListGroup'; import { ServersListGroup } from '../ServersListGroup';
import './ServerError.scss'; import './ServerError.scss';
interface ServerErrorProps { type ServerErrorProps = {
servers: ServersMap; servers: ServersMap;
selectedServer: SelectedServer; selectedServer: SelectedServer;
} };
type ServerErrorDeps = {
DeleteServerButton: FC<DeleteServerButtonProps>;
};
const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, selectedServer }) => {
const { DeleteServerButton } = useDependencies(ServerError);
return (
<NoMenuLayout>
<div className="server-error__container flex-column">
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && (
<>
<p>Oops! Could not connect to this Shlink server.</p>
Make sure you have internet connection, and the server is properly configured and on-line.
</>
)}
</Message>
<ServersListGroup servers={Object.values(servers)}>
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</ServersListGroup>
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
{ servers, selectedServer },
) => (
<NoMenuLayout>
<div className="server-error__container flex-column">
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && ( {isServerWithId(selectedServer) && (
<> <div className="container mt-3 mt-md-5">
<p>Oops! Could not connect to this Shlink server.</p> <h5>
Make sure you have internet connection, and the server is properly configured and on-line. Alternatively, if you think you may have miss-configured this server, you
</> can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</h5>
</div>
)} )}
</Message> </div>
</NoMenuLayout>
);
};
<ServersListGroup servers={Object.values(servers)}> export const ServerErrorFactory = componentFactory(ServerError, ['DeleteServerButton']);
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</ServersListGroup>
{isServerWithId(selectedServer) && (
<div className="container mt-3 mt-md-5">
<h5>
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</h5>
</div>
)}
</div>
</NoMenuLayout>
);

View file

@ -3,16 +3,25 @@ import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { NoMenuLayout } from '../../common/NoMenuLayout'; import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { useDependencies } from '../../container/utils';
import type { SelectedServer } from '../data'; import type { SelectedServer } from '../data';
import { isNotFoundServer } from '../data'; import { isNotFoundServer } from '../data';
interface WithSelectedServerProps { export type WithSelectedServerProps = {
selectServer: (serverId: string) => void; selectServer: (serverId: string) => void;
selectedServer: SelectedServer; selectedServer: SelectedServer;
} };
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) { type WithSelectedServerPropsDeps = {
return (props: WithSelectedServerProps & T) => { ServerError: FC;
};
export function withSelectedServer<T = {}>(
WrappedComponent: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps>,
) {
const ComponentWrapper: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps> = (props) => {
const { ServerError } = useDependencies(ComponentWrapper);
const params = useParams<{ serverId: string }>(); const params = useParams<{ serverId: string }>();
const { selectServer, selectedServer } = props; const { selectServer, selectedServer } = props;
@ -34,4 +43,5 @@ export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServ
return <WrappedComponent {...props} />; return <WrappedComponent {...props} />;
}; };
return ComponentWrapper;
} }

View file

@ -1,16 +1,16 @@
import type Bottle from 'bottlejs'; import type Bottle from 'bottlejs';
import { prop } from 'ramda'; import { prop } from 'ramda';
import type { ConnectDecorator } from '../../container/types'; import type { ConnectDecorator } from '../../container/types';
import { CreateServer } from '../CreateServer'; import { CreateServerFactory } from '../CreateServer';
import { DeleteServerButton } from '../DeleteServerButton'; import { DeleteServerButtonFactory } from '../DeleteServerButton';
import { DeleteServerModal } from '../DeleteServerModal'; import { DeleteServerModal } from '../DeleteServerModal';
import { EditServer } from '../EditServer'; import { EditServerFactory } from '../EditServer';
import { ImportServersBtn } from '../helpers/ImportServersBtn'; import { ImportServersBtnFactory } from '../helpers/ImportServersBtn';
import { ServerError } from '../helpers/ServerError'; import { ServerErrorFactory } from '../helpers/ServerError';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { ManageServers } from '../ManageServers'; import { ManageServersFactory } from '../ManageServers';
import { ManageServersRow } from '../ManageServersRow'; import { ManageServersRowFactory } from '../ManageServersRow';
import { ManageServersRowDropdown } from '../ManageServersRowDropdown'; import { ManageServersRowDropdownFactory } from '../ManageServersRowDropdown';
import { fetchServers } from '../reducers/remoteServers'; import { fetchServers } from '../reducers/remoteServers';
import { import {
resetSelectedServer, resetSelectedServer,
@ -24,27 +24,20 @@ import { ServersImporter } from './ServersImporter';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory( bottle.factory('ManageServers', ManageServersFactory);
'ManageServers',
ManageServers,
'ServersExporter',
'ImportServersBtn',
'useTimeoutToggle',
'ManageServersRow',
);
bottle.decorator('ManageServers', withoutSelectedServer); bottle.decorator('ManageServers', withoutSelectedServer);
bottle.decorator('ManageServers', connect(['selectedServer', 'servers'], ['resetSelectedServer'])); bottle.decorator('ManageServers', connect(['selectedServer', 'servers'], ['resetSelectedServer']));
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown'); bottle.factory('ManageServersRow', ManageServersRowFactory);
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal'); bottle.factory('ManageServersRowDropdown', ManageServersRowDropdownFactory);
bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect'])); bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect']));
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useTimeoutToggle'); bottle.factory('CreateServer', CreateServerFactory);
bottle.decorator('CreateServer', withoutSelectedServer); bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServers', 'resetSelectedServer'])); bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServers', 'resetSelectedServer']));
bottle.serviceFactory('EditServer', EditServer, 'ServerError'); bottle.factory('EditServer', EditServerFactory);
bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer'])); bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
bottle.serviceFactory('ServersDropdown', () => ServersDropdown); bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
@ -53,12 +46,12 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal); bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', connect(null, ['deleteServer'])); bottle.decorator('DeleteServerModal', connect(null, ['deleteServer']));
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal'); bottle.factory('DeleteServerButton', DeleteServerButtonFactory);
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter'); bottle.factory('ImportServersBtn', ImportServersBtnFactory);
bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers'])); bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers']));
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton'); bottle.factory('ServerError', ServerErrorFactory);
bottle.decorator('ServerError', connect(['servers', 'selectedServer'])); bottle.decorator('ServerError', connect(['servers', 'selectedServer']));
// Services // Services

View file

@ -2,6 +2,17 @@ import { NavPillItem, NavPills } from '@shlinkio/shlink-frontend-kit';
import type { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
type SettingsDeps = {
RealTimeUpdatesSettings: FC;
ShortUrlCreationSettings: FC;
ShortUrlsListSettings: FC;
UserInterfaceSettings: FC;
VisitsSettings: FC;
TagsSettings: FC;
};
const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => ( const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => (
<> <>
@ -9,26 +20,39 @@ const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => (
</> </>
); );
export const Settings = ( const Settings: FCWithDeps<{}, SettingsDeps> = () => {
RealTimeUpdates: FC, const {
ShortUrlCreation: FC, RealTimeUpdatesSettings: RealTimeUpdates,
ShortUrlsList: FC, ShortUrlCreationSettings: ShortUrlCreation,
UserInterface: FC, ShortUrlsListSettings: ShortUrlsList,
Visits: FC, UserInterfaceSettings: UserInterface,
Tags: FC, VisitsSettings: Visits,
) => () => ( TagsSettings: Tags,
<NoMenuLayout> } = useDependencies(Settings);
<NavPills className="mb-3">
<NavPillItem to="general">General</NavPillItem>
<NavPillItem to="short-urls">Short URLs</NavPillItem>
<NavPillItem to="other-items">Other items</NavPillItem>
</NavPills>
<Routes> return (
<Route path="general" element={<SettingsSections items={[<UserInterface />, <RealTimeUpdates />]} />} /> <NoMenuLayout>
<Route path="short-urls" element={<SettingsSections items={[<ShortUrlCreation />, <ShortUrlsList />]} />} /> <NavPills className="mb-3">
<Route path="other-items" element={<SettingsSections items={[<Tags />, <Visits />]} />} /> <NavPillItem to="general">General</NavPillItem>
<Route path="*" element={<Navigate replace to="general" />} /> <NavPillItem to="short-urls">Short URLs</NavPillItem>
</Routes> <NavPillItem to="other-items">Other items</NavPillItem>
</NoMenuLayout> </NavPills>
);
<Routes>
<Route path="general" element={<SettingsSections items={[<UserInterface />, <RealTimeUpdates />]} />} />
<Route path="short-urls" element={<SettingsSections items={[<ShortUrlCreation />, <ShortUrlsList />]} />} />
<Route path="other-items" element={<SettingsSections items={[<Tags />, <Visits />]} />} />
<Route path="*" element={<Navigate replace to="general" />} />
</Routes>
</NoMenuLayout>
);
};
export const SettingsFactory = componentFactory(Settings, [
'RealTimeUpdatesSettings',
'ShortUrlCreationSettings',
'ShortUrlsListSettings',
'UserInterfaceSettings',
'VisitsSettings',
'TagsSettings',
]);

View file

@ -11,7 +11,7 @@ import {
setVisitsSettings, setVisitsSettings,
toggleRealTimeUpdates, toggleRealTimeUpdates,
} from '../reducers/settings'; } from '../reducers/settings';
import { Settings } from '../Settings'; import { SettingsFactory } from '../Settings';
import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings'; import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
import { ShortUrlsListSettings } from '../ShortUrlsListSettings'; import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
import { TagsSettings } from '../TagsSettings'; import { TagsSettings } from '../TagsSettings';
@ -20,16 +20,7 @@ import { VisitsSettings } from '../VisitsSettings';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory( bottle.factory('Settings', SettingsFactory);
'Settings',
Settings,
'RealTimeUpdatesSettings',
'ShortUrlCreationSettings',
'ShortUrlsListSettings',
'UserInterfaceSettings',
'VisitsSettings',
'TagsSettings',
);
bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, ['resetSelectedServer'])); bottle.decorator('Settings', connect(null, ['resetSelectedServer']));

View file

@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { ErrorHandler as createErrorHandler } from '../../src/common/ErrorHandler'; import type { PropsWithChildren } from 'react';
import { ErrorHandler as BaseErrorHandler } from '../../src/common/ErrorHandler';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
const ComponentWithError = () => { const ComponentWithError = () => {
@ -9,18 +10,16 @@ const ComponentWithError = () => {
describe('<ErrorHandler />', () => { describe('<ErrorHandler />', () => {
const reload = vi.fn(); const reload = vi.fn();
const window = fromPartial<Window>({ const location = fromPartial<Window['location']>({ reload });
location: { reload },
});
const cons = fromPartial<Console>({ error: vi.fn() }); const cons = fromPartial<Console>({ error: vi.fn() });
const ErrorHandler = createErrorHandler(window, cons); const ErrorHandler = (props: PropsWithChildren) => <BaseErrorHandler console={cons} location={location} {...props} />;
beforeEach(() => { beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {}); // Silence react errors vi.spyOn(console, 'error').mockImplementation(() => {}); // Silence react errors
}); });
it('renders children when no error has occurred', () => { it('renders children when no error has occurred', () => {
render(<ErrorHandler children={<span>Foo</span>} />); render(<ErrorHandler><span>Foo</span></ErrorHandler>);
expect(screen.getByText('Foo')).toBeInTheDocument(); expect(screen.getByText('Foo')).toBeInTheDocument();
expect(screen.queryByText('Oops! This is awkward :S')).not.toBeInTheDocument(); expect(screen.queryByText('Oops! This is awkward :S')).not.toBeInTheDocument();
@ -28,14 +27,14 @@ describe('<ErrorHandler />', () => {
}); });
it('renders error page when error has occurred', () => { it('renders error page when error has occurred', () => {
render(<ErrorHandler children={<ComponentWithError />} />); render(<ErrorHandler><ComponentWithError /></ErrorHandler>);
expect(screen.getByText('Oops! This is awkward :S')).toBeInTheDocument(); expect(screen.getByText('Oops! This is awkward :S')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByRole('button')).toBeInTheDocument();
}); });
it('reloads page on button click', async () => { it('reloads page on button click', async () => {
const { user } = renderWithEvents(<ErrorHandler children={<ComponentWithError />} />); const { user } = renderWithEvents(<ErrorHandler><ComponentWithError /></ErrorHandler>);
expect(reload).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled();
await user.click(screen.getByRole('button')); await user.click(screen.getByRole('button'));

View file

@ -1,11 +1,14 @@
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom'; import { Router } from 'react-router-dom';
import { MainHeader as createMainHeader } from '../../src/common/MainHeader'; import { MainHeaderFactory } from '../../src/common/MainHeader';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<MainHeader />', () => { describe('<MainHeader />', () => {
const MainHeader = createMainHeader(() => <>ServersDropdown</>); const MainHeader = MainHeaderFactory(fromPartial({
ServersDropdown: () => <>ServersDropdown</>,
}));
const setUp = (pathname = '') => { const setUp = (pathname = '') => {
const history = createMemoryHistory(); const history = createMemoryHistory();
history.push(pathname); history.push(pathname);

View file

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ShlinkWebComponentContainer as createContainer } 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';
vi.mock('react-router-dom', async () => ({ vi.mock('react-router-dom', async () => ({
@ -10,12 +10,12 @@ vi.mock('react-router-dom', async () => ({
})); }));
describe('<ShlinkWebComponentContainer />', () => { describe('<ShlinkWebComponentContainer />', () => {
const ShlinkWebComponentContainer = createContainer( const ShlinkWebComponentContainer = ShlinkWebComponentContainerFactory(fromPartial({
vi.fn().mockReturnValue(fromPartial({})), buildShlinkApiClient: vi.fn().mockReturnValue(fromPartial({})),
fromPartial({}), TagColorsStorage: fromPartial({}),
() => <>ShlinkWebComponent</>, ShlinkWebComponent: () => <>ShlinkWebComponent</>,
() => <>ServerError</>, ServerError: () => <>ServerError</>,
); }));
const setUp = (selectedServer: SelectedServer) => render( const setUp = (selectedServer: SelectedServer) => render(
<ShlinkWebComponentContainer selectServer={vi.fn()} selectedServer={selectedServer} settings={{}} />, <ShlinkWebComponentContainer selectServer={vi.fn()} selectedServer={selectedServer} settings={{}} />,
); );

View file

@ -1,7 +1,7 @@
import { fireEvent, screen, waitFor } 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 { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { CreateServer as createCreateServer } 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 { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
@ -31,7 +31,10 @@ describe('<CreateServer />', () => {
callCount += 1; callCount += 1;
return result; return result;
}); });
const CreateServer = createCreateServer(() => <>ImportServersBtn</>, useTimeoutToggle); const CreateServer = CreateServerFactory(fromPartial({
ImportServersBtn: () => <>ImportServersBtn</>,
useTimeoutToggle,
}));
return renderWithEvents(<CreateServer createServers={createServersMock} servers={servers} />); return renderWithEvents(<CreateServer createServers={createServersMock} servers={servers} />);
}; };

View file

@ -1,13 +1,14 @@
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { DeleteServerButton as createDeleteServerButton } from '../../src/servers/DeleteServerButton'; import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton';
import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<DeleteServerButton />', () => { describe('<DeleteServerButton />', () => {
const DeleteServerButton = createDeleteServerButton( const DeleteServerButton = DeleteServerButtonFactory(fromPartial({
({ isOpen }) => <>DeleteServerModal {isOpen ? '[Open]' : '[Closed]'}</>, DeleteServerModal: ({ isOpen }: DeleteServerModalProps) => <>DeleteServerModal {isOpen ? '[Open]' : '[Closed]'}</>,
); }));
const setUp = (children?: ReactNode) => renderWithEvents( const setUp = (children?: ReactNode) => renderWithEvents(
<DeleteServerButton server={fromPartial({})} textClassName="button">{children}</DeleteServerButton>, <DeleteServerButton server={fromPartial({})} textClassName="button">{children}</DeleteServerButton>,
); );

View file

@ -2,7 +2,7 @@ import { fireEvent, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter, useNavigate } from 'react-router-dom'; import { MemoryRouter, useNavigate } from 'react-router-dom';
import type { ReachableServer, SelectedServer } from '../../src/servers/data'; import type { ReachableServer, SelectedServer } from '../../src/servers/data';
import { EditServer as editServerConstruct } from '../../src/servers/EditServer'; import { EditServerFactory } from '../../src/servers/EditServer';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
vi.mock('react-router-dom', async () => ({ vi.mock('react-router-dom', async () => ({
@ -20,7 +20,7 @@ describe('<EditServer />', () => {
url: 'the_url', url: 'the_url',
apiKey: 'the_api_key', apiKey: 'the_api_key',
}); });
const EditServer = editServerConstruct(ServerError); const EditServer = EditServerFactory(fromPartial({ ServerError }));
const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => renderWithEvents( const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => renderWithEvents(
<MemoryRouter> <MemoryRouter>
<EditServer editServer={editServerMock} selectedServer={selectedServer} selectServer={vi.fn()} /> <EditServer editServer={editServerMock} selectedServer={selectedServer} selectServer={vi.fn()} />

View file

@ -2,7 +2,7 @@ import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { ServersMap, ServerWithId } from '../../src/servers/data'; import type { ServersMap, ServerWithId } from '../../src/servers/data';
import { ManageServers as createManageServers } 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 { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
@ -10,12 +10,14 @@ describe('<ManageServers />', () => {
const exportServers = vi.fn(); const exportServers = vi.fn();
const serversExporter = fromPartial<ServersExporter>({ exportServers }); const serversExporter = fromPartial<ServersExporter>({ exportServers });
const useTimeoutToggle = vi.fn().mockReturnValue([false, vi.fn()]); const useTimeoutToggle = vi.fn().mockReturnValue([false, vi.fn()]);
const ManageServers = createManageServers( const ManageServers = ManageServersFactory(fromPartial({
serversExporter, ServersExporter: serversExporter,
() => <span>ImportServersBtn</span>, ImportServersBtn: () => <span>ImportServersBtn</span>,
useTimeoutToggle, useTimeoutToggle,
({ hasAutoConnect }) => <tr><td>ManageServersRow {hasAutoConnect ? '[YES]' : '[NO]'}</td></tr>, 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 },
); );

View file

@ -1,10 +1,13 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { ServerWithId } from '../../src/servers/data'; import type { ServerWithId } from '../../src/servers/data';
import { ManageServersRow as createManageServersRow } from '../../src/servers/ManageServersRow'; import { ManageServersRowFactory } from '../../src/servers/ManageServersRow';
describe('<ManageServersRow />', () => { describe('<ManageServersRow />', () => {
const ManageServersRow = createManageServersRow(() => <span>ManageServersRowDropdown</span>); 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',

View file

@ -2,13 +2,15 @@ import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { ServerWithId } from '../../src/servers/data'; import type { ServerWithId } from '../../src/servers/data';
import { ManageServersRowDropdown as createManageServersRowDropdown } from '../../src/servers/ManageServersRowDropdown'; import { ManageServersRowDropdownFactory } from '../../src/servers/ManageServersRowDropdown';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ManageServersRowDropdown />', () => { describe('<ManageServersRowDropdown />', () => {
const ManageServersRowDropdown = createManageServersRowDropdown( const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({
({ isOpen }) => <span>DeleteServerModal {isOpen ? '[OPEN]' : '[CLOSED]'}</span>, DeleteServerModal: ({ isOpen }: { isOpen: boolean }) => (
); <span>DeleteServerModal {isOpen ? '[OPEN]' : '[CLOSED]'}</span>
),
}));
const setAutoConnect = vi.fn(); 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 });

View file

@ -3,9 +3,7 @@ import { fromPartial } from '@total-typescript/shoehorn';
import type { ServersMap, ServerWithId } from '../../../src/servers/data'; import type { ServersMap, ServerWithId } from '../../../src/servers/data';
import type { import type {
ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn'; ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
import { import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn';
ImportServersBtn as createImportServersBtn,
} from '../../../src/servers/helpers/ImportServersBtn';
import type { ServersImporter } from '../../../src/servers/services/ServersImporter'; import type { ServersImporter } from '../../../src/servers/services/ServersImporter';
import { renderWithEvents } from '../../__helpers__/setUpTest'; import { renderWithEvents } from '../../__helpers__/setUpTest';
@ -14,7 +12,7 @@ describe('<ImportServersBtn />', () => {
const createServersMock = 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 = createImportServersBtn(serversImporterMock); const ImportServersBtn = ImportServersBtnFactory(fromPartial({ ServersImporter: serversImporterMock }));
const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) => renderWithEvents( const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) => renderWithEvents(
<ImportServersBtn <ImportServersBtn
servers={servers} servers={servers}

View file

@ -2,10 +2,10 @@ import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { NonReachableServer, NotFoundServer } from '../../../src/servers/data'; import type { NonReachableServer, NotFoundServer } from '../../../src/servers/data';
import { ServerError as createServerError } from '../../../src/servers/helpers/ServerError'; import { ServerErrorFactory } from '../../../src/servers/helpers/ServerError';
describe('<ServerError />', () => { describe('<ServerError />', () => {
const ServerError = createServerError(() => null); const ServerError = ServerErrorFactory(fromPartial({ DeleteServerButton: () => null }));
it.each([ it.each([
[ [

View file

@ -1,17 +1,18 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom'; import { Router } from 'react-router-dom';
import { Settings as createSettings } from '../../src/settings/Settings'; import { SettingsFactory } from '../../src/settings/Settings';
describe('<Settings />', () => { describe('<Settings />', () => {
const Settings = createSettings( const Settings = SettingsFactory(fromPartial({
() => <span>RealTimeUpdates</span>, RealTimeUpdatesSettings: () => <span>RealTimeUpdates</span>,
() => <span>ShortUrlCreation</span>, ShortUrlCreationSettings: () => <span>ShortUrlCreation</span>,
() => <span>ShortUrlsList</span>, ShortUrlsListSettings: () => <span>ShortUrlsList</span>,
() => <span>UserInterface</span>, UserInterfaceSettings: () => <span>UserInterface</span>,
() => <span>Visits</span>, VisitsSettings: () => <span>Visits</span>,
() => <span>Tags</span>, TagsSettings: () => <span>Tags</span>,
); }));
const setUp = (activeRoute = '/') => { const setUp = (activeRoute = '/') => {
const history = createMemoryHistory(); const history = createMemoryHistory();
history.push(activeRoute); history.push(activeRoute);