feat: init
This commit is contained in:
127
apps/web/core/components/web-hooks/create-webhook-modal.tsx
Normal file
127
apps/web/core/components/web-hooks/create-webhook-modal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWebhook, IWorkspace, TWebhookEventTypes } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// helpers
|
||||
import { csvDownload } from "@plane/utils";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
// components
|
||||
import { WebhookForm } from "./form";
|
||||
import { GeneratedHookDetails } from "./generated-hook-details";
|
||||
// utils
|
||||
import { getCurrentHookAsCSV } from "./utils";
|
||||
|
||||
interface ICreateWebhookModal {
|
||||
currentWorkspace: IWorkspace | null;
|
||||
isOpen: boolean;
|
||||
clearSecretKey: () => void;
|
||||
createWebhook: (
|
||||
workspaceSlug: string,
|
||||
data: Partial<IWebhook>
|
||||
) => Promise<{
|
||||
webHook: IWebhook;
|
||||
secretKey: string | null;
|
||||
}>;
|
||||
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 { workspaceSlug } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
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 }) => {
|
||||
captureSuccess({
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_created,
|
||||
payload: {
|
||||
webhook: formData?.url,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("workspace_settings.settings.webhooks.toasts.created.title"),
|
||||
message: t("workspace_settings.settings.webhooks.toasts.created.message"),
|
||||
});
|
||||
|
||||
setGeneratedKey(webHook);
|
||||
|
||||
const csvData = getCurrentHookAsCSV(currentWorkspace, webHook, secretKey ?? undefined);
|
||||
csvDownload(csvData, `webhook-secret-key-${Date.now()}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
captureError({
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_created,
|
||||
payload: {
|
||||
webhook: formData?.url,
|
||||
},
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("workspace_settings.settings.webhooks.toasts.not_created.title"),
|
||||
message: error?.error ?? t("workspace_settings.settings.webhooks.toasts.not_created.message"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setTimeout(() => {
|
||||
clearSecretKey();
|
||||
setGeneratedKey(null);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
useKeypress("Escape", () => {
|
||||
if (isOpen && !generatedWebhook) handleClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL} className="p-4 pb-0">
|
||||
{!generatedWebhook ? (
|
||||
<WebhookForm onSubmit={handleCreateWebhook} handleClose={handleClose} />
|
||||
) : (
|
||||
<GeneratedHookDetails webhookDetails={generatedWebhook} handleClose={handleClose} />
|
||||
)}
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
87
apps/web/core/components/web-hooks/delete-webhook-modal.tsx
Normal file
87
apps/web/core/components/web-hooks/delete-webhook-modal.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useWebhook } from "@/hooks/store/use-webhook";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
interface IDeleteWebhook {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DeleteWebhookModal: FC<IDeleteWebhook> = (props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// states
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { removeWebhook } = useWebhook();
|
||||
|
||||
const { workspaceSlug, webhookId } = useParams();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!workspaceSlug || !webhookId) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
removeWebhook(workspaceSlug.toString(), webhookId.toString())
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_deleted,
|
||||
payload: {
|
||||
webhook: webhookId,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Webhook deleted successfully.",
|
||||
});
|
||||
router.replace(`/${workspaceSlug}/settings/webhooks/`);
|
||||
})
|
||||
.catch((error) => {
|
||||
captureError({
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_deleted,
|
||||
payload: {
|
||||
webhook: webhookId,
|
||||
},
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsDeleting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={handleClose}
|
||||
handleSubmit={handleDelete}
|
||||
isSubmitting={isDeleting}
|
||||
isOpen={isOpen}
|
||||
title="Delete webhook"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete this webhook? Future events will not be delivered to this webhook. This action
|
||||
cannot be undone.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
32
apps/web/core/components/web-hooks/empty-state.tsx
Normal file
32
apps/web/core/components/web-hooks/empty-state.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import EmptyWebhook from "@/public/empty-state/web-hook.svg";
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
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`}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<Image src={EmptyWebhook} className="w-52 sm:w-60" alt="empty" />
|
||||
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">No webhooks</h6>
|
||||
<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}>
|
||||
Add webhook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
55
apps/web/core/components/web-hooks/form/delete-section.tsx
Normal file
55
apps/web/core/components/web-hooks/form/delete-section.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
||||
type Props = {
|
||||
openDeleteModal: () => void;
|
||||
};
|
||||
|
||||
export const WebhookDeleteSection: React.FC<Props> = (props) => {
|
||||
const { openDeleteModal } = props;
|
||||
|
||||
return (
|
||||
<Disclosure as="div" className="border-t border-custom-border-200">
|
||||
{({ open }) => (
|
||||
<div className="w-full">
|
||||
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
|
||||
<span className="text-lg tracking-tight">Danger zone</span>
|
||||
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
|
||||
</Disclosure.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="flex flex-col gap-8">
|
||||
<span className="text-sm tracking-tight">
|
||||
Once a webhook is deleted, it cannot be restored. Future events will no longer be delivered to this
|
||||
webhook.
|
||||
</span>
|
||||
<div>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={openDeleteModal}
|
||||
data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DELETE_BUTTON}
|
||||
>
|
||||
Delete webhook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
46
apps/web/core/components/web-hooks/form/event-types.tsx
Normal file
46
apps/web/core/components/web-hooks/form/event-types.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TWebhookEventTypes } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: TWebhookEventTypes) => void;
|
||||
};
|
||||
|
||||
const WEBHOOK_EVENT_TYPES: { key: TWebhookEventTypes; i18n_label: string }[] = [
|
||||
{
|
||||
key: "all",
|
||||
i18n_label: "workspace_settings.settings.webhooks.options.all",
|
||||
},
|
||||
{
|
||||
key: "individual",
|
||||
i18n_label: "workspace_settings.settings.webhooks.options.individual",
|
||||
},
|
||||
];
|
||||
|
||||
export const WebhookOptions: React.FC<Props> = (props) => {
|
||||
const { value, onChange } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h6 className="text-sm font-medium">{t("workspace_settings.settings.webhooks.modal.question")}</h6>
|
||||
<div className="space-y-3">
|
||||
{WEBHOOK_EVENT_TYPES.map((option) => (
|
||||
<div key={option.key} className="flex items-center gap-2">
|
||||
<input
|
||||
id={option.key}
|
||||
type="radio"
|
||||
value={option.key}
|
||||
checked={value == option.key}
|
||||
onChange={() => onChange(option.key)}
|
||||
/>
|
||||
<label className="text-sm" htmlFor={option.key}>
|
||||
{t(option.i18n_label)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
120
apps/web/core/components/web-hooks/form/form.tsx
Normal file
120
apps/web/core/components/web-hooks/form/form.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IWebhook, TWebhookEventTypes } from "@plane/types";
|
||||
// hooks
|
||||
import {
|
||||
WebhookIndividualEventOptions,
|
||||
WebhookInput,
|
||||
WebhookOptions,
|
||||
WebhookSecretKey,
|
||||
WebhookToggle,
|
||||
} from "@/components/web-hooks";
|
||||
import { useWebhook } from "@/hooks/store/use-webhook";
|
||||
// components
|
||||
// ui
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
data?: Partial<IWebhook>;
|
||||
onSubmit: (data: IWebhook, webhookEventType: TWebhookEventTypes) => Promise<void>;
|
||||
handleClose?: () => void;
|
||||
};
|
||||
|
||||
const initialWebhookPayload: Partial<IWebhook> = {
|
||||
cycle: true,
|
||||
issue: true,
|
||||
issue_comment: true,
|
||||
module: true,
|
||||
project: true,
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const WebhookForm: FC<Props> = observer((props) => {
|
||||
const { data, onSubmit, handleClose } = props;
|
||||
// states
|
||||
const [webhookEventType, setWebhookEventType] = useState<TWebhookEventTypes>("all");
|
||||
// store hooks
|
||||
const { webhookSecretKey } = useWebhook();
|
||||
const { t } = useTranslation();
|
||||
// use form
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<IWebhook>({
|
||||
defaultValues: { ...initialWebhookPayload, ...data },
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (formData: IWebhook) => {
|
||||
await onSubmit(formData, webhookEventType);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
if (data.project && data.cycle && data.module && data.issue && data.issue_comment) setWebhookEventType("all");
|
||||
else setWebhookEventType("individual");
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 ">
|
||||
<div className="text-xl font-medium text-custom-text-200">
|
||||
{data
|
||||
? t("workspace_settings.settings.webhooks.modal.details")
|
||||
: t("workspace_settings.settings.webhooks.modal.title")}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: t("workspace_settings.settings.webhooks.modal.error"),
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<WebhookInput value={value} onChange={onChange} hasError={Boolean(errors.url)} />
|
||||
)}
|
||||
/>
|
||||
{errors.url && <div className="text-xs text-red-500">{errors.url.message}</div>}
|
||||
</div>
|
||||
{data && <WebhookToggle control={control} />}
|
||||
<WebhookOptions value={webhookEventType} onChange={(val) => setWebhookEventType(val)} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{webhookEventType === "individual" && <WebhookIndividualEventOptions control={control} />}
|
||||
</div>
|
||||
</div>
|
||||
{data ? (
|
||||
<div className="pt-0 space-y-5">
|
||||
<WebhookSecretKey data={data} />
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_UPDATE_BUTTON}
|
||||
>
|
||||
{isSubmitting ? t("updating") : t("update")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
{!webhookSecretKey && (
|
||||
<Button type="submit" variant="primary" size="sm" loading={isSubmitting} className="capitalize">
|
||||
{isSubmitting ? t("common.creating") : t("common.create")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/web-hooks/form/index.ts
Normal file
7
apps/web/core/components/web-hooks/form/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./delete-section";
|
||||
export * from "./event-types";
|
||||
export * from "./form";
|
||||
export * from "./individual-event-options";
|
||||
export * from "./input";
|
||||
export * from "./secret-key";
|
||||
export * from "./toggle";
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import type { IWebhook } from "@plane/types";
|
||||
|
||||
export const INDIVIDUAL_WEBHOOK_OPTIONS: {
|
||||
key: keyof IWebhook;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "project",
|
||||
label: "Projects",
|
||||
description: "Project created, updated, or deleted",
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "Cycles",
|
||||
description: "Cycle created, updated, or deleted",
|
||||
},
|
||||
{
|
||||
key: "issue",
|
||||
label: "Work items",
|
||||
description: "Work item created, updated, deleted, added to a cycle or module",
|
||||
},
|
||||
{
|
||||
key: "module",
|
||||
label: "Modules",
|
||||
description: "Module created, updated, or deleted",
|
||||
},
|
||||
{
|
||||
key: "issue_comment",
|
||||
label: "Work item comments",
|
||||
description: "Comment posted, updated, or deleted",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
control: Control<IWebhook, any>;
|
||||
};
|
||||
|
||||
export const WebhookIndividualEventOptions = ({ control }: Props) => (
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-8 px-6 lg:grid-cols-2">
|
||||
{INDIVIDUAL_WEBHOOK_OPTIONS.map((option) => (
|
||||
<Controller
|
||||
key={option.key}
|
||||
control={control}
|
||||
name={option.key}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={option.key}
|
||||
onChange={() => onChange(!value)}
|
||||
type="checkbox"
|
||||
name="selectIndividualEvents"
|
||||
checked={value === true}
|
||||
/>
|
||||
<label className="text-sm" htmlFor={option.key}>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
<p className="ml-6 mt-0.5 text-xs text-custom-text-300">{option.description}</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
30
apps/web/core/components/web-hooks/form/input.tsx
Normal file
30
apps/web/core/components/web-hooks/form/input.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Input } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
hasError: boolean;
|
||||
};
|
||||
export const WebhookInput: React.FC<Props> = (props) => {
|
||||
const { value, onChange, hasError } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h6 className="text-sm font-medium">{t("workspace_settings.settings.webhooks.modal.payload")}</h6>
|
||||
<Input
|
||||
type="url"
|
||||
className="h-11 w-full"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
value={value}
|
||||
autoComplete="off"
|
||||
hasError={hasError}
|
||||
placeholder="https://example.com/post"
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
145
apps/web/core/components/web-hooks/form/secret-key.tsx
Normal file
145
apps/web/core/components/web-hooks/form/secret-key.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { range } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { Copy, Eye, EyeOff, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IWebhook } from "@plane/types";
|
||||
// ui
|
||||
import { csvDownload, copyTextToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWebhook } from "@/hooks/store/use-webhook";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// types
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// utils
|
||||
import { getCurrentHookAsCSV } from "../utils";
|
||||
// hooks
|
||||
|
||||
type Props = {
|
||||
data: Partial<IWebhook>;
|
||||
};
|
||||
|
||||
export const WebhookSecretKey: FC<Props> = observer((props) => {
|
||||
const { data } = props;
|
||||
// states
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [shouldShowKey, setShouldShowKey] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug, webhookId } = useParams();
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { currentWebhook, regenerateSecretKey, webhookSecretKey } = useWebhook();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
const handleCopySecretKey = () => {
|
||||
if (!webhookSecretKey) return;
|
||||
|
||||
copyTextToClipboard(webhookSecretKey)
|
||||
.then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: `${t("success")!}`,
|
||||
message: t("workspace_settings.settings.webhooks.toasts.secret_key_copied.message"),
|
||||
})
|
||||
)
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: `${t("error")}!`,
|
||||
message: t("workspace_settings.settings.webhooks.toasts.secret_key_not_copied.message"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRegenerateSecretKey = () => {
|
||||
if (!workspaceSlug || !data.id) return;
|
||||
|
||||
setIsRegenerating(true);
|
||||
|
||||
regenerateSecretKey(workspaceSlug.toString(), data.id)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: `${t("success")!}`,
|
||||
message: "New key regenerated successfully.",
|
||||
});
|
||||
|
||||
if (currentWebhook && webhookSecretKey) {
|
||||
const csvData = getCurrentHookAsCSV(currentWorkspace, currentWebhook, webhookSecretKey);
|
||||
csvDownload(csvData, `webhook-secret-key-${Date.now()}`);
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: `${t("error")}!`,
|
||||
message: err?.error ?? t("something_went_wrong_please_try_again"),
|
||||
})
|
||||
)
|
||||
.finally(() => setIsRegenerating(false));
|
||||
};
|
||||
|
||||
const toggleShowKey = () => setShouldShowKey((prevState) => !prevState);
|
||||
|
||||
const SECRET_KEY_OPTIONS = [
|
||||
{ label: "View secret key", Icon: shouldShowKey ? EyeOff : Eye, onClick: toggleShowKey, key: "eye" },
|
||||
{ label: "Copy secret key", Icon: Copy, onClick: handleCopySecretKey, key: "copy" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(data || webhookSecretKey) && (
|
||||
<div className="space-y-2">
|
||||
{webhookId && (
|
||||
<div className="text-sm font-medium">{t("workspace_settings.settings.webhooks.secret_key.title")}</div>
|
||||
)}
|
||||
<div className="text-xs text-custom-text-400">
|
||||
{t("workspace_settings.settings.webhooks.secret_key.message")}
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md: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 h-8">
|
||||
<div className="select-none overflow-hidden font-medium">
|
||||
{shouldShowKey ? (
|
||||
<p className="text-xs">{webhookSecretKey}</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 overflow-hidden mr-2">
|
||||
{range(30).map((index) => (
|
||||
<div key={index} className="h-1 w-1 rounded-full bg-custom-text-400 flex-shrink-0" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{webhookSecretKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
{SECRET_KEY_OPTIONS.map((option) => (
|
||||
<Tooltip key={option.key} tooltipContent={option.label} isMobile={isMobile}>
|
||||
<button type="button" className="grid flex-shrink-0 place-items-center" onClick={option.onClick}>
|
||||
<option.Icon className="h-3 w-3 text-custom-text-400" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{data && (
|
||||
<div>
|
||||
<Button onClick={handleRegenerateSecretKey} variant="accent-primary" loading={isRegenerating}>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{isRegenerating ? `${t("re_generating")}...` : t("re_generate_key")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
37
apps/web/core/components/web-hooks/form/toggle.tsx
Normal file
37
apps/web/core/components/web-hooks/form/toggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
// constants
|
||||
import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import type { IWebhook } from "@plane/types";
|
||||
// ui
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
|
||||
interface IWebHookToggle {
|
||||
control: Control<IWebhook, any>;
|
||||
}
|
||||
|
||||
export const WebhookToggle = ({ control }: IWebHookToggle) => (
|
||||
<div className="flex gap-6">
|
||||
<div className="text-sm font-medium">Enable webhook</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_active"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(val: boolean) => {
|
||||
captureClick({
|
||||
elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DETAILS_PAGE_TOGGLE_SWITCH,
|
||||
});
|
||||
onChange(val);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
// components
|
||||
// ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IWebhook } from "@plane/types";
|
||||
// types
|
||||
import { WebhookSecretKey } from "./form";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
webhookDetails: IWebhook;
|
||||
};
|
||||
|
||||
export const GeneratedHookDetails: React.FC<Props> = (props) => {
|
||||
const { handleClose, webhookDetails } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">{t("workspace_settings.key_created")}</h3>
|
||||
<p className="text-sm text-custom-text-400">{t("workspace_settings.copy_key")}</p>
|
||||
</div>
|
||||
<WebhookSecretKey data={webhookDetails} />
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
8
apps/web/core/components/web-hooks/index.ts
Normal file
8
apps/web/core/components/web-hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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";
|
||||
23
apps/web/core/components/web-hooks/utils.ts
Normal file
23
apps/web/core/components/web-hooks/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// helpers
|
||||
import type { IWebhook, IWorkspace } from "@plane/types";
|
||||
import { renderFormattedPayloadDate } from "@plane/utils";
|
||||
// types
|
||||
|
||||
export const getCurrentHookAsCSV = (
|
||||
currentWorkspace: IWorkspace | null,
|
||||
webhook: IWebhook | undefined,
|
||||
secretKey: string | undefined
|
||||
) => ({
|
||||
id: webhook?.id || "",
|
||||
url: webhook?.url || "",
|
||||
created_at: renderFormattedPayloadDate(webhook?.created_at || "") ?? "",
|
||||
updated_at: renderFormattedPayloadDate(webhook?.updated_at || "") ?? "",
|
||||
is_active: webhook?.is_active?.toString() || "",
|
||||
secret_key: secretKey || "",
|
||||
project: webhook?.project?.toString() || "",
|
||||
issue: webhook?.issue?.toString() || "",
|
||||
module: webhook?.module?.toString() || "",
|
||||
cycle: webhook?.cycle?.toString() || "",
|
||||
issue_comment: webhook?.issue_comment?.toString() || "",
|
||||
workspace: currentWorkspace?.name || "",
|
||||
});
|
||||
69
apps/web/core/components/web-hooks/webhooks-list-item.tsx
Normal file
69
apps/web/core/components/web-hooks/webhooks-list-item.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import type { IWebhook } from "@plane/types";
|
||||
// hooks
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
import { useWebhook } from "@/hooks/store/use-webhook";
|
||||
// ui
|
||||
// types
|
||||
|
||||
interface IWebhookListItem {
|
||||
webhook: IWebhook;
|
||||
}
|
||||
|
||||
export const WebhooksListItem: FC<IWebhookListItem> = (props) => {
|
||||
const { webhook } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { updateWebhook } = useWebhook();
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!workspaceSlug || !webhook.id) return;
|
||||
updateWebhook(workspaceSlug.toString(), webhook.id, { is_active: !webhook.is_active })
|
||||
.then(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_LIST_ITEM_TOGGLE_SWITCH,
|
||||
},
|
||||
event: {
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_toggled,
|
||||
state: "SUCCESS",
|
||||
payload: {
|
||||
webhook: webhook.url,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_LIST_ITEM_TOGGLE_SWITCH,
|
||||
},
|
||||
event: {
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_toggled,
|
||||
state: "ERROR",
|
||||
payload: {
|
||||
webhook: webhook.url,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-custom-border-200">
|
||||
<Link href={`/${workspaceSlug}/settings/webhooks/${webhook?.id}`}>
|
||||
<span className="flex items-center justify-between gap-4 py-[18px]">
|
||||
<h5 className="truncate text-base font-medium">{webhook.url}</h5>
|
||||
<ToggleSwitch value={webhook.is_active} onChange={handleToggle} />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
apps/web/core/components/web-hooks/webhooks-list.tsx
Normal file
18
apps/web/core/components/web-hooks/webhooks-list.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useWebhook } from "@/hooks/store/use-webhook";
|
||||
// components
|
||||
import { WebhooksListItem } from "./webhooks-list-item";
|
||||
|
||||
export const WebhooksList = observer(() => {
|
||||
// store hooks
|
||||
const { webhooks } = useWebhook();
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
{Object.values(webhooks ?? {}).map((webhook) => (
|
||||
<WebhooksListItem key={webhook.id} webhook={webhook} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user