Repository: shlinkio/shlink-web-client (ts)

Issue:
Standardize how loading, error and success messages are displayed
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

Improve the `Message` component during the process, as it's hard to reuse right now:

* It includes margins and colors by default, that are sometimes hard to override
* It expectes to be wrapped in a row.

Pull request:
Feature/message improvements
Closes #217 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index faf98cfa..c0ff9857 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 ### Changed
 * [#267](https://github.com/shlinkio/shlink-web-client/issues/267) Added some subtle but important improvements on UI/UX.
 * [#352](https://github.com/shlinkio/shlink-web-client/issues/352) Moved from Scrutinizer to Codecov as the code coverage backend.
+* [#217](https://github.com/shlinkio/shlink-web-client/issues/217) Improved how messages are displayed, by centralizing it in the `Message` and `Result` components..
 
 ### Deprecated
 * *Nothing*
diff --git a/src/servers/CreateServer.tsx b/src/servers/CreateServer.tsx
index 7181344b..d98d3619 100644
--- a/src/servers/CreateServer.tsx
+++ b/src/servers/CreateServer.tsx
@@ -1,7 +1,7 @@
 import { FC } from 'react';
 import { v4 as uuid } from 'uuid';
 import { RouterProps } from 'react-router';
-import classNames from 'classnames';
+import { Result } from '../utils/Result';
 import NoMenuLayout from '../common/NoMenuLayout';
 import { StateFlagTimeout } from '../utils/helpers/hooks';
 import { ServerForm } from './helpers/ServerForm';
@@ -15,19 +15,11 @@ interface CreateServerProps extends RouterProps {
   createServer: (server: ServerWithId) => void;
 }
 
-const Result: FC<{ type: 'success' | 'error' }> = ({ children, type }) => (
-  <div className="row">
-    <div className="col-md-10 offset-md-1">
-      <div
-        className={classNames('p-2 mt-3 text-white text-center', {
-          'bg-main': type === 'success',
-          'bg-danger': type === 'error',
-        })}
-      >
-        {children}
-      </div>
-    </div>
-  </div>
+const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
+  <Result type={type}>
+    {type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
+    {type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
+  </Result>
 );
 
 const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
@@ -49,8 +41,12 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
         <button className="btn btn-outline-primary">Create server</button>
       </ServerForm>
 
-      {serversImported && <Result type="success">Servers properly imported. You can now select one from the list :)</Result>}
-      {errorImporting && <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>}
+      {(serversImported || errorImporting) && (
+        <div className="mt-4">
+          {serversImported && <ImportResult type="success" />}
+          {errorImporting && <ImportResult type="error" />}
+        </div>
+      )}
     </NoMenuLayout>
   );
 };
diff --git a/src/servers/helpers/ServerError.tsx b/src/servers/helpers/ServerError.tsx
index 17d14be6..504bb610 100644
--- a/src/servers/helpers/ServerError.tsx
+++ b/src/servers/helpers/ServerError.tsx
@@ -17,17 +17,15 @@ export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC
 ) => (
   <NoMenuLayout>
     <div className="server-error__container flex-column">
-      <div className="row w-100 mb-3 mb-md-5">
-        <Message type="error" fullWidth noMargin>
-          {!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>
-      </div>
+      <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
diff --git a/src/servers/helpers/withSelectedServer.tsx b/src/servers/helpers/withSelectedServer.tsx
index 742bf160..2c91d344 100644
--- a/src/servers/helpers/withSelectedServer.tsx
+++ b/src/servers/helpers/withSelectedServer.tsx
@@ -20,7 +20,7 @@ export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServ
     if (!selectedServer) {
       return (
         <NoMenuLayout>
-          <Message loading noMargin />
+          <Message loading />
         </NoMenuLayout>
       );
     }
diff --git a/src/short-urls/helpers/CreateShortUrlResult.tsx b/src/short-urls/helpers/CreateShortUrlResult.tsx
index a9134e7c..916f622e 100644
--- a/src/short-urls/helpers/CreateShortUrlResult.tsx
+++ b/src/short-urls/helpers/CreateShortUrlResult.tsx
@@ -4,9 +4,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { isNil } from 'ramda';
 import { useEffect } from 'react';
 import CopyToClipboard from 'react-copy-to-clipboard';
-import { Card, CardBody, Tooltip } from 'reactstrap';
+import { Tooltip } from 'reactstrap';
 import { ShortUrlCreation } from '../reducers/shortUrlCreation';
 import { StateFlagTimeout } from '../../utils/helpers/hooks';
+import { Result } from '../../utils/Result';
 import './CreateShortUrlResult.scss';
 
 export interface CreateShortUrlResultProps extends ShortUrlCreation {
@@ -25,9 +26,10 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
 
   if (error) {
     return (
-      <Card body color="danger" inverse className="bg-danger mt-3">
+      <Result type="error" className="mt-3">
+        {canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
         An error occurred while creating the URL :(
-      </Card>
+      </Result>
     );
   }
 
@@ -38,26 +40,24 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
   const { shortUrl } = result;
 
   return (
-    <Card inverse className="bg-main mt-3">
-      <CardBody>
-        {canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
-        <b>Great!</b> The short URL is <b>{shortUrl}</b>
+    <Result type="success" className="mt-3">
+      {canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
+      <b>Great!</b> The short URL is <b>{shortUrl}</b>
 
-        <CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
-          <button
-            className="btn btn-light btn-sm create-short-url-result__copy-btn"
-            id="copyBtn"
-            type="button"
-          >
-            <FontAwesomeIcon icon={copyIcon} /> Copy
-          </button>
-        </CopyToClipboard>
+      <CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
+        <button
+          className="btn btn-light btn-sm create-short-url-result__copy-btn"
+          id="copyBtn"
+          type="button"
+        >
+          <FontAwesomeIcon icon={copyIcon} /> Copy
+        </button>
+      </CopyToClipboard>
 
-        <Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
-          Copied!
-        </Tooltip>
-      </CardBody>
-    </Card>
+      <Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
+        Copied!
+      </Tooltip>
+    </Result>
   );
 };
 
diff --git a/src/short-urls/helpers/DeleteShortUrlModal.tsx b/src/short-urls/helpers/DeleteShortUrlModal.tsx
index 20b16709..35f99cf4 100644
--- a/src/short-urls/helpers/DeleteShortUrlModal.tsx
+++ b/src/short-urls/helpers/DeleteShortUrlModal.tsx
@@ -4,6 +4,7 @@ import { identity, pipe } from 'ramda';
 import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
 import { ShortUrlModalProps } from '../data';
 import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
+import { Result } from '../../utils/Result';
 
 const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
 
@@ -42,25 +43,26 @@ const DeleteShortUrlModal = (
         <ModalBody>
           <p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
           <p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
+          <p>Write <b>{shortUrl.shortCode}</b> to confirm deletion.</p>
 
           <input
             type="text"
             className="form-control"
-            placeholder="Insert the short code of the URL"
+            placeholder={`Insert the short code (${shortUrl.shortCode})`}
             value={inputValue}
             onChange={(e) => setInputValue(e.target.value)}
           />
 
           {hasThresholdError && (
-            <div className="p-2 mt-2 bg-warning text-center">
+            <Result type="warning" small className="mt-2">
               {errorData?.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
               {!errorData?.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
-            </div>
+            </Result>
           )}
           {hasErrorOtherThanThreshold && (
-            <div className="p-2 mt-2 bg-danger text-white text-center">
+            <Result type="error" small className="mt-2">
               Something went wrong while deleting the URL :(
-            </div>
+            </Result>
           )}
         </ModalBody>
         <ModalFooter>
diff --git a/src/short-urls/helpers/EditMetaModal.tsx b/src/short-urls/helpers/EditMetaModal.tsx
index 109925bb..ca22c2a1 100644
--- a/src/short-urls/helpers/EditMetaModal.tsx
+++ b/src/short-urls/helpers/EditMetaModal.tsx
@@ -10,6 +10,7 @@ import DateInput from '../../utils/DateInput';
 import { formatIsoDate } from '../../utils/helpers/date';
 import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data';
 import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils';
+import { Result } from '../../utils/Result';
 
 interface EditMetaModalConnectProps extends ShortUrlModalProps {
   shortUrlMeta: ShortUrlMetaEdition;
@@ -78,9 +79,9 @@ const EditMetaModal = (
             />
           </FormGroup>
           {error && (
-            <div className="p-2 mt-2 bg-danger text-white text-center">
+            <Result type="error" small className="mt-2">
               Something went wrong while saving the metadata :(
-            </div>
+            </Result>
           )}
         </ModalBody>
         <ModalFooter>
diff --git a/src/short-urls/helpers/EditShortUrlModal.tsx b/src/short-urls/helpers/EditShortUrlModal.tsx
index 019ad5bc..1a24d640 100644
--- a/src/short-urls/helpers/EditShortUrlModal.tsx
+++ b/src/short-urls/helpers/EditShortUrlModal.tsx
@@ -4,6 +4,7 @@ import { ExternalLink } from 'react-external-link';
 import { ShortUrlEdition } from '../reducers/shortUrlEdition';
 import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
 import { ShortUrlModalProps } from '../data';
+import { Result } from '../../utils/Result';
 
 interface EditShortUrlModalProps extends ShortUrlModalProps {
   shortUrlEdition: ShortUrlEdition;
@@ -34,9 +35,9 @@ const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShor
             />
           </FormGroup>
           {error && (
-            <div className="p-2 mt-2 bg-danger text-white text-center">
+            <Result type="error" small className="mt-2">
               Something went wrong while saving the long URL :(
-            </div>
+            </Result>
           )}
         </ModalBody>
         <ModalFooter>
diff --git a/src/short-urls/helpers/EditTagsModal.tsx b/src/short-urls/helpers/EditTagsModal.tsx
index 03bbff81..a41a4b98 100644
--- a/src/short-urls/helpers/EditTagsModal.tsx
+++ b/src/short-urls/helpers/EditTagsModal.tsx
@@ -5,6 +5,7 @@ import { ShortUrlTags } from '../reducers/shortUrlTags';
 import { ShortUrlModalProps } from '../data';
 import { OptionalString } from '../../utils/utils';
 import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
+import { Result } from '../../utils/Result';
 
 interface EditTagsModalProps extends ShortUrlModalProps {
   shortUrlTags: ShortUrlTags;
@@ -32,9 +33,9 @@ const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
       <ModalBody>
         <TagsSelector tags={selectedTags} onChange={setSelectedTags} />
         {shortUrlTags.error && (
-          <div className="p-2 mt-2 bg-danger text-white text-center">
+          <Result type="error" small className="mt-2">
             Something went wrong while saving the tags :(
-          </div>
+          </Result>
         )}
       </ModalBody>
       <ModalFooter>
diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx
index 03b4d221..b72a74f6 100644
--- a/src/tags/TagsList.tsx
+++ b/src/tags/TagsList.tsx
@@ -4,6 +4,7 @@ import Message from '../utils/Message';
 import SearchField from '../utils/SearchField';
 import { SelectedServer } from '../servers/data';
 import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
+import { Result } from '../utils/Result';
 import { TagsList as TagsListState } from './reducers/tagsList';
 import { TagCardProps } from './TagCard';
 
@@ -28,15 +29,11 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
 
   const renderContent = () => {
     if (tagsList.loading) {
-      return <Message noMargin loading />;
+      return <Message loading />;
     }
 
     if (tagsList.error) {
-      return (
-        <div className="col-12">
-          <div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
-        </div>
-      );
+      return <Result type="error">Error loading tags :(</Result>;
     }
 
     const tagsCount = tagsList.filteredTags.length;
@@ -48,7 +45,7 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
     const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
 
     return (
-      <>
+      <div className="row">
         {tagsGroups.map((group, index) => (
           <div key={index} className="col-md-6 col-xl-3">
             {group.map((tag) => (
@@ -63,16 +60,14 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
             ))}
           </div>
         ))}
-      </>
+      </div>
     );
   };
 
   return (
     <>
       {!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
-      <div className="row">
-        {renderContent()}
-      </div>
+      {renderContent()}
     </>
   );
 }, () => 'https://shlink.io/new-visit');
diff --git a/src/tags/helpers/DeleteTagConfirmModal.tsx b/src/tags/helpers/DeleteTagConfirmModal.tsx
index 5429d704..eb95cd55 100644
--- a/src/tags/helpers/DeleteTagConfirmModal.tsx
+++ b/src/tags/helpers/DeleteTagConfirmModal.tsx
@@ -1,6 +1,7 @@
 import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 import { TagDeletion } from '../reducers/tagDelete';
 import { TagModalProps } from '../data';
+import { Result } from '../../utils/Result';
 
 interface DeleteTagConfirmModalProps extends TagModalProps {
   deleteTag: (tag: string) => Promise<void>;
@@ -25,9 +26,9 @@ const DeleteTagConfirmModal = (
       <ModalBody>
         Are you sure you want to delete tag <b>{tag}</b>?
         {tagDelete.error && (
-          <div className="p-2 mt-2 bg-danger text-white text-center">
+          <Result type="error" small className="mt-2">
             Something went wrong while deleting the tag :(
-          </div>
+          </Result>
         )}
       </ModalBody>
       <ModalFooter>
diff --git a/src/tags/helpers/EditTagModal.tsx b/src/tags/helpers/EditTagModal.tsx
index b1ae9c0d..5779520b 100644
--- a/src/tags/helpers/EditTagModal.tsx
+++ b/src/tags/helpers/EditTagModal.tsx
@@ -9,6 +9,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator';
 import { TagModalProps } from '../data';
 import { TagEdition } from '../reducers/tagEdit';
 import './EditTagModal.scss';
+import { Result } from '../../utils/Result';
 
 interface EditTagModalProps extends TagModalProps {
   tagEdit: TagEdition;
@@ -55,9 +56,9 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
           </div>
 
           {tagEdit.error && (
-            <div className="p-2 mt-2 bg-danger text-white text-center">
+            <Result type="error" small className="mt-2">
               Something went wrong while editing the tag :(
-            </div>
+            </Result>
           )}
         </ModalBody>
         <ModalFooter>
diff --git a/src/utils/Message.tsx b/src/utils/Message.tsx
index 70363983..51f4305a 100644
--- a/src/utils/Message.tsx
+++ b/src/utils/Message.tsx
@@ -1,5 +1,5 @@
 import { FC } from 'react';
-import { Card } from 'reactstrap';
+import { Card, Row } from 'reactstrap';
 import classNames from 'classnames';
 import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -23,32 +23,31 @@ const getTextClassForType = (type: MessageType) => {
   return map[type];
 };
 
-interface MessageProps {
-  noMargin?: boolean;
+export interface MessageProps {
+  className?: string;
   loading?: boolean;
   fullWidth?: boolean;
   type?: MessageType;
 }
 
-const Message: FC<MessageProps> = (
-  { children, loading = false, noMargin = false, type = 'default', fullWidth = false },
-) => {
-  const cardClasses = classNames(getClassForType(type), { 'mt-4': !noMargin });
+const Message: FC<MessageProps> = ({ className, children, loading = false, type = 'default', fullWidth = false }) => {
   const classes = classNames({
     'col-md-12': fullWidth,
     'col-md-10 offset-md-1': !fullWidth,
   });
 
   return (
-    <div className={classes}>
-      <Card className={cardClasses} body>
-        <h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
-          {loading && <FontAwesomeIcon icon={preloader} spin />}
-          {loading && <span className="ml-2">{children ?? 'Loading...'}</span>}
-          {!loading && children}
-        </h3>
-      </Card>
-    </div>
+    <Row noGutters className={className}>
+      <div className={classes}>
+        <Card className={getClassForType(type)} body>
+          <h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
+            {loading && <FontAwesomeIcon icon={preloader} spin />}
+            {loading && <span className="ml-2">{children ?? 'Loading...'}</span>}
+            {!loading && children}
+          </h3>
+        </Card>
+      </div>
+    </Row>
   );
 };
 
diff --git a/src/utils/Result.tsx b/src/utils/Result.tsx
new file mode 100644
index 00000000..5f49e703
--- /dev/null
+++ b/src/utils/Result.tsx
@@ -0,0 +1,30 @@
+import { FC } from 'react';
+import { Row } from 'reactstrap';
+import classNames from 'classnames';
+import { SimpleCard } from './SimpleCard';
+
+export type ResultType = 'success' | 'error' | 'warning';
+
+export interface ResultProps {
+  type: ResultType;
+  className?: string;
+  small?: boolean;
+}
+
+export const Result: FC<ResultProps> = ({ children, type, className, small = false }) => (
+  <Row className={className}>
+    <div className={classNames({ 'col-md-10 offset-md-1': !small, 'col-12': small })}>
+      <SimpleCard
+        className={classNames('text-center', {
+          'bg-main': type === 'success',
+          'bg-danger': type === 'error',
+          'bg-warning': type === 'warning',
+          'text-white': type !== 'warning',
+        })}
+        bodyClassName={classNames({ 'p-2': small })}
+      >
+        {children}
+      </SimpleCard>
+    </div>
+  </Row>
+);
diff --git a/src/utils/SimpleCard.tsx b/src/utils/SimpleCard.tsx
index e50213e7..5ecbdc4f 100644
--- a/src/utils/SimpleCard.tsx
+++ b/src/utils/SimpleCard.tsx
@@ -4,11 +4,12 @@ import { ReactNode } from 'react';
 
 interface SimpleCardProps extends Omit<CardProps, 'title'> {
   title?: ReactNode;
+  bodyClassName?: string;
 }
 
-export const SimpleCard = ({ title, children, ...rest }: SimpleCardProps) => (
+export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
   <Card {...rest}>
     {title && <CardHeader>{title}</CardHeader>}
-    <CardBody>{children}</CardBody>
+    <CardBody className={bodyClassName}>{children}</CardBody>
   </Card>
 );
diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx
index f1b9936a..83e267e0 100644
--- a/src/visits/VisitsStats.tsx
+++ b/src/visits/VisitsStats.tsx
@@ -11,6 +11,7 @@ import Message from '../utils/Message';
 import { formatIsoDate } from '../utils/helpers/date';
 import { ShlinkVisitsParams } from '../utils/services/types';
 import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
+import { Result } from '../utils/Result';
 import SortableBarGraph from './helpers/SortableBarGraph';
 import GraphCard from './helpers/GraphCard';
 import LineChartCard from './helpers/LineChartCard';
@@ -28,10 +29,16 @@ export interface VisitsStatsProps {
   domain?: string;
 }
 
+interface VisitsNavLinkProps {
+  title: string;
+  subPath: string;
+  icon: IconDefinition;
+}
+
 type HighlightableProps = 'referer' | 'country' | 'city';
 type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
 
-const sections: Record<Section, { title: string; subPath: string; icon: IconDefinition }> = {
+const sections: Record<Section, VisitsNavLinkProps> = {
   byTime: { title: 'By time', subPath: '', icon: faCalendarAlt },
   byContext: { title: 'By context', subPath: '/by-context', icon: faChartPie },
   byLocation: { title: 'By location', subPath: '/by-location', icon: faMapMarkedAlt },
@@ -53,6 +60,19 @@ const highlightedVisitsToStats = (
 let selectedBar: string | undefined;
 const initialInterval: DateInterval = 'last30Days';
 
+const VisitsNavLink: FC<VisitsNavLinkProps> = ({ subPath, title, icon, children }) => (
+  <NavLink
+    tag={RouterNavLink}
+    className="visits-stats__nav-link"
+    to={children}
+    isActive={(_: null, { pathname }: Location) => pathname.endsWith(`/visits${subPath}`)}
+    replace
+  >
+    <FontAwesomeIcon icon={icon} />
+    <span className="ml-2 d-none d-sm-inline">{title}</span>
+  </NavLink>
+);
+
 const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain }) => {
   const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
   const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
@@ -111,11 +131,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
     }
 
     if (error) {
-      return (
-        <Card className="mt-4" body inverse color="danger">
-          An error occurred while loading visits :(
-        </Card>
-      );
+      return <Result type="error">An error occurred while loading visits :(</Result>;
     }
 
     if (isEmpty(visits)) {
@@ -124,23 +140,10 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
 
     return (
       <>
-        <Card className="visits-stats__nav p-0 mt-4 overflow-hidden" body>
+        <Card className="visits-stats__nav p-0 overflow-hidden" body>
           <Nav pills justified>
-            {Object.entries(sections).map(
-              ([ section, { title, icon, subPath }]) => (
-                <NavLink
-                  key={section}
-                  tag={RouterNavLink}
-                  className="visits-stats__nav-link"
-                  to={buildSectionUrl(subPath)}
-                  isActive={(_: null, { pathname }: Location) => pathname.endsWith(`/visits${subPath}`)}
-                  replace
-                >
-                  <FontAwesomeIcon icon={icon} />
-                  <span className="ml-2 d-none d-sm-inline">{title}</span>
-                </NavLink>
-              ),
-            )}
+            {Object.entries(sections).map(([ section, props ]) =>
+              <VisitsNavLink key={section} {...props}>{buildSectionUrl(props.subPath)}</VisitsNavLink>)}
           </Nav>
         </Card>
         <div className="row">
@@ -259,7 +262,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, ca
         </div>
       </section>
 
-      <section>
+      <section className="mt-4">
         {renderVisitsContent()}
       </section>
     </>


Test patch:
diff --git a/test/servers/CreateServer.test.tsx b/test/servers/CreateServer.test.tsx
index 1e45ca17..dcc90006 100644
--- a/test/servers/CreateServer.test.tsx
+++ b/test/servers/CreateServer.test.tsx
@@ -30,12 +30,12 @@ describe('<CreateServer />', () => {
     const wrapper = createWrapper();
 
     expect(wrapper.find(ServerForm)).toHaveLength(1);
-    expect(wrapper.find('Result')).toHaveLength(0);
+    expect(wrapper.find('ImportResult')).toHaveLength(0);
   });
 
   it('shows success message when imported is true', () => {
     const wrapper = createWrapper(true);
-    const result = wrapper.find('Result');
+    const result = wrapper.find('ImportResult');
 
     expect(result).toHaveLength(1);
     expect(result.prop('type')).toEqual('success');
@@ -43,7 +43,7 @@ describe('<CreateServer />', () => {
 
   it('shows error message when import failed', () => {
     const wrapper = createWrapper(false, true);
-    const result = wrapper.find('Result');
+    const result = wrapper.find('ImportResult');
 
     expect(result).toHaveLength(1);
     expect(result.prop('type')).toEqual('error');
diff --git a/test/short-urls/helpers/CreateShortUrlResult.test.tsx b/test/short-urls/helpers/CreateShortUrlResult.test.tsx
index ba280df5..4430987b 100644
--- a/test/short-urls/helpers/CreateShortUrlResult.test.tsx
+++ b/test/short-urls/helpers/CreateShortUrlResult.test.tsx
@@ -5,6 +5,7 @@ import { Mock } from 'ts-mockery';
 import createCreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
 import { ShortUrl } from '../../../src/short-urls/data';
 import { StateFlagTimeout } from '../../../src/utils/helpers/hooks';
+import { Result } from '../../../src/utils/Result';
 
 describe('<CreateShortUrlResult />', () => {
   let wrapper: ShallowWrapper;
@@ -24,7 +25,7 @@ describe('<CreateShortUrlResult />', () => {
 
   it('renders an error when error is true', () => {
     const wrapper = createWrapper(Mock.all<ShortUrl>(), true);
-    const errorCard = wrapper.find('.bg-danger');
+    const errorCard = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
 
     expect(errorCard).toHaveLength(1);
     expect(errorCard.html()).toContain('An error occurred while creating the URL :(');
diff --git a/test/short-urls/helpers/DeleteShortUrlModal.test.tsx b/test/short-urls/helpers/DeleteShortUrlModal.test.tsx
index 11498d80..06ce4864 100644
--- a/test/short-urls/helpers/DeleteShortUrlModal.test.tsx
+++ b/test/short-urls/helpers/DeleteShortUrlModal.test.tsx
@@ -5,6 +5,7 @@ import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlM
 import { ShortUrl } from '../../../src/short-urls/data';
 import { ShortUrlDeletion } from '../../../src/short-urls/reducers/shortUrlDeletion';
 import { ProblemDetailsError } from '../../../src/utils/services/types';
+import { Result } from '../../../src/utils/Result';
 
 describe('<DeleteShortUrlModal />', () => {
   let wrapper: ShallowWrapper;
@@ -48,7 +49,7 @@ describe('<DeleteShortUrlModal />', () => {
       shortCode: 'abc123',
       errorData: Mock.of<ProblemDetailsError>(errorData),
     });
-    const warning = wrapper.find('.bg-warning');
+    const warning = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'warning');
 
     expect(warning).toHaveLength(1);
     expect(warning.html()).toContain(expectedMessage);
@@ -61,7 +62,7 @@ describe('<DeleteShortUrlModal />', () => {
       shortCode: 'abc123',
       errorData: Mock.of<ProblemDetailsError>({ type: 'OTHER_ERROR' }),
     });
-    const error = wrapper.find('.bg-danger');
+    const error = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
 
     expect(error).toHaveLength(1);
     expect(error.html()).toContain('Something went wrong while deleting the URL :(');
diff --git a/test/short-urls/helpers/EditMetaModal.test.tsx b/test/short-urls/helpers/EditMetaModal.test.tsx
index 730ceaa5..b5553125 100644
--- a/test/short-urls/helpers/EditMetaModal.test.tsx
+++ b/test/short-urls/helpers/EditMetaModal.test.tsx
@@ -4,6 +4,7 @@ import { Mock } from 'ts-mockery';
 import EditMetaModal from '../../../src/short-urls/helpers/EditMetaModal';
 import { ShortUrl } from '../../../src/short-urls/data';
 import { ShortUrlMetaEdition } from '../../../src/short-urls/reducers/shortUrlMeta';
+import { Result } from '../../../src/utils/Result';
 
 describe('<EditMetaModal />', () => {
   let wrapper: ShallowWrapper;
@@ -30,7 +31,7 @@ describe('<EditMetaModal />', () => {
 
   it('properly renders form with components', () => {
     const wrapper = createWrapper({ saving: false, error: false });
-    const error = wrapper.find('.bg-danger');
+    const error = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
     const form = wrapper.find('form');
     const formGroup = form.find(FormGroup);
 
@@ -52,7 +53,7 @@ describe('<EditMetaModal />', () => {
 
   it('renders error message on error', () => {
     const wrapper = createWrapper({ saving: false, error: true });
-    const error = wrapper.find('.bg-danger');
+    const error = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
 
     expect(error).toHaveLength(1);
   });
diff --git a/test/short-urls/helpers/EditShortUrlModal.test.tsx b/test/short-urls/helpers/EditShortUrlModal.test.tsx
index 95e2f494..2ab14c4b 100644
--- a/test/short-urls/helpers/EditShortUrlModal.test.tsx
+++ b/test/short-urls/helpers/EditShortUrlModal.test.tsx
@@ -4,6 +4,7 @@ import { Mock } from 'ts-mockery';
 import EditShortUrlModal from '../../../src/short-urls/helpers/EditShortUrlModal';
 import { ShortUrl } from '../../../src/short-urls/data';
 import { ShortUrlEdition } from '../../../src/short-urls/reducers/shortUrlEdition';
+import { Result } from '../../../src/utils/Result';
 
 describe('<EditShortUrlModal />', () => {
   let wrapper: ShallowWrapper;
@@ -31,7 +32,7 @@ describe('<EditShortUrlModal />', () => {
     [ true, 1 ],
   ])('properly renders form with expected components', (error, expectedErrorLength) => {
     const wrapper = createWrapper({}, { saving: false, error });
-    const errorElement = wrapper.find('.bg-danger');
+    const errorElement = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
     const form = wrapper.find('form');
     const formGroup = form.find(FormGroup);
 
diff --git a/test/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx
index 4369cc94..072f3b6f 100644
--- a/test/tags/TagsList.test.tsx
+++ b/test/tags/TagsList.test.tsx
@@ -7,6 +7,7 @@ import SearchField from '../../src/utils/SearchField';
 import { rangeOf } from '../../src/utils/utils';
 import { TagsList } from '../../src/tags/reducers/tagsList';
 import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
+import { Result } from '../../src/utils/Result';
 
 describe('<TagsList />', () => {
   let wrapper: ShallowWrapper;
@@ -41,7 +42,7 @@ describe('<TagsList />', () => {
 
   it('shows an error when tags failed to be loaded', () => {
     const wrapper = createWrapper({ error: true });
-    const errorMsg = wrapper.find('.bg-danger');
+    const errorMsg = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
 
     expect(errorMsg).toHaveLength(1);
     expect(errorMsg.html()).toContain('Error loading tags :(');
diff --git a/test/utils/Message.test.tsx b/test/utils/Message.test.tsx
new file mode 100644
index 00000000..036a6b49
--- /dev/null
+++ b/test/utils/Message.test.tsx
@@ -0,0 +1,64 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { PropsWithChildren } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Card } from 'reactstrap';
+import Message, { MessageProps } from '../../src/utils/Message';
+
+describe('<Message />', () => {
+  let wrapper: ShallowWrapper;
+  const createWrapper = (props: PropsWithChildren<MessageProps> = {}) => {
+    wrapper = shallow(<Message {...props} />);
+
+    return wrapper;
+  };
+
+  afterEach(() => wrapper?.unmount());
+
+  it.each([
+    [ true, 1, 0 ],
+    [ false, 0, 1 ],
+    [ undefined, 0, 1 ],
+  ])('renders expected classes based on width', (fullWidth, expectedFull, expectedNonFull) => {
+    const wrapper = createWrapper({ fullWidth });
+
+    expect(wrapper.find('.col-md-12')).toHaveLength(expectedFull);
+    expect(wrapper.find('.col-md-10')).toHaveLength(expectedNonFull);
+  });
+
+  it.each([
+    [ true, 'These are the children contents' ],
+    [ false, 'These are the children contents' ],
+    [ true, undefined ],
+    [ false, undefined ],
+  ])('renders expected content', (loading, children) => {
+    const wrapper = createWrapper({ loading, children });
+
+    expect(wrapper.find(FontAwesomeIcon)).toHaveLength(loading ? 1 : 0);
+
+    if (loading) {
+      expect(wrapper.find('span').text()).toContain(children ? children : 'Loading...');
+    } else {
+      expect(wrapper.find('span')).toHaveLength(0);
+      expect(wrapper.find('h3').text()).toContain(children ? children : '');
+    }
+  });
+
+  it.each([
+    [ 'error', 'border-danger', 'text-danger' ],
+    [ 'default', '', 'text-muted' ],
+    [ undefined, '', 'text-muted' ],
+  ])('renders proper classes based on message type', (type, expectedCardClass, expectedH3Class) => {
+    const wrapper = createWrapper({ type: type as 'default' | 'error' | undefined });
+    const card = wrapper.find(Card);
+    const h3 = wrapper.find('h3');
+
+    expect(card.prop('className')).toEqual(expectedCardClass);
+    expect(h3.prop('className')).toEqual(`text-center mb-0 ${expectedH3Class}`);
+  });
+
+  it.each([{ className: 'foo' }, { className: 'bar' }, {}])('renders provided classes', ({ className }) => {
+    const wrapper = createWrapper({ className });
+
+    expect(wrapper.prop('className')).toEqual(className);
+  });
+});
diff --git a/test/utils/Result.test.tsx b/test/utils/Result.test.tsx
new file mode 100644
index 00000000..ab0062d2
--- /dev/null
+++ b/test/utils/Result.test.tsx
@@ -0,0 +1,46 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { Result, ResultProps, ResultType } from '../../src/utils/Result';
+import { SimpleCard } from '../../src/utils/SimpleCard';
+
+describe('<Result />', () => {
+  let wrapper: ShallowWrapper;
+  const createWrapper = (props: ResultProps) => {
+    wrapper = shallow(<Result {...props} />);
+
+    return wrapper;
+  };
+
+  afterEach(() => wrapper?.unmount());
+
+  it.each([
+    [ 'success' as ResultType, 'bg-main text-white' ],
+    [ 'error' as ResultType, 'bg-danger text-white' ],
+    [ 'warning' as ResultType, 'bg-warning' ],
+  ])('renders expected classes based on type', (type, expectedClasses) => {
+    const wrapper = createWrapper({ type });
+    const innerCard = wrapper.find(SimpleCard);
+
+    expect(innerCard.prop('className')).toEqual(`text-center ${expectedClasses}`);
+  });
+
+  it.each([
+    [ undefined ],
+    [ 'foo' ],
+    [ 'bar' ],
+  ])('renders provided classes in root element', (className) => {
+    const wrapper = createWrapper({ type: 'success', className });
+
+    expect(wrapper.prop('className')).toEqual(className);
+  });
+
+  it.each([{ small: true }, { small: false }])('renders small results properly', ({ small }) => {
+    const wrapper = createWrapper({ type: 'success', small });
+    const bigElement = wrapper.find('.col-md-10');
+    const smallElement = wrapper.find('.col-12');
+    const innerCard = wrapper.find(SimpleCard);
+
+    expect(bigElement).toHaveLength(small ? 0 : 1);
+    expect(smallElement).toHaveLength(small ? 1 : 0);
+    expect(innerCard.prop('bodyClassName')).toEqual(small ? 'p-2' : '');
+  });
+});
diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx
index f5f00952..d878d2e8 100644
--- a/test/visits/VisitsStats.test.tsx
+++ b/test/visits/VisitsStats.test.tsx
@@ -1,5 +1,5 @@
 import { shallow, ShallowWrapper } from 'enzyme';
-import { Card, NavLink, Progress } from 'reactstrap';
+import { Progress } from 'reactstrap';
 import { Mock } from 'ts-mockery';
 import VisitStats from '../../src/visits/VisitsStats';
 import Message from '../../src/utils/Message';
@@ -8,6 +8,7 @@ import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph';
 import { Visit, VisitsInfo } from '../../src/visits/types';
 import LineChartCard from '../../src/visits/helpers/LineChartCard';
 import VisitsTable from '../../src/visits/VisitsTable';
+import { Result } from '../../src/utils/Result';
 
 describe('<VisitStats />', () => {
   const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ];
@@ -53,7 +54,7 @@ describe('<VisitStats />', () => {
 
   it('renders an error message when visits could not be loaded', () => {
     const wrapper = createComponent({ loading: false, error: true, visits: [] });
-    const errorMessage = wrapper.find(Card);
+    const errorMessage = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
 
     expect(errorMessage).toHaveLength(1);
     expect(errorMessage.html()).toContain('An error occurred while loading visits :(');
@@ -80,10 +81,6 @@ describe('<VisitStats />', () => {
 
   it('holds the map button content generator on cities graph extraHeaderContent', () => {
     const wrapper = createComponent({ loading: false, error: false, visits });
-    const locationNav = wrapper.find(NavLink).at(2);
-
-    locationNav.simulate('click');
-
     const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]');
     const extraHeaderContent = citiesGraph.prop('extraHeaderContent');