Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8eebbae1f |
@@ -1,13 +1,34 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
WorkspaceEstimateEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/estimates/",
|
||||
WorkspaceEstimateEndpoint.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="workspace-estimate-points",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/estimates/<uuid:estimate_id>/",
|
||||
WorkspaceEstimateEndpoint.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="workspace-estimate-points",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-estimates/",
|
||||
ProjectEstimatePointEndpoint.as_view(),
|
||||
|
||||
@@ -141,6 +141,7 @@ from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndp
|
||||
from .estimate import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
WorkspaceEstimateEndpoint,
|
||||
)
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
||||
|
||||
@@ -4,13 +4,158 @@ from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Project, Estimate, EstimatePoint
|
||||
from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission
|
||||
from plane.db.models import Project, Estimate, EstimatePoint, Workspace
|
||||
from plane.app.serializers import (
|
||||
EstimateSerializer,
|
||||
EstimatePointSerializer,
|
||||
EstimateReadSerializer,
|
||||
)
|
||||
from django.db.models import Q
|
||||
|
||||
class WorkspaceEstimateEndpoint(BaseViewSet):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
model = Estimate
|
||||
serializer_class = EstimateSerializer
|
||||
|
||||
def list(self, request, slug):
|
||||
estimates = Estimate.objects.filter(
|
||||
workspace__slug=slug, project_id__isnull=True
|
||||
).prefetch_related("points").select_related("workspace")
|
||||
serializer = EstimateReadSerializer(estimates, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug):
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
{"error": "Estimate is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_points = request.data.get("estimate_points", [])
|
||||
|
||||
if not len(estimate_points) or len(estimate_points) > 8:
|
||||
return Response(
|
||||
{"error": "Estimate points are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate_serializer = EstimateSerializer(data=request.data.get("estimate"))
|
||||
if not estimate_serializer.is_valid():
|
||||
return Response(
|
||||
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
estimate = estimate_serializer.save(workspace_id=workspace.id)
|
||||
estimate_points = EstimatePoint.objects.bulk_create(
|
||||
[
|
||||
EstimatePoint(
|
||||
estimate=estimate,
|
||||
key=estimate_point.get("key", 0),
|
||||
value=estimate_point.get("value", ""),
|
||||
description=estimate_point.get("description", ""),
|
||||
workspace_id=estimate.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for estimate_point in estimate_points
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
estimate_point_serializer = EstimatePointSerializer(
|
||||
estimate_points, many=True
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"estimate": estimate_serializer.data,
|
||||
"estimate_points": estimate_point_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, estimate_id):
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug
|
||||
)
|
||||
serializer = EstimateReadSerializer(estimate)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, estimate_id):
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
{"error": "Estimate is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not len(request.data.get("estimate_points", [])):
|
||||
return Response(
|
||||
{"error": "Estimate points are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate = Estimate.objects.get(pk=estimate_id)
|
||||
|
||||
estimate_serializer = EstimateSerializer(
|
||||
estimate, data=request.data.get("estimate"), partial=True
|
||||
)
|
||||
if not estimate_serializer.is_valid():
|
||||
return Response(
|
||||
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
estimate = estimate_serializer.save()
|
||||
|
||||
estimate_points_data = request.data.get("estimate_points", [])
|
||||
|
||||
estimate_points = EstimatePoint.objects.filter(
|
||||
pk__in=[
|
||||
estimate_point.get("id") for estimate_point in estimate_points_data
|
||||
],
|
||||
workspace__slug=slug,
|
||||
estimate_id=estimate_id,
|
||||
)
|
||||
|
||||
updated_estimate_points = []
|
||||
for estimate_point in estimate_points:
|
||||
# Find the data for that estimate point
|
||||
estimate_point_data = [
|
||||
point
|
||||
for point in estimate_points_data
|
||||
if point.get("id") == str(estimate_point.id)
|
||||
]
|
||||
if len(estimate_point_data):
|
||||
estimate_point.value = estimate_point_data[0].get(
|
||||
"value", estimate_point.value
|
||||
)
|
||||
updated_estimate_points.append(estimate_point)
|
||||
|
||||
EstimatePoint.objects.bulk_update(
|
||||
updated_estimate_points, ["value"], batch_size=10,
|
||||
)
|
||||
|
||||
estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True)
|
||||
return Response(
|
||||
{
|
||||
"estimate": estimate_serializer.data,
|
||||
"estimate_points": estimate_point_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, estimate_id):
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug
|
||||
)
|
||||
estimate.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProjectEstimatePointEndpoint(BaseAPIView):
|
||||
@@ -39,6 +184,14 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
serializer_class = EstimateSerializer
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
workspace = request.GET.get("workspace", False)
|
||||
if workspace:
|
||||
estimates = Estimate.objects.filter(
|
||||
Q(project_id=project_id) | Q(project_id__isnull=True), workspace__slug=slug,
|
||||
).prefetch_related("points").select_related("workspace")
|
||||
serializer = EstimateReadSerializer(estimates, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
estimates = Estimate.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).prefetch_related("points").select_related("workspace", "project")
|
||||
|
||||
+9
-64
@@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.2.7 on 2023-12-20 14:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import django.db.models.deletion
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -10,69 +10,14 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cycle',
|
||||
name='external_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
migrations.AlterField(
|
||||
model_name='estimate',
|
||||
name='project',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cycle',
|
||||
name='external_source',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importer',
|
||||
name='reason',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='external_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='external_source',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='external_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='external_source',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='label',
|
||||
name='external_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='label',
|
||||
name='external_source',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='external_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='external_source',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='state',
|
||||
name='external_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='state',
|
||||
name='external_source',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
migrations.AlterField(
|
||||
model_name='estimatepoint',
|
||||
name='project',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ from .workspace import (
|
||||
WorkspaceMemberInvite,
|
||||
TeamMember,
|
||||
WorkspaceTheme,
|
||||
WorkspaceBaseModel,
|
||||
)
|
||||
|
||||
from .project import (
|
||||
|
||||
@@ -3,10 +3,10 @@ from django.db import models
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
# Module imports
|
||||
from . import ProjectBaseModel
|
||||
from . import WorkspaceBaseModel
|
||||
|
||||
|
||||
class Estimate(ProjectBaseModel):
|
||||
class Estimate(WorkspaceBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(verbose_name="Estimate Description", blank=True)
|
||||
|
||||
@@ -22,7 +22,7 @@ class Estimate(ProjectBaseModel):
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
class EstimatePoint(ProjectBaseModel):
|
||||
class EstimatePoint(WorkspaceBaseModel):
|
||||
estimate = models.ForeignKey(
|
||||
"db.Estimate",
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -103,6 +103,23 @@ class Workspace(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceBaseModel(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project", models.CASCADE, related_name="project_%(class)s", null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.project:
|
||||
self.workspace = self.project.workspace
|
||||
super(WorkspaceBaseModel, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class WorkspaceMember(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
|
||||
|
||||
@@ -317,7 +317,7 @@ const renderItems = () => {
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
// @ts-ignore
|
||||
@@ -335,7 +335,7 @@ const renderItems = () => {
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
|
||||
@@ -217,7 +217,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
|
||||
{projectLabels && projectLabels.length === 0 && (
|
||||
<EmptyState
|
||||
title="No labels yet"
|
||||
description="Create labels to help organize and filter issues in your project."
|
||||
description="Create labels to help organize and filter issues in you project"
|
||||
image={emptyLabel}
|
||||
primaryButton={{
|
||||
text: "Add label",
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { WebhookForm } from "./form";
|
||||
import { GeneratedHookDetails } from "./generated-hook-details";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// helpers
|
||||
import { csvDownload } from "helpers/download.helper";
|
||||
// utils
|
||||
import { getCurrentHookAsCSV } from "./utils";
|
||||
// types
|
||||
import { IWebhook, IWorkspace, TWebhookEventTypes } from "types";
|
||||
|
||||
interface WebhookWithKey {
|
||||
webHook: IWebhook;
|
||||
secretKey: string | undefined;
|
||||
}
|
||||
interface ICreateWebhookModal {
|
||||
currentWorkspace: IWorkspace | null;
|
||||
isOpen: boolean;
|
||||
clearSecretKey: () => void;
|
||||
createWebhook: (workspaceSlug: string, data: Partial<IWebhook>) => Promise<WebhookWithKey>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CreateWebhookModal: React.FC<ICreateWebhookModal> = (props) => {
|
||||
const { isOpen, onClose, currentWorkspace, createWebhook, clearSecretKey } = props;
|
||||
// states
|
||||
const [generatedWebhook, setGeneratedKey] = useState<IWebhook | null>(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleCreateWebhook = async (formData: IWebhook, webhookEventType: TWebhookEventTypes) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
let payload: Partial<IWebhook> = {
|
||||
url: formData.url,
|
||||
};
|
||||
|
||||
if (webhookEventType === "all")
|
||||
payload = {
|
||||
...payload,
|
||||
project: true,
|
||||
cycle: true,
|
||||
module: true,
|
||||
issue: true,
|
||||
issue_comment: true,
|
||||
};
|
||||
else
|
||||
payload = {
|
||||
...payload,
|
||||
project: formData.project ?? false,
|
||||
cycle: formData.cycle ?? false,
|
||||
module: formData.module ?? false,
|
||||
issue: formData.issue ?? false,
|
||||
issue_comment: formData.issue_comment ?? false,
|
||||
};
|
||||
|
||||
await createWebhook(workspaceSlug.toString(), payload)
|
||||
.then(({ webHook, secretKey }) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Webhook created successfully.",
|
||||
});
|
||||
|
||||
setGeneratedKey(webHook);
|
||||
|
||||
const csvData = getCurrentHookAsCSV(currentWorkspace, webHook, secretKey);
|
||||
csvDownload(csvData, `webhook-secret-key-${Date.now()}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: error?.error ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setTimeout(() => {
|
||||
clearSecretKey();
|
||||
setGeneratedKey(null);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-20"
|
||||
onClose={() => {
|
||||
if (!generatedWebhook) handleClose();
|
||||
}}
|
||||
>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 p-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
||||
{!generatedWebhook ? (
|
||||
<WebhookForm onSubmit={handleCreateWebhook} handleClose={handleClose} />
|
||||
) : (
|
||||
<GeneratedHookDetails webhookDetails={generatedWebhook} handleClose={handleClose} />
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,14 @@
|
||||
import React from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// assets
|
||||
import EmptyWebhook from "public/empty-state/web-hook.svg";
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
export const WebhooksEmptyState = () => {
|
||||
const router = useRouter();
|
||||
|
||||
export const WebhooksEmptyState: React.FC<Props> = (props) => {
|
||||
const { onClick } = props;
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto flex w-full items-center justify-center rounded-sm border border-custom-border-200 bg-custom-background-90 px-16 py-10 lg:w-3/4`}
|
||||
@@ -21,7 +19,7 @@ export const WebhooksEmptyState: React.FC<Props> = (props) => {
|
||||
<p className="mb-7 text-custom-text-300 sm:mb-8">
|
||||
Create webhooks to receive real-time updates and automate actions
|
||||
</p>
|
||||
<Button className="flex items-center gap-1.5" onClick={onClick}>
|
||||
<Button className="flex items-center gap-1.5" onClick={() => router.push(`${router.asPath}/create/`)}>
|
||||
Add webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
WebhookIndividualEventOptions,
|
||||
@@ -10,16 +13,17 @@ import {
|
||||
WebhookOptions,
|
||||
WebhookSecretKey,
|
||||
WebhookToggle,
|
||||
getCurrentHookAsCSV,
|
||||
} from "components/web-hooks";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// helpers
|
||||
import { csvDownload } from "helpers/download.helper";
|
||||
// types
|
||||
import { IWebhook, TWebhookEventTypes } from "types";
|
||||
|
||||
type Props = {
|
||||
data?: Partial<IWebhook>;
|
||||
onSubmit: (data: IWebhook, webhookEventType: TWebhookEventTypes) => Promise<void>;
|
||||
handleClose?: () => void;
|
||||
};
|
||||
|
||||
const initialWebhookPayload: Partial<IWebhook> = {
|
||||
@@ -32,12 +36,18 @@ const initialWebhookPayload: Partial<IWebhook> = {
|
||||
};
|
||||
|
||||
export const WebhookForm: FC<Props> = observer((props) => {
|
||||
const { data, onSubmit, handleClose } = props;
|
||||
const { data } = props;
|
||||
// states
|
||||
const [webhookEventType, setWebhookEventType] = useState<TWebhookEventTypes>("all");
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// mobx store
|
||||
const {
|
||||
webhook: { webhookSecretKey },
|
||||
webhook: { createWebhook, updateWebhook },
|
||||
workspace: { currentWorkspace },
|
||||
} = useMobxStore();
|
||||
// use form
|
||||
const {
|
||||
@@ -48,8 +58,74 @@ export const WebhookForm: FC<Props> = observer((props) => {
|
||||
defaultValues: { ...initialWebhookPayload, ...data },
|
||||
});
|
||||
|
||||
const handleCreateWebhook = async (formData: IWebhook) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
let payload: Partial<IWebhook> = {
|
||||
url: formData.url,
|
||||
};
|
||||
|
||||
if (webhookEventType === "all")
|
||||
payload = {
|
||||
...payload,
|
||||
project: true,
|
||||
cycle: true,
|
||||
module: true,
|
||||
issue: true,
|
||||
issue_comment: true,
|
||||
};
|
||||
else
|
||||
payload = {
|
||||
...payload,
|
||||
project: formData.project ?? false,
|
||||
cycle: formData.cycle ?? false,
|
||||
module: formData.module ?? false,
|
||||
issue: formData.issue ?? false,
|
||||
issue_comment: formData.issue_comment ?? false,
|
||||
};
|
||||
|
||||
await createWebhook(workspaceSlug.toString(), payload)
|
||||
.then(({ webHook, secretKey }) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Webhook created successfully.",
|
||||
});
|
||||
|
||||
const csvData = getCurrentHookAsCSV(currentWorkspace, webHook, secretKey);
|
||||
csvDownload(csvData, `webhook-secret-key-${Date.now()}`);
|
||||
|
||||
if (webHook && webHook.id)
|
||||
router.push({ pathname: `/${workspaceSlug}/settings/webhooks/${webHook.id}`, query: { isCreated: true } });
|
||||
})
|
||||
.catch((error) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: error?.error ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateWebhook = async (formData: IWebhook) => {
|
||||
if (!workspaceSlug || !data || !data.id) return;
|
||||
|
||||
const payload = {
|
||||
url: formData?.url,
|
||||
is_active: formData?.is_active,
|
||||
project: formData?.project,
|
||||
cycle: formData?.cycle,
|
||||
module: formData?.module,
|
||||
issue: formData?.issue,
|
||||
issue_comment: formData?.issue_comment,
|
||||
};
|
||||
|
||||
return await updateWebhook(workspaceSlug.toString(), data.id, payload);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IWebhook) => {
|
||||
await onSubmit(formData, webhookEventType);
|
||||
if (data) await handleUpdateWebhook(formData);
|
||||
else await handleCreateWebhook(formData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,26 +161,12 @@ export const WebhookForm: FC<Props> = observer((props) => {
|
||||
<div className="mt-4">
|
||||
{webhookEventType === "individual" && <WebhookIndividualEventOptions control={control} />}
|
||||
</div>
|
||||
{data ? (
|
||||
<div className="mt-8 space-y-8">
|
||||
<WebhookSecretKey data={data} />
|
||||
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="neutral-primary" onClick={handleClose}>
|
||||
Discard
|
||||
</Button>
|
||||
{!webhookSecretKey && (
|
||||
<Button type="submit" variant="primary" loading={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-8 space-y-8">
|
||||
{data && <WebhookSecretKey data={data} />}
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{data ? (isSubmitting ? "Updating..." : "Update") : isSubmitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -56,11 +56,11 @@ export const WebhookSecretKey: FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleRegenerateSecretKey = () => {
|
||||
if (!workspaceSlug || !data.id) return;
|
||||
if (!workspaceSlug || !webhookId) return;
|
||||
|
||||
setIsRegenerating(true);
|
||||
|
||||
regenerateSecretKey(workspaceSlug.toString(), data.id)
|
||||
regenerateSecretKey(workspaceSlug.toString(), webhookId.toString())
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@@ -92,10 +92,10 @@ export const WebhookSecretKey: FC<Props> = observer((props) => {
|
||||
<>
|
||||
{(data || webhookSecretKey) && (
|
||||
<div className="space-y-2">
|
||||
{webhookId && <div className="text-sm font-medium">Secret key</div>}
|
||||
<div className="text-sm font-medium">Secret key</div>
|
||||
<div className="text-xs text-custom-text-400">Generate a token to sign-in to the webhook payload</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-grow max-w-lg items-center justify-between self-stretch rounded border border-custom-border-200 px-2 py-1.5">
|
||||
<div className="flex min-w-[30rem] max-w-lg items-center justify-between self-stretch rounded border border-custom-border-200 px-2 py-1.5">
|
||||
<div className="select-none overflow-hidden font-medium">
|
||||
{shouldShowKey ? (
|
||||
<p className="text-xs">{webhookSecretKey}</p>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// components
|
||||
import { WebhookSecretKey } from "./form";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// types
|
||||
import { IWebhook } from "types";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
webhookDetails: IWebhook;
|
||||
};
|
||||
|
||||
export const GeneratedHookDetails: React.FC<Props> = (props) => {
|
||||
const { handleClose, webhookDetails } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-3 mb-3">
|
||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Key created</h3>
|
||||
<p className="text-sm text-custom-text-400">
|
||||
Copy and save this secret key in Plane Pages. You can{"'"}t see this key after you hit Close. A CSV file
|
||||
containing the key has been downloaded.
|
||||
</p>
|
||||
</div>
|
||||
<WebhookSecretKey data={webhookDetails} />
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
export * from "./form";
|
||||
export * from "./delete-webhook-modal";
|
||||
export * from "./empty-state";
|
||||
export * from "./form";
|
||||
export * from "./generated-hook-details";
|
||||
export * from "./utils";
|
||||
export * from "./create-webhook-modal";
|
||||
export * from "./webhooks-list-item";
|
||||
export * from "./webhooks-list";
|
||||
|
||||
@@ -60,7 +60,6 @@ const calculateShades = (hexValue: string): TShades => {
|
||||
};
|
||||
|
||||
export const applyTheme = (palette: string, isDarkPalette: boolean) => {
|
||||
if (!palette) return;
|
||||
const dom = document?.querySelector<HTMLElement>("[data-theme='custom']");
|
||||
// palette: [bg, text, primary, sidebarBg, sidebarText]
|
||||
const values: string[] = palette.split(",");
|
||||
|
||||
@@ -47,7 +47,8 @@ const PosthogWrapper: FC<IPosthogWrapper> = (props) => {
|
||||
capture_pageview: false, // Disable automatic pageview capture, as we capture manually
|
||||
});
|
||||
}
|
||||
}, [posthogAPIKey, posthogHost]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Track page views
|
||||
@@ -59,7 +60,7 @@ const PosthogWrapper: FC<IPosthogWrapper> = (props) => {
|
||||
return () => {
|
||||
router.events.off("routeChangeComplete", handleRouteChange);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (posthogAPIKey) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
@@ -7,8 +7,6 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "components/web-hooks";
|
||||
@@ -16,21 +14,22 @@ import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "component
|
||||
import { Spinner } from "@plane/ui";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
import { IWebhook } from "types";
|
||||
|
||||
const WebhookDetailsPage: NextPageWithLayout = observer(() => {
|
||||
// states
|
||||
const [deleteWebhookModal, setDeleteWebhookModal] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, webhookId } = router.query;
|
||||
const { workspaceSlug, webhookId, isCreated } = router.query;
|
||||
// mobx store
|
||||
const {
|
||||
webhook: { currentWebhook, fetchWebhookById, updateWebhook },
|
||||
webhook: { currentWebhook, clearSecretKey, fetchWebhookById },
|
||||
user: { currentWorkspaceRole },
|
||||
} = useMobxStore();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreated !== "true") clearSecretKey();
|
||||
}, [clearSecretKey, isCreated]);
|
||||
|
||||
const isAdmin = currentWorkspaceRole === 20;
|
||||
|
||||
@@ -41,34 +40,6 @@ const WebhookDetailsPage: NextPageWithLayout = observer(() => {
|
||||
: null
|
||||
);
|
||||
|
||||
const handleUpdateWebhook = async (formData: IWebhook) => {
|
||||
if (!workspaceSlug || !formData || !formData.id) return;
|
||||
const payload = {
|
||||
url: formData?.url,
|
||||
is_active: formData?.is_active,
|
||||
project: formData?.project,
|
||||
cycle: formData?.cycle,
|
||||
module: formData?.module,
|
||||
issue: formData?.issue,
|
||||
issue_comment: formData?.issue_comment,
|
||||
};
|
||||
await updateWebhook(workspaceSlug.toString(), formData.id, payload)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Webhook updated successfully.",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: error?.error ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
@@ -87,7 +58,7 @@ const WebhookDetailsPage: NextPageWithLayout = observer(() => {
|
||||
<>
|
||||
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
|
||||
<div className="w-full space-y-8 overflow-y-auto py-8 pr-9">
|
||||
<WebhookForm onSubmit={async (data) => await handleUpdateWebhook(data)} data={currentWebhook} />
|
||||
<WebhookForm data={currentWebhook} />
|
||||
{currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
import { WebhookForm } from "components/web-hooks";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
|
||||
const CreateWebhookPage: NextPageWithLayout = observer(() => {
|
||||
const {
|
||||
user: { currentWorkspaceRole },
|
||||
} = useMobxStore();
|
||||
|
||||
const isAdmin = currentWorkspaceRole === 20;
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-y-auto py-8 pl-1 pr-9">
|
||||
<WebhookForm />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CreateWebhookPage.getLayout = function getLayout(page: React.ReactElement) {
|
||||
return (
|
||||
<AppLayout header={<WorkspaceSettingHeader title="Webhook settings" />}>
|
||||
<WorkspaceSettingLayout>{page}</WorkspaceSettingLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateWebhookPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
@@ -9,22 +10,18 @@ import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
import { WebhooksList, WebhooksEmptyState, CreateWebhookModal } from "components/web-hooks";
|
||||
import { WebhooksList, WebhooksEmptyState } from "components/web-hooks";
|
||||
// ui
|
||||
import { Button, Spinner } from "@plane/ui";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
|
||||
const WebhooksListPage: NextPageWithLayout = observer(() => {
|
||||
// states
|
||||
const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const {
|
||||
webhook: { fetchWebhooks, createWebhook, clearSecretKey, webhooks, webhookSecretKey },
|
||||
workspace: { currentWorkspace },
|
||||
webhook: { fetchWebhooks, webhooks },
|
||||
user: { currentWorkspaceRole },
|
||||
} = useMobxStore();
|
||||
|
||||
@@ -35,11 +32,6 @@ const WebhooksListPage: NextPageWithLayout = observer(() => {
|
||||
workspaceSlug && isAdmin ? () => fetchWebhooks(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
// clear secret key when modal is closed.
|
||||
useEffect(() => {
|
||||
if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey();
|
||||
}, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]);
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
@@ -56,28 +48,21 @@ const WebhooksListPage: NextPageWithLayout = observer(() => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden py-8 pr-9">
|
||||
<CreateWebhookModal
|
||||
createWebhook={createWebhook}
|
||||
clearSecretKey={clearSecretKey}
|
||||
currentWorkspace={currentWorkspace}
|
||||
isOpen={showCreateWebhookModal}
|
||||
onClose={() => {
|
||||
setShowCreateWebhookModal(false);
|
||||
}}
|
||||
/>
|
||||
{Object.keys(webhooks).length > 0 ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
|
||||
<div className="text-xl font-medium">Webhooks</div>
|
||||
<Button variant="primary" size="sm" onClick={() => setShowCreateWebhookModal(true)}>
|
||||
Add webhook
|
||||
</Button>
|
||||
<Link href={`/${workspaceSlug}/settings/webhooks/create`}>
|
||||
<Button variant="primary" size="sm">
|
||||
Add webhook
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<WebhooksList />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto">
|
||||
<WebhooksEmptyState onClick={() => setShowCreateWebhookModal(true)} />
|
||||
<WebhooksEmptyState />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user