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 ( + + ); +} + +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 ? (