Migrated short URL helper modal components to TS

This commit is contained in:
Alejandro Celaya 2020-08-26 20:37:36 +02:00
parent b19bbee7fc
commit f283dc8569
14 changed files with 90 additions and 130 deletions

View file

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

View file

@ -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 = {}) => (
<FormGroup>
<Input

View file

@ -27,3 +27,9 @@ export interface ShortUrlMeta {
validUntil?: string;
maxVisits?: number;
}
export interface ShortUrlModalProps {
shortUrl: ShortUrl;
isOpen: boolean;
toggle: () => void;
}

View file

@ -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<void>;
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 (
<Modal isOpen={isOpen} toggle={close} centered>
@ -56,8 +53,8 @@ const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, reset
{hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center">
{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.'}
</div>
)}
{hasErrorOtherThanThreshold && (
@ -81,6 +78,4 @@ const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, reset
);
};
DeleteShortUrlModal.propTypes = propTypes;
export default DeleteShortUrlModal;

View file

@ -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<ShortUrlMeta>) => Promise<void>;
@ -54,7 +48,7 @@ const EditMetaModal = (
<p>If any of the params is not met, the URL will behave as if it was an invalid short URL.</p>
</UncontrolledTooltip>
</ModalHeader>
<form onSubmit={pipe((e: SyntheticEvent) => e.preventDefault(), doEdit)}>
<form onSubmit={handleEventPreventingDefault(doEdit)}>
<ModalBody>
<FormGroup>
<DateInput

View file

@ -1,32 +1,28 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { shortUrlType } from '../reducers/shortUrlsList';
import { ShortUrlEditionType } from '../reducers/shortUrlEdition';
import { hasValue } from '../../utils/utils';
import { ShortUrlEdition } from '../reducers/shortUrlEdition';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
import { ShortUrlModalProps } from '../data';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlEdition: ShortUrlEditionType,
editShortUrl: PropTypes.func,
};
interface EditShortUrlModalProps extends ShortUrlModalProps {
shortUrlEdition: ShortUrlEdition;
editShortUrl: (shortUrl: string, domain: OptionalString, longUrl: string) => Promise<void>;
}
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 (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
Edit long URL for <ExternalLink href={url} />
</ModalHeader>
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
<form onSubmit={handleEventPreventingDefault(doEdit)}>
<ModalBody>
<FormGroup className="mb-0">
<Input
@ -52,6 +48,4 @@ const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShor
);
};
EditShortUrlModal.propTypes = propTypes;
export default EditShortUrlModal;

View file

@ -1,6 +1,4 @@
import PropTypes from 'prop-types';
import { Action, Dispatch } from 'redux';
import { apiErrorType } from '../../utils/services/ShlinkApiClient';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ProblemDetailsError, ShlinkApiClientBuilder } from '../../utils/services/types';
import { GetState } from '../../container/types';
@ -12,14 +10,6 @@ export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
/* eslint-enable padding-line-between-statements */
/** @deprecated Use ShortUrlDeletion interface */
export const shortUrlDeletionType = PropTypes.shape({
shortCode: PropTypes.string.isRequired,
loading: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
errorData: apiErrorType.isRequired,
});
export interface ShortUrlDeletion {
shortCode: string;
loading: boolean;

View file

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { Action, Dispatch } from 'redux';
import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../utils/services/types';
@ -11,14 +10,6 @@ export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
/* eslint-enable padding-line-between-statements */
/** @deprecated Use ShortUrlEdition interface instead */
export const ShortUrlEditionType = PropTypes.shape({
shortCode: PropTypes.string,
longUrl: PropTypes.string,
saving: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
});
export interface ShortUrlEdition {
shortCode: string | null;
longUrl: string | null;

View file

@ -6,6 +6,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import './EditTagModal.scss';
import { useToggle } from '../../utils/helpers/hooks';
import { handleEventPreventingDefault } from '../../utils/utils';
const propTypes = {
tag: PropTypes.string,
@ -24,14 +25,10 @@ const EditTagModal = ({ getColorForKey }) => {
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 (
<Modal isOpen={isOpen} toggle={toggle} centered>

View file

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

View file

@ -22,4 +22,5 @@ export interface ProblemDetailsError {
status: number;
error?: string; // Deprecated
message?: string; // Deprecated
[extraProps: string]: any;
}

View file

@ -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 = <T>(value: T | Empty): value is T => !isNil(value) && !isEmpty(value);
export const handleEventPreventingDefault = <T>(handler: () => T) => pipe(
(e: SyntheticEvent) => e.preventDefault(),
handler,
);
export type Nullable<T> = {
[P in keyof T]: T[P] | null
};

View file

@ -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('<DeleteShortUrlModal />', () => {
let wrapper;
const shortUrl = {
let wrapper: ShallowWrapper;
const shortUrl = Mock.of<ShortUrl>({
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<ShortUrlDeletion>) => {
wrapper = shallow(
<DeleteShortUrlModal
isOpen
shortUrl={shortUrl}
shortUrlDeletion={shortUrlDeletion}
toggle={identity}
shortUrlDeletion={Mock.of<ShortUrlDeletion>(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('<DeleteShortUrlModal />', () => {
{ 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<ProblemDetailsError>, expectedMessage) => {
const wrapper = createWrapper({
loading: false,
error: true,
shortCode: 'abc123',
errorData,
errorData: Mock.of<ProblemDetailsError>(errorData),
});
const warning = wrapper.find('.bg-warning');
@ -66,7 +68,7 @@ describe('<DeleteShortUrlModal />', () => {
loading: false,
error: true,
shortCode: 'abc123',
errorData: { error: 'OTHER_ERROR' },
errorData: Mock.of<ProblemDetailsError>({ error: 'OTHER_ERROR' }),
});
const error = wrapper.find('.bg-danger');
@ -79,7 +81,6 @@ describe('<DeleteShortUrlModal />', () => {
loading: true,
error: false,
shortCode: 'abc123',
errorData: {},
});
const submit = wrapper.find('.btn-danger');
@ -94,7 +95,6 @@ describe('<DeleteShortUrlModal />', () => {
loading: false,
error: false,
shortCode,
errorData: {},
});
const input = wrapper.find('.form-control');
@ -113,7 +113,6 @@ describe('<DeleteShortUrlModal />', () => {
loading: false,
error: false,
shortCode,
errorData: {},
});
const input = wrapper.find('.form-control');

View file

@ -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('<EditShortUrlModal />', () => {
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<ShortUrl>, shortUrlEdition: Partial<ShortUrlEdition>) => {
wrapper = shallow(
<EditShortUrlModal
isOpen={true}
shortUrl={shortUrl}
shortUrlEdition={shortUrlEdition}
shortUrl={Mock.of<ShortUrl>(shortUrl)}
shortUrlEdition={Mock.of<ShortUrlEdition>(shortUrlEdition)}
toggle={toggle}
editShortUrl={editShortUrl}
/>,
@ -21,10 +24,8 @@ describe('<EditShortUrlModal />', () => {
return wrapper;
};
afterEach(() => {
wrapper && wrapper.unmount();
jest.clearAllMocks();
});
afterEach(() => wrapper?.unmount());
afterEach(jest.clearAllMocks);
it.each([
[ false, 0 ],
@ -66,13 +67,13 @@ describe('<EditShortUrlModal />', () => {
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();
});