fix(ui): webhook event handling with tree structure (#7164)

* fix(ui): webhook event handling with tree structure

* Fix layout

* minor fix

* Fix edit webhook redirection.

* Fix tree structure issues

Co-authored-by: Sachin Chaurasiya <sachinchaurasiyachotey87@gmail.com>
This commit is contained in:
Chirag Madlani 2022-09-03 09:12:55 +05:30 committed by GitHub
parent c729abaa95
commit 00e6996ac0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 300 additions and 239 deletions

View File

@ -68,3 +68,11 @@ export const resetAllFilters = async () => {
return response.data; return response.data;
}; };
export const getInitialFilters = async () => {
const url = `${BASE_URL}/bootstrappedFilters`;
const response = await axiosClient.get<EventFilter[]>(url);
return response.data;
};

View File

@ -14,7 +14,6 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import { Store } from 'antd/lib/form/interface';
import classNames from 'classnames'; import classNames from 'classnames';
import cryptoRandomString from 'crypto-random-string-with-promisify-polyfill'; import cryptoRandomString from 'crypto-random-string-with-promisify-polyfill';
import { cloneDeep, isEmpty, isNil } from 'lodash'; import { cloneDeep, isEmpty, isNil } from 'lodash';
@ -26,7 +25,7 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { ROUTES, TERM_ALL } from '../../constants/constants'; import { ROUTES } from '../../constants/constants';
import { import {
GlobalSettingOptions, GlobalSettingOptions,
GlobalSettingsMenuCategory, GlobalSettingsMenuCategory,
@ -55,7 +54,6 @@ import {
import { checkPermission } from '../../utils/PermissionsUtils'; import { checkPermission } from '../../utils/PermissionsUtils';
import { getSettingPath } from '../../utils/RouterUtils'; import { getSettingPath } from '../../utils/RouterUtils';
import SVGIcons, { Icons } from '../../utils/SvgUtils'; import SVGIcons, { Icons } from '../../utils/SvgUtils';
import { getEventFilters } from '../../utils/WebhookUtils';
import { Button } from '../buttons/Button/Button'; import { Button } from '../buttons/Button/Button';
import CopyToClipboardButton from '../buttons/CopyToClipboardButton/CopyToClipboardButton'; import CopyToClipboardButton from '../buttons/CopyToClipboardButton/CopyToClipboardButton';
import CardV1 from '../common/Card/CardV1'; import CardV1 from '../common/Card/CardV1';
@ -67,8 +65,7 @@ import ConfirmationModal from '../Modals/ConfirmationModal/ConfirmationModal';
import { usePermissionProvider } from '../PermissionProvider/PermissionProvider'; import { usePermissionProvider } from '../PermissionProvider/PermissionProvider';
import { ResourceEntity } from '../PermissionProvider/PermissionProvider.interface'; import { ResourceEntity } from '../PermissionProvider/PermissionProvider.interface';
import { AddWebhookProps } from './AddWebhook.interface'; import { AddWebhookProps } from './AddWebhook.interface';
import EventFilterSelect from './EventFilterSelect.component'; import EventFilterTree from './EventFilterTree.component';
import { EVENT_FILTER_FORM_INITIAL_VALUE } from './WebhookConstants';
const CONFIGURE_TEXT: { [key: string]: string } = { const CONFIGURE_TEXT: { [key: string]: string } = {
msteams: CONFIGURE_MS_TEAMS_TEXT, msteams: CONFIGURE_MS_TEAMS_TEXT,
@ -80,26 +77,6 @@ const Field = ({ children }: { children: React.ReactNode }) => {
return <div className="tw-mt-4">{children}</div>; return <div className="tw-mt-4">{children}</div>;
}; };
const getFormData = (eventFilters: EventFilter[]): Store => {
if (eventFilters.length === 1 && eventFilters[0].entityType === TERM_ALL) {
return EVENT_FILTER_FORM_INITIAL_VALUE;
}
const formEventFilters = {} as Store;
eventFilters?.forEach((eventFilter) => {
if (eventFilter.entityType === TERM_ALL) {
return;
}
formEventFilters[eventFilter.entityType] = true;
formEventFilters[`${eventFilter.entityType}-tree`] =
eventFilter.filters?.map((filter) => filter.eventType) || [];
});
return formEventFilters;
};
const AddWebhook: FunctionComponent<AddWebhookProps> = ({ const AddWebhook: FunctionComponent<AddWebhookProps> = ({
data, data,
header, header,
@ -113,11 +90,9 @@ const AddWebhook: FunctionComponent<AddWebhookProps> = ({
onSave, onSave,
}: AddWebhookProps) => { }: AddWebhookProps) => {
const markdownRef = useRef<EditorContentRef>(); const markdownRef = useRef<EditorContentRef>();
const [eventFilterFormData, setEventFilterFormData] = useState<Store>( const [eventFilterFormData, setEventFilterFormData] = useState<
data?.eventFilters EventFilter[] | undefined
? getFormData(data?.eventFilters) >(data?.eventFilters);
: EVENT_FILTER_FORM_INITIAL_VALUE
);
const [name, setName] = useState<string>(data?.name || ''); const [name, setName] = useState<string>(data?.name || '');
const [endpointUrl, setEndpointUrl] = useState<string>(data?.endpoint || ''); const [endpointUrl, setEndpointUrl] = useState<string>(data?.endpoint || '');
const [description] = useState<string>(data?.description || ''); const [description] = useState<string>(data?.description || '');
@ -250,7 +225,7 @@ const AddWebhook: FunctionComponent<AddWebhookProps> = ({
name, name,
description: markdownRef.current?.getEditorContent() || undefined, description: markdownRef.current?.getEditorContent() || undefined,
endpoint: endpointUrl, endpoint: endpointUrl,
eventFilters: getEventFilters(eventFilterFormData), eventFilters: eventFilterFormData ?? ([] as EventFilter[]),
batchSize, batchSize,
timeout: connectionTimeout, timeout: connectionTimeout,
enabled: active, enabled: active,
@ -468,9 +443,9 @@ const AddWebhook: FunctionComponent<AddWebhookProps> = ({
</span>, </span>,
'tw-mt-3' 'tw-mt-3'
)} )}
<EventFilterSelect <EventFilterTree
eventFilterFormData={eventFilterFormData} value={eventFilterFormData || []}
setEventFilterFormData={(data) => setEventFilterFormData(data)} onChange={setEventFilterFormData}
/> />
<Field> <Field>
<div className="tw-flex tw-justify-end tw-pt-1"> <div className="tw-flex tw-justify-end tw-pt-1">

View File

@ -1,89 +0,0 @@
import { Col, Form, Row, TreeSelect } from 'antd';
import Checkbox from 'antd/lib/checkbox/Checkbox';
import { Store } from 'antd/lib/form/interface';
import { startCase } from 'lodash';
import React, { useMemo } from 'react';
import {
EventFilter,
EventType,
} from '../../generated/api/events/createWebhook';
import { Entities } from './WebhookConstants';
export enum EventUpdateTypes {
UpdatedFollowers = 'updatedFollowers',
UpdatedTags = 'updatedTags',
UpdatedOwner = 'updatedOwner',
UpdateDescription = 'updateDescription',
}
interface EventFilterSelectProps {
eventFilterFormData: Store;
setEventFilterFormData: (formData: EventFilter[]) => void;
}
const EventFilterSelect = ({
eventFilterFormData,
setEventFilterFormData,
}: EventFilterSelectProps) => {
const metricsOptions = useMemo(
() => [
{
title: 'All',
value: 'all',
key: 'all',
children: Object.values(EventType).map((metric) => ({
title: startCase(metric),
value: metric,
key: metric,
children:
metric === EventType.EntityUpdated
? Object.values(EventUpdateTypes).map((updateType) => ({
title: startCase(updateType),
value: `${EventType.EntityUpdated}-${updateType}`,
key: `${EventType.EntityUpdated}-${updateType}`,
}))
: undefined,
})),
},
],
[]
);
return (
<Form
autoComplete="off"
initialValues={eventFilterFormData}
layout="vertical"
onValuesChange={(_, data) => {
setEventFilterFormData(data);
}}>
<Row gutter={16}>
{Object.keys(Entities).map((key) => {
const value = Entities[key];
return (
<Col key={key} span={12}>
<Form.Item
name={key}
style={{ marginBottom: 4 }}
valuePropName="checked">
<Checkbox>{value}</Checkbox>
</Form.Item>
<Form.Item name={`${key}-tree`} style={{ marginBottom: 8 }}>
<TreeSelect
treeCheckable
disabled={!eventFilterFormData[key]}
maxTagCount={2}
placeholder="Please select"
showCheckedStrategy="SHOW_PARENT"
treeData={metricsOptions}
/>
</Form.Item>
</Col>
);
})}
</Row>
</Form>
);
};
export default EventFilterSelect;

View File

@ -0,0 +1,138 @@
import { Divider, Tree } from 'antd';
import { cloneDeep, isEmpty, map, startCase } from 'lodash';
import React, { Key, useEffect, useMemo, useState } from 'react';
import { getInitialFilters } from '../../axiosAPIs/eventFiltersAPI';
import { TERM_ALL } from '../../constants/constants';
import { EventFilter } from '../../generated/api/events/createWebhook';
import { Filters } from '../../generated/settings/settings';
import { getEventFilterFromTree } from '../../pages/ActivityFeedSettingsPage/ActivityFeedSettingsPage.utils';
import './../../pages/ActivityFeedSettingsPage/ActivityFeedSettingsPage.style.less';
interface EventFilterTreeProps {
value: EventFilter[];
onChange: (data: EventFilter[]) => void;
}
const EventFilterTree = ({ value, onChange }: EventFilterTreeProps) => {
const [updatedTree, setUpdatedTree] = useState<Record<string, string[]>>();
const [initialFilters, setInitialFilters] = useState<EventFilter[]>([]);
const eventFilters = isEmpty(value) ? initialFilters : value;
const fetchInitialFilters = async () => {
try {
const data = await getInitialFilters();
setInitialFilters(data);
isEmpty(value) && onChange(getEventFilterFromTree({}, eventFilters));
} catch (error) {
// eslint-disable-next-line no-console
console.error(`filed to fetch event filters `);
}
};
useEffect(() => {
fetchInitialFilters();
}, []);
const generateTreeData = (entityType: string, data?: Filters[]) => {
return [
{
key: entityType,
title: <strong>{startCase(entityType)}</strong>,
data: true,
children:
data?.map(({ eventType, include, exclude }) => {
const key = `${entityType}-${eventType}` as string;
return {
key: key,
title: startCase(eventType),
data: isEmpty(value) ? true : include,
children:
(include?.length === 1 && include[0] === TERM_ALL) ||
(exclude?.length === 1 && exclude[0] === TERM_ALL)
? undefined
: [
...(include?.map((inc) => ({
key: `${key}-${inc}`,
title: startCase(inc),
data: true,
})) || []),
...(exclude?.map((ex) => ({
key: `${key}-${ex}`,
title: startCase(ex),
data: false,
})) || []),
],
};
}) || [],
},
];
};
const handleTreeCheckChange = (keys: Key[], entityType: string) => {
const updateData = cloneDeep(updatedTree || {});
updateData[entityType] = keys as string[];
onChange(getEventFilterFromTree(cloneDeep(updateData), eventFilters));
setUpdatedTree(updateData);
};
const getCheckedKeys = (eventFilters: EventFilter[]) => {
const checkedArray = [] as string[];
const clonedFilters = cloneDeep(eventFilters);
clonedFilters?.map(({ entityType, filters }) => {
filters &&
filters.map((obj) => {
if (
obj.include &&
obj.include.length === 1 &&
obj.include[0] === 'all'
) {
checkedArray.push(`${entityType}-${obj.eventType}`);
} else {
obj?.include?.forEach((entityUpdated) => {
const name = `${entityType}-${obj.eventType}-${entityUpdated}`;
checkedArray.push(name);
});
}
});
});
return checkedArray;
};
const checkedKeys = useMemo(() => {
const checkKeys = getCheckedKeys(eventFilters as EventFilter[]);
return checkKeys;
}, [eventFilters, updatedTree]);
return (
<>
{initialFilters &&
map(initialFilters, ({ entityType, filters }, index) => (
<>
{entityType !== TERM_ALL ? (
<div className="tw-rounded-border" key={entityType}>
<Tree
checkable
defaultExpandAll
className="activity-feed-settings-tree"
defaultCheckedKeys={checkedKeys}
key={entityType}
treeData={generateTreeData(entityType, filters)}
onCheck={(keys) =>
handleTreeCheckChange(keys as Key[], entityType)
}
/>
{index !== initialFilters.length - 1 && <Divider />}
</div>
) : null}
</>
))}
</>
);
};
export default EventFilterTree;

View File

@ -128,8 +128,6 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
return ( return (
<> <>
<Row gutter={[16, 16]}>
<Col>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={18}> <Col xs={18}>
<Select <Select
@ -144,17 +142,11 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
/> />
</Col> </Col>
<Col xs={6}> <Col xs={6}>
<Space <Space align="center" className="tw-w-full tw-justify-end" size={16}>
align="center"
className="tw-w-full tw-justify-end"
size={16}>
{filteredData.length > 0 && (
<Tooltip <Tooltip
placement="left" placement="left"
title={ title={
addWebhookPermission addWebhookPermission ? 'Add Webhook' : NO_PERMISSION_FOR_ACTION
? 'Add Webhook'
: NO_PERMISSION_FOR_ACTION
}> }>
<Button <Button
className={classNames('tw-h-8 tw-rounded ')} className={classNames('tw-h-8 tw-rounded ')}
@ -167,7 +159,6 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
Add {WEBHOOKS_INTEGRATION[webhookType]} Add {WEBHOOKS_INTEGRATION[webhookType]}
</Button> </Button>
</Tooltip> </Tooltip>
)}
</Space> </Space>
</Col> </Col>
<Col xs={24}> <Col xs={24}>
@ -187,8 +178,6 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
)} )}
</Col> </Col>
</Row> </Row>
</Col>
</Row>
{selectedWebhook && ( {selectedWebhook && (
<ConfirmationModal <ConfirmationModal
bodyText={`You want to delete webhook ${selectedWebhook.name} permanently? This action cannot be reverted.`} bodyText={`You want to delete webhook ${selectedWebhook.name} permanently? This action cannot be reverted.`}

View File

@ -169,6 +169,7 @@ const jsonData = {
'update-profile-congif-success': 'Profile config updated successfully!', 'update-profile-congif-success': 'Profile config updated successfully!',
'update-test-case-success': 'Test case updated successfully!', 'update-test-case-success': 'Test case updated successfully!',
'update-webhook-success': 'Webhook updated successfully!',
}, },
'form-error-messages': { 'form-error-messages': {
'empty-email': 'Email is required.', 'empty-email': 'Email is required.',

View File

@ -6,6 +6,8 @@ describe('Test ActivityFeedSettingsPage', () => {
it('should render properly', async () => { it('should render properly', async () => {
const { findByText } = render(<ActivityFeedSettingsPage />); const { findByText } = render(<ActivityFeedSettingsPage />);
expect(await findByText(/Activity Feed/)).toBeInTheDocument(); expect(
await findByText(/No activity feed settings available/)
).toBeInTheDocument();
}); });
}); });

View File

@ -9,6 +9,7 @@ import {
resetAllFilters, resetAllFilters,
updateFilters, updateFilters,
} from '../../axiosAPIs/eventFiltersAPI'; } from '../../axiosAPIs/eventFiltersAPI';
import ErrorPlaceHolder from '../../components/common/error-with-placeholder/ErrorPlaceHolder';
import Loader from '../../components/Loader/Loader'; import Loader from '../../components/Loader/Loader';
import { TERM_ALL } from '../../constants/constants'; import { TERM_ALL } from '../../constants/constants';
import { import {
@ -187,6 +188,9 @@ const ActivityFeedSettingsPage: React.FC = () => {
<Loader /> <Loader />
</Col> </Col>
) : ( ) : (
<>
{eventFilters ? (
<>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={24}> <Col span={24}>
<Typography.Title level={5} type="secondary"> <Typography.Title level={5} type="secondary">
@ -232,6 +236,13 @@ const ActivityFeedSettingsPage: React.FC = () => {
<Col span={24} /> <Col span={24} />
<Col span={24} /> <Col span={24} />
</Row> </Row>
</>
) : (
<ErrorPlaceHolder>
<Typography.Text>No activity feed settings available</Typography.Text>
</ErrorPlaceHolder>
)}
</>
); );
}; };

View File

@ -1,4 +1,4 @@
import { intersection, isEmpty, isUndefined, xor } from 'lodash'; import { isEmpty, isUndefined, xor } from 'lodash';
import { import {
EventFilter, EventFilter,
EventType, EventType,
@ -77,19 +77,30 @@ export const getEventFilterFromTree = (
filters: eventFilter.filters?.map((filter) => { filters: eventFilter.filters?.map((filter) => {
let includeList = filter.include; let includeList = filter.include;
let excludeList = filter.exclude; let excludeList = filter.exclude;
// derive the merge list
const mergedList = [
...(includeList as string[]),
...(excludeList as string[]),
];
// manipulate tree if event type is present
if (updatedTree[eventFilter.entityType]) { if (updatedTree[eventFilter.entityType]) {
// Split the value to get list of [eventType, filter, event]
const temp = updatedTree[eventFilter.entityType].map((key) => const temp = updatedTree[eventFilter.entityType].map((key) =>
key.split('-') key.split('-')
); );
// grab the list of current eventType
const eventList = temp.filter((f) => f[1] === filter.eventType); const eventList = temp.filter((f) => f[1] === filter.eventType);
if (eventList.length > 0) { if (eventList.length > 0) {
if (filter.eventType === EventType.EntityUpdated) { if (filter.eventType === EventType.EntityUpdated) {
includeList = intersection( // derive include list based on selected events
filter.include ?? [], includeList = eventList.map((f) => f[2]).filter(Boolean);
eventList.map((f) => f[2])
); // derive the exclude list by symmetric difference
excludeList = xor(filter.include, includeList); excludeList = xor(mergedList, includeList);
} else { } else {
includeList = ['all']; includeList = ['all'];
excludeList = []; excludeList = [];

View File

@ -30,11 +30,11 @@ import {
} from '../../constants/globalSettings.constants'; } from '../../constants/globalSettings.constants';
import { FormSubmitType } from '../../enums/form.enum'; import { FormSubmitType } from '../../enums/form.enum';
import { CreateWebhook } from '../../generated/api/events/createWebhook'; import { CreateWebhook } from '../../generated/api/events/createWebhook';
import { Webhook } from '../../generated/entity/events/webhook'; import { Webhook, WebhookType } from '../../generated/entity/events/webhook';
import { useAuth } from '../../hooks/authHooks'; import { useAuth } from '../../hooks/authHooks';
import jsonData from '../../jsons/en'; import jsonData from '../../jsons/en';
import { getSettingPath } from '../../utils/RouterUtils'; import { getSettingPath } from '../../utils/RouterUtils';
import { showErrorToast } from '../../utils/ToastUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const EDIT_HEADER_WEBHOOKS_TITLE: { [key: string]: string } = { const EDIT_HEADER_WEBHOOKS_TITLE: { [key: string]: string } = {
msteams: 'MS Teams', msteams: 'MS Teams',
@ -69,11 +69,23 @@ const EditWebhookPage: FunctionComponent = () => {
}; };
const goToWebhooks = () => { const goToWebhooks = () => {
let type = GlobalSettingOptions.WEBHOOK;
switch (webhookData?.webhookType) {
case WebhookType.Msteams:
type = GlobalSettingOptions.MSTEAMS;
break;
case WebhookType.Slack:
type = GlobalSettingOptions.SLACK;
break;
default:
break;
}
history.push( history.push(
getSettingPath( `${getSettingPath(GlobalSettingsMenuCategory.INTEGRATIONS, type)}`
GlobalSettingsMenuCategory.INTEGRATIONS,
GlobalSettingOptions.WEBHOOK
)
); );
}; };
@ -92,6 +104,9 @@ const EditWebhookPage: FunctionComponent = () => {
setStatus('initial'); setStatus('initial');
goToWebhooks(); goToWebhooks();
}, 500); }, 500);
showSuccessToast(
jsonData['api-success-messages']['update-webhook-success']
);
} else { } else {
throw jsonData['api-error-messages']['unexpected-error']; throw jsonData['api-error-messages']['unexpected-error'];
} }