Refactored ServerError to infer error message based on provided server type guards

This commit is contained in:
Alejandro Celaya 2020-08-29 10:53:02 +02:00
parent f40ad91ea9
commit 8cc0695ee9
14 changed files with 177 additions and 186 deletions

View file

@ -13,18 +13,18 @@ import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { ServerWithId } from '../servers/data'; import { ServerWithId } from '../servers/data';
import './AsideMenu.scss'; import './AsideMenu.scss';
interface AsideMenuProps { export interface AsideMenuProps {
selectedServer: ServerWithId; selectedServer: ServerWithId;
className?: string; className?: string;
showOnMobile?: boolean; showOnMobile?: boolean;
} }
interface AsideMenuItemItemProps extends NavLinkProps { interface AsideMenuItemProps extends NavLinkProps {
to: string; to: string;
className?: string; className?: string;
} }
const AsideMenuItem: FC<AsideMenuItemItemProps> = ({ children, to, className, ...rest }) => ( const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
<NavLink <NavLink
className={classNames('aside-menu__item', className)} className={classNames('aside-menu__item', className)}
activeClassName="aside-menu__item--selected" activeClassName="aside-menu__item--selected"

View file

@ -1,30 +1,31 @@
import React from 'react'; import React, { ReactNode } from 'react';
import * as PropTypes from 'prop-types';
import './ErrorHandler.scss';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
import './ErrorHandler.scss';
// FIXME Replace with typescript: (window, console) interface ErrorHandlerState {
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component { hasError: boolean;
static propTypes = { }
children: PropTypes.node.isRequired,
};
constructor(props) { const ErrorHandler = (
{ location }: Window,
{ error }: Console,
) => class ErrorHandler extends React.Component<any, ErrorHandlerState> {
public constructor(props: object) {
super(props); super(props);
this.state = { hasError: false }; this.state = { hasError: false };
} }
static getDerivedStateFromError() { public static getDerivedStateFromError(): ErrorHandlerState {
return { hasError: true }; return { hasError: true };
} }
componentDidCatch(e) { public componentDidCatch(e: Error): void {
if (process.env.NODE_ENV !== 'development') { if (process.env.NODE_ENV !== 'development') {
error(e); error(e);
} }
} }
render() { public render(): ReactNode | undefined {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="error-handler"> <div className="error-handler">

View file

@ -2,8 +2,8 @@ import React, { useEffect } from 'react';
import { isEmpty, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import ServersListGroup from '../servers/ServersListGroup'; import ServersListGroup from '../servers/ServersListGroup';
import { ServersMap } from '../servers/reducers/servers';
import './Home.scss'; import './Home.scss';
import { ServersMap } from '../servers/data';
export interface HomeProps { export interface HomeProps {
resetSelectedServer: Function; resetSelectedServer: Function;

View file

@ -1,98 +0,0 @@
import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import * as PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useToggle } from '../utils/helpers/hooks';
import { versionMatch } from '../utils/helpers/version';
import NotFound from './NotFound';
import './MenuLayout.scss';
const propTypes = {
match: PropTypes.object,
location: PropTypes.object,
selectedServer: serverType,
};
const MenuLayout = (
TagsList,
ShortUrls,
AsideMenu,
CreateShortUrl,
ShortUrlVisits,
TagVisits,
ShlinkVersions,
ServerError,
) => {
const MenuLayoutComp = ({ match, location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
const { params: { serverId } } = match;
useEffect(() => hideSidebar(), [ location ]);
if (selectedServer.serverNotReachable) {
return <ServerError type="not-reachable" />;
}
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': sidebarVisible,
});
const swipeMenuIfNoModalExists = (callback) => (e) => {
const swippedOnVisitsTable = e.event.path.some(
({ classList }) => classList && classList.contains('visits-table'),
);
if (swippedOnVisitsTable || document.querySelector('.modal')) {
return;
}
callback();
};
return (
<React.Fragment>
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
<div className="menu-layout__container">
<Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
/>
</Switch>
</div>
<div className="menu-layout__footer text-center text-md-right">
<ShlinkVersions />
</div>
</div>
</div>
</Swipeable>
</React.Fragment>
);
};
MenuLayoutComp.propTypes = propTypes;
return withSelectedServer(MenuLayoutComp, ServerError);
};
export default MenuLayout;

89
src/common/MenuLayout.tsx Normal file
View file

@ -0,0 +1,89 @@
import React, { FC, useEffect } from 'react';
import { Route, RouteChildrenProps, Switch } from 'react-router-dom';
import { EventData, Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useToggle } from '../utils/helpers/hooks';
import { versionMatch } from '../utils/helpers/version';
import { isReachableServer, SelectedServer } from '../servers/data';
import NotFound from './NotFound';
import { AsideMenuProps } from './AsideMenu';
import './MenuLayout.scss';
interface MenuLayoutProps extends RouteChildrenProps {
selectedServer: SelectedServer;
}
const MenuLayout = (
TagsList: FC,
ShortUrls: FC,
AsideMenu: FC<AsideMenuProps>,
CreateShortUrl: FC,
ShortUrlVisits: FC,
TagVisits: FC,
ShlinkVersions: FC,
ServerError: FC,
) => withSelectedServer(({ location, selectedServer }: MenuLayoutProps) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
useEffect(() => hideSidebar(), [ location ]);
if (!isReachableServer(selectedServer)) {
return <ServerError />;
}
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': sidebarVisible,
});
const swipeMenuIfNoModalExists = (callback: () => void) => (e: EventData) => {
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
({ classList }) => classList?.contains('visits-table'),
);
if (swippedOnVisitsTable || document.querySelector('.modal')) {
return;
}
callback();
};
return (
<React.Fragment>
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
<div className="menu-layout__container">
<Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
/>
</Switch>
</div>
<div className="menu-layout__footer text-center text-md-right">
<ShlinkVersions />
</div>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}, ServerError);
export default MenuLayout;

View file

@ -1,6 +1,5 @@
import { MercureInfo } from '../mercure/reducers/mercureInfo'; import { MercureInfo } from '../mercure/reducers/mercureInfo';
import { ServersMap } from '../servers/reducers/servers'; import { SelectedServer, ServersMap } from '../servers/data';
import { SelectedServer } from '../servers/data';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta'; import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta';
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation'; import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';

View file

@ -25,8 +25,13 @@ export type RegularServer = ReachableServer | NonReachableServer;
export type SelectedServer = RegularServer | NotFoundServer | null; export type SelectedServer = RegularServer | NotFoundServer | null;
export type ServersMap = Record<string, ServerWithId>;
export const hasServerData = (server: ServerData | NotFoundServer | null): server is ServerData => export const hasServerData = (server: ServerData | NotFoundServer | null): server is ServerData =>
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey; !!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
export const isReachableServer = (server: SelectedServer): server is ReachableServer => export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
!!server?.hasOwnProperty('printableVersion'); !!server?.hasOwnProperty('printableVersion');
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
!!server?.hasOwnProperty('id');

View file

@ -1,50 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup';
import { serverType } from '../prop-types';
import './ServerError.scss';
const propTypes = {
servers: PropTypes.object,
selectedServer: serverType,
type: PropTypes.oneOf([ 'not-found', 'not-reachable' ]).isRequired,
};
export const ServerError = (DeleteServerButton) => {
const ServerErrorComp = ({ type, servers, selectedServer }) => (
<div className="server-error__container flex-column">
<div className="row w-100 mb-3 mb-md-5">
<Message type="error">
{type === 'not-found' && 'Could not find this Shlink server.'}
{type === 'not-reachable' && (
<React.Fragment>
<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.
</React.Fragment>
)}
</Message>
</div>
<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>
{type === 'not-reachable' && (
<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`}>edit it</Link>.
</h5>
</div>
)}
</div>
);
ServerErrorComp.propTypes = propTypes;
return ServerErrorComp;
};

View file

@ -0,0 +1,45 @@
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup';
import { DeleteServerButtonProps } from '../DeleteServerButton';
import { isServerWithId, SelectedServer, ServersMap } from '../data';
import './ServerError.scss';
interface ServerErrorProps {
servers: ServersMap;
selectedServer: SelectedServer;
}
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
{ servers, selectedServer },
) => (
<div className="server-error__container flex-column">
<div className="row w-100 mb-3 mb-md-5">
<Message type="error">
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && (
<React.Fragment>
<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.
</React.Fragment>
)}
</Message>
</div>
<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>
{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`}>edit it</Link>.
</h5>
</div>
)}
</div>
);

View file

@ -23,7 +23,7 @@ export const withSelectedServer = (WrappedComponent, ServerError) => {
} }
if (selectedServer.serverNotFound) { if (selectedServer.serverNotFound) {
return <ServerError type="not-found" />; return <ServerError />;
} }
return <WrappedComponent {...props} />; return <WrappedComponent {...props} />;

View file

@ -1,7 +1,7 @@
import { pipe, assoc, map, reduce, dissoc } from 'ramda'; import { assoc, dissoc, map, pipe, reduce } from 'ramda';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Action } from 'redux'; import { Action } from 'redux';
import { ServerData, ServerWithId } from '../data'; import { ServerData, ServersMap, ServerWithId } from '../data';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
@ -10,8 +10,6 @@ export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS'; export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export type ServersMap = Record<string, ServerWithId>;
export interface CreateServersAction extends Action<string> { export interface CreateServersAction extends Action<string> {
newServers: ServersMap; newServers: ServersMap;
} }

View file

@ -20,7 +20,7 @@ const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }) => {
const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : ''; const longLink = shortUrl && shortUrl.longUrl ? shortUrl.longUrl : '';
const renderDate = () => ( const renderDate = () => !shortUrl ? <small>Loading...</small> : (
<span> <span>
<b id="created" className="short-url-visits-header__created-at"> <b id="created" className="short-url-visits-header__created-at">
<Moment fromNow>{shortUrl.dateCreated}</Moment> <Moment fromNow>{shortUrl.dateCreated}</Moment>

View file

@ -1,16 +1,17 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
import { Mock } from 'ts-mockery';
import createErrorHandler from '../../src/common/ErrorHandler'; import createErrorHandler from '../../src/common/ErrorHandler';
describe('<ErrorHandler />', () => { describe('<ErrorHandler />', () => {
const window = { const window = Mock.of<Window>({
location: { location: {
reload: jest.fn(), reload: jest.fn(),
}, },
}; });
const console = { error: jest.fn() }; const console = Mock.of<Console>({ error: jest.fn() });
let wrapper; let wrapper: ShallowWrapper;
beforeEach(() => { beforeEach(() => {
const ErrorHandler = createErrorHandler(window, console); const ErrorHandler = createErrorHandler(window, console);

View file

@ -1,18 +1,19 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { Mock } from 'ts-mockery';
import { ServerError as createServerError } from '../../../src/servers/helpers/ServerError'; import { ServerError as createServerError } from '../../../src/servers/helpers/ServerError';
import { NonReachableServer, NotFoundServer } from '../../../src/servers/data';
describe('<ServerError />', () => { describe('<ServerError />', () => {
let wrapper; let wrapper: ShallowWrapper;
const selectedServer = { id: '' }; const ServerError = createServerError(() => null);
const ServerError = createServerError(() => '');
afterEach(() => wrapper && wrapper.unmount()); afterEach(() => wrapper?.unmount());
it.each([ it.each([
[ [
'not-found', Mock.all<NotFoundServer>(),
{ {
'Could not find this Shlink server.': true, 'Could not find this Shlink server.': true,
'Oops! Could not connect to this Shlink server.': false, 'Oops! Could not connect to this Shlink server.': false,
@ -21,7 +22,7 @@ describe('<ServerError />', () => {
}, },
], ],
[ [
'not-reachable', Mock.of<NonReachableServer>({ id: 'abc123' }),
{ {
'Could not find this Shlink server.': false, 'Could not find this Shlink server.': false,
'Oops! Could not connect to this Shlink server.': true, 'Oops! Could not connect to this Shlink server.': true,
@ -29,10 +30,10 @@ describe('<ServerError />', () => {
'Alternatively, if you think you may have miss-configured this server': true, 'Alternatively, if you think you may have miss-configured this server': true,
}, },
], ],
])('renders expected information for type "%s"', (type, textsToFind) => { ])('renders expected information based on provided server type', (selectedServer, textsToFind) => {
wrapper = shallow( wrapper = shallow(
<BrowserRouter> <BrowserRouter>
<ServerError type={type} servers={{}} selectedServer={selectedServer} /> <ServerError servers={{}} selectedServer={selectedServer} />
</BrowserRouter>, </BrowserRouter>,
); );
const wrapperText = wrapper.html(); const wrapperText = wrapper.html();