mirror of
https://github.com/ansible/awx.git
synced 2026-03-19 18:07:33 -02:30
Reorganize file locations/directory structure (#270)
Reorganize file locations
This commit is contained in:
28
src/screens/Application/Applications.jsx
Normal file
28
src/screens/Application/Applications.jsx
Normal 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);
|
||||
29
src/screens/Application/Applications.test.jsx
Normal file
29
src/screens/Application/Applications.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/Application/index.js
Normal file
1
src/screens/Application/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Applications';
|
||||
28
src/screens/AuthSetting/AuthSettings.jsx
Normal file
28
src/screens/AuthSetting/AuthSettings.jsx
Normal 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);
|
||||
29
src/screens/AuthSetting/AuthSettings.test.jsx
Normal file
29
src/screens/AuthSetting/AuthSettings.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/AuthSetting/index.js
Normal file
1
src/screens/AuthSetting/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './AuthSettings';
|
||||
28
src/screens/Credential/Credentials.jsx
Normal file
28
src/screens/Credential/Credentials.jsx
Normal 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);
|
||||
29
src/screens/Credential/Credentials.test.jsx
Normal file
29
src/screens/Credential/Credentials.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/Credential/index.js
Normal file
1
src/screens/Credential/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Credentials';
|
||||
28
src/screens/CredentialType/CredentialTypes.jsx
Normal file
28
src/screens/CredentialType/CredentialTypes.jsx
Normal 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);
|
||||
29
src/screens/CredentialType/CredentialTypes.test.jsx
Normal file
29
src/screens/CredentialType/CredentialTypes.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/CredentialType/index.js
Normal file
1
src/screens/CredentialType/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './CredentialTypes';
|
||||
28
src/screens/Dashboard/Dashboard.jsx
Normal file
28
src/screens/Dashboard/Dashboard.jsx
Normal 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);
|
||||
29
src/screens/Dashboard/Dashboard.test.jsx
Normal file
29
src/screens/Dashboard/Dashboard.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/Dashboard/index.js
Normal file
1
src/screens/Dashboard/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Dashboard';
|
||||
28
src/screens/InstanceGroup/InstanceGroups.jsx
Normal file
28
src/screens/InstanceGroup/InstanceGroups.jsx
Normal 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);
|
||||
29
src/screens/InstanceGroup/InstanceGroups.test.jsx
Normal file
29
src/screens/InstanceGroup/InstanceGroups.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/InstanceGroup/index.js
Normal file
1
src/screens/InstanceGroup/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './InstanceGroups';
|
||||
28
src/screens/Inventory/Inventories.jsx
Normal file
28
src/screens/Inventory/Inventories.jsx
Normal 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);
|
||||
29
src/screens/Inventory/Inventories.test.jsx
Normal file
29
src/screens/Inventory/Inventories.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/Inventory/index.js
Normal file
1
src/screens/Inventory/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Inventories';
|
||||
28
src/screens/InventoryScript/InventoryScripts.jsx
Normal file
28
src/screens/InventoryScript/InventoryScripts.jsx
Normal 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);
|
||||
29
src/screens/InventoryScript/InventoryScripts.test.jsx
Normal file
29
src/screens/InventoryScript/InventoryScripts.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/InventoryScript/index.js
Normal file
1
src/screens/InventoryScript/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './InventoryScripts';
|
||||
153
src/screens/Job/Job.jsx
Normal file
153
src/screens/Job/Job.jsx
Normal 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));
|
||||
9
src/screens/Job/Job.test.jsx
Normal file
9
src/screens/Job/Job.test.jsx
Normal 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 />);
|
||||
});
|
||||
});
|
||||
38
src/screens/Job/JobDetail/JobDetail.jsx
Normal file
38
src/screens/Job/JobDetail/JobDetail.jsx
Normal 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));
|
||||
24
src/screens/Job/JobDetail/JobDetail.test.jsx
Normal file
24
src/screens/Job/JobDetail/JobDetail.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
1
src/screens/Job/JobDetail/index.js
Normal file
1
src/screens/Job/JobDetail/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './JobDetail';
|
||||
18
src/screens/Job/JobOutput/JobOutput.jsx
Normal file
18
src/screens/Job/JobOutput/JobOutput.jsx
Normal 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;
|
||||
15
src/screens/Job/JobOutput/JobOutput.test.jsx
Normal file
15
src/screens/Job/JobOutput/JobOutput.test.jsx
Normal 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} />
|
||||
);
|
||||
});
|
||||
});
|
||||
2
src/screens/Job/JobOutput/index.js
Normal file
2
src/screens/Job/JobOutput/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './JobOutput';
|
||||
|
||||
64
src/screens/Job/Jobs.jsx
Normal file
64
src/screens/Job/Jobs.jsx
Normal 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));
|
||||
36
src/screens/Job/Jobs.test.jsx
Normal file
36
src/screens/Job/Jobs.test.jsx
Normal 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
2
src/screens/Job/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Job } from './Job';
|
||||
export { default as Jobs } from './Jobs';
|
||||
28
src/screens/JobsSetting/JobsSettings.jsx
Normal file
28
src/screens/JobsSetting/JobsSettings.jsx
Normal 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);
|
||||
29
src/screens/JobsSetting/JobsSettings.test.jsx
Normal file
29
src/screens/JobsSetting/JobsSettings.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/JobsSetting/index.js
Normal file
1
src/screens/JobsSetting/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './JobsSettings';
|
||||
28
src/screens/License/License.jsx
Normal file
28
src/screens/License/License.jsx
Normal 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);
|
||||
29
src/screens/License/License.test.jsx
Normal file
29
src/screens/License/License.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/License/index.js
Normal file
1
src/screens/License/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './License';
|
||||
151
src/screens/Login/Login.jsx
Normal file
151
src/screens/Login/Login.jsx
Normal 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));
|
||||
215
src/screens/Login/Login.test.jsx
Normal file
215
src/screens/Login/Login.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
1
src/screens/Login/index.js
Normal file
1
src/screens/Login/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Login';
|
||||
28
src/screens/ManagementJob/ManagementJobs.jsx
Normal file
28
src/screens/ManagementJob/ManagementJobs.jsx
Normal 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);
|
||||
29
src/screens/ManagementJob/ManagementJobs.test.jsx
Normal file
29
src/screens/ManagementJob/ManagementJobs.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/ManagementJob/index.js
Normal file
1
src/screens/ManagementJob/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ManagementJobs';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
28
src/screens/NotificationTemplate/NotificationTemplates.jsx
Normal file
28
src/screens/NotificationTemplate/NotificationTemplates.jsx
Normal 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);
|
||||
1
src/screens/NotificationTemplate/index.js
Normal file
1
src/screens/NotificationTemplate/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './NotificationTemplates';
|
||||
242
src/screens/Organization/Organization.jsx
Normal file
242
src/screens/Organization/Organization.jsx
Normal 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 };
|
||||
232
src/screens/Organization/Organization.test.jsx
Normal file
232
src/screens/Organization/Organization.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
3
src/screens/Organization/OrganizationAccess/index.js
Normal file
3
src/screens/Organization/OrganizationAccess/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as OrganizationAccess } from './OrganizationAccess';
|
||||
export { default as OrganizationAccessItem } from './OrganizationAccessItem';
|
||||
export { default as DeleteRoleConfirmationModal } from './DeleteRoleConfirmationModal';
|
||||
82
src/screens/Organization/OrganizationAdd/OrganizationAdd.jsx
Normal file
82
src/screens/Organization/OrganizationAdd/OrganizationAdd.jsx
Normal 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));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
1
src/screens/Organization/OrganizationAdd/index.js
Normal file
1
src/screens/Organization/OrganizationAdd/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './OrganizationAdd';
|
||||
@@ -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));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
1
src/screens/Organization/OrganizationDetail/index.js
Normal file
1
src/screens/Organization/OrganizationDetail/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './OrganizationDetail';
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
1
src/screens/Organization/OrganizationEdit/index.js
Normal file
1
src/screens/Organization/OrganizationEdit/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './OrganizationEdit';
|
||||
209
src/screens/Organization/OrganizationList/OrganizationList.jsx
Normal file
209
src/screens/Organization/OrganizationList/OrganizationList.jsx
Normal 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));
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
});
|
||||
2
src/screens/Organization/OrganizationList/index.js
Normal file
2
src/screens/Organization/OrganizationList/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as OrganizationList } from './OrganizationList';
|
||||
export { default as OrganizationListItem } from './OrganizationListItem';
|
||||
@@ -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));
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { default } from './OrganizationNotifications';
|
||||
@@ -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);
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/Organization/OrganizationTeams/index.js
Normal file
1
src/screens/Organization/OrganizationTeams/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './OrganizationTeams';
|
||||
92
src/screens/Organization/Organizations.jsx
Normal file
92
src/screens/Organization/Organizations.jsx
Normal 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));
|
||||
16
src/screens/Organization/Organizations.test.jsx
Normal file
16
src/screens/Organization/Organizations.test.jsx
Normal 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' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
1
src/screens/Organization/index.js
Normal file
1
src/screens/Organization/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Organizations';
|
||||
67
src/screens/Organization/shared/InstanceGroupsLookup.jsx
Normal file
67
src/screens/Organization/shared/InstanceGroupsLookup.jsx
Normal 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);
|
||||
212
src/screens/Organization/shared/OrganizationForm.jsx
Normal file
212
src/screens/Organization/shared/OrganizationForm.jsx
Normal 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));
|
||||
307
src/screens/Organization/shared/OrganizationForm.test.jsx
Normal file
307
src/screens/Organization/shared/OrganizationForm.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
2
src/screens/Organization/shared/index.js
Normal file
2
src/screens/Organization/shared/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
|
||||
export { default as OrganizationForm } from './OrganizationForm';
|
||||
28
src/screens/Portal/Portal.jsx
Normal file
28
src/screens/Portal/Portal.jsx
Normal 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);
|
||||
29
src/screens/Portal/Portal.test.jsx
Normal file
29
src/screens/Portal/Portal.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/Portal/index.js
Normal file
1
src/screens/Portal/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Portal';
|
||||
28
src/screens/Project/Projects.jsx
Normal file
28
src/screens/Project/Projects.jsx
Normal 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);
|
||||
29
src/screens/Project/Projects.test.jsx
Normal file
29
src/screens/Project/Projects.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/Project/index.js
Normal file
1
src/screens/Project/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Projects';
|
||||
28
src/screens/Schedule/Schedules.jsx
Normal file
28
src/screens/Schedule/Schedules.jsx
Normal 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);
|
||||
29
src/screens/Schedule/Schedules.test.jsx
Normal file
29
src/screens/Schedule/Schedules.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/screens/Schedule/index.js
Normal file
1
src/screens/Schedule/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Schedules';
|
||||
28
src/screens/SystemSetting/SystemSettings.jsx
Normal file
28
src/screens/SystemSetting/SystemSettings.jsx
Normal 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
Reference in New Issue
Block a user