From 7fc4e8d20aedc1a6cbf626fb964af720684fa048 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 9 Oct 2019 00:37:14 -0400 Subject: [PATCH] Add project detail and unit tests --- .../Project/ProjectDetail/ProjectDetail.jsx | 157 +++++++++++++- .../ProjectDetail/ProjectDetail.test.jsx | 205 ++++++++++++++++++ awx/ui_next/src/types.js | 51 ++++- 3 files changed, 402 insertions(+), 11 deletions(-) create mode 100644 awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx 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,