mirror of
https://github.com/ansible/awx.git
synced 2024-11-01 16:51:11 +03:00
Merge pull request #4965 from marshmalien/project-detail
Add project detail and unit tests Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
cbed525547
@ -1,10 +1,157 @@
|
||||
import React, { Component } from 'react';
|
||||
import { CardBody } from '@patternfly/react-core';
|
||||
import React from 'react';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { Project } from '@types';
|
||||
import { formatDateString } from '@util/dates';
|
||||
import { Button, CardBody, List, ListItem } from '@patternfly/react-core';
|
||||
import { DetailList, Detail } from '@components/DetailList';
|
||||
import { CredentialChip } from '@components/Chip';
|
||||
|
||||
class ProjectDetail extends Component {
|
||||
render() {
|
||||
return <CardBody>Coming soon :)</CardBody>;
|
||||
const ActionButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
& > :not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
function ProjectDetail({ project, i18n }) {
|
||||
const {
|
||||
allow_override,
|
||||
created,
|
||||
custom_virtualenv,
|
||||
description,
|
||||
id,
|
||||
modified,
|
||||
name,
|
||||
scm_branch,
|
||||
scm_clean,
|
||||
scm_delete_on_update,
|
||||
scm_type,
|
||||
scm_update_on_launch,
|
||||
scm_update_cache_timeout,
|
||||
scm_url,
|
||||
summary_fields,
|
||||
} = project;
|
||||
|
||||
let optionsList = '';
|
||||
if (
|
||||
scm_clean ||
|
||||
scm_delete_on_update ||
|
||||
scm_update_on_launch ||
|
||||
allow_override
|
||||
) {
|
||||
optionsList = (
|
||||
<List>
|
||||
{scm_clean && <ListItem>{i18n._(t`Clean`)}</ListItem>}
|
||||
{scm_delete_on_update && (
|
||||
<ListItem>{i18n._(t`Delete on Update`)}</ListItem>
|
||||
)}
|
||||
{scm_update_on_launch && (
|
||||
<ListItem>{i18n._(t`Update Revision on Launch`)}</ListItem>
|
||||
)}
|
||||
{allow_override && (
|
||||
<ListItem>{i18n._(t`Allow Branch Override`)}</ListItem>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
let createdBy = '';
|
||||
if (created) {
|
||||
if (summary_fields.created_by && summary_fields.created_by.username) {
|
||||
createdBy = i18n._(
|
||||
t`${formatDateString(created)} by ${summary_fields.created_by.username}`
|
||||
);
|
||||
} else {
|
||||
createdBy = formatDateString(created);
|
||||
}
|
||||
}
|
||||
|
||||
let modifiedBy = '';
|
||||
if (modified) {
|
||||
if (summary_fields.modified_by && summary_fields.modified_by.username) {
|
||||
modifiedBy = i18n._(
|
||||
t`${formatDateString(modified)} by ${
|
||||
summary_fields.modified_by.username
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
modifiedBy = formatDateString(modified);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody css="padding-top: 20px">
|
||||
<DetailList gutter="sm">
|
||||
<Detail label={i18n._(t`Name`)} value={name} />
|
||||
<Detail label={i18n._(t`Description`)} value={description} />
|
||||
{summary_fields.organization && (
|
||||
<Detail
|
||||
label={i18n._(t`Organization`)}
|
||||
value={summary_fields.organization.name}
|
||||
/>
|
||||
)}
|
||||
<Detail label={i18n._(t`SCM Type`)} value={scm_type} />
|
||||
<Detail label={i18n._(t`SCM URL`)} value={scm_url} />
|
||||
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
|
||||
{summary_fields.credential && (
|
||||
<Detail
|
||||
label={i18n._(t`SCM Credential`)}
|
||||
value={
|
||||
<CredentialChip
|
||||
key={summary_fields.credential.id}
|
||||
credential={summary_fields.credential}
|
||||
isReadOnly
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{optionsList && (
|
||||
<Detail label={i18n._(t`Options`)} value={optionsList} />
|
||||
)}
|
||||
<Detail
|
||||
label={i18n._(t`Cache Timeout`)}
|
||||
value={`${scm_update_cache_timeout} ${i18n._(t`Seconds`)}`}
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Ansible Environment`)}
|
||||
value={custom_virtualenv}
|
||||
/>
|
||||
{/* TODO: Link to user in users */}
|
||||
<Detail label={i18n._(t`Created`)} value={createdBy} />
|
||||
{/* TODO: Link to user in users */}
|
||||
<Detail label={i18n._(t`Last Modified`)} value={modifiedBy} />
|
||||
</DetailList>
|
||||
<ActionButtonWrapper>
|
||||
{summary_fields.user_capabilities &&
|
||||
summary_fields.user_capabilities.edit && (
|
||||
<Button
|
||||
aria-label={i18n._(t`edit`)}
|
||||
component={Link}
|
||||
to={`/projects/${id}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`close`)}
|
||||
component={Link}
|
||||
to="/projects"
|
||||
>
|
||||
{i18n._(t`Close`)}
|
||||
</Button>
|
||||
</ActionButtonWrapper>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectDetail;
|
||||
ProjectDetail.propTypes = {
|
||||
project: Project.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(withRouter(ProjectDetail));
|
||||
|
@ -0,0 +1,220 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import ProjectDetail from './ProjectDetail';
|
||||
|
||||
describe('<ProjectDetail />', () => {
|
||||
const mockProject = {
|
||||
id: 1,
|
||||
type: 'project',
|
||||
url: '/api/v2/projects/1',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 10,
|
||||
name: 'Foo',
|
||||
},
|
||||
credential: {
|
||||
id: 1000,
|
||||
name: 'qux',
|
||||
kind: 'scm',
|
||||
},
|
||||
last_job: {
|
||||
id: 9000,
|
||||
status: 'successful',
|
||||
},
|
||||
created_by: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
},
|
||||
modified_by: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
delete: true,
|
||||
start: true,
|
||||
schedule: true,
|
||||
copy: true,
|
||||
},
|
||||
},
|
||||
created: '2019-10-10T01:15:06.780472Z',
|
||||
modified: '2019-10-10T01:15:06.780490Z',
|
||||
name: 'Project 1',
|
||||
description: 'lorem ipsum',
|
||||
scm_type: 'git',
|
||||
scm_url: 'https://mock.com/bar',
|
||||
scm_branch: 'baz',
|
||||
scm_refspec: 'refs/remotes/*',
|
||||
scm_clean: true,
|
||||
scm_delete_on_update: true,
|
||||
credential: 100,
|
||||
status: 'successful',
|
||||
organization: 10,
|
||||
scm_update_on_launch: true,
|
||||
scm_update_cache_timeout: 5,
|
||||
allow_override: true,
|
||||
custom_virtualenv: '/custom-venv',
|
||||
};
|
||||
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||
});
|
||||
|
||||
test('should render Details', () => {
|
||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />, {
|
||||
context: {
|
||||
linguiPublisher: {
|
||||
i18n: {
|
||||
_: key => {
|
||||
if (key.values) {
|
||||
Object.entries(key.values).forEach(([k, v]) => {
|
||||
key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v);
|
||||
});
|
||||
}
|
||||
return key.id;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
function assertDetail(label, value) {
|
||||
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
|
||||
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
|
||||
}
|
||||
assertDetail('Name', mockProject.name);
|
||||
assertDetail('Description', mockProject.description);
|
||||
assertDetail('Organization', mockProject.summary_fields.organization.name);
|
||||
assertDetail('SCM Type', mockProject.scm_type);
|
||||
assertDetail('SCM URL', mockProject.scm_url);
|
||||
assertDetail('SCM Branch', mockProject.scm_branch);
|
||||
assertDetail(
|
||||
'SCM Credential',
|
||||
`Scm: ${mockProject.summary_fields.credential.name}`
|
||||
);
|
||||
assertDetail(
|
||||
'Cache Timeout',
|
||||
`${mockProject.scm_update_cache_timeout} Seconds`
|
||||
);
|
||||
assertDetail('Ansible Environment', mockProject.custom_virtualenv);
|
||||
assertDetail(
|
||||
'Created',
|
||||
`10/10/2019, 1:15:06 AM by ${mockProject.summary_fields.created_by.username}`
|
||||
);
|
||||
assertDetail(
|
||||
'Last Modified',
|
||||
`10/10/2019, 1:15:06 AM by ${mockProject.summary_fields.modified_by.username}`
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Detail[label="Options"]')
|
||||
.containsAllMatchingElements([
|
||||
<li>Clean</li>,
|
||||
<li>Delete on Update</li>,
|
||||
<li>Update Revision on Launch</li>,
|
||||
<li>Allow Branch Override</li>,
|
||||
])
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('should hide options label when all project options return false', () => {
|
||||
const mockOptions = {
|
||||
scm_clean: false,
|
||||
scm_delete_on_update: false,
|
||||
scm_update_on_launch: false,
|
||||
allow_override: false,
|
||||
created: '',
|
||||
modified: '',
|
||||
};
|
||||
const wrapper = mountWithContexts(
|
||||
<ProjectDetail project={{ ...mockProject, ...mockOptions }} />
|
||||
);
|
||||
expect(wrapper.find('Detail[label="Options"]').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should render with missing summary fields', async done => {
|
||||
const wrapper = mountWithContexts(
|
||||
<ProjectDetail project={{ ...mockProject, summary_fields: {} }} />
|
||||
);
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'Detail[label="Name"]',
|
||||
el => el.length === 1
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
test('should show edit button for users with edit permission', async done => {
|
||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||
const editButton = await waitForElement(
|
||||
wrapper,
|
||||
'ProjectDetail Button[aria-label="edit"]'
|
||||
);
|
||||
expect(editButton.text()).toEqual('Edit');
|
||||
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
|
||||
done();
|
||||
});
|
||||
|
||||
test('should hide edit button for users without edit permission', async done => {
|
||||
const wrapper = mountWithContexts(
|
||||
<ProjectDetail
|
||||
project={{
|
||||
...mockProject,
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await waitForElement(wrapper, 'ProjectDetail');
|
||||
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
|
||||
0
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
test('edit button should navigate to project edit', () => {
|
||||
const context = {
|
||||
router: {
|
||||
history: {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />, {
|
||||
context,
|
||||
});
|
||||
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(1);
|
||||
expect(context.router.history.push).not.toHaveBeenCalled();
|
||||
wrapper
|
||||
.find('Button[aria-label="edit"] Link')
|
||||
.simulate('click', { button: 0 });
|
||||
expect(context.router.history.push).toHaveBeenCalledWith(
|
||||
'/projects/1/edit'
|
||||
);
|
||||
});
|
||||
|
||||
test('close button should navigate to projects list', () => {
|
||||
const context = {
|
||||
router: {
|
||||
history: {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />, {
|
||||
context,
|
||||
});
|
||||
expect(wrapper.find('Button[aria-label="close"]').length).toBe(1);
|
||||
expect(context.router.history.push).not.toHaveBeenCalled();
|
||||
wrapper
|
||||
.find('Button[aria-label="close"] Link')
|
||||
.simulate('click', { button: 0 });
|
||||
expect(context.router.history.push).toHaveBeenCalledWith('/projects');
|
||||
});
|
||||
});
|
@ -4,6 +4,7 @@ import {
|
||||
number,
|
||||
string,
|
||||
bool,
|
||||
objectOf,
|
||||
oneOf,
|
||||
oneOfType,
|
||||
} from 'prop-types';
|
||||
@ -71,11 +72,6 @@ export const JobTemplate = shape({
|
||||
project: number,
|
||||
});
|
||||
|
||||
export const Project = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
});
|
||||
|
||||
export const Inventory = shape({
|
||||
id: number.isRequired,
|
||||
name: string,
|
||||
@ -109,6 +105,51 @@ export const Credential = shape({
|
||||
kind: string,
|
||||
});
|
||||
|
||||
export const Project = shape({
|
||||
id: number.isRequired,
|
||||
type: oneOf(['project']),
|
||||
url: string,
|
||||
related: shape(),
|
||||
summary_fields: shape({
|
||||
organization: Organization,
|
||||
credential: Credential,
|
||||
last_job: shape({}),
|
||||
last_update: shape({}),
|
||||
created_by: shape({}),
|
||||
modified_by: shape({}),
|
||||
object_roles: shape({}),
|
||||
user_capabilities: objectOf(bool),
|
||||
}),
|
||||
created: string,
|
||||
name: string.isRequired,
|
||||
description: string,
|
||||
scm_type: oneOf(['', 'git', 'hg', 'svn', 'insights']),
|
||||
scm_url: string,
|
||||
scm_branch: string,
|
||||
scm_refspec: string,
|
||||
scm_clean: bool,
|
||||
scm_delete_on_update: bool,
|
||||
credential: number,
|
||||
status: oneOf([
|
||||
'new',
|
||||
'pending',
|
||||
'waiting',
|
||||
'running',
|
||||
'successful',
|
||||
'failed',
|
||||
'error',
|
||||
'canceled',
|
||||
'never updated',
|
||||
'ok',
|
||||
'missing',
|
||||
]),
|
||||
organization: number,
|
||||
scm_update_on_launch: bool,
|
||||
scm_update_cache_timeout: number,
|
||||
allow_override: bool,
|
||||
custom_virtualenv: string,
|
||||
});
|
||||
|
||||
export const Job = shape({
|
||||
status: string,
|
||||
started: string,
|
||||
|
Loading…
Reference in New Issue
Block a user