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 index 5d9c0e90..55f58661 100644 --- a/ui/admin/app/lib/model/email-receivers.ts +++ b/ui/admin/app/lib/model/email-receivers.ts @@ -8,7 +8,11 @@ type EmailReceiverBase = { allowedSenders?: string[]; }; -export type EmailReceiver = EntityMeta & EmailReceiverBase; +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/service/api/EmailRecieverApiService.ts b/ui/admin/app/lib/service/api/emailReceiverApiService.ts similarity index 96% rename from ui/admin/app/lib/service/api/EmailRecieverApiService.ts rename to ui/admin/app/lib/service/api/emailReceiverApiService.ts index 85e85bd2..75e4c61d 100644 --- a/ui/admin/app/lib/service/api/EmailRecieverApiService.ts +++ b/ui/admin/app/lib/service/api/emailReceiverApiService.ts @@ -12,7 +12,7 @@ async function getEmailReceivers() { url: ApiRoutes.emailReceivers.getEmailReceivers().url, }); - return data ?? []; + return data.items ?? []; } getEmailReceivers.key = () => ({ url: ApiRoutes.emailReceivers.getEmailReceivers().url, @@ -30,6 +30,7 @@ getEmailReceiverById.key = (id: Nullish) => { return { url: ApiRoutes.emailReceivers.getEmailReceiverById(id).url, + emailReceiverId: id, }; }; 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" }]; +};