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',