mirror of https://github.com/linkerd/linkerd2.git
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:
parent
8c79ff3601
commit
3f6514431c
|
@ -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'],
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue