diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.jsx index 68b9f9879d..e777003c92 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.jsx @@ -26,6 +26,7 @@ function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) { pull, organization, summary_fields, + managed_by_tower: managedByTower, } = executionEnvironment; const { @@ -103,25 +104,27 @@ function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) { dataCy="execution-environment-modified" /> - - - - {i18n._(t`Delete`)} - - + {!managedByTower && ( + + + + {i18n._(t`Delete`)} + + + )} {error && ( ', () => { expect(dates).toHaveLength(2); expect(dates.at(0).prop('date')).toEqual(executionEnvironment.created); expect(dates.at(1).prop('date')).toEqual(executionEnvironment.modified); + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe('/execution_environments/17/edit'); + + const deleteButton = wrapper.find('Button[aria-label="Delete"]'); + expect(deleteButton.text()).toEqual('Delete'); }); test('should render organization detail', async () => { @@ -135,4 +141,38 @@ describe('', () => { expect(ExecutionEnvironmentsAPI.destroy).toHaveBeenCalledTimes(1); expect(history.location.pathname).toBe('/execution_environments'); }); + + test('should not render action buttons to ee managed by tower', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + + expect(wrapper.find('Detail[label="Image"]').prop('value')).toEqual( + executionEnvironment.image + ); + expect(wrapper.find('Detail[label="Description"]').prop('value')).toEqual( + 'Foo' + ); + expect(wrapper.find('Detail[label="Organization"]').prop('value')).toEqual( + 'Globally Available' + ); + expect( + wrapper.find('Detail[label="Credential"]').prop('value').props.children + ).toEqual(executionEnvironment.summary_fields.credential.name); + const dates = wrapper.find('UserDateDetail'); + expect(dates).toHaveLength(2); + expect(dates.at(0).prop('date')).toEqual(executionEnvironment.created); + expect(dates.at(1).prop('date')).toEqual(executionEnvironment.modified); + expect(wrapper.find('Button[aria-label="edit"]')).toHaveLength(0); + + expect(wrapper.find('Button[aria-label="Delete"]')).toHaveLength(0); + }); }); diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.jsx index 278566e632..914b884f1f 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.jsx @@ -195,6 +195,7 @@ function ExecutionEnvironmentList({ i18n }) { isSelected={selected.some( row => row.id === executionEnvironment.id )} + fetchExecutionEnvironments={fetchExecutionEnvironments} /> )} emptyStateControls={ diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.jsx index 1f56b53727..07816ff5fc 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.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 { t } from '@lingui/macro'; @@ -8,7 +8,10 @@ import { Tr, Td } from '@patternfly/react-table'; import { PencilAltIcon } from '@patternfly/react-icons'; import { ActionsTd, ActionItem } from '../../../components/PaginatedTable'; +import CopyButton from '../../../components/CopyButton'; import { ExecutionEnvironment } from '../../../types'; +import { ExecutionEnvironmentsAPI } from '../../../api'; +import { timeOfDay } from '../../../util/dates'; function ExecutionEnvironmentListItem({ executionEnvironment, @@ -17,7 +20,29 @@ function ExecutionEnvironmentListItem({ onSelect, i18n, rowIndex, + fetchExecutionEnvironments, }) { + const [isDisabled, setIsDisabled] = useState(false); + + const copyExecutionEnvironment = useCallback(async () => { + await ExecutionEnvironmentsAPI.copy(executionEnvironment.id, { + name: `${executionEnvironment.name} @ ${timeOfDay()}`, + }); + await fetchExecutionEnvironments(); + }, [ + executionEnvironment.id, + executionEnvironment.name, + fetchExecutionEnvironments, + ]); + + const handleCopyStart = useCallback(() => { + setIsDisabled(true); + }, []); + + const handleCopyFinish = useCallback(() => { + setIsDisabled(false); + }, []); + const labelId = `check-action-${executionEnvironment.id}`; return ( @@ -65,6 +90,19 @@ function ExecutionEnvironmentListItem({ + + + ); diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.test.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.test.jsx index 0e7c037aed..0df9bfcf31 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.test.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentListItem.test.jsx @@ -4,8 +4,15 @@ import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import ExecutionEnvironmentListItem from './ExecutionEnvironmentListItem'; +import { ExecutionEnvironmentsAPI } from '../../../api'; + +jest.mock('../../../api'); describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + let wrapper; const executionEnvironment = { name: 'Foo', @@ -13,7 +20,10 @@ describe('', () => { image: 'https://registry.com/r/image/manifest', organization: null, credential: null, - summary_fields: { user_capabilities: { edit: true } }, + summary_fields: { + user_capabilities: { edit: true, copy: true, delete: true }, + }, + managed_by_tower: false, }; test('should mount successfully', async () => { @@ -71,4 +81,110 @@ describe('', () => { expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); }); + + test('should call api to copy execution environment', async () => { + ExecutionEnvironmentsAPI.copy.mockResolvedValue(); + + wrapper = mountWithContexts( + + + {}} + /> + +
+ ); + + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + expect(ExecutionEnvironmentsAPI.copy).toHaveBeenCalled(); + }); + + test('should render proper alert modal on copy error', async () => { + ExecutionEnvironmentsAPI.copy.mockRejectedValue(new Error()); + + wrapper = mountWithContexts( + + + {}} + /> + +
+ ); + + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('Modal').prop('isOpen')).toBe(true); + }); + + test('should not render copy button', async () => { + wrapper = mountWithContexts( + + + {}} + /> + +
+ ); + expect(wrapper.find('CopyButton').length).toBe(0); + }); + + test('should not render the pencil action for ee managed by tower', async () => { + await act(async () => { + wrapper = mountWithContexts( + + + {}} + /> + +
+ ); + }); + expect( + wrapper + .find('Td') + .at(1) + .text() + ).toBe(executionEnvironment.name); + expect( + wrapper + .find('Td') + .at(2) + .text() + ).toBe(executionEnvironment.image); + + expect( + wrapper + .find('Td') + .at(3) + .text() + ).toBe('Globally Available'); + + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); });