From a47b73f6b45211857e24ebcc4c2ec81cf90f49ce Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Date: Wed, 1 Nov 2023 05:26:49 +0530 Subject: [PATCH 1/3] feat: workspace webhook store, services integeration and rendered webhook list and create --- web/components/web-hooks/empty-webhooks.tsx | 31 +++ web/components/web-hooks/generate-key.tsx | 55 +++++ web/components/web-hooks/index.ts | 4 + web/components/web-hooks/webhook-form.tsx | 221 ++++++++++++++++++ .../web-hooks/webhooks-list-item.tsx | 36 +++ web/components/web-hooks/webhooks-list.tsx | 35 +++ web/helpers/generate-random-string.ts | 13 ++ .../workspace-setting-layout/sidebar.tsx | 6 +- .../settings/webhooks/[webhookId].tsx | 44 ++++ .../settings/webhooks/create.tsx | 56 +++++ .../settings/webhooks/index.tsx | 42 ++++ web/services/webhook.service.ts | 58 +++++ web/store/root.ts | 5 + web/store/webhook.store.ts | 139 +++++++++++ web/types/index.d.ts | 1 + web/types/webhook.d.ts | 16 ++ 16 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 web/components/web-hooks/empty-webhooks.tsx create mode 100644 web/components/web-hooks/generate-key.tsx create mode 100644 web/components/web-hooks/index.ts create mode 100644 web/components/web-hooks/webhook-form.tsx create mode 100644 web/components/web-hooks/webhooks-list-item.tsx create mode 100644 web/components/web-hooks/webhooks-list.tsx create mode 100644 web/helpers/generate-random-string.ts create mode 100644 web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx create mode 100644 web/pages/[workspaceSlug]/settings/webhooks/create.tsx create mode 100644 web/pages/[workspaceSlug]/settings/webhooks/index.tsx create mode 100644 web/services/webhook.service.ts create mode 100644 web/store/webhook.store.ts create mode 100644 web/types/webhook.d.ts diff --git a/web/components/web-hooks/empty-webhooks.tsx b/web/components/web-hooks/empty-webhooks.tsx new file mode 100644 index 00000000000..c27efcccf78 --- /dev/null +++ b/web/components/web-hooks/empty-webhooks.tsx @@ -0,0 +1,31 @@ +import { FC } from "react"; +import Link from "next/link"; +import { Button } from "@plane/ui"; +import Image from "next/image"; +import EmptyWebhookLogo from "public/empty-state/issue.svg"; + +interface IWebHookLists { + workspaceSlug: string; +} + + +export const EmptyWebhooks:FC = (props) => { + + const {workspaceSlug} = props; + + return ( +
+
+ empty-webhook image + +
No Webhooks
+

Create webhooks to receive real-time updates and automate actions

+ + + +
+
+ ); +}; \ No newline at end of file diff --git a/web/components/web-hooks/generate-key.tsx b/web/components/web-hooks/generate-key.tsx new file mode 100644 index 00000000000..513da08b156 --- /dev/null +++ b/web/components/web-hooks/generate-key.tsx @@ -0,0 +1,55 @@ +import { useState, FC } from "react"; +import { Button, Input } from "@plane/ui"; +import { Copy, Eye, EyeOff, RefreshCw } from "lucide-react"; +import { generateRandomString } from "helpers/generate-random-string"; +import { observer } from "mobx-react-lite"; +import { RootStore } from "store/root"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { copyTextToClipboard } from "helpers/string.helper"; + +interface IGenerateKey { + type: "create" | "edit"; +} + +export const GenerateKey: FC = observer((props) => { + const { type } = props; + const { webhook: webhookStore }: RootStore = useMobxStore(); + + const [generateKey, setShowGenarateKey] = useState(false); + + return ( + <> + {type === 'edit' || (type === 'create' && webhookStore?.webhookSecretKey) && ( +
+
Secret Key
+
Genarate a token to sign-in the webhook payload
+ +
+
+
+ {webhookStore?.webhookSecretKey ?
{webhookStore?.webhookSecretKey}
: +
+ {[...Array(41)].map((_, index) =>
)} +
+ } +
+ {webhookStore?.webhookSecretKey && ( +
copyTextToClipboard(webhookStore?.webhookSecretKey || '')}> + +
+ )} +
+
+ {type != 'create' && ( + + )} +
+
+
+ )} + + ); +}); diff --git a/web/components/web-hooks/index.ts b/web/components/web-hooks/index.ts new file mode 100644 index 00000000000..91911ad9afb --- /dev/null +++ b/web/components/web-hooks/index.ts @@ -0,0 +1,4 @@ +export * from "./empty-webhooks"; +export * from "./webhooks-list"; +export * from "./webhooks-list-item"; +export * from "./webhook-form"; diff --git a/web/components/web-hooks/webhook-form.tsx b/web/components/web-hooks/webhook-form.tsx new file mode 100644 index 00000000000..b96f13e6082 --- /dev/null +++ b/web/components/web-hooks/webhook-form.tsx @@ -0,0 +1,221 @@ +import React, { FC, useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { Button, Input, ToggleSwitch } from "@plane/ui"; +import { IWebhook, IExtendedWebhook } from "types"; +import { GenerateKey } from "./generate-key"; +import { observer } from "mobx-react-lite"; +import { RootStore } from "store/root"; +import { useMobxStore } from "lib/mobx/store-provider"; + +interface IWebhookDetails { + type: 'create' | 'edit' + initialData: IWebhook; + onSubmit: (val: IExtendedWebhook) => Promise; +} + +export const WebhookDetails: FC = observer((props) => { + const { type, initialData, onSubmit } = props; + const { webhook: webhookStore }: RootStore = useMobxStore() + + const { reset, watch, handleSubmit, control, getValues, formState: { isSubmitting } } = useForm(); + + useEffect(() => { + if (initialData && reset) reset({ ...initialData, webhook_events: "all" }); + }, [initialData, reset]); + + useEffect(() => { + if (watch("webhook_events")) { + if (watch("webhook_events") === "all") + reset({ ...getValues(), project: true, module: true, cycle: true, issue: true, issue_comment: true }); + if (watch("webhook_events") === "individual") reset({ ...getValues(), project: false, module: false, cycle: false, issue: false, issue_comment: false }); + } + }, [watch && watch("webhook_events")]); + + return ( +
+
+
+
URL
+ ( + + )} + /> +
+ +
+
Enable webhook
+ ( + { + onChange(val); + }} + size="sm" + /> + )} + /> +
+ +
+
Which events do you like to trigger this webhook
+ +
+ ( + onChange("all")} + /> + )} + /> + +
+ +
+ ( + onChange("individual")} + /> + )} + /> + +
+ + {watch("webhook_events") === "individual" && ( +
+
+ ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + /> + +
+ )} + /> + ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + /> + +
+ )} + /> + ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + /> + +
+ )} + /> + ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + /> + +
+ )} + /> + + ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + /> + +
+ )} + /> +
+
+ )} +
+ + + + {!webhookStore?.webhookSecretKey && ( +
+ +
+ )} +
+
+ ); +}); diff --git a/web/components/web-hooks/webhooks-list-item.tsx b/web/components/web-hooks/webhooks-list-item.tsx new file mode 100644 index 00000000000..b54152186c0 --- /dev/null +++ b/web/components/web-hooks/webhooks-list-item.tsx @@ -0,0 +1,36 @@ +import { FC, useState } from "react"; +import { ToggleSwitch } from "@plane/ui"; +import { Pencil, XCircle } from "lucide-react"; +import { IWebhook } from "types"; +import Link from "next/link"; + +interface IWebhookListItem { + workspaceSlug: string; + webhook: IWebhook +} + +export const WebhooksListItem: FC = (props) => { + + const { workspaceSlug, webhook } = props + + const [toggle, setToggle] = useState(false); + + return ( +
+ + {/*
*/} +
+
+
{webhook?.url || "Webhook URL"}
+ {/*
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +
*/} +
+ {/*
+ setToggle(!toggle)} /> +
*/} +
+ +
+ ); +}; diff --git a/web/components/web-hooks/webhooks-list.tsx b/web/components/web-hooks/webhooks-list.tsx new file mode 100644 index 00000000000..5a3f72056de --- /dev/null +++ b/web/components/web-hooks/webhooks-list.tsx @@ -0,0 +1,35 @@ +import { FC } from "react"; +import Link from "next/link"; +import { Button } from "@plane/ui"; +import { WebhooksListItem } from "./webhooks-list-item"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; +import { observer } from "mobx-react-lite"; + +interface IWebHookLists { + workspaceSlug: string; +} + +export const WebhookLists: FC = observer((props) => { + const { workspaceSlug } = props; + const { webhook: webhookStore }: RootStore = useMobxStore(); + + return ( + <> +
+

Webhooks

+ + + +
+ {/* List */} +
+ {webhookStore.webhooks.map((item, index) => ( + + ))} +
+ + ); +}); diff --git a/web/helpers/generate-random-string.ts b/web/helpers/generate-random-string.ts new file mode 100644 index 00000000000..3d85b9b7f8a --- /dev/null +++ b/web/helpers/generate-random-string.ts @@ -0,0 +1,13 @@ + + +export const generateRandomString = (length: number) => { + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + let counter = 0; + while (counter < length) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + counter += 1; + } + return result; +} \ No newline at end of file diff --git a/web/layouts/setting-layout/workspace-setting-layout/sidebar.tsx b/web/layouts/setting-layout/workspace-setting-layout/sidebar.tsx index caf4f835894..c9cb42be1a3 100644 --- a/web/layouts/setting-layout/workspace-setting-layout/sidebar.tsx +++ b/web/layouts/setting-layout/workspace-setting-layout/sidebar.tsx @@ -26,6 +26,10 @@ export const WorkspaceSettingsSidebar = () => { label: "Integrations", href: `/${workspaceSlug}/settings/integrations`, }, + { + label: "Webhooks", + href: `/${workspaceSlug}/settings/webhooks`, + }, { label: "Imports", href: `/${workspaceSlug}/settings/imports`, @@ -53,7 +57,7 @@ export const WorkspaceSettingsSidebar = () => { href: `/${workspaceSlug}/me/profile/preferences`, }, ]; - +console.log(router.asPath); return (
diff --git a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx b/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx new file mode 100644 index 00000000000..0350b03b954 --- /dev/null +++ b/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx @@ -0,0 +1,44 @@ +import React from "react"; +import type { NextPage } from "next"; +import { AppLayout } from "layouts/app-layout"; +import { WorkspaceSettingHeader } from "components/headers"; +import { WorkspaceSettingLayout } from "layouts/setting-layout"; +import { WebhookDetails } from "components/web-hooks"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; +import { IExtendedWebhook, IWebhook } from "types"; + +const Webhooks: NextPage = observer(() => { + + const router = useRouter() + const { workspaceSlug, webhookId } = router.query as { workspaceSlug: string, webhookId: string } + + const { webhook: webhookStore }: RootStore = useMobxStore(); + + const onSubmit = (data: IExtendedWebhook): Promise => { + const payload = { + url: data?.url, + is_active: data?.is_active, + project: data?.project, + cycle: data?.cycle, + module: data?.module, + issue: data?.issue, + issue_comment: data?.issue_comment, + } + return webhookStore.create(workspaceSlug, payload); + } + + return ( + } > + +
+ +
+
+
+ ); +}); + +export default Webhooks; \ No newline at end of file diff --git a/web/pages/[workspaceSlug]/settings/webhooks/create.tsx b/web/pages/[workspaceSlug]/settings/webhooks/create.tsx new file mode 100644 index 00000000000..23fafa5561f --- /dev/null +++ b/web/pages/[workspaceSlug]/settings/webhooks/create.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { useRouter } from "next/router"; +import type { NextPage } from "next"; +import { AppLayout } from "layouts/app-layout"; +import { WorkspaceSettingHeader } from "components/headers"; +import { WorkspaceSettingLayout } from "layouts/setting-layout"; +import { WebhookDetails } from "components/web-hooks"; +import { IWebhook, IExtendedWebhook } from "types"; +import { RootStore } from "store/root"; +import { useMobxStore } from "lib/mobx/store-provider"; + +const Webhooks: NextPage = () => { + + const router = useRouter(); + + const { workspaceSlug } = router.query as { workspaceSlug: string }; + + const initialWebhookPayload: IWebhook = { + url: "", + is_active: true, + secret_key: "", + project: true, + issue_comment: true, + cycle: true, + module: true, + issue: true, + }; + + const { webhook: webhookStore }: RootStore = useMobxStore(); + + + const onSubmit = (data: IExtendedWebhook): Promise => { + const payload = { + url: data?.url, + is_active: data?.is_active, + project: data?.project, + cycle: data?.cycle, + module: data?.module, + issue: data?.issue, + issue_comment: data?.issue_comment, + } + return webhookStore.create(workspaceSlug, payload); + } + + return ( + } > + +
+ +
+
+
+ ); +}; + +export default Webhooks; \ No newline at end of file diff --git a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx b/web/pages/[workspaceSlug]/settings/webhooks/index.tsx new file mode 100644 index 00000000000..ed4bead9be3 --- /dev/null +++ b/web/pages/[workspaceSlug]/settings/webhooks/index.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import type { NextPage } from "next"; +import { useRouter } from "next/router"; +import { AppLayout } from "layouts/app-layout"; +import { WorkspaceSettingHeader } from "components/headers"; +import { WorkspaceSettingLayout } from "layouts/setting-layout"; +import { WebhookLists, EmptyWebhooks } from "components/web-hooks"; +import { WebhookService } from "services/webhook.service"; +import useSWR from "swr"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; +import { observer } from "mobx-react-lite"; + +const webhookService = new WebhookService(); + +const Webhooks: NextPage = observer(() => { + const router = useRouter(); + const { workspaceSlug } = router.query as { workspaceSlug: string }; + + const { webhook: webhookStore }: RootStore = useMobxStore(); + + useSWR( + "WEBHOOKS_LIST", + workspaceSlug + ? () => webhookStore.fetchAll(workspaceSlug) + : null + ); + + + + return ( + } > + +
+ {webhookStore.webhooks.length > 0 ? : } +
+
+
+ ); +}); + +export default Webhooks; diff --git a/web/services/webhook.service.ts b/web/services/webhook.service.ts new file mode 100644 index 00000000000..6e5501bf4d2 --- /dev/null +++ b/web/services/webhook.service.ts @@ -0,0 +1,58 @@ +import { APIService } from "services/api.service"; +// helpers +import { API_BASE_URL } from "helpers/common.helper"; +import { IWebhook } from "types"; + +export class WebhookService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getAll(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/webhooks/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getById(workspaceSlug: string, webhook_id: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create(workspaceSlug: string, data: {}): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/webhooks/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update(workspaceSlug: string, webhook_id: string, data: {}): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async remove(workspaceSlug: string, webhook_id: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async regenrate(workspaceSlug: string, webhook_id: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/regenerate/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/store/root.ts b/web/store/root.ts index fabf7ed9d05..df5e61e1efe 100644 --- a/web/store/root.ts +++ b/web/store/root.ts @@ -86,6 +86,7 @@ import { InboxIssuesStore, InboxStore, } from "store/inbox"; +import { IWebhookStore, WebhookStore } from "./webhook.store"; enableStaticRendering(typeof window === "undefined"); @@ -145,6 +146,8 @@ export class RootStore { inboxIssueDetails: IInboxIssueDetailsStore; inboxFilters: IInboxFiltersStore; + webhook: IWebhookStore; + constructor() { this.commandPalette = new CommandPaletteStore(this); this.user = new UserStore(this); @@ -200,5 +203,7 @@ export class RootStore { this.inboxIssues = new InboxIssuesStore(this); this.inboxIssueDetails = new InboxIssueDetailsStore(this); this.inboxFilters = new InboxFiltersStore(this); + + this.webhook = new WebhookStore(this); } } diff --git a/web/store/webhook.store.ts b/web/store/webhook.store.ts new file mode 100644 index 00000000000..6d9645cd308 --- /dev/null +++ b/web/store/webhook.store.ts @@ -0,0 +1,139 @@ +// mobx +import { action, observable, makeObservable, runInAction } from "mobx"; +import { IWebhook } from "types"; +import { WebhookService } from "services/webhook.service"; + +export interface IWebhookStore { + loader: boolean; + error: any | null; + + webhooks: IWebhook[] | []; + webhookSecretKey: string | null; + + // actions + fetchAll: (workspaceSlug: string) => Promise; + fetchById: (workspaceSlug: string, webhook_id: string) => Promise; + create: (workspaceSlug: string, data: IWebhook) => Promise; + update: (workspaceSlug: string, webhook_id: string, data: IWebhook) => Promise; + remove: (workspaceSlug: string, webhook_id: string) => Promise; + regenerate: (workspaceSlug: string, webhook_id: string, data: IWebhook) => Promise; +} + +export class WebhookStore implements IWebhookStore { + loader: boolean = false; + error: any | null = null; + + webhooks: IWebhook[] | [] = []; + webhookSecretKey: string | null = null; + + // root store + rootStore; + webhookService; + + constructor(_rootStore: any | null = null) { + makeObservable(this, { + loader: observable.ref, + error: observable.ref, + + webhooks: observable.ref, + webhookSecretKey: observable.ref, + + fetchAll: action, + create: action, + fetchById: action, + update: action, + remove: action, + regenerate: action, + }); + this.rootStore = _rootStore; + this.webhookService = new WebhookService(); + } + + fetchAll = async (workspaceSlug: string) => { + try { + this.loader = true; + this.error = null; + const webhookResponse = await this.webhookService.getAll(workspaceSlug); + runInAction(() => { + this.webhooks = webhookResponse; + this.loader = true; + this.error = null; + }); + return webhookResponse; + } catch (error) { + this.loader = false; + this.error = error; + throw error; + } + }; + + create = async (workspaceSlug: string, data: IWebhook) => { + try { + const webhookResponse = await this.webhookService.create(workspaceSlug, data); + const _secretKey = webhookResponse?.secret_key; + delete webhookResponse?.secret_key; + const _webhooks = [...this.webhooks, webhookResponse]; + runInAction(() => { + this.webhookSecretKey = _secretKey || null; + this.webhooks = _webhooks; + }); + return webhookResponse; + } catch (error) { + console.log(error); + throw error; + } + }; + + fetchById = async (workspaceSlug: string, webhook_id: string) => { + try { + const webhookResponse = await this.webhookService.getById(workspaceSlug, webhook_id); + return webhookResponse; + } catch (error) { + console.log(error); + throw error; + } + }; + + update = async (workspaceSlug: string, webhook_id: string, data: IWebhook) => { + try { + const webhookResponse = await this.webhookService.update(workspaceSlug, webhook_id, data); + const _updatedWebhooks = this.webhooks.map((element) => { + if (element.id === webhook_id) { + return webhookResponse; + } else { + return element; + } + }); + runInAction(() => { + this.webhooks = _updatedWebhooks; + }); + return webhookResponse; + } catch (error) { + console.log(error); + throw error; + } + }; + + remove = async (workspaceSlug: string, webhook_id: string) => { + try { + const webhookResponse = await this.webhookService.remove(workspaceSlug, webhook_id); + const _webhooks = this.webhooks.filter((element) => element.id != webhook_id); + runInAction(() => { + this.webhooks = _webhooks; + }); + } catch (error) { + console.log(error); + throw error; + } + }; + + regenerate = async (workspaceSlug: string, webhook_id: string) => { + try { + const webhookResponse = await this.webhookService.remove(workspaceSlug, webhook_id); + return webhookResponse; + } catch (error) { + console.log(error); + throw error; + } + }; +} diff --git a/web/types/index.d.ts b/web/types/index.d.ts index b350901066f..9f27e818ca1 100644 --- a/web/types/index.d.ts +++ b/web/types/index.d.ts @@ -20,6 +20,7 @@ export * from "./waitlist"; export * from "./reaction"; export * from "./view-props"; export * from "./workspace-views"; +export * from "./webhook"; export type NestedKeyOf = { [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object diff --git a/web/types/webhook.d.ts b/web/types/webhook.d.ts new file mode 100644 index 00000000000..238576b7c02 --- /dev/null +++ b/web/types/webhook.d.ts @@ -0,0 +1,16 @@ +export interface IWebhook { + id?: string; + secret_key?: string; + url: string; + is_active: boolean; + project: boolean; + cycle: boolean; + module: boolean; + issue: boolean; + issue_comment?: boolean; +} + +// this interface is used to handle the webhook form state +interface IExtendedWebhook extends IWebhook { + webhook_events: string; +} From 35bbc36e0fb5ead103171d31f26b84a62762fa9f Mon Sep 17 00:00:00 2001 From: gurusainath Date: Thu, 2 Nov 2023 11:59:40 +0530 Subject: [PATCH 2/3] chore: handled webhook update and rengenerate token in workspace webhooks --- web/components/web-hooks/webhook-form.tsx | 18 +++++-- .../web-hooks/webhooks-list-item.tsx | 6 +-- web/components/web-hooks/webhooks-list.tsx | 6 +-- .../settings/webhooks/[webhookId].tsx | 48 ++++++++++++------ .../settings/webhooks/create.tsx | 16 +++--- .../settings/webhooks/index.tsx | 39 +++++++++------ web/services/webhook.service.ts | 4 +- web/store/webhook.store.ts | 49 +++++++++++++++++-- 8 files changed, 131 insertions(+), 55 deletions(-) diff --git a/web/components/web-hooks/webhook-form.tsx b/web/components/web-hooks/webhook-form.tsx index b96f13e6082..fe13703d349 100644 --- a/web/components/web-hooks/webhook-form.tsx +++ b/web/components/web-hooks/webhook-form.tsx @@ -8,16 +8,23 @@ import { RootStore } from "store/root"; import { useMobxStore } from "lib/mobx/store-provider"; interface IWebhookDetails { - type: 'create' | 'edit' + type: "create" | "edit"; initialData: IWebhook; onSubmit: (val: IExtendedWebhook) => Promise; } export const WebhookDetails: FC = observer((props) => { const { type, initialData, onSubmit } = props; - const { webhook: webhookStore }: RootStore = useMobxStore() + const { webhook: webhookStore }: RootStore = useMobxStore(); - const { reset, watch, handleSubmit, control, getValues, formState: { isSubmitting } } = useForm(); + const { + reset, + watch, + handleSubmit, + control, + getValues, + formState: { isSubmitting }, + } = useForm(); useEffect(() => { if (initialData && reset) reset({ ...initialData, webhook_events: "all" }); @@ -27,7 +34,8 @@ export const WebhookDetails: FC = observer((props) => { if (watch("webhook_events")) { if (watch("webhook_events") === "all") reset({ ...getValues(), project: true, module: true, cycle: true, issue: true, issue_comment: true }); - if (watch("webhook_events") === "individual") reset({ ...getValues(), project: false, module: false, cycle: false, issue: false, issue_comment: false }); + if (watch("webhook_events") === "individual") + reset({ ...getValues(), project: false, module: false, cycle: false, issue: false, issue_comment: false }); } }, [watch && watch("webhook_events")]); @@ -211,7 +219,7 @@ export const WebhookDetails: FC = observer((props) => { {!webhookStore?.webhookSecretKey && (
)} diff --git a/web/components/web-hooks/webhooks-list-item.tsx b/web/components/web-hooks/webhooks-list-item.tsx index b54152186c0..2697ef26157 100644 --- a/web/components/web-hooks/webhooks-list-item.tsx +++ b/web/components/web-hooks/webhooks-list-item.tsx @@ -6,19 +6,17 @@ import Link from "next/link"; interface IWebhookListItem { workspaceSlug: string; - webhook: IWebhook + webhook: IWebhook; } export const WebhooksListItem: FC = (props) => { - - const { workspaceSlug, webhook } = props + const { workspaceSlug, webhook } = props; const [toggle, setToggle] = useState(false); return (
- {/*
*/}
{webhook?.url || "Webhook URL"}
diff --git a/web/components/web-hooks/webhooks-list.tsx b/web/components/web-hooks/webhooks-list.tsx index 5a3f72056de..5a58d7c4ebb 100644 --- a/web/components/web-hooks/webhooks-list.tsx +++ b/web/components/web-hooks/webhooks-list.tsx @@ -17,16 +17,16 @@ export const WebhookLists: FC = observer((props) => { return ( <>
-

Webhooks

+
Webhooks
- {/* List */} +
- {webhookStore.webhooks.map((item, index) => ( + {webhookStore.webhooks.map((item) => ( ))}
diff --git a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx b/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx index 0350b03b954..4ba60e3115d 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx +++ b/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx @@ -1,22 +1,34 @@ -import React from "react"; import type { NextPage } from "next"; +import { useRouter } from "next/router"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +// layout import { AppLayout } from "layouts/app-layout"; -import { WorkspaceSettingHeader } from "components/headers"; import { WorkspaceSettingLayout } from "layouts/setting-layout"; +// components +import { WorkspaceSettingHeader } from "components/headers"; import { WebhookDetails } from "components/web-hooks"; -import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +// hooks import { useMobxStore } from "lib/mobx/store-provider"; +// types import { RootStore } from "store/root"; import { IExtendedWebhook, IWebhook } from "types"; const Webhooks: NextPage = observer(() => { - - const router = useRouter() - const { workspaceSlug, webhookId } = router.query as { workspaceSlug: string, webhookId: string } + const router = useRouter(); + const { workspaceSlug, webhookId } = router.query as { workspaceSlug: string; webhookId: string }; const { webhook: webhookStore }: RootStore = useMobxStore(); + const { isLoading } = useSWR( + workspaceSlug && webhookId ? `WEBHOOKS_DETAIL_${workspaceSlug}_${webhookId}` : null, + workspaceSlug && webhookId + ? async () => { + await webhookStore.fetchById(workspaceSlug, webhookId); + } + : null + ); + const onSubmit = (data: IExtendedWebhook): Promise => { const payload = { url: data?.url, @@ -26,19 +38,25 @@ const Webhooks: NextPage = observer(() => { module: data?.module, issue: data?.issue, issue_comment: data?.issue_comment, - } - return webhookStore.create(workspaceSlug, payload); - } + }; + return webhookStore.update(workspaceSlug, webhookId, payload); + }; + + const initialPayload = webhookStore.currentWebhook as IWebhook; return ( - } > + }> -
- -
+
+ {isLoading ? ( +
Loading...
+ ) : ( + + )} +
); }); -export default Webhooks; \ No newline at end of file +export default Webhooks; diff --git a/web/pages/[workspaceSlug]/settings/webhooks/create.tsx b/web/pages/[workspaceSlug]/settings/webhooks/create.tsx index 23fafa5561f..66e613f3aa1 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/create.tsx +++ b/web/pages/[workspaceSlug]/settings/webhooks/create.tsx @@ -10,7 +10,6 @@ import { RootStore } from "store/root"; import { useMobxStore } from "lib/mobx/store-provider"; const Webhooks: NextPage = () => { - const router = useRouter(); const { workspaceSlug } = router.query as { workspaceSlug: string }; @@ -28,7 +27,6 @@ const Webhooks: NextPage = () => { const { webhook: webhookStore }: RootStore = useMobxStore(); - const onSubmit = (data: IExtendedWebhook): Promise => { const payload = { url: data?.url, @@ -38,19 +36,19 @@ const Webhooks: NextPage = () => { module: data?.module, issue: data?.issue, issue_comment: data?.issue_comment, - } + }; return webhookStore.create(workspaceSlug, payload); - } + }; return ( - } > + }> -
- -
+
+ +
); }; -export default Webhooks; \ No newline at end of file +export default Webhooks; diff --git a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx b/web/pages/[workspaceSlug]/settings/webhooks/index.tsx index ed4bead9be3..5caf1de3683 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx +++ b/web/pages/[workspaceSlug]/settings/webhooks/index.tsx @@ -1,17 +1,18 @@ import React from "react"; import type { NextPage } from "next"; import { useRouter } from "next/router"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +// layout import { AppLayout } from "layouts/app-layout"; -import { WorkspaceSettingHeader } from "components/headers"; import { WorkspaceSettingLayout } from "layouts/setting-layout"; +// components +import { WorkspaceSettingHeader } from "components/headers"; import { WebhookLists, EmptyWebhooks } from "components/web-hooks"; -import { WebhookService } from "services/webhook.service"; -import useSWR from "swr"; +// hooks import { useMobxStore } from "lib/mobx/store-provider"; +// types import { RootStore } from "store/root"; -import { observer } from "mobx-react-lite"; - -const webhookService = new WebhookService(); const Webhooks: NextPage = observer(() => { const router = useRouter(); @@ -19,21 +20,29 @@ const Webhooks: NextPage = observer(() => { const { webhook: webhookStore }: RootStore = useMobxStore(); - useSWR( - "WEBHOOKS_LIST", + const { isLoading } = useSWR( + workspaceSlug ? `WEBHOOKS_LIST_${workspaceSlug}` : null, workspaceSlug - ? () => webhookStore.fetchAll(workspaceSlug) + ? async () => { + await webhookStore.fetchAll(workspaceSlug); + } : null ); - - return ( - } > + }> -
- {webhookStore.webhooks.length > 0 ? : } -
+ {isLoading ? ( +
Loading...
+ ) : ( +
+ {webhookStore.webhooks.length > 0 ? ( + + ) : ( + + )} +
+ )}
); diff --git a/web/services/webhook.service.ts b/web/services/webhook.service.ts index 6e5501bf4d2..d26e3232c1c 100644 --- a/web/services/webhook.service.ts +++ b/web/services/webhook.service.ts @@ -1,6 +1,8 @@ +// api services import { APIService } from "services/api.service"; // helpers import { API_BASE_URL } from "helpers/common.helper"; +// types import { IWebhook } from "types"; export class WebhookService extends APIService { @@ -48,7 +50,7 @@ export class WebhookService extends APIService { }); } - async regenrate(workspaceSlug: string, webhook_id: string): Promise { + async regenerate(workspaceSlug: string, webhook_id: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/regenerate/`) .then((response) => response?.data) .catch((error) => { diff --git a/web/store/webhook.store.ts b/web/store/webhook.store.ts index 6d9645cd308..c4b8c650f31 100644 --- a/web/store/webhook.store.ts +++ b/web/store/webhook.store.ts @@ -1,5 +1,5 @@ // mobx -import { action, observable, makeObservable, runInAction } from "mobx"; +import { action, observable, makeObservable, computed, runInAction } from "mobx"; import { IWebhook } from "types"; import { WebhookService } from "services/webhook.service"; @@ -8,8 +8,15 @@ export interface IWebhookStore { error: any | null; webhooks: IWebhook[] | []; + webhook_id: string | null; + webhook_detail: { + [webhook_id: string]: IWebhook; + } | null; webhookSecretKey: string | null; + // computed + currentWebhook: IWebhook | null; + // actions fetchAll: (workspaceSlug: string) => Promise; fetchById: (workspaceSlug: string, webhook_id: string) => Promise; @@ -24,6 +31,10 @@ export class WebhookStore implements IWebhookStore { error: any | null = null; webhooks: IWebhook[] | [] = []; + webhook_id: string | null = null; + webhook_detail: { + [webhook_id: string]: IWebhook; + } | null = null; webhookSecretKey: string | null = null; // root store @@ -36,8 +47,12 @@ export class WebhookStore implements IWebhookStore { error: observable.ref, webhooks: observable.ref, + webhook_id: observable.ref, + webhook_detail: observable.ref, webhookSecretKey: observable.ref, + currentWebhook: computed, + fetchAll: action, create: action, fetchById: action, @@ -49,16 +64,24 @@ export class WebhookStore implements IWebhookStore { this.webhookService = new WebhookService(); } + get currentWebhook() { + if (!this.webhook_id) return null; + const currentWebhook = this.webhook_detail ? this.webhook_detail[this.webhook_id] : null; + return currentWebhook; + } + fetchAll = async (workspaceSlug: string) => { try { this.loader = true; this.error = null; const webhookResponse = await this.webhookService.getAll(workspaceSlug); + runInAction(() => { this.webhooks = webhookResponse; this.loader = true; this.error = null; }); + return webhookResponse; } catch (error) { this.loader = false; @@ -70,6 +93,7 @@ export class WebhookStore implements IWebhookStore { create = async (workspaceSlug: string, data: IWebhook) => { try { const webhookResponse = await this.webhookService.create(workspaceSlug, data); + const _secretKey = webhookResponse?.secret_key; delete webhookResponse?.secret_key; const _webhooks = [...this.webhooks, webhookResponse]; @@ -77,6 +101,7 @@ export class WebhookStore implements IWebhookStore { this.webhookSecretKey = _secretKey || null; this.webhooks = _webhooks; }); + return webhookResponse; } catch (error) { console.log(error); @@ -87,6 +112,16 @@ export class WebhookStore implements IWebhookStore { fetchById = async (workspaceSlug: string, webhook_id: string) => { try { const webhookResponse = await this.webhookService.getById(workspaceSlug, webhook_id); + + const _webhook_detail = { + ...this.webhook_detail, + [webhook_id]: webhookResponse, + }; + runInAction(() => { + this.webhook_id = webhook_id; + this.webhook_detail = _webhook_detail; + }); + return webhookResponse; } catch (error) { console.log(error); @@ -97,6 +132,7 @@ export class WebhookStore implements IWebhookStore { update = async (workspaceSlug: string, webhook_id: string, data: IWebhook) => { try { const webhookResponse = await this.webhookService.update(workspaceSlug, webhook_id, data); + const _updatedWebhooks = this.webhooks.map((element) => { if (element.id === webhook_id) { return webhookResponse; @@ -104,9 +140,12 @@ export class WebhookStore implements IWebhookStore { return element; } }); + const _webhookDetail = { ...this.webhook_detail, [webhook_id]: webhookResponse }; runInAction(() => { this.webhooks = _updatedWebhooks; + this.webhook_detail = _webhookDetail; }); + return webhookResponse; } catch (error) { console.log(error); @@ -116,10 +155,14 @@ export class WebhookStore implements IWebhookStore { remove = async (workspaceSlug: string, webhook_id: string) => { try { - const webhookResponse = await this.webhookService.remove(workspaceSlug, webhook_id); + await this.webhookService.remove(workspaceSlug, webhook_id); + const _webhooks = this.webhooks.filter((element) => element.id != webhook_id); + const _webhookDetail = { ...this.webhook_detail }; + delete _webhookDetail[webhook_id]; runInAction(() => { this.webhooks = _webhooks; + this.webhook_detail = _webhookDetail; }); } catch (error) { console.log(error); @@ -129,7 +172,7 @@ export class WebhookStore implements IWebhookStore { regenerate = async (workspaceSlug: string, webhook_id: string) => { try { - const webhookResponse = await this.webhookService.remove(workspaceSlug, webhook_id); + const webhookResponse = await this.webhookService.regenerate(workspaceSlug, webhook_id); return webhookResponse; } catch (error) { console.log(error); From 79c0388d04d995b62b572df3b7a1c3de5fc4725d Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra Date: Tue, 7 Nov 2023 12:44:22 +0530 Subject: [PATCH 3/3] feat: regenerate key and delete functionality --- .../web-hooks/delete-webhook-modal.tsx | 128 ++++++ web/components/web-hooks/generate-key.tsx | 85 +++- web/components/web-hooks/webhook-form.tsx | 391 ++++++++++-------- .../web-hooks/webhooks-list-item.tsx | 2 +- web/helpers/download.helper.ts | 13 + .../settings/webhooks/[webhookId].tsx | 22 +- .../settings/webhooks/create.tsx | 65 ++- .../settings/webhooks/index.tsx | 27 +- web/services/webhook.service.ts | 4 +- web/store/webhook.store.ts | 15 +- web/types/webhook.d.ts | 3 + 11 files changed, 549 insertions(+), 206 deletions(-) create mode 100644 web/components/web-hooks/delete-webhook-modal.tsx create mode 100644 web/helpers/download.helper.ts diff --git a/web/components/web-hooks/delete-webhook-modal.tsx b/web/components/web-hooks/delete-webhook-modal.tsx new file mode 100644 index 00000000000..79ce86fabf2 --- /dev/null +++ b/web/components/web-hooks/delete-webhook-modal.tsx @@ -0,0 +1,128 @@ +import { Dialog, Transition } from "@headlessui/react"; +import { Button, Input } from "@plane/ui"; +import { error } from "console"; +import useToast from "hooks/use-toast"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { AlertTriangle } from "lucide-react"; +import { useRouter } from "next/router"; +import React, { useState } from "react"; +import { FC } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { WebhookStore } from "store/webhook.store"; + +interface IDeleteWebhook { + isOpen: boolean; + webhook_url: string; + onClose: () => void; +} + +export const DeleteWebhookModal: FC = (props) => { + const { isOpen, onClose } = props; + + const router = useRouter(); + + const { webhook: webhookStore } = useMobxStore(); + + const { setToastAlert } = useToast(); + + const [deleting, setDelete] = useState(false); + + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + watch, + } = useForm({}); + + const { workspaceSlug, webhookId } = router.query; + + const handleClose = () => { + onClose(); + }; + + const handleDelete = async () => { + setDelete(true); + if (!workspaceSlug || !webhookId) return; + webhookStore + .remove(workspaceSlug.toString(), webhookId.toString()) + .then(() => { + setToastAlert({ + title: "Success", + type: "success", + message: "Successfully deleted", + }); + router.replace(`/${workspaceSlug}/settings/webhooks/`); + }) + .catch((error) => { + console.log(error); + setToastAlert({ + title: "Oops!", + type: "error", + message: error?.error, + }); + }) + .finally(() => { + setDelete(false); + }); + }; + + return ( + + + +
+ + +
+
+ + +
+ + + +

Delete Webhook

+
+
+ + +

+ Are you sure you want to delete workspace ? All + of the data related to the workspace will be permanently removed. This action cannot be undone. +

+
+ +
+ + +
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/web-hooks/generate-key.tsx b/web/components/web-hooks/generate-key.tsx index 513da08b156..4cbfe086343 100644 --- a/web/components/web-hooks/generate-key.tsx +++ b/web/components/web-hooks/generate-key.tsx @@ -5,7 +5,12 @@ import { generateRandomString } from "helpers/generate-random-string"; import { observer } from "mobx-react-lite"; import { RootStore } from "store/root"; import { useMobxStore } from "lib/mobx/store-provider"; -import { copyTextToClipboard } from "helpers/string.helper"; +import { copyTextToClipboard, copyUrlToClipboard } from "helpers/string.helper"; +import { useRouter } from "next/router"; +import useToast from "hooks/use-toast"; +import { csvDownload } from "helpers/download.helper"; +import { stringify } from "querystring"; +import { renderDateFormat } from "helpers/date-time.helper"; interface IGenerateKey { type: "create" | "edit"; @@ -13,13 +18,66 @@ interface IGenerateKey { export const GenerateKey: FC = observer((props) => { const { type } = props; - const { webhook: webhookStore }: RootStore = useMobxStore(); - - const [generateKey, setShowGenarateKey] = useState(false); + const [regenerating, setRegenerate] = useState(false); + const router = useRouter(); + const { workspaceSlug, webhookId } = router.query as { workspaceSlug: string, webhookId: string }; + const { webhook: webhookStore, workspace: workspaceStore }: RootStore = useMobxStore(); + const { setToastAlert } = useToast(); + // const [showgenerateKey, setShowGenarateKey] = useState(false); + function handleRegenerate() { + setRegenerate(true); + webhookStore.regenerate(workspaceSlug, webhookId).then( + () => { + // setShowGenarateKey(true); + setToastAlert({ + title: "Success", + type: "success", + message: "Successfully regenerated", + }); + csvDownload([[ + "id", + "url", + "created_at", + "updated_at", + "is_active", + "secret_key", + "project", + "issue", + "module", + "cycle", + "issue_comment", + "workspace" + ], [ + webhookStore.currentWebhook?.id!, + webhookStore.currentWebhook?.url!, + renderDateFormat(webhookStore.currentWebhook?.created_at!), + renderDateFormat(webhookStore.currentWebhook?.updated_at!), + String(webhookStore.currentWebhook?.is_active!), + webhookStore.currentWebhook?.secret_key!, + String(webhookStore.currentWebhook?.project!), + String(webhookStore.currentWebhook?.issue!), + String(webhookStore.currentWebhook?.module!), + String(webhookStore.currentWebhook?.cycle!), + String(webhookStore.currentWebhook?.issue_comment!), + workspaceStore.currentWorkspace?.name!, + ] + ], "Secret-key") + } + ).catch((err)=>{ + setToastAlert({ + title: "Oops!", + type: "error", + message: err?.error, + }); + }).finally(()=>{ + setRegenerate(false); + }) + } + console.log(webhookStore?.webhookSecretKey); return ( <> - {type === 'edit' || (type === 'create' && webhookStore?.webhookSecretKey) && ( + {(type === 'edit' || (type === 'create' && webhookStore?.webhookSecretKey)) && (
Secret Key
Genarate a token to sign-in the webhook payload
@@ -27,7 +85,7 @@ export const GenerateKey: FC = observer((props) => {
- {webhookStore?.webhookSecretKey ?
{webhookStore?.webhookSecretKey}
: + {(webhookStore?.webhookSecretKey) ?
{webhookStore?.webhookSecretKey}
:
{[...Array(41)].map((_, index) =>
)}
@@ -35,15 +93,24 @@ export const GenerateKey: FC = observer((props) => {
{webhookStore?.webhookSecretKey && (
copyTextToClipboard(webhookStore?.webhookSecretKey || '')}> - + { + navigator.clipboard.writeText(webhookStore.webhookSecretKey!); + setToastAlert({ + title: "Success", + type: "success", + message: "Secret key copied", + }); + }} className="text-custom-text-400 w-4 h-4" />
)}
{type != 'create' && ( - )}
diff --git a/web/components/web-hooks/webhook-form.tsx b/web/components/web-hooks/webhook-form.tsx index fe13703d349..d9e533ef764 100644 --- a/web/components/web-hooks/webhook-form.tsx +++ b/web/components/web-hooks/webhook-form.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect } from "react"; +import React, { FC, useEffect, useState } from "react"; import { useForm, Controller } from "react-hook-form"; import { Button, Input, ToggleSwitch } from "@plane/ui"; import { IWebhook, IExtendedWebhook } from "types"; @@ -6,17 +6,20 @@ import { GenerateKey } from "./generate-key"; import { observer } from "mobx-react-lite"; import { RootStore } from "store/root"; import { useMobxStore } from "lib/mobx/store-provider"; +import { Disclosure, Transition } from "@headlessui/react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { DeleteWebhookModal } from "./delete-webhook-modal"; interface IWebhookDetails { type: "create" | "edit"; initialData: IWebhook; - onSubmit: (val: IExtendedWebhook) => Promise; + onSubmit: (val: IExtendedWebhook) => void; } export const WebhookDetails: FC = observer((props) => { const { type, initialData, onSubmit } = props; const { webhook: webhookStore }: RootStore = useMobxStore(); - + const [openDeleteModal, setOpenDeleteModal] = useState(false); const { reset, watch, @@ -26,204 +29,258 @@ export const WebhookDetails: FC = observer((props) => { formState: { isSubmitting }, } = useForm(); + const checkWebhookEvent = (initialData: IWebhook) => { + const { project, module, cycle, issue, issue_comment } = initialData; + if (!project || !cycle || !module || !issue || !issue_comment) { + return "individual"; + } + return "all"; + } + useEffect(() => { - if (initialData && reset) reset({ ...initialData, webhook_events: "all" }); + if (initialData && reset) reset({ ...initialData, webhook_events: checkWebhookEvent(initialData) }); }, [initialData, reset]); useEffect(() => { if (watch("webhook_events")) { if (watch("webhook_events") === "all") - reset({ ...getValues(), project: true, module: true, cycle: true, issue: true, issue_comment: true }); + reset({ ...getValues(), project: watch('project') ?? true, module: watch('module') ?? true, cycle: watch('cycle') ?? true, issue: watch('issue') ?? true, issue_comment: watch('issue_comment') ?? true }); if (watch("webhook_events") === "individual") - reset({ ...getValues(), project: false, module: false, cycle: false, issue: false, issue_comment: false }); + reset({ ...getValues(), project: watch('project') ?? false, module: watch('module') ?? false, cycle: watch('cycle') ?? false, issue: watch('issue') ?? false, issue_comment: watch('issue_comment') ?? false }); } }, [watch && watch("webhook_events")]); return ( -
-
-
-
URL
- ( - - )} - /> -
- -
-
Enable webhook
- ( - { - onChange(val); - }} - size="sm" - /> - )} - /> -
- -
-
Which events do you like to trigger this webhook
- -
+ <> + {setOpenDeleteModal(false)}} + /> + +
+
+
URL
( - onChange("all")} + )} /> -
-
+
+
Enable webhook
( - onChange("individual")} + { + onChange(val); + }} + size="sm" /> )} /> -
- {watch("webhook_events") === "individual" && ( -
-
- ( -
- onChange(!value)} - type="checkbox" - name="selectIndividualEvents" - /> - -
- )} - /> - ( -
- onChange(!value)} - type="checkbox" - name="selectIndividualEvents" - /> - -
- )} - /> - ( -
- onChange(!value)} - type="checkbox" - name="selectIndividualEvents" - /> - -
- )} - /> - ( -
- onChange(!value)} - type="checkbox" - name="selectIndividualEvents" - /> - -
- )} - /> +
+
Which events do you like to trigger this webhook
- ( -
- onChange(!value)} - type="checkbox" - name="selectIndividualEvents" - /> - -
- )} - /> -
+
+ ( + onChange("all")} + /> + )} + /> + +
+ +
+ ( + onChange("individual")} + /> + )} + /> +
- )} -
- + {watch("webhook_events") === "individual" && ( +
+
+ ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + checked={value == true} + /> + +
+ )} + /> + ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + checked={value == true} + /> + +
+ )} + /> + ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + checked={value == true} + /> + +
+ )} + /> + ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + checked={value == true} + /> + +
+ )} + /> + + ( +
+ onChange(!value)} + type="checkbox" + name="selectIndividualEvents" + checked={value == true} + /> + +
+ )} + /> +
+
+ )} +
+ + - {!webhookStore?.webhookSecretKey && (
- )} -
- + {type === "edit" && + + {({ open }) => ( +
+ + Danger Zone + {open ? : } + + + + +
+ + The danger zone of the workspace delete page is a critical area that requires careful + consideration and attention. When deleting a workspace, all of the data and resources within + that workspace will be permanently removed and cannot be recovered. + +
+ +
+
+
+
+
+ )} +
} +
+ + ); }); diff --git a/web/components/web-hooks/webhooks-list-item.tsx b/web/components/web-hooks/webhooks-list-item.tsx index 2697ef26157..7e590a9cd33 100644 --- a/web/components/web-hooks/webhooks-list-item.tsx +++ b/web/components/web-hooks/webhooks-list-item.tsx @@ -17,7 +17,7 @@ export const WebhooksListItem: FC = (props) => { return (
-
+
{webhook?.url || "Webhook URL"}
{/*
diff --git a/web/helpers/download.helper.ts b/web/helpers/download.helper.ts new file mode 100644 index 00000000000..9a94ef9445d --- /dev/null +++ b/web/helpers/download.helper.ts @@ -0,0 +1,13 @@ +import { IApiToken } from "types/api_token"; +import { renderDateFormat } from "./date-time.helper"; + +export const csvDownload = (rows: Array>, name: string) => { + + let csvContent = "data:text/csv;charset=utf-8," + rows.map((e) => e.join(",")).join("\n"); + var encodedUri = encodeURI(csvContent); + var link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", `${name}.csv`); + document.body.appendChild(link); + link.click(); +}; diff --git a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx b/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx index 4ba60e3115d..90a5a9909ae 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx +++ b/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx @@ -4,7 +4,7 @@ import useSWR from "swr"; import { observer } from "mobx-react-lite"; // layout import { AppLayout } from "layouts/app-layout"; -import { WorkspaceSettingLayout } from "layouts/setting-layout"; +import { WorkspaceSettingLayout } from "layouts/settings-layout"; // components import { WorkspaceSettingHeader } from "components/headers"; import { WebhookDetails } from "components/web-hooks"; @@ -13,6 +13,8 @@ import { useMobxStore } from "lib/mobx/store-provider"; // types import { RootStore } from "store/root"; import { IExtendedWebhook, IWebhook } from "types"; +import { Spinner } from "@plane/ui"; +import { useEffect } from "react"; const Webhooks: NextPage = observer(() => { const router = useRouter(); @@ -20,12 +22,16 @@ const Webhooks: NextPage = observer(() => { const { webhook: webhookStore }: RootStore = useMobxStore(); + useEffect(() => { + webhookStore.clearSecretKey(); + }, []); + const { isLoading } = useSWR( workspaceSlug && webhookId ? `WEBHOOKS_DETAIL_${workspaceSlug}_${webhookId}` : null, workspaceSlug && webhookId ? async () => { - await webhookStore.fetchById(workspaceSlug, webhookId); - } + await webhookStore.fetchById(workspaceSlug, webhookId); + } : null ); @@ -48,11 +54,13 @@ const Webhooks: NextPage = observer(() => { }>
- {isLoading ? ( -
Loading...
- ) : ( + {isLoading ? +
+ +
+ : - )} + }
diff --git a/web/pages/[workspaceSlug]/settings/webhooks/create.tsx b/web/pages/[workspaceSlug]/settings/webhooks/create.tsx index 66e613f3aa1..a8e150ff5c3 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/create.tsx +++ b/web/pages/[workspaceSlug]/settings/webhooks/create.tsx @@ -1,13 +1,16 @@ -import React from "react"; +import React, { useEffect } from "react"; import { useRouter } from "next/router"; import type { NextPage } from "next"; import { AppLayout } from "layouts/app-layout"; import { WorkspaceSettingHeader } from "components/headers"; -import { WorkspaceSettingLayout } from "layouts/setting-layout"; +import { WorkspaceSettingLayout } from "layouts/settings-layout"; import { WebhookDetails } from "components/web-hooks"; import { IWebhook, IExtendedWebhook } from "types"; import { RootStore } from "store/root"; import { useMobxStore } from "lib/mobx/store-provider"; +import { renderDateFormat } from "helpers/date-time.helper"; +import { csvDownload } from "helpers/download.helper"; +import useToast from "hooks/use-toast"; const Webhooks: NextPage = () => { const router = useRouter(); @@ -17,17 +20,22 @@ const Webhooks: NextPage = () => { const initialWebhookPayload: IWebhook = { url: "", is_active: true, + created_at: "", + updated_at: "", secret_key: "", project: true, issue_comment: true, cycle: true, module: true, issue: true, + workspace: "", }; - const { webhook: webhookStore }: RootStore = useMobxStore(); + const { webhook: webhookStore, workspace: workspaceStore }: RootStore = useMobxStore(); - const onSubmit = (data: IExtendedWebhook): Promise => { + const { setToastAlert } = useToast(); + + const onSubmit = async (data: IExtendedWebhook) => { const payload = { url: data?.url, is_active: data?.is_active, @@ -37,9 +45,56 @@ const Webhooks: NextPage = () => { issue: data?.issue, issue_comment: data?.issue_comment, }; - return webhookStore.create(workspaceSlug, payload); + + const response = await webhookStore.create(workspaceSlug, payload).then( + (webhook) => { + setToastAlert({ + title: "Success", + type: "success", + message: "Successfully created", + }); + csvDownload([[ + "id", + "url", + "created_at", + "updated_at", + "is_active", + "secret_key", + "project", + "issue", + "module", + "cycle", + "issue_comment", + "workspace" + ], [ + webhook.id!, + webhook.url!, + renderDateFormat(webhook.updated_at!), + renderDateFormat(webhook.created_at!), + webhookStore.webhookSecretKey!, + String(webhook.is_active!), + String(webhook.issue!), + String(webhook.project!), + String(webhook.module!), + String(webhook.cycle!), + String(webhook.issue_comment!), + workspaceStore.currentWorkspace?.name!, + ] + ], "Secret-key"); + } + ).catch((error) => { + setToastAlert({ + title: "Oops!", + type: "error", + message: error?.error ?? "Something went wrong!", + }); + }) }; + useEffect(() => { + webhookStore.clearSecretKey(); + }, []); + return ( }> diff --git a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx b/web/pages/[workspaceSlug]/settings/webhooks/index.tsx index 5caf1de3683..1b3fd1e1fdf 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx +++ b/web/pages/[workspaceSlug]/settings/webhooks/index.tsx @@ -5,7 +5,7 @@ import useSWR from "swr"; import { observer } from "mobx-react-lite"; // layout import { AppLayout } from "layouts/app-layout"; -import { WorkspaceSettingLayout } from "layouts/setting-layout"; +import { WorkspaceSettingLayout } from "layouts/settings-layout"; // components import { WorkspaceSettingHeader } from "components/headers"; import { WebhookLists, EmptyWebhooks } from "components/web-hooks"; @@ -13,6 +13,7 @@ import { WebhookLists, EmptyWebhooks } from "components/web-hooks"; import { useMobxStore } from "lib/mobx/store-provider"; // types import { RootStore } from "store/root"; +import { Spinner } from "@plane/ui"; const Webhooks: NextPage = observer(() => { const router = useRouter(); @@ -24,25 +25,23 @@ const Webhooks: NextPage = observer(() => { workspaceSlug ? `WEBHOOKS_LIST_${workspaceSlug}` : null, workspaceSlug ? async () => { - await webhookStore.fetchAll(workspaceSlug); - } + await webhookStore.fetchAll(workspaceSlug); + } : null ); return ( }> - {isLoading ? ( -
Loading...
- ) : ( -
- {webhookStore.webhooks.length > 0 ? ( - - ) : ( - - )} -
- )} +
+ {webhookStore.webhooks.length > 0 ? isLoading ?
+ +
: ( + + ) : ( + + )} +
); diff --git a/web/services/webhook.service.ts b/web/services/webhook.service.ts index d26e3232c1c..05f622aef21 100644 --- a/web/services/webhook.service.ts +++ b/web/services/webhook.service.ts @@ -50,8 +50,8 @@ export class WebhookService extends APIService { }); } - async regenerate(workspaceSlug: string, webhook_id: string): Promise { - return this.delete(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/regenerate/`) + async regenerate(workspaceSlug: string, webhook_id: string): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/webhooks/${webhook_id}/regenerate/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/store/webhook.store.ts b/web/store/webhook.store.ts index c4b8c650f31..7e5bccb94a6 100644 --- a/web/store/webhook.store.ts +++ b/web/store/webhook.store.ts @@ -23,7 +23,8 @@ export interface IWebhookStore { create: (workspaceSlug: string, data: IWebhook) => Promise; update: (workspaceSlug: string, webhook_id: string, data: IWebhook) => Promise; remove: (workspaceSlug: string, webhook_id: string) => Promise; - regenerate: (workspaceSlug: string, webhook_id: string, data: IWebhook) => Promise; + regenerate: (workspaceSlug: string, webhook_id: string) => Promise; + clearSecretKey: () => void; } export class WebhookStore implements IWebhookStore { @@ -59,6 +60,7 @@ export class WebhookStore implements IWebhookStore { update: action, remove: action, regenerate: action, + clearSecretKey: action }); this.rootStore = _rootStore; this.webhookService = new WebhookService(); @@ -100,6 +102,9 @@ export class WebhookStore implements IWebhookStore { runInAction(() => { this.webhookSecretKey = _secretKey || null; this.webhooks = _webhooks; + this.webhook_detail = {...this.webhook_detail, [webhookResponse.id!]: webhookResponse}; + this.webhook_id = webhookResponse.id!; + console.log(this.webhook_detail); }); return webhookResponse; @@ -173,10 +178,18 @@ export class WebhookStore implements IWebhookStore { regenerate = async (workspaceSlug: string, webhook_id: string) => { try { const webhookResponse = await this.webhookService.regenerate(workspaceSlug, webhook_id); + runInAction(() => { + this.webhookSecretKey = webhookResponse.secret_key!; + this.webhook_detail = {...this.webhook_detail, [webhook_id]: webhookResponse}; + }); return webhookResponse; } catch (error) { console.log(error); throw error; } }; + + clearSecretKey = () => { + this.webhookSecretKey = null; + } } diff --git a/web/types/webhook.d.ts b/web/types/webhook.d.ts index 238576b7c02..b1420c2af80 100644 --- a/web/types/webhook.d.ts +++ b/web/types/webhook.d.ts @@ -2,12 +2,15 @@ export interface IWebhook { id?: string; secret_key?: string; url: string; + created_at?: string; + updated_at?: string; is_active: boolean; project: boolean; cycle: boolean; module: boolean; issue: boolean; issue_comment?: boolean; + workspace?: string; } // this interface is used to handle the webhook form state