Merge pull request #7867 from keithjgrant/5651-notification-templates

Notification templates list & details

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-08-11 18:10:35 +00:00 committed by GitHub
commit 025a979cb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1371 additions and 44 deletions

View File

@ -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;

View File

@ -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 (
<>
<DetailName
component={TextListItemVariants.dt}
fullWidth
css="grid-column: 1 / -1"
>
<div className="pf-c-form__label">
<span
className="pf-c-form__label-text"
css="font-weight: var(--pf-global--FontWeight--bold)"
>
{label}
</span>
</div>
</DetailName>
<DetailValue
component={TextListItemVariants.dd}
fullWidth
css="grid-column: 1 / -1; margin-top: -20px"
>
<CodeMirrorInput
mode="json"
value={JSON.stringify(value)}
readOnly
rows={rows}
fullHeight={fullHeight}
css="margin-top: 10px"
/>
</DetailValue>
</>
);
}
ObjectDetail.propTypes = {
value: shape.isRequired,
label: node.isRequired,
rows: number,
};
ObjectDetail.defaultProps = {
rows: null,
};
export default ObjectDetail;

View File

@ -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
*/

View File

@ -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 (
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
{label}
</Label>
);
}
StatusLabel.propTypes = {
status: oneOf([
'success',
'failed',
'error',
'running',
'pending',
'waiting',
'canceled',
]).isRequired,
};

View File

@ -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(<StatusLabel status="success" />);
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(<StatusLabel status="failed" />);
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(<StatusLabel status="error" />);
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(<StatusLabel status="running" />);
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(<StatusLabel status="pending" />);
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(<StatusLabel status="waiting" />);
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(<StatusLabel status="canceled" />);
expect(wrapper).toHaveLength(1);
expect(wrapper.find('ExclamationTriangleIcon')).toHaveLength(1);
expect(wrapper.find('Label').prop('color')).toEqual('orange');
expect(wrapper.text()).toEqual('Canceled');
});
});

View File

@ -0,0 +1 @@
export { default } from './StatusLabel';

View File

@ -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 (
<PageSection>
<Card>
<ContentError error={error}>
{error.response.status === 404 && (
<span>
{i18n._(t`Notification Template not found.`)}{' '}
<Link to="/notification_templates">
{i18n._(t`View all Notification Templates.`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
const showCardHeader = !isLoading && !location.pathname.endsWith('edit');
const tabs = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Notifications`)}
</>
),
link: `/notification_templates`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `${match.url}/details`,
id: 0,
},
];
return (
<PageSection>
<Card>
{showCardHeader && <RoutedTabs tabsArray={tabs} />}
<Switch>
<Redirect
from="/notification_templates/:id"
to="/notification_templates/:id/details"
exact
/>
{template && (
<>
<Route path="/notification_templates/:id/edit">
<NotificationTemplateEdit
template={template}
isLoading={isLoading}
/>
</Route>
<Route path="/notification_templates/:id/details">
<NotificationTemplateDetail
template={template}
isLoading={isLoading}
/>
</Route>
</>
)}
</Switch>
</Card>
</PageSection>
);
}
export default withI18n()(NotificationTemplate);

View File

@ -0,0 +1,5 @@
import React from 'react';
export default function NotificationTemplateAdd() {
return <div />;
}

View File

@ -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 (
<CardBody>
<DetailList gutter="sm">
<Detail
label={i18n._(t`Name`)}
value={template.name}
dataCy="nt-detail-name"
/>
<Detail
label={i18n._(t`Description`)}
value={template.description}
dataCy="nt-detail-description"
/>
{summary_fields.organization ? (
<Detail
label={i18n._(t`Organization`)}
value={
<Link
to={`/organizations/${summary_fields.organization.id}/details`}
>
{summary_fields.organization.name}
</Link>
}
/>
) : (
<DeletedDetail label={i18n._(t`Organization`)} />
)}
<Detail
label={i18n._(t`Notification Type`)}
value={
NOTIFICATION_TYPES[template.notification_type] ||
template.notification_type
}
dataCy="nt-detail-type"
/>
{template.notification_type === 'email' && (
<>
<Detail
label={i18n._(t`Username`)}
value={configuration.username}
dataCy="nt-detail-username"
/>
<Detail
label={i18n._(t`Host`)}
value={configuration.host}
dataCy="nt-detail-host"
/>
<Detail
label={i18n._(t`Recipient List`)}
value={configuration.recipients} // array
dataCy="nt-detail-recipients"
/>
<Detail
label={i18n._(t`Sender Email`)}
value={configuration.sender}
dataCy="nt-detail-sender"
/>
<Detail
label={i18n._(t`Port`)}
value={configuration.port}
dataCy="nt-detail-port"
/>
<Detail
label={i18n._(t`Timeout`)}
value={configuration.timeout}
dataCy="nt-detail-timeout"
/>
<Detail
label={i18n._(t`Email Options`)}
value={
configuration.use_ssl ? i18n._(t`Use SSL`) : i18n._(t`Use TLS`)
}
dataCy="nt-detail-email-options"
/>
</>
)}
{template.notification_type === 'grafana' && (
<>
<Detail
label={i18n._(t`Grafana URL`)}
value={configuration.grafana_url}
dataCy="nt-detail-grafana-url"
/>
<Detail
label={i18n._(t`ID of the Dashboard`)}
value={configuration.dashboardId}
dataCy="nt-detail-dashboard-id"
/>
<Detail
label={i18n._(t`ID of the Panel`)}
value={configuration.panelId}
dataCy="nt-detail-panel-id"
/>
<Detail
label={i18n._(t`Tags for the Annotation`)}
value={configuration.annotation_tags} // array
dataCy="nt-detail-"
/>
<Detail
label={i18n._(t`Disable SSL Verification`)}
value={
configuration.grafana_no_verify_ssl
? i18n._(t`True`)
: i18n._(t`False`)
}
dataCy="nt-detail-disable-ssl"
/>
</>
)}
{template.notification_type === 'irc' && (
<>
<Detail
label={i18n._(t`IRC Server Port`)}
value={configuration.port}
dataCy="nt-detail-irc-port"
/>
<Detail
label={i18n._(t`IRC Server Address`)}
value={configuration.server}
dataCy="nt-detail-irc-server"
/>
<Detail
label={i18n._(t`IRC Nick`)}
value={configuration.nickname}
dataCy="nt-detail-irc-nickname"
/>
<Detail
label={i18n._(t`Destination Channels or Users`)}
value={configuration.targets} // array
dataCy="nt-detail-channels"
/>
<Detail
label={i18n._(t`SSL Connection`)}
value={configuration.use_ssl ? i18n._(t`True`) : i18n._(t`False`)}
dataCy="nt-detail-irc-ssl"
/>
</>
)}
{template.notification_type === 'mattermost' && (
<>
<Detail
label={i18n._(t`Target URL`)}
value={configuration.mattermost_url}
dataCy="nt-detail-mattermost-url"
/>
<Detail
label={i18n._(t`Username`)}
value={configuration.mattermost_username}
dataCy="nt-detail-mattermost-username"
/>
<Detail
label={i18n._(t`Channel`)}
value={configuration.mattermost_channel}
dataCy="nt-detail-mattermost_channel"
/>
<Detail
label={i18n._(t`Icon URL`)}
value={configuration.mattermost_icon_url}
dataCy="nt-detail-mattermost-icon-url"
/>
<Detail
label={i18n._(t`Disable SSL Verification`)}
value={
configuration.mattermost_no_verify_ssl
? i18n._(t`True`)
: i18n._(t`False`)
}
dataCy="nt-detail-disable-ssl"
/>
</>
)}
{template.notification_type === 'pagerduty' && (
<>
<Detail
label={i18n._(t`Pagerduty Subdomain`)}
value={configuration.subdomain}
dataCy="nt-detail-pagerduty-subdomain"
/>
<Detail
label={i18n._(t`API Service/Integration Key`)}
value={configuration.service_key}
dataCy="nt-detail-pagerduty-service-key"
/>
<Detail
label={i18n._(t`Client Identifier`)}
value={configuration.client_name}
dataCy="nt-detail-pagerduty-client-name"
/>
</>
)}
{template.notification_type === 'rocketchat' && (
<>
<Detail
label={i18n._(t`Target URL`)}
value={configuration.rocketchat_url}
dataCy="nt-detail-rocketchat-url"
/>
<Detail
label={i18n._(t`Username`)}
value={configuration.rocketchat_username}
dataCy="nt-detail-pagerduty-rocketchat-username"
/>
<Detail
label={i18n._(t`Icon URL`)}
value={configuration.rocketchat_icon_url}
dataCy="nt-detail-rocketchat-icon-url"
/>
<Detail
label={i18n._(t`Disable SSL Verification`)}
value={
configuration.rocketchat_no_verify_ssl
? i18n._(t`True`)
: i18n._(t`False`)
}
dataCy="nt-detail-disable-ssl"
/>
</>
)}
{template.notification_type === 'slack' && (
<>
<Detail
label={i18n._(t`Destination Channels`)}
value={configuration.channels} // array
dataCy="nt-detail-slack-channels"
/>
<Detail
label={i18n._(t`Notification Color`)}
value={configuration.hex_color}
dataCy="nt-detail-slack-color"
/>
</>
)}
{template.notification_type === 'twilio' && (
<>
<Detail
label={i18n._(t`Source Phone Number`)}
value={configuration.from_number}
dataCy="nt-detail-twilio-source-phone"
/>
<Detail
label={i18n._(t`Destination SMS Number`)}
value={configuration.to_numbers} // array
dataCy="nt-detail-twilio-destination-numbers"
/>
<Detail
label={i18n._(t`Account SID`)}
value={configuration.account_sid}
dataCy="nt-detail-twilio-account-sid"
/>
</>
)}
{template.notification_type === 'webhook' && (
<>
<Detail
label={i18n._(t`Username`)}
value={configuration.username}
dataCy="nt-detail-webhook-password"
/>
<Detail
label={i18n._(t`Target URL`)}
value={configuration.url}
dataCy="nt-detail-webhook-url"
/>
<Detail
label={i18n._(t`Disable SSL Verification`)}
value={
configuration.disable_ssl_verification
? i18n._(t`True`)
: i18n._(t`False`)
}
dataCy="nt-detail-disable-ssl"
/>
<Detail
label={i18n._(t`HTTP Method`)}
value={configuration.http_method}
dataCy="nt-detail-webhook-http-method"
/>
<ObjectDetail
label={i18n._(t`HTTP Headers`)}
value={configuration.headers}
rows="6"
dataCy="nt-detail-webhook-headers"
/>
</>
)}
</DetailList>
<CardActionsRow>
{summary_fields.user_capabilities &&
summary_fields.user_capabilities.edit && (
<Button
component={Link}
to={`/notification_templates/${template.id}/edit`}
aria-label={i18n._(t`Edit`)}
>
{i18n._(t`Edit`)}
</Button>
)}
{summary_fields.user_capabilities &&
summary_fields.user_capabilities.delete && (
<DeleteButton
name={template.name}
modalTitle={i18n._(t`Delete Notification`)}
onConfirm={deleteTemplate}
isDisabled={isLoading}
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
</CardActionsRow>
{error && (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
>
{i18n._(t`Failed to delete notification.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</CardBody>
);
}
export default withI18n()(NotificationTemplateDetail);

View File

@ -0,0 +1,3 @@
import NotificationTemplateDetail from './NotificationTemplateDetail';
export default NotificationTemplateDetail;

View File

@ -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 (
<CardBody>
<Config>
{({ me }) => (
<NotificationTemplateForm
template={template}
onSubmit={handleSubmit}
onCancel={handleCancel}
me={me || {}}
submitError={formError}
/>
)}
</Config>
</CardBody>
);
}
NotificationTemplateEdit.propTypes = {
template: PropTypes.shape().isRequired,
};
NotificationTemplateEdit.contextTypes = {
custom_virtualenvs: PropTypes.arrayOf(PropTypes.string),
};
export { NotificationTemplateEdit as _NotificationTemplateEdit };
export default NotificationTemplateEdit;

View File

@ -0,0 +1,3 @@
import NotificationTemplateEdit from './NotificationTemplateEdit';
export default NotificationTemplateEdit;

View File

@ -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 (
<>
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isTemplatesLoading || isDeleteLoading}
items={templates}
itemCount={count}
pluralizedItemName={i18n._(t`Notification Templates`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Type`),
key: 'notification_type',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Type`),
key: 'notification_type',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={() => setSelected([...templates])}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [<ToolbarAddButton key="add" linkTo={addUrl} />]
: []),
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
itemsToDelete={selected}
pluralizedItemName="Organizations"
/>,
]}
/>
)}
renderItem={template => (
<NotificationTemplateListItem
key={template.id}
template={template}
detailUrl={`${match.url}/${template.id}`}
isSelected={selected.some(row => row.id === template.id)}
onSelect={() => handleSelect(template)}
/>
)}
emptyStateControls={
canAdd ? <ToolbarAddButton key="add" linkTo={addUrl} /> : null
}
/>
</Card>
</PageSection>
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more organizations.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</>
);
}
export default withI18n()(NotificationTemplatesList);

View File

@ -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('<NotificationTemplateList />', () => {
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(<NotificationTemplateList />);
});
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(<NotificationTemplateList />);
});
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(<NotificationTemplateList />);
});
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(<NotificationTemplateList />);
});
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(<NotificationTemplateList />);
});
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(<NotificationTemplateList />);
});
wrapper.update();
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
});
});

View File

@ -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 (
<DataListItem key={template.id} aria-labelledby={labelId} id={template.id}>
<DataListItemRow>
<DataListCheck
id={`select-template-${template.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name" id={labelId}>
<Link to={detailUrl}>
<b>{template.name}</b>
</Link>
</DataListCell>,
<DataListCell key="status">
{status && <StatusLabel status={status} />}
</DataListCell>,
<DataListCell key="type">
<strong>{i18n._(t`Type:`)}</strong>{' '}
{NOTIFICATION_TYPES[template.notification_type] ||
template.notification_type}
</DataListCell>,
]}
/>
<DataListAction aria-label="actions" aria-labelledby={labelId}>
<Tooltip content={i18n._(t`Test Notification`)} position="top">
<Button
aria-label={i18n._(t`Test Notification`)}
variant="plain"
onClick={sendTestNotification}
disabled={isLoading}
>
<BellIcon />
</Button>
</Tooltip>
{template.summary_fields.user_capabilities.edit ? (
<Tooltip
content={i18n._(t`Edit Notification Template`)}
position="top"
>
<Button
aria-label={i18n._(t`Edit Notification Template`)}
variant="plain"
component={Link}
to={`/notification_templates/${template.id}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
) : (
<div />
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
export default withI18n()(NotificationTemplateListItem);

View File

@ -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('<NotificationTemplateListItem />', () => {
test('should render template row', () => {
const wrapper = mountWithContexts(
<NotificationTemplateListItem
template={template}
detailUrl="/notification_templates/3/detail"
/>
);
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(
<NotificationTemplateListItem
template={template}
detailUrl="/notification_templates/3/detail"
/>
);
await act(async () => {
wrapper
.find('Button')
.at(0)
.invoke('onClick')();
});
expect(NotificationTemplatesAPI.test).toHaveBeenCalledTimes(1);
expect(
wrapper
.find('DataListCell')
.at(1)
.text()
).toEqual('Running');
});
});

View File

@ -0,0 +1,4 @@
import NotificationTemplateList from './NotificationTemplateList';
export default NotificationTemplateList;
export { default as NotificationTemplateListItem } from './NotificationTemplateListItem';

View File

@ -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 (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl" headingLevel="h2">
{i18n._(t`Notification Templates`)}
</Title>
</PageSection>
<PageSection />
</Fragment>
);
}
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 (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path={`${match.url}/add`}>
<NotificationTemplateAdd />
</Route>
<Route path={`${match.url}/:id`}>
<NotificationTemplate setBreadcrumb={updateBreadcrumbConfig} />
</Route>
<Route path={`${match.url}`}>
<NotificationTemplateList />
</Route>
</Switch>
</>
);
}
export default withI18n()(NotificationTemplates);

View File

@ -1,18 +1,14 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import NotificationTemplates from './NotificationTemplates';
describe('<NotificationTemplates />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<NotificationTemplates />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
@ -22,8 +18,6 @@ describe('<NotificationTemplates />', () => {
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');
});
});

View File

@ -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',
};

View File

@ -0,0 +1,3 @@
export default function NotificationTemplateForm() {
//
}

View File

@ -62,12 +62,10 @@ function OrganizationListItem({
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<span id={labelId}>
<Link to={`${detailUrl}`}>
<b>{organization.name}</b>
</Link>
</span>
<DataListCell key="name" id={labelId}>
<Link to={`${detailUrl}`}>
<b>{organization.name}</b>
</Link>
</DataListCell>,
<DataListCell key="related-field-counts">
<ListGroup>
@ -85,11 +83,7 @@ function OrganizationListItem({
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
<DataListAction aria-label="actions" aria-labelledby={labelId}>
{organization.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Organization`)} position="top">
<Button

View File

@ -78,7 +78,7 @@ function TemplateListItem({
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<DataListCell key="name" id={labelId}>
<span>
<Link to={`${detailUrl}`}>
<b>{template.name}</b>
@ -105,11 +105,7 @@ function TemplateListItem({
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
<DataListAction aria-label="actions" aria-labelledby={labelId}>
{canLaunch && template.type === 'job_template' && (
<Tooltip content={i18n._(t`Launch Template`)} position="top">
<LaunchButton resource={template}>