mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 02:07:26 +03:00
commit
591c3b76f9
27 changed files with 890 additions and 178 deletions
|
@ -1,37 +0,0 @@
|
||||||
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
|
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
|
||||||
import React from 'react';
|
|
||||||
import DatePicker from 'react-datepicker';
|
|
||||||
import { isNil } from 'ramda';
|
|
||||||
import './DateInput.scss';
|
|
||||||
|
|
||||||
export default class DateInput extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.inputRef = props.ref || React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { className, isClearable, selected } = this.props;
|
|
||||||
const showCalendarIcon = !isClearable || isNil(selected);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="date-input-container">
|
|
||||||
<DatePicker
|
|
||||||
{...this.props}
|
|
||||||
className={`date-input-container__input form-control ${className || ''}`}
|
|
||||||
dateFormat="YYYY-MM-DD"
|
|
||||||
readOnly
|
|
||||||
ref={this.inputRef}
|
|
||||||
/>
|
|
||||||
{showCalendarIcon && (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={calendarIcon}
|
|
||||||
className="date-input-container__icon"
|
|
||||||
onClick={() => this.inputRef.current.input.focus()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,12 +5,22 @@ import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Collapse } from 'reactstrap';
|
import { Collapse } from 'reactstrap';
|
||||||
import DateInput from '../common/DateInput';
|
import * as PropTypes from 'prop-types';
|
||||||
|
import DateInput from '../utils/DateInput';
|
||||||
import TagsSelector from '../tags/helpers/TagsSelector';
|
import TagsSelector from '../tags/helpers/TagsSelector';
|
||||||
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
|
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
|
||||||
import { createShortUrl, resetCreateShortUrl } from './reducers/shortUrlCreation';
|
import { createShortUrl, createShortUrlResultType, resetCreateShortUrl } from './reducers/shortUrlCreation';
|
||||||
|
|
||||||
|
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
|
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||||
|
|
||||||
export class CreateShortUrlComponent extends React.Component {
|
export class CreateShortUrlComponent extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
createShortUrl: PropTypes.func,
|
||||||
|
shortUrlCreationResult: createShortUrlResultType,
|
||||||
|
resetCreateShortUrl: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
longUrl: '',
|
longUrl: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
@ -24,18 +34,22 @@ export class CreateShortUrlComponent extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
||||||
|
|
||||||
const changeTags = (tags) => this.setState({ tags: tags.map(pipe(trim, replace(/ /g, '-'))) });
|
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">
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="form-control"
|
||||||
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={this.state[id]}
|
value={this.state[id]}
|
||||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
const createDateInput = (id, placeholder, props = {}) => (
|
const renderDateInput = (id, placeholder, props = {}) => (
|
||||||
|
<div className="form-group">
|
||||||
<DateInput
|
<DateInput
|
||||||
selected={this.state[id]}
|
selected={this.state[id]}
|
||||||
placeholderText={placeholder}
|
placeholderText={placeholder}
|
||||||
|
@ -43,8 +57,8 @@ export class CreateShortUrlComponent extends React.Component {
|
||||||
onChange={(date) => this.setState({ [id]: date })}
|
onChange={(date) => this.setState({ [id]: date })}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
|
||||||
const save = (e) => {
|
const save = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
createShortUrl(pipe(
|
createShortUrl(pipe(
|
||||||
|
@ -75,20 +89,12 @@ export class CreateShortUrlComponent extends React.Component {
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-6">
|
<div className="col-sm-6">
|
||||||
<div className="form-group">
|
|
||||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="col-sm-6">
|
<div className="col-sm-6">
|
||||||
<div className="form-group">
|
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
||||||
{createDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
{createDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
|
@ -115,8 +115,9 @@ export class ShortUrlsListComponent extends React.Component {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMobileOrderingControls() {
|
render() {
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment>
|
||||||
<div className="d-block d-md-none mb-3">
|
<div className="d-block d-md-none mb-3">
|
||||||
<SortingDropdown
|
<SortingDropdown
|
||||||
items={SORTABLE_FIELDS}
|
items={SORTABLE_FIELDS}
|
||||||
|
@ -125,13 +126,6 @@ export class ShortUrlsListComponent extends React.Component {
|
||||||
onChange={this.handleOrderBy}
|
onChange={this.handleOrderBy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
{this.renderMobileOrderingControls()}
|
|
||||||
<table className="table table-striped table-hover">
|
<table className="table table-striped table-hover">
|
||||||
<thead className="short-urls-list__header">
|
<thead className="short-urls-list__header">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -47,6 +47,8 @@ export class DeleteShortUrlModalComponent extends Component {
|
||||||
render() {
|
render() {
|
||||||
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
|
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
|
||||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||||
|
const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED;
|
||||||
|
const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
|
@ -66,12 +68,12 @@ export class DeleteShortUrlModalComponent extends Component {
|
||||||
onChange={(e) => this.setState({ inputValue: e.target.value })}
|
onChange={(e) => this.setState({ inputValue: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED && (
|
{hasThresholdError && (
|
||||||
<div className="p-2 mt-2 bg-warning text-center">
|
<div className="p-2 mt-2 bg-warning text-center">
|
||||||
This short URL has received too many visits and therefore, it cannot be deleted
|
This short URL has received too many visits and therefore, it cannot be deleted
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED && (
|
{hasErrorOtherThanThreshold && (
|
||||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||||
Something went wrong while deleting the URL :(
|
Something went wrong while deleting the URL :(
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,8 +10,7 @@ const propTypes = {
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PreviewModal({ url, toggle, isOpen }) {
|
const PreviewModal = ({ url, toggle, isOpen }) => (
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
<Modal isOpen={isOpen} toggle={toggle} size="lg">
|
||||||
<ModalHeader toggle={toggle}>
|
<ModalHeader toggle={toggle}>
|
||||||
Preview for <ExternalLink href={url}>{url}</ExternalLink>
|
Preview for <ExternalLink href={url}>{url}</ExternalLink>
|
||||||
|
@ -24,6 +23,7 @@ export default function PreviewModal({ url, toggle, isOpen }) {
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
PreviewModal.propTypes = propTypes;
|
PreviewModal.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default PreviewModal;
|
||||||
|
|
|
@ -10,8 +10,7 @@ const propTypes = {
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function QrCodeModal({ url, toggle, isOpen }) {
|
const QrCodeModal = ({ url, toggle, isOpen }) => (
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
<ModalHeader toggle={toggle}>
|
<ModalHeader toggle={toggle}>
|
||||||
QR code for <ExternalLink href={url}>{url}</ExternalLink>
|
QR code for <ExternalLink href={url}>{url}</ExternalLink>
|
||||||
|
@ -23,6 +22,7 @@ export default function QrCodeModal({ url, toggle, isOpen }) {
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
QrCodeModal.propTypes = propTypes;
|
QrCodeModal.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default QrCodeModal;
|
||||||
|
|
|
@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||||
const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
|
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
|
||||||
const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
|
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
|
||||||
const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
|
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
|
||||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
export const createShortUrlResultType = PropTypes.shape({
|
export const createShortUrlResultType = PropTypes.shape({
|
||||||
|
@ -29,6 +29,7 @@ export default function reducer(state = defaultState, action) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
saving: true,
|
saving: true,
|
||||||
|
error: false,
|
||||||
};
|
};
|
||||||
case CREATE_SHORT_URL_ERROR:
|
case CREATE_SHORT_URL_ERROR:
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -28,36 +28,20 @@ export default class TagCard extends React.Component {
|
||||||
return (
|
return (
|
||||||
<Card className="tag-card">
|
<Card className="tag-card">
|
||||||
<CardBody className="tag-card__body">
|
<CardBody className="tag-card__body">
|
||||||
<button
|
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
|
||||||
className="btn btn-light btn-sm tag-card__btn tag-card__btn--last"
|
|
||||||
onClick={toggleDelete}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={deleteIcon} />
|
<FontAwesomeIcon icon={deleteIcon} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
|
||||||
className="btn btn-light btn-sm tag-card__btn"
|
|
||||||
onClick={toggleEdit}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
<FontAwesomeIcon icon={editIcon} />
|
||||||
</button>
|
</button>
|
||||||
<h5 className="tag-card__tag-title">
|
<h5 className="tag-card__tag-title">
|
||||||
<TagBullet tag={tag} />
|
<TagBullet tag={tag} />
|
||||||
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>
|
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
|
||||||
{tag}
|
|
||||||
</Link>
|
|
||||||
</h5>
|
</h5>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|
||||||
<DeleteTagConfirmModal
|
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
|
||||||
tag={tag}
|
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
|
||||||
toggle={toggleDelete}
|
|
||||||
isOpen={this.state.isDeleteModalOpen}
|
|
||||||
/>
|
|
||||||
<EditTagModal
|
|
||||||
tag={tag}
|
|
||||||
toggle={toggleEdit}
|
|
||||||
isOpen={this.state.isEditModalOpen}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { filterTags, forceListTags } from './reducers/tagsList';
|
||||||
import TagCard from './TagCard';
|
import TagCard from './TagCard';
|
||||||
|
|
||||||
const { ceil } = Math;
|
const { ceil } = Math;
|
||||||
const TAGS_GROUP_SIZE = 4;
|
const TAGS_GROUPS_AMOUNT = 4;
|
||||||
|
|
||||||
export class TagsListComponent extends React.Component {
|
export class TagsListComponent extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -16,6 +16,8 @@ export class TagsListComponent extends React.Component {
|
||||||
forceListTags: PropTypes.func,
|
forceListTags: PropTypes.func,
|
||||||
tagsList: PropTypes.shape({
|
tagsList: PropTypes.shape({
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
|
error: PropTypes.bool,
|
||||||
|
filteredTags: PropTypes.arrayOf(PropTypes.string),
|
||||||
}),
|
}),
|
||||||
match: PropTypes.object,
|
match: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
@ -23,7 +25,7 @@ export class TagsListComponent extends React.Component {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { forceListTags } = this.props;
|
const { forceListTags } = this.props;
|
||||||
|
|
||||||
forceListTags(true);
|
forceListTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContent() {
|
renderContent() {
|
||||||
|
@ -47,7 +49,7 @@ export class TagsListComponent extends React.Component {
|
||||||
return <MuttedMessage>No tags found</MuttedMessage>;
|
return <MuttedMessage>No tags found</MuttedMessage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUP_SIZE), tagsList.filteredTags);
|
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
@ -71,13 +73,9 @@ export class TagsListComponent extends React.Component {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shlink-container">
|
<div className="shlink-container">
|
||||||
{!this.props.tagsList.loading && (
|
{!this.props.tagsList.loading &&
|
||||||
<SearchField
|
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
|
||||||
className="mb-3"
|
}
|
||||||
placeholder="Search tags..."
|
|
||||||
onChange={filterTags}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{this.renderContent()}
|
{this.renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
|
||||||
import shlinkApiClient from '../../api/ShlinkApiClient';
|
import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||||
const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
||||||
const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
||||||
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
||||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,9 @@ import shlinkApiClient from '../../api/ShlinkApiClient';
|
||||||
import colorGenerator from '../../utils/ColorGenerator';
|
import colorGenerator from '../../utils/ColorGenerator';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements, newline-after-var */
|
/* eslint-disable padding-line-between-statements, newline-after-var */
|
||||||
const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||||
const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
||||||
const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
||||||
/* eslint-enable padding-line-between-statements, newline-after-var */
|
/* eslint-enable padding-line-between-statements, newline-after-var */
|
||||||
|
|
||||||
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
|
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
|
||||||
|
@ -42,8 +42,7 @@ export default function reducer(state = defaultState, action) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) =>
|
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) => async (dispatch) => {
|
||||||
async (dispatch) => {
|
|
||||||
dispatch({ type: EDIT_TAG_START });
|
dispatch({ type: EDIT_TAG_START });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
42
src/utils/DateInput.js
Normal file
42
src/utils/DateInput.js
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { isNil } from 'ramda';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
|
import calendarIcon from '@fortawesome/fontawesome-free-regular/faCalendarAlt';
|
||||||
|
import * as PropTypes from 'prop-types';
|
||||||
|
import './DateInput.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
|
isClearable: PropTypes.bool,
|
||||||
|
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
|
||||||
|
ref: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DateInput = (props) => {
|
||||||
|
const { className, isClearable, selected, ref = React.createRef() } = props;
|
||||||
|
const showCalendarIcon = !isClearable || isNil(selected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="date-input-container">
|
||||||
|
<DatePicker
|
||||||
|
{...props}
|
||||||
|
className={`date-input-container__input form-control ${className || ''}`}
|
||||||
|
dateFormat="YYYY-MM-DD"
|
||||||
|
readOnly
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
{showCalendarIcon && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={calendarIcon}
|
||||||
|
className="date-input-container__icon"
|
||||||
|
onClick={() => ref.current.input.focus()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DateInput.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default DateInput;
|
|
@ -1,5 +1,5 @@
|
||||||
@import '../utils/mixins/vertical-align';
|
@import './mixins/vertical-align';
|
||||||
@import '../utils/base';
|
@import './base';
|
||||||
|
|
||||||
.date-input-container {
|
.date-input-container {
|
||||||
position: relative;
|
position: relative;
|
|
@ -5,7 +5,7 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import DateInput from '../common/DateInput';
|
import DateInput from '../utils/DateInput';
|
||||||
import MutedMessage from '../utils/MuttedMessage';
|
import MutedMessage from '../utils/MuttedMessage';
|
||||||
import SortableBarGraph from './SortableBarGraph';
|
import SortableBarGraph from './SortableBarGraph';
|
||||||
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
|
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||||
|
|
66
test/short-urls/CreateShortUrl.test.js
Normal file
66
test/short-urls/CreateShortUrl.test.js
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import moment from 'moment';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import { identity } from 'ramda';
|
||||||
|
import { CreateShortUrlComponent as CreateShortUrl } from '../../src/short-urls/CreateShortUrl';
|
||||||
|
import TagsSelector from '../../src/tags/helpers/TagsSelector';
|
||||||
|
import DateInput from '../../src/utils/DateInput';
|
||||||
|
|
||||||
|
describe('<CreateShortUrl />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const shortUrlCreationResult = {
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
const createShortUrl = sinon.spy();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
createShortUrl.resetHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves short URL with data set in form controls', (done) => {
|
||||||
|
const validSince = moment('2017-01-01');
|
||||||
|
const validUntil = moment('2017-01-06');
|
||||||
|
|
||||||
|
const urlInput = wrapper.find('.form-control-lg');
|
||||||
|
const tagsInput = wrapper.find(TagsSelector);
|
||||||
|
const customSlugInput = wrapper.find('#customSlug');
|
||||||
|
const maxVisitsInput = wrapper.find('#maxVisits');
|
||||||
|
const dateInputs = wrapper.find(DateInput);
|
||||||
|
const validSinceInput = dateInputs.at(0);
|
||||||
|
const validUntilInput = dateInputs.at(1);
|
||||||
|
|
||||||
|
urlInput.simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
|
||||||
|
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]);
|
||||||
|
customSlugInput.simulate('change', { target: { value: 'my-slug' } });
|
||||||
|
maxVisitsInput.simulate('change', { target: { value: '20' } });
|
||||||
|
validSinceInput.simulate('change', validSince);
|
||||||
|
validUntilInput.simulate('change', validUntil);
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
const form = wrapper.find('form');
|
||||||
|
|
||||||
|
form.simulate('submit', { preventDefault: identity });
|
||||||
|
expect(createShortUrl.callCount).toEqual(1);
|
||||||
|
expect(createShortUrl.getCall(0).args).toEqual(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
longUrl: 'https://long-domain.com/foo/bar',
|
||||||
|
tags: [ 'tag_foo', 'tag_bar' ],
|
||||||
|
customSlug: 'my-slug',
|
||||||
|
validSince: validSince.format(),
|
||||||
|
validUntil: validUntil.format(),
|
||||||
|
maxVisits: '20',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
26
test/short-urls/ShortUrls.test.js
Normal file
26
test/short-urls/ShortUrls.test.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { ShortUrlsComponent as ShortUrls } from '../../src/short-urls/ShortUrls';
|
||||||
|
import Paginator from '../../src/short-urls/Paginator';
|
||||||
|
import ShortUrlsList from '../../src/short-urls/ShortUrlsList';
|
||||||
|
import SearchBar from '../../src/short-urls/SearchBar';
|
||||||
|
|
||||||
|
describe('<ShortUrlsList />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const params = {
|
||||||
|
serverId: '1',
|
||||||
|
page: '1',
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapper = shallow(<ShortUrls match={{ params }} shortUrlsList={{ data: [] }} />);
|
||||||
|
});
|
||||||
|
afterEach(() => wrapper.unmount());
|
||||||
|
|
||||||
|
it('wraps a SearchBar, ShortUrlsList as Paginator', () => {
|
||||||
|
expect(wrapper.find(SearchBar)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(ShortUrlsList)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(Paginator)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
48
test/short-urls/helpers/CreateShortUrlResult.test.js
Normal file
48
test/short-urls/helpers/CreateShortUrlResult.test.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { identity } from 'ramda';
|
||||||
|
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||||
|
import { Tooltip } from 'reactstrap';
|
||||||
|
import CreateShortUrlResult from '../../../src/short-urls/helpers/CreateShortUrlResult';
|
||||||
|
|
||||||
|
describe('<CreateShortUrlResult />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const createWrapper = (result, error = false) => {
|
||||||
|
wrapper = shallow(<CreateShortUrlResult resetCreateShortUrl={identity} result={result} error={error} />);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper && wrapper.unmount());
|
||||||
|
|
||||||
|
it('renders an error when error is true', () => {
|
||||||
|
const wrapper = createWrapper({}, true);
|
||||||
|
const errorCard = wrapper.find('.bg-danger');
|
||||||
|
|
||||||
|
expect(errorCard).toHaveLength(1);
|
||||||
|
expect(errorCard.html()).toContain('An error occurred while creating the URL :(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when no result is provided', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
|
expect(wrapper.html()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a result message when result is provided', () => {
|
||||||
|
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
|
||||||
|
|
||||||
|
expect(wrapper.html()).toContain('<b>Great!</b> The short URL is <b>https://doma.in/abc123</b>');
|
||||||
|
expect(wrapper.find(CopyToClipboard)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(Tooltip)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows tooltip when copy to clipboard button is clicked', () => {
|
||||||
|
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
|
||||||
|
const copyBtn = wrapper.find(CopyToClipboard);
|
||||||
|
|
||||||
|
expect(wrapper.state('showCopyTooltip')).toEqual(false);
|
||||||
|
copyBtn.simulate('copy');
|
||||||
|
expect(wrapper.state('showCopyTooltip')).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
115
test/short-urls/helpers/DeleteShortUrlModal.test.js
Normal file
115
test/short-urls/helpers/DeleteShortUrlModal.test.js
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { identity } from 'ramda';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import { DeleteShortUrlModalComponent as DeleteShortUrlModal } from '../../../src/short-urls/helpers/DeleteShortUrlModal';
|
||||||
|
|
||||||
|
describe('<DeleteShortUrlModal />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const shortUrl = {
|
||||||
|
tags: [],
|
||||||
|
shortCode: 'abc123',
|
||||||
|
originalUrl: 'https://long-domain.com/foo/bar',
|
||||||
|
};
|
||||||
|
const deleteShortUrl = sinon.fake.returns(Promise.resolve());
|
||||||
|
const createWrapper = (shortUrlDeletion) => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<DeleteShortUrlModal
|
||||||
|
isOpen
|
||||||
|
shortUrl={shortUrl}
|
||||||
|
shortUrlDeletion={shortUrlDeletion}
|
||||||
|
toggle={identity}
|
||||||
|
deleteShortUrl={deleteShortUrl}
|
||||||
|
resetDeleteShortUrl={identity}
|
||||||
|
shortUrlDeleted={identity}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper && wrapper.unmount();
|
||||||
|
deleteShortUrl.resetHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows threshold error message when threshold error occurs', () => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
loading: false,
|
||||||
|
error: true,
|
||||||
|
shortCode: 'abc123',
|
||||||
|
errorData: { error: 'INVALID_SHORTCODE_DELETION' },
|
||||||
|
});
|
||||||
|
const warning = wrapper.find('.bg-warning');
|
||||||
|
|
||||||
|
expect(warning).toHaveLength(1);
|
||||||
|
expect(warning.html()).toContain('This short URL has received too many visits and therefore, it cannot be deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows generic error when non-threshold error occurs', () => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
loading: false,
|
||||||
|
error: true,
|
||||||
|
shortCode: 'abc123',
|
||||||
|
errorData: { error: 'OTHER_ERROR' },
|
||||||
|
});
|
||||||
|
const error = wrapper.find('.bg-danger');
|
||||||
|
|
||||||
|
expect(error).toHaveLength(1);
|
||||||
|
expect(error.html()).toContain('Something went wrong while deleting the URL :(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables submit button when loading', () => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
loading: true,
|
||||||
|
error: false,
|
||||||
|
shortCode: 'abc123',
|
||||||
|
errorData: {},
|
||||||
|
});
|
||||||
|
const submit = wrapper.find('.btn-danger');
|
||||||
|
|
||||||
|
expect(submit).toHaveLength(1);
|
||||||
|
expect(submit.prop('disabled')).toEqual(true);
|
||||||
|
expect(submit.html()).toContain('Deleting...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables submit button when proper short code is provided', (done) => {
|
||||||
|
const shortCode = 'abc123';
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
shortCode,
|
||||||
|
errorData: {},
|
||||||
|
});
|
||||||
|
const input = wrapper.find('.form-control');
|
||||||
|
|
||||||
|
input.simulate('change', { target: { value: shortCode } });
|
||||||
|
setImmediate(() => {
|
||||||
|
const submit = wrapper.find('.btn-danger');
|
||||||
|
|
||||||
|
expect(submit.prop('disabled')).toEqual(false);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries to delete short URL when form is submit', (done) => {
|
||||||
|
const shortCode = 'abc123';
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
shortCode,
|
||||||
|
errorData: {},
|
||||||
|
});
|
||||||
|
const input = wrapper.find('.form-control');
|
||||||
|
|
||||||
|
input.simulate('change', { target: { value: shortCode } });
|
||||||
|
setImmediate(() => {
|
||||||
|
const form = wrapper.find('form');
|
||||||
|
|
||||||
|
expect(deleteShortUrl.callCount).toEqual(0);
|
||||||
|
form.simulate('submit', { preventDefault: identity });
|
||||||
|
expect(deleteShortUrl.callCount).toEqual(1);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
28
test/short-urls/helpers/PreviewModal.test.js
Normal file
28
test/short-urls/helpers/PreviewModal.test.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import PreviewModal from '../../../src/short-urls/helpers/PreviewModal';
|
||||||
|
import ExternalLink from '../../../src/utils/ExternalLink';
|
||||||
|
|
||||||
|
describe('<PreviewModal />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const url = 'https://doma.in/abc123';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = shallow(<PreviewModal url={url} />);
|
||||||
|
});
|
||||||
|
afterEach(() => wrapper.unmount());
|
||||||
|
|
||||||
|
it('shows an external link to the URL', () => {
|
||||||
|
const externalLink = wrapper.find(ExternalLink);
|
||||||
|
|
||||||
|
expect(externalLink).toHaveLength(1);
|
||||||
|
expect(externalLink.prop('href')).toEqual(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays an image with the preview of the URL', () => {
|
||||||
|
const img = wrapper.find('img');
|
||||||
|
|
||||||
|
expect(img).toHaveLength(1);
|
||||||
|
expect(img.prop('src')).toEqual(`${url}/preview`);
|
||||||
|
});
|
||||||
|
});
|
28
test/short-urls/helpers/QrCodeModal.test.js
Normal file
28
test/short-urls/helpers/QrCodeModal.test.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal';
|
||||||
|
import ExternalLink from '../../../src/utils/ExternalLink';
|
||||||
|
|
||||||
|
describe('<QrCodeModal />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const url = 'https://doma.in/abc123';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = shallow(<QrCodeModal url={url} />);
|
||||||
|
});
|
||||||
|
afterEach(() => wrapper.unmount());
|
||||||
|
|
||||||
|
it('shows an external link to the URL', () => {
|
||||||
|
const externalLink = wrapper.find(ExternalLink);
|
||||||
|
|
||||||
|
expect(externalLink).toHaveLength(1);
|
||||||
|
expect(externalLink.prop('href')).toEqual(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays an image with the QR code of the URL', () => {
|
||||||
|
const img = wrapper.find('img');
|
||||||
|
|
||||||
|
expect(img).toHaveLength(1);
|
||||||
|
expect(img.prop('src')).toEqual(`${url}/qr-code`);
|
||||||
|
});
|
||||||
|
});
|
94
test/short-urls/reducers/shortUrlCreation.test.js
Normal file
94
test/short-urls/reducers/shortUrlCreation.test.js
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import reducer, {
|
||||||
|
CREATE_SHORT_URL_START,
|
||||||
|
CREATE_SHORT_URL_ERROR,
|
||||||
|
CREATE_SHORT_URL,
|
||||||
|
RESET_CREATE_SHORT_URL,
|
||||||
|
_createShortUrl,
|
||||||
|
resetCreateShortUrl,
|
||||||
|
} from '../../../src/short-urls/reducers/shortUrlCreation';
|
||||||
|
|
||||||
|
describe('shortUrlCreationReducer', () => {
|
||||||
|
describe('reducer', () => {
|
||||||
|
it('returns loading on CREATE_SHORT_URL_START', () => {
|
||||||
|
expect(reducer({}, { type: CREATE_SHORT_URL_START })).toEqual({
|
||||||
|
saving: true,
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error on CREATE_SHORT_URL_ERROR', () => {
|
||||||
|
expect(reducer({}, { type: CREATE_SHORT_URL_ERROR })).toEqual({
|
||||||
|
saving: false,
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns result on CREATE_SHORT_URL', () => {
|
||||||
|
expect(reducer({}, { type: CREATE_SHORT_URL, result: 'foo' })).toEqual({
|
||||||
|
saving: false,
|
||||||
|
error: false,
|
||||||
|
result: 'foo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default state on RESET_CREATE_SHORT_URL', () => {
|
||||||
|
expect(reducer({}, { type: RESET_CREATE_SHORT_URL })).toEqual({
|
||||||
|
result: null,
|
||||||
|
saving: false,
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns provided state on unknown action', () =>
|
||||||
|
expect(reducer({}, { type: 'unknown' })).toEqual({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetCreateShortUrl', () => {
|
||||||
|
it('returns proper action', () =>
|
||||||
|
expect(resetCreateShortUrl()).toEqual({ type: RESET_CREATE_SHORT_URL }));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createShortUrl', () => {
|
||||||
|
const createApiClientMock = (result) => ({
|
||||||
|
createShortUrl: sinon.fake.returns(result),
|
||||||
|
});
|
||||||
|
const dispatch = sinon.spy();
|
||||||
|
|
||||||
|
afterEach(() => dispatch.resetHistory());
|
||||||
|
|
||||||
|
it('calls API on success', async () => {
|
||||||
|
const expectedDispatchCalls = 2;
|
||||||
|
const result = 'foo';
|
||||||
|
const apiClientMock = createApiClientMock(Promise.resolve(result));
|
||||||
|
const dispatchable = _createShortUrl(apiClientMock, {});
|
||||||
|
|
||||||
|
await dispatchable(dispatch);
|
||||||
|
|
||||||
|
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
|
||||||
|
|
||||||
|
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||||
|
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
|
||||||
|
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL, result }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on error', async () => {
|
||||||
|
const expectedDispatchCalls = 2;
|
||||||
|
const error = 'Error';
|
||||||
|
const apiClientMock = createApiClientMock(Promise.reject(error));
|
||||||
|
const dispatchable = _createShortUrl(apiClientMock, {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dispatchable(dispatch);
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toEqual(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(apiClientMock.createShortUrl.callCount).toEqual(1);
|
||||||
|
|
||||||
|
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||||
|
expect(dispatch.getCall(0).args).toEqual([{ type: CREATE_SHORT_URL_START }]);
|
||||||
|
expect(dispatch.getCall(1).args).toEqual([{ type: CREATE_SHORT_URL_ERROR }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
46
test/tags/TagCard.test.js
Normal file
46
test/tags/TagCard.test.js
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import TagCard from '../../src/tags/TagCard';
|
||||||
|
import TagBullet from '../../src/tags/helpers/TagBullet';
|
||||||
|
|
||||||
|
describe('<TagCard />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = shallow(<TagCard tag="ssr" currentServerId="1" />);
|
||||||
|
});
|
||||||
|
afterEach(() => wrapper.unmount());
|
||||||
|
|
||||||
|
it('shows a TagBullet and a link to the list filtering by the tag', () => {
|
||||||
|
const link = wrapper.find(Link);
|
||||||
|
const bullet = wrapper.find(TagBullet);
|
||||||
|
|
||||||
|
expect(link.prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr');
|
||||||
|
expect(bullet.prop('tag')).toEqual('ssr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays delete modal when delete btn is clicked', (done) => {
|
||||||
|
const delBtn = wrapper.find('.tag-card__btn').at(0);
|
||||||
|
|
||||||
|
expect(wrapper.state('isDeleteModalOpen')).toEqual(false);
|
||||||
|
delBtn.simulate('click');
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
expect(wrapper.state('isDeleteModalOpen')).toEqual(true);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays edit modal when edit btn is clicked', (done) => {
|
||||||
|
const editBtn = wrapper.find('.tag-card__btn').at(1);
|
||||||
|
|
||||||
|
expect(wrapper.state('isEditModalOpen')).toEqual(false);
|
||||||
|
editBtn.simulate('click');
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
expect(wrapper.state('isEditModalOpen')).toEqual(true);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
76
test/tags/TagsList.test.js
Normal file
76
test/tags/TagsList.test.js
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { identity, range } from 'ramda';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import { TagsListComponent as TagsList } from '../../src/tags/TagsList';
|
||||||
|
import MuttedMessage from '../../src/utils/MuttedMessage';
|
||||||
|
import TagCard from '../../src/tags/TagCard';
|
||||||
|
import SearchField from '../../src/utils/SearchField';
|
||||||
|
|
||||||
|
describe('<TagsList />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const filterTags = sinon.spy();
|
||||||
|
const createWrapper = (tagsList) => {
|
||||||
|
const params = { serverId: '1' };
|
||||||
|
|
||||||
|
wrapper = shallow(
|
||||||
|
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper && wrapper.unmount();
|
||||||
|
filterTags.resetHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a loading message when tags are being loaded', () => {
|
||||||
|
const wrapper = createWrapper({ loading: true });
|
||||||
|
const loadingMsg = wrapper.find(MuttedMessage);
|
||||||
|
|
||||||
|
expect(loadingMsg).toHaveLength(1);
|
||||||
|
expect(loadingMsg.html()).toContain('Loading...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error when tags failed to be loaded', () => {
|
||||||
|
const wrapper = createWrapper({ error: true });
|
||||||
|
const errorMsg = wrapper.find('.bg-danger');
|
||||||
|
|
||||||
|
expect(errorMsg).toHaveLength(1);
|
||||||
|
expect(errorMsg.html()).toContain('Error loading tags :(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a message when the list of tags is empty', () => {
|
||||||
|
const wrapper = createWrapper({ filteredTags: [] });
|
||||||
|
const msg = wrapper.find(MuttedMessage);
|
||||||
|
|
||||||
|
expect(msg).toHaveLength(1);
|
||||||
|
expect(msg.html()).toContain('No tags found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the proper amount of groups and cards based on the amount of tags', () => {
|
||||||
|
const amountOfTags = 10;
|
||||||
|
const amountOfGroups = 4;
|
||||||
|
const wrapper = createWrapper({ filteredTags: range(0, amountOfTags).map((i) => `tag_${i}`) });
|
||||||
|
const cards = wrapper.find(TagCard);
|
||||||
|
const groups = wrapper.find('.col-md-6');
|
||||||
|
|
||||||
|
expect(cards).toHaveLength(amountOfTags);
|
||||||
|
expect(groups).toHaveLength(amountOfGroups);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers tags filtering when search field changes', (done) => {
|
||||||
|
const wrapper = createWrapper({ filteredTags: [] });
|
||||||
|
const searchField = wrapper.find(SearchField);
|
||||||
|
|
||||||
|
expect(searchField).toHaveLength(1);
|
||||||
|
expect(filterTags.callCount).toEqual(0);
|
||||||
|
searchField.simulate('change');
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
expect(filterTags.callCount).toEqual(1);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
91
test/tags/reducers/tagDelete.test.js
Normal file
91
test/tags/reducers/tagDelete.test.js
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import reducer, {
|
||||||
|
DELETE_TAG_START,
|
||||||
|
DELETE_TAG_ERROR,
|
||||||
|
DELETE_TAG,
|
||||||
|
TAG_DELETED,
|
||||||
|
tagDeleted,
|
||||||
|
_deleteTag,
|
||||||
|
} from '../../../src/tags/reducers/tagDelete';
|
||||||
|
|
||||||
|
describe('tagDeleteReducer', () => {
|
||||||
|
describe('reducer', () => {
|
||||||
|
it('returns loading on DELETE_TAG_START', () => {
|
||||||
|
expect(reducer({}, { type: DELETE_TAG_START })).toEqual({
|
||||||
|
deleting: true,
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error on DELETE_TAG_ERROR', () => {
|
||||||
|
expect(reducer({}, { type: DELETE_TAG_ERROR })).toEqual({
|
||||||
|
deleting: false,
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tag names on DELETE_TAG', () => {
|
||||||
|
expect(reducer({}, { type: DELETE_TAG })).toEqual({
|
||||||
|
deleting: false,
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns provided state on unknown action', () =>
|
||||||
|
expect(reducer({}, { type: 'unknown' })).toEqual({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tagDeleted', () => {
|
||||||
|
it('returns action based on provided params', () =>
|
||||||
|
expect(tagDeleted('foo')).toEqual({
|
||||||
|
type: TAG_DELETED,
|
||||||
|
tag: 'foo',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteTag', () => {
|
||||||
|
const createApiClientMock = (result) => ({
|
||||||
|
deleteTags: sinon.fake.returns(result),
|
||||||
|
});
|
||||||
|
const dispatch = sinon.spy();
|
||||||
|
|
||||||
|
afterEach(() => dispatch.resetHistory());
|
||||||
|
|
||||||
|
it('calls API on success', async () => {
|
||||||
|
const expectedDispatchCalls = 2;
|
||||||
|
const tag = 'foo';
|
||||||
|
const apiClientMock = createApiClientMock(Promise.resolve());
|
||||||
|
const dispatchable = _deleteTag(apiClientMock, tag);
|
||||||
|
|
||||||
|
await dispatchable(dispatch);
|
||||||
|
|
||||||
|
expect(apiClientMock.deleteTags.callCount).toEqual(1);
|
||||||
|
expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
|
||||||
|
|
||||||
|
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||||
|
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_TAG_START }]);
|
||||||
|
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_TAG }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on error', async () => {
|
||||||
|
const expectedDispatchCalls = 2;
|
||||||
|
const error = 'Error';
|
||||||
|
const tag = 'foo';
|
||||||
|
const apiClientMock = createApiClientMock(Promise.reject(error));
|
||||||
|
const dispatchable = _deleteTag(apiClientMock, tag);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dispatchable(dispatch);
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toEqual(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(apiClientMock.deleteTags.callCount).toEqual(1);
|
||||||
|
expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
|
||||||
|
|
||||||
|
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||||
|
expect(dispatch.getCall(0).args).toEqual([{ type: DELETE_TAG_START }]);
|
||||||
|
expect(dispatch.getCall(1).args).toEqual([{ type: DELETE_TAG_ERROR }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
110
test/tags/reducers/tagEdit.test.js
Normal file
110
test/tags/reducers/tagEdit.test.js
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import reducer, {
|
||||||
|
EDIT_TAG_START,
|
||||||
|
EDIT_TAG_ERROR,
|
||||||
|
EDIT_TAG,
|
||||||
|
TAG_EDITED,
|
||||||
|
tagEdited,
|
||||||
|
_editTag,
|
||||||
|
} from '../../../src/tags/reducers/tagEdit';
|
||||||
|
|
||||||
|
describe('tagEditReducer', () => {
|
||||||
|
describe('reducer', () => {
|
||||||
|
it('returns loading on EDIT_TAG_START', () => {
|
||||||
|
expect(reducer({}, { type: EDIT_TAG_START })).toEqual({
|
||||||
|
editing: true,
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error on EDIT_TAG_ERROR', () => {
|
||||||
|
expect(reducer({}, { type: EDIT_TAG_ERROR })).toEqual({
|
||||||
|
editing: false,
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tag names on EDIT_TAG', () => {
|
||||||
|
expect(reducer({}, { type: EDIT_TAG, oldName: 'foo', newName: 'bar' })).toEqual({
|
||||||
|
editing: false,
|
||||||
|
error: false,
|
||||||
|
oldName: 'foo',
|
||||||
|
newName: 'bar',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns provided state on unknown action', () =>
|
||||||
|
expect(reducer({}, { type: 'unknown' })).toEqual({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tagEdited', () => {
|
||||||
|
it('returns action based on provided params', () =>
|
||||||
|
expect(tagEdited('foo', 'bar', '#ff0000')).toEqual({
|
||||||
|
type: TAG_EDITED,
|
||||||
|
oldName: 'foo',
|
||||||
|
newName: 'bar',
|
||||||
|
color: '#ff0000',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editTag', () => {
|
||||||
|
const createApiClientMock = (result) => ({
|
||||||
|
editTag: sinon.fake.returns(result),
|
||||||
|
});
|
||||||
|
const colorGenerator = {
|
||||||
|
setColorForKey: sinon.spy(),
|
||||||
|
};
|
||||||
|
const dispatch = sinon.spy();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
colorGenerator.setColorForKey.resetHistory();
|
||||||
|
dispatch.resetHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls API on success', async () => {
|
||||||
|
const expectedDispatchCalls = 2;
|
||||||
|
const oldName = 'foo';
|
||||||
|
const newName = 'bar';
|
||||||
|
const color = '#ff0000';
|
||||||
|
const apiClientMock = createApiClientMock(Promise.resolve());
|
||||||
|
const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color);
|
||||||
|
|
||||||
|
await dispatchable(dispatch);
|
||||||
|
|
||||||
|
expect(apiClientMock.editTag.callCount).toEqual(1);
|
||||||
|
expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
|
||||||
|
|
||||||
|
expect(colorGenerator.setColorForKey.callCount).toEqual(1);
|
||||||
|
expect(colorGenerator.setColorForKey.getCall(0).args).toEqual([ newName, color ]);
|
||||||
|
|
||||||
|
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||||
|
expect(dispatch.getCall(0).args).toEqual([{ type: EDIT_TAG_START }]);
|
||||||
|
expect(dispatch.getCall(1).args).toEqual([{ type: EDIT_TAG, oldName, newName }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on error', async () => {
|
||||||
|
const expectedDispatchCalls = 2;
|
||||||
|
const error = 'Error';
|
||||||
|
const oldName = 'foo';
|
||||||
|
const newName = 'bar';
|
||||||
|
const color = '#ff0000';
|
||||||
|
const apiClientMock = createApiClientMock(Promise.reject(error));
|
||||||
|
const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dispatchable(dispatch);
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toEqual(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(apiClientMock.editTag.callCount).toEqual(1);
|
||||||
|
expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
|
||||||
|
|
||||||
|
expect(colorGenerator.setColorForKey.callCount).toEqual(0);
|
||||||
|
|
||||||
|
expect(dispatch.callCount).toEqual(expectedDispatchCalls);
|
||||||
|
expect(dispatch.getCall(0).args).toEqual([{ type: EDIT_TAG_START }]);
|
||||||
|
expect(dispatch.getCall(1).args).toEqual([{ type: EDIT_TAG_ERROR }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import DateInput from '../../src/common/DateInput';
|
import DateInput from '../../src/utils/DateInput';
|
||||||
|
|
||||||
describe('<DateInput />', () => {
|
describe('<DateInput />', () => {
|
||||||
let wrapped;
|
let wrapped;
|
||||||
|
@ -13,12 +13,7 @@ describe('<DateInput />', () => {
|
||||||
return wrapped;
|
return wrapped;
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => wrapped && wrapped.unmount());
|
||||||
if (wrapped !== undefined) {
|
|
||||||
wrapped.unmount();
|
|
||||||
wrapped = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wrapps a DatePicker', () => {
|
it('wrapps a DatePicker', () => {
|
||||||
wrapped = createComponent();
|
wrapped = createComponent();
|
|
@ -6,7 +6,7 @@ import * as sinon from 'sinon';
|
||||||
import { ShortUrlsVisitsComponent as ShortUrlsVisits } from '../../src/visits/ShortUrlVisits';
|
import { ShortUrlsVisitsComponent as ShortUrlsVisits } from '../../src/visits/ShortUrlVisits';
|
||||||
import MutedMessage from '../../src/utils/MuttedMessage';
|
import MutedMessage from '../../src/utils/MuttedMessage';
|
||||||
import GraphCard from '../../src/visits/GraphCard';
|
import GraphCard from '../../src/visits/GraphCard';
|
||||||
import DateInput from '../../src/common/DateInput';
|
import DateInput from '../../src/utils/DateInput';
|
||||||
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
import SortableBarGraph from '../../src/visits/SortableBarGraph';
|
||||||
|
|
||||||
describe('<ShortUrlVisits />', () => {
|
describe('<ShortUrlVisits />', () => {
|
||||||
|
|
Loading…
Reference in a new issue