diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
index 6cc799902c..14968059c3 100644
--- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
@@ -1,10 +1,155 @@
-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 Coming soon :);
+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 = (
+
+ {scm_clean && {i18n._(t`Clean`)}}
+ {scm_delete_on_update && (
+ {i18n._(t`Delete on Update`)}
+ )}
+ {scm_update_on_launch && (
+ {i18n._(t`Update Revision on Launch`)}
+ )}
+ {allow_override && (
+ {i18n._(t`Allow Branch Override`)}
+ )}
+
+ );
+ }
+
+ let createdBy = '';
+ if (created) {
+ if (summary_fields.created_by && summary_fields.created_by.username) {
+ createdBy = `${formatDateString(created)} ${i18n._(t`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 = `${formatDateString(modified)} ${i18n._(t`by`)} ${
+ summary_fields.modified_by.username
+ }`;
+ } else {
+ modifiedBy = formatDateString(modified);
+ }
+ }
+
+ return (
+
+
+
+
+ {summary_fields.organization && (
+
+ )}
+
+
+
+ {summary_fields.credential && (
+
+ }
+ />
+ )}
+ {optionsList && (
+
+ )}
+
+
+ {/* TODO: Link to user in users */}
+
+ {/* TODO: Link to user in users */}
+
+
+
+ {summary_fields.user_capabilities &&
+ summary_fields.user_capabilities.edit && (
+
+ )}
+
+
+
+ );
}
-export default ProjectDetail;
+ProjectDetail.propTypes = {
+ project: Project.isRequired,
+};
+
+export default withI18n()(withRouter(ProjectDetail));
diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx
new file mode 100644
index 0000000000..80c31bd25b
--- /dev/null
+++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx
@@ -0,0 +1,205 @@
+import React from 'react';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import ProjectDetail from './ProjectDetail';
+
+describe('', () => {
+ 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();
+ });
+
+ test('should render Details', () => {
+ const wrapper = mountWithContexts();
+ 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([
+
Clean,
+ Delete on Update,
+ Update Revision on Launch,
+ Allow Branch Override,
+ ])
+ ).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(
+
+ );
+ expect(wrapper.find('Detail[label="Options"]').length).toBe(0);
+ });
+
+ test('should render with missing summary fields', async done => {
+ const wrapper = mountWithContexts(
+
+ );
+ 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();
+ 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(
+
+ );
+ 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(, {
+ 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(, {
+ 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');
+ });
+});
diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js
index fb9981fc2e..61ca7ee41d 100644
--- a/awx/ui_next/src/types.js
+++ b/awx/ui_next/src/types.js
@@ -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,