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

Issue:
List existing domains on short URL creation as a drop-down
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
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.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.

With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->

#### Summary

When creating new short URLs, it is possible to provide a custom domain, but right now, it's done through a text field.

The component should be a drop-down displaying already used domains, but also allowing the input of new values.


Pull request description:
Feature/domains dropdown
Closes #309 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7021d265..1ce3226f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
 
+## [Unreleased]
+### Added
+* [#309](https://github.com/shlinkio/shlink-web-client/issues/309) Added new domain selector component in create URL form which allows selecting from previously used domains or set a new one.
+
+### Changed
+* *Nothing*
+
+### Deprecated
+* *Nothing*
+
+### Removed
+* *Nothing*
+
+### Fixed
+* *Nothing*
+
+
 ## [2.6.2] - 2020-11-14
 ### Added
 * *Nothing*
diff --git a/src/container/index.ts b/src/container/index.ts
index 83885353..685fbf84 100644
--- a/src/container/index.ts
+++ b/src/container/index.ts
@@ -11,6 +11,7 @@ import provideTagsServices from '../tags/services/provideServices';
 import provideUtilsServices from '../utils/services/provideServices';
 import provideMercureServices from '../mercure/services/provideServices';
 import provideSettingsServices from '../settings/services/provideServices';
+import provideDomainsServices from '../domains/services/provideServices';
 import { ConnectDecorator } from './types';
 
 type LazyActionMap = Record<string, Function>;
@@ -41,5 +42,6 @@ provideVisitsServices(bottle, connect);
 provideUtilsServices(bottle);
 provideMercureServices(bottle);
 provideSettingsServices(bottle, connect);
+provideDomainsServices(bottle, connect);
 
 export default container;
diff --git a/src/container/types.ts b/src/container/types.ts
index a9ebbde3..d5c4d8e0 100644
--- a/src/container/types.ts
+++ b/src/container/types.ts
@@ -14,6 +14,7 @@ import { TagsList } from '../tags/reducers/tagsList';
 import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
 import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
 import { TagVisits } from '../visits/reducers/tagVisits';
+import { DomainsList } from '../domains/reducers/domainsList';
 
 export interface ShlinkState {
   servers: ServersMap;
@@ -33,6 +34,7 @@ export interface ShlinkState {
   tagEdit: TagEdition;
   mercureInfo: MercureInfo;
   settings: Settings;
+  domainsList: DomainsList;
 }
 
 export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
diff --git a/src/domains/DomainSelector.scss b/src/domains/DomainSelector.scss
new file mode 100644
index 00000000..c9ab0ba0
--- /dev/null
+++ b/src/domains/DomainSelector.scss
@@ -0,0 +1,31 @@
+@import '../utils/mixins/vertical-align';
+
+.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
+.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover,
+.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active {
+  text-align: left;
+  color: #6c757d;
+  border-color: #ced4da;
+  background-color: white;
+}
+
+.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
+.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
+.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
+  color: #495057;
+}
+
+.domains-dropdown__back-btn.domains-dropdown__back-btn,
+.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
+  border-color: #ced4da;
+}
+
+.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn::after {
+  right: .75rem;
+
+  @include vertical-align();
+}
+
+.domains-dropdown__menu {
+  width: 100%;
+}
diff --git a/src/domains/DomainSelector.tsx b/src/domains/DomainSelector.tsx
new file mode 100644
index 00000000..ec53331e
--- /dev/null
+++ b/src/domains/DomainSelector.tsx
@@ -0,0 +1,95 @@
+import { useEffect } from 'react';
+import {
+  Button,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  Input,
+  InputGroup,
+  InputGroupAddon,
+  InputProps,
+  UncontrolledTooltip,
+} from 'reactstrap';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faUndo } from '@fortawesome/free-solid-svg-icons';
+import { isEmpty, pipe } from 'ramda';
+import classNames from 'classnames';
+import { useToggle } from '../utils/helpers/hooks';
+import { DomainsList } from './reducers/domainsList';
+import './DomainSelector.scss';
+
+export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {
+  value?: string;
+  onChange: (domain: string) => void;
+}
+
+interface DomainSelectorConnectProps extends DomainSelectorProps {
+  listDomains: Function;
+  domainsList: DomainsList;
+}
+
+export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
+  const [ inputDisplayed,, showInput, hideInput ] = useToggle();
+  const [ isDropdownOpen, toggleDropdown ] = useToggle();
+  const { domains } = domainsList;
+  const valueIsEmpty = isEmpty(value);
+  const unselectDomain = () => onChange('');
+
+  useEffect(() => {
+    listDomains();
+  }, []);
+
+  return inputDisplayed ? (
+    <InputGroup>
+      <Input
+        value={value}
+        placeholder="Domain"
+        onChange={(e) => onChange(e.target.value)}
+      />
+      <InputGroupAddon addonType="append">
+        <Button
+          id="backToDropdown"
+          outline
+          type="button"
+          className="domains-dropdown__back-btn"
+          onClick={pipe(unselectDomain, hideInput)}
+        >
+          <FontAwesomeIcon icon={faUndo} />
+        </Button>
+        <UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
+          Existing domains
+        </UncontrolledTooltip>
+      </InputGroupAddon>
+    </InputGroup>
+  ) : (
+    <Dropdown isOpen={isDropdownOpen} toggle={toggleDropdown}>
+      <DropdownToggle
+        caret
+        className={classNames(
+          'domains-dropdown__toggle-btn btn-block',
+          { 'domains-dropdown__toggle-btn--active': !valueIsEmpty },
+        )}
+      >
+        {valueIsEmpty && <>Domain</>}
+        {!valueIsEmpty && <>Domain: {value}</>}
+      </DropdownToggle>
+      <DropdownMenu className="domains-dropdown__menu">
+        {domains.map(({ domain, isDefault }) => (
+          <DropdownItem
+            key={domain}
+            active={value === domain || isDefault && valueIsEmpty}
+            onClick={() => onChange(domain)}
+          >
+            {domain}
+            {isDefault && <span className="float-right text-muted">default</span>}
+          </DropdownItem>
+        ))}
+        <DropdownItem divider />
+        <DropdownItem onClick={pipe(unselectDomain, showInput)}>
+          <i>New domain</i>
+        </DropdownItem>
+      </DropdownMenu>
+    </Dropdown>
+  );
+};
diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts
new file mode 100644
index 00000000..4a51acab
--- /dev/null
+++ b/src/domains/reducers/domainsList.ts
@@ -0,0 +1,49 @@
+import { Action, Dispatch } from 'redux';
+import { ShlinkDomain } from '../../utils/services/types';
+import { buildReducer } from '../../utils/helpers/redux';
+import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
+import { GetState } from '../../container/types';
+
+/* eslint-disable padding-line-between-statements */
+export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
+export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
+export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
+/* eslint-enable padding-line-between-statements */
+
+export interface DomainsList {
+  domains: ShlinkDomain[];
+  loading: boolean;
+  error: boolean;
+}
+
+export interface ListDomainsAction extends Action<string> {
+  domains: ShlinkDomain[];
+}
+
+const initialState: DomainsList = {
+  domains: [],
+  loading: false,
+  error: false,
+};
+
+export default buildReducer<DomainsList, ListDomainsAction>({
+  [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
+  [LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
+  [LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
+}, initialState);
+
+export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
+  dispatch: Dispatch,
+  getState: GetState,
+) => {
+  dispatch({ type: LIST_DOMAINS_START });
+  const { listDomains } = buildShlinkApiClient(getState);
+
+  try {
+    const domains = await listDomains();
+
+    dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
+  } catch (e) {
+    dispatch({ type: LIST_DOMAINS_ERROR });
+  }
+};
diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts
new file mode 100644
index 00000000..bd56d8a2
--- /dev/null
+++ b/src/domains/services/provideServices.ts
@@ -0,0 +1,15 @@
+import Bottle from 'bottlejs';
+import { ConnectDecorator } from '../../container/types';
+import { listDomains } from '../reducers/domainsList';
+import { DomainSelector } from '../DomainSelector';
+
+const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
+  // Components
+  bottle.serviceFactory('DomainSelector', () => DomainSelector);
+  bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
+
+  // Actions
+  bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
+};
+
+export default provideServices;
diff --git a/src/reducers/index.ts b/src/reducers/index.ts
index 58bf18ad..6ec599e1 100644
--- a/src/reducers/index.ts
+++ b/src/reducers/index.ts
@@ -16,6 +16,7 @@ import tagDeleteReducer from '../tags/reducers/tagDelete';
 import tagEditReducer from '../tags/reducers/tagEdit';
 import mercureInfoReducer from '../mercure/reducers/mercureInfo';
 import settingsReducer from '../settings/reducers/settings';
+import domainsListReducer from '../domains/reducers/domainsList';
 import { ShlinkState } from '../container/types';
 
 export default combineReducers<ShlinkState>({
@@ -36,4 +37,5 @@ export default combineReducers<ShlinkState>({
   tagEdit: tagEditReducer,
   mercureInfo: mercureInfoReducer,
   settings: settingsReducer,
+  domainsList: domainsListReducer,
 });
diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx
index 8b1a4b50..da07dffa 100644
--- a/src/short-urls/CreateShortUrl.tsx
+++ b/src/short-urls/CreateShortUrl.tsx
@@ -13,6 +13,7 @@ import { useToggle } from '../utils/helpers/hooks';
 import { isReachableServer, SelectedServer } from '../servers/data';
 import { formatIsoDate } from '../utils/helpers/date';
 import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
+import { DomainSelectorProps } from '../domains/DomainSelector';
 import { ShortUrlData } from './data';
 import { ShortUrlCreation } from './reducers/shortUrlCreation';
 import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
@@ -46,6 +47,7 @@ const CreateShortUrl = (
   TagsSelector: FC<TagsSelectorProps>,
   CreateShortUrlResult: FC<CreateShortUrlResultProps>,
   ForServerVersion: FC<Versions>,
+  DomainSelector: FC<DomainSelectorProps>,
 ) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
   const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
   const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
@@ -87,6 +89,7 @@ const CreateShortUrl = (
 
   const currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
   const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
+  const showDomainSelector = versionMatch(currentServerVersion, { minVersion: '2.4.0' });
   const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
 
   return (
@@ -123,10 +126,18 @@ const CreateShortUrl = (
             })}
           </div>
           <div className="col-sm-4">
-            {renderOptionalInput('domain', 'Domain', 'text', {
+            {!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text', {
               disabled: disableDomain,
               ...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
             })}
+            {showDomainSelector && (
+              <FormGroup>
+                <DomainSelector
+                  value={shortUrlCreation.domain}
+                  onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
+                />
+              </FormGroup>
+            )}
           </div>
         </div>
 
@@ -145,6 +156,7 @@ const CreateShortUrl = (
         <ForServerVersion minVersion="1.16.0">
           <div className="mb-4 text-right">
             <Checkbox
+              inline
               className="mr-2"
               checked={shortUrlCreation.findIfExists}
               onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts
index b7ee0102..0735af20 100644
--- a/src/short-urls/services/provideServices.ts
+++ b/src/short-urls/services/provideServices.ts
@@ -51,7 +51,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   );
   bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
 
-  bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
+  bottle.serviceFactory(
+    'CreateShortUrl',
+    CreateShortUrl,
+    'TagsSelector',
+    'CreateShortUrlResult',
+    'ForServerVersion',
+    'DomainSelector',
+  );
   bottle.decorator(
     'CreateShortUrl',
     connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx
index 7717d198..1611ac03 100644
--- a/src/tags/helpers/TagsSelector.tsx
+++ b/src/tags/helpers/TagsSelector.tsx
@@ -17,6 +17,8 @@ interface TagsSelectorConnectProps extends TagsSelectorProps {
   tagsList: TagsList;
 }
 
+const noop = () => {};
+
 const TagsSelector = (colorGenerator: ColorGenerator) => (
   { tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
 ) => {
@@ -55,8 +57,8 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
             {suggestion}
           </>
         )}
-        onSuggestionsFetchRequested={() => {}}
-        onSuggestionsClearRequested={() => {}}
+        onSuggestionsFetchRequested={noop}
+        onSuggestionsClearRequested={noop}
         onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
           addTag(suggestion);
         }}
diff --git a/src/utils/BooleanControl.tsx b/src/utils/BooleanControl.tsx
index 9b25b69c..72f7056e 100644
--- a/src/utils/BooleanControl.tsx
+++ b/src/utils/BooleanControl.tsx
@@ -7,6 +7,7 @@ export interface BooleanControlProps {
   checked?: boolean;
   onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
   className?: string;
+  inline?: boolean;
 }
 
 interface BooleanControlWithTypeProps extends BooleanControlProps {
@@ -14,7 +15,7 @@ interface BooleanControlWithTypeProps extends BooleanControlProps {
 }
 
 const BooleanControl: FC<BooleanControlWithTypeProps> = (
-  { checked = false, onChange = identity, className, children, type },
+  { checked = false, onChange = identity, className, children, type, inline = false },
 ) => {
   const id = uuid();
   const onChecked = (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked, e);
@@ -22,9 +23,10 @@ const BooleanControl: FC<BooleanControlWithTypeProps> = (
     'custom-switch': type === 'switch',
     'custom-checkbox': type === 'checkbox',
   };
+  const style = inline ? { display: 'inline-block' } : {};
 
   return (
-    <span className={classNames('custom-control', typeClasses, className)}>
+    <span className={classNames('custom-control', typeClasses, className)} style={style}>
       <input type="checkbox" className="custom-control-input" id={id} checked={checked} onChange={onChecked} />
       <label className="custom-control-label" htmlFor={id}>{children}</label>
     </span>
diff --git a/src/utils/services/ShlinkApiClient.ts b/src/utils/services/ShlinkApiClient.ts
index a4db10ad..03f5fa71 100644
--- a/src/utils/services/ShlinkApiClient.ts
+++ b/src/utils/services/ShlinkApiClient.ts
@@ -13,6 +13,8 @@ import {
   ShlinkVisits,
   ShlinkVisitsParams,
   ShlinkShortUrlMeta,
+  ShlinkDomain,
+  ShlinkDomainsResponse,
 } from './types';
 
 const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
@@ -93,6 +95,9 @@ export default class ShlinkApiClient {
     this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
       .then((resp) => resp.data);
 
+  public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
+    this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
+
   private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
     try {
       return await this.axios({
diff --git a/src/utils/services/types.ts b/src/utils/services/types.ts
index 9c10ce7d..df7687c3 100644
--- a/src/utils/services/types.ts
+++ b/src/utils/services/types.ts
@@ -64,3 +64,12 @@ export interface ProblemDetailsError {
   message?: string; // Deprecated
   [extraProps: string]: any;
 }
+
+export interface ShlinkDomain {
+  domain: string;
+  isDefault: boolean;
+}
+
+export interface ShlinkDomainsResponse {
+  data: ShlinkDomain[];
+}


Test patch:
diff --git a/test/domains/DomainSelector.test.tsx b/test/domains/DomainSelector.test.tsx
new file mode 100644
index 00000000..2e7a3d2f
--- /dev/null
+++ b/test/domains/DomainSelector.test.tsx
@@ -0,0 +1,42 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { Mock } from 'ts-mockery';
+import { DropdownItem, DropdownMenu, InputGroup } from 'reactstrap';
+import { DomainSelector } from '../../src/domains/DomainSelector';
+import { DomainsList } from '../../src/domains/reducers/domainsList';
+import { ShlinkDomain } from '../../src/utils/services/types';
+
+describe('<DomainSelector />', () => {
+  let wrapper: ShallowWrapper;
+  const domainsList = Mock.of<DomainsList>({
+    domains: [
+      Mock.of<ShlinkDomain>({ domain: 'foo.com' }),
+      Mock.of<ShlinkDomain>({ domain: 'bar.com' }),
+    ],
+  });
+
+  beforeEach(() => {
+    wrapper = shallow(<DomainSelector domainsList={domainsList} listDomains={jest.fn()} onChange={jest.fn()} />);
+  });
+
+  afterEach(jest.clearAllMocks);
+  afterEach(() => wrapper.unmount());
+
+  it('shows dropdown by default', () => {
+    const input = wrapper.find(InputGroup);
+    const dropdown = wrapper.find(DropdownMenu);
+
+    expect(input).toHaveLength(0);
+    expect(dropdown).toHaveLength(1);
+    expect(dropdown.find(DropdownItem)).toHaveLength(4);
+  });
+
+  it('allows to toggle between dropdown and input', () => {
+    wrapper.find(DropdownItem).last().simulate('click');
+    expect(wrapper.find(InputGroup)).toHaveLength(1);
+    expect(wrapper.find(DropdownMenu)).toHaveLength(0);
+
+    wrapper.find('.domains-dropdown__back-btn').simulate('click');
+    expect(wrapper.find(InputGroup)).toHaveLength(0);
+    expect(wrapper.find(DropdownMenu)).toHaveLength(1);
+  });
+});
diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts
new file mode 100644
index 00000000..1e090770
--- /dev/null
+++ b/test/domains/reducers/domainsList.test.ts
@@ -0,0 +1,63 @@
+import { Mock } from 'ts-mockery';
+import reducer, {
+  LIST_DOMAINS,
+  LIST_DOMAINS_ERROR,
+  LIST_DOMAINS_START,
+  ListDomainsAction,
+  listDomains as listDomainsAction,
+} from '../../../src/domains/reducers/domainsList';
+import { ShlinkDomain } from '../../../src/utils/services/types';
+import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
+
+describe('domainsList', () => {
+  const domains = [ Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>() ];
+
+  describe('reducer', () => {
+    const action = (type: string, args: Partial<ListDomainsAction> = {}) => Mock.of<ListDomainsAction>(
+      { type, ...args },
+    );
+
+    it('returns loading on LIST_DOMAINS_START', () => {
+      expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual({ domains: [], loading: true, error: false });
+    });
+
+    it('returns error on LIST_DOMAINS_ERROR', () => {
+      expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual({ domains: [], loading: false, error: true });
+    });
+
+    it('returns domains on LIST_DOMAINS', () => {
+      expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual({ domains, loading: false, error: false });
+    });
+  });
+
+  describe('listDomains', () => {
+    const dispatch = jest.fn();
+    const getState = jest.fn();
+    const listDomains = jest.fn();
+    const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ listDomains });
+
+    beforeEach(jest.clearAllMocks);
+
+    it('dispatches error when loading domains fails', async () => {
+      listDomains.mockRejectedValue(new Error('error'));
+
+      await listDomainsAction(buildShlinkApiClient)()(dispatch, getState);
+
+      expect(dispatch).toHaveBeenCalledTimes(2);
+      expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START });
+      expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS_ERROR });
+      expect(listDomains).toHaveBeenCalledTimes(1);
+    });
+
+    it('dispatches domains once loaded', async () => {
+      listDomains.mockResolvedValue(domains);
+
+      await listDomainsAction(buildShlinkApiClient)()(dispatch, getState);
+
+      expect(dispatch).toHaveBeenCalledTimes(2);
+      expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START });
+      expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS, domains });
+      expect(listDomains).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/test/short-urls/CreateShortUrl.test.tsx b/test/short-urls/CreateShortUrl.test.tsx
index 13493238..4aeb72bb 100644
--- a/test/short-urls/CreateShortUrl.test.tsx
+++ b/test/short-urls/CreateShortUrl.test.tsx
@@ -13,7 +13,7 @@ describe('<CreateShortUrl />', () => {
   const createShortUrl = jest.fn(async () => Promise.resolve());
 
   beforeEach(() => {
-    const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null);
+    const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null, () => null);
 
     wrapper = shallow(
       <CreateShortUrl
diff --git a/test/utils/Checkbox.test.tsx b/test/utils/Checkbox.test.tsx
index f9d08d48..444924f4 100644
--- a/test/utils/Checkbox.test.tsx
+++ b/test/utils/Checkbox.test.tsx
@@ -60,4 +60,11 @@ describe('<Checkbox />', () => {
 
     expect(onChange).toHaveBeenCalledWith(false, e);
   });
+
+  it('allows setting inline rendering', () => {
+    const wrapped = createComponent({ inline: true });
+    const control = wrapped.find('.custom-control');
+
+    expect(control.prop('style')).toEqual({ display: 'inline-block' });
+  });
 });
diff --git a/test/utils/services/ShlinkApiClient.test.ts b/test/utils/services/ShlinkApiClient.test.ts
index b581885d..4838712f 100644
--- a/test/utils/services/ShlinkApiClient.test.ts
+++ b/test/utils/services/ShlinkApiClient.test.ts
@@ -1,6 +1,8 @@
 import { AxiosInstance, AxiosRequestConfig } from 'axios';
 import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
 import { OptionalString } from '../../../src/utils/utils';
+import { Mock } from 'ts-mockery';
+import { ShlinkDomain } from '../../../src/utils/services/types';
 
 describe('ShlinkApiClient', () => {
   const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
@@ -251,4 +253,20 @@ describe('ShlinkApiClient', () => {
       expect(result).toEqual(expectedData);
     });
   });
+
+  describe('listDomains', () => {
+    it('returns domains', async () => {
+      const expectedData = [Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>()];
+      const resp = {
+        domains: { data: expectedData },
+      };
+      const axiosSpy = createAxiosMock({ data: resp });
+      const { listDomains } = new ShlinkApiClient(axiosSpy, '', '');
+
+      const result = await listDomains();
+
+      expect(axiosSpy).toHaveBeenCalled();
+      expect(result).toEqual(expectedData);
+    });
+  });
 });