diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index e786181682..cbcb6a3b09 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -167,6 +167,7 @@ function InventoryList({ i18n }) { key={inventory.id} value={inventory.name} inventory={inventory} + fetchInventories={fetchInventories} detailUrl={ inventory.kind === 'smart' ? `${match.url}/smart_inventory/${inventory.id}/details` @@ -182,6 +183,7 @@ function InventoryList({ i18n }) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx index 6caf0f0afd..641ed0154f 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx @@ -279,7 +279,7 @@ describe('', () => { }); wrapper.update(); - const modal = wrapper.find('Modal'); + const modal = wrapper.find('Modal[aria-label="Deletion Error"]'); expect(modal).toHaveLength(1); expect(modal.prop('title')).toEqual('Error!'); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx index 7457c43332..d667345ff1 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useCallback } from 'react'; import { string, bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { @@ -16,79 +16,106 @@ import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { PencilAltIcon } from '@patternfly/react-icons'; - +import { timeOfDay } from '@util/dates'; +import { InventoriesAPI } from '@api'; import { Inventory } from '@types'; +import CopyButton from '../../../components/CopyButton/CopyButton'; const DataListAction = styled(_DataListAction)` align-items: center; display: grid; grid-gap: 16px; - grid-template-columns: 40px; + grid-template-columns: repeat(2, 40px); `; -class InventoryListItem extends React.Component { - static propTypes = { +function InventoryListItem({ + inventory, + isSelected, + onSelect, + detailUrl, + i18n, + fetchInventories, +}) { + InventoryListItem.propTypes = { inventory: Inventory.isRequired, detailUrl: string.isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, }; + const [isDisabled, setIsDisabled] = useState(false); - render() { - const { inventory, isSelected, onSelect, detailUrl, i18n } = this.props; - const labelId = `check-action-${inventory.id}`; - return ( - - - - - - {inventory.name} - - , - - {inventory.kind === 'smart' - ? i18n._(t`Smart Inventory`) - : i18n._(t`Inventory`)} - , - ]} - /> - - {inventory.summary_fields.user_capabilities.edit ? ( - - - - ) : ( - '' - )} - - - - ); - } + const copyInventory = useCallback(async () => { + await InventoriesAPI.copy(inventory.id, { + name: `${inventory.name} @ ${timeOfDay()}`, + }); + await fetchInventories(); + }, [inventory.id, inventory.name, fetchInventories]); + + const labelId = `check-action-${inventory.id}`; + return ( + + + + + + {inventory.name} + + , + + {inventory.kind === 'smart' + ? i18n._(t`Smart Inventory`) + : i18n._(t`Inventory`)} + , + ]} + /> + + {inventory.summary_fields.user_capabilities.edit ? ( + + + + ) : ( + '' + )} + {inventory.summary_fields.user_capabilities.copy && ( + setIsDisabled(true)} + onDoneLoading={() => setIsDisabled(false)} + helperText={{ + tooltip: i18n._(t`Copy Inventory`), + errorMessage: i18n._(t`Failed to copy inventory.`), + }} + /> + )} + + + + ); } export default withI18n()(InventoryListItem); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx index 4485d9034d..b67b2a5070 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx @@ -1,9 +1,13 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { I18nProvider } from '@lingui/react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { InventoriesAPI } from '@api'; import InventoryListItem from './InventoryListItem'; +jest.mock('@api/models/Inventories'); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts( @@ -85,4 +89,104 @@ describe('', () => { ); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); }); + test('should call api to copy inventory', async () => { + InventoriesAPI.copy.mockResolvedValue(); + + const wrapper = mountWithContexts( + + + {}} + /> + + + ); + + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + expect(InventoriesAPI.copy).toHaveBeenCalled(); + jest.clearAllMocks(); + }); + + test('should render proper alert modal on copy error', async () => { + InventoriesAPI.copy.mockRejectedValue(new Error()); + + const wrapper = mountWithContexts( + + + {}} + /> + + + ); + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('Modal').prop('isOpen')).toBe(true); + jest.clearAllMocks(); + }); + + test('should not render copy button', async () => { + const wrapper = mountWithContexts( + + + {}} + /> + + + ); + expect(wrapper.find('CopyButton').length).toBe(0); + }); }); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx index 6e1d24ba1a..77db42660b 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -170,6 +170,7 @@ function ProjectList({ i18n }) { )} renderItem={o => ( diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx index ff67e3541b..b59435e5a6 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useState, useCallback } from 'react'; import { string, bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { @@ -16,35 +16,45 @@ import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; import { PencilAltIcon, SyncIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; - +import { timeOfDay } from '@util/dates'; +import { ProjectsAPI } from '@api'; import ClipboardCopyButton from '@components/ClipboardCopyButton'; -import ProjectSyncButton from '../shared/ProjectSyncButton'; import StatusIcon from '@components/StatusIcon'; import { toTitleCase } from '@util/strings'; +import CopyButton from '@components/CopyButton'; +import ProjectSyncButton from '../shared/ProjectSyncButton'; import { Project } from '@types'; const DataListAction = styled(_DataListAction)` align-items: center; display: grid; grid-gap: 16px; - grid-template-columns: repeat(2, 40px); + grid-template-columns: repeat(3, 40px); `; -class ProjectListItem extends React.Component { - static propTypes = { +function ProjectListItem({ + project, + isSelected, + onSelect, + detailUrl, + i18n, + fetchProjects, +}) { + const [isDisabled, setIsDisabled] = useState(false); + ProjectListItem.propTypes = { project: Project.isRequired, detailUrl: string.isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, }; - constructor(props) { - super(props); + const copyProject = useCallback(async () => { + await ProjectsAPI.copy(project.id, { + name: `${project.name} @ ${timeOfDay()}`, + }); + await fetchProjects(); + }, [project.id, project.name, fetchProjects]); - this.generateLastJobTooltip = this.generateLastJobTooltip.bind(this); - } - - generateLastJobTooltip = job => { - const { i18n } = this.props; + const generateLastJobTooltip = job => { return (
{i18n._(t`MOST RECENT SYNC`)}
@@ -63,107 +73,116 @@ class ProjectListItem extends React.Component { ); }; - render() { - const { project, isSelected, onSelect, detailUrl, i18n } = this.props; - const labelId = `check-action-${project.id}`; - return ( - - - - - {project.summary_fields.last_job && ( - - - - - - )} - , - - - {project.name} - - , - - {project.scm_type === '' - ? i18n._(t`Manual`) - : toTitleCase(project.scm_type)} - , - - {project.scm_revision.substring(0, 7)} - {project.scm_revision ? ( - - ) : null} - , - ]} - /> - - {project.summary_fields.user_capabilities.start ? ( - - - {handleSync => ( - + const labelId = `check-action-${project.id}`; + return ( + + + + + {project.summary_fields.last_job && ( + - - ) : ( - '' - )} - {project.summary_fields.user_capabilities.edit ? ( - - - - ) : ( - '' - )} - - - - ); - } + + + + + )} + , + + + {project.name} + + , + + {project.scm_type === '' + ? i18n._(t`Manual`) + : toTitleCase(project.scm_type)} + , + + {project.scm_revision.substring(0, 7)} + {project.scm_revision ? ( + + ) : null} + , + ]} + /> + + {project.summary_fields.user_capabilities.start ? ( + + + {handleSync => ( + + )} + + + ) : ( + '' + )} + {project.summary_fields.user_capabilities.edit ? ( + + + + ) : ( + '' + )} + {project.summary_fields.user_capabilities.copy && ( + setIsDisabled(true)} + onDoneLoading={() => setIsDisabled(false)} + helperText={{ + tooltip: i18n._(t`Copy Project`), + errorMessage: i18n._(t`Failed to copy project.`), + }} + /> + )} + + + + ); } export default withI18n()(ProjectListItem); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx index b556e0957f..9beb03cf09 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx @@ -1,8 +1,11 @@ import React from 'react'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; - +import { act } from 'react-dom/test-utils'; import ProjectsListItem from './ProjectListItem'; +import { ProjectsAPI } from '@api'; + +jest.mock('@api/models/Projects'); describe('', () => { test('launch button shown to users with start capabilities', () => { @@ -32,6 +35,7 @@ describe('', () => { ); expect(wrapper.find('ProjectSyncButton').exists()).toBeTruthy(); }); + test('launch button hidden from users without start capabilities', () => { const wrapper = mountWithContexts( ', () => { ); expect(wrapper.find('ProjectSyncButton').exists()).toBeFalsy(); }); + test('edit button shown to users with edit capabilities', () => { const wrapper = mountWithContexts( ', () => { ); expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); }); + test('edit button hidden from users without edit capabilities', () => { const wrapper = mountWithContexts( ', () => { ); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); }); + + test('should call api to copy project', async () => { + ProjectsAPI.copy.mockResolvedValue(); + const wrapper = mountWithContexts( + {}} + project={{ + id: 1, + name: 'Project 1', + url: '/api/v2/projects/1', + type: 'project', + scm_type: 'git', + scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', + summary_fields: { + last_job: { + id: 9000, + status: 'successful', + }, + user_capabilities: { + edit: false, + copy: true, + }, + }, + }} + /> + ); + + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + expect(ProjectsAPI.copy).toHaveBeenCalled(); + jest.clearAllMocks(); + }); + + test('should render proper alert modal on copy error', async () => { + ProjectsAPI.copy.mockRejectedValue(new Error()); + + const wrapper = mountWithContexts( + {}} + project={{ + id: 1, + name: 'Project 1', + url: '/api/v2/projects/1', + type: 'project', + scm_type: 'git', + scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', + summary_fields: { + last_job: { + id: 9000, + status: 'successful', + }, + user_capabilities: { + edit: false, + copy: true, + }, + }, + }} + /> + ); + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('Modal').prop('isOpen')).toBe(true); + jest.clearAllMocks(); + }); + test('should not render copy button', async () => { + const wrapper = mountWithContexts( + {}} + project={{ + id: 1, + name: 'Project 1', + url: '/api/v2/projects/1', + type: 'project', + scm_type: 'git', + scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', + summary_fields: { + last_job: { + id: 9000, + status: 'successful', + }, + user_capabilities: { + edit: false, + copy: false, + }, + }, + }} + /> + ); + expect(wrapper.find('CopyButton').length).toBe(0); + }); });