diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 0543587ceb..66622a7649 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -1,5 +1,5 @@ -import React, { Component } from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card } from '@patternfly/react-core'; @@ -29,65 +29,96 @@ const QS_CONFIG = getQSConfig('template', { type: 'job_template,workflow_job_template', }); -class TemplatesList extends Component { - constructor(props) { - super(props); +function TemplatesList({ i18n }) { + const { id: projectId } = useParams(); + const { pathname, search } = useLocation(); - this.state = { - hasContentLoading: true, - contentError: null, - deletionError: null, - selected: [], - templates: [], - itemCount: 0, - }; + const [deletionError, setDelectionError] = useState(null); + const [contentError, setContentError] = useState(null); + const [hasContentLoading, setHasContentLoading] = useState(true); + const [jtActions, setJTActions] = useState(null); + const [wfjtActions, setWFJTActions] = useState(null); + const [count, setCount] = useState(0); + const [templates, setTemplates] = useState([]); + const [selected, setSelected] = useState([]); - this.loadTemplates = this.loadTemplates.bind(this); - this.handleSelectAll = this.handleSelectAll.bind(this); - this.handleSelect = this.handleSelect.bind(this); - this.handleTemplateDelete = this.handleTemplateDelete.bind(this); - this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); - } + useEffect( + () => { + const loadTemplates = async () => { + const params = { + ...parseQueryString(QS_CONFIG, search), + }; - componentDidMount() { - this.loadTemplates(); - } + let jtOptionsPromise; + if (jtActions) { + jtOptionsPromise = Promise.resolve({ + data: { actions: jtActions }, + }); + } else { + jtOptionsPromise = JobTemplatesAPI.readOptions(); + } - componentDidUpdate(prevProps) { - const { location } = this.props; + let wfjtOptionsPromise; + if (wfjtActions) { + wfjtOptionsPromise = Promise.resolve({ + data: { actions: wfjtActions }, + }); + } else { + wfjtOptionsPromise = WorkflowJobTemplatesAPI.readOptions(); + } + if (pathname.startsWith('/projects') && projectId) { + params.jobtemplate__project = projectId; + } - if (location !== prevProps.location) { - this.loadTemplates(); - } - } + const promises = Promise.all([ + UnifiedJobTemplatesAPI.read(params), + jtOptionsPromise, + wfjtOptionsPromise, + ]); + setDelectionError(null); - componentWillUnmount() { - document.removeEventListener('click', this.handleAddToggle, false); - } + try { + const [ + { + data: { count: itemCount, results }, + }, + { + data: { actions: jobTemplateActions }, + }, + { + data: { actions: workFlowJobTemplateActions }, + }, + ] = await promises; + setJTActions(jobTemplateActions); + setWFJTActions(workFlowJobTemplateActions); + setCount(itemCount); + setTemplates(results); + setHasContentLoading(false); + } catch (err) { + setContentError(err); + } + }; + loadTemplates(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [pathname, search, count, projectId] + ); - handleDeleteErrorClose() { - this.setState({ deletionError: null }); - } + const handleSelectAll = isSelected => { + const selectedItems = isSelected ? [...templates] : []; + setSelected(selectedItems); + }; - handleSelectAll(isSelected) { - const { templates } = this.state; - const selected = isSelected ? [...templates] : []; - this.setState({ selected }); - } - - handleSelect(template) { - const { selected } = this.state; + const handleSelect = template => { if (selected.some(s => s.id === template.id)) { - this.setState({ selected: selected.filter(s => s.id !== template.id) }); + setSelected(selected.filter(s => s.id !== template.id)); } else { - this.setState({ selected: selected.concat(template) }); + setSelected(selected.concat(template)); } - } + }; - async handleTemplateDelete() { - const { selected, itemCount } = this.state; - - this.setState({ hasContentLoading: true }); + const handleTemplateDelete = async () => { + setHasContentLoading(true); try { await Promise.all( selected.map(({ type, id }) => { @@ -100,213 +131,126 @@ class TemplatesList extends Component { return deletePromise; }) ); - this.setState({ itemCount: itemCount - selected.length }); + setCount(count - selected.length); } catch (err) { - this.setState({ deletionError: err }); - } finally { - await this.loadTemplates(); + setDelectionError(err); } + }; + + const canAddJT = + jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); + const canAddWFJT = + wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST'); + const addButtonOptions = []; + if (canAddJT) { + addButtonOptions.push({ + label: i18n._(t`Template`), + url: `/templates/job_template/add/`, + }); } - - async loadTemplates() { - const { - location, - match: { - params: { id: projectId }, - url, - }, - } = this.props; - const { - jtActions: cachedJTActions, - wfjtActions: cachedWFJTActions, - } = this.state; - const params = { - ...parseQueryString(QS_CONFIG, location.search), - }; - - let jtOptionsPromise; - if (cachedJTActions) { - jtOptionsPromise = Promise.resolve({ - data: { actions: cachedJTActions }, - }); - } else { - jtOptionsPromise = JobTemplatesAPI.readOptions(); - } - - let wfjtOptionsPromise; - if (cachedWFJTActions) { - wfjtOptionsPromise = Promise.resolve({ - data: { actions: cachedWFJTActions }, - }); - } else { - wfjtOptionsPromise = WorkflowJobTemplatesAPI.readOptions(); - } - if (url.startsWith('/projects') && projectId) { - params.jobtemplate__project = projectId; - } - - const promises = Promise.all([ - UnifiedJobTemplatesAPI.read(params), - jtOptionsPromise, - wfjtOptionsPromise, - ]); - - this.setState({ contentError: null, hasContentLoading: true }); - - try { - const [ - { - data: { count, results }, - }, - { - data: { actions: jtActions }, - }, - { - data: { actions: wfjtActions }, - }, - ] = await promises; - - this.setState({ - jtActions, - wfjtActions, - itemCount: count, - templates: results, - selected: [], - }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); - } - } - - render() { - const { - contentError, - hasContentLoading, - deletionError, - templates, - itemCount, - selected, - jtActions, - wfjtActions, - } = this.state; - const { match, i18n } = this.props; - const canAddJT = - jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); - const canAddWFJT = - wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST'); - const addButtonOptions = []; - if (canAddJT) { - addButtonOptions.push({ - label: i18n._(t`Template`), - url: `/templates/job_template/add/`, - }); - } - if (canAddWFJT) { - addButtonOptions.push({ - label: i18n._(t`Workflow Template`), - url: `/templates/workflow_job_template/add/`, - }); - } - const isAllSelected = - selected.length === templates.length && selected.length > 0; - const addButton = ( - - ); - return ( - <> - - ( - , - (canAddJT || canAddWFJT) && addButton, - ]} - /> - )} - renderItem={template => ( - this.handleSelect(template)} - isSelected={selected.some(row => row.id === template.id)} - /> - )} - emptyStateControls={(canAddJT || canAddWFJT) && addButton} - /> - - - {i18n._(t`Failed to delete one or more templates.`)} - - - - ); + if (canAddWFJT) { + addButtonOptions.push({ + label: i18n._(t`Workflow Template`), + url: `/templates/workflow_job_template/add/`, + }); } + const isAllSelected = + selected.length === templates.length && selected.length > 0; + const addButton = ( + + ); + return ( + <> + + ( + , + (canAddJT || canAddWFJT) && addButton, + ]} + /> + )} + renderItem={template => ( + handleSelect(template)} + isSelected={selected.some(row => row.id === template.id)} + /> + )} + emptyStateControls={(canAddJT || canAddWFJT) && addButton} + /> + + setDelectionError(null)} + > + {i18n._(t`Failed to delete one or more templates.`)} + + + + ); } export { TemplatesList as _TemplatesList }; -export default withI18n()(withRouter(TemplatesList)); +export default withI18n()(TemplatesList); diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx index 51bb28c28b..ed32babfd3 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx @@ -59,7 +59,7 @@ const RightActionButtonCell = styled(ActionButtonCell)` ${rightStyle} `; -function TemplateListItem({ i18n, template, isSelected, onSelect }) { +function TemplateListItem({ i18n, template, isSelected, onSelect, detailUrl }) { const canLaunch = template.summary_fields.user_capabilities.start; const missingResourceIcon = @@ -86,7 +86,7 @@ function TemplateListItem({ i18n, template, isSelected, onSelect }) { - + {template.name} diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplatesList.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplatesList.test.jsx index 108caece3a..58eefbbe5b 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplatesList.test.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplatesList.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { Route } from 'react-router-dom'; import { @@ -8,7 +9,7 @@ import { } from '@api'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import TemplatesList, { _TemplatesList } from './TemplateList'; +import TemplatesList from './TemplateList'; jest.mock('@api'); @@ -90,119 +91,181 @@ describe('', () => { jest.clearAllMocks(); }); - test('initially renders successfully', () => { - mountWithContexts( - - ); - }); - - test('Templates are retrieved from the api and the components finishes loading', async done => { - const loadTemplates = jest.spyOn(_TemplatesList.prototype, 'loadTemplates'); - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'TemplatesList', - el => el.state('hasContentLoading') === true - ); - expect(loadTemplates).toHaveBeenCalled(); - await waitForElement( - wrapper, - 'TemplatesList', - el => el.state('hasContentLoading') === false - ); - done(); - }); - - test('handleSelect is called when a template list item is selected', async done => { - const handleSelect = jest.spyOn(_TemplatesList.prototype, 'handleSelect'); - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'TemplatesList', - el => el.state('hasContentLoading') === false - ); - await wrapper - .find('input#select-jobTemplate-1') - .closest('DataListCheck') - .props() - .onChange(); - expect(handleSelect).toBeCalled(); - await waitForElement( - wrapper, - 'TemplatesList', - el => el.state('selected').length === 1 - ); - done(); - }); - - test('handleSelectAll is called when a template list item is selected', async done => { - const handleSelectAll = jest.spyOn( - _TemplatesList.prototype, - 'handleSelectAll' - ); - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'TemplatesList', - el => el.state('hasContentLoading') === false - ); - wrapper - .find('Checkbox#select-all') - .props() - .onChange(true); - expect(handleSelectAll).toBeCalled(); - await waitForElement( - wrapper, - 'TemplatesList', - el => el.state('selected').length === 5 - ); - done(); - }); - - test('delete button is disabled if user does not have delete capabilities on a selected template', async done => { - const wrapper = mountWithContexts(); - wrapper.find('TemplatesList').setState({ - templates: mockTemplates, - itemCount: 5, - isInitialized: true, - selected: mockTemplates.slice(0, 4), + test('initially renders successfully', async () => { + await act(async () => { + mountWithContexts( + + ); }); - await waitForElement( - wrapper, - 'ToolbarDeleteButton * button', - el => el.getDOMNode().disabled === false - ); - wrapper.find('TemplatesList').setState({ - selected: mockTemplates, - }); - await waitForElement( - wrapper, - 'ToolbarDeleteButton * button', - el => el.getDOMNode().disabled === true - ); - done(); }); - test('api is called to delete templates for each selected template.', () => { - JobTemplatesAPI.destroy = jest.fn(); - WorkflowJobTemplatesAPI.destroy = jest.fn(); + test('Templates are retrieved from the api and the components finishes loading', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(UnifiedJobTemplatesAPI.read).toBeCalled(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + expect(wrapper.find('TemplateListItem').length).toEqual(5); + }); + + test('handleSelect is called when a template list item is selected', async () => { const wrapper = mountWithContexts(); - wrapper.find('TemplatesList').setState({ - templates: mockTemplates, - itemCount: 5, - isInitialized: true, - isModalOpen: true, - selected: mockTemplates.slice(0, 4), + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); - expect(JobTemplatesAPI.destroy).toHaveBeenCalledTimes(3); - expect(WorkflowJobTemplatesAPI.destroy).toHaveBeenCalledTimes(1); + const checkBox = wrapper + .find('TemplateListItem') + .at(1) + .find('input'); + + checkBox.simulate('change', { + target: { + id: 2, + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + + expect( + wrapper + .find('TemplateListItem') + .at(1) + .prop('isSelected') + ).toBe(true); }); - test('error is shown when template not successfully deleted from api', async done => { + test('handleSelectAll is called when a template list item is selected', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(false); + + const toolBarCheckBox = wrapper.find('Checkbox#select-all'); + act(() => { + toolBarCheckBox.prop('onChange')(true); + }); + wrapper.update(); + expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(true); + }); + + test('delete button is disabled if user does not have delete capabilities on a selected template', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const deleteableItem = wrapper + .find('TemplateListItem') + .at(0) + .find('input'); + const nonDeleteableItem = wrapper + .find('TemplateListItem') + .at(4) + .find('input'); + + deleteableItem.simulate('change', { + id: 1, + name: 'Job Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }); + + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + false + ); + deleteableItem.simulate('change', { + id: 1, + name: 'Job Template 1', + url: '/templates/job_template/1', + type: 'job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + nonDeleteableItem.simulate('change', { + id: 5, + name: 'Workflow Job Template 2', + url: '/templates/workflow_job_template/5', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: false, + }, + }, + }); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + }); + + test('api is called to delete templates for each selected template.', async () => { + const wrapper = mountWithContexts(); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const jobTemplate = wrapper + .find('TemplateListItem') + .at(1) + .find('input'); + const workflowJobTemplate = wrapper + .find('TemplateListItem') + .at(3) + .find('input'); + + jobTemplate.simulate('change', { + target: { + id: 2, + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + + workflowJobTemplate.simulate('change', { + target: { + id: 4, + name: 'Workflow Job Template 1', + url: '/templates/workflow_job_template/4', + type: 'workflow_job_template', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + }); + + wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + wrapper.update(); + await act(async () => { + await wrapper + .find('button[aria-label="confirm delete"]') + .prop('onClick')(); + }); + expect(JobTemplatesAPI.destroy).toBeCalledWith(2); + expect(WorkflowJobTemplatesAPI.destroy).toBeCalledWith(4); + }); + + test('error is shown when template not successfully deleted from api', async () => { JobTemplatesAPI.destroy.mockRejectedValue( new Error({ response: { @@ -215,21 +278,35 @@ describe('', () => { }) ); const wrapper = mountWithContexts(); - wrapper.find('TemplatesList').setState({ - templates: mockTemplates, - itemCount: 1, - isInitialized: true, - isModalOpen: true, - selected: mockTemplates.slice(0, 1), + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + const checkBox = wrapper + .find('TemplateListItem') + .at(1) + .find('input'); + + checkBox.simulate('change', { + target: { + id: 'a', + name: 'Job Template 2', + url: '/templates/job_template/2', + type: 'job_template', + summary_fields: { user_capabilities: { delete: true } }, + }, + }); + wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + wrapper.update(); + await act(async () => { + await wrapper + .find('button[aria-label="confirm delete"]') + .prop('onClick')(); }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); await waitForElement( wrapper, 'Modal', el => el.props().isOpen === true && el.props().title === 'Error!' ); - - done(); }); test('Calls API with jobtemplate__project id', async () => { const history = createMemoryHistory({ @@ -252,11 +329,9 @@ describe('', () => { }, } ); - await waitForElement( - wrapper, - 'TemplatesList', - el => el.state('hasContentLoading') === true - ); + await act(async () => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 1); + }); expect(UnifiedJobTemplatesAPI.read).toBeCalledWith({ jobtemplate__project: '6', order_by: 'name',