Added Team Webhook (#6973)

* Added Team Webhook

* Ms Teams UI for setting Page

* Updated Review Comment Changes

* minor changes

* Fix webhook types

* Updated Parser for Teams

* Change MsTeams content and created Justify Card

* Fixed failing tests cases

Co-authored-by: Ashish gupta <ag939431@gmail.com>
Co-authored-by: Ashish Gupta <ashish@getcollate.io>
This commit is contained in:
mohitdeuex 2022-08-30 11:10:02 +05:30 committed by GitHub
parent 0ca86c1a24
commit b7351c220a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 559 additions and 83 deletions

View File

@ -0,0 +1,64 @@
package org.openmetadata.catalog.events;
import java.util.concurrent.TimeUnit;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Invocation;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.catalog.events.errors.EventPublisherException;
import org.openmetadata.catalog.jdbi3.CollectionDAO;
import org.openmetadata.catalog.resources.events.EventResource;
import org.openmetadata.catalog.slack.SlackRetriableException;
import org.openmetadata.catalog.slack.TeamsMessage;
import org.openmetadata.catalog.type.ChangeEvent;
import org.openmetadata.catalog.type.Webhook;
import org.openmetadata.catalog.util.ChangeEventParser;
@Slf4j
public class MSTeamsWebhookPublisher extends WebhookPublisher {
private final Invocation.Builder target;
private final Client client;
public MSTeamsWebhookPublisher(Webhook webhook, CollectionDAO dao) {
super(webhook, dao);
String msTeamsWebhookURL = webhook.getEndpoint().toString();
ClientBuilder clientBuilder = ClientBuilder.newBuilder();
clientBuilder.connectTimeout(webhook.getTimeout(), TimeUnit.SECONDS);
clientBuilder.readTimeout(webhook.getReadTimeout(), TimeUnit.SECONDS);
client = clientBuilder.build();
target = client.target(msTeamsWebhookURL).request();
}
@Override
public void onStart() {
LOG.info("Slack Webhook Publisher Started");
}
@Override
public void onShutdown() {
if (client != null) {
client.close();
}
}
@Override
public void publish(EventResource.ChangeEventList events) throws EventPublisherException {
for (ChangeEvent event : events.getData()) {
try {
TeamsMessage teamsMessage = ChangeEventParser.buildTeamsMessage(event);
Response response =
target.post(javax.ws.rs.client.Entity.entity(teamsMessage, MediaType.APPLICATION_JSON_TYPE));
if (response.getStatus() >= 300 && response.getStatus() < 400) {
throw new EventPublisherException(
"Slack webhook callback is getting redirected. " + "Please check your configuration");
} else if (response.getStatus() >= 300 && response.getStatus() < 600) {
throw new SlackRetriableException(response.getStatusInfo().getReasonPhrase());
}
} catch (Exception e) {
LOG.error("Failed to publish event {} to slack due to {} ", event, e.getMessage());
}
}
}
}

View File

@ -28,9 +28,9 @@ import lombok.extern.slf4j.Slf4j;
import org.openmetadata.catalog.Entity;
import org.openmetadata.catalog.events.EventPubSub;
import org.openmetadata.catalog.events.EventPubSub.ChangeEventHolder;
import org.openmetadata.catalog.events.MSTeamsWebhookPublisher;
import org.openmetadata.catalog.events.WebhookPublisher;
import org.openmetadata.catalog.filter.EventFilter;
import org.openmetadata.catalog.kafka.KafkaWebhookEventPublisher;
import org.openmetadata.catalog.resources.events.WebhookResource;
import org.openmetadata.catalog.slack.SlackWebhookEventPublisher;
import org.openmetadata.catalog.type.Webhook;
@ -90,8 +90,8 @@ public class WebhookRepository extends EntityRepository<Webhook> {
WebhookPublisher publisher;
if (webhook.getWebhookType() == WebhookType.slack) {
publisher = new SlackWebhookEventPublisher(webhook, daoCollection);
} else if (webhook.getWebhookType() == WebhookType.kafka) {
publisher = new KafkaWebhookEventPublisher(webhook, daoCollection);
} else if (webhook.getWebhookType() == WebhookType.msteams) {
publisher = new MSTeamsWebhookPublisher(webhook, daoCollection);
} else {
publisher = new WebhookPublisher(webhook, daoCollection);
}

View File

@ -24,8 +24,8 @@ public class SlackWebhookEventPublisher extends WebhookPublisher {
super(webhook, dao);
String slackWebhookURL = webhook.getEndpoint().toString();
ClientBuilder clientBuilder = ClientBuilder.newBuilder();
clientBuilder.connectTimeout(10, TimeUnit.SECONDS);
clientBuilder.readTimeout(12, TimeUnit.SECONDS);
clientBuilder.connectTimeout(webhook.getTimeout(), TimeUnit.SECONDS);
clientBuilder.readTimeout(webhook.getReadTimeout(), TimeUnit.SECONDS);
client = clientBuilder.build();
target = client.target(slackWebhookURL).request();
}

View File

@ -0,0 +1,34 @@
package org.openmetadata.catalog.slack;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@JsonIgnoreProperties(ignoreUnknown = true)
@Getter
@Setter
public class TeamsMessage {
@Getter
@Setter
public static class Section {
@JsonProperty("activityTitle")
public String activityTitle;
@JsonProperty("activityText")
public String activityText;
}
@JsonProperty("@type")
public String type = "MessageCard";
@JsonProperty("@context")
public String context = "http://schema.org/extensions";
@JsonProperty("summary")
public String summary;
@JsonProperty("sections")
public List<Section> sections;
}

View File

@ -41,6 +41,7 @@ import org.openmetadata.catalog.EntityInterface;
import org.openmetadata.catalog.resources.feeds.MessageParser.EntityLink;
import org.openmetadata.catalog.slack.SlackAttachment;
import org.openmetadata.catalog.slack.SlackMessage;
import org.openmetadata.catalog.slack.TeamsMessage;
import org.openmetadata.catalog.type.ChangeDescription;
import org.openmetadata.catalog.type.ChangeEvent;
import org.openmetadata.catalog.type.EntityReference;
@ -67,17 +68,104 @@ public final class ChangeEventParser {
public enum PUBLISH_TO {
FEED,
SLACK
SLACK,
TEAMS
}
public static String getEntityUrl(ChangeEvent event) {
public static String getBold(PUBLISH_TO publishTo) {
switch (publishTo) {
case FEED:
case TEAMS:
// TEAMS and FEED bold formatting is same
return FEED_BOLD;
case SLACK:
return SLACK_BOLD;
default:
return "INVALID";
}
}
public static String getLineBreak(PUBLISH_TO publishTo) {
switch (publishTo) {
case FEED:
case TEAMS:
// TEAMS and FEED bold formatting is same
return FEED_LINE_BREAK;
case SLACK:
return SLACK_LINE_BREAK;
default:
return "INVALID";
}
}
public static String getAddMarker(PUBLISH_TO publishTo) {
switch (publishTo) {
case FEED:
return FEED_SPAN_ADD;
case TEAMS:
// TEAMS and FEED bold formatting is same
return "**";
case SLACK:
return "*";
default:
return "INVALID";
}
}
public static String getAddMarkerClose(PUBLISH_TO publishTo) {
switch (publishTo) {
case FEED:
return FEED_SPAN_CLOSE;
case TEAMS:
// TEAMS and FEED bold formatting is same
return "** ";
case SLACK:
return "* ";
default:
return "INVALID";
}
}
public static String getRemoveMarker(PUBLISH_TO publishTo) {
switch (publishTo) {
case FEED:
return FEED_SPAN_REMOVE;
case TEAMS:
// TEAMS and FEED bold formatting is same
return "~~";
case SLACK:
return "~";
default:
return "INVALID";
}
}
public static String getRemoveMarkerClose(PUBLISH_TO publishTo) {
switch (publishTo) {
case FEED:
return FEED_SPAN_CLOSE;
case TEAMS:
// TEAMS and FEED bold formatting is same
return "~~ ";
case SLACK:
return "~ ";
default:
return "INVALID";
}
}
public static String getEntityUrl(PUBLISH_TO publishTo, ChangeEvent event) {
EntityInterface entity = (EntityInterface) event.getEntity();
URI urlInstance = entity.getHref();
String fqn = event.getEntityFullyQualifiedName();
if (Objects.nonNull(urlInstance)) {
String scheme = urlInstance.getScheme();
String host = urlInstance.getHost();
return String.format("<%s://%s/%s/%s|%s>", scheme, host, event.getEntityType(), fqn, fqn);
if (publishTo == PUBLISH_TO.SLACK) {
return String.format("<%s://%s/%s/%s|%s>", scheme, host, event.getEntityType(), fqn, fqn);
} else if (publishTo == PUBLISH_TO.TEAMS) {
return String.format("[%s](%s://%s/%s/%s)", fqn, scheme, host, event.getEntityType(), fqn);
}
}
return urlInstance.toString();
}
@ -87,7 +175,7 @@ public final class ChangeEventParser {
slackMessage.setUsername(event.getUserName());
if (event.getEntity() != null) {
String headerTxt = "%s posted on " + event.getEntityType() + " %s";
String headerText = String.format(headerTxt, event.getUserName(), getEntityUrl(event));
String headerText = String.format(headerTxt, event.getUserName(), getEntityUrl(PUBLISH_TO.SLACK, event));
slackMessage.setText(headerText);
}
Map<EntityLink, String> messages =
@ -105,6 +193,28 @@ public final class ChangeEventParser {
return slackMessage;
}
public static TeamsMessage buildTeamsMessage(ChangeEvent event) {
TeamsMessage teamsMessage = new TeamsMessage();
teamsMessage.setSummary("Change Event From OMD");
TeamsMessage.Section teamsSections = new TeamsMessage.Section();
if (event.getEntity() != null) {
String headerTxt = "%s posted on " + event.getEntityType() + " %s";
String headerText = String.format(headerTxt, event.getUserName(), getEntityUrl(PUBLISH_TO.TEAMS, event));
teamsSections.setActivityTitle(headerText);
}
Map<EntityLink, String> messages =
getFormattedMessages(PUBLISH_TO.TEAMS, event.getChangeDescription(), (EntityInterface) event.getEntity());
List<TeamsMessage.Section> attachmentList = new ArrayList<>();
for (var entry : messages.entrySet()) {
TeamsMessage.Section section = new TeamsMessage.Section();
section.setActivityTitle(teamsSections.getActivityTitle());
section.setActivityText(entry.getValue());
attachmentList.add(section);
}
teamsMessage.setSections(attachmentList);
return teamsMessage;
}
public static Map<EntityLink, String> getFormattedMessages(
PUBLISH_TO publishTo, ChangeDescription changeDescription, EntityInterface entity) {
// Store a map of entityLink -> message
@ -278,16 +388,9 @@ public final class ChangeEventParser {
String fieldValue = getFieldValue(newFieldValue);
if (Entity.FIELD_FOLLOWERS.equals(updatedField)) {
message =
String.format(
("Followed " + (publishTo == PUBLISH_TO.FEED ? FEED_BOLD : SLACK_BOLD) + " `%s`"),
link.getEntityType(),
link.getEntityFQN());
String.format(("Followed " + getBold(publishTo) + " `%s`"), link.getEntityType(), link.getEntityFQN());
} else if (fieldValue != null && !fieldValue.isEmpty()) {
message =
String.format(
("Added " + (publishTo == PUBLISH_TO.FEED ? FEED_BOLD : SLACK_BOLD) + ": `%s`"),
updatedField,
fieldValue);
message = String.format(("Added " + getBold(publishTo) + ": `%s`"), updatedField, fieldValue);
}
break;
case UPDATE:
@ -297,7 +400,7 @@ public final class ChangeEventParser {
if (Entity.FIELD_FOLLOWERS.equals(updatedField)) {
message = String.format("Unfollowed %s `%s`", link.getEntityType(), link.getEntityFQN());
} else {
message = String.format(("Deleted " + (publishTo == PUBLISH_TO.FEED ? FEED_BOLD : SLACK_BOLD)), updatedField);
message = String.format(("Deleted " + getBold(publishTo)), updatedField);
}
break;
default:
@ -313,7 +416,7 @@ public final class ChangeEventParser {
if (nullOrEmpty(diff)) {
return StringUtils.EMPTY;
} else {
String field = String.format("Updated %s: %s", (publishTo == PUBLISH_TO.FEED ? FEED_BOLD : SLACK_BOLD), diff);
String field = String.format("Updated %s: %s", getBold(publishTo), diff);
return String.format(field, updatedField);
}
}
@ -330,17 +433,12 @@ public final class ChangeEventParser {
"%s: %s", key, getPlaintextDiff(publishTo, oldJson.get(key).toString(), newJson.get(key).toString())));
}
}
String updates = String.join((publishTo == PUBLISH_TO.FEED ? FEED_LINE_BREAK : SLACK_LINE_BREAK), labels);
String updates = String.join(getLineBreak(publishTo), labels);
// Include name of the field if the json contains "name" key
if (newJson.containsKey("name")) {
updatedField = String.format("%s.%s", updatedField, newJson.getString("name"));
}
String format =
String.format(
"Updated %s:%s%s",
publishTo == PUBLISH_TO.FEED ? FEED_BOLD : SLACK_BOLD,
publishTo == PUBLISH_TO.FEED ? FEED_LINE_BREAK : SLACK_LINE_BREAK,
updates);
String format = String.format("Updated %s:%s%s", getBold(publishTo), getLineBreak(publishTo), updates);
return String.format(format, updatedField);
}
@ -351,9 +449,7 @@ public final class ChangeEventParser {
}
if (oldValue == null || oldValue.toString().isEmpty()) {
String format =
String.format(
"Updated %s to %s", publishTo == PUBLISH_TO.FEED ? FEED_BOLD : SLACK_BOLD, getFieldValue(newValue));
String format = String.format("Updated %s to %s", getBold(publishTo), getFieldValue(newValue));
return String.format(format, updatedField);
} else if (updatedField.contains("tags") || updatedField.contains(FIELD_OWNER)) {
return getPlainTextUpdateMessage(publishTo, updatedField, getFieldValue(oldValue), getFieldValue(newValue));
@ -426,21 +522,10 @@ public final class ChangeEventParser {
// Replace them with html tags to render nicely in the UI
// Example: This is a test <!remove>sentence<!remove><!add>line<!add>
// This is a test <span class="diff-removed">sentence</span><span class="diff-added">line</span>
String spanAdd;
String spanAddClose;
String spanRemove;
String spanRemoveClose;
if (publishTo == PUBLISH_TO.FEED) {
spanAdd = FEED_SPAN_ADD;
spanAddClose = FEED_SPAN_CLOSE;
spanRemove = FEED_SPAN_REMOVE;
spanRemoveClose = FEED_SPAN_CLOSE;
} else {
spanAdd = "*";
spanAddClose = "* ";
spanRemove = "~";
spanRemoveClose = "~ ";
}
String spanAdd = getAddMarker(publishTo);
String spanAddClose = getAddMarkerClose(publishTo);
String spanRemove = getRemoveMarker(publishTo);
String spanRemoveClose = getRemoveMarkerClose(publishTo);
if (diff != null) {
diff = replaceMarkers(diff, addMarker, spanAdd, spanAddClose);
diff = replaceMarkers(diff, removeMarker, spanRemove, spanRemoveClose);

View File

@ -12,7 +12,7 @@
"type": "string",
"javaType": "org.openmetadata.catalog.type.WebhookType",
"default": "generic",
"enum": ["slack", "generic", "kafka"],
"enum": ["slack", "generic", "msteams"],
"javaEnums": [
{
"name": "slack"
@ -21,7 +21,7 @@
"name": "generic"
},
{
"name": "kafka"
"name": "msteams"
}
]
}
@ -77,6 +77,11 @@
"type": "integer",
"default": 10
},
"readTimeout": {
"description": "Read timeout in seconds. (Default 12s).",
"type": "integer",
"default": 12
},
"enabled": {
"description": "When set to `true`, the webhook event notification is enabled. Set it to `false` to disable the subscription. (Default `true`).",
"type": "boolean",

View File

@ -0,0 +1,11 @@
<svg viewBox="0 0 33 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13621_28513)">
<path d="M27.394 12.221C27.659 12.486 27.9716 12.6967 28.3168 12.8435C29.0372 13.1452 29.8485 13.1452 30.5702 12.8435C31.2661 12.5472 31.8206 11.9941 32.1155 11.2982C32.4172 10.5779 32.4172 9.76649 32.1155 9.04481C31.8192 8.34895 31.2661 7.79444 30.5702 7.49951C29.8499 7.19779 29.0385 7.19779 28.3168 7.49951C27.621 7.7958 27.0665 8.34895 26.7715 9.04481C26.4698 9.76513 26.4698 10.5765 26.7715 11.2982C26.9183 11.642 27.1303 11.956 27.394 12.221ZM26.5459 14.9093V23.6361H28.0002C28.5003 23.6334 28.9977 23.5872 29.4884 23.4948C29.9817 23.4078 30.4574 23.242 30.8978 23.0028C31.3137 22.7772 31.6725 22.4591 31.9484 22.0759C32.2378 21.6505 32.3833 21.1435 32.3629 20.6284V14.9093H26.5459ZM21.8326 11.2506C22.2186 11.2533 22.6018 11.1759 22.9579 11.0237C23.6538 10.7274 24.2069 10.1742 24.5018 9.47836C24.8036 8.75804 24.8036 7.94666 24.5005 7.22497C24.2042 6.52911 23.6511 5.9746 22.9552 5.67968C22.5991 5.52746 22.2158 5.44999 21.8285 5.45271C21.4439 5.44999 21.0633 5.52746 20.71 5.67968C20.4463 5.79248 20.3634 5.93927 20.0005 6.11595V10.5874C20.3647 10.7654 20.4463 10.9109 20.71 11.0237C21.0647 11.1772 21.4466 11.2533 21.8326 11.2506ZM20.0005 25.9439C20.3647 26.0227 20.4952 26.0838 20.7535 26.1192C21.0361 26.16 21.3216 26.1803 21.607 26.1817C22.0473 26.179 22.4877 26.1328 22.9185 26.0403C23.3575 25.952 23.7788 25.7876 24.1621 25.5552C24.5385 25.3241 24.8552 25.0088 25.089 24.635C25.3472 24.1988 25.4736 23.6959 25.4546 23.189V13.0908H20.0005V25.9439V25.9439ZM0 28.7273L18.9092 32V0L0 3.27271V28.7273Z" fill="#37352F"/>
<path d="M13.8166 9.81836L5.08984 10.3498V12.3626L8.36256 12.2308V21.5637L10.5439 21.6915V12.1451L13.8166 11.9997V9.81836Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_13621_28513">
<rect width="32.3656" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,11 @@
<svg viewBox="0 0 33 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13621_28509)">
<path d="M27.394 12.221C27.659 12.486 27.9716 12.6967 28.3168 12.8435C29.0372 13.1452 29.8485 13.1452 30.5702 12.8435C31.2661 12.5472 31.8206 11.9941 32.1155 11.2982C32.4172 10.5779 32.4172 9.76649 32.1155 9.04481C31.8192 8.34895 31.2661 7.79444 30.5702 7.49951C29.8499 7.19779 29.0385 7.19779 28.3168 7.49951C27.621 7.7958 27.0665 8.34895 26.7715 9.04481C26.4698 9.76513 26.4698 10.5765 26.7715 11.2982C26.9183 11.642 27.1303 11.956 27.394 12.221ZM26.5459 14.9093V23.6361H28.0002C28.5003 23.6334 28.9977 23.5872 29.4884 23.4948C29.9817 23.4078 30.4574 23.242 30.8978 23.0028C31.3137 22.7772 31.6725 22.4591 31.9484 22.0759C32.2378 21.6505 32.3833 21.1435 32.3629 20.6284V14.9093H26.5459ZM21.8326 11.2506C22.2186 11.2533 22.6018 11.1759 22.9579 11.0237C23.6538 10.7274 24.2069 10.1742 24.5018 9.47836C24.8036 8.75804 24.8036 7.94666 24.5005 7.22497C24.2042 6.52911 23.6511 5.9746 22.9552 5.67968C22.5991 5.52746 22.2158 5.44999 21.8285 5.45271C21.4439 5.44999 21.0633 5.52746 20.71 5.67968C20.4463 5.79248 20.3634 5.93927 20.0005 6.11595V10.5874C20.3647 10.7654 20.4463 10.9109 20.71 11.0237C21.0647 11.1772 21.4466 11.2533 21.8326 11.2506ZM20.0005 25.9439C20.3647 26.0227 20.4952 26.0838 20.7535 26.1192C21.0361 26.16 21.3216 26.1803 21.607 26.1817C22.0473 26.179 22.4877 26.1328 22.9185 26.0403C23.3575 25.952 23.7788 25.7876 24.1621 25.5552C24.5385 25.3241 24.8552 25.0088 25.089 24.635C25.3472 24.1988 25.4736 23.6959 25.4546 23.189V13.0908H20.0005V25.9439V25.9439ZM0 28.7273L18.9092 32V0L0 3.27271V28.7273Z" fill="#5558AF"/>
<path d="M13.8166 9.81836L5.08984 10.3498V12.3626L8.36256 12.2308V21.5637L10.5439 21.6915V12.1451L13.8166 11.9997V9.81836Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_13621_28509">
<rect width="32.3656" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -32,6 +32,7 @@ import {
GlobalSettingsMenuCategory,
} from '../../constants/globalSettings.constants';
import {
CONFIGURE_MS_TEAMS_TEXT,
CONFIGURE_SLACK_TEXT,
CONFIGURE_WEBHOOK_TEXT,
NO_PERMISSION_FOR_ACTION,
@ -57,6 +58,7 @@ import { getSettingPath } from '../../utils/RouterUtils';
import SVGIcons, { Icons } from '../../utils/SvgUtils';
import { Button } from '../buttons/Button/Button';
import CopyToClipboardButton from '../buttons/CopyToClipboardButton/CopyToClipboardButton';
import CardV1 from '../common/Card/CardV1';
import RichTextEditor from '../common/rich-text-editor/RichTextEditor';
import TitleBreadcrumb from '../common/title-breadcrumb/title-breadcrumb.component';
import PageLayout from '../containers/PageLayout';
@ -71,6 +73,12 @@ import {
EVENT_FILTER_FORM_INITIAL_VALUE,
} from './WebhookConstants';
const CONFIGURE_TEXT: { [key: string]: string } = {
msteams: CONFIGURE_MS_TEAMS_TEXT,
slack: CONFIGURE_SLACK_TEXT,
generic: CONFIGURE_WEBHOOK_TEXT,
};
const Field = ({ children }: { children: React.ReactNode }) => {
return <div className="tw-mt-4">{children}</div>;
};
@ -374,12 +382,11 @@ const AddWebhook: FunctionComponent<AddWebhookProps> = ({
const fetchRightPanel = useCallback(() => {
return (
<div className="tw-px-2">
<h6 className="tw-heading tw-text-base">Configure Webhooks</h6>
<div className="tw-mb-5">
{webhookType === WebhookType.Slack
? CONFIGURE_SLACK_TEXT
: CONFIGURE_WEBHOOK_TEXT}
</div>
<CardV1
description={CONFIGURE_TEXT[webhookType]}
heading="Configure Webhooks"
id="webhook"
/>
</div>
);
}, [webhookType]);

View File

@ -11,12 +11,13 @@
* limitations under the License.
*/
import { Card, Col, Row, Select, Space, Tooltip } from 'antd';
import { Col, Row, Select, Space, Tooltip } from 'antd';
import classNames from 'classnames';
import { isEmpty, isNil } from 'lodash';
import React, { FC, useEffect, useMemo, useState } from 'react';
import { PAGE_SIZE } from '../../constants/constants';
import {
MS_TEAMS_LISTING_TEXT,
NO_PERMISSION_FOR_ACTION,
SLACK_LISTING_TEXT,
WEBHOOK_LISTING_TEXT,
@ -27,6 +28,7 @@ import { Operation } from '../../generated/entity/policies/policy';
import { checkPermission } from '../../utils/PermissionsUtils';
import { statuses } from '../AddWebhook/WebhookConstants';
import { Button } from '../buttons/Button/Button';
import CardV1 from '../common/Card/CardV1';
import ErrorPlaceHolder from '../common/error-with-placeholder/ErrorPlaceHolder';
import NextPrevious from '../common/next-previous/NextPrevious';
import WebhookDataCard from '../common/webhook-data-card/WebhookDataCard';
@ -36,9 +38,21 @@ import { ResourceEntity } from '../PermissionProvider/PermissionProvider.interfa
import { WebhooksV1Props } from './WebhooksV1.interface';
import './webhookV1.less';
const LISTING_TEXT: { [key: string]: string } = {
msteams: MS_TEAMS_LISTING_TEXT,
slack: SLACK_LISTING_TEXT,
generic: WEBHOOK_LISTING_TEXT,
};
const WEBHOOKS_INTEGRATION: { [key: string]: string } = {
msteams: 'MS Teams',
slack: 'Slack',
generic: 'Webhook',
};
const WebhooksV1: FC<WebhooksV1Props> = ({
data = [],
webhookType,
webhookType = WebhookType.Generic,
paging,
selectedStatus = [],
onAddWebhook,
@ -68,16 +82,9 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
const rightPanel = useMemo(() => {
return (
<Card
data-testid="data-summary-container"
size="small"
style={leftPanelAntCardStyle}>
<div className="tw-my-2">
{webhookType === WebhookType.Slack
? SLACK_LISTING_TEXT
: WEBHOOK_LISTING_TEXT}
</div>
</Card>
<div style={leftPanelAntCardStyle}>
<CardV1 description={LISTING_TEXT[webhookType]} id="data" />
</div>
);
}, []);
@ -100,7 +107,7 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
theme="primary"
variant="contained"
onClick={onAddWebhook}>
Add {webhookType === WebhookType.Slack ? 'Slack' : 'Webhook'}
Add {WEBHOOKS_INTEGRATION[webhookType]}
</Button>
</Tooltip>
</p>
@ -115,7 +122,9 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
}, [data, selectedStatus]);
if (data.length === 0) {
return fetchErrorPlaceHolder('No webhooks found');
return fetchErrorPlaceHolder(
`No ${WEBHOOKS_INTEGRATION[webhookType]} found`
);
}
return (
@ -155,8 +164,7 @@ const WebhooksV1: FC<WebhooksV1Props> = ({
theme="primary"
variant="contained"
onClick={onAddWebhook}>
Add{' '}
{webhookType === WebhookType.Slack ? 'Slack' : 'Webhook'}
Add {WEBHOOKS_INTEGRATION[webhookType]}
</Button>
</Tooltip>
)}

View File

@ -0,0 +1,33 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Card } from 'antd';
import { lowerCase } from 'lodash';
import React from 'react';
interface CardProps {
description: string;
id: string;
heading?: string;
}
const CardV1 = ({ description, id, heading }: CardProps) => {
return (
<Card data-testid={`${lowerCase(id)}-summary-container`} size="small">
{heading ? <h6 className="tw-heading tw-text-base">{heading}</h6> : ''}
<div style={{ margin: '8px', textAlign: 'justify' }}>{description}</div>
</Card>
);
};
export default CardV1;

View File

@ -26,11 +26,17 @@ type Props = {
onClick?: (name: string) => void;
};
const ICON: { [key: string]: string } = {
generic: Icons.WEBHOOK,
msteams: Icons.MSTEAMS_GREY,
slack: Icons.SLACK_GREY,
};
const WebhookDataCard: FunctionComponent<Props> = ({
name,
description,
endpoint,
type,
type = WebhookType.Generic,
status = Status.Disabled,
onClick,
}: Props) => {
@ -44,11 +50,7 @@ const WebhookDataCard: FunctionComponent<Props> = ({
data-testid="webhook-data-card">
<div>
<div className="tw-flex tw-items-center">
<SVGIcons
alt="webhook"
icon={type === WebhookType.Slack ? Icons.SLACK_GREY : Icons.WEBHOOK}
width="16"
/>
<SVGIcons alt="webhook" icon={ICON[type]} width="16" />
<h6 className="tw-flex tw-items-center tw-m-0 tw-heading tw-pl-1">
<button
className="tw-font-medium tw-text-primary hover:tw-underline tw-cursor-pointer"

View File

@ -19,12 +19,18 @@ export const WEBHOOK_LISTING_TEXT =
export const SLACK_LISTING_TEXT =
'Provide timely updates to the producers and consumers of metadata via Slack notifications. Use Slack webhooks to send notifications regarding the metadata change events in your organization through APIs. You can add, list, update, and delete these webhooks.';
export const MS_TEAMS_LISTING_TEXT =
'Provide timely updates to the producers and consumers of metadata via MS Teams notifications. Use MS Teams webhooks to send notifications regarding the metadata change events in your organization through APIs. You can add, list, update, and delete these webhooks.';
export const CONFIGURE_WEBHOOK_TEXT =
'OpenMetadata can be configured to automatically send out event notifications to registered webhooks. Enter the webhook name, and an Endpoint URL to receive the HTTP call back on. Use Event Filters to only receive notifications based on events of interest, like when an entity is created, updated, or deleted; and for the entities your application is interested in. Add a description to help people understand the purpose of the webhook and to keep track of the use case. Use advanced configuration to set up a shared secret key to verify the webhook events using HMAC signature.';
export const CONFIGURE_SLACK_TEXT =
'Automatically send out event notifications to registered Slack webhooks through OpenMetadata. Enter the webhook name, and an Endpoint URL to receive the HTTP call back on. Use Event Filters to only receive notifications for the required entities. Filter events based on when an entity is created, updated, or deleted. Add a description to note the use case of the webhook. You can use advanced configuration to set up a shared secret key to verify the Slack webhook events using HMAC signature.';
export const CONFIGURE_MS_TEAMS_TEXT =
'Automatically send out event notifications to registered MS Teams webhooks through OpenMetadata. Enter the MS Teams webhook name, and an Endpoint URL to receive the HTTP call back on. Use Event Filters to only receive notifications for the required entities. Filter events based on when an entity is created, updated, or deleted. Add a description to note the use case of the webhook. You can use advanced configuration to set up a shared secret key to verify the MS Teams webhook events using HMAC signature.';
export const ADD_ROLE_TEXT = `Roles are assigned to Users. In OpenMetadata, Roles are a collection of
Policies. Each Role must have at least one policy attached to it. A Role
supports multiple policies with a one to many relationship. Ensure that

View File

@ -16,6 +16,7 @@ import { ReactComponent as BotIcon } from '../../src/assets/svg/bot-profile.svg'
import { ReactComponent as DashboardIcon } from '../../src/assets/svg/dashboard-grey.svg';
import { ReactComponent as RolesIcon } from '../../src/assets/svg/icon-role-grey.svg';
import { ReactComponent as MlModelIcon } from '../../src/assets/svg/mlmodal.svg';
import { ReactComponent as MSTeams } from '../../src/assets/svg/ms-teams.svg';
import { ReactComponent as PipelineIcon } from '../../src/assets/svg/pipeline-grey.svg';
import { ReactComponent as PoliciesIcon } from '../../src/assets/svg/policies.svg';
import { ReactComponent as SlackIcon } from '../../src/assets/svg/slack.svg';
@ -139,6 +140,11 @@ export const GLOBAL_SETTINGS_MENU = [
isProtected: true,
icon: <SlackIcon className="tw-w-4 side-panel-icons" />,
},
{
label: 'MS Teams',
isProtected: true,
icon: <MSTeams className="tw-w-4 side-panel-icons" />,
},
{
label: 'Bots',
isProtected: true,
@ -179,4 +185,5 @@ export enum GlobalSettingOptions {
SLACK = 'slack',
BOTS = 'bots',
TABLES = 'tables',
MSTEAMS = 'msteams',
}

View File

@ -33,6 +33,12 @@ import jsonData from '../../jsons/en';
import { getSettingPath } from '../../utils/RouterUtils';
import { showErrorToast } from '../../utils/ToastUtils';
const HEADER_TEXT_WEBHOOK: { [key: string]: string } = {
msteams: 'MS Teams',
slack: 'Slack',
generic: 'Webhook',
};
const AddWebhookPage: FunctionComponent = () => {
const { isAdminUser } = useAuth();
const { isAuthDisabled } = useAuthContext();
@ -89,9 +95,7 @@ const AddWebhookPage: FunctionComponent = () => {
<div className="tw-self-center">
<AddWebhook
allowAccess={isAdminUser || isAuthDisabled}
header={`Add ${
webhookType === WebhookType.Slack ? 'Slack' : 'Webhook'
}`}
header={`Add ${HEADER_TEXT_WEBHOOK[webhookType]}`}
mode={FormSubmitType.ADD}
saveState={status}
webhookType={webhookType}

View File

@ -30,12 +30,18 @@ import {
} from '../../constants/globalSettings.constants';
import { FormSubmitType } from '../../enums/form.enum';
import { CreateWebhook } from '../../generated/api/events/createWebhook';
import { Webhook, WebhookType } from '../../generated/entity/events/webhook';
import { Webhook } 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';
const EDIT_HEADER_WEBHOOKS_TITLE: { [key: string]: string } = {
msteams: 'MS Teams',
slack: 'Edit Slack',
generic: 'Edit Webhook',
};
const EditWebhookPage: FunctionComponent = () => {
const { webhookName } = useParams<{ [key: string]: string }>();
const { isAdminUser } = useAuth();
@ -126,9 +132,7 @@ const EditWebhookPage: FunctionComponent = () => {
data={webhookData}
deleteState={deleteStatus}
header={
webhookData?.webhookType === WebhookType.Slack
? 'Edit Slack'
: 'Edit Webhook'
EDIT_HEADER_WEBHOOKS_TITLE[webhookData?.webhookType || 'generic']
}
mode={FormSubmitType.EDIT}
saveState={status}

View File

@ -0,0 +1,119 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AxiosError } from 'axios';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { getWebhooks } from '../../axiosAPIs/webhookAPI';
import PageContainerV1 from '../../components/containers/PageContainerV1';
import Loader from '../../components/Loader/Loader';
import WebhooksV1 from '../../components/Webhooks/WebhooksV1';
import {
getAddWebhookPath,
getEditWebhookPath,
pagingObject,
} from '../../constants/constants';
import {
Status,
Webhook,
WebhookType,
} from '../../generated/entity/events/webhook';
import { Paging } from '../../generated/type/paging';
import jsonData from '../../jsons/en';
import { showErrorToast } from '../../utils/ToastUtils';
const MsTeamsPage = () => {
const history = useHistory();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [paging, setPaging] = useState<Paging>(pagingObject);
const [data, setData] = useState<Array<Webhook>>([]);
const [selectedStatus, setSelectedStatus] = useState<Status[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const fetchData = (paging?: string) => {
setIsLoading(true);
getWebhooks(paging)
.then((res) => {
if (res.data) {
const genericWebhooks = res.data.filter(
(d) => d.webhookType === WebhookType.Msteams
);
setData(genericWebhooks);
setPaging(res.paging);
} else {
setData([]);
setPaging(pagingObject);
throw jsonData['api-error-messages']['unexpected-server-response'];
}
})
.catch((err: AxiosError) => {
showErrorToast(
err,
jsonData['api-error-messages']['fetch-webhook-error']
);
})
.finally(() => {
setIsLoading(false);
});
};
const handlePageChange = (
cursorType: string | number,
activePage?: number
) => {
const pagingString = `&${cursorType}=${
paging[cursorType as keyof typeof paging]
}`;
fetchData(pagingString);
setCurrentPage(activePage ?? 1);
};
const handleStatusFilter = (status: Status[]) => {
setSelectedStatus(status);
};
const handleAddWebhook = () => {
history.push(getAddWebhookPath(WebhookType.Msteams));
};
const handleClickWebhook = (name: string) => {
history.push(getEditWebhookPath(name));
};
useEffect(() => {
fetchData();
}, []);
return (
<PageContainerV1>
{!isLoading ? (
<WebhooksV1
currentPage={currentPage}
data={data}
paging={paging}
selectedStatus={selectedStatus}
webhookType={WebhookType.Msteams}
onAddWebhook={handleAddWebhook}
onClickWebhook={handleClickWebhook}
onPageChange={handlePageChange}
onStatusFilter={handleStatusFilter}
/>
) : (
<Loader />
)}
</PageContainerV1>
);
};
export default MsTeamsPage;

View File

@ -0,0 +1,48 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { findByTestId, findByText, render } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { getWebhooks } from '../../axiosAPIs/webhookAPI';
import MsTeamsPage from './MsTeamsPage.component';
jest.mock('../../components/containers/PageContainerV1', () => {
return jest
.fn()
.mockImplementation(({ children }: { children: ReactNode }) => (
<div data-testid="PageContainerV1">{children}</div>
));
});
jest.mock('../../components/Webhooks/WebhooksV1', () => {
return jest.fn().mockImplementation(() => <>testWebhookV1</>);
});
jest.mock('../../axiosAPIs/webhookAPI.ts', () => ({
getWebhooks: jest.fn().mockImplementation(() => Promise.resolve()),
}));
describe('Test MS Teams page Component', () => {
it('should load WebhookV1 component on API success', async () => {
const { container } = render(<MsTeamsPage />, {
wrapper: MemoryRouter,
});
const PageContainerV1 = await findByTestId(container, 'PageContainerV1');
const webhookComponent = await findByText(container, /testWebhookV1/);
expect(PageContainerV1).toBeInTheDocument();
expect(webhookComponent).toBeInTheDocument();
expect(getWebhooks).toBeCalledTimes(1);
});
});

View File

@ -104,6 +104,7 @@ const WebhooksPageV1 = () => {
data={data}
paging={paging}
selectedStatus={selectedStatus}
webhookType={WebhookType.Generic}
onAddWebhook={handleAddWebhook}
onClickWebhook={handleClickWebhook}
onPageChange={handlePageChange}

View File

@ -66,6 +66,9 @@ const SlackSettingsPage = withSuspenseFallback(
() => import('../pages/SlackSettingsPage/SlackSettingsPage.component')
)
);
const MsTeamsPage = withSuspenseFallback(
React.lazy(() => import('../pages/MsTeamsPage/MsTeamsPage.component'))
);
const GlobalSettingRouter = () => {
const { permissions } = usePermissionProvider();
@ -211,6 +214,20 @@ const GlobalSettingRouter = () => {
)}
/>
<AdminProtectedRoute
exact
component={MsTeamsPage}
hasPermission={checkPermission(
Operation.ViewAll,
ResourceEntity.WEBHOOK,
permissions
)}
path={getSettingPath(
GlobalSettingsMenuCategory.INTEGRATIONS,
GlobalSettingOptions.MSTEAMS
)}
/>
<Route
exact
component={ServicesPage}

View File

@ -130,6 +130,8 @@ import Logo from '../assets/svg/logo.svg';
import IconManageColor from '../assets/svg/manage-color.svg';
import IconMinus from '../assets/svg/minus.svg';
import IconMlModal from '../assets/svg/mlmodal.svg';
import IconMSTeamsGrey from '../assets/svg/ms-teams-grey.svg';
import IconMSTeams from '../assets/svg/ms-teams.svg';
import IconPaperPlanePrimary from '../assets/svg/paper-plane-primary.svg';
import IconPaperPlane from '../assets/svg/paper-plane.svg';
import IconPendingBadge from '../assets/svg/pending-badge.svg';
@ -341,6 +343,8 @@ export const Icons = {
ICON_REMOVE: 'icon-remove',
DELETE_COLORED: 'icon-delete-colored',
IC_EDIT_PRIMARY: 'ic-edit-primary',
MSTEAMS: 'msteams',
MSTEAMS_GREY: 'msteams-grey',
};
const SVGIcons: FunctionComponent<Props> = ({
@ -993,12 +997,18 @@ const SVGIcons: FunctionComponent<Props> = ({
IconComponent = IconInfoSecondary;
break;
case Icons.MSTEAMS:
IconComponent = IconMSTeams;
break;
case Icons.MSTEAMS_GREY:
IconComponent = IconMSTeamsGrey;
break;
case Icons.DELETE_COLORED:
IconComponent = IconDeleteColored;
break;
default:
IconComponent = null;