Implement React Context API

- Move API GET request to /v2/config out to the top level of our App.
- Store /v2/config response data in sessionStorage.
- Use Context API to pass down relevant data to Organizations component.
- Wrap our AnsibleSelect component as a context consumer and pass in the list of Ansible Environments of the logged in user.
- Clear sessionStorage object when user logs out.
- Update unit tests.
This commit is contained in:
kialam
2018-12-17 11:44:11 -05:00
parent 7ea5ea2ecd
commit b8fc402d55
8 changed files with 115 additions and 41 deletions

View File

@@ -1,5 +1,13 @@
const axios = require('axios'); import * as endpoints from '../src/endpoints';
const axios = require('axios');
const mockAPIConfigData = {
data: {
custom_virtualenvs: ['foo', 'bar'],
ansible_version: "2.7.2",
version: "2.1.1-40-g2758a3848"
}
};
jest.genMockFromModule('axios'); jest.genMockFromModule('axios');
axios.create = jest.fn(() => axios); axios.create = jest.fn(() => axios);
@@ -9,7 +17,16 @@ axios.create.mockReturnValue({
get: axios.get, get: axios.get,
post: axios.post post: axios.post
}); });
axios.get.mockResolvedValue('get results'); axios.get.mockImplementation((endpoint) => {
if (endpoint === endpoints.API_CONFIG) {
return new Promise((resolve, reject) => {
resolve(mockAPIConfigData);
});
}
else {
return 'get results';
}
});
axios.post.mockResolvedValue('post results'); axios.post.mockResolvedValue('post results');
axios.customClearMocks = () => { axios.customClearMocks = () => {

View File

@@ -2,16 +2,29 @@ import React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import AnsibleSelect from '../../src/components/AnsibleSelect'; import AnsibleSelect from '../../src/components/AnsibleSelect';
const mockData = ['foo', 'bar']; const label = "test select"
const mockData = ["/venv/baz/", "/venv/ansible/"];
describe('<AnsibleSelect />', () => { describe('<AnsibleSelect />', () => {
test('initially renders succesfully', async() => { test('initially renders succesfully', async () => {
const wrapper = mount(<AnsibleSelect selected="foo" data={mockData} selectChange={() => {}} />); mount(
wrapper.setState({ isHidden: false }); <AnsibleSelect
selected="foo"
selectChange={() => { }}
labelName={label}
data={mockData}
/>
);
}); });
test('calls "onSelectChange" on dropdown select change', () => { test('calls "onSelectChange" on dropdown select change', () => {
const spy = jest.spyOn(AnsibleSelect.prototype, 'onSelectChange'); const spy = jest.spyOn(AnsibleSelect.prototype, 'onSelectChange');
const wrapper = mount(<AnsibleSelect selected="foo" data={mockData} selectChange={() => {}} />); const wrapper = mount(
wrapper.setState({ isHidden: false }); <AnsibleSelect
selected="foo"
selectChange={() => { }}
labelName={label}
data={mockData}
/>
);
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
wrapper.find('select').simulate('change'); wrapper.find('select').simulate('change');
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();

View File

@@ -1,6 +1,27 @@
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import OrganizationAdd from '../../../../src/pages/Organizations/views/Organization.add';
let OrganizationAdd;
const getAppWithConfigContext = (context = {
custom_virtualenvs: ['foo', 'bar']
}) => {
// Mock the ConfigContext module being used in our OrganizationAdd component
jest.doMock('../../../../src/context', () => {
return {
ConfigContext: {
Consumer: (props) => props.children(context)
}
}
});
// Return the updated OrganizationAdd module with mocked context
return require('../../../../src/pages/Organizations/views/Organization.add').default;
};
beforeEach(() => {
OrganizationAdd = getAppWithConfigContext();
})
describe('<OrganizationAdd />', () => { describe('<OrganizationAdd />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
@@ -20,8 +41,8 @@ describe('<OrganizationAdd />', () => {
/> />
); );
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
wrapper.find('input#add-org-form-name').simulate('change', {target: {value: 'foo'}}); wrapper.find('input#add-org-form-name').simulate('change', { target: { value: 'foo' } });
wrapper.find('input#add-org-form-description').simulate('change', {target: {value: 'bar'}}); wrapper.find('input#add-org-form-description').simulate('change', { target: { value: 'bar' } });
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
}); });
test('calls "onSubmit" when Save button is clicked', () => { test('calls "onSubmit" when Save button is clicked', () => {

View File

@@ -46,6 +46,7 @@
"@patternfly/react-styles": "^2.3.0", "@patternfly/react-styles": "^2.3.0",
"@patternfly/react-tokens": "^1.9.0", "@patternfly/react-tokens": "^1.9.0",
"axios": "^0.18.0", "axios": "^0.18.0",
"prop-types": "^15.6.2",
"react": "^16.4.1", "react": "^16.4.1",
"react-dom": "^16.4.1", "react-dom": "^16.4.1",
"react-router-dom": "^4.3.1" "react-router-dom": "^4.3.1"

View File

@@ -1,4 +1,6 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { ConfigContext } from './context';
import { import {
Redirect, Redirect,
Switch, Switch,
@@ -22,7 +24,7 @@ import {
import { global_breakpoint_md as breakpointMd } from '@patternfly/react-tokens'; import { global_breakpoint_md as breakpointMd } from '@patternfly/react-tokens';
import api from './api'; import api from './api';
import { API_LOGOUT } from './endpoints'; import { API_LOGOUT, API_CONFIG } from './endpoints';
import HelpDropdown from './components/HelpDropdown'; import HelpDropdown from './components/HelpDropdown';
import LogoutButton from './components/LogoutButton'; import LogoutButton from './components/LogoutButton';
@@ -90,15 +92,19 @@ const SideNavItems = ({ items, history }) => {
}; };
class App extends React.Component { class App extends React.Component {
constructor (props) { constructor(props) {
super(props); super(props);
const isNavOpen = typeof window !== 'undefined' && window.innerWidth >= parseInt(breakpointMd.value, 10); const isNavOpen = typeof window !== 'undefined' && window.innerWidth >= parseInt(breakpointMd.value, 10);
this.state = { this.state = {
isNavOpen isNavOpen,
}; };
} }
getSessionObject(key) {
return JSON.parse(sessionStorage.getItem(key) || '{}');
}
onNavToggle = () => { onNavToggle = () => {
this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen }));
}; };
@@ -110,9 +116,19 @@ class App extends React.Component {
onDevLogout = async () => { onDevLogout = async () => {
await api.get(API_LOGOUT); await api.get(API_LOGOUT);
this.setState({ activeGroup: 'views_group', activeItem: 'views_group_dashboard' }); this.setState({ activeGroup: 'views_group', activeItem: 'views_group_dashboard' });
if (sessionStorage.config) {
sessionStorage.clear();
}
} }
render () { async componentDidMount() {
// Grab our config data from the API and store in sessionStorage
if (!sessionStorage.config) {
const { data } = await api.get(API_CONFIG);
sessionStorage.setItem('config', JSON.stringify(data));
}
}
render() {
const { isNavOpen } = this.state; const { isNavOpen } = this.state;
const { logo, loginInfo, history } = this.props; const { logo, loginInfo, history } = this.props;
@@ -302,7 +318,9 @@ class App extends React.Component {
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/projects" component={Projects} /> <ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/projects" component={Projects} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/inventories" component={Inventories} /> <ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/inventories" component={Inventories} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/inventory_scripts" component={InventoryScripts} /> <ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/inventory_scripts" component={InventoryScripts} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/organizations" component={Organizations} /> <ConfigContext.Provider value={this.getSessionObject('config')}>
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/organizations" component={Organizations} />
</ConfigContext.Provider>
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/users" component={Users} /> <ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/users" component={Users} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/teams" component={Teams} /> <ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/teams" component={Teams} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/credential_types" component={CredentialTypes} /> <ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/credential_types" component={CredentialTypes} />

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { import {
FormGroup, FormGroup,
Select, Select,
@@ -16,19 +17,19 @@ class AnsibleSelect extends React.Component {
} }
render() { render() {
const { hidden } = this.props; if (this.props.data.length > 1) {
if (hidden) {
return null;
} else {
return ( return (
<FormGroup label={this.props.labelName} fieldId="ansible-select"> <FormGroup label={this.props.labelName} fieldId="ansible-select">
<Select value={this.props.selected} onChange={this.onSelectChange} aria-label="Select Input"> <Select value={this.props.selected} onChange={this.onSelectChange} aria-label="Select Input">
{this.props.data.map((env, index) => ( {this.props.data.map((datum, index) => (
<SelectOption isDisabled={env.disabled} key={index} value={env} label={env} /> <SelectOption isDisabled={datum.disabled} key={index} value={datum} label={datum} />
))} ))}
</Select> </Select>
</FormGroup> </FormGroup>
); )
}
else {
return null;
} }
} }
} }

3
src/context.jsx Normal file
View File

@@ -0,0 +1,3 @@
import React from "react";
export const ConfigContext = React.createContext({});

View File

@@ -1,4 +1,5 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { import {
PageSection, PageSection,
PageSectionVariants, PageSectionVariants,
@@ -15,7 +16,8 @@ import {
CardBody, CardBody,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { API_ORGANIZATIONS, API_CONFIG } from '../../../endpoints'; import { ConfigContext } from '../../../context';
import { API_ORGANIZATIONS } from '../../../endpoints';
import api from '../../../api'; import api from '../../../api';
import AnsibleSelect from '../../../components/AnsibleSelect' import AnsibleSelect from '../../../components/AnsibleSelect'
const { light } = PageSectionVariants; const { light } = PageSectionVariants;
@@ -35,8 +37,6 @@ class OrganizationAdd extends React.Component {
description: '', description: '',
instanceGroups: '', instanceGroups: '',
custom_virtualenv: '', custom_virtualenv: '',
custom_virtualenvs: [],
hideAnsibleSelect: true,
}; };
onSelectChange(value, _) { onSelectChange(value, _) {
@@ -61,17 +61,10 @@ class OrganizationAdd extends React.Component {
this.resetForm(); this.resetForm();
} }
async componentDidMount() {
const { data } = await api.get(API_CONFIG);
this.setState({ custom_virtualenvs: [...data.custom_virtualenvs] });
if (this.state.custom_virtualenvs.length > 1) {
// Show dropdown if we have more than one ansible environment
this.setState({ hideAnsibleSelect: !this.state.hideAnsibleSelect });
}
}
render() { render() {
const { name } = this.state; const { name } = this.state;
const enabled = name.length > 0; // TODO: add better form validation const enabled = name.length > 0; // TODO: add better form validation
return ( return (
<Fragment> <Fragment>
<PageSection variant={light} className="pf-m-condensed"> <PageSection variant={light} className="pf-m-condensed">
@@ -113,13 +106,16 @@ class OrganizationAdd extends React.Component {
onChange={this.handleChange} onChange={this.handleChange}
/> />
</FormGroup> </FormGroup>
<AnsibleSelect <ConfigContext.Consumer>
labelName="Ansible Environment" {({ custom_virtualenvs }) =>
selected={this.state.custom_virtualenv} <AnsibleSelect
selectChange={this.onSelectChange} labelName="Ansible Environment"
data={this.state.custom_virtualenvs} selected={this.state.custom_virtualenv}
hidden={this.state.hideAnsibleSelect} selectChange={this.onSelectChange}
/> data={custom_virtualenvs}
/>
}
</ConfigContext.Consumer>
</Gallery> </Gallery>
<ActionGroup className="at-align-right"> <ActionGroup className="at-align-right">
<Toolbar> <Toolbar>
@@ -140,4 +136,8 @@ class OrganizationAdd extends React.Component {
} }
} }
OrganizationAdd.contextTypes = {
custom_virtualenvs: PropTypes.array,
};
export default OrganizationAdd; export default OrganizationAdd;