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

Issue:
Allow customizing the initial value for "validate URL"
<!--
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

Currently the `validateUrl` checkbox is always enabled by default. Allow to change this so that it can be disabled by default.


Pull request description:
Feature/validate urls setting
Closes #385 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 448a383b..4abdf179 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 ## [Unreleased]
 ### Added
 * [#379](https://github.com/shlinkio/shlink-web-client/issues/379) and [#384](https://github.com/shlinkio/shlink-web-client/issues/384) Improved QR code modal, including controls to customize size, format and margin, as well as a button to copy the link to the clipboard.
+* [#385](https://github.com/shlinkio/shlink-web-client/issues/385) Added setting to determine if "validate URL" should be enabled or disabled by default.
 
 ### Changed
 * *Nothing*
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index d2813af9..bbd524d9 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -1,9 +1,17 @@
 import { FC } from 'react';
+import { Row } from 'reactstrap';
 import NoMenuLayout from '../common/NoMenuLayout';
 
-const Settings = (RealTimeUpdates: FC) => () => (
+const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC) => () => (
   <NoMenuLayout>
-    <RealTimeUpdates />
+    <Row>
+      <div className="col-lg-6">
+        <RealTimeUpdates />
+      </div>
+      <div className="col-lg-6">
+        <ShortUrlCreation />
+      </div>
+    </Row>
   </NoMenuLayout>
 );
 
diff --git a/src/settings/ShortUrlCreation.tsx b/src/settings/ShortUrlCreation.tsx
new file mode 100644
index 00000000..afd88c12
--- /dev/null
+++ b/src/settings/ShortUrlCreation.tsx
@@ -0,0 +1,29 @@
+import { FC } from 'react';
+import { FormGroup } from 'reactstrap';
+import { SimpleCard } from '../utils/SimpleCard';
+import ToggleSwitch from '../utils/ToggleSwitch';
+import { Settings, ShortUrlCreationSettings } from './reducers/settings';
+
+interface ShortUrlCreationProps {
+  settings: Settings;
+  setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
+}
+
+export const ShortUrlCreation: FC<ShortUrlCreationProps> = (
+  { settings: { shortUrlCreation }, setShortUrlCreationSettings },
+) => (
+  <SimpleCard title="Short URLs creation">
+    <FormGroup className="mb-0">
+      <ToggleSwitch
+        checked={shortUrlCreation?.validateUrls ?? false}
+        onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })}
+      >
+        By default, request validation on long URLs when creating new short URLs.
+        <small className="form-text text-muted">
+          The initial state of the <b>Validate URL</b> checkbox will
+          be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
+        </small>
+      </ToggleSwitch>
+    </FormGroup>
+  </SimpleCard>
+);
diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts
index 8a21d146..aa1bc929 100644
--- a/src/settings/reducers/settings.ts
+++ b/src/settings/reducers/settings.ts
@@ -1,23 +1,36 @@
 import { Action } from 'redux';
-import { mergeDeepRight } from 'ramda';
+import { dissoc, mergeDeepRight } from 'ramda';
 import { buildReducer } from '../../utils/helpers/redux';
 import { RecursivePartial } from '../../utils/utils';
 
-export const SET_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/SET_REAL_TIME_UPDATES';
+export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
 
-interface RealTimeUpdates {
+/**
+ * Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as
+ * optional, as old instances of the app will load partial objects from local storage until it is saved again.
+ */
+
+interface RealTimeUpdatesSettings {
   enabled: boolean;
   interval?: number;
 }
 
+export interface ShortUrlCreationSettings {
+  validateUrls: boolean;
+}
+
 export interface Settings {
-  realTimeUpdates: RealTimeUpdates;
+  realTimeUpdates: RealTimeUpdatesSettings;
+  shortUrlCreation?: ShortUrlCreationSettings;
 }
 
 const initialState: Settings = {
   realTimeUpdates: {
     enabled: true,
   },
+  shortUrlCreation: {
+    validateUrls: false,
+  },
 };
 
 type SettingsAction = Action & Settings;
@@ -25,15 +38,20 @@ type SettingsAction = Action & Settings;
 type PartialSettingsAction = Action & RecursivePartial<Settings>;
 
 export default buildReducer<Settings, SettingsAction>({
-  [SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => mergeDeepRight(state, { realTimeUpdates }),
+  [SET_SETTINGS]: (state, action) => mergeDeepRight(state, dissoc('type', action)),
 }, initialState);
 
 export const toggleRealTimeUpdates = (enabled: boolean): PartialSettingsAction => ({
-  type: SET_REAL_TIME_UPDATES,
+  type: SET_SETTINGS,
   realTimeUpdates: { enabled },
 });
 
 export const setRealTimeUpdatesInterval = (interval: number): PartialSettingsAction => ({
-  type: SET_REAL_TIME_UPDATES,
+  type: SET_SETTINGS,
   realTimeUpdates: { interval },
 });
+
+export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings): PartialSettingsAction => ({
+  type: SET_SETTINGS,
+  shortUrlCreation: settings,
+});
diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts
index 5da9eca1..393ccefc 100644
--- a/src/settings/services/provideServices.ts
+++ b/src/settings/services/provideServices.ts
@@ -1,26 +1,30 @@
 import Bottle from 'bottlejs';
 import RealTimeUpdates from '../RealTimeUpdates';
 import Settings from '../Settings';
-import { setRealTimeUpdatesInterval, toggleRealTimeUpdates } from '../reducers/settings';
+import { setRealTimeUpdatesInterval, setShortUrlCreationSettings, toggleRealTimeUpdates } from '../reducers/settings';
 import { ConnectDecorator } from '../../container/types';
 import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
+import { ShortUrlCreation } from '../ShortUrlCreation';
 
 const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   // Components
-  bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates');
+  bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation');
   bottle.decorator('Settings', withoutSelectedServer);
   bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
 
-  // Services
   bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
   bottle.decorator(
     'RealTimeUpdates',
     connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
   );
 
+  bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation);
+  bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
+
   // Actions
   bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
   bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
+  bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
 };
 
 export default provideServices;
diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx
index 64c3926f..7bf2fe1f 100644
--- a/src/short-urls/CreateShortUrl.tsx
+++ b/src/short-urls/CreateShortUrl.tsx
@@ -1,5 +1,5 @@
 import { isEmpty, pipe, replace, trim } from 'ramda';
-import { FC, useState } from 'react';
+import { FC, useMemo, useState } from 'react';
 import { Button, FormGroup, Input } from 'reactstrap';
 import { InputType } from 'reactstrap/lib/Input';
 import * as m from 'moment';
@@ -12,6 +12,7 @@ import { formatIsoDate } from '../utils/helpers/date';
 import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
 import { DomainSelectorProps } from '../domains/DomainSelector';
 import { SimpleCard } from '../utils/SimpleCard';
+import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
 import { ShortUrlData } from './data';
 import { ShortUrlCreation } from './reducers/shortUrlCreation';
 import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
@@ -23,6 +24,7 @@ export interface CreateShortUrlProps {
 }
 
 interface CreateShortUrlConnectProps extends CreateShortUrlProps {
+  settings: Settings;
   shortUrlCreationResult: ShortUrlCreation;
   selectedServer: SelectedServer;
   createShortUrl: (data: ShortUrlData) => Promise<void>;
@@ -31,7 +33,7 @@ interface CreateShortUrlConnectProps extends CreateShortUrlProps {
 
 export const normalizeTag = pipe(trim, replace(/ /g, '-'));
 
-const initialState: ShortUrlData = {
+const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({
   longUrl: '',
   tags: [],
   customSlug: '',
@@ -41,8 +43,8 @@ const initialState: ShortUrlData = {
   validUntil: undefined,
   maxVisits: undefined,
   findIfExists: false,
-  validateUrl: true,
-};
+  validateUrl: settings?.validateUrls ?? false,
+});
 
 type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits';
 type DateFields = 'validSince' | 'validUntil';
@@ -58,9 +60,10 @@ const CreateShortUrl = (
   resetCreateShortUrl,
   selectedServer,
   basicMode = false,
+  settings: { shortUrlCreation: shortUrlCreationSettings },
 }: CreateShortUrlConnectProps) => {
+  const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [ shortUrlCreationSettings ]);
   const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
-
   const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
   const reset = () => setShortUrlCreation(initialState);
   const save = handleEventPreventingDefault(() => {
diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts
index 93d987b0..e40fd84f 100644
--- a/src/short-urls/services/provideServices.ts
+++ b/src/short-urls/services/provideServices.ts
@@ -56,7 +56,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   );
   bottle.decorator(
     'CreateShortUrl',
-    connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
+    connect([ 'shortUrlCreationResult', 'selectedServer', 'settings' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
   );
 
   bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);


Test patch:
diff --git a/test/settings/ShortUrlCreation.test.tsx b/test/settings/ShortUrlCreation.test.tsx
new file mode 100644
index 00000000..07d0bd7b
--- /dev/null
+++ b/test/settings/ShortUrlCreation.test.tsx
@@ -0,0 +1,54 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { Mock } from 'ts-mockery';
+import { ShortUrlCreationSettings, Settings } from '../../src/settings/reducers/settings';
+import { ShortUrlCreation } from '../../src/settings/ShortUrlCreation';
+import ToggleSwitch from '../../src/utils/ToggleSwitch';
+
+describe('<ShortUrlCreation />', () => {
+  let wrapper: ShallowWrapper;
+  const setShortUrlCreationSettings = jest.fn();
+  const createWrapper = (shortUrlCreation?: ShortUrlCreationSettings) => {
+    wrapper = shallow(
+      <ShortUrlCreation
+        settings={Mock.of<Settings>({ shortUrlCreation })}
+        setShortUrlCreationSettings={setShortUrlCreationSettings}
+      />,
+    );
+
+    return wrapper;
+  };
+
+  afterEach(() => wrapper?.unmount());
+  afterEach(jest.clearAllMocks);
+
+  it.each([
+    [{ validateUrls: true }, true ],
+    [{ validateUrls: false }, false ],
+    [ undefined, false ],
+  ])('switch is toggled if option is tru', (shortUrlCreation, expectedChecked) => {
+    const wrapper = createWrapper(shortUrlCreation);
+    const toggle = wrapper.find(ToggleSwitch);
+
+    expect(toggle.prop('checked')).toEqual(expectedChecked);
+  });
+
+  it.each([[ true ], [ false ]])('invokes setShortUrlCreationSettings when toggle value changes', (validateUrls) => {
+    const wrapper = createWrapper();
+    const toggle = wrapper.find(ToggleSwitch);
+
+    expect(setShortUrlCreationSettings).not.toHaveBeenCalled();
+    toggle.simulate('change', validateUrls);
+    expect(setShortUrlCreationSettings).toHaveBeenCalledWith({ validateUrls });
+  });
+
+  it.each([
+    [{ validateUrls: true }, 'checkbox will be checked' ],
+    [{ validateUrls: false }, 'checkbox will be unchecked' ],
+    [ undefined, 'checkbox will be unchecked' ],
+  ])('shows expected helper text', (shortUrlCreation, expectedText) => {
+    const wrapper = createWrapper(shortUrlCreation);
+    const text = wrapper.find('.form-text');
+
+    expect(text.text()).toContain(expectedText);
+  });
+});
diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts
index 9018b311..1bfd1701 100644
--- a/test/settings/reducers/settings.test.ts
+++ b/test/settings/reducers/settings.test.ts
@@ -1,11 +1,18 @@
-import reducer, { SET_REAL_TIME_UPDATES, toggleRealTimeUpdates, setRealTimeUpdatesInterval } from '../../../src/settings/reducers/settings';
+import reducer, {
+  SET_SETTINGS,
+  toggleRealTimeUpdates,
+  setRealTimeUpdatesInterval,
+  setShortUrlCreationSettings,
+} from '../../../src/settings/reducers/settings';
 
 describe('settingsReducer', () => {
   const realTimeUpdates = { enabled: true };
+  const shortUrlCreation = { validateUrls: false };
+  const settings = { realTimeUpdates, shortUrlCreation };
 
   describe('reducer', () => {
-    it('returns realTimeUpdates when action is SET_REAL_TIME_UPDATES', () => {
-      expect(reducer(undefined, { type: SET_REAL_TIME_UPDATES, realTimeUpdates })).toEqual({ realTimeUpdates });
+    it('returns realTimeUpdates when action is SET_SETTINGS', () => {
+      expect(reducer(undefined, { type: SET_SETTINGS, realTimeUpdates })).toEqual(settings);
     });
   });
 
@@ -13,7 +20,7 @@ describe('settingsReducer', () => {
     it.each([[ true ], [ false ]])('updates settings with provided value and then loads updates again', (enabled) => {
       const result = toggleRealTimeUpdates(enabled);
 
-      expect(result).toEqual({ type: SET_REAL_TIME_UPDATES, realTimeUpdates: { enabled } });
+      expect(result).toEqual({ type: SET_SETTINGS, realTimeUpdates: { enabled } });
     });
   });
 
@@ -21,7 +28,15 @@ describe('settingsReducer', () => {
     it.each([[ 0 ], [ 1 ], [ 2 ], [ 10 ]])('updates settings with provided value and then loads updates again', (interval) => {
       const result = setRealTimeUpdatesInterval(interval);
 
-      expect(result).toEqual({ type: SET_REAL_TIME_UPDATES, realTimeUpdates: { interval } });
+      expect(result).toEqual({ type: SET_SETTINGS, realTimeUpdates: { interval } });
+    });
+  });
+
+  describe('setShortUrlCreationSettings', () => {
+    it('creates action to set shortUrlCreation settings', () => {
+      const result = setShortUrlCreationSettings({ validateUrls: true });
+
+      expect(result).toEqual({ type: SET_SETTINGS, shortUrlCreation: { validateUrls: true } });
     });
   });
 });
diff --git a/test/short-urls/CreateShortUrl.test.tsx b/test/short-urls/CreateShortUrl.test.tsx
index e4d0d5ab..ca36661f 100644
--- a/test/short-urls/CreateShortUrl.test.tsx
+++ b/test/short-urls/CreateShortUrl.test.tsx
@@ -6,10 +6,12 @@ import { Input } from 'reactstrap';
 import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl';
 import DateInput from '../../src/utils/DateInput';
 import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation';
+import { Settings } from '../../src/settings/reducers/settings';
 
 describe('<CreateShortUrl />', () => {
   let wrapper: ShallowWrapper;
   const TagsSelector = () => null;
+  const shortUrlCreation = { validateUrls: true };
   const shortUrlCreationResult = Mock.all<ShortUrlCreation>();
   const createShortUrl = jest.fn(async () => Promise.resolve());
 
@@ -22,6 +24,7 @@ describe('<CreateShortUrl />', () => {
         createShortUrl={createShortUrl}
         selectedServer={null}
         resetCreateShortUrl={() => {}}
+        settings={Mock.of<Settings>({ shortUrlCreation })}
       />,
     );
   });