Created component to edit short URLs meta

This commit is contained in:
Alejandro Celaya 2020-01-17 21:07:59 +01:00
parent 2d60f830f7
commit 80a8e0b55c
10 changed files with 168 additions and 26 deletions

View file

@ -6,6 +6,7 @@ import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListPara
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation'; import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList'; import tagsListReducer from '../tags/reducers/tagsList';
@ -20,6 +21,7 @@ export default combineReducers({
shortUrlCreationResult: shortUrlCreationReducer, shortUrlCreationResult: shortUrlCreationReducer,
shortUrlDeletion: shortUrlDeletionReducer, shortUrlDeletion: shortUrlDeletionReducer,
shortUrlTags: shortUrlTagsReducer, shortUrlTags: shortUrlTagsReducer,
shortUrlMeta: shortUrlMetaReducer,
shortUrlVisits: shortUrlVisitsReducer, shortUrlVisits: shortUrlVisitsReducer,
shortUrlDetail: shortUrlDetailReducer, shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer, tagsList: tagsListReducer,

View file

@ -2,7 +2,7 @@ import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@forta
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda'; import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React from 'react'; import React from 'react';
import { Collapse } from 'reactstrap'; import { Collapse, FormGroup, Input } from 'reactstrap';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput'; import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox'; import Checkbox from '../utils/Checkbox';
@ -40,9 +40,8 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) }); const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => ( const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
<div className="form-group"> <FormGroup>
<input <Input
className="form-control"
id={id} id={id}
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
@ -50,7 +49,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
onChange={(e) => this.setState({ [id]: e.target.value })} onChange={(e) => this.setState({ [id]: e.target.value })}
{...props} {...props}
/> />
</div> </FormGroup>
); );
const renderDateInput = (id, placeholder, props = {}) => ( const renderDateInput = (id, placeholder, props = {}) => (
<div className="form-group"> <div className="form-group">

View file

@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import moment from 'moment';
import { shortUrlType } from '../reducers/shortUrlsList';
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
import DateInput from '../../utils/DateInput';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlMeta: shortUrlEditMetaType,
editShortUrlMeta: PropTypes.func,
shortUrlMetaEdited: PropTypes.func,
resetShortUrlMeta: PropTypes.func,
};
const dateOrUndefined = (shortUrl, dateName) => {
const date = shortUrl && shortUrl.meta && shortUrl.meta[dateName];
return date && moment(date);
};
const EditMetaModal = (
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, shortUrlMetaEdited, resetShortUrlMeta }
) => {
const { saving, error } = editShortUrlMeta;
const url = shortUrl && (shortUrl.shortUrl || '');
const validSince = dateOrUndefined(shortUrl, 'validSince');
const validUntil = dateOrUndefined(shortUrl, 'validUntil');
console.log(shortUrlMeta, shortUrlMetaEdited, resetShortUrlMeta, useEffect, useState);
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
Edit metadata for <ExternalLink href={url} />
</ModalHeader>
<form>
<ModalBody>
<FormGroup>
<DateInput
placeholderText="Enabled since..."
selected={validSince}
maxDate={validUntil}
isClearable
/>
</FormGroup>
<FormGroup>
<DateInput
placeholderText="Enabled until..."
selected={validUntil}
minDate={validSince}
isClearable
/>
</FormGroup>
<FormGroup className="mb-0">
<Input type="number" placeholder="Maximum number of visits allowed" />
</FormGroup>
{error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the metadata :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" type="button" onClick={toggle}>Cancel</button>
<button className="btn btn-primary" type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</ModalFooter>
</form>
</Modal>
);
};
EditMetaModal.propTypes = propTypes;
export default EditMetaModal;

View file

@ -9,7 +9,6 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
static propTypes = { static propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired, toggle: PropTypes.func.isRequired,
url: PropTypes.string.isRequired,
shortUrl: shortUrlType.isRequired, shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType, shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func, editShortUrlTags: PropTypes.func,
@ -51,12 +50,13 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
} }
render() { render() {
const { isOpen, toggle, url, shortUrlTags } = this.props; const { isOpen, toggle, shortUrl, shortUrlTags } = this.props;
const url = shortUrl && (shortUrl.shortUrl || '');
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}> <Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}>
<ModalHeader toggle={toggle}> <ModalHeader toggle={toggle}>
Edit tags for <ExternalLink href={url}>{url}</ExternalLink> Edit tags for <ExternalLink href={url} />
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} /> <TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />

View file

@ -21,8 +21,11 @@ import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal'; import QrCodeModal from './QrCodeModal';
import './ShortUrlsRowMenu.scss'; import './ShortUrlsRowMenu.scss';
// FIXME Replace with typescript: (DeleteShortUrlModal component, EditTagsModal component) const ShortUrlsRowMenu = (
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component { DeleteShortUrlModal,
EditTagsModal,
EditMetaModal
) => class ShortUrlsRowMenu extends React.Component {
static propTypes = { static propTypes = {
onCopyToClipboard: PropTypes.func, onCopyToClipboard: PropTypes.func,
selectedServer: serverType, selectedServer: serverType,
@ -64,16 +67,12 @@ const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrls
<DropdownItem onClick={toggleTags}> <DropdownItem onClick={toggleTags}>
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags <FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
</DropdownItem> </DropdownItem>
<EditTagsModal <EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
url={completeShortUrl}
shortUrl={shortUrl}
isOpen={this.state.isTagsModalOpen}
toggle={toggleTags}
/>
<DropdownItem onClick={toggleMeta}> <DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata <FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem> </DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}> <DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL <FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL

View file

@ -0,0 +1,52 @@
import { createAction, handleActions } from 'redux-actions';
import PropTypes from 'prop-types';
import { shortUrlMetaType } from './shortUrlsList';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START';
export const EDIT_SHORT_URL_META_ERROR = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_ERROR';
export const EDIT_SHORT_URL_META = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META';
export const RESET_EDIT_SHORT_URL_META = 'shlink/shortUrlMeta/RESET_EDIT_SHORT_URL_META';
export const SHORT_URL_META_EDITED = 'shlink/shortUrlMeta/SHORT_URL_META_EDITED';
/* eslint-enable padding-line-between-statements */
export const shortUrlEditMetaType = PropTypes.shape({
shortCode: PropTypes.string,
meta: shortUrlMetaType.isRequired,
saving: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
});
const initialState = {
shortCode: null,
meta: {},
saving: false,
error: false,
};
export default handleActions({
[EDIT_SHORT_URL_META_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_META_ERROR]: (state) => ({ ...state, saving: false, error: true }),
[EDIT_SHORT_URL_META]: (state, { shortCode, meta }) => ({ shortCode, meta, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_META]: () => initialState,
}, initialState);
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, meta) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, meta);
dispatch({ shortCode, meta, type: EDIT_SHORT_URL_META });
} catch (e) {
dispatch({ type: EDIT_SHORT_URL_META_ERROR });
}
};
export const resetShortUrlMeta = createAction(RESET_EDIT_SHORT_URL_META);
export const shortUrlMetaEdited = (shortCode, meta) => ({
meta,
shortCode,
type: SHORT_URL_META_EDITED,
});

View file

@ -14,6 +14,7 @@ import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreatio
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion'; import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags'; import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams'; import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
import EditMetaModal from '../helpers/EditMetaModal';
const provideServices = (bottle, connect) => { const provideServices = (bottle, connect) => {
// Components // Components
@ -33,7 +34,7 @@ const provideServices = (bottle, connect) => {
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal'); bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal', 'EditMetaModal');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult'); bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
@ -54,6 +55,12 @@ const provideServices = (bottle, connect) => {
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ] [ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
)); ));
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
bottle.decorator('EditMetaModal', connect(
[ 'shortUrlMeta' ],
[ 'editShortUrlMeta', 'shortUrlMetaEdited', 'resetShortUrlMeta' ]
));
// Actions // Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient'); bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags); bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);

View file

@ -23,7 +23,7 @@ export default class ShlinkApiClient {
listShortUrls = pipe( listShortUrls = pipe(
(options = {}) => reject(isNil, options), (options = {}) => reject(isNil, options),
(options = {}) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls) (options) => this._performRequest('/short-urls', 'GET', options).then((resp) => resp.data.shortUrls)
); );
createShortUrl = (options) => { createShortUrl = (options) => {
@ -49,6 +49,10 @@ export default class ShlinkApiClient {
this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags }) this._performRequest(`/short-urls/${shortCode}/tags`, 'PUT', {}, { tags })
.then((resp) => resp.data.tags); .then((resp) => resp.data.tags);
updateShortUrlMeta = (shortCode, meta) =>
this._performRequest(`/short-urls/${shortCode}`, 'PATCH', {}, meta)
.then(() => ({ meta }));
listTags = () => listTags = () =>
this._performRequest('/tags', 'GET') this._performRequest('/tags', 'GET')
.then((resp) => resp.data.tags.data); .then((resp) => resp.data.tags.data);

View file

@ -17,7 +17,6 @@ describe('<EditTagsModal />', () => {
wrapper = shallow( wrapper = shallow(
<EditTagsModal <EditTagsModal
isOpen={true} isOpen={true}
url={''}
shortUrl={{ shortUrl={{
tags: [], tags: [],
shortCode, shortCode,

View file

@ -10,6 +10,7 @@ describe('<ShortUrlsRowMenu />', () => {
let wrapper; let wrapper;
const DeleteShortUrlModal = () => ''; const DeleteShortUrlModal = () => '';
const EditTagsModal = () => ''; const EditTagsModal = () => '';
const EditMetaModal = () => '';
const onCopyToClipboard = jest.fn(); const onCopyToClipboard = jest.fn();
const selectedServer = { id: 'abc123' }; const selectedServer = { id: 'abc123' };
const shortUrl = { const shortUrl = {
@ -17,7 +18,7 @@ describe('<ShortUrlsRowMenu />', () => {
shortUrl: 'https://doma.in/abc123', shortUrl: 'https://doma.in/abc123',
}; };
const createWrapper = (serverVersion = '1.21.1') => { const createWrapper = (serverVersion = '1.21.1') => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal); const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, EditTagsModal, EditMetaModal);
wrapper = shallow( wrapper = shallow(
<ShortUrlsRowMenu <ShortUrlsRowMenu
@ -46,12 +47,12 @@ describe('<ShortUrlsRowMenu />', () => {
}); });
each([ each([
[ '1.20.3', 6, 2 ], [ '1.20.3', 7, 2 ],
[ '1.21.0', 6, 2 ], [ '1.21.0', 7, 2 ],
[ '1.21.1', 6, 2 ], [ '1.21.1', 7, 2 ],
[ '2.0.0', 5, 1 ], [ '2.0.0', 6, 1 ],
[ '2.0.1', 5, 1 ], [ '2.0.1', 6, 1 ],
[ '2.1.0', 5, 1 ], [ '2.1.0', 6, 1 ],
]).it('renders correct amount of menu items depending on the version', (version, expectedNonDividerItems, expectedDividerItems) => { ]).it('renders correct amount of menu items depending on the version', (version, expectedNonDividerItems, expectedDividerItems) => {
const wrapper = createWrapper(version); const wrapper = createWrapper(version);
const items = wrapper.find(DropdownItem); const items = wrapper.find(DropdownItem);