Reorganize file locations/directory structure (#270)

Reorganize file locations
This commit is contained in:
Michael Abashian
2019-06-19 11:41:14 -04:00
committed by GitHub
parent e3cb8d0447
commit ee56e9ccfb
229 changed files with 478 additions and 317 deletions

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class Applications extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Applications`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(Applications);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Applications from './Applications';
describe('<Applications />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Applications />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './Applications';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class AuthSettings extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Authentication Settings`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(AuthSettings);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AuthSettings from './AuthSettings';
describe('<AuthSettings />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<AuthSettings />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './AuthSettings';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class Credentials extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Credentials`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(Credentials);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Credentials from './Credentials';
describe('<Credentials />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Credentials />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './Credentials';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class CredentialTypes extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Credential Types`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(CredentialTypes);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import CredentialTypes from './CredentialTypes';
describe('<CredentialTypes />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<CredentialTypes />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './CredentialTypes';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class Dashboard extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Dashboard`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(Dashboard);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Dashboard from './Dashboard';
describe('<Dashboard />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Dashboard />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './Dashboard';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class InstanceGroups extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Instance Groups`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(InstanceGroups);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InstanceGroups from './InstanceGroups';
describe('<InstanceGroups />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<InstanceGroups />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './InstanceGroups';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class Inventories extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Inventories`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(Inventories);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Inventories from './Inventories';
describe('<Inventories />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Inventories />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './Inventories';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class InventoryScripts extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Inventory Scripts`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(InventoryScripts);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InventoryScripts from './InventoryScripts';
describe('<InventoryScripts />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<InventoryScripts />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './InventoryScripts';

153
src/screens/Job/Job.jsx Normal file
View File

@@ -0,0 +1,153 @@
import React, { Component } from 'react';
import { Route, withRouter, Switch, Redirect } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Card, CardHeader as PFCardHeader, PageSection } from '@patternfly/react-core';
import { JobsAPI } from '../../api';
import ContentError from '../../components/ContentError';
import CardCloseButton from '../../components/CardCloseButton';
import RoutedTabs from '../../components/RoutedTabs';
import JobDetail from './JobDetail';
import JobOutput from './JobOutput';
export class Job extends Component {
constructor (props) {
super(props);
this.state = {
job: null,
contentError: false,
contentLoading: true,
isInitialized: false
};
this.fetchJob = this.fetchJob.bind(this);
}
async componentDidMount () {
await this.fetchJob();
this.setState({ isInitialized: true });
}
async componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
await this.fetchJob();
}
}
async fetchJob () {
const {
match,
setBreadcrumb,
} = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: false, contentLoading: true });
try {
const { data } = await JobsAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ job: data });
} catch (error) {
this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
}
}
render () {
const {
history,
match,
i18n
} = this.props;
const {
job,
contentError,
contentLoading,
isInitialized
} = this.state;
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Output`), link: `${match.url}/output`, id: 1 }
];
const CardHeader = styled(PFCardHeader)`
--pf-c-card--first-child--PaddingTop: 0;
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
position: relative;
`;
let cardHeader = (
<CardHeader>
<RoutedTabs
match={match}
history={history}
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/jobs" />
</CardHeader>
);
if (!isInitialized) {
cardHeader = null;
}
if (!match) {
cardHeader = null;
}
if (!contentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError />
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card>
{ cardHeader }
<Switch>
<Redirect
from="/jobs/:id"
to="/jobs/:id/details"
exact
/>
{job && (
<Route
path="/jobs/:id/details"
render={() => (
<JobDetail
match={match}
job={job}
/>
)}
/>
)}
{job && (
<Route
path="/jobs/:id/output"
render={() => (
<JobOutput
match={match}
job={job}
/>
)}
/>
)}
</Switch>
</Card>
</PageSection>
);
}
}
export default withI18n()(withRouter(Job));

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Job from './Jobs';
describe('<Job />', () => {
test('initially renders succesfully', () => {
mountWithContexts(<Job />);
});
});

View File

@@ -0,0 +1,38 @@
import React, { Component } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CardBody, Button } from '@patternfly/react-core';
import styled from 'styled-components';
const ActionButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
`;
class JobDetail extends Component {
render () {
const {
job,
i18n
} = this.props;
return (
<CardBody>
<b>{job.name}</b>
<ActionButtonWrapper>
<Button
variant="secondary"
aria-label="close"
component={Link}
to="/jobs"
>
{i18n._(t`Close`)}
</Button>
</ActionButtonWrapper>
</CardBody>
);
}
}
export default withI18n()(withRouter(JobDetail));

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import JobDetail from './JobDetail';
describe('<JobDetail />', () => {
const mockDetails = {
name: 'Foo'
};
test('initially renders succesfully', () => {
mountWithContexts(
<JobDetail job={mockDetails} />
);
});
test('should display a Close button', () => {
const wrapper = mountWithContexts(
<JobDetail job={mockDetails} />
);
expect(wrapper.find('Button[aria-label="close"]').length).toBe(1);
wrapper.unmount();
});
});

View File

@@ -0,0 +1 @@
export { default } from './JobDetail';

View File

@@ -0,0 +1,18 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class JobOutput extends Component {
render () {
const {
job
} = this.props;
return (
<CardBody>
<b>{job.name}</b>
</CardBody>
);
}
}
export default JobOutput;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import JobOutput from './JobOutput';
describe('<JobOutput />', () => {
const mockDetails = {
name: 'Foo'
};
test('initially renders succesfully', () => {
mountWithContexts(
<JobOutput job={mockDetails} />
);
});
});

View File

@@ -0,0 +1,2 @@
export { default } from './JobOutput';

64
src/screens/Job/Jobs.jsx Normal file
View File

@@ -0,0 +1,64 @@
import React, { Component, Fragment } from 'react';
import { Route, withRouter, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import { Job } from '.';
class Jobs extends Component {
constructor (props) {
super(props);
const { i18n } = props;
this.state = {
breadcrumbConfig: {
'/jobs': i18n._(t`Jobs`)
}
};
}
setBreadcrumbConfig = (job) => {
const { i18n } = this.props;
if (!job) {
return;
}
const breadcrumbConfig = {
'/jobs': i18n._(t`Jobs`),
[`/jobs/${job.id}`]: `${job.name}`,
[`/jobs/${job.id}/details`]: i18n._(t`Details`),
[`/jobs/${job.id}/output`]: i18n._(t`Output`)
};
this.setState({ breadcrumbConfig });
}
render () {
const { match, history, location } = this.props;
const { breadcrumbConfig } = this.state;
return (
<Fragment>
<Breadcrumbs
breadcrumbConfig={breadcrumbConfig}
/>
<Switch>
<Route
path={`${match.path}/:id`}
render={() => (
<Job
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
/>
)}
/>
</Switch>
</Fragment>
);
}
}
export default withI18n()(withRouter(Jobs));

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Jobs from './Jobs';
describe('<Jobs />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<Jobs />
);
});
test('should display a breadcrumb heading', () => {
const history = createMemoryHistory({
initialEntries: ['/jobs'],
});
const match = { path: '/jobs', url: '/jobs', isExact: true };
const wrapper = mountWithContexts(
<Jobs />,
{
context: {
router: {
history,
route: {
location: history.location,
match
}
}
}
}
);
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
wrapper.unmount();
});
});

2
src/screens/Job/index.js Normal file
View File

@@ -0,0 +1,2 @@
export { default as Job } from './Job';
export { default as Jobs } from './Jobs';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class JobsSettings extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Jobs Settings`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(JobsSettings);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import JobsSettings from './JobsSettings';
describe('<JobsSettings />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<JobsSettings />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './JobsSettings';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class License extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`License`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(License);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import License from './License';
describe('<License />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<License />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './License';

151
src/screens/Login/Login.jsx Normal file
View File

@@ -0,0 +1,151 @@
import React, { Component } from 'react';
import { Redirect, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
LoginForm,
LoginPage as PFLoginPage,
} from '@patternfly/react-core';
import { RootAPI } from '../../api';
import { BrandName } from '../../variables';
import brandLogo from '../../../images/brand-logo.svg';
const LoginPage = styled(PFLoginPage)`
& .pf-c-brand {
max-height: 285px;
}
`;
class AWXLogin extends Component {
constructor (props) {
super(props);
this.state = {
username: '',
password: '',
authenticationError: false,
validationError: false,
isAuthenticating: false,
isLoading: true,
logo: null,
loginInfo: null,
};
this.handleChangeUsername = this.handleChangeUsername.bind(this);
this.handleChangePassword = this.handleChangePassword.bind(this);
this.handleLoginButtonClick = this.handleLoginButtonClick.bind(this);
this.loadCustomLoginInfo = this.loadCustomLoginInfo.bind(this);
}
async componentDidMount () {
await this.loadCustomLoginInfo();
}
async loadCustomLoginInfo () {
this.setState({ isLoading: true });
try {
const { data: { custom_logo, custom_login_info } } = await RootAPI.read();
const logo = custom_logo ? `data:image/jpeg;${custom_logo}` : brandLogo;
this.setState({ logo, loginInfo: custom_login_info });
} catch (err) {
this.setState({ logo: brandLogo });
} finally {
this.setState({ isLoading: false });
}
}
async handleLoginButtonClick (event) {
const { username, password, isAuthenticating } = this.state;
event.preventDefault();
if (isAuthenticating) {
return;
}
this.setState({ authenticationError: false, isAuthenticating: true });
try {
// note: if authentication is successful, the appropriate cookie will be set automatically
// and isAuthenticated() (the source of truth) will start returning true.
await RootAPI.login(username, password);
} catch (err) {
if (err && err.response && err.response.status === 401) {
this.setState({ validationError: true });
} else {
this.setState({ authenticationError: true });
}
} finally {
this.setState({ isAuthenticating: false });
}
}
handleChangeUsername (value) {
this.setState({ username: value, validationError: false });
}
handleChangePassword (value) {
this.setState({ password: value, validationError: false });
}
render () {
const {
authenticationError,
validationError,
username,
password,
isLoading,
logo,
loginInfo,
} = this.state;
const { alt, i18n, isAuthenticated } = this.props;
// Setting BrandName to a variable here is necessary to get the jest tests
// passing. Attempting to use BrandName in the template literal results
// in failing tests.
const brandName = BrandName;
if (isLoading) {
return null;
}
if (isAuthenticated(document.cookie)) {
return (<Redirect to="/" />);
}
let helperText;
if (validationError) {
helperText = i18n._(t`Invalid username or password. Please try again.`);
} else {
helperText = i18n._(t`There was a problem signing in. Please try again.`);
}
return (
<LoginPage
brandImgSrc={logo}
brandImgAlt={alt || brandName}
loginTitle={i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)}
textContent={loginInfo}
>
<LoginForm
className={(authenticationError || validationError) ? 'pf-m-error' : ''}
usernameLabel={i18n._(t`Username`)}
passwordLabel={i18n._(t`Password`)}
showHelperText={(authenticationError || validationError)}
helperText={helperText}
usernameValue={username}
passwordValue={password}
isValidUsername={!validationError}
isValidPassword={!validationError}
onChangeUsername={this.handleChangeUsername}
onChangePassword={this.handleChangePassword}
onLoginButtonClick={this.handleLoginButtonClick}
/>
</LoginPage>
);
}
}
export { AWXLogin as _AWXLogin };
export default withI18n()(withRouter(AWXLogin));

View File

@@ -0,0 +1,215 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../../../testUtils/enzymeHelpers';
import AWXLogin from './Login';
import { RootAPI } from '../../api';
jest.mock('../../api');
describe('<Login />', () => {
async function findChildren (wrapper) {
const [
awxLogin,
loginPage,
loginForm,
usernameInput,
passwordInput,
submitButton,
loginHeaderLogo,
] = await Promise.all([
waitForElement(wrapper, 'AWXLogin', (el) => el.length === 1),
waitForElement(wrapper, 'LoginPage', (el) => el.length === 1),
waitForElement(wrapper, 'LoginForm', (el) => el.length === 1),
waitForElement(wrapper, 'input#pf-login-username-id', (el) => el.length === 1),
waitForElement(wrapper, 'input#pf-login-password-id', (el) => el.length === 1),
waitForElement(wrapper, 'Button[type="submit"]', (el) => el.length === 1),
waitForElement(wrapper, 'img', (el) => el.length === 1),
]);
return {
awxLogin,
loginPage,
loginForm,
usernameInput,
passwordInput,
submitButton,
loginHeaderLogo,
};
}
beforeEach(() => {
RootAPI.read.mockResolvedValue({
data: {
custom_login_info: '',
custom_logo: 'images/foo.jpg'
}
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders without crashing', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const {
awxLogin,
usernameInput,
passwordInput,
submitButton,
} = await findChildren(loginWrapper);
expect(usernameInput.props().value).toBe('');
expect(passwordInput.props().value).toBe('');
expect(awxLogin.state('validationError')).toBe(false);
expect(submitButton.props().isDisabled).toBe(false);
done();
});
test('custom logo renders Brand component with correct src and alt', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin alt="Foo Application" isAuthenticated={() => false} />
);
const { loginHeaderLogo } = await findChildren(loginWrapper);
const { alt, src } = loginHeaderLogo.props();
expect([alt, src]).toEqual(['Foo Application', 'data:image/jpeg;images/foo.jpg']);
done();
});
test('default logo renders Brand component with correct src and alt', async (done) => {
RootAPI.read.mockResolvedValue({ data: {} });
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const { loginHeaderLogo } = await findChildren(loginWrapper);
const { alt, src } = loginHeaderLogo.props();
expect([alt, src]).toEqual(['AWX', 'brand-logo.svg']);
done();
});
test('default logo renders on data initialization error', async (done) => {
RootAPI.read.mockRejectedValueOnce({ response: { status: 500 } });
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const { loginHeaderLogo } = await findChildren(loginWrapper);
const { alt, src } = loginHeaderLogo.props();
expect([alt, src]).toEqual(['AWX', 'brand-logo.svg']);
done();
});
test('state maps to un/pw input value props', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const { usernameInput, passwordInput } = await findChildren(loginWrapper);
usernameInput.props().onChange({ currentTarget: { value: 'un' } });
passwordInput.props().onChange({ currentTarget: { value: 'pw' } });
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('username') === 'un');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('password') === 'pw');
done();
});
test('handles input validation errors and clears on input value change', async (done) => {
const formError = '.pf-c-form__helper-text.pf-m-error';
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const {
usernameInput,
passwordInput,
submitButton
} = await findChildren(loginWrapper);
RootAPI.login.mockRejectedValueOnce({ response: { status: 401 } });
usernameInput.props().onChange({ currentTarget: { value: 'invalid' } });
passwordInput.props().onChange({ currentTarget: { value: 'invalid' } });
submitButton.simulate('click');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('username') === 'invalid');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('password') === 'invalid');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('validationError') === true);
await waitForElement(loginWrapper, formError, (el) => el.length === 1);
usernameInput.props().onChange({ currentTarget: { value: 'dsarif' } });
passwordInput.props().onChange({ currentTarget: { value: 'freneticpny' } });
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('username') === 'dsarif');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('password') === 'freneticpny');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('validationError') === false);
await waitForElement(loginWrapper, formError, (el) => el.length === 0);
done();
});
test('handles other errors and clears on resubmit', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const {
usernameInput,
passwordInput,
submitButton
} = await findChildren(loginWrapper);
RootAPI.login.mockRejectedValueOnce({ response: { status: 500 } });
submitButton.simulate('click');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('authenticationError') === true);
usernameInput.props().onChange({ currentTarget: { value: 'sgrimes' } });
passwordInput.props().onChange({ currentTarget: { value: 'ovid' } });
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('username') === 'sgrimes');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('password') === 'ovid');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('authenticationError') === true);
submitButton.simulate('click');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('authenticationError') === false);
done();
});
test('no login requests are made when already authenticating', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const { awxLogin, submitButton } = await findChildren(loginWrapper);
awxLogin.setState({ isAuthenticating: true });
submitButton.simulate('click');
submitButton.simulate('click');
expect(RootAPI.login).toHaveBeenCalledTimes(0);
awxLogin.setState({ isAuthenticating: false });
submitButton.simulate('click');
submitButton.simulate('click');
expect(RootAPI.login).toHaveBeenCalledTimes(1);
done();
});
test('submit calls api.login successfully', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const {
usernameInput,
passwordInput,
submitButton,
} = await findChildren(loginWrapper);
usernameInput.props().onChange({ currentTarget: { value: 'gthorpe' } });
passwordInput.props().onChange({ currentTarget: { value: 'hydro' } });
submitButton.simulate('click');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('isAuthenticating') === true);
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('isAuthenticating') === false);
expect(RootAPI.login).toHaveBeenCalledTimes(1);
expect(RootAPI.login).toHaveBeenCalledWith('gthorpe', 'hydro');
done();
});
test('render Redirect to / when already authenticated', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => true} />
);
await waitForElement(loginWrapper, 'Redirect', (el) => el.length === 1);
await waitForElement(loginWrapper, 'Redirect', (el) => el.props().to === '/');
done();
});
});

View File

@@ -0,0 +1 @@
export { default } from './Login';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class ManagementJobs extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Management Jobs`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(ManagementJobs);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ManagementJobs from './ManagementJobs';
describe('<ManagementJobs />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<ManagementJobs />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './ManagementJobs';

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import NotificationTemplates from './NotificationTemplates';
describe('<NotificationTemplates />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<NotificationTemplates />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class NotificationTemplates extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Notification Templates`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(NotificationTemplates);

View File

@@ -0,0 +1 @@
export { default } from './NotificationTemplates';

View File

@@ -0,0 +1,242 @@
import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect } from 'react-router-dom';
import { Card, CardHeader as PFCardHeader, PageSection } from '@patternfly/react-core';
import styled from 'styled-components';
import CardCloseButton from '../../components/CardCloseButton';
import ContentError from '../../components/ContentError';
import RoutedTabs from '../../components/RoutedTabs';
import { OrganizationAccess } from './OrganizationAccess';
import OrganizationDetail from './OrganizationDetail';
import OrganizationEdit from './OrganizationEdit';
import OrganizationNotifications from './OrganizationNotifications';
import OrganizationTeams from './OrganizationTeams';
import { OrganizationsAPI } from '../../api';
class Organization extends Component {
constructor (props) {
super(props);
this.state = {
organization: null,
contentLoading: true,
contentError: false,
isInitialized: false,
isNotifAdmin: false,
isAuditorOfThisOrg: false,
isAdminOfThisOrg: false,
};
this.loadOrganization = this.loadOrganization.bind(this);
this.loadOrganizationAndRoles = this.loadOrganizationAndRoles.bind(this);
}
async componentDidMount () {
await this.loadOrganizationAndRoles();
this.setState({ isInitialized: true });
}
async componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
await this.loadOrganization();
}
}
async loadOrganizationAndRoles () {
const {
match,
setBreadcrumb,
} = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: false, contentLoading: true });
try {
const [{ data }, notifAdminRes, auditorRes, adminRes] = await Promise.all([
OrganizationsAPI.readDetail(id),
OrganizationsAPI.read({ page_size: 1, role_level: 'notification_admin_role' }),
OrganizationsAPI.read({ id, role_level: 'auditor_role' }),
OrganizationsAPI.read({ id, role_level: 'admin_role' }),
]);
setBreadcrumb(data);
this.setState({
organization: data,
isNotifAdmin: notifAdminRes.data.results.length > 0,
isAuditorOfThisOrg: auditorRes.data.results.length > 0,
isAdminOfThisOrg: adminRes.data.results.length > 0
});
} catch (err) {
this.setState(({ contentError: true }));
} finally {
this.setState({ contentLoading: false });
}
}
async loadOrganization () {
const {
match,
setBreadcrumb,
} = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: false, contentLoading: true });
try {
const { data } = await OrganizationsAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ organization: data });
} catch (err) {
this.setState(({ contentError: true }));
} finally {
this.setState({ contentLoading: false });
}
}
render () {
const {
location,
match,
me,
history,
i18n
} = this.props;
const {
organization,
contentError,
contentLoading,
isInitialized,
isNotifAdmin,
isAuditorOfThisOrg,
isAdminOfThisOrg
} = this.state;
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin || isAuditorOfThisOrg;
const canToggleNotifications = isNotifAdmin && (
me.is_system_auditor
|| isAuditorOfThisOrg
|| isAdminOfThisOrg
);
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Teams`), link: `${match.url}/teams`, id: 2 }
];
if (canSeeNotificationsTab) {
tabsArray.push({
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
id: 3
});
}
const CardHeader = styled(PFCardHeader)`
--pf-c-card--first-child--PaddingTop: 0;
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
position: relative;
`;
let cardHeader = (
<CardHeader>
<RoutedTabs
match={match}
history={history}
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/organizations" />
</CardHeader>
);
if (!isInitialized) {
cardHeader = null;
}
if (!match) {
cardHeader = null;
}
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
if (!contentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError />
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect
from="/organizations/:id"
to="/organizations/:id/details"
exact
/>
{organization && (
<Route
path="/organizations/:id/edit"
render={() => (
<OrganizationEdit
match={match}
organization={organization}
/>
)}
/>
)}
{organization && (
<Route
path="/organizations/:id/details"
render={() => (
<OrganizationDetail
match={match}
organization={organization}
/>
)}
/>
)}
{organization && (
<Route
path="/organizations/:id/access"
render={() => (
<OrganizationAccess
organization={organization}
/>
)}
/>
)}
<Route
path="/organizations/:id/teams"
render={() => (
<OrganizationTeams id={Number(match.params.id)} />
)}
/>
{canSeeNotificationsTab && (
<Route
path="/organizations/:id/notifications"
render={() => (
<OrganizationNotifications
id={Number(match.params.id)}
canToggleNotifications={canToggleNotifications}
/>
)}
/>
)}
</Switch>
</Card>
</PageSection>
);
}
}
export default withI18n()(withRouter(Organization));
export { Organization as _Organization };

View File

@@ -0,0 +1,232 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../../../testUtils/enzymeHelpers';
import Organization from './Organization';
import { OrganizationsAPI } from '../../api';
jest.mock('../../api');
const mockMe = {
is_super_user: true,
is_system_auditor: false
};
const mockNoResults = {
count: 0,
next: null,
previous: null,
data: { results: [] }
};
const mockDetails = {
data: {
id: 1,
type: 'organization',
url: '/api/v2/organizations/1/',
related: {
notification_templates: '/api/v2/organizations/1/notification_templates/',
notification_templates_any: '/api/v2/organizations/1/notification_templates_any/',
notification_templates_success: '/api/v2/organizations/1/notification_templates_success/',
notification_templates_error: '/api/v2/organizations/1/notification_templates_error/',
object_roles: '/api/v2/organizations/1/object_roles/',
access_list: '/api/v2/organizations/1/access_list/',
instance_groups: '/api/v2/organizations/1/instance_groups/'
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
object_roles: {
admin_role: {
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 42
},
notification_admin_role: {
description: 'Can manage all notifications of the organization',
name: 'Notification Admin',
id: 1683
},
auditor_role: {
description: 'Can view all aspects of the organization',
name: 'Auditor',
id: 41
},
},
user_capabilities: {
edit: true,
delete: true
},
related_field_counts: {
users: 51,
admins: 19,
inventories: 23,
teams: 12,
projects: 33,
job_templates: 30
}
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Sarif Industries',
description: '',
max_hosts: 0,
custom_virtualenv: null
}
};
const adminOrganization = {
id: 1,
type: 'organization',
url: '/api/v2/organizations/1/',
related: {
instance_groups: '/api/v2/organizations/1/instance_groups/',
object_roles: '/api/v2/organizations/1/object_roles/',
access_list: '/api/v2/organizations/1/access_list/',
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Sarif Industries',
description: '',
max_hosts: 0,
custom_virtualenv: null
};
const auditorOrganization = {
id: 2,
type: 'organization',
url: '/api/v2/organizations/2/',
related: {
instance_groups: '/api/v2/organizations/2/instance_groups/',
object_roles: '/api/v2/organizations/2/object_roles/',
access_list: '/api/v2/organizations/2/access_list/',
},
summary_fields: {
created_by: {
id: 2,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
modified_by: {
id: 2,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Autobots',
description: '',
max_hosts: 0,
custom_virtualenv: null
};
const notificationAdminOrganization = {
id: 3,
type: 'organization',
url: '/api/v2/organizations/3/',
related: {
instance_groups: '/api/v2/organizations/3/instance_groups/',
object_roles: '/api/v2/organizations/3/object_roles/',
access_list: '/api/v2/organizations/3/access_list/',
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Decepticons',
description: '',
max_hosts: 0,
custom_virtualenv: null
};
const allOrganizations = [
adminOrganization,
auditorOrganization,
notificationAdminOrganization
];
async function getOrganizations (params) {
let results = allOrganizations;
if (params && params.role_level) {
if (params.role_level === 'admin_role') {
results = [adminOrganization];
}
if (params.role_level === 'auditor_role') {
results = [auditorOrganization];
}
if (params.role_level === 'notification_admin_role') {
results = [notificationAdminOrganization];
}
}
return {
count: results.length,
next: null,
previous: null,
data: { results }
};
}
describe.only('<Organization />', () => {
test('initially renders succesfully', () => {
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.read.mockImplementation(getOrganizations);
mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
});
test('notifications tab shown for admins', async (done) => {
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.read.mockImplementation(getOrganizations);
const wrapper = mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
const tabs = await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 4);
expect(tabs.last().text()).toEqual('Notifications');
done();
});
test('notifications tab hidden with reduced permissions', async (done) => {
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.read.mockResolvedValue(mockNoResults);
const wrapper = mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
const tabs = await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 3);
tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
done();
});
});

View File

@@ -0,0 +1,66 @@
import React, { Fragment } from 'react';
import { func, string } from 'prop-types';
import { Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '../../../components/AlertModal';
import { Role } from '../../../types';
class DeleteRoleConfirmationModal extends React.Component {
static propTypes = {
role: Role.isRequired,
username: string,
onCancel: func.isRequired,
onConfirm: func.isRequired,
}
static defaultProps = {
username: '',
}
isTeamRole () {
const { role } = this.props;
return typeof role.team_id !== 'undefined';
}
render () {
const { role, username, onCancel, onConfirm, i18n } = this.props;
const title = i18n._(t`Remove ${this.isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access`);
return (
<AlertModal
variant="danger"
title={title}
isOpen
onClose={onCancel}
actions={[
<Button
key="delete"
variant="danger"
aria-label="Confirm delete"
onClick={onConfirm}
>
{i18n._(t`Delete`)}
</Button>,
<Button key="cancel" variant="secondary" onClick={onCancel}>
{i18n._(t`Cancel`)}
</Button>
]}
>
{this.isTeamRole() ? (
<Fragment>
{i18n._(t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.`)}
<br />
<br />
{i18n._(t`If you ${(<b><i>only</i></b>)} want to remove access for this particular user, please remove them from the team.`)}
</Fragment>
) : (
<Fragment>
{i18n._(t`Are you sure you want to remove ${role.name} access from ${username}?`)}
</Fragment>
)}
</AlertModal>
);
}
}
export default withI18n()(DeleteRoleConfirmationModal);

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import DeleteRoleConfirmationModal from './DeleteRoleConfirmationModal';
const role = {
id: 3,
name: 'Member',
resource_name: 'Org',
resource_type: 'organization',
team_id: 5,
team_name: 'The Team',
};
describe('<DeleteRoleConfirmationModal />', () => {
test('should render initially', () => {
const wrapper = mountWithContexts(
<DeleteRoleConfirmationModal
role={role}
username="jane"
onCancel={() => {}}
onConfirm={() => {}}
/>
);
wrapper.update();
expect(wrapper.find('DeleteRoleConfirmationModal')).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,219 @@
import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '../../../components/AlertModal';
import PaginatedDataList, { ToolbarAddButton } from '../../../components/PaginatedDataList';
import DataListToolbar from '../../../components/DataListToolbar';
import OrganizationAccessItem from './OrganizationAccessItem';
import DeleteRoleConfirmationModal from './DeleteRoleConfirmationModal';
import AddResourceRole from '../../../components/AddRole/AddResourceRole';
import {
getQSConfig,
encodeQueryString,
parseNamespacedQueryString
} from '../../../util/qs';
import { Organization } from '../../../types';
import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../api';
const QS_CONFIG = getQSConfig('access', {
page: 1,
page_size: 5,
order_by: 'first_name',
});
class OrganizationAccess extends React.Component {
static propTypes = {
organization: Organization.isRequired,
};
constructor (props) {
super(props);
this.state = {
accessRecords: [],
contentError: false,
contentLoading: true,
deletionError: false,
deletionRecord: null,
deletionRole: null,
isAddModalOpen: false,
itemCount: 0,
};
this.loadAccessList = this.loadAccessList.bind(this);
this.handleAddClose = this.handleAddClose.bind(this);
this.handleAddOpen = this.handleAddOpen.bind(this);
this.handleAddSuccess = this.handleAddSuccess.bind(this);
this.handleDeleteCancel = this.handleDeleteCancel.bind(this);
this.handleDeleteConfirm = this.handleDeleteConfirm.bind(this);
this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
this.handleDeleteOpen = this.handleDeleteOpen.bind(this);
}
componentDidMount () {
this.loadAccessList();
}
componentDidUpdate (prevProps) {
const { location } = this.props;
const prevParams = parseNamespacedQueryString(QS_CONFIG, prevProps.location.search);
const currentParams = parseNamespacedQueryString(QS_CONFIG, location.search);
if (encodeQueryString(currentParams) !== encodeQueryString(prevParams)) {
this.loadAccessList();
}
}
async loadAccessList () {
const { organization, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ contentError: false, contentLoading: true });
try {
const {
data: {
results: accessRecords = [],
count: itemCount = 0
}
} = await OrganizationsAPI.readAccessList(organization.id, params);
this.setState({ itemCount, accessRecords });
} catch (error) {
this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
}
}
handleDeleteOpen (deletionRole, deletionRecord) {
this.setState({ deletionRole, deletionRecord });
}
handleDeleteCancel () {
this.setState({ deletionRole: null, deletionRecord: null });
}
handleDeleteErrorClose () {
this.setState({
deletionError: false,
deletionRecord: null,
deletionRole: null
});
}
async handleDeleteConfirm () {
const { deletionRole, deletionRecord } = this.state;
if (!deletionRole || !deletionRecord) {
return;
}
let promise;
if (typeof deletionRole.team_id !== 'undefined') {
promise = TeamsAPI.disassociateRole(deletionRole.team_id, deletionRole.id);
} else {
promise = UsersAPI.disassociateRole(deletionRecord.id, deletionRole.id);
}
this.setState({ contentLoading: true });
try {
await promise.then(this.loadAccessList);
this.setState({
deletionRole: null,
deletionRecord: null
});
} catch (error) {
this.setState({
contentLoading: false,
deletionError: true
});
}
}
handleAddClose () {
this.setState({ isAddModalOpen: false });
}
handleAddOpen () {
this.setState({ isAddModalOpen: true });
}
handleAddSuccess () {
this.setState({ isAddModalOpen: false });
this.loadAccessList();
}
render () {
const { organization, i18n } = this.props;
const {
accessRecords,
contentError,
contentLoading,
deletionRole,
deletionRecord,
deletionError,
itemCount,
isAddModalOpen,
} = this.state;
const canEdit = organization.summary_fields.user_capabilities.edit;
const isDeleteModalOpen = !contentLoading && !deletionError && deletionRole;
return (
<Fragment>
<PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={accessRecords}
itemCount={itemCount}
itemName="role"
qsConfig={QS_CONFIG}
toolbarColumns={[
{ name: i18n._(t`Name`), key: 'first_name', isSortable: true },
{ name: i18n._(t`Username`), key: 'username', isSortable: true },
{ name: i18n._(t`Last Name`), key: 'last_name', isSortable: true },
]}
renderToolbar={(props) => (
<DataListToolbar
{...props}
additionalControls={canEdit ? [
<ToolbarAddButton key="add" onClick={this.handleAddOpen} />
] : null}
/>
)}
renderItem={accessRecord => (
<OrganizationAccessItem
key={accessRecord.id}
accessRecord={accessRecord}
onRoleDelete={this.handleDeleteOpen}
/>
)}
/>
{isAddModalOpen && (
<AddResourceRole
onClose={this.handleAddClose}
onSave={this.handleAddSuccess}
roles={organization.summary_fields.object_roles}
/>
)}
{isDeleteModalOpen && (
<DeleteRoleConfirmationModal
role={deletionRole}
username={deletionRecord.username}
onCancel={this.handleDeleteCancel}
onConfirm={this.handleDeleteConfirm}
/>
)}
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete role`)}
</AlertModal>
</Fragment>
);
}
}
export { OrganizationAccess as _OrganizationAccess };
export default withI18n()(withRouter(OrganizationAccess));

View File

@@ -0,0 +1,162 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../../../../testUtils/enzymeHelpers';
import OrganizationAccess from './OrganizationAccess';
import { sleep } from '../../../../testUtils/testUtils';
import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../api';
jest.mock('../../../api');
describe('<OrganizationAccess />', () => {
const organization = {
id: 1,
name: 'Default',
summary_fields: {
object_roles: {},
user_capabilities: {
edit: true
}
}
};
const data = {
count: 2,
results: [{
id: 1,
username: 'joe',
url: '/foo',
first_name: 'joe',
last_name: 'smith',
summary_fields: {
direct_access: [{
role: {
id: 1,
name: 'Member',
resource_name: 'Org',
resource_type: 'organization',
user_capabilities: { unattach: true },
}
}],
indirect_access: [],
}
}, {
id: 2,
username: 'jane',
url: '/bar',
first_name: 'jane',
last_name: 'brown',
summary_fields: {
direct_access: [{
role: {
id: 3,
name: 'Member',
resource_name: 'Org',
resource_type: 'organization',
team_id: 5,
team_name: 'The Team',
user_capabilities: { unattach: true },
}
}],
indirect_access: [],
}
}]
};
beforeEach(() => {
OrganizationsAPI.readAccessList.mockResolvedValue({ data });
TeamsAPI.disassociateRole.mockResolvedValue({});
UsersAPI.disassociateRole.mockResolvedValue({});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders succesfully', () => {
const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
expect(wrapper.find('OrganizationAccess')).toMatchSnapshot();
});
test('should fetch and display access records on mount', async (done) => {
const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
await waitForElement(wrapper, 'OrganizationAccessItem', el => el.length === 2);
expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(data.results);
expect(wrapper.find('OrganizationAccess').state('contentLoading')).toBe(false);
expect(wrapper.find('OrganizationAccess').state('contentError')).toBe(false);
done();
});
test('should open confirmation dialog when deleting role', async (done) => {
const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
await sleep(0);
wrapper.update();
const button = wrapper.find('ChipButton').at(0);
button.prop('onClick')();
wrapper.update();
const component = wrapper.find('OrganizationAccess');
expect(component.state('deletionRole'))
.toEqual(data.results[0].summary_fields.direct_access[0].role);
expect(component.state('deletionRecord'))
.toEqual(data.results[0]);
expect(component.find('DeleteRoleConfirmationModal')).toHaveLength(1);
done();
});
it('should close dialog when cancel button clicked', async (done) => {
const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
await sleep(0);
wrapper.update();
const button = wrapper.find('ChipButton').at(0);
button.prop('onClick')();
wrapper.update();
wrapper.find('DeleteRoleConfirmationModal').prop('onCancel')();
const component = wrapper.find('OrganizationAccess');
expect(component.state('deletionRole')).toBeNull();
expect(component.state('deletionRecord')).toBeNull();
expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled();
expect(UsersAPI.disassociateRole).not.toHaveBeenCalled();
done();
});
it('should delete user role', async (done) => {
const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
const button = await waitForElement(wrapper, 'ChipButton', el => el.length === 2);
button.at(0).prop('onClick')();
const confirmation = await waitForElement(wrapper, 'DeleteRoleConfirmationModal');
confirmation.prop('onConfirm')();
await waitForElement(wrapper, 'DeleteRoleConfirmationModal', el => el.length === 0);
await sleep(0);
wrapper.update();
const component = wrapper.find('OrganizationAccess');
expect(component.state('deletionRole')).toBeNull();
expect(component.state('deletionRecord')).toBeNull();
expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled();
expect(UsersAPI.disassociateRole).toHaveBeenCalledWith(1, 1);
expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2);
done();
});
it('should delete team role', async (done) => {
const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
const button = await waitForElement(wrapper, 'ChipButton', el => el.length === 2);
button.at(1).prop('onClick')();
const confirmation = await waitForElement(wrapper, 'DeleteRoleConfirmationModal');
confirmation.prop('onConfirm')();
await waitForElement(wrapper, 'DeleteRoleConfirmationModal', el => el.length === 0);
await sleep(0);
wrapper.update();
const component = wrapper.find('OrganizationAccess');
expect(component.state('deletionRole')).toBeNull();
expect(component.state('deletionRecord')).toBeNull();
expect(TeamsAPI.disassociateRole).toHaveBeenCalledWith(5, 3);
expect(UsersAPI.disassociateRole).not.toHaveBeenCalled();
expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2);
done();
});
});

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
DataListItem,
DataListItemRow,
DataListItemCells as PFDataListItemCells,
DataListCell,
Text,
TextContent,
TextVariants,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { AccessRecord } from '../../../types';
import { DetailList, Detail } from '../../../components/DetailList';
import { ChipGroup, Chip } from '../../../components/Chip';
const DataListItemCells = styled(PFDataListItemCells)`
align-items: start;
`;
class OrganizationAccessItem extends React.Component {
static propTypes = {
accessRecord: AccessRecord.isRequired,
onRoleDelete: func.isRequired,
};
constructor (props) {
super(props);
this.renderChip = this.renderChip.bind(this);
}
getRoleLists () {
const { accessRecord } = this.props;
const teamRoles = [];
const userRoles = [];
function sort (item) {
const { role } = item;
if (role.team_id) {
teamRoles.push(role);
} else {
userRoles.push(role);
}
}
accessRecord.summary_fields.direct_access.map(sort);
accessRecord.summary_fields.indirect_access.map(sort);
return [teamRoles, userRoles];
}
renderChip (role) {
const { accessRecord, onRoleDelete } = this.props;
return (
<Chip
key={role.id}
isReadOnly={!role.user_capabilities.unattach}
onClick={() => { onRoleDelete(role, accessRecord); }}
>
{role.name}
</Chip>
);
}
render () {
const { accessRecord, i18n } = this.props;
const [teamRoles, userRoles] = this.getRoleLists();
return (
<DataListItem aria-labelledby="access-list-item" key={accessRecord.id}>
<DataListItemRow>
<DataListItemCells dataListCells={[
<DataListCell key="name">
{accessRecord.username && (
<TextContent>
{accessRecord.url ? (
<Text component={TextVariants.h6}>
<Link
to={{ pathname: accessRecord.url }}
css="font-weight: bold"
>
{accessRecord.username}
</Link>
</Text>
) : (
<Text
component={TextVariants.h6}
css="font-weight: bold"
>
{accessRecord.username}
</Text>
)}
</TextContent>
)}
{accessRecord.first_name || accessRecord.last_name ? (
<DetailList stacked>
<Detail
label={i18n._(t`Name`)}
value={`${accessRecord.first_name} ${accessRecord.last_name}`}
/>
</DetailList>
) : (
null
)}
</DataListCell>,
<DataListCell key="roles">
<DetailList stacked>
{userRoles.length > 0 && (
<Detail
label={i18n._(t`User Roles`)}
value={(
<ChipGroup>
{userRoles.map(this.renderChip)}
</ChipGroup>
)}
/>
)}
{teamRoles.length > 0 && (
<Detail
label={i18n._(t`Team Roles`)}
value={(
<ChipGroup>
{teamRoles.map(this.renderChip)}
</ChipGroup>
)}
/>
)}
</DetailList>
</DataListCell>
]}
/>
</DataListItemRow>
</DataListItem>
);
}
}
export default withI18n()(OrganizationAccessItem);

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import OrganizationAccessItem from './OrganizationAccessItem';
const accessRecord = {
id: 2,
username: 'jane',
url: '/bar',
first_name: 'jane',
last_name: 'brown',
summary_fields: {
direct_access: [{
role: {
id: 3,
name: 'Member',
resource_name: 'Org',
resource_type: 'organization',
team_id: 5,
team_name: 'The Team',
user_capabilities: { unattach: true },
}
}],
indirect_access: [],
}
};
describe('<OrganizationAccessItem />', () => {
test('initially renders succesfully', () => {
const wrapper = mountWithContexts(
<OrganizationAccessItem
accessRecord={accessRecord}
onRoleDelete={() => {}}
/>
);
expect(wrapper.find('OrganizationAccessItem')).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,483 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
<DeleteRoleConfirmationModal
i18n={"/i18n/"}
onCancel={[Function]}
onConfirm={[Function]}
role={
Object {
"id": 3,
"name": "Member",
"resource_name": "Org",
"resource_type": "organization",
"team_id": 5,
"team_name": "The Team",
}
}
username="jane"
>
<_default
actions={
Array [
<Button
aria-label="Confirm delete"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
onClick={[Function]}
type="button"
variant="danger"
>
Delete
</Button>,
<Button
aria-label={null}
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
onClick={[Function]}
type="button"
variant="secondary"
>
Cancel
</Button>,
]
}
isOpen={true}
onClose={[Function]}
title="Remove {0} Access"
variant="danger"
>
<Modal
actions={
Array [
<Button
aria-label="Confirm delete"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
onClick={[Function]}
type="button"
variant="danger"
>
Delete
</Button>,
<Button
aria-label={null}
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
onClick={[Function]}
type="button"
variant="secondary"
>
Cancel
</Button>,
]
}
ariaDescribedById=""
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
hideTitle={false}
isLarge={false}
isOpen={true}
isSmall={false}
onClose={[Function]}
title="Remove {0} Access"
width={null}
>
<Portal
containerInfo={
<div>
<div
class="pf-c-backdrop"
>
<div
class="pf-l-bullseye"
>
<div
aria-describedby="pf-modal-0"
aria-label="Remove {0} Access"
aria-modal="true"
class="pf-c-modal-box awx-c-modal at-c-alertModal at-c-alertModal--danger"
role="dialog"
>
<button
aria-label="Close"
class="pf-c-button pf-m-plain"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
role="img"
style="vertical-align: -0.125em;"
viewBox="0 0 352 512"
width="1em"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
transform=""
/>
</svg>
</button>
<h3
class="pf-c-title pf-m-2xl"
>
Remove {0} Access
</h3>
<div
class="pf-c-modal-box__body"
id="pf-modal-0"
>
Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team.
<br />
<br />
If you {0} want to remove access for this particular user, please remove them from the team.
<svg
aria-hidden="true"
class="at-c-alertModal__icon"
fill="currentColor"
height="1em"
role="img"
style="vertical-align: -0.125em;"
viewBox="0 0 512 512"
width="1em"
>
<path
d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
transform=""
/>
</svg>
</div>
<div
class="pf-c-modal-box__footer"
>
<button
aria-label="Confirm delete"
class="pf-c-button pf-m-danger"
type="button"
>
Delete
</button>
<button
class="pf-c-button pf-m-secondary"
type="button"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
}
>
<ModalContent
actions={
Array [
<Button
aria-label="Confirm delete"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
onClick={[Function]}
type="button"
variant="danger"
>
Delete
</Button>,
<Button
aria-label={null}
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
onClick={[Function]}
type="button"
variant="secondary"
>
Cancel
</Button>,
]
}
ariaDescribedById=""
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
hideTitle={false}
id="pf-modal-0"
isLarge={false}
isOpen={true}
isSmall={false}
onClose={[Function]}
title="Remove {0} Access"
width={null}
>
<Backdrop
className=""
>
<div
className="pf-c-backdrop"
>
<FocusTrap
_createFocusTrap={[Function]}
active={true}
className="pf-l-bullseye"
focusTrapOptions={
Object {
"clickOutsideDeactivates": true,
}
}
paused={false}
tag="div"
>
<div
className="pf-l-bullseye"
>
<ModalBox
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
id="pf-modal-0"
isLarge={false}
isSmall={false}
style={
Object {
"width": null,
}
}
title="Remove {0} Access"
>
<div
aria-describedby="pf-modal-0"
aria-label="Remove {0} Access"
aria-modal="true"
className="pf-c-modal-box awx-c-modal at-c-alertModal at-c-alertModal--danger"
role="dialog"
style={
Object {
"width": null,
}
}
>
<ModalBoxCloseButton
className=""
onClose={[Function]}
>
<Button
aria-label="Close"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
onClick={[Function]}
type="button"
variant="plain"
>
<button
aria-disabled={null}
aria-label="Close"
className="pf-c-button pf-m-plain"
disabled={false}
onClick={[Function]}
tabIndex={null}
type="button"
>
<TimesIcon
color="currentColor"
size="sm"
title={null}
>
<svg
aria-hidden={true}
aria-labelledby={null}
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 352 512"
width="1em"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
transform=""
/>
</svg>
</TimesIcon>
</button>
</Button>
</ModalBoxCloseButton>
<ModalBoxHeader
className=""
hideTitle={false}
>
<Title
className=""
headingLevel="h3"
size="2xl"
>
<h3
className="pf-c-title pf-m-2xl"
>
Remove {0} Access
</h3>
</Title>
</ModalBoxHeader>
<ModalBoxBody
className=""
id="pf-modal-0"
>
<div
className="pf-c-modal-box__body"
id="pf-modal-0"
>
Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team.
<br />
<br />
If you {0} want to remove access for this particular user, please remove them from the team.
<ExclamationCircleIcon
className="at-c-alertModal__icon"
color="currentColor"
size="sm"
title={null}
>
<svg
aria-hidden={true}
aria-labelledby={null}
className="at-c-alertModal__icon"
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 512 512"
width="1em"
>
<path
d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
transform=""
/>
</svg>
</ExclamationCircleIcon>
</div>
</ModalBoxBody>
<ModalBoxFooter
className=""
>
<div
className="pf-c-modal-box__footer"
>
<Button
aria-label="Confirm delete"
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
key="delete"
onClick={[Function]}
type="button"
variant="danger"
>
<button
aria-disabled={null}
aria-label="Confirm delete"
className="pf-c-button pf-m-danger"
disabled={false}
onClick={[Function]}
tabIndex={null}
type="button"
>
Delete
</button>
</Button>
<Button
aria-label={null}
className=""
component="button"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
key="cancel"
onClick={[Function]}
type="button"
variant="secondary"
>
<button
aria-disabled={null}
aria-label={null}
className="pf-c-button pf-m-secondary"
disabled={false}
onClick={[Function]}
tabIndex={null}
type="button"
>
Cancel
</button>
</Button>
</div>
</ModalBoxFooter>
</div>
</ModalBox>
</div>
</FocusTrap>
</div>
</Backdrop>
</ModalContent>
</Portal>
</Modal>
</_default>
</DeleteRoleConfirmationModal>
`;

View File

@@ -0,0 +1,260 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
<OrganizationAccess
history={"/history/"}
i18n={"/i18n/"}
location={
Object {
"hash": "",
"pathname": "",
"search": "",
"state": "",
}
}
match={
Object {
"isExact": false,
"params": Object {},
"path": "",
"url": "",
}
}
organization={
Object {
"id": 1,
"name": "Default",
"summary_fields": Object {
"object_roles": Object {},
"user_capabilities": Object {
"edit": true,
},
},
}
}
>
<WithI18n
contentError={false}
contentLoading={true}
itemCount={0}
itemName="role"
items={Array []}
qsConfig={
Object {
"defaultParams": Object {
"order_by": "first_name",
"page": 1,
"page_size": 5,
},
"integerFields": Array [
"page",
"page_size",
],
"namespace": "access",
}
}
renderItem={[Function]}
renderToolbar={[Function]}
toolbarColumns={
Array [
Object {
"isSortable": true,
"key": "first_name",
"name": "Name",
},
Object {
"isSortable": true,
"key": "username",
"name": "Username",
},
Object {
"isSortable": true,
"key": "last_name",
"name": "Last Name",
},
]
}
>
<I18n
update={true}
withHash={true}
>
<withRouter(PaginatedDataList)
contentError={false}
contentLoading={true}
i18n={"/i18n/"}
itemCount={0}
itemName="role"
items={Array []}
qsConfig={
Object {
"defaultParams": Object {
"order_by": "first_name",
"page": 1,
"page_size": 5,
},
"integerFields": Array [
"page",
"page_size",
],
"namespace": "access",
}
}
renderItem={[Function]}
renderToolbar={[Function]}
toolbarColumns={
Array [
Object {
"isSortable": true,
"key": "first_name",
"name": "Name",
},
Object {
"isSortable": true,
"key": "username",
"name": "Username",
},
Object {
"isSortable": true,
"key": "last_name",
"name": "Last Name",
},
]
}
>
<Route>
<PaginatedDataList
contentError={false}
contentLoading={true}
history={"/history/"}
i18n={"/i18n/"}
itemCount={0}
itemName="role"
itemNamePlural=""
items={Array []}
location={
Object {
"hash": "",
"pathname": "",
"search": "",
"state": "",
}
}
match={
Object {
"isExact": false,
"params": Object {},
"path": "",
"url": "",
}
}
qsConfig={
Object {
"defaultParams": Object {
"order_by": "first_name",
"page": 1,
"page_size": 5,
},
"integerFields": Array [
"page",
"page_size",
],
"namespace": "access",
}
}
renderItem={[Function]}
renderToolbar={[Function]}
showPageSizeOptions={true}
toolbarColumns={
Array [
Object {
"isSortable": true,
"key": "first_name",
"name": "Name",
},
Object {
"isSortable": true,
"key": "username",
"name": "Username",
},
Object {
"isSortable": true,
"key": "last_name",
"name": "Last Name",
},
]
}
>
<WithI18n>
<I18n
update={true}
withHash={true}
>
<ContentLoading
i18n={"/i18n/"}
>
<EmptyState
className=""
variant="large"
>
<div
className="pf-c-empty-state pf-m-lg"
>
<EmptyStateBody
className=""
>
<p
className="pf-c-empty-state__body"
>
Loading...
</p>
</EmptyStateBody>
</div>
</EmptyState>
</ContentLoading>
</I18n>
</WithI18n>
</PaginatedDataList>
</Route>
</withRouter(PaginatedDataList)>
</I18n>
</WithI18n>
<_default
isOpen={false}
onClose={[Function]}
title="Error!"
variant="danger"
>
<Modal
actions={Array []}
ariaDescribedById=""
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
hideTitle={false}
isLarge={false}
isOpen={false}
isSmall={false}
onClose={[Function]}
title="Error!"
width={null}
>
<Portal
containerInfo={<div />}
>
<ModalContent
actions={Array []}
ariaDescribedById=""
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
hideTitle={false}
id="pf-modal-0"
isLarge={false}
isOpen={false}
isSmall={false}
onClose={[Function]}
title="Error!"
width={null}
/>
</Portal>
</Modal>
</_default>
</OrganizationAccess>
`;

View File

@@ -0,0 +1,872 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
<OrganizationAccessItem
accessRecord={
Object {
"first_name": "jane",
"id": 2,
"last_name": "brown",
"summary_fields": Object {
"direct_access": Array [
Object {
"role": Object {
"id": 3,
"name": "Member",
"resource_name": "Org",
"resource_type": "organization",
"team_id": 5,
"team_name": "The Team",
"user_capabilities": Object {
"unattach": true,
},
},
},
],
"indirect_access": Array [],
},
"url": "/bar",
"username": "jane",
}
}
i18n={"/i18n/"}
onRoleDelete={[Function]}
>
<DataListItem
aria-labelledby="access-list-item"
className=""
isExpanded={false}
key="2"
>
<li
aria-labelledby="access-list-item"
className="pf-c-data-list__item"
>
<DataListItemRow
className=""
key=".0"
rowid="access-list-item"
>
<div
className="pf-c-data-list__item-row"
>
<OrganizationAccessItem__DataListItemCells
dataListCells={
Array [
<DataListCell
alignRight={false}
className=""
isFilled={true}
isIcon={false}
width={1}
>
<TextContent
className=""
>
<Text
className=""
component="h6"
>
<ForwardRef
to={
Object {
"pathname": "/bar",
}
}
>
jane
</ForwardRef>
</Text>
</TextContent>
<ForwardRef
stacked={true}
>
<Detail
fullWidth={false}
label="Name"
value="jane brown"
/>
</ForwardRef>
</DataListCell>,
<DataListCell
alignRight={false}
className=""
isFilled={true}
isIcon={false}
width={1}
>
<ForwardRef
stacked={true}
>
<Detail
fullWidth={false}
label="Team Roles"
value={
<ForwardRef>
<ForwardRef
isReadOnly={false}
onClick={[Function]}
>
Member
</ForwardRef>
</ForwardRef>
}
/>
</ForwardRef>
</DataListCell>,
]
}
key=".0"
rowid="access-list-item"
>
<StyledComponent
dataListCells={
Array [
<DataListCell
alignRight={false}
className=""
isFilled={true}
isIcon={false}
width={1}
>
<TextContent
className=""
>
<Text
className=""
component="h6"
>
<ForwardRef
to={
Object {
"pathname": "/bar",
}
}
>
jane
</ForwardRef>
</Text>
</TextContent>
<ForwardRef
stacked={true}
>
<Detail
fullWidth={false}
label="Name"
value="jane brown"
/>
</ForwardRef>
</DataListCell>,
<DataListCell
alignRight={false}
className=""
isFilled={true}
isIcon={false}
width={1}
>
<ForwardRef
stacked={true}
>
<Detail
fullWidth={false}
label="Team Roles"
value={
<ForwardRef>
<ForwardRef
isReadOnly={false}
onClick={[Function]}
>
Member
</ForwardRef>
</ForwardRef>
}
/>
</ForwardRef>
</DataListCell>,
]
}
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "OrganizationAccessItem__DataListItemCells-sc-1b9e0ad-0",
"isStatic": true,
"lastClassName": "QeteT",
"rules": Array [
"align-items:start;",
],
},
"displayName": "OrganizationAccessItem__DataListItemCells",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "OrganizationAccessItem__DataListItemCells-sc-1b9e0ad-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
rowid="access-list-item"
>
<DataListItemCells
className="OrganizationAccessItem__DataListItemCells-sc-1b9e0ad-0 QeteT"
dataListCells={
Array [
<DataListCell
alignRight={false}
className=""
isFilled={true}
isIcon={false}
width={1}
>
<TextContent
className=""
>
<Text
className=""
component="h6"
>
<ForwardRef
to={
Object {
"pathname": "/bar",
}
}
>
jane
</ForwardRef>
</Text>
</TextContent>
<ForwardRef
stacked={true}
>
<Detail
fullWidth={false}
label="Name"
value="jane brown"
/>
</ForwardRef>
</DataListCell>,
<DataListCell
alignRight={false}
className=""
isFilled={true}
isIcon={false}
width={1}
>
<ForwardRef
stacked={true}
>
<Detail
fullWidth={false}
label="Team Roles"
value={
<ForwardRef>
<ForwardRef
isReadOnly={false}
onClick={[Function]}
>
Member
</ForwardRef>
</ForwardRef>
}
/>
</ForwardRef>
</DataListCell>,
]
}
rowid="access-list-item"
>
<div
className="pf-c-data-list__item-content OrganizationAccessItem__DataListItemCells-sc-1b9e0ad-0 QeteT"
>
<DataListCell
alignRight={false}
className=""
isFilled={true}
isIcon={false}
key="name"
width={1}
>
<div
className="pf-c-data-list__cell"
>
<TextContent
className=""
>
<div
className="pf-c-content"
>
<Text
className=""
component="h6"
>
<h6
className=""
data-pf-content={true}
>
<Styled(Link)
to={
Object {
"pathname": "/bar",
}
}
>
<StyledComponent
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-bdVaJa",
"isStatic": true,
"lastClassName": "fqQVUT",
"rules": Array [
"font-weight: bold",
],
},
"displayName": "Styled(Link)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-bdVaJa",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
to={
Object {
"pathname": "/bar",
}
}
>
<Link
className="sc-bdVaJa fqQVUT"
replace={false}
to={
Object {
"pathname": "/bar",
}
}
>
<a
className="sc-bdVaJa fqQVUT"
onClick={[Function]}
>
jane
</a>
</Link>
</StyledComponent>
</Styled(Link)>
</h6>
</Text>
</div>
</TextContent>
<DetailList
stacked={true}
>
<StyledComponent
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "DetailList-sc-12g7m4-0",
"isStatic": false,
"lastClassName": "hndBXy",
"rules": Array [
"display:grid;grid-gap:20px;",
[Function],
],
},
"displayName": "DetailList",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "DetailList-sc-12g7m4-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
stacked={true}
>
<DetailList
className="DetailList-sc-12g7m4-0 hndBXy"
stacked={true}
>
<TextList
className="DetailList-sc-12g7m4-0 hndBXy"
component="dl"
>
<dl
className="DetailList-sc-12g7m4-0 hndBXy"
data-pf-content={true}
>
<Detail
fullWidth={false}
label="Name"
value="jane brown"
>
<Detail__DetailName
component="dt"
fullWidth={false}
>
<StyledComponent
component="dt"
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "Detail__DetailName-sc-16ypsyv-0",
"isStatic": false,
"lastClassName": "gRioSK",
"rules": Array [
"font-weight:var(--pf-global--FontWeight--bold);text-align:right;",
[Function],
],
},
"displayName": "Detail__DetailName",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "Detail__DetailName-sc-16ypsyv-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
fullWidth={false}
>
<Component
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
component="dt"
fullWidth={false}
>
<TextListItem
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
component="dt"
>
<dt
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
data-pf-content={true}
>
Name
</dt>
</TextListItem>
</Component>
</StyledComponent>
</Detail__DetailName>
<Detail__DetailValue
component="dd"
fullWidth={false}
>
<StyledComponent
component="dd"
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "Detail__DetailValue-sc-16ypsyv-1",
"isStatic": false,
"lastClassName": "yHlYM",
"rules": Array [
"word-break:break-all;",
[Function],
],
},
"displayName": "Detail__DetailValue",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "Detail__DetailValue-sc-16ypsyv-1",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
fullWidth={false}
>
<Component
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
component="dd"
fullWidth={false}
>
<TextListItem
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
component="dd"
>
<dd
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
data-pf-content={true}
>
jane brown
</dd>
</TextListItem>
</Component>
</StyledComponent>
</Detail__DetailValue>
</Detail>
</dl>
</TextList>
</DetailList>
</StyledComponent>
</DetailList>
</div>
</DataListCell>
<DataListCell
alignRight={false}
className=""
isFilled={true}
isIcon={false}
key="roles"
width={1}
>
<div
className="pf-c-data-list__cell"
>
<DetailList
stacked={true}
>
<StyledComponent
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "DetailList-sc-12g7m4-0",
"isStatic": false,
"lastClassName": "hndBXy",
"rules": Array [
"display:grid;grid-gap:20px;",
[Function],
],
},
"displayName": "DetailList",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "DetailList-sc-12g7m4-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
stacked={true}
>
<DetailList
className="DetailList-sc-12g7m4-0 hndBXy"
stacked={true}
>
<TextList
className="DetailList-sc-12g7m4-0 hndBXy"
component="dl"
>
<dl
className="DetailList-sc-12g7m4-0 hndBXy"
data-pf-content={true}
>
<Detail
fullWidth={false}
label="Team Roles"
value={
<ForwardRef>
<ForwardRef
isReadOnly={false}
onClick={[Function]}
>
Member
</ForwardRef>
</ForwardRef>
}
>
<Detail__DetailName
component="dt"
fullWidth={false}
>
<StyledComponent
component="dt"
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "Detail__DetailName-sc-16ypsyv-0",
"isStatic": false,
"lastClassName": "gRioSK",
"rules": Array [
"font-weight:var(--pf-global--FontWeight--bold);text-align:right;",
[Function],
],
},
"displayName": "Detail__DetailName",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "Detail__DetailName-sc-16ypsyv-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
fullWidth={false}
>
<Component
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
component="dt"
fullWidth={false}
>
<TextListItem
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
component="dt"
>
<dt
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
data-pf-content={true}
>
Team Roles
</dt>
</TextListItem>
</Component>
</StyledComponent>
</Detail__DetailName>
<Detail__DetailValue
component="dd"
fullWidth={false}
>
<StyledComponent
component="dd"
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "Detail__DetailValue-sc-16ypsyv-1",
"isStatic": false,
"lastClassName": "yHlYM",
"rules": Array [
"word-break:break-all;",
[Function],
],
},
"displayName": "Detail__DetailValue",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "Detail__DetailValue-sc-16ypsyv-1",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
fullWidth={false}
>
<Component
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
component="dd"
fullWidth={false}
>
<TextListItem
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
component="dd"
>
<dd
className="Detail__DetailValue-sc-16ypsyv-1 yHlYM"
data-pf-content={true}
>
<ChipGroup>
<StyledComponent
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "ChipGroup-sc-10zu8t0-0",
"isStatic": true,
"lastClassName": "vIGxT",
"rules": Array [
"--pf-c-chip-group--c-chip--MarginRight:10px;--pf-c-chip-group--c-chip--MarginBottom:10px;",
],
},
"displayName": "ChipGroup",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "ChipGroup-sc-10zu8t0-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
>
<ChipGroup
className="ChipGroup-sc-10zu8t0-0 vIGxT"
showOverflowAfter={null}
>
<ul
className="pf-c-chip-group ChipGroup-sc-10zu8t0-0 vIGxT"
>
<Chip
component="li"
isReadOnly={false}
key=".$3"
onClick={[Function]}
>
<StyledComponent
component="li"
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "Chip-sc-1rzr8oo-0",
"isStatic": false,
"lastClassName": "dgUGLg",
"rules": Array [
"--pf-c-chip--m-read-only--PaddingTop:3px;--pf-c-chip--m-read-only--PaddingRight:8px;--pf-c-chip--m-read-only--PaddingBottom:3px;--pf-c-chip--m-read-only--PaddingLeft:8px;& > .pf-c-button{padding:3px 8px;}",
[Function],
],
},
"displayName": "Chip",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "Chip-sc-1rzr8oo-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
isReadOnly={false}
onClick={[Function]}
>
<Chip
className="Chip-sc-1rzr8oo-0 dgUGLg"
closeBtnAriaLabel="close"
component="li"
isOverflowChip={false}
isReadOnly={false}
onClick={[Function]}
tooltipPosition="top"
>
<GenerateId
prefix="pf-random-id-"
>
<li
className="pf-c-chip Chip-sc-1rzr8oo-0 dgUGLg"
>
<span
className="pf-c-chip__text"
id="pf-random-id-0"
>
Member
</span>
<ChipButton
aria-labelledby="remove_pf-random-id-0 pf-random-id-0"
ariaLabel="close"
className=""
id="remove_pf-random-id-0"
onClick={[Function]}
>
<Button
aria-label="close"
aria-labelledby="remove_pf-random-id-0 pf-random-id-0"
className=""
component="button"
id="remove_pf-random-id-0"
isActive={false}
isBlock={false}
isDisabled={false}
isFocus={false}
isHover={false}
isInline={false}
onClick={[Function]}
type="button"
variant="plain"
>
<button
aria-disabled={null}
aria-label="close"
aria-labelledby="remove_pf-random-id-0 pf-random-id-0"
className="pf-c-button pf-m-plain"
disabled={false}
id="remove_pf-random-id-0"
onClick={[Function]}
tabIndex={null}
type="button"
>
<TimesCircleIcon
aria-hidden="true"
color="currentColor"
size="sm"
title={null}
>
<svg
aria-hidden="true"
aria-labelledby={null}
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 512 512"
width="1em"
>
<path
d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm121.6 313.1c4.7 4.7 4.7 12.3 0 17L338 377.6c-4.7 4.7-12.3 4.7-17 0L256 312l-65.1 65.6c-4.7 4.7-12.3 4.7-17 0L134.4 338c-4.7-4.7-4.7-12.3 0-17l65.6-65-65.6-65.1c-4.7-4.7-4.7-12.3 0-17l39.6-39.6c4.7-4.7 12.3-4.7 17 0l65 65.7 65.1-65.6c4.7-4.7 12.3-4.7 17 0l39.6 39.6c4.7 4.7 4.7 12.3 0 17L312 256l65.6 65.1z"
transform=""
/>
</svg>
</TimesCircleIcon>
</button>
</Button>
</ChipButton>
</li>
</GenerateId>
</Chip>
</StyledComponent>
</Chip>
</ul>
</ChipGroup>
</StyledComponent>
</ChipGroup>
</dd>
</TextListItem>
</Component>
</StyledComponent>
</Detail__DetailValue>
</Detail>
</dl>
</TextList>
</DetailList>
</StyledComponent>
</DetailList>
</div>
</DataListCell>
</div>
</DataListItemCells>
</StyledComponent>
</OrganizationAccessItem__DataListItemCells>
</div>
</DataListItemRow>
</li>
</DataListItem>
</OrganizationAccessItem>
`;

View File

@@ -0,0 +1,3 @@
export { default as OrganizationAccess } from './OrganizationAccess';
export { default as OrganizationAccessItem } from './OrganizationAccessItem';
export { default as DeleteRoleConfirmationModal } from './DeleteRoleConfirmationModal';

View File

@@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
Card,
CardHeader,
CardBody,
Tooltip,
} from '@patternfly/react-core';
import { Config } from '../../../contexts/Config';
import CardCloseButton from '../../../components/CardCloseButton';
import OrganizationForm from '../shared/OrganizationForm';
import { OrganizationsAPI } from '../../../api';
class OrganizationAdd extends React.Component {
constructor (props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.state = { error: '' };
}
async handleSubmit (values, groupsToAssociate) {
const { history } = this.props;
try {
const { data: response } = await OrganizationsAPI.create(values);
await Promise.all(groupsToAssociate.map(id => OrganizationsAPI
.associateInstanceGroup(response.id, id)));
history.push(`/organizations/${response.id}`);
} catch (error) {
this.setState({ error });
}
}
handleCancel () {
const { history } = this.props;
history.push('/organizations');
}
render () {
const { error } = this.state;
const { i18n } = this.props;
return (
<PageSection>
<Card>
<CardHeader className="at-u-textRight">
<Tooltip
content={i18n._(t`Close`)}
position="top"
>
<CardCloseButton onClick={this.handleCancel} />
</Tooltip>
</CardHeader>
<CardBody>
<Config>
{({ me }) => (
<OrganizationForm
handleSubmit={this.handleSubmit}
handleCancel={this.handleCancel}
me={me || {}}
/>
)}
</Config>
{error ? <div>error</div> : ''}
</CardBody>
</Card>
</PageSection>
);
}
}
OrganizationAdd.contextTypes = {
custom_virtualenvs: PropTypes.arrayOf(PropTypes.string)
};
export { OrganizationAdd as _OrganizationAdd };
export default withI18n()(withRouter(OrganizationAdd));

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../../../../testUtils/enzymeHelpers';
import OrganizationAdd from './OrganizationAdd';
import { OrganizationsAPI } from '../../../api';
jest.mock('../../../api');
describe('<OrganizationAdd />', () => {
test('handleSubmit should post to api', () => {
const wrapper = mountWithContexts(<OrganizationAdd />);
const updatedOrgData = {
name: 'new name',
description: 'new description',
custom_virtualenv: 'Buzz',
};
wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [], []);
expect(OrganizationsAPI.create).toHaveBeenCalledWith(updatedOrgData);
});
test('should navigate to organizations list when cancel is clicked', () => {
const history = {
push: jest.fn(),
};
const wrapper = mountWithContexts(
<OrganizationAdd />,
{ context: { router: { history } } }
);
expect(history.push).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(history.push).toHaveBeenCalledWith('/organizations');
});
test('should navigate to organizations list when close (x) is clicked', () => {
const history = {
push: jest.fn(),
};
const wrapper = mountWithContexts(
<OrganizationAdd />,
{ context: { router: { history } } }
);
expect(history.push).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Close"]').prop('onClick')();
expect(history.push).toHaveBeenCalledWith('/organizations');
});
test('successful form submission should trigger redirect', async (done) => {
const history = {
push: jest.fn(),
};
const orgData = {
name: 'new name',
description: 'new description',
custom_virtualenv: 'Buzz',
};
OrganizationsAPI.create.mockResolvedValueOnce({
data: {
id: 5,
related: {
instance_groups: '/bar',
},
...orgData,
}
});
const wrapper = mountWithContexts(
<OrganizationAdd />,
{ context: { router: { history } } }
);
await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [3], []);
expect(history.push).toHaveBeenCalledWith('/organizations/5');
done();
});
test('handleSubmit should post instance groups', async (done) => {
const orgData = {
name: 'new name',
description: 'new description',
custom_virtualenv: 'Buzz',
};
OrganizationsAPI.create.mockResolvedValueOnce({
data: {
id: 5,
related: {
instance_groups: '/api/v2/organizations/5/instance_groups',
},
...orgData,
}
});
const wrapper = mountWithContexts(<OrganizationAdd />);
await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [3], []);
expect(OrganizationsAPI.associateInstanceGroup)
.toHaveBeenCalledWith(5, 3);
done();
});
test('AnsibleSelect component renders if there are virtual environments', () => {
const config = {
custom_virtualenvs: ['foo', 'bar'],
};
const wrapper = mountWithContexts(
<OrganizationAdd />,
{ context: { config } }
).find('AnsibleSelect');
expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
});
test('AnsibleSelect component does not render if there are 0 virtual environments', () => {
const config = {
custom_virtualenvs: [],
};
const wrapper = mountWithContexts(
<OrganizationAdd />,
{ context: { config } }
).find('AnsibleSelect');
expect(wrapper.find('FormSelect')).toHaveLength(0);
});
});

View File

@@ -0,0 +1 @@
export { default } from './OrganizationAdd';

View File

@@ -0,0 +1,133 @@
import React, { Component } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CardBody as PFCardBody, Button } from '@patternfly/react-core';
import styled from 'styled-components';
import { DetailList, Detail } from '../../../components/DetailList';
import { ChipGroup, Chip } from '../../../components/Chip';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import { OrganizationsAPI } from '../../../api';
const CardBody = styled(PFCardBody)`
padding-top: 20px;
`;
class OrganizationDetail extends Component {
constructor (props) {
super(props);
this.state = {
contentError: false,
contentLoading: true,
instanceGroups: [],
};
this.loadInstanceGroups = this.loadInstanceGroups.bind(this);
}
componentDidMount () {
this.loadInstanceGroups();
}
async loadInstanceGroups () {
const { match: { params: { id } } } = this.props;
this.setState({ contentLoading: true });
try {
const { data: { results = [] } } = await OrganizationsAPI.readInstanceGroups(id);
this.setState({ instanceGroups: [...results] });
} catch (err) {
this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
}
}
render () {
const {
contentLoading,
contentError,
instanceGroups,
} = this.state;
const {
organization: {
name,
description,
custom_virtualenv,
max_hosts,
created,
modified,
summary_fields
},
match,
i18n
} = this.props;
if (contentLoading) {
return (<ContentLoading />);
}
if (contentError) {
return (<ContentError />);
}
return (
<CardBody>
<DetailList>
<Detail
label={i18n._(t`Name`)}
value={name}
/>
<Detail
label={i18n._(t`Description`)}
value={description}
/>
<Detail
label={i18n._(t`Max Hosts`)}
value={`${max_hosts}`}
/>
<Detail
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
<Detail
label={i18n._(t`Created`)}
value={created}
/>
<Detail
label={i18n._(t`Last Modified`)}
value={modified}
/>
{(instanceGroups && instanceGroups.length > 0) && (
<Detail
fullWidth
label={i18n._(t`Instance Groups`)}
value={(
<ChipGroup showOverflowAfter={5}>
{instanceGroups.map(ig => (
<Chip key={ig.id} isReadOnly>{ig.name}</Chip>
))}
</ChipGroup>
)}
/>
)}
</DetailList>
{summary_fields.user_capabilities.edit && (
<div css="margin-top: 10px; text-align: right;">
<Button
component={Link}
to={`/organizations/${match.params.id}/edit`}
>
{i18n._(t`Edit`)}
</Button>
</div>
)}
</CardBody>
);
}
}
export default withI18n()(withRouter(OrganizationDetail));

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../../../../testUtils/enzymeHelpers';
import OrganizationDetail from './OrganizationDetail';
import { OrganizationsAPI } from '../../../api';
jest.mock('../../../api');
describe('<OrganizationDetail />', () => {
const mockOrganization = {
name: 'Foo',
description: 'Bar',
custom_virtualenv: 'Fizz',
max_hosts: '0',
created: 'Bat',
modified: 'Boo',
summary_fields: {
user_capabilities: {
edit: true
}
}
};
const mockInstanceGroups = {
data: {
results: [
{ name: 'One', id: 1 },
{ name: 'Two', id: 2 }
]
}
};
beforeEach(() => {
OrganizationsAPI.readInstanceGroups.mockResolvedValue(mockInstanceGroups);
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders succesfully', () => {
mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
});
test('should request instance groups from api', () => {
mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
});
test('should handle setting instance groups to state', async (done) => {
const wrapper = mountWithContexts(
<OrganizationDetail organization={mockOrganization} />
);
const component = await waitForElement(wrapper, 'OrganizationDetail');
expect(component.state().instanceGroups).toEqual(mockInstanceGroups.data.results);
done();
});
test('should render Details', async (done) => {
const wrapper = mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
const testParams = [
{ label: 'Name', value: 'Foo' },
{ label: 'Description', value: 'Bar' },
{ label: 'Ansible Environment', value: 'Fizz' },
{ label: 'Created', value: 'Bat' },
{ label: 'Last Modified', value: 'Boo' },
{ label: 'Max Hosts', value: '0' },
];
// eslint-disable-next-line no-restricted-syntax
for (const { label, value } of testParams) {
// eslint-disable-next-line no-await-in-loop
const detail = await waitForElement(wrapper, `Detail[label="${label}"]`);
expect(detail.find('dt').text()).toBe(label);
expect(detail.find('dd').text()).toBe(value);
}
done();
});
test('should show edit button for users with edit permission', async (done) => {
const wrapper = mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
const editButton = await waitForElement(wrapper, 'OrganizationDetail Button');
expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe('/organizations/undefined/edit');
done();
});
test('should hide edit button for users without edit permission', async (done) => {
const readOnlyOrg = { ...mockOrganization };
readOnlyOrg.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts(<OrganizationDetail organization={readOnlyOrg} />);
await waitForElement(wrapper, 'OrganizationDetail');
expect(wrapper.find('OrganizationDetail Button').length).toBe(0);
done();
});
});

View File

@@ -0,0 +1 @@
export { default } from './OrganizationDetail';

View File

@@ -0,0 +1,92 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core';
import OrganizationForm from '../shared/OrganizationForm';
import { Config } from '../../../contexts/Config';
import { OrganizationsAPI } from '../../../api';
class OrganizationEdit extends Component {
constructor (props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.submitInstanceGroups = this.submitInstanceGroups.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleSuccess = this.handleSuccess.bind(this);
this.state = {
error: '',
};
}
async handleSubmit (values, groupsToAssociate, groupsToDisassociate) {
const { organization } = this.props;
try {
await OrganizationsAPI.update(organization.id, values);
await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate);
this.handleSuccess();
} catch (err) {
this.setState({ error: err });
}
}
handleCancel () {
const { organization: { id }, history } = this.props;
history.push(`/organizations/${id}`);
}
handleSuccess () {
const { organization: { id }, history } = this.props;
history.push(`/organizations/${id}`);
}
async submitInstanceGroups (groupsToAssociate, groupsToDisassociate) {
const { organization } = this.props;
try {
await Promise.all(
groupsToAssociate.map(id => OrganizationsAPI.associateInstanceGroup(organization.id, id))
);
await Promise.all(
groupsToDisassociate.map(
id => OrganizationsAPI.disassociateInstanceGroup(organization.id, id)
)
);
} catch (err) {
this.setState({ error: err });
}
}
render () {
const { organization } = this.props;
const { error } = this.state;
return (
<CardBody>
<Config>
{({ me }) => (
<OrganizationForm
organization={organization}
handleSubmit={this.handleSubmit}
handleCancel={this.handleCancel}
me={me || {}}
/>
)}
</Config>
{error ? <div>error</div> : null}
</CardBody>
);
}
}
OrganizationEdit.propTypes = {
organization: PropTypes.shape().isRequired,
};
OrganizationEdit.contextTypes = {
custom_virtualenvs: PropTypes.arrayOf(PropTypes.string)
};
export { OrganizationEdit as _OrganizationEdit };
export default withRouter(OrganizationEdit);

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import OrganizationEdit from './OrganizationEdit';
import { OrganizationsAPI } from '../../../api';
jest.mock('../../../api');
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
describe('<OrganizationEdit />', () => {
let api;
const mockData = {
name: 'Foo',
description: 'Bar',
custom_virtualenv: 'Fizz',
id: 1,
related: {
instance_groups: '/api/v2/organizations/1/instance_groups'
}
};
beforeEach(() => {
api = {
getInstanceGroups: jest.fn(),
updateOrganizationDetails: jest.fn(),
associateInstanceGroup: jest.fn(),
disassociate: jest.fn(),
};
});
test('handleSubmit should call api update', () => {
const wrapper = mountWithContexts(
<OrganizationEdit
organization={mockData}
/>, { context: { network: {
api,
} } }
);
const updatedOrgData = {
name: 'new name',
description: 'new description',
custom_virtualenv: 'Buzz',
};
wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [], []);
expect(OrganizationsAPI.update).toHaveBeenCalledWith(1, updatedOrgData);
});
test('handleSubmit associates and disassociates instance groups', async () => {
const wrapper = mountWithContexts(
<OrganizationEdit
organization={mockData}
/>, { context: { network: {
api,
} } }
);
const updatedOrgData = {
name: 'new name',
description: 'new description',
custom_virtualenv: 'Buzz',
};
wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [3, 4], [2]);
await sleep(1);
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3);
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4);
expect(OrganizationsAPI.disassociateInstanceGroup).toHaveBeenCalledWith(1, 2);
});
test('should navigate to organization detail when cancel is clicked', () => {
const history = {
push: jest.fn(),
};
const wrapper = mountWithContexts(
<OrganizationEdit
organization={mockData}
/>, { context: {
network: {
api: { api },
},
router: { history }
} }
);
expect(history.push).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(history.push).toHaveBeenCalledWith('/organizations/1');
});
});

View File

@@ -0,0 +1 @@
export { default } from './OrganizationEdit';

View File

@@ -0,0 +1,209 @@
import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Card,
PageSection,
PageSectionVariants,
} from '@patternfly/react-core';
import PaginatedDataList, {
ToolbarDeleteButton,
ToolbarAddButton
} from '../../../components/PaginatedDataList';
import DataListToolbar from '../../../components/DataListToolbar';
import OrganizationListItem from './OrganizationListItem';
import AlertModal from '../../../components/AlertModal';
import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs';
import { OrganizationsAPI } from '../../../api';
const QS_CONFIG = getQSConfig('organization', {
page: 1,
page_size: 5,
order_by: 'name',
});
class OrganizationsList extends Component {
constructor (props) {
super(props);
this.state = {
contentLoading: true,
contentError: false,
deletionError: false,
organizations: [],
selected: [],
itemCount: 0,
actions: null,
};
this.handleSelectAll = this.handleSelectAll.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleOrgDelete = this.handleOrgDelete.bind(this);
this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
this.loadOrganizations = this.loadOrganizations.bind(this);
}
componentDidMount () {
this.loadOrganizations();
}
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.loadOrganizations();
}
}
handleSelectAll (isSelected) {
const { organizations } = this.state;
const selected = isSelected ? [...organizations] : [];
this.setState({ selected });
}
handleSelect (row) {
const { selected } = this.state;
if (selected.some(s => s.id === row.id)) {
this.setState({ selected: selected.filter(s => s.id !== row.id) });
} else {
this.setState({ selected: selected.concat(row) });
}
}
handleDeleteErrorClose () {
this.setState({ deletionError: false });
}
async handleOrgDelete () {
const { selected } = this.state;
this.setState({ contentLoading: true, deletionError: false });
try {
await Promise.all(selected.map((org) => OrganizationsAPI.destroy(org.id)));
} catch (err) {
this.setState({ deletionError: true });
} finally {
await this.loadOrganizations();
}
}
async loadOrganizations () {
const { location } = this.props;
const { actions: cachedActions } = this.state;
const params = parseNamespacedQueryString(QS_CONFIG, location.search);
let optionsPromise;
if (cachedActions) {
optionsPromise = Promise.resolve({ data: { actions: cachedActions } });
} else {
optionsPromise = OrganizationsAPI.readOptions();
}
const promises = Promise.all([
OrganizationsAPI.read(params),
optionsPromise,
]);
this.setState({ contentError: false, contentLoading: true });
try {
const [{ data: { count, results } }, { data: { actions } }] = await promises;
this.setState({
actions,
itemCount: count,
organizations: results,
selected: [],
});
} catch (err) {
this.setState(({ contentError: true }));
} finally {
this.setState({ contentLoading: false });
}
}
render () {
const {
medium,
} = PageSectionVariants;
const {
actions,
itemCount,
contentError,
contentLoading,
deletionError,
selected,
organizations,
} = this.state;
const { match, i18n } = this.props;
const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected = selected.length === organizations.length;
return (
<Fragment>
<PageSection variant={medium}>
<Card>
<PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={organizations}
itemCount={itemCount}
itemName="organization"
qsConfig={QS_CONFIG}
toolbarColumns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
{ name: i18n._(t`Modified`), key: 'modified', isSortable: true, isNumeric: true },
{ name: i18n._(t`Created`), key: 'created', isSortable: true, isNumeric: true },
]}
renderToolbar={(props) => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={this.handleSelectAll}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={this.handleOrgDelete}
itemsToDelete={selected}
itemName="Organization"
/>,
canAdd
? <ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
: null,
]}
/>
)}
renderItem={(o) => (
<OrganizationListItem
key={o.id}
organization={o}
detailUrl={`${match.url}/${o.id}`}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => this.handleSelect(o)}
/>
)}
emptyStateControls={
canAdd ? <ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
: null
}
/>
</Card>
</PageSection>
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete one or more organizations.`)}
</AlertModal>
</Fragment>
);
}
}
export { OrganizationsList as _OrganizationsList };
export default withI18n()(withRouter(OrganizationsList));

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../../../../testUtils/enzymeHelpers';
import OrganizationsList, { _OrganizationsList } from './OrganizationList';
import { OrganizationsAPI } from '../../../api';
jest.mock('../../../api');
const mockAPIOrgsList = {
data: {
count: 3,
results: [{
name: 'Organization 0',
id: 1,
url: '/organizations/1',
summary_fields: {
related_field_counts: {
teams: 3,
users: 4
},
user_capabilities: {
delete: true
}
},
},
{
name: 'Organization 1',
id: 2,
url: '/organizations/2',
summary_fields: {
related_field_counts: {
teams: 2,
users: 5
},
user_capabilities: {
delete: true
}
},
},
{
name: 'Organization 2',
id: 3,
url: '/organizations/3',
summary_fields: {
related_field_counts: {
teams: 5,
users: 6
},
user_capabilities: {
delete: true
}
},
}]
},
isModalOpen: false,
warningTitle: 'title',
warningMsg: 'message'
};
describe('<OrganizationsList />', () => {
let wrapper;
test('initially renders succesfully', () => {
mountWithContexts(<OrganizationsList />);
});
test('Puts 1 selected Org in state when handleSelect is called.', () => {
wrapper = mountWithContexts(<OrganizationsList />).find('OrganizationsList');
wrapper.setState({
organizations: mockAPIOrgsList.data.results,
itemCount: 3,
isInitialized: true
});
wrapper.update();
expect(wrapper.state('selected').length).toBe(0);
wrapper.instance().handleSelect(mockAPIOrgsList.data.results.slice(0, 1));
expect(wrapper.state('selected').length).toBe(1);
});
test('Puts all Orgs in state when handleSelectAll is called.', () => {
wrapper = mountWithContexts(<OrganizationsList />);
const list = wrapper.find('OrganizationsList');
list.setState({
organizations: mockAPIOrgsList.data.results,
itemCount: 3,
isInitialized: true
});
expect(list.state('selected').length).toBe(0);
list.instance().handleSelectAll(true);
wrapper.update();
expect(list.state('selected').length)
.toEqual(list.state('organizations').length);
});
test('api is called to delete Orgs for each org in selected.', () => {
wrapper = mountWithContexts(<OrganizationsList />);
const component = wrapper.find('OrganizationsList');
wrapper.find('OrganizationsList').setState({
organizations: mockAPIOrgsList.data.results,
itemCount: 3,
isInitialized: true,
isModalOpen: mockAPIOrgsList.isModalOpen,
selected: mockAPIOrgsList.data.results
});
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
expect(OrganizationsAPI.destroy).toHaveBeenCalledTimes(component.state('selected').length);
});
test('call loadOrganizations after org(s) have been deleted', () => {
const fetchOrgs = jest.spyOn(_OrganizationsList.prototype, 'loadOrganizations');
const event = { preventDefault: () => { } };
wrapper = mountWithContexts(<OrganizationsList />);
wrapper.find('OrganizationsList').setState({
organizations: mockAPIOrgsList.data.results,
itemCount: 3,
isInitialized: true,
selected: mockAPIOrgsList.data.results.slice(0, 1)
});
const component = wrapper.find('OrganizationsList');
component.instance().handleOrgDelete(event);
expect(fetchOrgs).toBeCalled();
});
test('error is shown when org not successfully deleted from api', async () => {
OrganizationsAPI.destroy = () => Promise.reject();
wrapper = mountWithContexts(<OrganizationsList />);
wrapper.find('OrganizationsList').setState({
organizations: mockAPIOrgsList.data.results,
itemCount: 3,
isInitialized: true,
selected: mockAPIOrgsList.data.results.slice(0, 1)
});
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
await waitForElement(wrapper, 'Modal', (el) => el.props().isOpen === true && el.props().title === 'Error!');
});
});

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Badge as PFBadge,
DataListItem,
DataListItemRow,
DataListItemCells,
DataListCheck,
DataListCell as PFDataListCell,
} from '@patternfly/react-core';
import {
Link
} from 'react-router-dom';
import styled from 'styled-components';
import VerticalSeparator from '../../../components/VerticalSeparator';
import { Organization } from '../../../types';
const Badge = styled(PFBadge)`
align-items: center;
display: flex;
justify-content: center;
margin-left: 10px;
`;
const ListGroup = styled.span`
display: flex;
margin-left: 40px;
@media screen and (min-width: 768px) {
margin-left: 20px;
&:first-of-type {
margin-left: 0;
}
}
`;
const DataListCell = styled(PFDataListCell)`
display: flex;
align-items: center;
padding-bottom: ${props => (props.righthalf ? '16px' : '8px')};
@media screen and (min-width: 768px) {
padding-bottom: 0;
}
`;
class OrganizationListItem extends React.Component {
static propTypes = {
organization: Organization.isRequired,
detailUrl: string.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired
}
render () {
const {
organization,
isSelected,
onSelect,
detailUrl,
i18n
} = this.props;
const labelId = `check-action-${organization.id}`;
return (
<DataListItem key={organization.id} aria-labelledby={labelId}>
<DataListItemRow>
<DataListCheck
id={`select-organization-${organization.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells dataListCells={[
<DataListCell key="divider">
<VerticalSeparator />
<span id={labelId}>
<Link
to={`${detailUrl}`}
>
<b>{organization.name}</b>
</Link>
</span>
</DataListCell>,
<DataListCell key="related-field-counts" righthalf="true" width={2}>
<ListGroup>
{i18n._(t`Members`)}
<Badge isRead>
{organization.summary_fields.related_field_counts.users}
</Badge>
</ListGroup>
<ListGroup>
{i18n._(t`Teams`)}
<Badge isRead>
{organization.summary_fields.related_field_counts.teams}
</Badge>
</ListGroup>
</DataListCell>
]}
/>
</DataListItemRow>
</DataListItem>
);
}
}
export default withI18n()(OrganizationListItem);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { I18nProvider } from '@lingui/react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import OrganizationListItem from './OrganizationListItem';
describe('<OrganizationListItem />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<I18nProvider>
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
<OrganizationListItem
organization={{
id: 1,
name: 'Org',
summary_fields: { related_field_counts: {
users: 1,
teams: 1,
} }
}}
detailUrl="/organization/1"
isSelected
onSelect={() => {}}
/>
</MemoryRouter>
</I18nProvider>
);
});
});

View File

@@ -0,0 +1,2 @@
export { default as OrganizationList } from './OrganizationList';
export { default as OrganizationListItem } from './OrganizationListItem';

View File

@@ -0,0 +1,195 @@
import React, { Component, Fragment } from 'react';
import { number, shape, string, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '../../../components/AlertModal';
import PaginatedDataList from '../../../components/PaginatedDataList';
import NotificationListItem from '../../../components/NotificationsList/NotificationListItem';
import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs';
import { OrganizationsAPI } from '../../../api';
const QS_CONFIG = getQSConfig('notification', {
page: 1,
page_size: 5,
order_by: 'name',
});
const COLUMNS = [
{ key: 'name', name: 'Name', isSortable: true },
{ key: 'modified', name: 'Modified', isSortable: true, isNumeric: true },
{ key: 'created', name: 'Created', isSortable: true, isNumeric: true },
];
class OrganizationNotifications extends Component {
constructor (props) {
super(props);
this.state = {
contentError: false,
contentLoading: true,
toggleError: false,
toggleLoading: false,
itemCount: 0,
notifications: [],
successTemplateIds: [],
errorTemplateIds: [],
};
this.handleNotificationToggle = this.handleNotificationToggle.bind(this);
this.handleNotificationErrorClose = this.handleNotificationErrorClose.bind(this);
this.loadNotifications = this.loadNotifications.bind(this);
}
componentDidMount () {
this.loadNotifications();
}
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.loadNotifications();
}
}
async loadNotifications () {
const { id, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ contentError: false, contentLoading: true });
try {
const {
data: {
count: itemCount = 0,
results: notifications = [],
}
} = await OrganizationsAPI.readNotificationTemplates(id, params);
let idMatchParams;
if (notifications.length > 0) {
idMatchParams = { id__in: notifications.map(n => n.id).join(',') };
} else {
idMatchParams = {};
}
const [
{ data: successTemplates },
{ data: errorTemplates },
] = await Promise.all([
OrganizationsAPI.readNotificationTemplatesSuccess(id, idMatchParams),
OrganizationsAPI.readNotificationTemplatesError(id, idMatchParams),
]);
this.setState({
itemCount,
notifications,
successTemplateIds: successTemplates.results.map(s => s.id),
errorTemplateIds: errorTemplates.results.map(e => e.id),
});
} catch {
this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
}
}
async handleNotificationToggle (notificationId, isCurrentlyOn, status) {
const { id } = this.props;
let stateArrayName;
if (status === 'success') {
stateArrayName = 'successTemplateIds';
} else {
stateArrayName = 'errorTemplateIds';
}
let stateUpdateFunction;
if (isCurrentlyOn) {
// when switching off, remove the toggled notification id from the array
stateUpdateFunction = (prevState) => ({
[stateArrayName]: prevState[stateArrayName].filter(i => i !== notificationId)
});
} else {
// when switching on, add the toggled notification id to the array
stateUpdateFunction = (prevState) => ({
[stateArrayName]: prevState[stateArrayName].concat(notificationId)
});
}
this.setState({ toggleLoading: true });
try {
await OrganizationsAPI.updateNotificationTemplateAssociation(
id,
notificationId,
status,
!isCurrentlyOn
);
this.setState(stateUpdateFunction);
} catch (err) {
this.setState({ toggleError: true });
} finally {
this.setState({ toggleLoading: false });
}
}
handleNotificationErrorClose () {
this.setState({ toggleError: false });
}
render () {
const { canToggleNotifications, i18n } = this.props;
const {
contentError,
contentLoading,
toggleError,
toggleLoading,
itemCount,
notifications,
successTemplateIds,
errorTemplateIds,
} = this.state;
return (
<Fragment>
<PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={notifications}
itemCount={itemCount}
itemName="notification"
qsConfig={QS_CONFIG}
toolbarColumns={COLUMNS}
renderItem={(notification) => (
<NotificationListItem
key={notification.id}
notification={notification}
detailUrl={`/notifications/${notification.id}`}
canToggleNotifications={canToggleNotifications && !toggleLoading}
toggleNotification={this.handleNotificationToggle}
errorTurnedOn={errorTemplateIds.includes(notification.id)}
successTurnedOn={successTemplateIds.includes(notification.id)}
/>
)}
/>
<AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={toggleError && !toggleLoading}
onClose={this.handleNotificationErrorClose}
>
{i18n._(t`Failed to toggle notification.`)}
</AlertModal>
</Fragment>
);
}
}
OrganizationNotifications.propTypes = {
id: number.isRequired,
canToggleNotifications: bool.isRequired,
location: shape({
search: string.isRequired,
}).isRequired,
};
export { OrganizationNotifications as _OrganizationNotifications };
export default withI18n()(withRouter(OrganizationNotifications));

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import OrganizationNotifications from './OrganizationNotifications';
import { sleep } from '../../../../testUtils/testUtils';
import { OrganizationsAPI } from '../../../api';
jest.mock('../../../api');
describe('<OrganizationNotifications />', () => {
let data;
beforeEach(() => {
data = {
count: 2,
results: [{
id: 1,
name: 'Notification one',
url: '/api/v2/notification_templates/1/',
notification_type: 'email',
}, {
id: 2,
name: 'Notification two',
url: '/api/v2/notification_templates/2/',
notification_type: 'email',
}]
};
OrganizationsAPI.readNotificationTemplates.mockReturnValue({ data });
OrganizationsAPI.readNotificationTemplatesSuccess.mockReturnValue({
data: { results: [{ id: 1 }] },
});
OrganizationsAPI.readNotificationTemplatesError.mockReturnValue({
data: { results: [{ id: 2 }] },
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders succesfully', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />
);
await sleep(0);
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
test('should render list fetched of items', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />
);
await sleep(0);
wrapper.update();
expect(OrganizationsAPI.readNotificationTemplates).toHaveBeenCalled();
expect(wrapper.find('OrganizationNotifications').state('notifications'))
.toEqual(data.results);
const items = wrapper.find('NotificationListItem');
expect(items).toHaveLength(2);
expect(items.at(0).prop('successTurnedOn')).toEqual(true);
expect(items.at(0).prop('errorTurnedOn')).toEqual(false);
expect(items.at(1).prop('successTurnedOn')).toEqual(false);
expect(items.at(1).prop('errorTurnedOn')).toEqual(true);
});
test('should enable success notification', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('successTemplateIds')
).toEqual([1]);
const items = wrapper.find('NotificationListItem');
items.at(1).find('Switch').at(0).prop('onChange')();
expect(OrganizationsAPI.updateNotificationTemplateAssociation).toHaveBeenCalledWith(1, 2, 'success', true);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('successTemplateIds')
).toEqual([1, 2]);
});
test('should enable error notification', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('errorTemplateIds')
).toEqual([2]);
const items = wrapper.find('NotificationListItem');
items.at(0).find('Switch').at(1).prop('onChange')();
expect(OrganizationsAPI.updateNotificationTemplateAssociation).toHaveBeenCalledWith(1, 1, 'error', true);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('errorTemplateIds')
).toEqual([2, 1]);
});
test('should disable success notification', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('successTemplateIds')
).toEqual([1]);
const items = wrapper.find('NotificationListItem');
items.at(0).find('Switch').at(0).prop('onChange')();
expect(OrganizationsAPI.updateNotificationTemplateAssociation).toHaveBeenCalledWith(1, 1, 'success', false);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('successTemplateIds')
).toEqual([]);
});
test('should disable error notification', async () => {
const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('errorTemplateIds')
).toEqual([2]);
const items = wrapper.find('NotificationListItem');
items.at(1).find('Switch').at(1).prop('onChange')();
expect(OrganizationsAPI.updateNotificationTemplateAssociation).toHaveBeenCalledWith(1, 2, 'error', false);
await sleep(0);
wrapper.update();
expect(
wrapper.find('OrganizationNotifications').state('errorTemplateIds')
).toEqual([]);
});
});

View File

@@ -0,0 +1 @@
export { default } from './OrganizationNotifications';

View File

@@ -0,0 +1,79 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import PaginatedDataList from '../../../components/PaginatedDataList';
import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs';
import { OrganizationsAPI } from '../../../api';
const QS_CONFIG = getQSConfig('team', {
page: 1,
page_size: 5,
order_by: 'name',
});
class OrganizationTeams extends React.Component {
constructor (props) {
super(props);
this.loadOrganizationTeamsList = this.loadOrganizationTeamsList.bind(this);
this.state = {
contentError: false,
contentLoading: true,
itemCount: 0,
teams: [],
};
}
componentDidMount () {
this.loadOrganizationTeamsList();
}
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.loadOrganizationTeamsList();
}
}
async loadOrganizationTeamsList () {
const { id, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ contentLoading: true, contentError: false });
try {
const {
data: { count = 0, results = [] },
} = await OrganizationsAPI.readTeams(id, params);
this.setState({
itemCount: count,
teams: results,
});
} catch {
this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
}
}
render () {
const { contentError, contentLoading, teams, itemCount } = this.state;
return (
<PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={teams}
itemCount={itemCount}
itemName="team"
qsConfig={QS_CONFIG}
/>
);
}
}
OrganizationTeams.propTypes = {
id: PropTypes.number.isRequired
};
export { OrganizationTeams as _OrganizationTeams };
export default withRouter(OrganizationTeams);

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { sleep } from '../../../../testUtils/testUtils';
import OrganizationTeams from './OrganizationTeams';
import { OrganizationsAPI } from '../../../api';
jest.mock('../../../api');
const listData = {
data: {
count: 7,
results: [
{ id: 1, name: 'one', url: '/org/team/1' },
{ id: 2, name: 'two', url: '/org/team/2' },
{ id: 3, name: 'three', url: '/org/team/3' },
{ id: 4, name: 'four', url: '/org/team/4' },
{ id: 5, name: 'five', url: '/org/team/5' },
]
}
};
describe('<OrganizationTeams />', () => {
beforeEach(() => {
OrganizationsAPI.readTeams.mockResolvedValue(listData);
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders succesfully', () => {
shallow(
<OrganizationTeams
id={1}
searchString=""
location={{ search: '', pathname: '/organizations/1/teams' }}
/>
);
});
test('should load teams on mount', () => {
mountWithContexts(
<OrganizationTeams
id={1}
searchString=""
/>
).find('OrganizationTeams');
expect(OrganizationsAPI.readTeams).toHaveBeenCalledWith(1, {
page: 1,
page_size: 5,
order_by: 'name',
});
});
test('should pass fetched teams to PaginatedDatalist', async () => {
const wrapper = mountWithContexts(
<OrganizationTeams
id={1}
searchString=""
/>
);
await sleep(0);
wrapper.update();
const list = wrapper.find('PaginatedDataList');
expect(list.prop('items')).toEqual(listData.data.results);
expect(list.prop('itemCount')).toEqual(listData.data.count);
expect(list.prop('qsConfig')).toEqual({
namespace: 'team',
defaultParams: {
page: 1,
page_size: 5,
order_by: 'name',
},
integerFields: ['page', 'page_size'],
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './OrganizationTeams';

View File

@@ -0,0 +1,92 @@
import React, { Component, Fragment } from 'react';
import { Route, withRouter, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import OrganizationsList from './OrganizationList/OrganizationList';
import OrganizationAdd from './OrganizationAdd/OrganizationAdd';
import Organization from './Organization';
class Organizations extends Component {
constructor (props) {
super(props);
const { i18n } = props;
this.state = {
breadcrumbConfig: {
'/organizations': i18n._(t`Organizations`),
'/organizations/add': i18n._(t`Create New Organization`)
}
};
}
setBreadcrumbConfig = (organization) => {
const { i18n } = this.props;
if (!organization) {
return;
}
const breadcrumbConfig = {
'/organizations': i18n._(t`Organizations`),
'/organizations/add': i18n._(t`Create New Organization`),
[`/organizations/${organization.id}`]: `${organization.name}`,
[`/organizations/${organization.id}/edit`]: i18n._(t`Edit Details`),
[`/organizations/${organization.id}/details`]: i18n._(t`Details`),
[`/organizations/${organization.id}/access`]: i18n._(t`Access`),
[`/organizations/${organization.id}/teams`]: i18n._(t`Teams`),
[`/organizations/${organization.id}/notifications`]: i18n._(t`Notifications`),
};
this.setState({ breadcrumbConfig });
}
render () {
const { match, history, location } = this.props;
const { breadcrumbConfig } = this.state;
return (
<Fragment>
<Breadcrumbs
breadcrumbConfig={breadcrumbConfig}
/>
<Switch>
<Route
path={`${match.path}/add`}
render={() => (
<OrganizationAdd />
)}
/>
<Route
path={`${match.path}/:id`}
render={() => (
<Config>
{({ me }) => (
<Organization
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
)}
/>
<Route
path={`${match.path}`}
render={() => (
<OrganizationsList />
)}
/>
</Switch>
</Fragment>
);
}
}
export { Organizations as _Organizations };
export default withI18n()(withRouter(Organizations));

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Organizations from './Organizations';
jest.mock('../../../src/api');
describe('<Organizations />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<Organizations
match={{ path: '/organizations', url: '/organizations' }}
location={{ search: '', pathname: '/organizations' }}
/>
);
});
});

View File

@@ -0,0 +1 @@
export { default } from './Organizations';

View File

@@ -0,0 +1,67 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon } from '@patternfly/react-icons';
import Lookup from '../../../components/Lookup';
import { InstanceGroupsAPI } from '../../../api';
const getInstanceGroups = async (params) => InstanceGroupsAPI.read(params);
class InstanceGroupsLookup extends React.Component {
render () {
const { value, tooltip, onChange, i18n } = this.props;
return (
<FormGroup
label={(
<Fragment>
{i18n._(t`Instance Groups`)}
{' '}
{
tooltip && (
<Tooltip
position="right"
content={tooltip}
>
<QuestionCircleIcon />
</Tooltip>
)
}
</Fragment>
)}
fieldId="org-instance-groups"
>
<Lookup
id="org-instance-groups"
lookupHeader={i18n._(t`Instance Groups`)}
name="instanceGroups"
value={value}
onLookupSave={onChange}
getItems={getInstanceGroups}
columns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
{ name: i18n._(t`Modified`), key: 'modified', isSortable: false, isNumeric: true },
{ name: i18n._(t`Created`), key: 'created', isSortable: false, isNumeric: true }
]}
sortedColumnKey="name"
/>
</FormGroup>
);
}
}
InstanceGroupsLookup.propTypes = {
value: PropTypes.arrayOf(PropTypes.object).isRequired,
tooltip: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
InstanceGroupsLookup.defaultProps = {
tooltip: '',
};
export default withI18n()(InstanceGroupsLookup);

View File

@@ -0,0 +1,212 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { QuestionCircleIcon } from '@patternfly/react-icons';
import { withRouter } from 'react-router-dom';
import { Formik, Field } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Tooltip,
Form,
FormGroup,
} from '@patternfly/react-core';
import { Config } from '../../../contexts/Config';
import FormRow from '../../../components/FormRow';
import FormField from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import AnsibleSelect from '../../../components/AnsibleSelect';
import InstanceGroupsLookup from './InstanceGroupsLookup';
import { OrganizationsAPI } from '../../../api';
import { required, minMaxValue } from '../../../util/validators';
class OrganizationForm extends Component {
constructor (props) {
super(props);
this.getRelatedInstanceGroups = this.getRelatedInstanceGroups.bind(this);
this.handleInstanceGroupsChange = this.handleInstanceGroupsChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
instanceGroups: [],
initialInstanceGroups: [],
formIsValid: true,
};
}
async componentDidMount () {
let instanceGroups = [];
if (!this.isEditingNewOrganization()) {
try {
instanceGroups = await this.getRelatedInstanceGroups();
} catch (err) {
this.setState({ error: err });
}
}
this.setState({
instanceGroups,
initialInstanceGroups: [...instanceGroups],
});
}
async getRelatedInstanceGroups () {
const {
organization: { id }
} = this.props;
const { data } = await OrganizationsAPI.readInstanceGroups(id);
return data.results;
}
isEditingNewOrganization () {
const { organization } = this.props;
return !organization.id;
}
handleInstanceGroupsChange (instanceGroups) {
this.setState({ instanceGroups });
}
handleSubmit (values) {
const { handleSubmit } = this.props;
const { instanceGroups, initialInstanceGroups } = this.state;
const initialIds = initialInstanceGroups.map(ig => ig.id);
const updatedIds = instanceGroups.map(ig => ig.id);
const groupsToAssociate = [...updatedIds]
.filter(x => !initialIds.includes(x));
const groupsToDisassociate = [...initialIds]
.filter(x => !updatedIds.includes(x));
if (typeof values.max_hosts !== 'number' || values.max_hosts === 'undefined') {
values.max_hosts = 0;
}
handleSubmit(values, groupsToAssociate, groupsToDisassociate);
}
render () {
const { organization, handleCancel, i18n, me } = this.props;
const { instanceGroups, formIsValid, error } = this.state;
const defaultVenv = '/venv/ansible/';
return (
<Formik
initialValues={{
name: organization.name,
description: organization.description,
custom_virtualenv: organization.custom_virtualenv || '',
max_hosts: organization.max_hosts || '0',
}}
onSubmit={this.handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
<FormField
id="org-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="org-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
<FormField
id="org-max_hosts"
name="max_hosts"
type="number"
label={
(
<Fragment>
{i18n._(t`Max Hosts`)}
{' '}
{(
<Tooltip
position="right"
content="The maximum number of hosts allowed to be managed by this organization. Value defaults to 0 which means no limit. Refer to the Ansible documentation for more details."
>
<QuestionCircleIcon />
</Tooltip>
)}
</Fragment>
)
}
validate={minMaxValue(0, 2147483647, i18n)}
me={me || {}}
isDisabled={!me.is_superuser}
/>
<Config>
{({ custom_virtualenvs }) => (
custom_virtualenvs && custom_virtualenvs.length > 1 && (
<Field
name="custom_virtualenv"
render={({ field }) => (
<FormGroup
fieldId="org-custom-virtualenv"
label={i18n._(t`Ansible Environment`)}
>
<AnsibleSelect
data={custom_virtualenvs}
defaultSelected={defaultVenv}
label={i18n._(t`Ansible Environment`)}
{...field}
/>
</FormGroup>
)}
/>
)
)}
</Config>
</FormRow>
<InstanceGroupsLookup
value={instanceGroups}
onChange={this.handleInstanceGroupsChange}
tooltip={i18n._(t`Select the Instance Groups for this Organization to run on.`)}
/>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
submitDisabled={!formIsValid}
/>
{error ? <div>error</div> : null}
</Form>
)}
/>
);
}
}
FormField.propTypes = {
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired
};
OrganizationForm.propTypes = {
organization: PropTypes.shape(),
handleSubmit: PropTypes.func.isRequired,
handleCancel: PropTypes.func.isRequired,
};
OrganizationForm.defaultProps = {
organization: {
name: '',
description: '',
max_hosts: '0',
custom_virtualenv: '',
},
};
OrganizationForm.contextTypes = {
custom_virtualenvs: PropTypes.arrayOf(PropTypes.string)
};
export { OrganizationForm as _OrganizationForm };
export default withI18n()(withRouter(OrganizationForm));

View File

@@ -0,0 +1,307 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { sleep } from '../../../../testUtils/testUtils';
import OrganizationForm from './OrganizationForm';
import { OrganizationsAPI } from '../../../api';
jest.mock('../../../api');
describe('<OrganizationForm />', () => {
const network = {};
const meConfig = {
me: {
is_superuser: false
}
};
const mockData = {
id: 1,
name: 'Foo',
description: 'Bar',
max_hosts: 1,
custom_virtualenv: 'Fizz',
related: {
instance_groups: '/api/v2/organizations/1/instance_groups'
}
};
afterEach(() => {
jest.clearAllMocks();
});
test('should request related instance groups from api', () => {
mountWithContexts(
(
<OrganizationForm
organization={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
me={meConfig.me}
/>
), {
context: { network },
}
);
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
});
test('componentDidMount should set instanceGroups to state', async () => {
const mockInstanceGroups = [
{ name: 'One', id: 1 },
{ name: 'Two', id: 2 }
];
OrganizationsAPI.readInstanceGroups.mockReturnValue({
data: {
results: mockInstanceGroups
}
});
const wrapper = mountWithContexts(
(
<OrganizationForm
organization={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
me={meConfig.me}
/>
), {
context: { network },
}
);
await sleep(0);
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalled();
expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual(mockInstanceGroups);
});
test('changing instance group successfully sets instanceGroups state', () => {
const wrapper = mountWithContexts(
<OrganizationForm
organization={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
me={meConfig.me}
/>
);
const lookup = wrapper.find('InstanceGroupsLookup');
expect(lookup.length).toBe(1);
lookup.prop('onChange')([
{
id: 1,
name: 'foo'
}
], 'instanceGroups');
expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual([
{
id: 1,
name: 'foo'
}
]);
});
test('changing inputs should update form values', () => {
const wrapper = mountWithContexts(
<OrganizationForm
organization={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
me={meConfig.me}
/>
);
const form = wrapper.find('Formik');
wrapper.find('input#org-name').simulate('change', {
target: { value: 'new foo', name: 'name' }
});
expect(form.state('values').name).toEqual('new foo');
wrapper.find('input#org-description').simulate('change', {
target: { value: 'new bar', name: 'description' }
});
expect(form.state('values').description).toEqual('new bar');
wrapper.find('input#org-max_hosts').simulate('change', {
target: { value: '134', name: 'max_hosts' }
});
expect(form.state('values').max_hosts).toEqual('134');
});
test('AnsibleSelect component renders if there are virtual environments', () => {
const config = {
custom_virtualenvs: ['foo', 'bar'],
};
const wrapper = mountWithContexts(
(
<OrganizationForm
organization={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
me={meConfig.me}
/>
), {
context: { config },
}
);
expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
});
test('calls handleSubmit when form submitted', async () => {
const handleSubmit = jest.fn();
const wrapper = mountWithContexts(
<OrganizationForm
organization={mockData}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
me={meConfig.me}
/>
);
expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1);
expect(handleSubmit).toHaveBeenCalledWith({
name: 'Foo',
description: 'Bar',
max_hosts: 1,
custom_virtualenv: 'Fizz',
}, [], []);
});
test('handleSubmit associates and disassociates instance groups', async () => {
const mockInstanceGroups = [
{ name: 'One', id: 1 },
{ name: 'Two', id: 2 }
];
OrganizationsAPI.readInstanceGroups.mockReturnValue({
data: {
results: mockInstanceGroups
}
});
const mockDataForm = {
name: 'Foo',
description: 'Bar',
max_hosts: 1,
custom_virtualenv: 'Fizz',
};
const handleSubmit = jest.fn();
OrganizationsAPI.update.mockResolvedValue(1, mockDataForm);
OrganizationsAPI.associateInstanceGroup.mockResolvedValue('done');
OrganizationsAPI.disassociateInstanceGroup.mockResolvedValue('done');
const wrapper = mountWithContexts(
(
<OrganizationForm
organization={mockData}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
me={meConfig.me}
/>
), {
context: { network }
}
);
await sleep(0);
wrapper.find('InstanceGroupsLookup').prop('onChange')([
{ name: 'One', id: 1 },
{ name: 'Three', id: 3 }
], 'instanceGroups');
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(0);
expect(handleSubmit).toHaveBeenCalledWith(mockDataForm, [3], [2]);
});
test('handleSubmit is called with max_hosts value if it is in range', async () => {
const handleSubmit = jest.fn();
// normal mount
const wrapper = mountWithContexts(
<OrganizationForm
organization={mockData}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
me={meConfig.me}
/>
);
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(0);
expect(handleSubmit).toHaveBeenCalledWith({
name: 'Foo',
description: 'Bar',
max_hosts: 1,
custom_virtualenv: 'Fizz',
}, [], []);
});
test('handleSubmit does not get called if max_hosts value is out of range', async () => {
const handleSubmit = jest.fn();
// not mount with Negative value
const mockDataNegative = JSON.parse(JSON.stringify(mockData));
mockDataNegative.max_hosts = -5;
const wrapper1 = mountWithContexts(
<OrganizationForm
organization={mockDataNegative}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
me={meConfig.me}
/>
);
wrapper1.find('button[aria-label="Save"]').simulate('click');
await sleep(0);
expect(handleSubmit).not.toHaveBeenCalled();
// not mount with Out of Range value
const mockDataOoR = JSON.parse(JSON.stringify(mockData));
mockDataOoR.max_hosts = 999999999999;
const wrapper2 = mountWithContexts(
<OrganizationForm
organization={mockDataOoR}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
me={meConfig.me}
/>
);
wrapper2.find('button[aria-label="Save"]').simulate('click');
await sleep(0);
expect(handleSubmit).not.toHaveBeenCalled();
});
test('handleSubmit is called and max_hosts value defaults to 0 if input is not a number', async () => {
const handleSubmit = jest.fn();
// mount with String value (default to zero)
const mockDataString = JSON.parse(JSON.stringify(mockData));
mockDataString.max_hosts = 'Bee';
const wrapper = mountWithContexts(
<OrganizationForm
organization={mockDataString}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
me={meConfig.me}
/>
);
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(0);
expect(handleSubmit).toHaveBeenCalledWith({
name: 'Foo',
description: 'Bar',
max_hosts: 0,
custom_virtualenv: 'Fizz',
}, [], []);
});
test('calls "handleCancel" when Cancel button is clicked', () => {
const handleCancel = jest.fn();
const wrapper = mountWithContexts(
<OrganizationForm
organization={mockData}
handleSubmit={jest.fn()}
handleCancel={handleCancel}
me={meConfig.me}
/>
);
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
});
});

View File

@@ -0,0 +1,2 @@
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
export { default as OrganizationForm } from './OrganizationForm';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class Portal extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`My View`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(Portal);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Portal from './Portal';
describe('<Portal />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Portal />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './Portal';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class Projects extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Projects`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(Projects);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Projects from './Projects';
describe('<Projects />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Projects />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './Projects';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class Schedules extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`Schedules`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(Schedules);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Schedules from './Schedules';
describe('<Schedules />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Schedules />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
pageSections.forEach(section => {
expect(section.props().variant).toBeDefined();
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './Schedules';

View File

@@ -0,0 +1,28 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class SystemSettings extends Component {
render () {
const { i18n } = this.props;
const { light, medium } = PageSectionVariants;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">
{i18n._(t`System Settings`)}
</Title>
</PageSection>
<PageSection variant={medium} />
</Fragment>
);
}
}
export default withI18n()(SystemSettings);

Some files were not shown because too many files have changed in this diff Show More