diff --git a/src/servers/helpers/ServerForm.js b/src/servers/helpers/ServerForm.js index 57650f09..03c60868 100644 --- a/src/servers/helpers/ServerForm.js +++ b/src/servers/helpers/ServerForm.js @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup'; +import { handleEventPreventingDefault } from '../../utils/utils'; const propTypes = { onSubmit: PropTypes.func.isRequired, @@ -16,10 +17,7 @@ export const ServerForm = ({ onSubmit, initialValues, children }) => { const [ name, setName ] = useState(''); const [ url, setUrl ] = useState(''); const [ apiKey, setApiKey ] = useState(''); - const handleSubmit = (e) => { - e.preventDefault(); - onSubmit({ name, url, apiKey }); - }; + const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey })); useEffect(() => { initialValues && setName(initialValues.name); diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index 0759489b..e1591fd1 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -8,7 +8,7 @@ import DateInput from '../utils/DateInput'; import Checkbox from '../utils/Checkbox'; import { serverType } from '../servers/prop-types'; import { versionMatch } from '../utils/helpers/version'; -import { hasValue } from '../utils/utils'; +import { handleEventPreventingDefault, hasValue } from '../utils/utils'; import { useToggle } from '../utils/helpers/hooks'; import { createShortUrlResultType } from './reducers/shortUrlCreation'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; @@ -42,9 +42,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => const changeTags = (tags) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) }); const reset = () => setShortUrlCreation(initialState); - const save = (e) => { - e.preventDefault(); - + const save = handleEventPreventingDefault(() => { const shortUrlData = { ...shortUrlCreation, validSince: formatDate(shortUrlCreation.validSince), @@ -52,7 +50,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => }; createShortUrl(shortUrlData).then(reset).catch(() => {}); - }; + }); const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => ( void; +} diff --git a/src/short-urls/helpers/DeleteShortUrlModal.js b/src/short-urls/helpers/DeleteShortUrlModal.tsx similarity index 69% rename from src/short-urls/helpers/DeleteShortUrlModal.js rename to src/short-urls/helpers/DeleteShortUrlModal.tsx index dbc598ed..55365af7 100644 --- a/src/short-urls/helpers/DeleteShortUrlModal.js +++ b/src/short-urls/helpers/DeleteShortUrlModal.tsx @@ -1,40 +1,37 @@ import React, { useEffect, useState } from 'react'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; -import PropTypes from 'prop-types'; import { identity, pipe } from 'ramda'; -import { shortUrlType } from '../reducers/shortUrlsList'; -import { shortUrlDeletionType } from '../reducers/shortUrlDeletion'; +import { ShortUrlDeletion } from '../reducers/shortUrlDeletion'; +import { ShortUrlModalProps } from '../data'; +import { handleEventPreventingDefault, OptionalString } from '../../utils/utils'; const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION'; -const propTypes = { - shortUrl: shortUrlType, - toggle: PropTypes.func, - isOpen: PropTypes.bool, - shortUrlDeletion: shortUrlDeletionType, - deleteShortUrl: PropTypes.func, - resetDeleteShortUrl: PropTypes.func, -}; +interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps { + shortUrlDeletion: ShortUrlDeletion; + deleteShortUrl: (shortCode: string, domain: OptionalString) => Promise; + resetDeleteShortUrl: () => void; +} -const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }) => { +const DeleteShortUrlModal = ( + { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }: DeleteShortUrlModalConnectProps, +) => { const [ inputValue, setInputValue ] = useState(''); useEffect(() => resetDeleteShortUrl, []); const { error, errorData } = shortUrlDeletion; - const errorCode = error && errorData && (errorData.type || errorData.error); + const errorCode = error && (errorData?.type || errorData?.error); const hasThresholdError = errorCode === THRESHOLD_REACHED; const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED; const close = pipe(resetDeleteShortUrl, toggle); - const handleDeleteUrl = (e) => { - e.preventDefault(); - + const handleDeleteUrl = handleEventPreventingDefault(() => { const { shortCode, domain } = shortUrl; deleteShortUrl(shortCode, domain) .then(toggle) .catch(identity); - }; + }); return ( @@ -56,8 +53,8 @@ const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, reset {hasThresholdError && (
- {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.'} + {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.'}
)} {hasErrorOtherThanThreshold && ( @@ -81,6 +78,4 @@ const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, reset ); }; -DeleteShortUrlModal.propTypes = propTypes; - export default DeleteShortUrlModal; diff --git a/src/short-urls/helpers/EditMetaModal.tsx b/src/short-urls/helpers/EditMetaModal.tsx index 84462ac1..58eaf42e 100644 --- a/src/short-urls/helpers/EditMetaModal.tsx +++ b/src/short-urls/helpers/EditMetaModal.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, SyntheticEvent, useState } from 'react'; +import React, { ChangeEvent, useState } from 'react'; import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; @@ -8,16 +8,10 @@ import { isEmpty, pipe } from 'ramda'; import { ShortUrlMetaEdition } from '../reducers/shortUrlMeta'; import DateInput from '../../utils/DateInput'; import { formatIsoDate } from '../../utils/helpers/date'; -import { ShortUrl, ShortUrlMeta } from '../data'; -import { Nullable, OptionalString } from '../../utils/utils'; +import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data'; +import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils'; -export interface EditMetaModalProps { - shortUrl: ShortUrl; - isOpen: boolean; - toggle: () => void; -} - -interface EditMetaModalConnectProps extends EditMetaModalProps { +interface EditMetaModalConnectProps extends ShortUrlModalProps { shortUrlMeta: ShortUrlMetaEdition; resetShortUrlMeta: () => void; editShortUrlMeta: (shortCode: string, domain: OptionalString, meta: Nullable) => Promise; @@ -54,7 +48,7 @@ const EditMetaModal = (

If any of the params is not met, the URL will behave as if it was an invalid short URL.

-
e.preventDefault(), doEdit)}> + Promise; +} -const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }) => { +const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => { const { saving, error } = shortUrlEdition; - const url = shortUrl && (shortUrl.shortUrl || ''); + const url = shortUrl?.shortUrl ?? ''; const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl); - const doEdit = () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle); + const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle); return ( Edit long URL for - e.preventDefault() || doEdit()}> + { const [ newTagName, setNewTagName ] = useState(tag); const [ color, setColor ] = useState(getColorForKey(tag)); const [ showColorPicker, toggleColorPicker ] = useToggle(); - const saveTag = (e) => { - e.preventDefault(); - - editTag(tag, newTagName, color) - .then(() => tagEdited(tag, newTagName, color)) - .then(toggle) - .catch(() => {}); - }; + const saveTag = handleEventPreventingDefault(() => editTag(tag, newTagName, color) + .then(() => tagEdited(tag, newTagName, color)) + .then(toggle) + .catch(() => {})); return ( diff --git a/src/utils/services/ShlinkApiClient.js b/src/utils/services/ShlinkApiClient.js index 05a9c5bf..a26d7078 100644 --- a/src/utils/services/ShlinkApiClient.js +++ b/src/utils/services/ShlinkApiClient.js @@ -1,15 +1,5 @@ import qs from 'qs'; import { isEmpty, isNil, reject } from 'ramda'; -import PropTypes from 'prop-types'; - -export const apiErrorType = PropTypes.shape({ - type: PropTypes.string, - detail: PropTypes.string, - title: PropTypes.string, - status: PropTypes.number, - error: PropTypes.string, // Deprecated - message: PropTypes.string, // Deprecated -}); const buildShlinkBaseUrl = (url, apiVersion) => url ? `${url}/rest/v${apiVersion}` : ''; const rejectNilProps = reject(isNil); diff --git a/src/utils/services/types.ts b/src/utils/services/types.ts index 048ce954..b52a83ae 100644 --- a/src/utils/services/types.ts +++ b/src/utils/services/types.ts @@ -22,4 +22,5 @@ export interface ProblemDetailsError { status: number; error?: string; // Deprecated message?: string; // Deprecated + [extraProps: string]: any; } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index ccaf6706..45592f26 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,5 @@ -import { isEmpty, isNil, range } from 'ramda'; +import { isEmpty, isNil, pipe, range } from 'ramda'; +import { SyntheticEvent } from 'react'; export type OrderDir = 'ASC' | 'DESC' | undefined; @@ -22,6 +23,11 @@ export type Empty = null | undefined | '' | never[]; export const hasValue = (value: T | Empty): value is T => !isNil(value) && !isEmpty(value); +export const handleEventPreventingDefault = (handler: () => T) => pipe( + (e: SyntheticEvent) => e.preventDefault(), + handler, +); + export type Nullable = { [P in keyof T]: T[P] | null }; diff --git a/test/short-urls/helpers/DeleteShortUrlModal.test.js b/test/short-urls/helpers/DeleteShortUrlModal.test.tsx similarity index 76% rename from test/short-urls/helpers/DeleteShortUrlModal.test.js rename to test/short-urls/helpers/DeleteShortUrlModal.test.tsx index c950d642..a29395e5 100644 --- a/test/short-urls/helpers/DeleteShortUrlModal.test.js +++ b/test/short-urls/helpers/DeleteShortUrlModal.test.tsx @@ -1,35 +1,37 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import { identity } from 'ramda'; +import { Mock } from 'ts-mockery'; import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlModal'; +import { ShortUrl } from '../../../src/short-urls/data'; +import { ShortUrlDeletion } from '../../../src/short-urls/reducers/shortUrlDeletion'; +import { ProblemDetailsError } from '../../../src/utils/services/types'; describe('', () => { - let wrapper; - const shortUrl = { + let wrapper: ShallowWrapper; + const shortUrl = Mock.of({ tags: [], shortCode: 'abc123', - originalUrl: 'https://long-domain.com/foo/bar', - }; - const deleteShortUrl = jest.fn(() => Promise.resolve()); - const createWrapper = (shortUrlDeletion) => { + longUrl: 'https://long-domain.com/foo/bar', + }); + const deleteShortUrl = jest.fn(async () => Promise.resolve()); + const createWrapper = (shortUrlDeletion: Partial) => { wrapper = shallow( (shortUrlDeletion)} + toggle={() => {}} deleteShortUrl={deleteShortUrl} - resetDeleteShortUrl={identity} + resetDeleteShortUrl={() => {}} />, ); return wrapper; }; - afterEach(() => { - wrapper && wrapper.unmount(); - deleteShortUrl.mockClear(); - }); + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); it.each([ [ @@ -48,12 +50,12 @@ describe('', () => { { type: 'INVALID_SHORTCODE_DELETION', threshold: 8 }, 'This short URL has received more than 8 visits, and therefore, it cannot be deleted.', ], - ])('shows threshold error message when threshold error occurs', (errorData, expectedMessage) => { + ])('shows threshold error message when threshold error occurs', (errorData: Partial, expectedMessage) => { const wrapper = createWrapper({ loading: false, error: true, shortCode: 'abc123', - errorData, + errorData: Mock.of(errorData), }); const warning = wrapper.find('.bg-warning'); @@ -66,7 +68,7 @@ describe('', () => { loading: false, error: true, shortCode: 'abc123', - errorData: { error: 'OTHER_ERROR' }, + errorData: Mock.of({ error: 'OTHER_ERROR' }), }); const error = wrapper.find('.bg-danger'); @@ -79,7 +81,6 @@ describe('', () => { loading: true, error: false, shortCode: 'abc123', - errorData: {}, }); const submit = wrapper.find('.btn-danger'); @@ -94,7 +95,6 @@ describe('', () => { loading: false, error: false, shortCode, - errorData: {}, }); const input = wrapper.find('.form-control'); @@ -113,7 +113,6 @@ describe('', () => { loading: false, error: false, shortCode, - errorData: {}, }); const input = wrapper.find('.form-control'); diff --git a/test/short-urls/helpers/EditShortUrlModal.test.js b/test/short-urls/helpers/EditShortUrlModal.test.tsx similarity index 70% rename from test/short-urls/helpers/EditShortUrlModal.test.js rename to test/short-urls/helpers/EditShortUrlModal.test.tsx index a7a4ab15..2b723112 100644 --- a/test/short-urls/helpers/EditShortUrlModal.test.js +++ b/test/short-urls/helpers/EditShortUrlModal.test.tsx @@ -1,18 +1,21 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import { FormGroup, Modal, ModalHeader } from 'reactstrap'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { FormGroup } from 'reactstrap'; +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'; describe('', () => { - let wrapper; - const editShortUrl = jest.fn(() => Promise.resolve()); + let wrapper: ShallowWrapper; + const editShortUrl = jest.fn(async () => Promise.resolve()); const toggle = jest.fn(); - const createWrapper = (shortUrl, shortUrlEdition) => { + const createWrapper = (shortUrl: Partial, shortUrlEdition: Partial) => { wrapper = shallow( (shortUrl)} + shortUrlEdition={Mock.of(shortUrlEdition)} toggle={toggle} editShortUrl={editShortUrl} />, @@ -21,10 +24,8 @@ describe('', () => { return wrapper; }; - afterEach(() => { - wrapper && wrapper.unmount(); - jest.clearAllMocks(); - }); + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); it.each([ [ false, 0 ], @@ -66,13 +67,13 @@ describe('', () => { it.each([ [ '[color="link"]', 'onClick' ], - [ Modal, 'toggle' ], - [ ModalHeader, 'toggle' ], + [ 'Modal', 'toggle' ], + [ 'ModalHeader', 'toggle' ], ])('toggles modal with different mechanisms', (componentToFind, propToCall) => { const wrapper = createWrapper({}, { saving: false, error: false }); const component = wrapper.find(componentToFind); - component.prop(propToCall)(); + (component.prop(propToCall) as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion expect(toggle).toHaveBeenCalled(); });