Add a HOC for the REST API tooling (#989)

* Add a HOC for the REST API tooling

We're copying and duplicating logic all over the place with components that need to talk to the API.

Moves most of the REST API tooling into a HOC that can be used by other components. Now, a component can use `withREST`, pass in the promises that it would like resolved and receive the responses as props.

* Show PageHeader whether there's an error or not

* Hiding page header during loading

* Test updates to work with namespace restructuring
This commit is contained in:
Thomas Rampelberg 2018-05-29 09:56:49 -07:00 committed by GitHub
parent 8c79ff3601
commit 3f6514431c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 225 additions and 88 deletions

View File

@ -5,107 +5,62 @@ import MetricsTable from './MetricsTable.jsx';
import PageHeader from './PageHeader.jsx';
import { processSingleResourceRollup } from './util/MetricUtils.js';
import React from 'react';
import { withContext } from './util/AppContext.jsx';
import withREST from './util/withREST.jsx';
import './../../css/list.css';
import 'whatwg-fetch';
class ResourceList extends React.Component {
constructor(props) {
super(props);
this.api = this.props.api;
this.handleApiError = this.handleApiError.bind(this);
this.loadFromServer = this.loadFromServer.bind(this);
export class ResourceList extends React.Component {
banner = () => {
const {error} = this.props;
this.state = this.getInitialState(this.props);
}
getInitialState() {
return {
pollingInterval: 2000, // TODO: poll based on metricsWindow size
metrics: [],
pendingRequests: false,
loaded: false,
error: ''
};
}
componentDidMount() {
this.startServerPolling(this.props.resource);
}
componentWillReceiveProps(newProps) {
// React won't unmount this component when switching resource pages so we need to clear state
this.stopServerPolling();
this.setState(this.getInitialState(newProps));
this.startServerPolling(newProps.resource);
}
componentWillUnmount() {
this.stopServerPolling();
}
startServerPolling(resource) {
this.loadFromServer(resource);
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval, resource);
}
stopServerPolling() {
window.clearInterval(this.timerId);
this.api.cancelCurrentRequests();
}
loadFromServer(resource) {
if (this.state.pendingRequests) {
return; // don't make more requests if the ones we sent haven't completed
}
this.setState({ pendingRequests: true });
this.api.setCurrentRequests([
this.api.fetchMetrics(this.api.urlsForResource(resource))
]);
Promise.all(this.api.getCurrentPromises())
.then(([rollup]) => {
let processedMetrics = processSingleResourceRollup(rollup);
this.setState({
metrics: processedMetrics,
loaded: true,
pendingRequests: false,
error: ''
});
})
.catch(this.handleApiError);
}
handleApiError(e) {
if (e.isCanceled) {
if (!error) {
return;
}
this.setState({
pendingRequests: false,
error: `Error getting data from server: ${e.message}`
});
return <ErrorBanner message={error} />;
}
content = () => {
const {data, loading} = this.props;
if (loading) {
return <ConduitSpinner />;
}
let processedMetrics = [];
if (_.has(data, '[0].ok')) {
processedMetrics = processSingleResourceRollup(data[0]);
}
const friendlyTitle = _.startCase(this.props.resource);
return (
<MetricsTable
resource={friendlyTitle}
metrics={processedMetrics}
linkifyNsColumn={true} />
);
}
render() {
let friendlyTitle = _.startCase(this.props.resource);
const {api, loading, resource} = this.props;
const friendlyTitle = _.startCase(resource);
return (
<div className="page-content">
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <ConduitSpinner /> :
<div>
<PageHeader header={friendlyTitle + "s"} />
<MetricsTable
resource={friendlyTitle}
metrics={this.state.metrics}
linkifyNsColumn={true} />
</div>
}
</div>);
<div>
{this.banner()}
{loading ? null : <PageHeader header={`${friendlyTitle}s`} api={api} />}
{this.content()}
</div>
</div>
);
}
}
export default withContext(ResourceList);
export default withREST(
ResourceList,
({api, resource}) => [api.fetchMetrics(api.urlsForResource(resource))],
['resource'],
);

View File

@ -0,0 +1,101 @@
import _ from 'lodash';
import React from 'react';
/**
* Provides components with data fetched via. polling a list of REST URLs.
* @constructor
* @param {React.Component} WrappedComponent - Component to add functionality to.
* @param {List[string]} requestURLs - List of URLs to poll.
* @param {List[string]} resetProps - Props that on change cause a state reset.
*/
const withREST = (WrappedComponent, componentPromises, resetProps = []) => {
return class extends React.Component {
constructor(props) {
super(props);
this.api = this.props.api;
this.state = this.getInitialState(this.props);
}
getInitialState = () => ({
pollingInterval: 2000, // TODO: poll based on metricsWindow size
data: [],
pendingRequests: false,
loading: true,
error: ''
});
componentDidMount() {
this.startServerPolling(this.props);
}
componentWillReceiveProps(newProps) {
const changed = _.filter(
resetProps,
prop => _.get(newProps, prop) !== _.get(this.props, prop),
);
if (_.isEmpty(changed)) return;
// React won't unmount this component when switching resource pages so we need to clear state
this.stopServerPolling();
this.setState(this.getInitialState());
this.startServerPolling(newProps);
}
componentWillUnmount() {
this.stopServerPolling();
}
startServerPolling = props => {
this.loadFromServer(props);
this.timerId = window.setInterval(
this.loadFromServer, this.state.pollingInterval, props);
}
stopServerPolling = () => {
window.clearInterval(this.timerId);
this.api.cancelCurrentRequests();
}
loadFromServer = props => {
if (this.state.pendingRequests) {
return; // don't make more requests if the ones we sent haven't completed
}
this.setState({ pendingRequests: true });
this.api.setCurrentRequests(componentPromises(props));
Promise.all(this.api.getCurrentPromises())
.then(data => {
this.setState({
data: data,
loading: false,
pendingRequests: false,
error: '',
});
})
.catch(this.handleApiError);
}
handleApiError = e => {
if (e.isCanceled) return;
this.setState({
pendingRequests: false,
error: `Error getting data from server: ${e.message}`
});
}
render() {
return (
<WrappedComponent
{..._.pick(this.state, ['data', 'error', 'loading'])}
{...this.props} />
);
}
};
};
export default withREST;

View File

@ -0,0 +1,81 @@
import _ from 'lodash';
import Adapter from 'enzyme-adapter-react-16';
import ConduitSpinner from '../js/components/ConduitSpinner.jsx';
import deployRollup from './fixtures/deployRollup.json';
import Enzyme from 'enzyme';
import ErrorBanner from '../js/components/ErrorBanner.jsx';
import { expect } from 'chai';
import MetricsTable from '../js/components/MetricsTable.jsx';
import PageHeader from '../js/components/PageHeader.jsx';
import React from 'react';
import { ResourceList } from '../js/components/ResourceList.jsx';
import { shallow } from 'enzyme';
Enzyme.configure({ adapter: new Adapter() });
describe('Tests for <ResourceList>', () => {
it('displays an error if the api call fails', () => {
const msg = 'foobar';
const component = shallow(
<ResourceList
data={[]}
error={msg}
loading={false} />
);
const err = component.find(ErrorBanner);
expect(err).to.have.length(1);
expect(component.find(PageHeader)).to.have.length(1);
expect(component.find(ConduitSpinner)).to.have.length(0);
expect(component.find(MetricsTable)).to.have.length(1);
expect(err.props().message).to.equal(msg);
});
it('shows a loading spinner', () => {
const component = shallow(
<ResourceList
data={[]}
loading={true} />
);
expect(component.find(ErrorBanner)).to.have.length(0);
expect(component.find(PageHeader)).to.have.length(0);
expect(component.find(ConduitSpinner)).to.have.length(1);
expect(component.find(MetricsTable)).to.have.length(0);
});
it('handles empty content', () => {
const component = shallow(
<ResourceList
data={[]}
loading={false} />
);
expect(component.find(ErrorBanner)).to.have.length(0);
expect(component.find(PageHeader)).to.have.length(1);
expect(component.find(ConduitSpinner)).to.have.length(0);
expect(component.find(MetricsTable)).to.have.length(1);
});
it('renders a metrics table', () => {
const resource = 'deployment';
const component = shallow(
<ResourceList
data={[deployRollup]}
loading={false}
resource={resource} />
);
const metrics = component.find(MetricsTable);
expect(component.find(ErrorBanner)).to.have.length(0);
expect(component.find(PageHeader)).to.have.length(1);
expect(component.find(ConduitSpinner)).to.have.length(0);
expect(metrics).to.have.length(1);
expect(metrics.props().resource).to.equal(_.startCase(resource));
expect(metrics.props().metrics).to.have.length(1);
});
});