Merge remote-tracking branch 'origin/master' into react-context-api

This commit is contained in:
kialam
2018-12-18 10:52:09 -05:00
9 changed files with 284 additions and 212 deletions

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { mount } from 'enzyme';
import { Nav } from '@patternfly/react-core';
import NavExpandableGroup from '../../src/components/NavExpandableGroup';
describe('NavExpandableGroup', () => {
test('initialization and render', () => {
const component = mount(
<MemoryRouter initialEntries={['/foo']}>
<Nav aria-label="Test Navigation">
<NavExpandableGroup
groupId="test"
title="Test"
routes={[
{ path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' },
{ path: '/fiz', title: 'Fiz' },
]}
/>
</Nav>
</MemoryRouter>
).find('NavExpandableGroup').instance();
expect(component.navItemPaths).toEqual(['/foo', '/bar', '/fiz']);
expect(component.isActiveGroup()).toEqual(true);
});
describe('isActivePath', () => {
const params = [
['/fo', '/foo', false],
['/foo', '/foo', true],
['/foo/1/bar/fiz', '/foo', true],
['/foo/1/bar/fiz', 'foo', false],
['/foo/1/bar/fiz', 'foo/', false],
['/foo/1/bar/fiz', '/bar', false],
['/foo/1/bar/fiz', '/fiz', false],
];
params.forEach(([location, path, expected]) => {
test(`when location is ${location}', isActivePath('${path}') returns ${expected} `, () => {
const component = mount(
<MemoryRouter initialEntries={[location]}>
<Nav aria-label="Test Navigation">
<NavExpandableGroup
groupId="test"
title="Test"
routes={[
{ path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' },
{ path: '/fiz', title: 'Fiz' },
]}
/>
</Nav>
</MemoryRouter>
).find('NavExpandableGroup').instance();
expect(component.isActivePath(path)).toEqual(expected);
});
});
});
});

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom';
let OrganizationAdd; let OrganizationAdd;
const getAppWithConfigContext = (context = { const getAppWithConfigContext = (context = {
@@ -26,19 +27,23 @@ beforeEach(() => {
describe('<OrganizationAdd />', () => { describe('<OrganizationAdd />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mount( mount(
<OrganizationAdd <MemoryRouter>
match={{ path: '/organizations/add', url: '/organizations/add' }} <OrganizationAdd
location={{ search: '', pathname: '/organizations/add' }} match={{ path: '/organizations/add', url: '/organizations/add' }}
/> location={{ search: '', pathname: '/organizations/add' }}
/>
</MemoryRouter>
); );
}); });
test('calls "handleChange" when input values change', () => { test('calls "handleChange" when input values change', () => {
const spy = jest.spyOn(OrganizationAdd.prototype, 'handleChange'); const spy = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'handleChange');
const wrapper = mount( const wrapper = mount(
<OrganizationAdd <MemoryRouter>
match={{ path: '/organizations/add', url: '/organizations/add' }} <OrganizationAdd
location={{ search: '', pathname: '/organizations/add' }} match={{ path: '/organizations/add', url: '/organizations/add' }}
/> location={{ search: '', pathname: '/organizations/add' }}
/>
</MemoryRouter>
); );
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' } });
@@ -46,15 +51,31 @@ describe('<OrganizationAdd />', () => {
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
}); });
test('calls "onSubmit" when Save button is clicked', () => { test('calls "onSubmit" when Save button is clicked', () => {
const spy = jest.spyOn(OrganizationAdd.prototype, 'onSubmit'); const spy = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onSubmit');
const wrapper = mount( const wrapper = mount(
<OrganizationAdd <MemoryRouter>
match={{ path: '/organizations/add', url: '/organizations/add' }} <OrganizationAdd
location={{ search: '', pathname: '/organizations/add' }} match={{ path: '/organizations/add', url: '/organizations/add' }}
/> location={{ search: '', pathname: '/organizations/add' }}
/>
</MemoryRouter>
); );
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
wrapper.find('button.at-C-SubmitButton').prop('onClick')(); wrapper.find('button.at-C-SubmitButton').prop('onClick')();
expect(spy).toBeCalled(); expect(spy).toBeCalled();
}); });
test('calls "onCancel" when Cancel button is clicked', () => {
const spy = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onCancel');
const wrapper = mount(
<MemoryRouter>
<OrganizationAdd
match={{ path: '/organizations/add', url: '/organizations/add' }}
location={{ search: '', pathname: '/organizations/add' }}
/>
</MemoryRouter>
);
expect(spy).not.toHaveBeenCalled();
wrapper.find('button.at-C-CancelButton').prop('onClick')();
expect(spy).toBeCalled();
});
}); });

View File

@@ -13,9 +13,7 @@ import {
BackgroundImage, BackgroundImage,
BackgroundImageSrc, BackgroundImageSrc,
Nav, Nav,
NavExpandable,
NavList, NavList,
NavItem,
Page, Page,
PageHeader, PageHeader,
PageSidebar, PageSidebar,
@@ -32,6 +30,7 @@ import HelpDropdown from './components/HelpDropdown';
import LogoutButton from './components/LogoutButton'; import LogoutButton from './components/LogoutButton';
import TowerLogo from './components/TowerLogo'; import TowerLogo from './components/TowerLogo';
import ConditionalRedirect from './components/ConditionalRedirect'; import ConditionalRedirect from './components/ConditionalRedirect';
import NavExpandableGroup from './components/NavExpandableGroup';
import Applications from './pages/Applications'; import Applications from './pages/Applications';
import Credentials from './pages/Credentials'; import Credentials from './pages/Credentials';
@@ -69,41 +68,6 @@ const language = (navigator.languages && navigator.languages[0])
const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0];
const SideNavItems = ({ items, history }) => {
const currentPath = history.location.pathname.split('/')[1];
let activeGroup;
if (currentPath !== '') {
[{ groupName: activeGroup }] = items
.map(({ groupName, routes }) => ({
groupName,
paths: routes.map(({ path }) => path)
}))
.filter(({ paths }) => paths.indexOf(currentPath) > -1);
} else {
activeGroup = 'views';
}
return (items.map(({ title, groupName, routes }) => (
<NavExpandable
key={groupName}
title={title}
groupId={`${groupName}_group`}
isActive={`${activeGroup}_group` === `${groupName}_group`}
isExpanded={`${activeGroup}_group` === `${groupName}_group`}
>
{routes.map(({ path, title: itemTitle }) => (
<NavItem
key={path}
to={`#/${path}`}
groupId={`${groupName}_group`}
isActive={currentPath === path}
>
{itemTitle}
</NavItem>
))}
</NavExpandable>
)));
};
class App extends React.Component { class App extends React.Component {
constructor(props) { constructor(props) {
@@ -176,7 +140,12 @@ class App extends React.Component {
}} }}
/> />
<Switch> <Switch>
<ConditionalRedirect shouldRedirect={() => api.isAuthenticated()} redirectPath="/" path="/login" component={() => <Login logo={logo} loginInfo={loginInfo} />} /> <ConditionalRedirect
shouldRedirect={() => api.isAuthenticated()}
redirectPath="/"
path="/login"
component={() => <Login logo={logo} loginInfo={loginInfo} />}
/>
<Fragment> <Fragment>
<Page <Page
header={( header={(
@@ -195,127 +164,56 @@ class App extends React.Component {
{({ i18n }) => ( {({ i18n }) => (
<Nav aria-label={i18n._(t`Primary Navigation`)}> <Nav aria-label={i18n._(t`Primary Navigation`)}>
<NavList> <NavList>
<SideNavItems <NavExpandableGroup
history={history} groupId="views_group"
items={[ title={i18n._("Views")}
{ routes={[
groupName: 'views', { path: '/home', title: i18n._('Dashboard') },
title: i18n._('Views'), { path: '/jobs', title: i18n._('Jobs') },
routes: [ { path: '/schedules', title: i18n._('Schedules') },
{ { path: '/portal', title: i18n._('Portal Mode') },
path: 'home', ]}
title: i18n._('Dashboard') />
}, <NavExpandableGroup
{ groupId="resources_group"
path: 'jobs', title={i18n._("Resources")}
title: i18n._('Jobs') routes={[
}, { path: '/templates', title: i18n._('Templates') },
{ { path: '/credentials', title: i18n._('Credentials') },
path: 'schedules', { path: '/projects', title: i18n._('Projects') },
title: i18n._('Schedules') { path: '/inventories', title: i18n._('Inventories') },
}, { path: '/inventory_scripts', title: i18n._('Inventory Scripts') }
{ ]}
path: 'portal', />
title: i18n._('Portal Mode') <NavExpandableGroup
}, groupId="access_group"
] title={i18n._("Access")}
}, routes={[
{ { path: '/organizations', title: i18n._('Organizations') },
groupName: 'resources', { path: '/users', title: i18n._('Users') },
title: i18n._('Resources'), { path: '/teams', title: i18n._('Teams') }
routes: [ ]}
{ />
path: 'templates', <NavExpandableGroup
title: i18n._('Templates') groupId="administration_group"
}, title={i18n._("Administration")}
{ routes={[
path: 'credentials', { path: '/credential_types', title: i18n._('Credential Types') },
title: i18n._('Credentials') { path: '/notification_templates', title: i18n._('Notifications') },
}, { path: '/management_jobs', title: i18n._('Management Jobs') },
{ { path: '/instance_groups', title: i18n._('Instance Groups') },
path: 'projects', { path: '/applications', title: i18n._('Integrations') }
title: i18n._('Projects') ]}
}, />
{ <NavExpandableGroup
path: 'inventories', groupId="settings_group"
title: i18n._('Inventories') title={i18n._("Settings")}
}, routes={[
{ { path: '/auth_settings', title: i18n._('Authentication') },
path: 'inventory_scripts', { path: '/jobs_settings', title: i18n._('Jobs') },
title: i18n._('Inventory Scripts') { path: '/system_settings', title: i18n._('System') },
} { path: '/ui_settings', title: i18n._('User Interface') },
] { path: '/license', title: i18n._('License') }
},
{
groupName: 'access',
title: i18n._('Access'),
routes: [
{
path: 'organizations',
title: i18n._('Organizations')
},
{
path: 'users',
title: i18n._('Users')
},
{
path: 'teams',
title: i18n._('Teams')
}
]
},
{
groupName: 'administration',
title: i18n._('Administration'),
routes: [
{
path: 'credential_types',
title: i18n._('Credential Types'),
},
{
path: 'notification_templates',
title: i18n._('Notifications')
},
{
path: 'management_jobs',
title: i18n._('Management Jobs')
},
{
path: 'instance_groups',
title: i18n._('Instance Groups')
},
{
path: 'applications',
title: i18n._('Integrations')
}
]
},
{
groupName: 'settings',
title: i18n._('Settings'),
routes: [
{
path: 'auth_settings',
title: i18n._('Authentication'),
},
{
path: 'jobs_settings',
title: i18n._('Jobs')
},
{
path: 'system_settings',
title: i18n._('System')
},
{
path: 'ui_settings',
title: i18n._('User Interface')
},
{
path: 'license',
title: i18n._('License')
}
]
}
]} ]}
/> />
</NavList> </NavList>

View File

@@ -118,3 +118,13 @@
--pf-c-about-modal-box--MaxHeight: 40rem; --pf-c-about-modal-box--MaxHeight: 40rem;
--pf-c-about-modal-box--MaxWidth: 63rem; --pf-c-about-modal-box--MaxWidth: 63rem;
} }
//
// layout styles
//
.at-align-right {
display: flex;
flex-direction: row;
justify-content: flex-end;
}

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { I18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import { Trans, t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -23,6 +23,7 @@ import {
SortNumericDownIcon, SortNumericDownIcon,
SortNumericUpIcon, SortNumericUpIcon,
TrashAltIcon, TrashAltIcon,
PlusIcon
} from '@patternfly/react-icons'; } from '@patternfly/react-icons';
import { import {
Link Link
@@ -85,7 +86,8 @@ class DataListToolbar extends React.Component {
onSort, onSort,
sortedColumnKey, sortedColumnKey,
sortOrder, sortOrder,
addUrl addUrl,
showExpandCollapse
} = this.props; } = this.props;
const { const {
// isActionDropdownOpen, // isActionDropdownOpen,
@@ -113,6 +115,22 @@ class DataListToolbar extends React.Component {
return icon; return icon;
}; };
const searchDropdownItems = columns
.filter(({ key }) => key !== searchKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{ name }
</DropdownItem>
));
const sortDropdownItems = columns
.filter(({ key, isSortable }) => isSortable && key !== sortedColumnKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{ name }
</DropdownItem>
));
return ( return (
<I18n> <I18n>
{({ i18n }) => ( {({ i18n }) => (
@@ -145,13 +163,8 @@ class DataListToolbar extends React.Component {
{ searchColumnName } { searchColumnName }
</DropdownToggle> </DropdownToggle>
)} )}
> dropdownItems={searchDropdownItems}
{columns.filter(({ key }) => key !== searchKey).map(({ key, name }) => ( />
<DropdownItem key={key} component="button">
{ name }
</DropdownItem>
))}
</Dropdown>
<TextInput <TextInput
type="search" type="search"
aria-label={i18n._(t`Search text input`)} aria-label={i18n._(t`Search text input`)}
@@ -182,15 +195,8 @@ class DataListToolbar extends React.Component {
{ sortedColumnName } { sortedColumnName }
</DropdownToggle> </DropdownToggle>
)} )}
> dropdownItems={sortDropdownItems}
{columns />
.filter(({ key, isSortable }) => isSortable && key !== sortedColumnKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{ name }
</DropdownItem>
))}
</Dropdown>
</ToolbarItem> </ToolbarItem>
<ToolbarItem> <ToolbarItem>
<Button <Button
@@ -202,18 +208,20 @@ class DataListToolbar extends React.Component {
</Button> </Button>
</ToolbarItem> </ToolbarItem>
</ToolbarGroup> </ToolbarGroup>
<ToolbarGroup> { showExpandCollapse && (
<ToolbarItem> <ToolbarGroup>
<Button variant="plain" aria-label={i18n._(t`Expand`)}> <ToolbarItem>
<BarsIcon /> <Button variant="plain" aria-label={i18n._(t`Expand`)}>
</Button> <BarsIcon />
</ToolbarItem> </Button>
<ToolbarItem> </ToolbarItem>
<Button variant="plain" aria-label={i18n._(t`Collapse`)}> <ToolbarItem>
<EqualsIcon /> <Button variant="plain" aria-label={i18n._(t`Collapse`)}>
</Button> <EqualsIcon />
</ToolbarItem> </Button>
</ToolbarGroup> </ToolbarItem>
</ToolbarGroup>
)}
</Toolbar> </Toolbar>
</LevelItem> </LevelItem>
<LevelItem> <LevelItem>
@@ -225,7 +233,7 @@ class DataListToolbar extends React.Component {
{addUrl && ( {addUrl && (
<Link to={addUrl}> <Link to={addUrl}>
<Button variant="primary" aria-label={i18n._(t`Add`)}> <Button variant="primary" aria-label={i18n._(t`Add`)}>
<Trans>Add</Trans> <PlusIcon />
</Button> </Button>
</Link> </Link>
)} )}

View File

@@ -33,7 +33,7 @@
margin-right: 20px; margin-right: 20px;
} }
.awx-toolbar button { .awx-toolbar button.pf-c-button {
height: 30px; height: 30px;
padding: 0px; padding: 0px;
} }
@@ -43,7 +43,7 @@
height: 30px; height: 30px;
input { input {
padding: 0px; padding: 0 10px;
width: 300px; width: 300px;
} }
@@ -57,7 +57,7 @@
min-height: 30px; min-height: 30px;
min-width: 70px; min-width: 70px;
height: 30px; height: 30px;
padding: 0px; padding: 0 10px;
margin: 0px; margin: 0px;
.pf-c-dropdown__toggle-icon { .pf-c-dropdown__toggle-icon {
@@ -74,10 +74,9 @@
.awx-toolbar .pf-c-button.pf-m-primary { .awx-toolbar .pf-c-button.pf-m-primary {
background-color: #5cb85c; background-color: #5cb85c;
min-width: 0px; min-width: 0px;
width: 58px; width: 30px;
height: 30px; height: 30px;
text-align: center; text-align: center;
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
margin-right: 20px; margin-right: 20px;

View File

@@ -0,0 +1,53 @@
import React, { Component } from 'react';
import {
withRouter
} from 'react-router-dom';
import {
NavExpandable,
NavItem,
} from '@patternfly/react-core';
class NavExpandableGroup extends Component {
constructor (props) {
super(props);
const { routes } = this.props;
// Extract a list of paths from the route params and store them for later. This creates
// an array of url paths associated with any NavItem component rendered by this component.
this.navItemPaths = routes.map(({ path }) => path);
}
isActiveGroup = () => this.navItemPaths.some(this.isActivePath);
isActivePath = (path) => {
const { history } = this.props;
return history.location.pathname.startsWith(path);
};
render () {
const { routes, groupId, staticContext, ...rest } = this.props;
const isActive = this.isActiveGroup();
return (
<NavExpandable
isActive={isActive}
isExpanded={isActive}
groupId={groupId}
{...rest}
>
{routes.map(({ path, title }) => (
<NavItem
groupId={groupId}
isActive={this.isActivePath(path)}
key={path}
to={`/#${path}`}
>
{title}
</NavItem>
))}
</NavExpandable>
);
}
}
export default withRouter(NavExpandableGroup);

View File

@@ -41,7 +41,7 @@ export default ({
state: { breadcrumb: [parentBreadcrumb, { name, url: detailUrl }] } state: { breadcrumb: [parentBreadcrumb, { name, url: detailUrl }] }
}} }}
> >
{name} <b>{name}</b>
</Link> </Link>
</span> </span>
</div> </div>

View File

@@ -1,5 +1,6 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { import {
PageSection, PageSection,
@@ -31,6 +32,7 @@ class OrganizationAdd extends React.Component {
this.onSelectChange = this.onSelectChange.bind(this); this.onSelectChange = this.onSelectChange.bind(this);
this.onSubmit = this.onSubmit.bind(this); this.onSubmit = this.onSubmit.bind(this);
this.resetForm = this.resetForm.bind(this); this.resetForm = this.resetForm.bind(this);
this.onCancel = this.onCancel.bind(this);
} }
state = { state = {
@@ -38,6 +40,7 @@ class OrganizationAdd extends React.Component {
description: '', description: '',
instanceGroups: '', instanceGroups: '',
custom_virtualenv: '', custom_virtualenv: '',
error:'',
}; };
onSelectChange(value, _) { onSelectChange(value, _) {
@@ -62,6 +65,23 @@ class OrganizationAdd extends React.Component {
this.resetForm(); this.resetForm();
} }
onCancel() {
this.props.history.push('/organizations');
}
async componentDidMount() {
try {
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 });
}
} catch (error) {
this.setState({ error })
}
}
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
@@ -126,7 +146,7 @@ class OrganizationAdd extends React.Component {
<Button className="at-C-SubmitButton" variant="primary" onClick={this.onSubmit} isDisabled={!enabled}>Save</Button> <Button className="at-C-SubmitButton" variant="primary" onClick={this.onSubmit} isDisabled={!enabled}>Save</Button>
</ToolbarGroup> </ToolbarGroup>
<ToolbarGroup> <ToolbarGroup>
<Button variant="secondary">Cancel</Button> <Button className="at-C-CancelButton" variant="secondary" onClick={this.onCancel}>Cancel</Button>
</ToolbarGroup> </ToolbarGroup>
</Toolbar> </Toolbar>
</ActionGroup> </ActionGroup>
@@ -143,4 +163,4 @@ OrganizationAdd.contextTypes = {
custom_virtualenvs: PropTypes.array, custom_virtualenvs: PropTypes.array,
}; };
export default OrganizationAdd; export default withRouter(OrganizationAdd);