diff --git a/awx/ui_next/src/api/models/NotificationTemplates.js b/awx/ui_next/src/api/models/NotificationTemplates.js
index 7736921ad2..69cd5f4022 100644
--- a/awx/ui_next/src/api/models/NotificationTemplates.js
+++ b/awx/ui_next/src/api/models/NotificationTemplates.js
@@ -5,6 +5,10 @@ class NotificationTemplates extends Base {
super(http);
this.baseUrl = '/api/v2/notification_templates/';
}
+
+ test(id) {
+ return this.http.post(`${this.baseUrl}${id}/test/`);
+ }
}
export default NotificationTemplates;
diff --git a/awx/ui_next/src/components/DetailList/ObjectDetail.jsx b/awx/ui_next/src/components/DetailList/ObjectDetail.jsx
new file mode 100644
index 0000000000..bf008866a8
--- /dev/null
+++ b/awx/ui_next/src/components/DetailList/ObjectDetail.jsx
@@ -0,0 +1,51 @@
+import 'styled-components/macro';
+import React from 'react';
+import { shape, node, number } from 'prop-types';
+import { TextListItemVariants } from '@patternfly/react-core';
+import { DetailName, DetailValue } from './Detail';
+import CodeMirrorInput from '../CodeMirrorInput';
+
+function ObjectDetail({ value, label, rows, fullHeight }) {
+ return (
+ <>
+
+
+
+ {label}
+
+
+
+
+
+
+ >
+ );
+}
+ObjectDetail.propTypes = {
+ value: shape.isRequired,
+ label: node.isRequired,
+ rows: number,
+};
+ObjectDetail.defaultProps = {
+ rows: null,
+};
+
+export default ObjectDetail;
diff --git a/awx/ui_next/src/components/DetailList/index.js b/awx/ui_next/src/components/DetailList/index.js
index 6a12824bad..8bebb27ce4 100644
--- a/awx/ui_next/src/components/DetailList/index.js
+++ b/awx/ui_next/src/components/DetailList/index.js
@@ -3,3 +3,8 @@ export { default as Detail, DetailName, DetailValue } from './Detail';
export { default as DeletedDetail } from './DeletedDetail';
export { default as UserDateDetail } from './UserDateDetail';
export { default as DetailBadge } from './DetailBadge';
+/*
+ NOTE: ObjectDetail cannot be imported here, as it causes circular
+ dependencies in testing environment. Import it directly from
+ DetailList/ObjectDetail
+*/
diff --git a/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx b/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx
new file mode 100644
index 0000000000..0f2be56fdc
--- /dev/null
+++ b/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx
@@ -0,0 +1,68 @@
+import 'styled-components/macro';
+import React from 'react';
+import { oneOf } from 'prop-types';
+import { Label } from '@patternfly/react-core';
+import {
+ CheckCircleIcon,
+ ExclamationCircleIcon,
+ SyncAltIcon,
+ ExclamationTriangleIcon,
+ ClockIcon,
+} from '@patternfly/react-icons';
+import styled, { keyframes } from 'styled-components';
+
+const Spin = keyframes`
+ from {
+ transform: rotate(0);
+ }
+ to {
+ transform: rotate(1turn);
+ }
+`;
+
+const RunningIcon = styled(SyncAltIcon)`
+ animation: ${Spin} 1.75s linear infinite;
+`;
+
+const colors = {
+ success: 'green',
+ failed: 'red',
+ error: 'red',
+ running: 'blue',
+ pending: 'blue',
+ waiting: 'grey',
+ canceled: 'orange',
+};
+const icons = {
+ success: CheckCircleIcon,
+ failed: ExclamationCircleIcon,
+ error: ExclamationCircleIcon,
+ running: RunningIcon,
+ pending: ClockIcon,
+ waiting: ClockIcon,
+ canceled: ExclamationTriangleIcon,
+};
+
+export default function StatusLabel({ status }) {
+ const label = status.charAt(0).toUpperCase() + status.slice(1);
+ const color = colors[status] || 'grey';
+ const Icon = icons[status];
+
+ return (
+ : null}>
+ {label}
+
+ );
+}
+
+StatusLabel.propTypes = {
+ status: oneOf([
+ 'success',
+ 'failed',
+ 'error',
+ 'running',
+ 'pending',
+ 'waiting',
+ 'canceled',
+ ]).isRequired,
+};
diff --git a/awx/ui_next/src/components/StatusLabel/StatusLabel.test.jsx b/awx/ui_next/src/components/StatusLabel/StatusLabel.test.jsx
new file mode 100644
index 0000000000..58fb6c1a28
--- /dev/null
+++ b/awx/ui_next/src/components/StatusLabel/StatusLabel.test.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import StatusLabel from './StatusLabel';
+
+describe('StatusLabel', () => {
+ test('should render success', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('CheckCircleIcon')).toHaveLength(1);
+ expect(wrapper.find('Label').prop('color')).toEqual('green');
+ expect(wrapper.text()).toEqual('Success');
+ });
+
+ test('should render failed', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1);
+ expect(wrapper.find('Label').prop('color')).toEqual('red');
+ expect(wrapper.text()).toEqual('Failed');
+ });
+
+ test('should render error', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1);
+ expect(wrapper.find('Label').prop('color')).toEqual('red');
+ expect(wrapper.text()).toEqual('Error');
+ });
+
+ test('should render running', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('SyncAltIcon')).toHaveLength(1);
+ expect(wrapper.find('Label').prop('color')).toEqual('blue');
+ expect(wrapper.text()).toEqual('Running');
+ });
+
+ test('should render pending', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('ClockIcon')).toHaveLength(1);
+ expect(wrapper.find('Label').prop('color')).toEqual('blue');
+ expect(wrapper.text()).toEqual('Pending');
+ });
+
+ test('should render waiting', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('ClockIcon')).toHaveLength(1);
+ expect(wrapper.find('Label').prop('color')).toEqual('grey');
+ expect(wrapper.text()).toEqual('Waiting');
+ });
+
+ test('should render canceled', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('ExclamationTriangleIcon')).toHaveLength(1);
+ expect(wrapper.find('Label').prop('color')).toEqual('orange');
+ expect(wrapper.text()).toEqual('Canceled');
+ });
+});
diff --git a/awx/ui_next/src/components/StatusLabel/index.js b/awx/ui_next/src/components/StatusLabel/index.js
new file mode 100644
index 0000000000..b9dfc8cd99
--- /dev/null
+++ b/awx/ui_next/src/components/StatusLabel/index.js
@@ -0,0 +1 @@
+export { default } from './StatusLabel';
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplate.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplate.jsx
new file mode 100644
index 0000000000..7b8516f787
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplate.jsx
@@ -0,0 +1,113 @@
+import React, { useEffect, useCallback } from 'react';
+import { t } from '@lingui/macro';
+import { withI18n } from '@lingui/react';
+import { Card, PageSection } from '@patternfly/react-core';
+import { CaretLeftIcon } from '@patternfly/react-icons';
+import {
+ Link,
+ Switch,
+ Route,
+ Redirect,
+ useParams,
+ useRouteMatch,
+ useLocation,
+} from 'react-router-dom';
+import useRequest from '../../util/useRequest';
+import RoutedTabs from '../../components/RoutedTabs';
+import ContentError from '../../components/ContentError';
+import { NotificationTemplatesAPI } from '../../api';
+import NotificationTemplateDetail from './NotificationTemplateDetail';
+import NotificationTemplateEdit from './NotificationTemplateEdit';
+
+function NotificationTemplate({ setBreadcrumb, i18n }) {
+ const { id: templateId } = useParams();
+ const match = useRouteMatch();
+ const location = useLocation();
+ const {
+ result: template,
+ isLoading,
+ error,
+ request: fetchTemplate,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await NotificationTemplatesAPI.readDetail(templateId);
+ setBreadcrumb(data);
+ return data;
+ }, [templateId, setBreadcrumb]),
+ null
+ );
+
+ useEffect(() => {
+ fetchTemplate();
+ }, [fetchTemplate]);
+
+ if (error) {
+ return (
+
+
+
+ {error.response.status === 404 && (
+
+ {i18n._(t`Notification Template not found.`)}{' '}
+
+ {i18n._(t`View all Notification Templates.`)}
+
+
+ )}
+
+
+
+ );
+ }
+
+ const showCardHeader = !isLoading && !location.pathname.endsWith('edit');
+ const tabs = [
+ {
+ name: (
+ <>
+
+ {i18n._(t`Back to Notifications`)}
+ >
+ ),
+ link: `/notification_templates`,
+ id: 99,
+ },
+ {
+ name: i18n._(t`Details`),
+ link: `${match.url}/details`,
+ id: 0,
+ },
+ ];
+ return (
+
+
+ {showCardHeader && }
+
+
+ {template && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+export default withI18n()(NotificationTemplate);
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateAdd.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateAdd.jsx
new file mode 100644
index 0000000000..bbf39b61a9
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateAdd.jsx
@@ -0,0 +1,5 @@
+import React from 'react';
+
+export default function NotificationTemplateAdd() {
+ return
;
+}
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx
new file mode 100644
index 0000000000..951ba5bd8b
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx
@@ -0,0 +1,361 @@
+import React, { useCallback } from 'react';
+import { Link, useHistory } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { Button } from '@patternfly/react-core';
+import { t } from '@lingui/macro';
+import AlertModal from '../../../components/AlertModal';
+import { CardBody, CardActionsRow } from '../../../components/Card';
+import {
+ Detail,
+ DetailList,
+ DeletedDetail,
+} from '../../../components/DetailList';
+import ObjectDetail from '../../../components/DetailList/ObjectDetail';
+import DeleteButton from '../../../components/DeleteButton';
+import ErrorDetail from '../../../components/ErrorDetail';
+import { NotificationTemplatesAPI } from '../../../api';
+import useRequest, { useDismissableError } from '../../../util/useRequest';
+import { NOTIFICATION_TYPES } from '../constants';
+
+function NotificationTemplateDetail({ i18n, template }) {
+ const history = useHistory();
+
+ const {
+ notification_configuration: configuration,
+ summary_fields,
+ } = template;
+
+ const { request: deleteTemplate, isLoading, error: deleteError } = useRequest(
+ useCallback(async () => {
+ await NotificationTemplatesAPI.destroy(template.id);
+ history.push(`/notification_templates`);
+ }, [template.id, history])
+ );
+
+ const { error, dismissError } = useDismissableError(deleteError);
+
+ return (
+
+
+
+
+ {summary_fields.organization ? (
+
+ {summary_fields.organization.name}
+
+ }
+ />
+ ) : (
+
+ )}
+
+ {template.notification_type === 'email' && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+ {template.notification_type === 'grafana' && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {template.notification_type === 'irc' && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {template.notification_type === 'mattermost' && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {template.notification_type === 'pagerduty' && (
+ <>
+
+
+
+ >
+ )}
+ {template.notification_type === 'rocketchat' && (
+ <>
+
+
+
+
+ >
+ )}
+ {template.notification_type === 'slack' && (
+ <>
+
+
+ >
+ )}
+ {template.notification_type === 'twilio' && (
+ <>
+
+
+
+ >
+ )}
+ {template.notification_type === 'webhook' && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+ {summary_fields.user_capabilities &&
+ summary_fields.user_capabilities.edit && (
+
+ )}
+ {summary_fields.user_capabilities &&
+ summary_fields.user_capabilities.delete && (
+
+ {i18n._(t`Delete`)}
+
+ )}
+
+ {error && (
+
+ {i18n._(t`Failed to delete notification.`)}
+
+
+ )}
+
+ );
+}
+
+export default withI18n()(NotificationTemplateDetail);
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/index.js b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/index.js
new file mode 100644
index 0000000000..118818bb64
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/index.js
@@ -0,0 +1,3 @@
+import NotificationTemplateDetail from './NotificationTemplateDetail';
+
+export default NotificationTemplateDetail;
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/NotificationTemplateEdit.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/NotificationTemplateEdit.jsx
new file mode 100644
index 0000000000..b089b6b89f
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/NotificationTemplateEdit.jsx
@@ -0,0 +1,68 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useHistory } from 'react-router-dom';
+import { CardBody } from '../../../components/Card';
+import { OrganizationsAPI } from '../../../api';
+import { Config } from '../../../contexts/Config';
+
+import NotificationTemplateForm from '../shared/NotificationTemplateForm';
+
+function NotificationTemplateEdit({ template }) {
+ const detailsUrl = `/notification_templates/${template.id}/details`;
+ const history = useHistory();
+ const [formError, setFormError] = useState(null);
+
+ const handleSubmit = async (
+ values,
+ groupsToAssociate,
+ groupsToDisassociate
+ ) => {
+ try {
+ await OrganizationsAPI.update(template.id, values);
+ await Promise.all(
+ groupsToAssociate.map(id =>
+ OrganizationsAPI.associateInstanceGroup(template.id, id)
+ )
+ );
+ await Promise.all(
+ groupsToDisassociate.map(id =>
+ OrganizationsAPI.disassociateInstanceGroup(template.id, id)
+ )
+ );
+ history.push(detailsUrl);
+ } catch (error) {
+ setFormError(error);
+ }
+ };
+
+ const handleCancel = () => {
+ history.push(detailsUrl);
+ };
+
+ return (
+
+
+ {({ me }) => (
+
+ )}
+
+
+ );
+}
+
+NotificationTemplateEdit.propTypes = {
+ template: PropTypes.shape().isRequired,
+};
+
+NotificationTemplateEdit.contextTypes = {
+ custom_virtualenvs: PropTypes.arrayOf(PropTypes.string),
+};
+
+export { NotificationTemplateEdit as _NotificationTemplateEdit };
+export default NotificationTemplateEdit;
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/index.js b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/index.js
new file mode 100644
index 0000000000..be9b40a69c
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateEdit/index.js
@@ -0,0 +1,3 @@
+import NotificationTemplateEdit from './NotificationTemplateEdit';
+
+export default NotificationTemplateEdit;
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx
new file mode 100644
index 0000000000..3dac16f6a2
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx
@@ -0,0 +1,170 @@
+import React, { useCallback, useEffect } from 'react';
+import { useLocation, useRouteMatch } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Card, PageSection } from '@patternfly/react-core';
+import { NotificationTemplatesAPI } from '../../../api';
+import PaginatedDataList, {
+ ToolbarAddButton,
+ ToolbarDeleteButton,
+} from '../../../components/PaginatedDataList';
+import AlertModal from '../../../components/AlertModal';
+import ErrorDetail from '../../../components/ErrorDetail';
+import DataListToolbar from '../../../components/DataListToolbar';
+import NotificationTemplateListItem from './NotificationTemplateListItem';
+import useRequest, { useDeleteItems } from '../../../util/useRequest';
+import useSelected from '../../../util/useSelected';
+import { getQSConfig, parseQueryString } from '../../../util/qs';
+
+const QS_CONFIG = getQSConfig('notification-templates', {
+ page: 1,
+ page_size: 20,
+ order_by: 'name',
+});
+
+function NotificationTemplatesList({ i18n }) {
+ const location = useLocation();
+ const match = useRouteMatch();
+
+ const addUrl = `${match.url}/add`;
+
+ const {
+ result: { templates, count, actions },
+ error: contentError,
+ isLoading: isTemplatesLoading,
+ request: fetchTemplates,
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, location.search);
+ const responses = await Promise.all([
+ NotificationTemplatesAPI.read(params),
+ NotificationTemplatesAPI.readOptions(),
+ ]);
+ return {
+ templates: responses[0].data.results,
+ count: responses[0].data.count,
+ actions: responses[1].data.actions,
+ };
+ }, [location]),
+ {
+ templates: [],
+ count: 0,
+ actions: {},
+ }
+ );
+
+ useEffect(() => {
+ fetchTemplates();
+ }, [fetchTemplates]);
+
+ const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
+ templates
+ );
+
+ const {
+ isLoading: isDeleteLoading,
+ deleteItems: deleteTemplates,
+ deletionError,
+ clearDeletionError,
+ } = useDeleteItems(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(({ id }) => NotificationTemplatesAPI.destroy(id))
+ );
+ }, [selected]),
+ {
+ qsConfig: QS_CONFIG,
+ allItemsSelected: isAllSelected,
+ fetchItems: fetchTemplates,
+ }
+ );
+
+ const handleDelete = async () => {
+ await deleteTemplates();
+ setSelected([]);
+ };
+
+ const canAdd = actions && actions.POST;
+
+ return (
+ <>
+
+
+ (
+ setSelected([...templates])}
+ qsConfig={QS_CONFIG}
+ additionalControls={[
+ ...(canAdd
+ ? []
+ : []),
+ ,
+ ]}
+ />
+ )}
+ renderItem={template => (
+ row.id === template.id)}
+ onSelect={() => handleSelect(template)}
+ />
+ )}
+ emptyStateControls={
+ canAdd ? : null
+ }
+ />
+
+
+
+ {i18n._(t`Failed to delete one or more organizations.`)}
+
+
+ >
+ );
+}
+
+export default withI18n()(NotificationTemplatesList);
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx
new file mode 100644
index 0000000000..d39bffe087
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx
@@ -0,0 +1,202 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { OrganizationsAPI } from '../../../api';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import NotificationTemplateList from './NotificationTemplateList';
+
+jest.mock('../../../api');
+
+const mockTemplates = {
+ data: {
+ count: 3,
+ results: [
+ {
+ name: 'Boston',
+ id: 1,
+ url: '/notification_templates/1',
+ type: 'slack',
+ summary_fields: {
+ recent_notifications: [
+ {
+ status: 'success',
+ },
+ ],
+ user_capabilities: {
+ delete: true,
+ edit: true,
+ },
+ },
+ },
+ {
+ name: 'Minneapolis',
+ id: 2,
+ url: '/notification_templates/2',
+ summary_fields: {
+ recent_notifications: [],
+ user_capabilities: {
+ delete: true,
+ edit: true,
+ },
+ },
+ },
+ {
+ name: 'Philidelphia',
+ id: 3,
+ url: '/notification_templates/3',
+ summary_fields: {
+ recent_notifications: [
+ {
+ status: 'failed',
+ },
+ {
+ status: 'success',
+ },
+ ],
+ user_capabilities: {
+ delete: true,
+ edit: true,
+ },
+ },
+ },
+ ],
+ },
+};
+
+describe('', () => {
+ let wrapper;
+ beforeEach(() => {
+ OrganizationsAPI.read.mockResolvedValue(mockTemplates);
+ OrganizationsAPI.readOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {},
+ POST: {},
+ },
+ },
+ });
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('should load notifications', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+ expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('NotificationTemplateListItem').length).toBe(3);
+ });
+
+ test('should select item', async () => {
+ const itemCheckboxInput = 'input#select-template-1';
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+ expect(wrapper.find(itemCheckboxInput).prop('checked')).toEqual(false);
+ await act(async () => {
+ wrapper
+ .find(itemCheckboxInput)
+ .closest('DataListCheck')
+ .props()
+ .onChange();
+ });
+ wrapper.update();
+ expect(wrapper.find(itemCheckboxInput).prop('checked')).toEqual(true);
+ });
+
+ test('should delete notifications', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+ expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
+ await act(async () => {
+ wrapper
+ .find('Checkbox#select-all')
+ .props()
+ .onChange(true);
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('button[aria-label="Delete"]').simulate('click');
+ wrapper.update();
+ });
+ const deleteButton = global.document.querySelector(
+ 'body div[role="dialog"] button[aria-label="confirm delete"]'
+ );
+ expect(deleteButton).not.toEqual(null);
+ await act(async () => {
+ deleteButton.click();
+ });
+ expect(OrganizationsAPI.destroy).toHaveBeenCalledTimes(3);
+ expect(OrganizationsAPI.read).toHaveBeenCalledTimes(2);
+ });
+
+ test('should show error dialog shown for failed deletion', async () => {
+ const itemCheckboxInput = 'input#select-template-1';
+ OrganizationsAPI.destroy.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'delete',
+ url: '/api/v2/organizations/1',
+ },
+ data: 'An error occurred',
+ },
+ })
+ );
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper
+ .find(itemCheckboxInput)
+ .closest('DataListCheck')
+ .props()
+ .onChange();
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('button[aria-label="Delete"]').simulate('click');
+ wrapper.update();
+ });
+ const deleteButton = global.document.querySelector(
+ 'body div[role="dialog"] button[aria-label="confirm delete"]'
+ );
+ expect(deleteButton).not.toEqual(null);
+ await act(async () => {
+ deleteButton.click();
+ });
+ wrapper.update();
+
+ const modal = wrapper.find('Modal');
+ expect(modal.prop('isOpen')).toEqual(true);
+ expect(modal.prop('title')).toEqual('Error!');
+ });
+
+ test('should show add button', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+ expect(wrapper.find('ToolbarAddButton').length).toBe(1);
+ });
+
+ test('should hide add button (rbac)', async () => {
+ OrganizationsAPI.readOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {},
+ },
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+ expect(wrapper.find('ToolbarAddButton').length).toBe(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx
new file mode 100644
index 0000000000..0087e7f9a8
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx
@@ -0,0 +1,122 @@
+import 'styled-components/macro';
+import React, { useState, useEffect, useCallback } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import {
+ Button,
+ DataListAction as _DataListAction,
+ DataListCheck,
+ DataListItem,
+ DataListItemCells,
+ DataListItemRow,
+ Tooltip,
+} from '@patternfly/react-core';
+import { PencilAltIcon, BellIcon } from '@patternfly/react-icons';
+import { NotificationTemplatesAPI } from '../../../api';
+import DataListCell from '../../../components/DataListCell';
+import StatusLabel from '../../../components/StatusLabel';
+import useRequest from '../../../util/useRequest';
+import { NOTIFICATION_TYPES } from '../constants';
+
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: 40px 40px;
+`;
+
+function NotificationTemplateListItem({
+ template,
+ detailUrl,
+ isSelected,
+ onSelect,
+ i18n,
+}) {
+ const recentNotifications = template.summary_fields?.recent_notifications;
+ const latestStatus = recentNotifications
+ ? recentNotifications[0]?.status
+ : null;
+ const [status, setStatus] = useState(latestStatus);
+
+ useEffect(() => {
+ setStatus(latestStatus);
+ }, [latestStatus]);
+
+ const { request: sendTestNotification, isLoading, error } = useRequest(
+ useCallback(() => {
+ NotificationTemplatesAPI.test(template.id);
+ setStatus('running');
+ }, [template.id])
+ );
+
+ useEffect(() => {
+ if (error) {
+ setStatus('error');
+ }
+ }, [error]);
+
+ const labelId = `template-name-${template.id}`;
+
+ return (
+
+
+
+
+
+ {template.name}
+
+ ,
+
+ {status && }
+ ,
+
+ {i18n._(t`Type:`)}{' '}
+ {NOTIFICATION_TYPES[template.notification_type] ||
+ template.notification_type}
+ ,
+ ]}
+ />
+
+
+
+
+ {template.summary_fields.user_capabilities.edit ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+export default withI18n()(NotificationTemplateListItem);
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx
new file mode 100644
index 0000000000..5a4566779e
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { NotificationTemplatesAPI } from '../../../api';
+import NotificationTemplateListItem from './NotificationTemplateListItem';
+
+jest.mock('../../../api/models/NotificationTemplates');
+
+const template = {
+ id: 3,
+ notification_type: 'slack',
+ name: 'Test Notification',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ },
+ recent_notifications: [
+ {
+ status: 'success',
+ },
+ ],
+ },
+};
+
+describe('', () => {
+ test('should render template row', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ const cells = wrapper.find('DataListCell');
+ expect(cells).toHaveLength(3);
+ expect(cells.at(0).text()).toEqual('Test Notification');
+ expect(cells.at(1).text()).toEqual('Success');
+ expect(cells.at(2).text()).toEqual('Type: Slack');
+ });
+
+ test('should send test notification', async () => {
+ NotificationTemplatesAPI.test.mockResolvedValue({});
+
+ const wrapper = mountWithContexts(
+
+ );
+ await act(async () => {
+ wrapper
+ .find('Button')
+ .at(0)
+ .invoke('onClick')();
+ });
+ expect(NotificationTemplatesAPI.test).toHaveBeenCalledTimes(1);
+ expect(
+ wrapper
+ .find('DataListCell')
+ .at(1)
+ .text()
+ ).toEqual('Running');
+ });
+});
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/index.js b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/index.js
new file mode 100644
index 0000000000..335e76dd6c
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/index.js
@@ -0,0 +1,4 @@
+import NotificationTemplateList from './NotificationTemplateList';
+
+export default NotificationTemplateList;
+export { default as NotificationTemplateListItem } from './NotificationTemplateListItem';
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx
index 857201bc6b..2ae913202f 100644
--- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.jsx
@@ -1,28 +1,51 @@
-import React, { Component, Fragment } from 'react';
+import React, { useState, useCallback } from 'react';
+import { Route, Switch, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import {
- PageSection,
- PageSectionVariants,
- Title,
-} from '@patternfly/react-core';
+import NotificationTemplateList from './NotificationTemplateList';
+import NotificationTemplateAdd from './NotificationTemplateAdd';
+import NotificationTemplate from './NotificationTemplate';
+import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
-class NotificationTemplates extends Component {
- render() {
- const { i18n } = this.props;
- const { light } = PageSectionVariants;
+function NotificationTemplates({ i18n }) {
+ const match = useRouteMatch();
+ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
+ '/notification_templates': i18n._(t`Notification Templates`),
+ '/notification_templates/add': i18n._(t`Create New Notification Template`),
+ });
- return (
-
-
-
- {i18n._(t`Notification Templates`)}
-
-
-
-
- );
- }
+ const updateBreadcrumbConfig = useCallback(
+ notification => {
+ const { id } = notification;
+ setBreadcrumbConfig({
+ '/notification_templates': i18n._(t`Notification Templates`),
+ '/notification_templates/add': i18n._(
+ t`Create New Notification Template`
+ ),
+ [`/notification_templates/${id}`]: notification.name,
+ [`/notification_templates/${id}/edit`]: i18n._(t`Edit Details`),
+ [`/notification_templates/${id}/details`]: i18n._(t`Details`),
+ });
+ },
+ [i18n]
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
}
export default withI18n()(NotificationTemplates);
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx
index 93babc8e06..9333850cf9 100644
--- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplates.test.jsx
@@ -1,18 +1,14 @@
import React from 'react';
-
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
-
import NotificationTemplates from './NotificationTemplates';
describe('', () => {
let pageWrapper;
let pageSections;
- let title;
beforeEach(() => {
pageWrapper = mountWithContexts();
pageSections = pageWrapper.find('PageSection');
- title = pageWrapper.find('Title');
});
afterEach(() => {
@@ -22,8 +18,6 @@ describe('', () => {
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
- expect(title.length).toBe(1);
- expect(title.props().size).toBe('2xl');
expect(pageSections.first().props().variant).toBe('light');
});
});
diff --git a/awx/ui_next/src/screens/NotificationTemplate/constants.js b/awx/ui_next/src/screens/NotificationTemplate/constants.js
new file mode 100644
index 0000000000..5937e48743
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/constants.js
@@ -0,0 +1,12 @@
+/* eslint-disable-next-line import/prefer-default-export */
+export const NOTIFICATION_TYPES = {
+ email: 'Email',
+ grafana: 'Grafana',
+ irc: 'IRC',
+ mattermost: 'Mattermost',
+ pagerduty: 'Pagerduty',
+ rocketchat: 'Rocket.Chat',
+ slack: 'Slack',
+ twilio: 'Twilio',
+ webhook: 'Webhook',
+};
diff --git a/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx b/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx
new file mode 100644
index 0000000000..c08caaa3e5
--- /dev/null
+++ b/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx
@@ -0,0 +1,3 @@
+export default function NotificationTemplateForm() {
+ //
+}
diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
index 51f78c173c..37d01a9e0a 100644
--- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
+++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
@@ -62,12 +62,10 @@ function OrganizationListItem({
/>
-
-
- {organization.name}
-
-
+
+
+ {organization.name}
+
,
@@ -85,11 +83,7 @@ function OrganizationListItem({
,
]}
/>
-
+
{organization.summary_fields.user_capabilities.edit ? (
+
{template.name}
@@ -105,11 +105,7 @@ function TemplateListItem({
,
]}
/>
-
+
{canLaunch && template.type === 'job_template' && (