240 jt details skeleton v2 (#273)

* adding package-lock.json

* deleted unsured file

* Adds a Bottom Border Component

* Updates dependencies

* Adds JT Details and tests for it

* merge and rebase

* addresses UI PR issues

* Addresses PR Issues and fixes failing tests.

* Updates to code, fixes package and package-lock.json addresses PR Issues

* fixes package files
This commit is contained in:
Alex Corey 2019-06-25 15:28:07 -04:00 committed by GitHub
parent e49b9a202e
commit ec1fa4dae6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 697 additions and 19 deletions

View File

@ -1,6 +1,7 @@
import Base from '../Base';
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
class JobTemplates extends Base {
class JobTemplates extends InstanceGroupsMixin(Base) {
constructor (http) {
super(http);
this.baseUrl = '/api/v2/job_templates/';

View File

@ -7,7 +7,6 @@ const DetailName = styled(({ fullWidth, ...props }) => (
<TextListItem {...props} />
))`
font-weight: var(--pf-global--FontWeight--bold);
text-align: right;
${props => props.fullWidth && `
grid-column: 1;
`}

View File

@ -4,17 +4,15 @@ 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 { OrganizationsAPI } from '@api';
import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
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) {
@ -141,10 +139,11 @@ class Organization extends Component {
`;
let cardHeader = (
<CardHeader>
<CardHeader style={{ padding: 0 }}>
<RoutedTabs
match={match}
history={history}
labeltext={i18n._(t`Organization detail tabs`)}
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/organizations" />

View File

@ -426,9 +426,9 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
"componentStyle": ComponentStyle {
"componentId": "Detail__DetailName-sc-16ypsyv-0",
"isStatic": false,
"lastClassName": "gRioSK",
"lastClassName": "erdIBg",
"rules": Array [
"font-weight:var(--pf-global--FontWeight--bold);text-align:right;",
"font-weight:var(--pf-global--FontWeight--bold);",
[Function],
],
},
@ -446,16 +446,16 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
className="Detail__DetailName-sc-16ypsyv-0 erdIBg"
component="dt"
fullWidth={false}
>
<TextListItem
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
className="Detail__DetailName-sc-16ypsyv-0 erdIBg"
component="dt"
>
<dt
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
className="Detail__DetailName-sc-16ypsyv-0 erdIBg"
data-pf-content={true}
>
Name
@ -603,9 +603,9 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
"componentStyle": ComponentStyle {
"componentId": "Detail__DetailName-sc-16ypsyv-0",
"isStatic": false,
"lastClassName": "gRioSK",
"lastClassName": "erdIBg",
"rules": Array [
"font-weight:var(--pf-global--FontWeight--bold);text-align:right;",
"font-weight:var(--pf-global--FontWeight--bold);",
[Function],
],
},
@ -623,16 +623,16 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
className="Detail__DetailName-sc-16ypsyv-0 erdIBg"
component="dt"
fullWidth={false}
>
<TextListItem
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
className="Detail__DetailName-sc-16ypsyv-0 erdIBg"
component="dt"
>
<dt
className="Detail__DetailName-sc-16ypsyv-0 gRioSK"
className="Detail__DetailName-sc-16ypsyv-0 erdIBg"
data-pf-content={true}
>
Team Roles

View File

@ -0,0 +1,340 @@
import React, { Component } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { CardBody, Button, TextList, TextListItem, TextListItemVariants, TextListVariants } from '@patternfly/react-core';
import styled from 'styled-components';
import { t } from '@lingui/macro';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import { ChipGroup, Chip } from '../../../components/Chip';
import { DetailList, Detail } from '../../../components/DetailList';
import { JobTemplatesAPI } from '../../../api';
import { toTitleCase } from '../../../util/strings';
const ButtonGroup = styled.div`
display: flex;
justify-content: flex-end;
margin-top: 20px;
& > :not(:first-child){
margin-left: 20px;
}
`;
class JobTemplateDetail extends Component {
constructor (props) {
super(props);
this.state = {
contentError: false,
hasContentLoading: true,
instanceGroups: []
};
this.readInstanceGroups = this.readInstanceGroups.bind(this);
}
componentDidMount () {
this.readInstanceGroups();
}
async readInstanceGroups () {
const { match } = this.props;
try {
const { data } = await JobTemplatesAPI.readInstanceGroups(match.params.id);
this.setState({ instanceGroups: [...data.results] });
} catch {
this.setState({ contentError: true });
} finally {
this.setState({ hasContentLoading: false });
}
}
render () {
const {
template: {
allow_simultaneous,
become_enabled,
created,
description,
diff_mode,
forks,
host_config_key,
job_slice_count,
job_tags,
job_type,
inventory,
name,
limit,
modified,
playbook,
project,
skip_tags,
timeout,
summary_fields,
use_fact_cache,
url,
verbosity
},
hasTemplateLoading,
i18n,
} = this.props;
const { instanceGroups, hasContentLoading, contentError } = this.state;
const verbosityOptions = [
{ verbosity: 0, details: i18n._(t`0 (Normal)`) },
{ verbosity: 1, details: i18n._(t`1 (Verbose)`) },
{ verbosity: 2, details: i18n._(t`2 (More Verbose)`) },
{ verbosity: 3, details: i18n._(t`3 (Debug)`) },
{ verbosity: 4, details: i18n._(t`4 (Connection Debug)`) },
{ verbosity: 5, details: i18n._(t`5 (WinRM Debug)`) },
];
const verbosityDetails = verbosityOptions.filter(
option => option.verbosity === verbosity
);
const generateCallBackUrl = `${window.location.origin + url}callback/`;
const isInitialized = !hasTemplateLoading && !hasContentLoading;
const credentialType = (c) => (c === 'aws' || c === 'ssh' ? c.toUpperCase() : toTitleCase(c));
const renderOptionsField = become_enabled || host_config_key || allow_simultaneous
|| use_fact_cache;
const renderOptions = (
<TextList component={TextListVariants.ul}>
{become_enabled && (
<TextListItem
component={TextListItemVariants.li}
>
{i18n._(t`Enable Privilege Escalation`)}
</TextListItem>
)}
{host_config_key && (
<TextListItem
component={TextListItemVariants.li}
>
{i18n._(t`Allow Provisioning Callbacks`)}
</TextListItem>
)}
{allow_simultaneous && (
<TextListItem
component={TextListItemVariants.li}
>
{i18n._(t`Enable Concurrent Jobs`)}
</TextListItem>
)}
{use_fact_cache && (
<TextListItem
component={TextListItemVariants.li}
>
{i18n._(t`Use Fact Cache`)}
</TextListItem>
)}
</TextList>
);
if (contentError) {
return (<ContentError />);
}
if (hasContentLoading) {
return (<ContentLoading />);
}
return (
isInitialized && (
<CardBody css="padding-top: 20px;">
<DetailList gutter="sm">
<Detail
label={i18n._(t`Name`)}
value={name}
/>
<Detail
label={i18n._(t`Description`)}
value={description}
/>
<Detail
label={i18n._(t`Job Type`)}
value={job_type}
/>
{inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={summary_fields.inventory.name}
/>
)}
{project && (
<Detail
label={i18n._(t`Project`)}
value={summary_fields.project.name}
/>
)}
<Detail
label={i18n._(t`Playbook`)}
value={playbook}
/>
<Detail
label={i18n._(t`Forks`)}
value={forks || '0'}
/>
<Detail
label={i18n._(t`Limit`)}
value={limit}
/>
<Detail
label={i18n._(t`Verbosity`)}
value={verbosityDetails[0].details}
/>
<Detail
label={i18n._(t`Timeout`)}
value={timeout || '0'}
/>
<Detail
label={i18n._(t`Created`)}
value={`${created} by ${summary_fields.created_by.username}`} // TODO: link to user in users
/>
<Detail
label={i18n._(t`Last Modified`)}
value={`${modified} by ${summary_fields.modified_by.username}`} // TODO: link to user in users
/>
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? 'On' : 'Off'}
/>
<Detail
label={i18n._(t` Job Slicing`)}
value={job_slice_count}
/>
{host_config_key && (
<React.Fragment>
<Detail
label={i18n._(t`Host Config Key`)}
value={host_config_key}
/>
<Detail
label={i18n._(t`Provisioning Callback URL`)}
value={generateCallBackUrl}
/>
</React.Fragment>
)}
{renderOptionsField && (
<Detail
label={i18n._(t`Options`)}
value={renderOptions}
/>
)}
{(summary_fields.credentials && summary_fields.credentials.length > 0) && (
<Detail
fullWidth
label={i18n._(t`Credentials`)}
value={(
<ChipGroup showOverflowAfter={5}>
{summary_fields.credentials.map(c => (
<Chip key={c.id} isReadOnly>
<strong className="credential">
{c.kind ? credentialType(c.kind) : i18n._(t`Cloud`)}
:
</strong>
{` ${c.name}`}
</Chip>
))
}
</ChipGroup>
)}
/>
)}
{(summary_fields.labels && summary_fields.labels.results.length > 0) && (
<Detail
fullWidth
label={i18n._(t`Labels`)}
value={(
<ChipGroup showOverflowAfter={5}>
{summary_fields.labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))
}
</ChipGroup>
)}
/>
)}
{(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>
)}
/>
)}
{(job_tags && job_tags.length > 0) && (
<Detail
fullWidth
label={i18n._(t`Job tags`)}
value={(
<ChipGroup showOverflowAfter={5}>
{job_tags.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))
}
</ChipGroup>
)}
/>
)}
{(skip_tags && skip_tags.length > 0) && (
<Detail
fullWidth
label={i18n._(t`Skip tags`)}
value={(
<ChipGroup showOverflowAfter={5}>
{skip_tags.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))
}
</ChipGroup>
)}
/>
)}
</DetailList>
<ButtonGroup>
{summary_fields.user_capabilities.edit && (
<Button
component={Link}
to="/home"
aria-label={i18n._(t`Edit`)}
>
{i18n._(t`Edit`)}
</Button>
)}
<Button
variant="secondary"
component={Link}
to="/templates"
aria-label={i18n._(t`Launch`)}
>
{i18n._(t`Launch`)}
</Button>
<Button
variant="secondary"
component={Link}
to="/templates"
aria-label={i18n._(t`Close`)}
>
{i18n._(t`Close`)}
</Button>
</ButtonGroup>
</CardBody>
)
);
}
}
export { JobTemplateDetail as _JobTemplateDetail };
export default withI18n()(withRouter(JobTemplateDetail));

View File

@ -0,0 +1,114 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../../../../testUtils/enzymeHelpers';
import JobTemplateDetail, { _JobTemplateDetail } from './JobTemplateDetail';
describe('<JobTemplateDetail />', () => {
const template = {
forks: 1,
host_config_key: 'ssh',
name: 'Temp 1',
job_type: 'run',
inventory: 1,
limit: '1',
project: 7,
playbook: '',
id: 1,
verbosity: 1,
summary_fields: {
user_capabilities: { edit: true },
created_by: { username: 'Joe' },
modified_by: { username: 'Joe' },
credentials: [
{ id: 1, kind: 'ssh', name: 'Credential 1' },
{ id: 2, kind: 'awx', name: 'Credential 2' }
],
inventory: { name: 'Inventory' },
project: { name: 'Project' }
}
};
const mockInstanceGroups = {
count: 5,
data: {
results: [
{ id: 1, name: 'IG1' },
{ id: 2, name: 'IG2' }]
}
};
const readInstanceGroups = jest.spyOn(_JobTemplateDetail.prototype, 'readInstanceGroups');
test('initially renders succesfully', () => {
const wrapper = mountWithContexts(
<JobTemplateDetail
template={template}
/>
);
expect(wrapper).toMatchSnapshot();
});
test('When component mounts API is called to get instance groups', async (done) => {
const wrapper = mountWithContexts(
<JobTemplateDetail
template={template}
/>
);
await waitForElement(wrapper, 'JobTemplateDetail', (el) => el.state('hasContentLoading') === true);
expect(readInstanceGroups).toHaveBeenCalled();
await waitForElement(wrapper, 'JobTemplateDetail', (el) => el.state('hasContentLoading') === false);
done();
});
test('Edit button is absent when user does not have edit privilege', async (done) => {
const regularUser = {
forks: 1,
host_config_key: 'ssh',
name: 'Temp 1',
job_tags: 'cookies,pizza',
job_type: 'run',
inventory: 1,
limit: '1',
project: 7,
playbook: '',
id: 1,
verbosity: 0,
created_by: 'Alex',
skip_tags: 'coffe,tea',
summary_fields: {
user_capabilities: { edit: false },
created_by: { username: 'Joe' },
modified_by: { username: 'Joe' },
inventory: { name: 'Inventory' },
project: { name: 'Project' },
labels: { count: 1, results: [{ name: 'Label', id: 1 }] }
}
};
const wrapper = mountWithContexts(
<JobTemplateDetail
template={regularUser}
/>
);
const jobTemplateDetail = wrapper.find('JobTemplateDetail');
const editButton = jobTemplateDetail.find('button[aria-label="Edit"]');
jobTemplateDetail.setState({
instanceGroups: mockInstanceGroups, hasContentLoading: false, contentError: false
});
expect(editButton.length).toBe(0);
done();
});
test('Credential type is Cloud if credential.kind is null', async (done) => {
template.summary_fields.credentials = [{ id: 1, name: 'cred', kind: null, }];
const wrapper = mountWithContexts(
<JobTemplateDetail
template={template}
/>
);
const jobTemplateDetail = wrapper.find('JobTemplateDetail');
jobTemplateDetail.setState({
instanceGroups: mockInstanceGroups.data.results, hasContentLoading: false, contentError: false
});
const cred = wrapper.find('strong.credential');
expect(cred.text()).toContain('Cloud:');
done();
});
});

View File

@ -0,0 +1,199 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<JobTemplateDetail /> initially renders succesfully 1`] = `
<Wrap>
<WithI18n
template={
Object {
"forks": 1,
"host_config_key": "ssh",
"id": 1,
"inventory": 1,
"job_type": "run",
"limit": "1",
"name": "Temp 1",
"playbook": "",
"project": 7,
"summary_fields": Object {
"created_by": Object {
"username": "Joe",
},
"credentials": Array [
Object {
"id": 1,
"kind": "ssh",
"name": "Credential 1",
},
Object {
"id": 2,
"kind": "awx",
"name": "Credential 2",
},
],
"inventory": Object {
"name": "Inventory",
},
"modified_by": Object {
"username": "Joe",
},
"project": Object {
"name": "Project",
},
"user_capabilities": Object {
"edit": true,
},
},
"verbosity": 1,
}
}
>
<I18n
update={true}
withHash={true}
>
<withRouter(JobTemplateDetail)
i18n={"/i18n/"}
template={
Object {
"forks": 1,
"host_config_key": "ssh",
"id": 1,
"inventory": 1,
"job_type": "run",
"limit": "1",
"name": "Temp 1",
"playbook": "",
"project": 7,
"summary_fields": Object {
"created_by": Object {
"username": "Joe",
},
"credentials": Array [
Object {
"id": 1,
"kind": "ssh",
"name": "Credential 1",
},
Object {
"id": 2,
"kind": "awx",
"name": "Credential 2",
},
],
"inventory": Object {
"name": "Inventory",
},
"modified_by": Object {
"username": "Joe",
},
"project": Object {
"name": "Project",
},
"user_capabilities": Object {
"edit": true,
},
},
"verbosity": 1,
}
}
>
<Route>
<JobTemplateDetail
history={"/history/"}
i18n={"/i18n/"}
location={
Object {
"hash": "",
"pathname": "",
"search": "",
"state": "",
}
}
match={
Object {
"isExact": false,
"params": Object {},
"path": "",
"url": "",
}
}
template={
Object {
"forks": 1,
"host_config_key": "ssh",
"id": 1,
"inventory": 1,
"job_type": "run",
"limit": "1",
"name": "Temp 1",
"playbook": "",
"project": 7,
"summary_fields": Object {
"created_by": Object {
"username": "Joe",
},
"credentials": Array [
Object {
"id": 1,
"kind": "ssh",
"name": "Credential 1",
},
Object {
"id": 2,
"kind": "awx",
"name": "Credential 2",
},
],
"inventory": Object {
"name": "Inventory",
},
"modified_by": Object {
"username": "Joe",
},
"project": Object {
"name": "Project",
},
"user_capabilities": Object {
"edit": true,
},
},
"verbosity": 1,
}
}
>
<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>
</JobTemplateDetail>
</Route>
</withRouter(JobTemplateDetail)>
</I18n>
</WithI18n>
</Wrap>
`;

View File

@ -0,0 +1,4 @@
import JobTemplateDetail from './JobTemplateDetail';
export { JobTemplateDetail as _JobTemplateDetail };
export default JobTemplateDetail;

View File

@ -7,6 +7,7 @@ import { Switch, Route, Redirect, withRouter } from 'react-router-dom';
import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
import JobTemplateDetail from './JobTemplateDetail';
import { JobTemplatesAPI } from '@api';
class Template extends Component {
@ -85,7 +86,11 @@ class Template extends Component {
<Route
path="/templates/:templateType/:id/details"
render={() => (
<span>Coming soon!</span>
<JobTemplateDetail
match={match}
hasTemplateLoading={hasContentLoading}
template={template}
/>
)}
/>
)}

View File

@ -0,0 +1,17 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import Template, { _Template } from './Template';
describe('<Template />', () => {
test('initially renders succesfully', () => {
mountWithContexts(<Template />);
});
test('When component mounts API is called and the response is put in state', async (done) => {
const readTemplate = jest.spyOn(_Template.prototype, 'readTemplate');
const wrapper = mountWithContexts(<Template />);
await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
expect(readTemplate).toHaveBeenCalled();
await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
done();
});
});

View File

@ -15,7 +15,7 @@ export function ucFirst (str) {
return `${str[0].toUpperCase()}${str.substr(1)}`;
}
export const toTitleCase = (type) => type
export const toTitleCase = (string) => string
.toLowerCase()
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))