From dc183602b1008188c78a406c709c8864a65600a8 Mon Sep 17 00:00:00 2001 From: Ryan Hopper-Lowe <46546486+ryanhopperlowe@users.noreply.github.com> Date: Wed, 8 Jan 2025 13:57:06 -0600 Subject: [PATCH] feat: implement email receivers in workflow-triggers page (#1148) * feat: implement email receiver api service - initialize types, api routes, and service methods for email receivers Signed-off-by: Ryan Hopper-Lowe * feat: implement email receivers in workflow triggers UI --------- Signed-off-by: Ryan Hopper-Lowe --- .../CreateWorkflowTrigger.tsx | 12 ++ .../DeleteWorkflowTrigger.tsx | 34 ++- .../workflow-triggers/EmailReceiverForm.tsx | 195 ++++++++++++++++++ .../WorkflowTriggerActions.tsx | 24 ++- ui/admin/app/lib/model/email-receivers.ts | 18 ++ ui/admin/app/lib/model/workflow-trigger.ts | 67 +++++- ui/admin/app/lib/routers/apiRoutes.ts | 8 + .../service/api/emailReceiverApiService.ts | 73 +++++++ ui/admin/app/lib/service/routeService.ts | 12 ++ .../routes/_auth.workflow-triggers._index.tsx | 96 +++++---- ...auth.workflow-triggers.email.$receiver.tsx | 56 +++++ .../_auth.workflow-triggers.email.create.tsx | 17 ++ 12 files changed, 556 insertions(+), 56 deletions(-) create mode 100644 ui/admin/app/components/workflow-triggers/EmailReceiverForm.tsx create mode 100644 ui/admin/app/lib/model/email-receivers.ts create mode 100644 ui/admin/app/lib/service/api/emailReceiverApiService.ts create mode 100644 ui/admin/app/routes/_auth.workflow-triggers.email.$receiver.tsx create mode 100644 ui/admin/app/routes/_auth.workflow-triggers.email.create.tsx diff --git a/ui/admin/app/components/workflow-triggers/CreateWorkflowTrigger.tsx b/ui/admin/app/components/workflow-triggers/CreateWorkflowTrigger.tsx index 83ae2c60..cda5ebc8 100644 --- a/ui/admin/app/components/workflow-triggers/CreateWorkflowTrigger.tsx +++ b/ui/admin/app/components/workflow-triggers/CreateWorkflowTrigger.tsx @@ -62,6 +62,18 @@ export function CreateWorkflowTrigger() { Set up a workflow to run on an interval. + + + + + Email + + + diff --git a/ui/admin/app/components/workflow-triggers/DeleteWorkflowTrigger.tsx b/ui/admin/app/components/workflow-triggers/DeleteWorkflowTrigger.tsx index 79385790..4c2327a8 100644 --- a/ui/admin/app/components/workflow-triggers/DeleteWorkflowTrigger.tsx +++ b/ui/admin/app/components/workflow-triggers/DeleteWorkflowTrigger.tsx @@ -1,7 +1,9 @@ import { toast } from "sonner"; import { mutate } from "swr"; +import { WorkflowTriggerType } from "~/lib/model/workflow-trigger"; import { CronJobApiService } from "~/lib/service/api/cronjobApiService"; +import { EmailReceiverApiService } from "~/lib/service/api/emailReceiverApiService"; import { WebhookApiService } from "~/lib/service/api/webhookApiService"; import { ConfirmationDialog } from "~/components/composed/ConfirmationDialog"; @@ -16,7 +18,7 @@ export function DeleteWorkflowTrigger({ }: { id: string; name?: string; - type: "webhook" | "schedule"; + type: WorkflowTriggerType; }) { const deleteWebhook = useAsync(WebhookApiService.deleteWebhook, { onSuccess: () => { @@ -32,16 +34,21 @@ export function DeleteWorkflowTrigger({ }, }); - const handleConfirmDelete = async () => { - if (type === "webhook") { - await deleteWebhook.executeAsync(id); - } else { - await deleteCronjob.executeAsync(id); + const deleteEmailReceiver = useAsync( + EmailReceiverApiService.deleteEmailReceiver, + { + onSuccess: () => { + mutate(EmailReceiverApiService.getEmailReceivers.key()); + toast.success("Email workflow trigger has been deleted."); + }, } - }; + ); const { interceptAsync, dialogProps } = useConfirmationDialog(); + const handleConfirmDelete = async () => + await getDeleteFunction().executeAsync(id); + return ( <> ); + + function getDeleteFunction() { + switch (type) { + case "webhook": + return deleteWebhook; + case "schedule": + return deleteCronjob; + case "email": + return deleteEmailReceiver; + default: + throw new Error(`Unknown workflow trigger type: ${type}`); + } + } } diff --git a/ui/admin/app/components/workflow-triggers/EmailReceiverForm.tsx b/ui/admin/app/components/workflow-triggers/EmailReceiverForm.tsx new file mode 100644 index 00000000..2f616022 --- /dev/null +++ b/ui/admin/app/components/workflow-triggers/EmailReceiverForm.tsx @@ -0,0 +1,195 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router"; +import { $path } from "safe-routes"; +import { toast } from "sonner"; +import useSWR, { mutate } from "swr"; +import { z } from "zod"; + +import { EmailReceiver } from "~/lib/model/email-receivers"; +import { EmailReceiverApiService } from "~/lib/service/api/emailReceiverApiService"; +import { WorkflowService } from "~/lib/service/api/workflowService"; + +import { + ControlledCustomInput, + ControlledInput, +} from "~/components/form/controlledInputs"; +import { Button } from "~/components/ui/button"; +import { Form } from "~/components/ui/form"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { useAsync } from "~/hooks/useAsync"; + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string(), + alias: z.string(), + workflow: z.string().min(1, "Workflow is required"), + allowedSenders: z.array(z.string()), +}); + +export type EmailRecieverFormValues = z.infer; + +type EmailRecieverFormProps = { + emailReceiver?: EmailReceiver; +}; + +export function EmailReceiverForm({ emailReceiver }: EmailRecieverFormProps) { + const navigate = useNavigate(); + const getWorkflows = useSWR(WorkflowService.getWorkflows.key(), () => + WorkflowService.getWorkflows() + ); + + const handleSubmitSuccess = () => { + if (emailReceiver) { + mutate( + EmailReceiverApiService.getEmailReceiverById(emailReceiver.id) + ); + } + mutate(EmailReceiverApiService.getEmailReceivers.key()); + navigate($path("/workflow-triggers")); + }; + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + name: emailReceiver?.name || "", + description: emailReceiver?.description || "", + alias: emailReceiver?.alias || "", + workflow: emailReceiver?.workflow || "", + allowedSenders: emailReceiver?.allowedSenders || [], + }, + }); + + const createEmailReceiver = useAsync( + EmailReceiverApiService.createEmailReceiver, + { + onSuccess: handleSubmitSuccess, + onError: () => { + toast.error("Failed to create email receiver."); + }, + } + ); + + const updateEmailReceiver = useAsync( + EmailReceiverApiService.updateEmailReceiver, + { + onSuccess: handleSubmitSuccess, + onError: () => { + toast.error("Failed to update email receiver."); + }, + } + ); + + useEffect(() => { + if (emailReceiver) { + form.reset(emailReceiver); + } + }, [emailReceiver, form]); + + const handleSubmit = form.handleSubmit((values: EmailRecieverFormValues) => + emailReceiver?.id + ? updateEmailReceiver.execute(emailReceiver.id, values) + : createEmailReceiver.execute(values) + ); + + const workflows = getWorkflows.data; + const isEdit = !!emailReceiver?.id; + const loading = + createEmailReceiver.isLoading || updateEmailReceiver.isLoading; + + return ( + +
+ +

{isEdit ? "Edit" : "Create"} Email Receiver

+ + + + + + + + + {({ field: { ref: _, ...field }, className }) => ( + + )} + + + + + +
+ ); + + function getWorkflowOptions() { + const workflow = form.watch("workflow"); + + if (getWorkflows.isLoading) + return ( + + Loading workflows... + + ); + + if (!workflows?.length) + return ( + + No workflows found + + ); + + return workflows.map((workflow) => ( + + {workflow.name} + + )); + } +} diff --git a/ui/admin/app/components/workflow-triggers/WorkflowTriggerActions.tsx b/ui/admin/app/components/workflow-triggers/WorkflowTriggerActions.tsx index f6c3f03a..c02a283c 100644 --- a/ui/admin/app/components/workflow-triggers/WorkflowTriggerActions.tsx +++ b/ui/admin/app/components/workflow-triggers/WorkflowTriggerActions.tsx @@ -15,14 +15,22 @@ import { Link } from "~/components/ui/link"; import { DeleteWorkflowTrigger } from "~/components/workflow-triggers/DeleteWorkflowTrigger"; export function WorkflowTriggerActions({ item }: { item: WorkflowTrigger }) { - const path = - item.type === "webhook" - ? $path("/workflow-triggers/webhooks/:webhook", { - webhook: item.id, - }) - : $path("/workflow-triggers/schedule/:trigger", { - trigger: item.id, - }); + let path: string = ""; + + if (item.type === "webhook") { + path = $path("/workflow-triggers/webhooks/:webhook", { + webhook: item.id, + }); + } else if (item.type === "schedule") { + path = $path("/workflow-triggers/schedule/:trigger", { + trigger: item.id, + }); + } else if (item.type === "email") { + path = $path("/workflow-triggers/email/:receiver", { + receiver: item.id, + }); + } + return ( diff --git a/ui/admin/app/lib/model/email-receivers.ts b/ui/admin/app/lib/model/email-receivers.ts new file mode 100644 index 00000000..55f58661 --- /dev/null +++ b/ui/admin/app/lib/model/email-receivers.ts @@ -0,0 +1,18 @@ +import { EntityMeta } from "~/lib/model/primitives"; + +type EmailReceiverBase = { + name: string; + description: string; + alias?: string; + workflow: string; + allowedSenders?: string[]; +}; + +export type EmailReceiver = EntityMeta & + EmailReceiverBase & { + aliasAssigned?: boolean; + emailAddress?: string; + }; + +export type CreateEmailReceiver = EmailReceiverBase; +export type UpdateEmailReceiver = EmailReceiverBase; diff --git a/ui/admin/app/lib/model/workflow-trigger.ts b/ui/admin/app/lib/model/workflow-trigger.ts index 30dbd06d..57f1d686 100644 --- a/ui/admin/app/lib/model/workflow-trigger.ts +++ b/ui/admin/app/lib/model/workflow-trigger.ts @@ -1,6 +1,71 @@ +import { CronJob } from "~/lib/model/cronjobs"; +import { EmailReceiver } from "~/lib/model/email-receivers"; +import { Webhook } from "~/lib/model/webhooks"; + +type WorkFlowTriggerEntity = EmailReceiver | Webhook | CronJob; + +export type WorkflowTriggerType = "webhook" | "schedule" | "email"; + export type WorkflowTrigger = { id: string; - type: "webhook" | "schedule"; + type: WorkflowTriggerType; name: string; workflow: string; }; + +const objectHasAllKeys = ( + obj: object, + keys: (keyof T)[] +): obj is T => keys.every((key) => key in obj); + +function isEmailReceiver( + entity: WorkFlowTriggerEntity +): entity is EmailReceiver { + return objectHasAllKeys(entity, [ + "workflow", + "emailAddress", + ]); +} + +function isWebhook(entity: WorkFlowTriggerEntity): entity is Webhook { + return objectHasAllKeys(entity, ["workflow", "validationHeader"]); +} + +function isCronJob(entity: WorkFlowTriggerEntity): entity is CronJob { + return objectHasAllKeys(entity, ["workflow", "schedule"]); +} + +function convertToWorkflowTrigger( + entity: WorkFlowTriggerEntity +): WorkflowTrigger | null { + switch (true) { + case isEmailReceiver(entity): + return { + id: entity.id, + type: "email", + name: entity.name, + workflow: entity.workflow, + }; + case isWebhook(entity): + return { + id: entity.id, + type: "webhook", + name: entity.name, + workflow: entity.workflow, + }; + case isCronJob(entity): + return { + id: entity.id, + type: "schedule", + name: entity.id, + workflow: entity.workflow, + }; + default: + console.error("Unknown entity type", entity); + return null; + } +} + +export function collateWorkflowTriggers(list: WorkFlowTriggerEntity[]) { + return list.map(convertToWorkflowTrigger).filter((x) => !!x); +} diff --git a/ui/admin/app/lib/routers/apiRoutes.ts b/ui/admin/app/lib/routers/apiRoutes.ts index 82ffbc97..66bf4984 100644 --- a/ui/admin/app/lib/routers/apiRoutes.ts +++ b/ui/admin/app/lib/routers/apiRoutes.ts @@ -305,6 +305,14 @@ export const ApiRoutes = { updateCronJob: (cronJobId: string) => buildUrl(`/cronjobs/${cronJobId}`), }, + emailReceivers: { + getEmailReceivers: () => buildUrl("/email-receivers"), + getEmailReceiverById: (id: string) => + buildUrl(`/email-receivers/${id}`), + createEmailReceiver: () => buildUrl(`/email-receivers`), + updateEmailReceiver: (id: string) => buildUrl(`/email-receivers/${id}`), + deleteEmailReceiver: (id: string) => buildUrl(`/email-receivers/${id}`), + }, version: () => buildUrl("/version"), }; diff --git a/ui/admin/app/lib/service/api/emailReceiverApiService.ts b/ui/admin/app/lib/service/api/emailReceiverApiService.ts new file mode 100644 index 00000000..75e4c61d --- /dev/null +++ b/ui/admin/app/lib/service/api/emailReceiverApiService.ts @@ -0,0 +1,73 @@ +import { + CreateEmailReceiver, + EmailReceiver, + UpdateEmailReceiver, +} from "~/lib/model/email-receivers"; +import { EntityList } from "~/lib/model/primitives"; +import { ApiRoutes } from "~/lib/routers/apiRoutes"; +import { request } from "~/lib/service/api/primitives"; + +async function getEmailReceivers() { + const { data } = await request>({ + url: ApiRoutes.emailReceivers.getEmailReceivers().url, + }); + + return data.items ?? []; +} +getEmailReceivers.key = () => ({ + url: ApiRoutes.emailReceivers.getEmailReceivers().url, +}); + +async function getEmailReceiverById(id: string) { + const { data } = await request({ + url: ApiRoutes.emailReceivers.getEmailReceiverById(id).url, + }); + + return data; +} +getEmailReceiverById.key = (id: Nullish) => { + if (!id) return null; + + return { + url: ApiRoutes.emailReceivers.getEmailReceiverById(id).url, + emailReceiverId: id, + }; +}; + +async function createEmailReceiver(emailReceiver: CreateEmailReceiver) { + const { data } = await request({ + url: ApiRoutes.emailReceivers.createEmailReceiver().url, + method: "POST", + data: emailReceiver, + }); + + return data; +} + +async function updateEmailReceiver( + id: string, + emailReceiver: UpdateEmailReceiver +) { + const { data } = await request({ + url: ApiRoutes.emailReceivers.updateEmailReceiver(id).url, + method: "PUT", + data: emailReceiver, + }); + + return data; +} + +async function deleteEmailReceiver(id: string) { + await request({ + url: ApiRoutes.emailReceivers.deleteEmailReceiver(id).url, + method: "DELETE", + }); +} + +export const EmailReceiverApiService = { + getEmailReceivers, + getEmailReceiverById, + createEmailReceiver, + updateEmailReceiver, + deleteEmailReceiver, +}; diff --git a/ui/admin/app/lib/service/routeService.ts b/ui/admin/app/lib/service/routeService.ts index a9909e51..c75b94a1 100644 --- a/ui/admin/app/lib/service/routeService.ts +++ b/ui/admin/app/lib/service/routeService.ts @@ -137,6 +137,18 @@ export const RouteHelperMap = { path: "/workflow-triggers/webhooks/:webhook", schema: z.null(), }, + "/workflow-triggers/email/create": { + regex: exactRegex($path("/workflow-triggers/email/create")), + path: "/workflow-triggers/email/create", + schema: z.null(), + }, + "/workflow-triggers/email/:receiver": { + regex: exactRegex( + $path("/workflow-triggers/email/:receiver", { receiver: "(.+)" }) + ), + path: "/workflow-triggers/email/:receiver", + schema: z.null(), + }, "/workflows": { regex: exactRegex($path("/workflows")), path: "/workflows", diff --git a/ui/admin/app/routes/_auth.workflow-triggers._index.tsx b/ui/admin/app/routes/_auth.workflow-triggers._index.tsx index f63906a7..47a0e8a5 100644 --- a/ui/admin/app/routes/_auth.workflow-triggers._index.tsx +++ b/ui/admin/app/routes/_auth.workflow-triggers._index.tsx @@ -4,9 +4,13 @@ import { MetaFunction, useNavigate } from "react-router"; import { $path } from "safe-routes"; import useSWR, { preload } from "swr"; -import { WorkflowTrigger } from "~/lib/model/workflow-trigger"; +import { + WorkflowTrigger, + collateWorkflowTriggers, +} from "~/lib/model/workflow-trigger"; import { Workflow } from "~/lib/model/workflows"; import { CronJobApiService } from "~/lib/service/api/cronjobApiService"; +import { EmailReceiverApiService } from "~/lib/service/api/emailReceiverApiService"; import { WebhookApiService } from "~/lib/service/api/webhookApiService"; import { WorkflowService } from "~/lib/service/api/workflowService"; @@ -25,24 +29,39 @@ export async function clientLoader() { preload(CronJobApiService.getCronJobs.key(), () => CronJobApiService.getCronJobs() ), + preload(EmailReceiverApiService.getEmailReceivers.key(), () => + EmailReceiverApiService.getEmailReceivers() + ), ]); return null; } export default function WorkflowTriggersPage() { - const { data: webhooks } = useSWR(WebhookApiService.getWebhooks.key(), () => - WebhookApiService.getWebhooks() + const { data: webhooks } = useSWR( + WebhookApiService.getWebhooks.key(), + () => WebhookApiService.getWebhooks(), + { fallbackData: [] } ); const navigate = useNavigate(); - const getWorkflows = useSWR(WorkflowService.getWorkflows.key(), () => - WorkflowService.getWorkflows() + const getWorkflows = useSWR( + WorkflowService.getWorkflows.key(), + () => WorkflowService.getWorkflows(), + { fallbackData: [] } + ); + + const { data: cronjobs } = useSWR( + CronJobApiService.getCronJobs.key(), + () => CronJobApiService.getCronJobs(), + { fallbackData: [] } ); - const { data: cronjobs } = useSWR(CronJobApiService.getCronJobs.key(), () => - CronJobApiService.getCronJobs() + const { data: emailReceivers } = useSWR( + EmailReceiverApiService.getEmailReceivers.key(), + () => EmailReceiverApiService.getEmailReceivers(), + { fallbackData: [] } ); const workflows = getWorkflows.data; @@ -58,25 +77,36 @@ export default function WorkflowTriggersPage() { ); }, [workflows]); - const tableData: WorkflowTrigger[] = [ - ...(webhooks ?? []), - ...(cronjobs ?? []), - ].map((item) => - "schedule" in item - ? { - id: item.id, - type: "schedule", - name: item.id, - workflow: item.workflow, - } - : { - id: item.id, - type: "webhook", - name: item.name || item.id, - workflow: item.workflow, - } - ); + const tableData = collateWorkflowTriggers([ + ...webhooks, + ...cronjobs, + ...emailReceivers, + ]); + const onNavigate = (row: WorkflowTrigger): void => { + switch (row.type) { + case "webhook": + navigate( + $path("/workflow-triggers/webhooks/:webhook", { + webhook: row.id, + }) + ); + break; + case "email": + navigate( + $path("/workflow-triggers/email/:receiver", { + receiver: row.id, + }) + ); + break; + case "schedule": + navigate( + $path("/workflow-triggers/schedule/:trigger", { + trigger: row.id, + }) + ); + } + }; return (
@@ -87,21 +117,7 @@ export default function WorkflowTriggersPage() {
{ - if (row.type === "webhook") { - navigate( - $path("/workflow-triggers/webhooks/:webhook", { - webhook: row.id, - }) - ); - } else { - navigate( - $path("/workflow-triggers/schedule/:trigger", { - trigger: row.id, - }) - ); - } - }} + onRowClick={onNavigate} columns={getColumns()} data={tableData} /> diff --git a/ui/admin/app/routes/_auth.workflow-triggers.email.$receiver.tsx b/ui/admin/app/routes/_auth.workflow-triggers.email.$receiver.tsx new file mode 100644 index 00000000..b7b8585a --- /dev/null +++ b/ui/admin/app/routes/_auth.workflow-triggers.email.$receiver.tsx @@ -0,0 +1,56 @@ +import { + ClientLoaderFunctionArgs, + MetaFunction, + useLoaderData, + useMatch, +} from "react-router"; +import useSWR, { preload } from "swr"; + +import { EmailReceiverApiService } from "~/lib/service/api/emailReceiverApiService"; +import { RouteHandle } from "~/lib/service/routeHandles"; +import { RouteService } from "~/lib/service/routeService"; + +import { EmailReceiverForm } from "~/components/workflow-triggers/EmailReceiverForm"; + +export async function clientLoader({ + request, + params, +}: ClientLoaderFunctionArgs) { + const { pathParams } = RouteService.getRouteInfo( + "/workflow-triggers/email/:receiver", + new URL(request.url), + params + ); + + await preload( + EmailReceiverApiService.getEmailReceiverById.key(pathParams.receiver), + () => EmailReceiverApiService.getEmailReceiverById(pathParams.receiver) + ); + + return { emailReceiverId: pathParams.receiver }; +} + +export default function EmailReceiverPage() { + const { emailReceiverId } = useLoaderData(); + const { data: emailReceiver } = useSWR( + EmailReceiverApiService.getEmailReceiverById.key(emailReceiverId), + ({ emailReceiverId }) => + EmailReceiverApiService.getEmailReceiverById(emailReceiverId) + ); + + return ; +} + +const EmailReceiverBreadcrumb = () => { + const match = useMatch("/workflow-triggers/email/:receiver"); + + return match?.params?.receiver || "Edit"; +}; + +export const handle: RouteHandle = { + breadcrumb: () => [{ content: }], +}; + +export const meta: MetaFunction = ({ data }) => { + return [{ title: `Email Receiver • ${data?.emailReceiverId}` }]; +}; diff --git a/ui/admin/app/routes/_auth.workflow-triggers.email.create.tsx b/ui/admin/app/routes/_auth.workflow-triggers.email.create.tsx new file mode 100644 index 00000000..c729653c --- /dev/null +++ b/ui/admin/app/routes/_auth.workflow-triggers.email.create.tsx @@ -0,0 +1,17 @@ +import { MetaFunction } from "react-router"; + +import { RouteHandle } from "~/lib/service/routeHandles"; + +import { EmailReceiverForm } from "~/components/workflow-triggers/EmailReceiverForm"; + +export default function CreateEmailReceiverPage() { + return ; +} + +export const handle: RouteHandle = { + breadcrumb: () => [{ content: "Create Email Receiver" }], +}; + +export const meta: MetaFunction = () => { + return [{ title: "Create Email Receiver" }]; +};