diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx
new file mode 100644
index 0000000000..e0a210f8c0
--- /dev/null
+++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.jsx
@@ -0,0 +1,280 @@
+import React, { Fragment, useEffect, useState, useCallback } from 'react';
+import { useLocation } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Card } from '@patternfly/react-core';
+
+import {
+ JobTemplatesAPI,
+ UnifiedJobTemplatesAPI,
+ WorkflowJobTemplatesAPI,
+} from '../../../api';
+import AlertModal from '../../../components/AlertModal';
+import DatalistToolbar from '../../../components/DataListToolbar';
+import ErrorDetail from '../../../components/ErrorDetail';
+import PaginatedDataList, {
+ ToolbarDeleteButton,
+} from '../../../components/PaginatedDataList';
+import useRequest, { useDeleteItems } from '../../../util/useRequest';
+import { getQSConfig, parseQueryString } from '../../../util/qs';
+import useWsTemplates from '../../../util/useWsTemplates';
+import AddDropDownButton from '../../../components/AddDropDownButton';
+
+import DashboardTemplateListItem from './DashboardTemplateListItem';
+
+const QS_CONFIG = getQSConfig(
+ 'template',
+ {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+ type: 'job_template,workflow_job_template',
+ },
+ ['id', 'page', 'page_size']
+);
+
+function DashboardTemplateList({ i18n }) {
+ // The type value in const QS_CONFIG below does not have a space between job_template and
+ // workflow_job_template so the params sent to the API match what the api expects.
+
+ const location = useLocation();
+
+ const [selected, setSelected] = useState([]);
+
+ const {
+ result: {
+ results,
+ count,
+ jtActions,
+ wfjtActions,
+ relatedSearchableKeys,
+ searchableKeys,
+ },
+ error: contentError,
+ isLoading,
+ request: fetchTemplates,
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, location.search);
+ const responses = await Promise.all([
+ UnifiedJobTemplatesAPI.read(params),
+ JobTemplatesAPI.readOptions(),
+ WorkflowJobTemplatesAPI.readOptions(),
+ UnifiedJobTemplatesAPI.readOptions(),
+ ]);
+ return {
+ results: responses[0].data.results,
+ count: responses[0].data.count,
+ jtActions: responses[1].data.actions,
+ wfjtActions: responses[2].data.actions,
+ relatedSearchableKeys: (
+ responses[3]?.data?.related_search_fields || []
+ ).map(val => val.slice(0, -8)),
+ searchableKeys: Object.keys(
+ responses[3].data.actions?.GET || {}
+ ).filter(key => responses[3].data.actions?.GET[key].filterable),
+ };
+ }, [location]),
+ {
+ results: [],
+ count: 0,
+ jtActions: {},
+ wfjtActions: {},
+ relatedSearchableKeys: [],
+ searchableKeys: [],
+ }
+ );
+
+ useEffect(() => {
+ fetchTemplates();
+ }, [fetchTemplates]);
+
+ const templates = useWsTemplates(results);
+
+ const isAllSelected =
+ selected.length === templates.length && selected.length > 0;
+ const {
+ isLoading: isDeleteLoading,
+ deleteItems: deleteTemplates,
+ deletionError,
+ clearDeletionError,
+ } = useDeleteItems(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(({ type, id }) => {
+ if (type === 'job_template') {
+ return JobTemplatesAPI.destroy(id);
+ }
+ if (type === 'workflow_job_template') {
+ return WorkflowJobTemplatesAPI.destroy(id);
+ }
+ return false;
+ })
+ );
+ }, [selected]),
+ {
+ qsConfig: QS_CONFIG,
+ allItemsSelected: isAllSelected,
+ fetchItems: fetchTemplates,
+ }
+ );
+
+ const handleTemplateDelete = async () => {
+ await deleteTemplates();
+ setSelected([]);
+ };
+
+ const handleSelectAll = isSelected => {
+ setSelected(isSelected ? [...templates] : []);
+ };
+
+ const handleSelect = template => {
+ if (selected.some(s => s.id === template.id)) {
+ setSelected(selected.filter(s => s.id !== template.id));
+ } else {
+ setSelected(selected.concat(template));
+ }
+ };
+
+ const canAddJT =
+ jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST');
+ const canAddWFJT =
+ wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST');
+ // spreading Set() returns only unique keys
+ const addButtonOptions = [];
+
+ if (canAddJT) {
+ addButtonOptions.push({
+ label: i18n._(t`Job Template`),
+ url: `/templates/job_template/add/`,
+ });
+ }
+
+ if (canAddWFJT) {
+ addButtonOptions.push({
+ label: i18n._(t`Workflow Template`),
+ url: `/templates/workflow_job_template/add/`,
+ });
+ }
+
+ const addButton = (
+
+ );
+
+ return (
+
+
+ (
+ ,
+ ]}
+ />
+ )}
+ renderItem={template => (
+ handleSelect(template)}
+ isSelected={selected.some(row => row.id === template.id)}
+ fetchTemplates={fetchTemplates}
+ />
+ )}
+ emptyStateControls={(canAddJT || canAddWFJT) && addButton}
+ />
+
+
+ {i18n._(t`Failed to delete one or more templates.`)}
+
+
+
+ );
+}
+
+export default withI18n()(DashboardTemplateList);
diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx
new file mode 100644
index 0000000000..5b33d3d717
--- /dev/null
+++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateList.test.jsx
@@ -0,0 +1,336 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ JobTemplatesAPI,
+ UnifiedJobTemplatesAPI,
+ WorkflowJobTemplatesAPI,
+} from '../../../api';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+
+import DashboardTemplateList from './DashboardTemplateList';
+
+jest.mock('../../../api');
+
+const mockTemplates = [
+ {
+ id: 1,
+ name: 'Job Template 1',
+ url: '/templates/job_template/1',
+ type: 'job_template',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ edit: true,
+ copy: true,
+ },
+ },
+ },
+ {
+ id: 2,
+ name: 'Job Template 2',
+ url: '/templates/job_template/2',
+ type: 'job_template',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ },
+ },
+ },
+ {
+ id: 3,
+ name: 'Job Template 3',
+ url: '/templates/job_template/3',
+ type: 'job_template',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ },
+ },
+ },
+ {
+ id: 4,
+ name: 'Workflow Job Template 1',
+ url: '/templates/workflow_job_template/4',
+ type: 'workflow_job_template',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ },
+ },
+ },
+ {
+ id: 5,
+ name: 'Workflow Job Template 2',
+ url: '/templates/workflow_job_template/5',
+ type: 'workflow_job_template',
+ summary_fields: {
+ user_capabilities: {
+ delete: false,
+ },
+ },
+ },
+];
+
+describe('', () => {
+ let debug;
+ beforeEach(() => {
+ UnifiedJobTemplatesAPI.read.mockResolvedValue({
+ data: {
+ count: mockTemplates.length,
+ results: mockTemplates,
+ },
+ });
+
+ UnifiedJobTemplatesAPI.readOptions.mockResolvedValue({
+ data: {
+ actions: [],
+ },
+ });
+ debug = global.console.debug; // eslint-disable-line prefer-destructuring
+ global.console.debug = () => {};
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ global.console.debug = debug;
+ });
+
+ test('initially renders successfully', async () => {
+ await act(async () => {
+ mountWithContexts(
+
+ );
+ });
+ });
+
+ 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('DashboardTemplateListItem').length).toEqual(5);
+ });
+
+ test('handleSelect is called when a template list item is selected', async () => {
+ const wrapper = mountWithContexts();
+ await act(async () => {
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+ const checkBox = wrapper
+ .find('DashboardTemplateListItem')
+ .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('DashboardTemplateListItem')
+ .at(1)
+ .prop('isSelected')
+ ).toBe(true);
+ });
+
+ 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('DashboardTemplateListItem')
+ .at(0)
+ .find('input');
+ const nonDeleteableItem = wrapper
+ .find('DashboardTemplateListItem')
+ .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('DashboardTemplateListItem')
+ .at(1)
+ .find('input');
+ const workflowJobTemplate = wrapper
+ .find('DashboardTemplateListItem')
+ .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,
+ },
+ },
+ },
+ });
+
+ await act(async () => {
+ 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: {
+ config: {
+ method: 'delete',
+ url: '/api/v2/job_templates/1',
+ },
+ data: 'An error occurred',
+ },
+ })
+ );
+ const wrapper = mountWithContexts();
+ await act(async () => {
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+ const checkBox = wrapper
+ .find('DashboardTemplateListItem')
+ .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 } },
+ },
+ });
+ await act(async () => {
+ wrapper.find('button[aria-label="Delete"]').prop('onClick')();
+ });
+ wrapper.update();
+ await act(async () => {
+ await wrapper
+ .find('button[aria-label="confirm delete"]')
+ .prop('onClick')();
+ });
+
+ await waitForElement(
+ wrapper,
+ 'Modal[aria-label="Deletion Error"]',
+ el => el.props().isOpen === true && el.props().title === 'Error!'
+ );
+ });
+ test('should properly copy template', async () => {
+ JobTemplatesAPI.copy.mockResolvedValue({});
+ const wrapper = mountWithContexts();
+ await act(async () => {
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+ await act(async () =>
+ wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
+ );
+ expect(JobTemplatesAPI.copy).toHaveBeenCalled();
+ expect(UnifiedJobTemplatesAPI.read).toHaveBeenCalled();
+ wrapper.update();
+ });
+});
diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx
new file mode 100644
index 0000000000..f06cf71aeb
--- /dev/null
+++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.jsx
@@ -0,0 +1,179 @@
+import 'styled-components/macro';
+import React, { useState, useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import {
+ Button,
+ DataListAction as _DataListAction,
+ DataListCheck,
+ DataListItem,
+ DataListItemRow,
+ DataListItemCells,
+ Tooltip,
+} from '@patternfly/react-core';
+import { t } from '@lingui/macro';
+import { withI18n } from '@lingui/react';
+import {
+ ExclamationTriangleIcon,
+ PencilAltIcon,
+ ProjectDiagramIcon,
+ RocketIcon,
+} from '@patternfly/react-icons';
+import styled from 'styled-components';
+
+import DataListCell from '../../../components/DataListCell';
+import { timeOfDay } from '../../../util/dates';
+import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
+import LaunchButton from '../../../components/LaunchButton';
+import Sparkline from '../../../components/Sparkline';
+import { toTitleCase } from '../../../util/strings';
+import CopyButton from '../../../components/CopyButton';
+
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(4, 40px);
+`;
+
+function DashboardTemplateListItem({
+ i18n,
+ template,
+ isSelected,
+ onSelect,
+ detailUrl,
+ fetchTemplates,
+}) {
+ const [isDisabled, setIsDisabled] = useState(false);
+ const labelId = `check-action-${template.id}`;
+
+ const copyTemplate = useCallback(async () => {
+ if (template.type === 'job_template') {
+ await JobTemplatesAPI.copy(template.id, {
+ name: `${template.name} @ ${timeOfDay()}`,
+ });
+ } else {
+ await WorkflowJobTemplatesAPI.copy(template.id, {
+ name: `${template.name} @ ${timeOfDay()}`,
+ });
+ }
+ await fetchTemplates();
+ }, [fetchTemplates, template.id, template.name, template.type]);
+
+ const handleCopyStart = useCallback(() => {
+ setIsDisabled(true);
+ }, []);
+
+ const handleCopyFinish = useCallback(() => {
+ setIsDisabled(false);
+ }, []);
+
+ const missingResourceIcon =
+ template.type === 'job_template' &&
+ (!template.summary_fields.project ||
+ (!template.summary_fields.inventory &&
+ !template.ask_inventory_on_launch));
+ return (
+
+
+
+
+
+
+ {template.name}
+
+
+ {missingResourceIcon && (
+
+
+
+
+
+ )}
+ ,
+
+ {toTitleCase(template.type)}
+ ,
+
+
+ ,
+ ]}
+ />
+
+ {template.type === 'workflow_job_template' && (
+
+
+
+ )}
+ {template.summary_fields.user_capabilities.start && (
+
+
+ {({ handleLaunch }) => (
+
+ )}
+
+
+ )}
+ {template.summary_fields.user_capabilities.edit && (
+
+
+
+ )}
+ {template.summary_fields.user_capabilities.copy && (
+
+ )}
+
+
+
+ );
+}
+
+export { DashboardTemplateListItem as _TemplateListItem };
+export default withI18n()(DashboardTemplateListItem);
diff --git a/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx
new file mode 100644
index 0000000000..571ef260c0
--- /dev/null
+++ b/awx/ui_next/src/screens/Dashboard/shared/DashboardTemplateListItem.test.jsx
@@ -0,0 +1,268 @@
+import React from 'react';
+import { createMemoryHistory } from 'history';
+import { act } from 'react-dom/test-utils';
+
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { JobTemplatesAPI } from '../../../api';
+
+import mockJobTemplateData from './data.job_template.json';
+import DashboardTemplateListItem from './DashboardTemplateListItem';
+
+jest.mock('../../../api');
+
+describe('', () => {
+ test('launch button shown to users with start capabilities', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
+ });
+ test('launch button hidden from users without start capabilities', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('LaunchButton').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('missing resource icon is shown.', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
+ });
+ test('missing resource icon is not shown when there is a project and an inventory.', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
+ });
+ test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
+ });
+ test('missing resource icon is not shown type is workflow_job_template', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
+ });
+ test('clicking on template from templates list navigates properly', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/templates'],
+ });
+ const wrapper = mountWithContexts(
+ ,
+ { context: { router: { history } } }
+ );
+ wrapper.find('Link').simulate('click', { button: 0 });
+ expect(history.location.pathname).toEqual(
+ '/templates/job_template/1/details'
+ );
+ });
+ test('should call api to copy template', async () => {
+ JobTemplatesAPI.copy.mockResolvedValue();
+
+ const wrapper = mountWithContexts(
+
+ );
+ await act(async () =>
+ wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
+ );
+ expect(JobTemplatesAPI.copy).toHaveBeenCalled();
+ jest.clearAllMocks();
+ });
+
+ test('should render proper alert modal on copy error', async () => {
+ JobTemplatesAPI.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);
+ });
+
+ test('should render visualizer button for workflow', async () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
+ });
+
+ test('should not render visualizer button for job template', async () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
index 455bf4ce6c..c225b6ec49 100644
--- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
+++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
@@ -1,8 +1,8 @@
-import React, { useEffect, useState, useCallback } from 'react';
+import React, { Fragment, useEffect, useState, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { Card, PageSection } from '@patternfly/react-core';
+import { Card } from '@patternfly/react-core';
import {
JobTemplatesAPI,
@@ -17,7 +17,7 @@ import PaginatedDataList, {
} from '../../../components/PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs';
-import useWsTemplates from './useWsTemplates';
+import useWsTemplates from '../../../util/useWsTemplates';
import AddDropDownButton from '../../../components/AddDropDownButton';
import TemplateListItem from './TemplateListItem';
@@ -156,7 +156,7 @@ function TemplateList({ i18n }) {
);
return (
-
+
-
+
);
}
diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx
index 0b529a95af..d63d8f7d46 100644
--- a/awx/ui_next/src/screens/Template/Templates.jsx
+++ b/awx/ui_next/src/screens/Template/Templates.jsx
@@ -2,6 +2,7 @@ import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Route, withRouter, Switch } from 'react-router-dom';
+import { PageSection } from '@patternfly/react-core';
import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
@@ -120,7 +121,9 @@ class Templates extends Component {
)}
/>
-
+
+
+
>
diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js b/awx/ui_next/src/util/useWsTemplates.jsx
similarity index 96%
rename from awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js
rename to awx/ui_next/src/util/useWsTemplates.jsx
index fa10424c85..3c6a8d4d9c 100644
--- a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js
+++ b/awx/ui_next/src/util/useWsTemplates.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
-import useWebsocket from '../../../util/useWebsocket';
+import useWebsocket from './useWebsocket';
export default function useWsTemplates(initialTemplates) {
const [templates, setTemplates] = useState(initialTemplates);
diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx b/awx/ui_next/src/util/useWsTemplates.test.jsx
similarity index 97%
rename from awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx
rename to awx/ui_next/src/util/useWsTemplates.test.jsx
index 61bc6e042e..9b32fb3eac 100644
--- a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx
+++ b/awx/ui_next/src/util/useWsTemplates.test.jsx
@@ -1,14 +1,14 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import WS from 'jest-websocket-mock';
-import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { mountWithContexts } from '../../testUtils/enzymeHelpers';
import useWsTemplates from './useWsTemplates';
/*
Jest mock timers don’t play well with jest-websocket-mock,
so we'll stub out throttling to resolve immediately
*/
-jest.mock('../../../util/useThrottle', () => ({
+jest.mock('./useThrottle', () => ({
__esModule: true,
default: jest.fn(val => val),
}));