style: create webhook page to modal (#3223)

* style: create webhook page to modal

* fix: create page removed

* fix: auto modal close on empty state

* fix: secret key heading removed from generated modal
This commit is contained in:
Lakhan Baheti 2023-12-26 13:28:47 +05:30 committed by GitHub
parent 542b18a585
commit e14baf17a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 273 additions and 158 deletions

View File

@ -0,0 +1,139 @@
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>
);
};

View File

@ -1,14 +1,16 @@
// next import React from "react";
import { useRouter } from "next/router";
import Image from "next/image"; import Image from "next/image";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// assets // assets
import EmptyWebhook from "public/empty-state/web-hook.svg"; import EmptyWebhook from "public/empty-state/web-hook.svg";
export const WebhooksEmptyState = () => { type Props = {
const router = useRouter(); onClick: () => void;
};
export const WebhooksEmptyState: React.FC<Props> = (props) => {
const { onClick } = props;
return ( return (
<div <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`} 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`}
@ -19,7 +21,7 @@ export const WebhooksEmptyState = () => {
<p className="mb-7 text-custom-text-300 sm:mb-8"> <p className="mb-7 text-custom-text-300 sm:mb-8">
Create webhooks to receive real-time updates and automate actions Create webhooks to receive real-time updates and automate actions
</p> </p>
<Button className="flex items-center gap-1.5" onClick={() => router.push(`${router.asPath}/create/`)}> <Button className="flex items-center gap-1.5" onClick={onClick}>
Add webhook Add webhook
</Button> </Button>
</div> </div>

View File

@ -1,11 +1,8 @@
import React, { FC, useEffect, useState } from "react"; import React, { FC, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// components // components
import { import {
WebhookIndividualEventOptions, WebhookIndividualEventOptions,
@ -13,17 +10,16 @@ import {
WebhookOptions, WebhookOptions,
WebhookSecretKey, WebhookSecretKey,
WebhookToggle, WebhookToggle,
getCurrentHookAsCSV,
} from "components/web-hooks"; } from "components/web-hooks";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// helpers
import { csvDownload } from "helpers/download.helper";
// types // types
import { IWebhook, TWebhookEventTypes } from "types"; import { IWebhook, TWebhookEventTypes } from "types";
type Props = { type Props = {
data?: Partial<IWebhook>; data?: Partial<IWebhook>;
onSubmit: (data: IWebhook, webhookEventType: TWebhookEventTypes) => Promise<void>;
handleClose?: () => void;
}; };
const initialWebhookPayload: Partial<IWebhook> = { const initialWebhookPayload: Partial<IWebhook> = {
@ -36,18 +32,12 @@ const initialWebhookPayload: Partial<IWebhook> = {
}; };
export const WebhookForm: FC<Props> = observer((props) => { export const WebhookForm: FC<Props> = observer((props) => {
const { data } = props; const { data, onSubmit, handleClose } = props;
// states // states
const [webhookEventType, setWebhookEventType] = useState<TWebhookEventTypes>("all"); const [webhookEventType, setWebhookEventType] = useState<TWebhookEventTypes>("all");
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// toast
const { setToastAlert } = useToast();
// mobx store // mobx store
const { const {
webhook: { createWebhook, updateWebhook }, webhook: { webhookSecretKey },
workspace: { currentWorkspace },
} = useMobxStore(); } = useMobxStore();
// use form // use form
const { const {
@ -58,74 +48,8 @@ export const WebhookForm: FC<Props> = observer((props) => {
defaultValues: { ...initialWebhookPayload, ...data }, 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) => { const handleFormSubmit = async (formData: IWebhook) => {
if (data) await handleUpdateWebhook(formData); await onSubmit(formData, webhookEventType);
else await handleCreateWebhook(formData);
}; };
useEffect(() => { useEffect(() => {
@ -161,12 +85,26 @@ export const WebhookForm: FC<Props> = observer((props) => {
<div className="mt-4"> <div className="mt-4">
{webhookEventType === "individual" && <WebhookIndividualEventOptions control={control} />} {webhookEventType === "individual" && <WebhookIndividualEventOptions control={control} />}
</div> </div>
<div className="mt-8 space-y-8"> {data ? (
{data && <WebhookSecretKey data={data} />} <div className="mt-8 space-y-8">
<Button type="submit" loading={isSubmitting}> <WebhookSecretKey data={data} />
{data ? (isSubmitting ? "Updating..." : "Update") : isSubmitting ? "Creating..." : "Create"}
</Button> <Button type="submit" loading={isSubmitting}>
</div> {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>
)}
</form> </form>
</div> </div>
); );

View File

@ -56,11 +56,11 @@ export const WebhookSecretKey: FC<Props> = observer((props) => {
}; };
const handleRegenerateSecretKey = () => { const handleRegenerateSecretKey = () => {
if (!workspaceSlug || !webhookId) return; if (!workspaceSlug || !data.id) return;
setIsRegenerating(true); setIsRegenerating(true);
regenerateSecretKey(workspaceSlug.toString(), webhookId.toString()) regenerateSecretKey(workspaceSlug.toString(), data.id)
.then(() => { .then(() => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -92,10 +92,10 @@ export const WebhookSecretKey: FC<Props> = observer((props) => {
<> <>
{(data || webhookSecretKey) && ( {(data || webhookSecretKey) && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium">Secret key</div> {webhookId && <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="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 items-center gap-4">
<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="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="select-none overflow-hidden font-medium"> <div className="select-none overflow-hidden font-medium">
{shouldShowKey ? ( {shouldShowKey ? (
<p className="text-xs">{webhookSecretKey}</p> <p className="text-xs">{webhookSecretKey}</p>

View File

@ -0,0 +1,33 @@
// 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>
);
};

View File

@ -1,6 +1,8 @@
export * from "./form";
export * from "./delete-webhook-modal"; export * from "./delete-webhook-modal";
export * from "./empty-state"; export * from "./empty-state";
export * from "./form";
export * from "./generated-hook-details";
export * from "./utils"; export * from "./utils";
export * from "./create-webhook-modal";
export * from "./webhooks-list-item"; export * from "./webhooks-list-item";
export * from "./webhooks-list"; export * from "./webhooks-list";

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
@ -7,6 +7,8 @@ import { useMobxStore } from "lib/mobx/store-provider";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout"; import { WorkspaceSettingLayout } from "layouts/settings-layout";
// hooks
import useToast from "hooks/use-toast";
// components // components
import { WorkspaceSettingHeader } from "components/headers"; import { WorkspaceSettingHeader } from "components/headers";
import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "components/web-hooks"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "components/web-hooks";
@ -14,22 +16,21 @@ import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "component
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
// types // types
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
import { IWebhook } from "types";
const WebhookDetailsPage: NextPageWithLayout = observer(() => { const WebhookDetailsPage: NextPageWithLayout = observer(() => {
// states // states
const [deleteWebhookModal, setDeleteWebhookModal] = useState(false); const [deleteWebhookModal, setDeleteWebhookModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, webhookId, isCreated } = router.query; const { workspaceSlug, webhookId } = router.query;
// mobx store // mobx store
const { const {
webhook: { currentWebhook, clearSecretKey, fetchWebhookById }, webhook: { currentWebhook, fetchWebhookById, updateWebhook },
user: { currentWorkspaceRole }, user: { currentWorkspaceRole },
} = useMobxStore(); } = useMobxStore();
// toast
useEffect(() => { const { setToastAlert } = useToast();
if (isCreated !== "true") clearSecretKey();
}, [clearSecretKey, isCreated]);
const isAdmin = currentWorkspaceRole === 20; const isAdmin = currentWorkspaceRole === 20;
@ -40,6 +41,34 @@ const WebhookDetailsPage: NextPageWithLayout = observer(() => {
: null : 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) if (!isAdmin)
return ( return (
<div className="mt-10 flex h-full w-full justify-center p-4"> <div className="mt-10 flex h-full w-full justify-center p-4">
@ -58,7 +87,7 @@ const WebhookDetailsPage: NextPageWithLayout = observer(() => {
<> <>
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} /> <DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
<div className="w-full space-y-8 overflow-y-auto py-8 pr-9"> <div className="w-full space-y-8 overflow-y-auto py-8 pr-9">
<WebhookForm data={currentWebhook} /> <WebhookForm onSubmit={async (data) => await handleUpdateWebhook(data)} data={currentWebhook} />
{currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />} {currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />}
</div> </div>
</> </>

View File

@ -1,43 +0,0 @@
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;

View File

@ -1,5 +1,4 @@
import React from "react"; import React, { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@ -10,18 +9,22 @@ import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout"; import { WorkspaceSettingLayout } from "layouts/settings-layout";
// components // components
import { WorkspaceSettingHeader } from "components/headers"; import { WorkspaceSettingHeader } from "components/headers";
import { WebhooksList, WebhooksEmptyState } from "components/web-hooks"; import { WebhooksList, WebhooksEmptyState, CreateWebhookModal } from "components/web-hooks";
// ui // ui
import { Button, Spinner } from "@plane/ui"; import { Button, Spinner } from "@plane/ui";
// types // types
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
const WebhooksListPage: NextPageWithLayout = observer(() => { const WebhooksListPage: NextPageWithLayout = observer(() => {
// states
const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false);
// router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { const {
webhook: { fetchWebhooks, webhooks }, webhook: { fetchWebhooks, createWebhook, clearSecretKey, webhooks, webhookSecretKey },
workspace: { currentWorkspace },
user: { currentWorkspaceRole }, user: { currentWorkspaceRole },
} = useMobxStore(); } = useMobxStore();
@ -32,6 +35,11 @@ const WebhooksListPage: NextPageWithLayout = observer(() => {
workspaceSlug && isAdmin ? () => fetchWebhooks(workspaceSlug.toString()) : null workspaceSlug && isAdmin ? () => fetchWebhooks(workspaceSlug.toString()) : null
); );
// clear secret key when modal is closed.
useEffect(() => {
if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey();
}, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]);
if (!isAdmin) if (!isAdmin)
return ( return (
<div className="mt-10 flex h-full w-full justify-center p-4"> <div className="mt-10 flex h-full w-full justify-center p-4">
@ -48,21 +56,28 @@ const WebhooksListPage: NextPageWithLayout = observer(() => {
return ( return (
<div className="h-full w-full overflow-hidden py-8 pr-9"> <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 ? ( {Object.keys(webhooks).length > 0 ? (
<div className="flex h-full w-full flex-col"> <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="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
<div className="text-xl font-medium">Webhooks</div> <div className="text-xl font-medium">Webhooks</div>
<Link href={`/${workspaceSlug}/settings/webhooks/create`}> <Button variant="primary" size="sm" onClick={() => setShowCreateWebhookModal(true)}>
<Button variant="primary" size="sm"> Add webhook
Add webhook </Button>
</Button>
</Link>
</div> </div>
<WebhooksList /> <WebhooksList />
</div> </div>
) : ( ) : (
<div className="mx-auto"> <div className="mx-auto">
<WebhooksEmptyState /> <WebhooksEmptyState onClick={() => setShowCreateWebhookModal(true)} />
</div> </div>
)} )}
</div> </div>