diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6ebc8a..bc98c657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer. * [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded. * [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited. -* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short RUL when suing Shlink 2.1 or higher. +* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when suing Shlink 2.1 or higher. +* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher. #### Changed diff --git a/src/reducers/index.js b/src/reducers/index.js index 47656016..2d80d488 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -7,6 +7,7 @@ import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta'; +import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; import tagsListReducer from '../tags/reducers/tagsList'; @@ -22,6 +23,7 @@ export default combineReducers({ shortUrlDeletion: shortUrlDeletionReducer, shortUrlTags: shortUrlTagsReducer, shortUrlMeta: shortUrlMetaReducer, + shortUrlEdition: shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, diff --git a/src/short-urls/helpers/EditMetaModal.js b/src/short-urls/helpers/EditMetaModal.js index f7adeb86..aa1cbcb6 100644 --- a/src/short-urls/helpers/EditMetaModal.js +++ b/src/short-urls/helpers/EditMetaModal.js @@ -26,9 +26,7 @@ const dateOrUndefined = (shortUrl, dateName) => { return date && moment(date); }; -const EditMetaModal = ( - { isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta } -) => { +const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }) => { const { saving, error } = shortUrlMeta; const url = shortUrl && (shortUrl.shortUrl || ''); const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince')); diff --git a/src/short-urls/helpers/EditShortUrlModal.js b/src/short-urls/helpers/EditShortUrlModal.js new file mode 100644 index 00000000..ddb65689 --- /dev/null +++ b/src/short-urls/helpers/EditShortUrlModal.js @@ -0,0 +1,57 @@ +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'; + +const propTypes = { + isOpen: PropTypes.bool.isRequired, + toggle: PropTypes.func.isRequired, + shortUrl: shortUrlType.isRequired, + shortUrlEdition: ShortUrlEditionType, + editShortUrl: PropTypes.func, +}; + +const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }) => { + const { saving, error } = shortUrlEdition; + const url = shortUrl && (shortUrl.shortUrl || ''); + const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl); + + const doEdit = () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle); + + return ( + + + Edit long URL for + + e.preventDefault() || doEdit()}> + + + setLongUrl(e.target.value)} + /> + + {error && ( + + Something went wrong while saving the long URL :( + + )} + + + Cancel + {saving ? 'Saving...' : 'Save'} + + + + ); +}; + +EditShortUrlModal.propTypes = propTypes; + +export default EditShortUrlModal; diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.js b/src/short-urls/helpers/ShortUrlsRowMenu.js index 0e8a149d..fe490cc8 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.js +++ b/src/short-urls/helpers/ShortUrlsRowMenu.js @@ -6,50 +6,37 @@ import { faQrcode as qrIcon, faMinusCircle as deleteIcon, faEdit as editIcon, + faLink as linkIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import React from 'react'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { serverType } from '../../servers/prop-types'; import { shortUrlType } from '../reducers/shortUrlsList'; +import { useToggle } from '../../utils/helpers/hooks'; import PreviewModal from './PreviewModal'; import QrCodeModal from './QrCodeModal'; import VisitStatsLink from './VisitStatsLink'; import './ShortUrlsRowMenu.scss'; -const ShortUrlsRowMenu = ( - DeleteShortUrlModal, - EditTagsModal, - EditMetaModal, - ForServerVersion -) => class ShortUrlsRowMenu extends React.Component { - static propTypes = { - selectedServer: serverType, - shortUrl: shortUrlType, - }; +const propTypes = { + selectedServer: serverType, + shortUrl: shortUrlType, +}; - state = { - isOpen: false, - isQrModalOpen: false, - isPreviewModalOpen: false, - isTagsModalOpen: false, - isMetaModalOpen: false, - isDeleteModalOpen: false, - }; - toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen })); - - render() { - const { shortUrl, selectedServer } = this.props; +const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal, EditMetaModal, EditShortUrlModal, ForServerVersion) => { + const ShortUrlsRowMenuComp = ({ shortUrl, selectedServer }) => { + const [ isOpen, toggle ] = useToggle(false); + const [ isQrModalOpen, toggleQrCode ] = useToggle(false); + const [ isPreviewModalOpen, togglePreview ] = useToggle(false); + const [ isTagsModalOpen, toggleTags ] = useToggle(false); + const [ isMetaModalOpen, toggleMeta ] = useToggle(false); + const [ isDeleteModalOpen, toggleDelete ] = useToggle(false); + const [ isEditModalOpen, toggleEdit ] = useToggle(false); const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; - const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] })); - const toggleQrCode = toggleModal('isQrModalOpen'); - const togglePreview = toggleModal('isPreviewModalOpen'); - const toggleTags = toggleModal('isTagsModalOpen'); - const toggleMeta = toggleModal('isMetaModalOpen'); - const toggleDelete = toggleModal('isDeleteModalOpen'); return ( - + @@ -61,25 +48,32 @@ const ShortUrlsRowMenu = ( Edit tags - + Edit metadata - + + + + + + Edit long URL + + QR code - + Preview - + @@ -87,11 +81,15 @@ const ShortUrlsRowMenu = ( Delete short URL - + ); - } + }; + + ShortUrlsRowMenuComp.propTypes = propTypes; + + return ShortUrlsRowMenuComp; }; export default ShortUrlsRowMenu; diff --git a/src/short-urls/reducers/shortUrlEdition.js b/src/short-urls/reducers/shortUrlEdition.js new file mode 100644 index 00000000..4c545b6d --- /dev/null +++ b/src/short-urls/reducers/shortUrlEdition.js @@ -0,0 +1,42 @@ +import { handleActions } from 'redux-actions'; +import PropTypes from 'prop-types'; + +/* eslint-disable padding-line-between-statements */ +export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START'; +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 */ + +export const ShortUrlEditionType = PropTypes.shape({ + shortCode: PropTypes.string, + longUrl: PropTypes.string, + saving: PropTypes.bool.isRequired, + error: PropTypes.bool.isRequired, +}); + +const initialState = { + shortCode: null, + longUrl: null, + saving: false, + error: false, +}; + +export default handleActions({ + [EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), + [EDIT_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }), + [SHORT_URL_EDITED]: (state, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }), +}, initialState); + +export const editShortUrl = (buildShlinkApiClient) => (shortCode, domain, longUrl) => async (dispatch, getState) => { + dispatch({ type: EDIT_SHORT_URL_START }); + const { updateShortUrlMeta } = buildShlinkApiClient(getState); + + try { + await updateShortUrlMeta(shortCode, domain, { longUrl }); + dispatch({ shortCode, longUrl, domain, type: SHORT_URL_EDITED }); + } catch (e) { + dispatch({ type: EDIT_SHORT_URL_ERROR }); + + throw e; + } +}; diff --git a/src/short-urls/reducers/shortUrlsList.js b/src/short-urls/reducers/shortUrlsList.js index efd8978b..a141dba2 100644 --- a/src/short-urls/reducers/shortUrlsList.js +++ b/src/short-urls/reducers/shortUrlsList.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta'; +import { SHORT_URL_EDITED } from './shortUrlEdition'; /* eslint-disable padding-line-between-statements */ export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START'; @@ -54,6 +55,7 @@ export default handleActions({ ), [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'), + [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), }, initialState); export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => { diff --git a/src/short-urls/services/provideServices.js b/src/short-urls/services/provideServices.js index 3c3c258f..0dc79a73 100644 --- a/src/short-urls/services/provideServices.js +++ b/src/short-urls/services/provideServices.js @@ -9,6 +9,7 @@ import CreateShortUrl from '../CreateShortUrl'; import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal'; import EditTagsModal from '../helpers/EditTagsModal'; import EditMetaModal from '../helpers/EditMetaModal'; +import EditShortUrlModal from '../helpers/EditShortUrlModal'; import CreateShortUrlResult from '../helpers/CreateShortUrlResult'; import { listShortUrls } from '../reducers/shortUrlsList'; import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation'; @@ -16,6 +17,7 @@ import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletio import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags'; import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta'; import { resetShortUrlParams } from '../reducers/shortUrlsListParams'; +import { editShortUrl } from '../reducers/shortUrlEdition'; const provideServices = (bottle, connect) => { // Components @@ -41,6 +43,7 @@ const provideServices = (bottle, connect) => { 'DeleteShortUrlModal', 'EditTagsModal', 'EditMetaModal', + 'EditShortUrlModal', 'ForServerVersion' ); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout'); @@ -60,6 +63,9 @@ const provideServices = (bottle, connect) => { bottle.serviceFactory('EditMetaModal', () => EditMetaModal); bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ])); + bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal); + bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ])); + // Actions bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient'); bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags); @@ -75,6 +81,8 @@ const provideServices = (bottle, connect) => { bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient'); bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta); + + bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient'); }; export default provideServices; diff --git a/test/short-urls/helpers/EditShortUrlModal.test.js b/test/short-urls/helpers/EditShortUrlModal.test.js new file mode 100644 index 00000000..976cde14 --- /dev/null +++ b/test/short-urls/helpers/EditShortUrlModal.test.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { FormGroup, Modal, ModalHeader } from 'reactstrap'; +import EditShortUrlModal from '../../../src/short-urls/helpers/EditShortUrlModal'; + +describe('', () => { + let wrapper; + const editShortUrl = jest.fn(() => Promise.resolve()); + const toggle = jest.fn(); + const createWrapper = (shortUrl, shortUrlEdition) => { + wrapper = shallow( + + ); + + return wrapper; + }; + + afterEach(() => { + wrapper && wrapper.unmount(); + jest.clearAllMocks(); + }); + + it.each([ + [ false, 0 ], + [ true, 1 ], + ])('properly renders form with expected components', (error, expectedErrorLength) => { + const wrapper = createWrapper({}, { saving: false, error }); + const errorElement = wrapper.find('.bg-danger'); + const form = wrapper.find('form'); + const formGroup = form.find(FormGroup); + + expect(form).toHaveLength(1); + expect(formGroup).toHaveLength(1); + expect(errorElement).toHaveLength(expectedErrorLength); + }); + + it.each([ + [ true, 'Saving...', 'something', true ], + [ true, 'Saving...', undefined, true ], + [ false, 'Save', 'something', false ], + [ false, 'Save', undefined, true ], + ])('renders submit button on expected state', (saving, expectedText, longUrl, expectedDisabled) => { + const wrapper = createWrapper({ longUrl }, { saving, error: false }); + const button = wrapper.find('[color="primary"]'); + + expect(button.prop('disabled')).toEqual(expectedDisabled); + expect(button.html()).toContain(expectedText); + }); + + it('saves data when form is submit', () => { + const preventDefault = jest.fn(); + const wrapper = createWrapper({}, { saving: false, error: false }); + const form = wrapper.find('form'); + + form.simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(editShortUrl).toHaveBeenCalled(); + }); + + it.each([ + [ '[color="link"]', 'onClick' ], + [ 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)(); + + expect(toggle).toHaveBeenCalled(); + }); +}); diff --git a/test/short-urls/helpers/ShortUrlsRowMenu.test.js b/test/short-urls/helpers/ShortUrlsRowMenu.test.js index 8ffd0d38..aecd2d84 100644 --- a/test/short-urls/helpers/ShortUrlsRowMenu.test.js +++ b/test/short-urls/helpers/ShortUrlsRowMenu.test.js @@ -10,6 +10,7 @@ describe('', () => { const DeleteShortUrlModal = () => ''; const EditTagsModal = () => ''; const EditMetaModal = () => ''; + const EditShortUrlModal = () => ''; const onCopyToClipboard = jest.fn(); const selectedServer = { id: 'abc123' }; const shortUrl = { @@ -17,7 +18,13 @@ describe('', () => { shortUrl: 'https://doma.in/abc123', }; const createWrapper = () => { - const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal, () => ''); + const ShortUrlsRowMenu = createShortUrlsRowMenu( + DeleteShortUrlModal, + EditTagsModal, + EditMetaModal, + EditShortUrlModal, + () => '' + ); wrapper = shallow( ', () => { const editTagsModal = wrapper.find(EditTagsModal); const previewModal = wrapper.find(PreviewModal); const qrCodeModal = wrapper.find(QrCodeModal); + const editModal = wrapper.find(EditShortUrlModal); expect(deleteShortUrlModal).toHaveLength(1); expect(editTagsModal).toHaveLength(1); expect(previewModal).toHaveLength(1); expect(qrCodeModal).toHaveLength(1); + expect(editModal).toHaveLength(1); }); it('renders correct amount of menu items', () => { const wrapper = createWrapper(); const items = wrapper.find(DropdownItem); - expect(items).toHaveLength(7); + expect(items).toHaveLength(8); expect(items.find('[divider]')).toHaveLength(1); }); describe('toggles state when toggling modal windows', () => { - const assert = (modalComponent, stateProp, done) => { + const assert = (modalComponent) => { const wrapper = createWrapper(); - const modal = wrapper.find(modalComponent); - expect(wrapper.state(stateProp)).toEqual(false); - modal.prop('toggle')(); - setImmediate(() => { - expect(wrapper.state(stateProp)).toEqual(true); - done(); - }); + expect(wrapper.find(modalComponent).prop('isOpen')).toEqual(false); + wrapper.find(modalComponent).prop('toggle')(); + expect(wrapper.find(modalComponent).prop('isOpen')).toEqual(true); }; - it('DeleteShortUrlModal', (done) => assert(DeleteShortUrlModal, 'isDeleteModalOpen', done)); - it('EditTagsModal', (done) => assert(EditTagsModal, 'isTagsModalOpen', done)); - it('PreviewModal', (done) => assert(PreviewModal, 'isPreviewModalOpen', done)); - it('QrCodeModal', (done) => assert(QrCodeModal, 'isQrModalOpen', done)); + it('DeleteShortUrlModal', () => assert(DeleteShortUrlModal)); + it('EditTagsModal', () => assert(EditTagsModal)); + it('PreviewModal', () => assert(PreviewModal)); + it('QrCodeModal', () => assert(QrCodeModal)); + it('EditShortUrlModal', () => assert(EditShortUrlModal)); }); - it('toggles dropdown state when toggling dropdown', (done) => { + it('toggles dropdown state when toggling dropdown', () => { const wrapper = createWrapper(); - const dropdown = wrapper.find(ButtonDropdown); - expect(wrapper.state('isOpen')).toEqual(false); - dropdown.prop('toggle')(); - setImmediate(() => { - expect(wrapper.state('isOpen')).toEqual(true); - done(); - }); + expect(wrapper.find(ButtonDropdown).prop('isOpen')).toEqual(false); + wrapper.find(ButtonDropdown).prop('toggle')(); + expect(wrapper.find(ButtonDropdown).prop('isOpen')).toEqual(true); }); }); diff --git a/test/short-urls/reducers/shortUrlEdition.test.js b/test/short-urls/reducers/shortUrlEdition.test.js new file mode 100644 index 00000000..a64f5492 --- /dev/null +++ b/test/short-urls/reducers/shortUrlEdition.test.js @@ -0,0 +1,74 @@ +import reducer, { + EDIT_SHORT_URL_START, + EDIT_SHORT_URL_ERROR, + SHORT_URL_EDITED, + editShortUrl, +} from '../../../src/short-urls/reducers/shortUrlEdition'; + +describe('shortUrlEditionReducer', () => { + const longUrl = 'https://shlink.io'; + const shortCode = 'abc123'; + + describe('reducer', () => { + it('returns loading on EDIT_SHORT_URL_START', () => { + expect(reducer({}, { type: EDIT_SHORT_URL_START })).toEqual({ + saving: true, + error: false, + }); + }); + + it('returns error on EDIT_SHORT_URL_ERROR', () => { + expect(reducer({}, { type: EDIT_SHORT_URL_ERROR })).toEqual({ + saving: false, + error: true, + }); + }); + + it('returns provided tags and shortCode on SHORT_URL_EDITED', () => { + expect(reducer({}, { type: SHORT_URL_EDITED, longUrl, shortCode })).toEqual({ + longUrl, + shortCode, + saving: false, + error: false, + }); + }); + }); + + describe('editShortUrl', () => { + const updateShortUrlMeta = jest.fn().mockResolvedValue({}); + const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlMeta }); + const dispatch = jest.fn(); + + afterEach(jest.clearAllMocks); + + it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches long URL on success', async (domain) => { + await editShortUrl(buildShlinkApiClient)(shortCode, domain, longUrl)(dispatch); + + expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); + expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); + expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, { longUrl }); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, longUrl, shortCode, domain }); + }); + + it('dispatches error on failure', async () => { + const error = new Error(); + + updateShortUrlMeta.mockRejectedValue(error); + + try { + await editShortUrl(buildShlinkApiClient)(shortCode, undefined, longUrl)(dispatch); + } catch (e) { + expect(e).toBe(error); + } + + expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); + expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); + expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, { longUrl }); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_ERROR }); + }); + }); +});