Compare commits

..
Author SHA1 Message Date
NarayanBavisetti a8eebbae1f dev: workspace estimates 2023-12-26 15:51:41 +05:30
20 changed files with 372 additions and 349 deletions
+22 -1
View File
@@ -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(),
+1
View File
@@ -141,6 +141,7 @@ from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndp
from .estimate import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
WorkspaceEstimateEndpoint,
)
from .inbox import InboxViewSet, InboxIssueViewSet
+155 -2
View File
@@ -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")
@@ -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'),
),
]
+1
View File
@@ -9,6 +9,7 @@ from .workspace import (
WorkspaceMemberInvite,
TeamMember,
WorkspaceTheme,
WorkspaceBaseModel,
)
from .project import (
+3 -3
View File
@@ -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,
+17
View File
@@ -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>
);
};
+5 -7
View File
@@ -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>
+87 -25
View File
@@ -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>
);
+4 -4
View File
@@ -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 -3
View File
@@ -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";
-1
View File
@@ -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(",");
+3 -2
View File
@@ -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>