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) => () => ( - + +
+ +
+
+ +
+
); 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 = ( + { settings: { shortUrlCreation }, setShortUrlCreationSettings }, +) => ( + + + setShortUrlCreationSettings({ validateUrls })} + > + By default, request validation on long URLs when creating new short URLs. + + The initial state of the Validate URL checkbox will + be {shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}. + + + + +); 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; export default buildReducer({ - [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; @@ -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); 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('', () => { + let wrapper: ShallowWrapper; + const setShortUrlCreationSettings = jest.fn(); + const createWrapper = (shortUrlCreation?: ShortUrlCreationSettings) => { + wrapper = shallow( + ({ 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('', () => { let wrapper: ShallowWrapper; const TagsSelector = () => null; + const shortUrlCreation = { validateUrls: true }; const shortUrlCreationResult = Mock.all(); const createShortUrl = jest.fn(async () => Promise.resolve()); @@ -22,6 +24,7 @@ describe('', () => { createShortUrl={createShortUrl} selectedServer={null} resetCreateShortUrl={() => {}} + settings={Mock.of({ shortUrlCreation })} />, ); });