Jelajahi Sumber

support initial notification configuration from front end (#4250)

d-g-town 2 tahun lalu
induk
melakukan
24cd922610

+ 119 - 0
api/server/handlers/notifications/get_notification_config.go

@@ -0,0 +1,119 @@
+package notifications
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+// GetNotificationConfigHandler is the handler for the POST /notifications/{notification_config_id} endpoint
+type GetNotificationConfigHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewNotificationConfigHandler returns a new GetNotificationConfigHandler
+func NewNotificationConfigHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetNotificationConfigHandler {
+	return &GetNotificationConfigHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// GetNotificationConfigRequest is the request object for the /notifications/{notification_config_id} endpoint
+type GetNotificationConfigRequest struct{}
+
+// GetNotificationConfigResponse is the response object for the /notifications/{notification_config_id} endpoint
+type GetNotificationConfigResponse struct {
+	Config Config `json:"config"`
+}
+
+// ServeHTTP updates a notification config
+func (n *GetNotificationConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-notification-config")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	notificationConfigID, reqErr := requestutils.GetURLParamUint(r, types.URLParamNotificationConfigID)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, nil, "error parsing event id from url")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "notification-config-id", Value: notificationConfigID},
+	)
+
+	request := &GetNotificationConfigRequest{}
+	if ok := n.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	configReq := connect.NewRequest(&porterv1.NotificationConfigRequest{
+		ProjectId:            int64(project.ID),
+		NotificationConfigId: int64(notificationConfigID),
+	})
+	ccpResp, err := n.Config().ClusterControlPlaneClient.NotificationConfig(ctx, configReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp apply porter app")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil || ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, nil, "ccp response or msg is nil")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	response := &GetNotificationConfigResponse{
+		Config: configFromProto(ccpResp.Msg.Config),
+	}
+
+	n.WriteResult(w, r, response)
+}
+
+func configFromProto(proto *porterv1.NotificationConfig) Config {
+	if proto == nil {
+		return Config{}
+	}
+
+	var statuses []Status
+	for _, status := range proto.Statuses {
+		statuses = append(statuses, Status{transformProtoToStatusString[status]})
+	}
+
+	var mention string
+	if proto.SlackConfig != nil && len(proto.SlackConfig.Mentions) > 0 {
+		mention = proto.SlackConfig.Mentions[0]
+	}
+
+	return Config{
+		Statuses: statuses,
+		Mention:  mention,
+	}
+}
+
+var transformProtoToStatusString = map[porterv1.EnumNotificationStatus]string{
+	porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_SUCCESSFUL:  "successful",
+	porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_FAILED:      "failed",
+	porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_PROGRESSING: "progressing",
+}

+ 130 - 0
api/server/handlers/notifications/update_notification_config.go

@@ -0,0 +1,130 @@
+package notifications
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+// UpdateNotificationConfigHandler is the handler for the POST /notifications/{notification_config_id} endpoint
+type UpdateNotificationConfigHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewUpdateNotificationConfigHandler returns a new UpdateNotificationConfigHandler
+func NewUpdateNotificationConfigHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateNotificationConfigHandler {
+	return &UpdateNotificationConfigHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// UpdateNotificationConfigRequest is the request object for the /notifications/{notification_config_id} endpoint
+type UpdateNotificationConfigRequest struct {
+	Config             Config `json:"config"`
+	SlackIntegrationID uint   `json:"slack_integration_id"`
+}
+
+// Config is the config object for the /notifications endpoint
+type Config struct {
+	Mention  string   `json:"mention"`
+	Statuses []Status `json:"statuses"`
+}
+
+// Status is a wrapper object over a string for zod validation
+type Status struct {
+	Status string `json:"status"`
+}
+
+// UpdateNotificationConfigResponse is the response object for the /notifications/{notification_config_id} endpoint
+type UpdateNotificationConfigResponse struct {
+	ID uint `json:"id"`
+}
+
+// ServeHTTP updates a notification config
+func (n *UpdateNotificationConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-notification-config-update")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+	)
+
+	notificationConfigID, reqErr := requestutils.GetURLParamUint(r, types.URLParamNotificationConfigID)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, nil, "error parsing event id from url")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "notification-config-id", Value: notificationConfigID},
+	)
+
+	request := &UpdateNotificationConfigRequest{}
+	if ok := n.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	updateReq := connect.NewRequest(&porterv1.UpdateNotificationConfigRequest{
+		ProjectId:            int64(project.ID),
+		NotificationConfigId: int64(notificationConfigID),
+		Config:               configToProto(request.Config),
+		SlackIntegrationId:   int64(request.SlackIntegrationID),
+	})
+	updateResp, err := n.Config().ClusterControlPlaneClient.UpdateNotificationConfig(ctx, updateReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp apply porter app")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if updateResp == nil || updateResp.Msg == nil {
+		err := telemetry.Error(ctx, span, nil, "ccp response or msg is nil")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	response := &UpdateNotificationConfigResponse{
+		ID: uint(updateResp.Msg.NotificationConfigId),
+	}
+
+	n.WriteResult(w, r, response)
+}
+
+func configToProto(config Config) *porterv1.NotificationConfig {
+	var statuses []porterv1.EnumNotificationStatus
+	for _, status := range config.Statuses {
+		statuses = append(statuses, transformStatusStringToProto[status.Status])
+	}
+
+	return &porterv1.NotificationConfig{
+		Statuses:    statuses,
+		SlackConfig: &porterv1.SlackConfig{Mentions: []string{config.Mention}},
+	}
+}
+
+var transformStatusStringToProto = map[string]porterv1.EnumNotificationStatus{
+	"successful":  porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_SUCCESSFUL,
+	"failed":      porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_FAILED,
+	"progressing": porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_PROGRESSING,
+}

+ 118 - 0
api/server/router/notification.go

@@ -0,0 +1,118 @@
+package router
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/notifications"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// NewNotificationScopedRegisterer is a registerer for all /notifications routes
+func NewNotificationScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetNotificationScopedRoutes,
+		Children:  children,
+	}
+}
+
+// GetNotificationScopedRoutes returns all /notifications routes
+func GetNotificationScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getNotificationRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getNotificationRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/notifications"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	routes := make([]*router.Route, 0)
+
+	// POST /api/projects/{project_id}/notifications/{notification_config_id} -> notifications.NewUpdateNotificationConfigHandler
+	updateNotificationConfigEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamNotificationConfigID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	updateNotificationConfigHandler := notifications.NewUpdateNotificationConfigHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateNotificationConfigEndpoint,
+		Handler:  updateNotificationConfigHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/notifications/{notification_config_id} -> notifications.NewNotificationConfigHandler
+	getNotificationConfigEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamNotificationConfigID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getNotificationConfigHandler := notifications.NewNotificationConfigHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getNotificationConfigEndpoint,
+		Handler:  getNotificationConfigHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 2 - 0
api/server/router/router.go

@@ -45,6 +45,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	inviteRegisterer := NewInviteScopedRegisterer()
 	projectIntegrationRegisterer := NewProjectIntegrationScopedRegisterer()
 	projectOAuthRegisterer := NewProjectOAuthScopedRegisterer()
+	notificationRegisterer := NewNotificationScopedRegisterer()
 	slackIntegrationRegisterer := NewSlackIntegrationScopedRegisterer()
 	projRegisterer := NewProjectScopedRegisterer(
 		cloudProviderRegisterer,
@@ -58,6 +59,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 		projectOAuthRegisterer,
 		slackIntegrationRegisterer,
 		deploymentTargetRegisterer,
+		notificationRegisterer,
 	)
 	statusRegisterer := NewStatusScopedRegisterer()
 

+ 1 - 0
api/types/request.go

@@ -55,6 +55,7 @@ const (
 	URLParamAppRevisionID         URLParam = "app_revision_id"
 	URLParamDatastoreType         URLParam = "datastore_type"
 	URLParamDatastoreName         URLParam = "datastore_name"
+	URLParamNotificationConfigID  URLParam = "notification_config_id"
 	URLParamCloudProviderType     URLParam = "cloud_provider_type"
 	URLParamCloudProviderID       URLParam = "cloud_provider_id"
 	URLParamDeploymentTargetID    URLParam = "deployment_target_id"

+ 2 - 0
api/types/slack_integration.go

@@ -23,6 +23,8 @@ type SlackIntegration struct {
 
 	// The URL for configuring the workspace app instance
 	ConfigurationURL string `json:"configuration_url"`
+
+	NotificationConfigID uint `json:"notification_config_id"`
 }
 
 type ListSlackIntegrationsResponse []*SlackIntegration

+ 3 - 0
dashboard/src/assets/hash-02.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M19.1998 15.5998H4.7998M19.1998 8.39981H4.7998M7.1998 19.1998L9.5998 4.7998M14.3998 19.1998L16.7998 4.79981" stroke="white" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 178 - 0
dashboard/src/components/porter/Dropdown.tsx

@@ -0,0 +1,178 @@
+import React, { type ReactNode } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import styled, { keyframes } from "styled-components";
+
+import Container from "./Container";
+import Spacer from "./Spacer";
+import Tag from "./Tag";
+import Text from "./Text";
+
+type Props = {
+  key: string;
+  title: string;
+  tag?: ReactNode;
+  iconURL?: string;
+  isDefaultOpen?: boolean;
+  deleteFunc?: () => void;
+  children: ReactNode;
+};
+
+const Dropdown: React.FC<Props> = ({
+  title,
+  tag,
+  iconURL,
+  isDefaultOpen = false,
+  deleteFunc,
+  children,
+}) => {
+  const [isOpenedState, setIsOpenedState] = React.useState(isDefaultOpen);
+
+  return (
+    <>
+      <Header
+        showExpanded={isOpenedState}
+        onClick={() => {
+          setIsOpenedState(!isOpenedState);
+        }}
+        bordersRounded={!isOpenedState}
+      >
+        <Title>
+          <Container row>
+            <ActionButton>
+              <span className="material-icons dropdown">arrow_drop_down</span>
+            </ActionButton>
+            {iconURL && <Icon src={iconURL} />}
+            {title}
+            <Spacer inline x={0.5} />
+            {tag}
+          </Container>
+        </Title>
+
+        {deleteFunc && (
+          <ActionButton
+            onClick={(e) => {
+              e.stopPropagation();
+              deleteFunc();
+            }}
+          >
+            <span className="material-icons">delete</span>
+          </ActionButton>
+        )}
+      </Header>
+      <AnimatePresence>
+        {isOpenedState && (
+          <StyledSourceBox
+            initial={{
+              height: 0,
+            }}
+            animate={{
+              height: "auto",
+              transition: {
+                duration: 0.3,
+              },
+            }}
+            exit={{
+              height: 0,
+              transition: {
+                duration: 0.3,
+              },
+            }}
+            showExpanded={isDefaultOpen}
+          >
+            <div
+              style={{
+                padding: "14px 25px 30px",
+                border: "1px solid #494b4f",
+              }}
+            >
+              {children}
+            </div>
+          </StyledSourceBox>
+        )}
+      </AnimatePresence>
+    </>
+  );
+};
+
+export default Dropdown;
+
+const Title = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const StyledSourceBox = styled(motion.div)<{
+  showExpanded?: boolean;
+  hasFooter?: boolean;
+}>`
+  overflow: hidden;
+  color: #ffffff;
+  font-size: 13px;
+  background: ${(props) => props.theme.fg};
+  border-top: 0;
+  border-bottom-left-radius: ${(props) => (props.hasFooter ? "0" : "5px")};
+  border-bottom-right-radius: ${(props) => (props.hasFooter ? "0" : "5px")};
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  :hover {
+    color: white;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+  margin-right: 5px;
+`;
+
+const Header = styled.div<{
+  showExpanded?: boolean;
+  bordersRounded?: boolean;
+}>`
+  flex-direction: row;
+  display: flex;
+  height: 60px;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${(props) => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  border-bottom-left-radius: ${(props) => (props.bordersRounded ? "" : "0")};
+  border-bottom-right-radius: ${(props) => (props.bordersRounded ? "" : "0")};
+
+  .dropdown {
+    font-size: 30px;
+    cursor: pointer;
+    border-radius: 20px;
+    margin-left: -10px;
+    transform: ${(props: { showExpanded?: boolean }) =>
+      props.showExpanded ? "" : "rotate(-90deg)"};
+  }
+`;
+
+const Icon = styled.img`
+  width: 27px;
+  margin-right: 12px;
+  margin-bottom: -1px;
+`;

+ 101 - 0
dashboard/src/components/porter/SelectableList.tsx

@@ -0,0 +1,101 @@
+import React from "react";
+import styled, { css } from "styled-components";
+
+import Icon from "components/porter/Icon";
+
+type SelectableRowProps = {
+  selectable: React.ReactNode;
+  onSelect?: () => void;
+  onDeselect?: () => void;
+  selected?: boolean;
+  selectedIcon?: string;
+};
+
+const SelectableRow: React.FC<SelectableRowProps> = ({
+  selectable,
+  selected,
+  onSelect,
+  onDeselect,
+  selectedIcon,
+}) => {
+  return (
+    <ResourceOption
+      selected={selected}
+      onClick={() => {
+        if (selected) {
+          onDeselect?.();
+        } else {
+          onSelect?.();
+        }
+      }}
+      isHoverable={onSelect != null || onDeselect != null}
+    >
+      <div>{selectable}</div>
+      {selected && selectedIcon && <Icon height="18px" src={selectedIcon} />}
+    </ResourceOption>
+  );
+};
+
+type ListProps = {
+  listItems: Array<{
+    selectable: React.ReactNode;
+    key: string;
+    onSelect?: () => void;
+    onDeselect?: () => void;
+    isSelected?: boolean;
+  }>;
+  scroll?: boolean;
+};
+
+const SelectableList: React.FC<ListProps> = ({ listItems, scroll = true }) => {
+  return (
+    <StyledSelectableList scroll={scroll}>
+      {listItems.map((li) => {
+        return (
+          <SelectableRow
+            key={li.key}
+            selectable={li.selectable}
+            selected={li.isSelected}
+            onSelect={li.onSelect}
+            onDeselect={li.onDeselect}
+          />
+        );
+      })}
+    </StyledSelectableList>
+  );
+};
+
+export default SelectableList;
+
+const StyledSelectableList = styled.div<{ scroll?: boolean }>`
+  display: flex;
+  row-gap: 10px;
+  flex-direction: column;
+  ${(props) =>
+    props.scroll &&
+    css`
+      max-height: 400px;
+      overflow-y: scroll;
+    `}
+`;
+
+const ResourceOption = styled.div<{ selected?: boolean; isHoverable: boolean }>`
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid
+    ${(props) => (props.selected ? "#ffffff" : props.theme.border)};
+  width: 100%;
+  padding: 10px 15px;
+  border-radius: 5px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  ${(props) => props.isHoverable && "cursor: pointer;"}
+  ${(props) =>
+    props.isHoverable &&
+    !props.selected &&
+    css`
+      &:hover {
+        border: 1px solid #7a7b80;
+      }
+    `}
+`;

+ 13 - 3
dashboard/src/components/porter/Tag.tsx

@@ -3,23 +3,32 @@ import styled from "styled-components";
 
 type Props = {
   backgroundColor?: string;
-  hoverable?: boolean;
   children: React.ReactNode;
+  hoverable?: boolean;
+
   borderColor?: string;
+  hoverColor?: string;
+  borderRadiusPixels?: number;
+  onClick?: () => void;
 };
 
 const Tag: React.FC<Props> = ({
   backgroundColor,
   hoverable = true,
+  hoverColor,
   children,
   borderColor,
+  borderRadiusPixels = 5,
+  onClick,
 }) => {
   return (
     <StyledTag
       backgroundColor={backgroundColor ?? "#ffffff22"}
       hoverable={hoverable}
-      hoverColor={backgroundColor ?? "#ffffff44"}
+      hoverColor={hoverColor ?? "#ffffff44"}
       borderColor={borderColor}
+      borderRadiusPixels={borderRadiusPixels}
+      onClick={onClick}
     >
       {children}
     </StyledTag>
@@ -33,11 +42,12 @@ const StyledTag = styled.div<{
   backgroundColor: string;
   hoverColor: string;
   borderColor?: string;
+  borderRadiusPixels: number;
 }>`
   display: flex;
   justify-content: center;
   padding: 3px 5px;
-  border-radius: 5px;
+  border-radius: ${({ borderRadiusPixels }) => borderRadiusPixels}px;
   background: ${({ backgroundColor }) => backgroundColor};
   user-select: text;
   border: 1px solid

+ 20 - 0
dashboard/src/lib/notifications/types.ts

@@ -0,0 +1,20 @@
+import { z } from "zod";
+
+export const notificationConfigFormValidator = z.object({
+  mention: z.string().regex(/^[a-z0-9-]*$/, {
+    message: "Lowercase letters, numbers, and “-” only.",
+  }),
+  statuses: z
+    .object({
+      status: z.string(),
+    })
+    .array(),
+});
+export type NotificationConfigFormData = z.infer<
+  typeof notificationConfigFormValidator
+>;
+
+export const emptyNotificationConfig: NotificationConfigFormData = {
+  mention: "",
+  statuses: [],
+};

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx

@@ -37,7 +37,7 @@ const NotificationSettingsSection: React.FC<Props> = (props) => {
 
   useEffect(() => {
     api
-      .getNotificationConfig(
+      .legacyGetNotificationConfig(
         "<token>",
         {},
         {
@@ -81,7 +81,7 @@ const NotificationSettingsSection: React.FC<Props> = (props) => {
     };
 
     api
-      .updateNotificationConfig(
+      .legacyUpdateNotificationConfig(
         "<token>",
         {
           payload,

+ 315 - 132
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -1,14 +1,47 @@
-import React, { useContext, useRef, useState } from "react";
-import ConfirmOverlay from "../../../components/ConfirmOverlay";
+import React, { useContext, useMemo, useRef, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+import { useFieldArray, useForm } from "react-hook-form";
 import styled from "styled-components";
-import { Context } from "../../../shared/Context";
-import api from "../../../shared/api";
+import { match } from "ts-pattern";
+import type { IterableElement } from "type-fest";
+import { z } from "zod";
 
-interface Props {
+import ConfirmOverlay from "components/ConfirmOverlay";
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Dropdown from "components/porter/Dropdown";
+import Error from "components/porter/Error";
+import Icon from "components/porter/Icon";
+import SelectableList from "components/porter/SelectableList";
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import {
+  emptyNotificationConfig,
+  notificationConfigFormValidator,
+  type NotificationConfigFormData,
+} from "lib/notifications/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import hash from "assets/hash-02.svg";
+import save from "assets/save-01.svg";
+
+type SlackIntegrationListProps = {
   slackData: any[];
-}
+};
 
-const SlackIntegrationList: React.FC<Props> = (props) => {
+const statusOptions = [
+  { value: "successful", emoji: "✅", label: "Successful" },
+  { value: "failed", emoji: "⚠️", label: "Failed" },
+  { value: "progressing", emoji: "🚀", label: "Progressing" },
+];
+
+const SlackIntegrationList: React.FC<SlackIntegrationListProps> = (props) => {
   const [isDelete, setIsDelete] = useState(false);
   const [deleteIndex, setDeleteIndex] = useState(-1); // guaranteed to be set when used
   const { currentProject, setCurrentError } = useContext(Context);
@@ -38,53 +71,32 @@ const SlackIntegrationList: React.FC<Props> = (props) => {
       <ConfirmOverlay
         show={isDelete}
         message={
-          deleteIndex != -1 &&
+          deleteIndex !== -1 &&
           `Are you sure you want to delete the slack integration for team ${
             props.slackData[deleteIndex].team_name ||
             props.slackData[deleteIndex].team_id
           } in channel ${props.slackData[deleteIndex].channel}?`
         }
         onYes={handleDelete}
-        onNo={() => setIsDelete(false)}
+        onNo={() => {
+          setIsDelete(false);
+        }}
       />
       <StyledIntegrationList>
         {props.slackData?.length > 0 ? (
           props.slackData.map((inst, idx) => {
             if (deleted.current.has(idx)) return null;
             return (
-              <Integration
-                onClick={() => {}}
-                disabled={false}
-                key={`${inst.team_id}-${inst.channel}`}
-              >
-                <MainRow disabled={false}>
-                  <Flex>
-                    <Icon src={inst.team_icon_url && inst.team_icon_url} />
-                    <Label>
-                      {inst.team_name || inst.team_id} - {inst.channel}
-                    </Label>
-                  </Flex>
-                  <MaterialIconTray disabled={false}>
-                    <i
-                      className="material-icons"
-                      onClick={() => {
-                        setDeleteIndex(idx);
-                        setIsDelete(true);
-                      }}
-                    >
-                      delete
-                    </i>
-                    <i
-                      className="material-icons"
-                      onClick={() => {
-                        window.open(inst.configuration_url, "_blank");
-                      }}
-                    >
-                      launch
-                    </i>
-                  </MaterialIconTray>
-                </MainRow>
-              </Integration>
+              <SlackIntegration
+                projectID={currentProject.id}
+                key={idx.toString()}
+                idx={idx}
+                deleteIndex={(index: number) => {
+                  setDeleteIndex(index);
+                  setIsDelete(true);
+                }}
+                inst={inst}
+              />
             );
           })
         ) : (
@@ -97,6 +109,267 @@ const SlackIntegrationList: React.FC<Props> = (props) => {
 
 export default SlackIntegrationList;
 
+type SlackIntegrationProps = {
+  projectID: number;
+  idx: number;
+  inst: any;
+  deleteIndex: (index: number) => void;
+};
+
+const SlackIntegration: React.FC<SlackIntegrationProps> = ({
+  projectID,
+  idx,
+  inst,
+  deleteIndex,
+}) => {
+  return (
+    <Dropdown
+      key={idx.toString()}
+      title={inst.team_name || inst.team_id}
+      tag={
+        <Tag
+          borderRadiusPixels={10}
+          backgroundColor={"#55555555"}
+          hoverColor={"#55555577"}
+          borderColor={"#88888888"}
+          onClick={() => {
+            window.open(inst.configuration_url, "_blank");
+          }}
+        >
+          <Container row>
+            <Icon src={hash} height={"12px"} />
+            <Spacer x={0.2} inline />
+            <Text size={13} color="#eeeeeedd">
+              {inst.channel}
+            </Text>
+          </Container>
+        </Tag>
+      }
+      iconURL={inst.team_icon_url}
+      deleteFunc={() => {
+        deleteIndex(idx);
+      }}
+    >
+      <SetupNotificationConfig
+        projectID={projectID}
+        slackIntegrationID={inst.id}
+        notificationConfigID={inst.notification_config_id}
+      />
+    </Dropdown>
+  );
+};
+
+type NotificationConfigContainerProps = {
+  projectID: number;
+  notificationConfigID: number;
+  slackIntegrationID: number;
+  existingConfig: NotificationConfigFormData;
+};
+
+const NotificationConfigContainer: React.FC<
+  NotificationConfigContainerProps
+> = ({
+  projectID,
+  notificationConfigID,
+  slackIntegrationID,
+  existingConfig,
+}) => {
+  const [isUpdating, setIsUpdating] = useState(false);
+  const [isSuccessful, setIsSuccessful] = useState(false);
+  const [updateError, setUpdateError] = useState("");
+
+  const queryClient = useQueryClient();
+
+  const notificationForm = useForm<NotificationConfigFormData>({
+    resolver: zodResolver(notificationConfigFormValidator),
+    reValidateMode: "onSubmit",
+    defaultValues: existingConfig,
+  });
+
+  const {
+    control,
+    formState: { isSubmitting: isValidating, errors },
+    register,
+  } = notificationForm;
+
+  const { append, remove, fields } = useFieldArray({
+    control,
+    name: "statuses",
+  });
+
+  const onAdd = (
+    inp: IterableElement<NotificationConfigFormData["statuses"]>
+  ): void => {
+    const previouslyAdded = fields.findIndex((s) => s.status === inp.status);
+
+    if (previouslyAdded === -1) {
+      append(inp);
+    }
+  };
+
+  const submitBtnStatus = useMemo(() => {
+    if (isValidating || isUpdating) {
+      return "loading";
+    }
+
+    if (updateError) {
+      return <Error message={updateError} />;
+    }
+
+    if (isSuccessful) {
+      return "success";
+    }
+  }, [isValidating, isUpdating, updateError, errors, isSuccessful]);
+
+  const handleSubmit = notificationForm.handleSubmit(async (data) => {
+    try {
+      setIsUpdating(true);
+
+      await api.updateNotificationConfig(
+        "<token>",
+        {
+          slack_integration_id: slackIntegrationID,
+          config: data,
+        },
+        {
+          project_id: projectID,
+          notification_config_id: notificationConfigID,
+        }
+      );
+      await queryClient.invalidateQueries({
+        queryKey: ["getNotificationConfig"],
+      });
+      setIsUpdating(false);
+      setIsSuccessful(true);
+    } catch (err) {
+      setIsUpdating(false);
+      if (axios.isAxiosError(err) && err.response?.data?.error) {
+        setUpdateError(err.response?.data?.error);
+        return;
+      }
+      setUpdateError(
+        "An error occurred while updating your notification config. Please try again."
+      );
+    }
+  });
+
+  return (
+    <>
+      <Text>Filter deployment notifications:</Text>
+      <Spacer y={0.5} />
+      <SelectableList
+        scroll={false}
+        listItems={statusOptions.map((option) => {
+          const selectedOptionsIdx = fields.findIndex(
+            (s) => s.status === option.value
+          );
+          return {
+            selectable: (
+              <Container row>
+                <Spacer inline width="1px" />
+                <Text size={12}>
+                  {option.emoji}
+                  <Spacer inline x={0.7} />
+                  {option.label}
+                </Text>
+                <Spacer inline x={1} />
+              </Container>
+            ),
+            key: option.value,
+            onSelect: () => {
+              onAdd({ status: option.value });
+            },
+            onDeselect: () => {
+              remove(selectedOptionsIdx);
+            },
+            isSelected: selectedOptionsIdx !== -1,
+          };
+        })}
+      />
+      <Spacer y={0.75} />
+      <Text>@ Mention (only on failure):</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        placeholder="ex: oncall"
+        type="text"
+        width="300px"
+        error={errors.mention?.message}
+        {...register("mention")}
+      />
+      <Spacer y={0.75} />
+      <form onSubmit={handleSubmit}>
+        <Button
+          type="submit"
+          status={submitBtnStatus}
+          loadingText={"Saving..."}
+          onClick={handleSubmit}
+          successText={"Saved!"}
+          disabled={isUpdating}
+        >
+          <Icon src={save} height={"13px"} />
+          <Spacer inline x={0.5} />
+          Save
+        </Button>
+      </form>
+    </>
+  );
+};
+
+type SetupNotificationConfigProps = {
+  projectID: number;
+  notificationConfigID: number;
+  slackIntegrationID: number;
+};
+
+const SetupNotificationConfig: React.FC<SetupNotificationConfigProps> = ({
+  projectID,
+  notificationConfigID,
+  slackIntegrationID,
+}) => {
+  const configRes = useQuery(
+    ["getNotificationConfig", projectID, notificationConfigID],
+    async () => {
+      if (notificationConfigID === 0) {
+        return emptyNotificationConfig;
+      }
+      const res = await api.getNotificationConfig(
+        "<token>",
+        {},
+        {
+          project_id: projectID,
+          notification_config_id: notificationConfigID,
+        }
+      );
+
+      const object = await z
+        .object({
+          config: notificationConfigFormValidator,
+        })
+        .parseAsync(res.data);
+
+      return object.config;
+    }
+  );
+
+  return (
+    <>
+      {match(configRes)
+        .with({ status: "loading" }, () => <Loading />)
+        .with({ status: "success" }, ({ data }) => {
+          return (
+            <NotificationConfigContainer
+              projectID={projectID}
+              slackIntegrationID={slackIntegrationID}
+              notificationConfigID={notificationConfigID}
+              existingConfig={data}
+            />
+          );
+        })
+        .otherwise(() => null)}
+    </>
+  );
+};
+
 const Placeholder = styled.div`
   width: 100%;
   height: 250px;
@@ -111,96 +384,6 @@ const Placeholder = styled.div`
   border: 1px solid #494b4f;
 `;
 
-const Label = styled.div`
-  color: #ffffff;
-  font-size: 14px;
-  font-weight: 500;
-`;
-
 const StyledIntegrationList = styled.div`
   margin-bottom: 80px;
 `;
-
-const MainRow = styled.div`
-  height: 70px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 25px;
-  border-radius: 5px;
-  :hover {
-    background: ${(props: { disabled: boolean }) =>
-      props.disabled ? "" : "#ffffff11"};
-    > i {
-      background: ${(props: { disabled: boolean }) =>
-        props.disabled ? "" : "#ffffff11"};
-    }
-  }
-
-  > i {
-    border-radius: 20px;
-    font-size: 18px;
-    padding: 5px;
-    color: #ffffff44;
-    margin-right: -7px;
-    :hover {
-      background: ${(props: { disabled: boolean }) =>
-        props.disabled ? "" : "#ffffff11"};
-    }
-  }
-`;
-
-const Integration = styled.div`
-  margin-left: -2px;
-  display: flex;
-  flex-direction: column;
-  background: #26282f;
-  cursor: ${(props: { disabled: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-  margin-bottom: 15px;
-  border-radius: 8px;
-  box-shadow: 0 4px 15px 0px #00000055;
-`;
-
-const Icon = styled.img`
-  width: 27px;
-  margin-right: 12px;
-  margin-bottom: -1px;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-
-  > i {
-    cursor: pointer;
-    font-size: 24px;
-    color: #969fbbaa;
-    padding: 3px;
-    margin-right: 11px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-  }
-`;
-
-const MaterialIconTray = styled.div`
-  max-width: 60px;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  > i {
-    background: #26282f;
-    border-radius: 20px;
-    font-size: 18px;
-    padding: 5px;
-    margin: 0 5px;
-    color: #ffffff44;
-    :hover {
-      background: ${(props: { disabled: boolean }) =>
-        props.disabled ? "" : "#ffffff11"};
-    }
-  }
-`;

+ 35 - 3
dashboard/src/shared/api.tsx

@@ -655,7 +655,7 @@ const deleteSlackIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/slack_integrations/${pathParams.slack_integration_id}`;
 });
 
-const updateNotificationConfig = baseApi<
+const legacyUpdateNotificationConfig = baseApi<
   {
     payload: any;
   },
@@ -671,6 +671,36 @@ const updateNotificationConfig = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/notifications`;
 });
 
+const getNotificationConfig = baseApi<
+  {},
+  {
+    project_id: number;
+    notification_config_id: number;
+  }
+>("GET", (pathParams) => {
+  const { project_id, notification_config_id } = pathParams;
+
+  return `/api/projects/${project_id}/notifications/${notification_config_id}`;
+});
+
+const updateNotificationConfig = baseApi<
+  {
+    slack_integration_id: number;
+    config: {
+      mention: string;
+      statuses: Array<{ status: string }>;
+    };
+  },
+  {
+    project_id: number;
+    notification_config_id: number;
+  }
+>("POST", (pathParams) => {
+  const { project_id, notification_config_id } = pathParams;
+
+  return `/api/projects/${project_id}/notifications/${notification_config_id}`;
+});
+
 const getPRDeploymentList = baseApi<
   {
     environment_id?: number;
@@ -712,7 +742,7 @@ const deletePRDeployment = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}`;
 });
 
-const getNotificationConfig = baseApi<
+const legacyGetNotificationConfig = baseApi<
   {},
   {
     project_id: number;
@@ -1566,7 +1596,7 @@ const getClusterState = baseApi<{}, { project_id: number; cluster_id: number }>(
 );
 
 const getComplianceChecks = baseApi<
-  { vendor: "vanta", profile: "soc2" | "hipaa" },
+  { vendor: "vanta"; profile: "soc2" | "hipaa" },
   { projectId: number; clusterId: number }
 >("GET", ({ projectId, clusterId }) => {
   return `/api/projects/${projectId}/clusters/${clusterId}/compliance/checks`;
@@ -3487,7 +3517,9 @@ export default {
   deleteProject,
   deleteRegistryIntegration,
   deleteSlackIntegration,
+  legacyUpdateNotificationConfig,
   updateNotificationConfig,
+  legacyGetNotificationConfig,
   getNotificationConfig,
   createSubdomain,
   deployTemplate,

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.99
+	github.com/porter-dev/api-contracts v0.2.102
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 4 - 0
go.sum

@@ -1525,6 +1525,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
 github.com/porter-dev/api-contracts v0.2.99 h1:eHzYSwacLEV5Di2boCnuv8ddoxzvaNCORAD0VOibOBU=
 github.com/porter-dev/api-contracts v0.2.99/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.101 h1:pNPtkTqcr+khUwBfT1z8y86DaVhFIkcHjtD+GL2xCF0=
+github.com/porter-dev/api-contracts v0.2.101/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.102 h1:iZnwSmBM4gfRqdNXV5t9fFqd/pz5bj49MV+yqXgb46Q=
+github.com/porter-dev/api-contracts v0.2.102/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 8 - 7
internal/models/integrations/slack.go

@@ -51,12 +51,13 @@ type SlackIntegration struct {
 
 func (s *SlackIntegration) ToSlackIntegraionType() *types.SlackIntegration {
 	return &types.SlackIntegration{
-		ID:               s.ID,
-		ProjectID:        s.ProjectID,
-		TeamID:           s.TeamID,
-		TeamName:         s.TeamName,
-		TeamIconURL:      s.TeamIconURL,
-		Channel:          s.Channel,
-		ConfigurationURL: s.ConfigurationURL,
+		ID:                   s.ID,
+		ProjectID:            s.ProjectID,
+		TeamID:               s.TeamID,
+		TeamName:             s.TeamName,
+		TeamIconURL:          s.TeamIconURL,
+		Channel:              s.Channel,
+		ConfigurationURL:     s.ConfigurationURL,
+		NotificationConfigID: s.NotificationConfigID,
 	}
 }