Converted short URLs list in functional component

This commit is contained in:
Alejandro Celaya 2020-04-17 17:39:30 +02:00
parent f3129399de
commit 0f73cb9f8c
2 changed files with 130 additions and 154 deletions

View file

@ -1,7 +1,7 @@
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, values } from 'ramda'; import { head, isEmpty, keys, values } from 'ramda';
import React from 'react'; import React, { useState, useEffect } from 'react';
import qs from 'qs'; import qs from 'qs';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
@ -20,148 +20,130 @@ export const SORTABLE_FIELDS = {
visits: 'Visits', visits: 'Visits',
}; };
const propTypes = {
listShortUrls: PropTypes.func,
resetShortUrlParams: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
match: PropTypes.object,
location: PropTypes.object,
loading: PropTypes.bool,
error: PropTypes.bool,
shortUrlsList: PropTypes.arrayOf(shortUrlType),
selectedServer: serverType,
createNewVisit: PropTypes.func,
mercureInfo: MercureInfoType,
};
// FIXME Replace with typescript: (ShortUrlsRow component) // FIXME Replace with typescript: (ShortUrlsRow component)
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component { const ShortUrlsList = (ShortUrlsRow) => {
static propTypes = { const ShortUrlsListComp = ({
listShortUrls: PropTypes.func, listShortUrls,
resetShortUrlParams: PropTypes.func, resetShortUrlParams,
shortUrlsListParams: shortUrlsListParamsType, shortUrlsListParams,
match: PropTypes.object, match,
location: PropTypes.object, location,
loading: PropTypes.bool, loading,
error: PropTypes.bool, error,
shortUrlsList: PropTypes.arrayOf(shortUrlType), shortUrlsList,
selectedServer: serverType, selectedServer,
createNewVisit: PropTypes.func, createNewVisit,
mercureInfo: MercureInfoType, mercureInfo,
}; }) => {
const { orderBy } = shortUrlsListParams;
refreshList = (extraParams) => { const [ order, setOrder ] = useState({
const { listShortUrls, shortUrlsListParams } = this.props; orderField: orderBy && head(keys(orderBy)),
orderDir: orderBy && head(values(orderBy)),
listShortUrls({
...shortUrlsListParams,
...extraParams,
}); });
}; const refreshList = (extraParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
const handleOrderBy = (orderField, orderDir) => {
handleOrderBy = (orderField, orderDir) => { setOrder({ orderField, orderDir });
this.setState({ orderField, orderDir }); refreshList({ orderBy: { [orderField]: orderDir } });
this.refreshList({ orderBy: { [orderField]: orderDir } });
};
orderByColumn = (columnName) => () =>
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
renderOrderIcon = (field) => {
if (this.state.orderField !== field) {
return null;
}
if (!this.state.orderDir) {
return null;
}
return (
<FontAwesomeIcon
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
className="short-urls-list__header-icon"
/>
);
};
constructor(props) {
super(props);
const { orderBy } = props.shortUrlsListParams;
this.state = {
orderField: orderBy ? head(keys(orderBy)) : undefined,
orderDir: orderBy ? head(values(orderBy)) : undefined,
}; };
} const orderByColumn = (columnName) => () =>
handleOrderBy(columnName, determineOrderDir(columnName, order.orderField, order.orderDir));
const renderOrderIcon = (field) => {
if (order.orderField !== field) {
return null;
}
componentDidMount() { if (!order.orderDir) {
const { match: { params }, location, shortUrlsListParams } = this.props; return null;
const query = qs.parse(location.search, { ignoreQueryPrefix: true }); }
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
this.refreshList({ page: params.page, tags });
}
componentDidUpdate() {
const { mercureHubUrl, token, loading, error } = this.props.mercureInfo;
if (loading || error) {
return;
}
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', 'https://shlink.io/new-visit');
this.closeEventSource();
this.es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
this.es.onmessage = ({ data }) => this.props.createNewVisit(JSON.parse(data));
}
componentWillUnmount() {
const { resetShortUrlParams } = this.props;
this.closeEventSource();
resetShortUrlParams();
}
closeEventSource = () => {
if (this.es) {
this.es.close();
this.es = undefined;
}
}
renderShortUrls() {
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
if (error) {
return ( return (
<tr> <FontAwesomeIcon
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td> icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
</tr> className="short-urls-list__header-icon"
/>
); );
} };
const renderShortUrls = () => {
if (error) {
return (
<tr>
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
);
}
if (loading) { if (loading) {
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>; return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
} }
if (!loading && isEmpty(shortUrlsList)) { if (!loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan="6" className="text-center">No results found</td></tr>; return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
} }
return shortUrlsList.map((shortUrl) => ( return shortUrlsList.map((shortUrl) => (
<ShortUrlsRow <ShortUrlsRow
key={shortUrl.shortUrl} key={shortUrl.shortUrl}
shortUrl={shortUrl} shortUrl={shortUrl}
selectedServer={selectedServer} selectedServer={selectedServer}
refreshList={this.refreshList} refreshList={refreshList}
shortUrlsListParams={shortUrlsListParams} shortUrlsListParams={shortUrlsListParams}
/> />
)); ));
} };
useEffect(() => {
const { params } = match;
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
refreshList({ page: params.page, tags });
return resetShortUrlParams;
}, []);
useEffect(() => {
const { mercureHubUrl, token, loading, error } = mercureInfo;
if (loading || error) {
return undefined;
}
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', 'https://shlink.io/new-visit');
const es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
// es.onmessage = pipe(JSON.parse, createNewVisit);
es.onmessage = ({ data }) => createNewVisit(JSON.parse(data));
return () => es.close();
}, [ mercureInfo ]);
render() {
return ( return (
<React.Fragment> <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}
orderField={this.state.orderField} orderField={order.orderField}
orderDir={this.state.orderDir} orderDir={order.orderDir}
onChange={this.handleOrderBy} onChange={handleOrderBy}
/> />
</div> </div>
<table className="table table-striped table-hover"> <table className="table table-striped table-hover">
@ -169,42 +151,46 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
<tr> <tr>
<th <th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action" className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('dateCreated')} onClick={orderByColumn('dateCreated')}
> >
{this.renderOrderIcon('dateCreated')} {renderOrderIcon('dateCreated')}
Created at Created at
</th> </th>
<th <th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action" className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('shortCode')} onClick={orderByColumn('shortCode')}
> >
{this.renderOrderIcon('shortCode')} {renderOrderIcon('shortCode')}
Short URL Short URL
</th> </th>
<th <th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action" className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('longUrl')} onClick={orderByColumn('longUrl')}
> >
{this.renderOrderIcon('longUrl')} {renderOrderIcon('longUrl')}
Long URL Long URL
</th> </th>
<th className="short-urls-list__header-cell">Tags</th> <th className="short-urls-list__header-cell">Tags</th>
<th <th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action" className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={this.orderByColumn('visits')} onClick={orderByColumn('visits')}
> >
<span className="indivisible">{this.renderOrderIcon('visits')} Visits</span> <span className="indivisible">{renderOrderIcon('visits')} Visits</span>
</th> </th>
<th className="short-urls-list__header-cell">&nbsp;</th> <th className="short-urls-list__header-cell">&nbsp;</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{this.renderShortUrls()} {renderShortUrls()}
</tbody> </tbody>
</table> </table>
</React.Fragment> </React.Fragment>
); );
} };
ShortUrlsListComp.propTypes = propTypes;
return ShortUrlsListComp;
}; };
export default ShortUrlsList; export default ShortUrlsList;

View file

@ -71,10 +71,6 @@ describe('<ShortUrlsList />', () => {
}); });
it('should render 6 table header cells with conditional order by icon', () => { it('should render 6 table header cells with conditional order by icon', () => {
const orderDirOptionToIconMap = {
ASC: caretUpIcon,
DESC: caretDownIcon,
};
const getThElementForSortableField = (sortableField) => wrapper.find('table') const getThElementForSortableField = (sortableField) => wrapper.find('table')
.find('thead') .find('thead')
.find('tr') .find('tr')
@ -82,24 +78,18 @@ describe('<ShortUrlsList />', () => {
.filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField])); .filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField]));
Object.keys(SORTABLE_FIELDS).forEach((sortableField) => { Object.keys(SORTABLE_FIELDS).forEach((sortableField) => {
const sortableThElementWrapper = getThElementForSortableField(sortableField); expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0);
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0); getThElementForSortableField(sortableField).simulate('click');
sortableThElementWrapper.simulate('click');
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1); expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual( expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretUpIcon);
orderDirOptionToIconMap.ASC,
);
sortableThElementWrapper.simulate('click'); getThElementForSortableField(sortableField).simulate('click');
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1); expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual( expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretDownIcon);
orderDirOptionToIconMap.DESC,
);
sortableThElementWrapper.simulate('click'); getThElementForSortableField(sortableField).simulate('click');
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0); expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0);
}); });
}); });
}); });