diff --git a/package-lock.json b/package-lock.json index 27beeeee..f7749882 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1368,11 +1368,11 @@ } }, "@fortawesome/react-fontawesome": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.5.tgz", - "integrity": "sha512-WYDKTgyAWOncujWhhzhW7k8sgO5Eo2pZTUL51yNzSQNBUwwr6rNKg/JUSE3iebaU1XShHw74aKc1kJ+jvtRNew==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.11.tgz", + "integrity": "sha512-sClfojasRifQKI0OPqTy8Ln8iIhnxR/Pv/hukBhWnBz9kQRmqi6JSH3nghlhAY7SUeIIM7B5/D2G8WjX0iepVg==", "requires": { - "prop-types": "^15.5.10" + "prop-types": "^15.7.2" } }, "@hapi/address": { @@ -3378,6 +3378,17 @@ "csstype": "^3.0.2" } }, + "@types/react-datepicker": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-1.8.0.tgz", + "integrity": "sha512-QyHMOFCOFIkcyDCXUGnL7OyM+Gj2aG95d3t18wgrLTxQJseVQXeQFTVnUeXmmF2cZxeWtGTfRl1uYPTr3/rAFg==", + "dev": true, + "requires": { + "@types/react": "*", + "moment": ">=2.14.0", + "popper.js": "^1.14.1" + } + }, "@types/react-dom": { "version": "16.9.8", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.8.tgz", diff --git a/package.json b/package.json index 68dbdc47..95cd3fad 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.25", "@fortawesome/free-regular-svg-icons": "^5.11.2", "@fortawesome/free-solid-svg-icons": "^5.11.2", - "@fortawesome/react-fontawesome": "^0.1.5", + "@fortawesome/react-fontawesome": "^0.1.11", "array-filter": "^1.0.0", "array-map": "^0.0.0", "array-reduce": "^0.0.0", @@ -82,6 +82,7 @@ "@types/moment": "^2.13.0", "@types/ramda": "^0.27.14", "@types/react": "^16.9.46", + "@types/react-datepicker": "~1.8.0", "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.9", "@types/react-router-dom": "^5.1.5", diff --git a/src/short-urls/helpers/EditMetaModal.js b/src/short-urls/helpers/EditMetaModal.tsx similarity index 65% rename from src/short-urls/helpers/EditMetaModal.js rename to src/short-urls/helpers/EditMetaModal.tsx index aa1cbcb6..84462ac1 100644 --- a/src/short-urls/helpers/EditMetaModal.js +++ b/src/short-urls/helpers/EditMetaModal.tsx @@ -1,41 +1,46 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; +import React, { ChangeEvent, SyntheticEvent, 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'; import { ExternalLink } from 'react-external-link'; import moment from 'moment'; import { isEmpty, pipe } from 'ramda'; -import { shortUrlType } from '../reducers/shortUrlsList'; -import { shortUrlEditMetaType } from '../reducers/shortUrlMeta'; +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'; -const propTypes = { - isOpen: PropTypes.bool.isRequired, - toggle: PropTypes.func.isRequired, - shortUrl: shortUrlType.isRequired, - shortUrlMeta: shortUrlEditMetaType, - editShortUrlMeta: PropTypes.func, - resetShortUrlMeta: PropTypes.func, +export interface EditMetaModalProps { + shortUrl: ShortUrl; + isOpen: boolean; + toggle: () => void; +} + +interface EditMetaModalConnectProps extends EditMetaModalProps { + shortUrlMeta: ShortUrlMetaEdition; + resetShortUrlMeta: () => void; + editShortUrlMeta: (shortCode: string, domain: OptionalString, meta: Nullable) => Promise; +} + +const dateOrUndefined = (shortUrl: ShortUrl | undefined, dateName: 'validSince' | 'validUntil') => { + const date = shortUrl?.meta?.[dateName]; + + return date ? moment(date) : undefined; }; -const dateOrUndefined = (shortUrl, dateName) => { - const date = shortUrl && shortUrl.meta && shortUrl.meta[dateName]; - - return date && moment(date); -}; - -const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }) => { +const EditMetaModal = ( + { isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }: EditMetaModalConnectProps, +) => { const { saving, error } = shortUrlMeta; const url = shortUrl && (shortUrl.shortUrl || ''); const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince')); const [ validUntil, setValidUntil ] = useState(dateOrUndefined(shortUrl, 'validUntil')); - const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits); + const [ maxVisits, setMaxVisits ] = useState(shortUrl?.meta?.maxVisits); const close = pipe(resetShortUrlMeta, toggle); - const doEdit = () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, { - maxVisits: maxVisits && !isEmpty(maxVisits) ? parseInt(maxVisits) : null, + const doEdit = async () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, { + maxVisits: maxVisits && !isEmpty(maxVisits) ? maxVisits : null, validSince: validSince && formatIsoDate(validSince), validUntil: validUntil && formatIsoDate(validUntil), }).then(close); @@ -49,7 +54,7 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet

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

-
e.preventDefault() || doEdit()}> + e.preventDefault(), doEdit)}> @@ -66,7 +71,7 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet selected={validUntil} minDate={validSince} isClearable - onChange={setValidUntil} + onChange={setValidUntil as any} /> @@ -74,8 +79,8 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet type="number" placeholder="Maximum number of visits allowed" min={1} - value={maxVisits || ''} - onChange={(e) => setMaxVisits(e.target.value)} + value={maxVisits ?? ''} + onChange={(e: ChangeEvent) => setMaxVisits(Number(e.target.value))} /> {error && ( @@ -93,6 +98,4 @@ const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMet ); }; -EditMetaModal.propTypes = propTypes; - export default EditMetaModal; diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index 8b1a0634..fb429e44 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -3,6 +3,7 @@ import { Action, Dispatch } from 'redux'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../utils/services/types'; import { GetState } from '../../container/types'; +import { OptionalString } from '../../utils/utils'; /* eslint-disable padding-line-between-statements */ export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START'; @@ -28,7 +29,7 @@ export interface ShortUrlEdition { export interface ShortUrlEditedAction extends Action { shortCode: string; longUrl: string; - domain: string | undefined | null; + domain: OptionalString; } const initialState: ShortUrlEdition = { @@ -46,7 +47,7 @@ export default buildReducer({ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( shortCode: string, - domain: string | undefined | null, + domain: OptionalString, longUrl: string, ) => async (dispatch: Dispatch, getState: GetState) => { dispatch({ type: EDIT_SHORT_URL_START }); diff --git a/src/short-urls/reducers/shortUrlMeta.ts b/src/short-urls/reducers/shortUrlMeta.ts index e21401af..a69c35de 100644 --- a/src/short-urls/reducers/shortUrlMeta.ts +++ b/src/short-urls/reducers/shortUrlMeta.ts @@ -4,6 +4,7 @@ import { ShortUrlMeta } from '../data'; import { ShlinkApiClientBuilder } from '../../utils/services/types'; import { GetState } from '../../container/types'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { OptionalString } from '../../utils/utils'; /* eslint-disable padding-line-between-statements */ export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START'; @@ -19,14 +20,6 @@ export const shortUrlMetaType = PropTypes.shape({ maxVisits: PropTypes.number, }); -/** @deprecated Use ShortUrlMetaEdition interface instead */ -export const shortUrlEditMetaType = PropTypes.shape({ - shortCode: PropTypes.string, - meta: shortUrlMetaType.isRequired, - saving: PropTypes.bool.isRequired, - error: PropTypes.bool.isRequired, -}); - export interface ShortUrlMetaEdition { shortCode: string | null; meta: ShortUrlMeta; @@ -56,7 +49,7 @@ export default buildReducer({ export const editShortUrlMeta = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( shortCode: string, - domain: string | null | undefined, + domain: OptionalString, meta: ShortUrlMeta, ) => async (dispatch: Dispatch, getState: GetState) => { dispatch({ type: EDIT_SHORT_URL_META_START }); diff --git a/src/short-urls/reducers/shortUrlTags.ts b/src/short-urls/reducers/shortUrlTags.ts index 4890271d..89ecd15d 100644 --- a/src/short-urls/reducers/shortUrlTags.ts +++ b/src/short-urls/reducers/shortUrlTags.ts @@ -3,6 +3,7 @@ import { Action, Dispatch } from 'redux'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../utils/services/types'; import { GetState } from '../../container/types'; +import { OptionalString } from '../../utils/utils'; /* eslint-disable padding-line-between-statements */ export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START'; @@ -29,7 +30,7 @@ export interface ShortUrlTags { export interface EditShortUrlTagsAction extends Action { shortCode: string; tags: string[]; - domain: string | null | undefined; + domain: OptionalString; } const initialState: ShortUrlTags = { @@ -48,7 +49,7 @@ export default buildReducer({ export const editShortUrlTags = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( shortCode: string, - domain: string | null | undefined, + domain: OptionalString, tags: string[], ) => async (dispatch: Dispatch, getState: GetState) => { dispatch({ type: EDIT_SHORT_URL_TAGS_START }); diff --git a/src/utils/DateInput.js b/src/utils/DateInput.tsx similarity index 65% rename from src/utils/DateInput.js rename to src/utils/DateInput.tsx index 52905c62..dd1f8220 100644 --- a/src/utils/DateInput.js +++ b/src/utils/DateInput.tsx @@ -1,21 +1,16 @@ -import React from 'react'; +import React, { Component, RefObject } from 'react'; import { isNil } from 'ramda'; -import DatePicker from 'react-datepicker'; +import DatePicker, { ReactDatePickerProps } from 'react-datepicker'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons'; -import * as PropTypes from 'prop-types'; import classNames from 'classnames'; import './DateInput.scss'; -const propTypes = { - className: PropTypes.string, - isClearable: PropTypes.bool, - selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]), - ref: PropTypes.object, - disabled: PropTypes.bool, -}; +export interface DateInputProps extends ReactDatePickerProps { + ref?: RefObject & { input: HTMLInputElement }>; +} -const DateInput = (props) => { +const DateInput = (props: DateInputProps) => { const { className, isClearable, selected, ref = React.createRef() } = props; const showCalendarIcon = !isClearable || isNil(selected); @@ -32,13 +27,11 @@ const DateInput = (props) => { ref.current.input.focus()} + onClick={() => ref.current?.input.focus()} /> )} ); }; -DateInput.propTypes = propTypes; - export default DateInput; diff --git a/src/utils/helpers/date.ts b/src/utils/helpers/date.ts index c4a44b3c..9d15c41f 100644 --- a/src/utils/helpers/date.ts +++ b/src/utils/helpers/date.ts @@ -1,11 +1,12 @@ import * as moment from 'moment'; +import { OptionalString } from '../utils'; type MomentOrString = moment.Moment | string; type NullableDate = MomentOrString | null; -const isMomentObject = (date: moment.Moment | string): date is moment.Moment => typeof (date as moment.Moment).format === 'function'; +const isMomentObject = (date: MomentOrString): date is moment.Moment => typeof (date as moment.Moment).format === 'function'; -const formatDateFromFormat = (date?: NullableDate, format?: string): NullableDate | undefined => +const formatDateFromFormat = (date?: NullableDate, format?: string): OptionalString => !date || !isMomentObject(date) ? date : date.format(format); export const formatDate = (format = 'YYYY-MM-DD') => (date?: NullableDate) => formatDateFromFormat(date, format); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index c968bf45..ccaf6706 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -25,3 +25,7 @@ export const hasValue = (value: T | Empty): value is T => !isNil(value) && !i export type Nullable = { [P in keyof T]: T[P] | null }; + +type Optional = T | null | undefined; + +export type OptionalString = Optional; diff --git a/test/short-urls/helpers/EditMetaModal.test.js b/test/short-urls/helpers/EditMetaModal.test.tsx similarity index 60% rename from test/short-urls/helpers/EditMetaModal.test.js rename to test/short-urls/helpers/EditMetaModal.test.tsx index fb20f7ac..f1ec8b2a 100644 --- a/test/short-urls/helpers/EditMetaModal.test.js +++ b/test/short-urls/helpers/EditMetaModal.test.tsx @@ -1,19 +1,22 @@ 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 EditMetaModal from '../../../src/short-urls/helpers/EditMetaModal'; +import { ShortUrl } from '../../../src/short-urls/data'; +import { ShortUrlMetaEdition } from '../../../src/short-urls/reducers/shortUrlMeta'; describe('', () => { - let wrapper; - const editShortUrlMeta = jest.fn(() => Promise.resolve()); + let wrapper: ShallowWrapper; + const editShortUrlMeta = jest.fn(async () => Promise.resolve()); const resetShortUrlMeta = jest.fn(); const toggle = jest.fn(); - const createWrapper = (shortUrl, shortUrlMeta) => { + const createWrapper = (shortUrlMeta: Partial) => { wrapper = shallow( ()} + shortUrlMeta={Mock.of(shortUrlMeta)} toggle={toggle} editShortUrlMeta={editShortUrlMeta} resetShortUrlMeta={resetShortUrlMeta} @@ -23,13 +26,11 @@ describe('', () => { return wrapper; }; - afterEach(() => { - wrapper && wrapper.unmount(); - jest.clearAllMocks(); - }); + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); it('properly renders form with components', () => { - const wrapper = createWrapper({}, { saving: false, error: false, meta: {} }); + const wrapper = createWrapper({ saving: false, error: false }); const error = wrapper.find('.bg-danger'); const form = wrapper.find('form'); const formGroup = form.find(FormGroup); @@ -43,7 +44,7 @@ describe('', () => { [ true, 'Saving...' ], [ false, 'Save' ], ])('renders submit button on expected state', (saving, expectedText) => { - const wrapper = createWrapper({}, { saving, error: false, meta: {} }); + const wrapper = createWrapper({ saving, error: false }); const button = wrapper.find('[type="submit"]'); expect(button.prop('disabled')).toEqual(saving); @@ -51,7 +52,7 @@ describe('', () => { }); it('renders error message on error', () => { - const wrapper = createWrapper({}, { saving: false, error: true, meta: {} }); + const wrapper = createWrapper({ saving: false, error: true }); const error = wrapper.find('.bg-danger'); expect(error).toHaveLength(1); @@ -59,7 +60,7 @@ describe('', () => { it('saves meta when form is submit', () => { const preventDefault = jest.fn(); - const wrapper = createWrapper({}, { saving: false, error: false, meta: {} }); + const wrapper = createWrapper({ saving: false, error: false }); const form = wrapper.find('form'); form.simulate('submit', { preventDefault }); @@ -70,13 +71,13 @@ describe('', () => { it.each([ [ '.btn-link', 'onClick' ], - [ Modal, 'toggle' ], - [ ModalHeader, 'toggle' ], + [ 'Modal', 'toggle' ], + [ 'ModalHeader', 'toggle' ], ])('resets meta when modal is toggled in any way', (componentToFind, propToCall) => { - const wrapper = createWrapper({}, { saving: false, error: false, meta: {} }); + 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(resetShortUrlMeta).toHaveBeenCalled(); }); diff --git a/test/utils/DateInput.test.js b/test/utils/DateInput.test.tsx similarity index 67% rename from test/utils/DateInput.test.js rename to test/utils/DateInput.test.tsx index 995cd865..cb62f2a5 100644 --- a/test/utils/DateInput.test.js +++ b/test/utils/DateInput.test.tsx @@ -1,21 +1,22 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import moment from 'moment'; -import DateInput from '../../src/utils/DateInput'; +import { Mock } from 'ts-mockery'; +import DateInput, { DateInputProps } from '../../src/utils/DateInput'; describe('', () => { - let wrapped; + let wrapped: ShallowWrapper; - const createComponent = (props = {}) => { - wrapped = shallow(); + const createComponent = (props: Partial = {}) => { + wrapped = shallow((props)} />); return wrapped; }; - afterEach(() => wrapped && wrapped.unmount()); + afterEach(() => wrapped?.unmount()); - it('wrapps a DatePicker', () => { + it('wraps a DatePicker', () => { wrapped = createComponent(); });