Merge pull request #389 from acelaya-forks/feature/validate-urls-setting

Feature/validate urls setting
This commit is contained in:
Alejandro Celaya 2021-02-14 17:38:08 +01:00 committed by GitHub
commit 3c53f7d0fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 158 additions and 23 deletions

View file

@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased] ## [Unreleased]
### Added ### 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. * [#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 ### Changed
* *Nothing* * *Nothing*

View file

@ -1,9 +1,17 @@
import { FC } from 'react'; import { FC } from 'react';
import { Row } from 'reactstrap';
import NoMenuLayout from '../common/NoMenuLayout'; import NoMenuLayout from '../common/NoMenuLayout';
const Settings = (RealTimeUpdates: FC) => () => ( const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC) => () => (
<NoMenuLayout> <NoMenuLayout>
<Row>
<div className="col-lg-6">
<RealTimeUpdates /> <RealTimeUpdates />
</div>
<div className="col-lg-6">
<ShortUrlCreation />
</div>
</Row>
</NoMenuLayout> </NoMenuLayout>
); );

View file

@ -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>
);

View file

@ -1,23 +1,36 @@
import { Action } from 'redux'; import { Action } from 'redux';
import { mergeDeepRight } from 'ramda'; import { dissoc, mergeDeepRight } from 'ramda';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { RecursivePartial } from '../../utils/utils'; 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; enabled: boolean;
interval?: number; interval?: number;
} }
export interface ShortUrlCreationSettings {
validateUrls: boolean;
}
export interface Settings { export interface Settings {
realTimeUpdates: RealTimeUpdates; realTimeUpdates: RealTimeUpdatesSettings;
shortUrlCreation?: ShortUrlCreationSettings;
} }
const initialState: Settings = { const initialState: Settings = {
realTimeUpdates: { realTimeUpdates: {
enabled: true, enabled: true,
}, },
shortUrlCreation: {
validateUrls: false,
},
}; };
type SettingsAction = Action & Settings; type SettingsAction = Action & Settings;
@ -25,15 +38,20 @@ type SettingsAction = Action & Settings;
type PartialSettingsAction = Action & RecursivePartial<Settings>; type PartialSettingsAction = Action & RecursivePartial<Settings>;
export default buildReducer<Settings, SettingsAction>({ export default buildReducer<Settings, SettingsAction>({
[SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => mergeDeepRight(state, { realTimeUpdates }), [SET_SETTINGS]: (state, action) => mergeDeepRight(state, dissoc('type', action)),
}, initialState); }, initialState);
export const toggleRealTimeUpdates = (enabled: boolean): PartialSettingsAction => ({ export const toggleRealTimeUpdates = (enabled: boolean): PartialSettingsAction => ({
type: SET_REAL_TIME_UPDATES, type: SET_SETTINGS,
realTimeUpdates: { enabled }, realTimeUpdates: { enabled },
}); });
export const setRealTimeUpdatesInterval = (interval: number): PartialSettingsAction => ({ export const setRealTimeUpdatesInterval = (interval: number): PartialSettingsAction => ({
type: SET_REAL_TIME_UPDATES, type: SET_SETTINGS,
realTimeUpdates: { interval }, realTimeUpdates: { interval },
}); });
export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings): PartialSettingsAction => ({
type: SET_SETTINGS,
shortUrlCreation: settings,
});

View file

@ -1,26 +1,30 @@
import Bottle from 'bottlejs'; import Bottle from 'bottlejs';
import RealTimeUpdates from '../RealTimeUpdates'; import RealTimeUpdates from '../RealTimeUpdates';
import Settings from '../Settings'; import Settings from '../Settings';
import { setRealTimeUpdatesInterval, toggleRealTimeUpdates } from '../reducers/settings'; import { setRealTimeUpdatesInterval, setShortUrlCreationSettings, toggleRealTimeUpdates } from '../reducers/settings';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ShortUrlCreation } from '../ShortUrlCreation';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates'); bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation');
bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
// Services
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates); bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
bottle.decorator( bottle.decorator(
'RealTimeUpdates', 'RealTimeUpdates',
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]), connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
); );
bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation);
bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
// Actions // Actions
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
}; };
export default provideServices; export default provideServices;

View file

@ -1,5 +1,5 @@
import { isEmpty, pipe, replace, trim } from 'ramda'; 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 { Button, FormGroup, Input } from 'reactstrap';
import { InputType } from 'reactstrap/lib/Input'; import { InputType } from 'reactstrap/lib/Input';
import * as m from 'moment'; import * as m from 'moment';
@ -12,6 +12,7 @@ import { formatIsoDate } from '../utils/helpers/date';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { DomainSelectorProps } from '../domains/DomainSelector'; import { DomainSelectorProps } from '../domains/DomainSelector';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import { ShortUrlData } from './data'; import { ShortUrlData } from './data';
import { ShortUrlCreation } from './reducers/shortUrlCreation'; import { ShortUrlCreation } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
@ -23,6 +24,7 @@ export interface CreateShortUrlProps {
} }
interface CreateShortUrlConnectProps extends CreateShortUrlProps { interface CreateShortUrlConnectProps extends CreateShortUrlProps {
settings: Settings;
shortUrlCreationResult: ShortUrlCreation; shortUrlCreationResult: ShortUrlCreation;
selectedServer: SelectedServer; selectedServer: SelectedServer;
createShortUrl: (data: ShortUrlData) => Promise<void>; createShortUrl: (data: ShortUrlData) => Promise<void>;
@ -31,7 +33,7 @@ interface CreateShortUrlConnectProps extends CreateShortUrlProps {
export const normalizeTag = pipe(trim, replace(/ /g, '-')); export const normalizeTag = pipe(trim, replace(/ /g, '-'));
const initialState: ShortUrlData = { const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({
longUrl: '', longUrl: '',
tags: [], tags: [],
customSlug: '', customSlug: '',
@ -41,8 +43,8 @@ const initialState: ShortUrlData = {
validUntil: undefined, validUntil: undefined,
maxVisits: undefined, maxVisits: undefined,
findIfExists: false, findIfExists: false,
validateUrl: true, validateUrl: settings?.validateUrls ?? false,
}; });
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits'; type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits';
type DateFields = 'validSince' | 'validUntil'; type DateFields = 'validSince' | 'validUntil';
@ -58,9 +60,10 @@ const CreateShortUrl = (
resetCreateShortUrl, resetCreateShortUrl,
selectedServer, selectedServer,
basicMode = false, basicMode = false,
settings: { shortUrlCreation: shortUrlCreationSettings },
}: CreateShortUrlConnectProps) => { }: CreateShortUrlConnectProps) => {
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [ shortUrlCreationSettings ]);
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState); const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) }); const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlCreation(initialState); const reset = () => setShortUrlCreation(initialState);
const save = handleEventPreventingDefault(() => { const save = handleEventPreventingDefault(() => {

View file

@ -56,7 +56,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
); );
bottle.decorator( bottle.decorator(
'CreateShortUrl', 'CreateShortUrl',
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ]), connect([ 'shortUrlCreationResult', 'selectedServer', 'settings' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
); );
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);

View file

@ -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);
});
});

View file

@ -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', () => { describe('settingsReducer', () => {
const realTimeUpdates = { enabled: true }; const realTimeUpdates = { enabled: true };
const shortUrlCreation = { validateUrls: false };
const settings = { realTimeUpdates, shortUrlCreation };
describe('reducer', () => { describe('reducer', () => {
it('returns realTimeUpdates when action is SET_REAL_TIME_UPDATES', () => { it('returns realTimeUpdates when action is SET_SETTINGS', () => {
expect(reducer(undefined, { type: SET_REAL_TIME_UPDATES, realTimeUpdates })).toEqual({ realTimeUpdates }); 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) => { it.each([[ true ], [ false ]])('updates settings with provided value and then loads updates again', (enabled) => {
const result = toggleRealTimeUpdates(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) => { it.each([[ 0 ], [ 1 ], [ 2 ], [ 10 ]])('updates settings with provided value and then loads updates again', (interval) => {
const result = setRealTimeUpdatesInterval(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 } });
}); });
}); });
}); });

View file

@ -6,10 +6,12 @@ import { Input } from 'reactstrap';
import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl'; import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl';
import DateInput from '../../src/utils/DateInput'; import DateInput from '../../src/utils/DateInput';
import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation'; import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation';
import { Settings } from '../../src/settings/reducers/settings';
describe('<CreateShortUrl />', () => { describe('<CreateShortUrl />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const TagsSelector = () => null; const TagsSelector = () => null;
const shortUrlCreation = { validateUrls: true };
const shortUrlCreationResult = Mock.all<ShortUrlCreation>(); const shortUrlCreationResult = Mock.all<ShortUrlCreation>();
const createShortUrl = jest.fn(async () => Promise.resolve()); const createShortUrl = jest.fn(async () => Promise.resolve());
@ -22,6 +24,7 @@ describe('<CreateShortUrl />', () => {
createShortUrl={createShortUrl} createShortUrl={createShortUrl}
selectedServer={null} selectedServer={null}
resetCreateShortUrl={() => {}} resetCreateShortUrl={() => {}}
settings={Mock.of<Settings>({ shortUrlCreation })}
/>, />,
); );
}); });