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

@ -129,64 +129,53 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
return (
<>
<Row gutter={[16, 16]}>
<Col>
<Row gutter={[16, 16]}>
<Col xs={18}>
<Select
showArrow
bordered={false}
className="tw-text-body webhook-filter-select cursor-pointer"
mode="multiple"
options={statuses}
placeholder="Filter by status"
style={{ minWidth: '148px' }}
onChange={onStatusFilter}
/>
</Col>
<Col xs={6}>
<Space
align="center"
className="tw-w-full tw-justify-end"
size={16}>
{filteredData.length > 0 && (
<Tooltip
placement="left"
title={
addWebhookPermission
? 'Add Webhook'
: NO_PERMISSION_FOR_ACTION
}>
<Button
className={classNames('tw-h-8 tw-rounded ')}
data-testid="add-webhook-button"
disabled={!addWebhookPermission}
size="small"
theme="primary"
variant="contained"
onClick={onAddWebhook}>
Add {WEBHOOKS_INTEGRATION[webhookType]}
</Button>
</Tooltip>
)}
</Space>
</Col>
<Col xs={24}>
<WebhookTable
webhookList={filteredData || []}
onDelete={(data) => setWebhook(data)}
onEdit={onClickWebhook}
/>
{Boolean(!isNil(paging.after) || !isNil(paging.before)) && (
<NextPrevious
currentPage={currentPage}
pageSize={PAGE_SIZE}
paging={paging}
pagingHandler={onPageChange}
totalCount={paging.total}
/>
)}
</Col>
</Row>
<Col xs={18}>
<Select
showArrow
bordered={false}
className="tw-text-body webhook-filter-select cursor-pointer"
mode="multiple"
options={statuses}
placeholder="Filter by status"
style={{ minWidth: '148px' }}
onChange={onStatusFilter}
/>
</Col>
<Col xs={6}>
<Space align="center" className="tw-w-full tw-justify-end" size={16}>
<Tooltip
placement="left"
title={
addWebhookPermission ? 'Add Webhook' : NO_PERMISSION_FOR_ACTION
}>
<Button
className={classNames('tw-h-8 tw-rounded ')}
data-testid="add-webhook-button"
disabled={!addWebhookPermission}
size="small"
theme="primary"
variant="contained"
onClick={onAddWebhook}>
Add {WEBHOOKS_INTEGRATION[webhookType]}
</Button>
</Tooltip>
</Space>
</Col>
<Col xs={24}>
<WebhookTable
webhookList={filteredData || []}
onDelete={(data) => setWebhook(data)}
onEdit={onClickWebhook}
/>
{Boolean(!isNil(paging.after) || !isNil(paging.before)) && (
<NextPrevious
currentPage={currentPage}
pageSize={PAGE_SIZE}
paging={paging}
pagingHandler={onPageChange}
totalCount={paging.total}
/>
)}
</Col>
</Row>
{selectedWebhook && (

View File

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

View File

@ -6,6 +6,8 @@ describe('Test ActivityFeedSettingsPage', () => {
it('should render properly', async () => {
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,
updateFilters,
} from '../../axiosAPIs/eventFiltersAPI';
import ErrorPlaceHolder from '../../components/common/error-with-placeholder/ErrorPlaceHolder';
import Loader from '../../components/Loader/Loader';
import { TERM_ALL } from '../../constants/constants';
import {
@ -187,51 +188,61 @@ const ActivityFeedSettingsPage: React.FC = () => {
<Loader />
</Col>
) : (
<Row gutter={[16, 16]}>
<Col span={24}>
<Typography.Title level={5} type="secondary">
Activity Feed
</Typography.Title>
</Col>
<Col span={24}>
<Card size="small">
{eventFilters &&
map(eventFilters, ({ entityType, filters }, index) => (
<>
{entityType !== TERM_ALL ? (
<div className="tw-rounded-border" key={entityType}>
<Tree
checkable
defaultExpandAll
className="activity-feed-settings-tree"
defaultCheckedKeys={checkedKeys}
icon={null}
key={entityType}
treeData={generateTreeData(entityType, filters)}
onCheck={(keys) =>
handleTreeCheckChange(keys as Key[], entityType)
}
/>
{index !== eventFilters?.length - 1 && <Divider />}
</div>
) : null}
</>
))}
</Card>
</Col>
<Col>
<Space direction="horizontal" size={16}>
<Button type="primary" onClick={onSave}>
Save
</Button>
<Button type="text" onClick={handleResetClick}>
Reset all
</Button>
</Space>
</Col>
<Col span={24} />
<Col span={24} />
</Row>
<>
{eventFilters ? (
<>
<Row gutter={[16, 16]}>
<Col span={24}>
<Typography.Title level={5} type="secondary">
Activity Feed
</Typography.Title>
</Col>
<Col span={24}>
<Card size="small">
{eventFilters &&
map(eventFilters, ({ entityType, filters }, index) => (
<>
{entityType !== TERM_ALL ? (
<div className="tw-rounded-border" key={entityType}>
<Tree
checkable
defaultExpandAll
className="activity-feed-settings-tree"
defaultCheckedKeys={checkedKeys}
icon={null}
key={entityType}
treeData={generateTreeData(entityType, filters)}
onCheck={(keys) =>
handleTreeCheckChange(keys as Key[], entityType)
}
/>
{index !== eventFilters?.length - 1 && <Divider />}
</div>
) : null}
</>
))}
</Card>
</Col>
<Col>
<Space direction="horizontal" size={16}>
<Button type="primary" onClick={onSave}>
Save
</Button>
<Button type="text" onClick={handleResetClick}>
Reset all
</Button>
</Space>
</Col>
<Col span={24} />
<Col span={24} />
</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 {
EventFilter,
EventType,
@ -77,19 +77,30 @@ export const getEventFilterFromTree = (
filters: eventFilter.filters?.map((filter) => {
let includeList = filter.include;
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]) {
// Split the value to get list of [eventType, filter, event]
const temp = updatedTree[eventFilter.entityType].map((key) =>
key.split('-')
);
// grab the list of current eventType
const eventList = temp.filter((f) => f[1] === filter.eventType);
if (eventList.length > 0) {
if (filter.eventType === EventType.EntityUpdated) {
includeList = intersection(
filter.include ?? [],
eventList.map((f) => f[2])
);
excludeList = xor(filter.include, includeList);
// derive include list based on selected events
includeList = eventList.map((f) => f[2]).filter(Boolean);
// derive the exclude list by symmetric difference
excludeList = xor(mergedList, includeList);
} else {
includeList = ['all'];
excludeList = [];

View File

@ -30,11 +30,11 @@ import {
} from '../../constants/globalSettings.constants';
import { FormSubmitType } from '../../enums/form.enum';
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 jsonData from '../../jsons/en';
import { getSettingPath } from '../../utils/RouterUtils';
import { showErrorToast } from '../../utils/ToastUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const EDIT_HEADER_WEBHOOKS_TITLE: { [key: string]: string } = {
msteams: 'MS Teams',
@ -69,11 +69,23 @@ const EditWebhookPage: FunctionComponent = () => {
};
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(
getSettingPath(
GlobalSettingsMenuCategory.INTEGRATIONS,
GlobalSettingOptions.WEBHOOK
)
`${getSettingPath(GlobalSettingsMenuCategory.INTEGRATIONS, type)}`
);
};
@ -92,6 +104,9 @@ const EditWebhookPage: FunctionComponent = () => {
setStatus('initial');
goToWebhooks();
}, 500);
showSuccessToast(
jsonData['api-success-messages']['update-webhook-success']
);
} else {
throw jsonData['api-error-messages']['unexpected-error'];
}