Skip to content

Commit

Permalink
feat(dashboard): Create workflow sheet
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Oct 17, 2024
1 parent 67a55e1 commit 677565a
Show file tree
Hide file tree
Showing 17 changed files with 649 additions and 135 deletions.
3 changes: 2 additions & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"dependencies": {
"@clerk/clerk-react": "^5.2.5",
"@hookform/resolvers": "^2.9.11",
"@hookform/resolvers": "^3.9.0",
"@novu/react": "^2.3.0",
"@novu/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.2",
Expand All @@ -32,6 +32,7 @@
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
Expand Down
8 changes: 6 additions & 2 deletions apps/dashboard/src/api/workflows.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { WorkflowResponseDto } from '@novu/shared';
import { getV2 } from './api.client';
import type { CreateWorkflowDto, WorkflowResponseDto } from '@novu/shared';
import { getV2, postV2 } from './api.client';

export const fetchWorkflow = async ({ workflowId }: { workflowId?: string }): Promise<WorkflowResponseDto> => {
const { data } = await getV2<{ data: WorkflowResponseDto }>(`/workflows/${workflowId}`);

return data;
};

export async function createWorkflow(payload: CreateWorkflowDto) {
return postV2<{ data: WorkflowResponseDto }>(`/workflows`, payload);
}
184 changes: 184 additions & 0 deletions apps/dashboard/src/components/create-workflow-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { createWorkflow } from '@/api/workflows';
import { Button } from '@/components/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/primitives/form';
import { Input, InputField } from '@/components/primitives/input';
import { Separator } from '@/components/primitives/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetMain,
SheetTitle,
SheetTrigger,
} from '@/components/primitives/sheet';
import { TagInput } from '@/components/primitives/tag-input';
import { Textarea } from '@/components/primitives/textarea';
import { useEnvironment } from '@/context/environment/hooks';
import { QueryKeys } from '@/utils/query-keys';
import { zodResolver } from '@hookform/resolvers/zod';
import type { CreateWorkflowDto, WorkflowCreationSourceEnum } from '@novu/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ComponentProps, useState } from 'react';
import { useForm } from 'react-hook-form';
import { RiExternalLinkLine } from 'react-icons/ri';

import { Link } from 'react-router-dom';
import { z } from 'zod';

const formSchema = z.object({
name: z.string(),
identifier: z.string().regex(/^[a-z0-9-]+$/, 'Invalid identifier format. Must follow ^[a-z0-9-]+$'),
tags: z
.array(z.string().min(1))
.max(8)
.refine((tags) => new Set(tags).size === tags.length, {
message: 'Duplicate tags are not allowed.',
}),
description: z.string().max(200).optional(),
});

type CreateWorkflowButtonProps = ComponentProps<typeof SheetTrigger>;
export const CreateWorkflowButton = (props: CreateWorkflowButtonProps) => {
const queryClient = useQueryClient();
const { currentEnvironment } = useEnvironment();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isPending } = useMutation({
mutationFn: async (data: CreateWorkflowDto) => createWorkflow(data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id] });
queryClient.invalidateQueries({
queryKey: [QueryKeys.fetchWorkflow, currentEnvironment?._id, result.data.workflowId],
});
setIsOpen(false);
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { description: '', identifier: '', name: '', tags: [] },
});

return (
<Sheet
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (open) {
form.reset();
}
}}
>
<SheetTrigger {...props} />
<SheetContent>
<SheetHeader>
<SheetTitle>Create workflow</SheetTitle>
<div>
<SheetDescription>
Workflows manage event-driven notifications across multiple channels in a version-controlled flow, with
the ability to manage preference for each subscriber.
</SheetDescription>
<Link
target="_blank"
to="https://docs.novu.co/api-reference/workflows/create-workflow"
className="text-foreground-400 flex items-center text-sm underline"
>
Learn more <RiExternalLinkLine className="inline size-4" />
</Link>
</div>
</SheetHeader>
<Separator />
<SheetMain>
<Form {...form}>
<form
id="create-workflow"
onSubmit={form.handleSubmit((values) => {
mutateAsync({
name: values.name,
steps: [],
__source: WorkflowCreationSourceEnum.DASHBOARD,
workflowId: values.identifier,
description: values.description || undefined,
tags: values.tags,
});
})}
className="flex flex-col gap-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<InputField>
<Input placeholder="Untitled" {...field} />
</InputField>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="identifier"
render={({ field }) => (
<FormItem>
<FormLabel>Identifier</FormLabel>
<FormControl>
<InputField>
<Input placeholder="untitled" {...field} />
</InputField>
</FormControl>
<FormMessage>Must be unique and all lowercase ^[a-z0-9\-]+$</FormMessage>
</FormItem>
)}
/>

<Separator className="bg-neutral-alpha-100" />

<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1">
<FormLabel hint="(max. 8)">Add tags</FormLabel>
</div>
<FormControl>
<TagInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1">
<FormLabel optional>Description</FormLabel>
</div>
<FormControl>
<Textarea placeholder="Description of what this workflow does" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SheetMain>
<Separator />
<SheetFooter>
<Button disabled={isPending} variant="default" type="submit" form="create-workflow">
Create workflow
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useLayoutEffect, useState } from 'react';
import { RiLinkM, RiPencilFill } from 'react-icons/ri';
import { useForm } from 'react-hook-form';
// eslint-disable-next-line
// @ts-ignore
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

Expand Down Expand Up @@ -86,7 +84,7 @@ export const EditBridgeUrlButton = () => {
<FormItem>
<FormLabel>Bridge Endpoint URL</FormLabel>
<FormControl>
<InputField variant="xs" state={errors.bridgeUrl?.message ? 'error' : 'default'}>
<InputField state={errors.bridgeUrl?.message ? 'error' : 'default'}>
<RiLinkM className="size-5 min-w-5" />
<Input id="bridgeUrl" {...field} />
</InputField>
Expand Down
29 changes: 23 additions & 6 deletions apps/dashboard/src/components/primitives/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider } fro
import { cn } from '@/utils/ui';
import { Label } from '@/components/primitives/label';
import { cva } from 'class-variance-authority';
import { RiInformationFill } from 'react-icons/ri';
import { FormFieldContext, FormItemContext, useFormField } from './form-context';
import { RiErrorWarningFill, RiInformationFill } from 'react-icons/ri';
import { BsFillInfoCircleFill } from 'react-icons/bs';

const Form = FormProvider;

Expand Down Expand Up @@ -39,11 +40,27 @@ FormItem.displayName = 'FormItem';

const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & { optional?: boolean; hint?: string }
>(({ className, optional, hint, children, ...props }, ref) => {
const { formItemId } = useFormField();

return <Label ref={ref} className={className} htmlFor={formItemId} {...props} />;
return (
<Label ref={ref} className={cn('text-foreground-950', className)} htmlFor={formItemId} {...props}>
{children}
{hint && (
<span className="text-foreground-400 ml-0.5 inline-flex items-center gap-1">
{hint}
<BsFillInfoCircleFill className="text-foreground-300 inline size-3" />
</span>
)}

{optional && (
<span className="text-foreground-400 ml-0.5 inline-flex items-center gap-1">
(optional) <BsFillInfoCircleFill className="text-foreground-300 inline size-3" />
</span>
)}
</Label>
);
});
FormLabel.displayName = 'FormLabel';

Expand Down Expand Up @@ -78,7 +95,7 @@ FormDescription.displayName = 'FormDescription';
const formMessageVariants = cva('flex items-center gap-1', {
variants: {
variant: {
default: '[&>svg]:text-neutral-400 [&>span]:text-foreground-600',
default: '[&>svg]:text-foreground-300 text-foreground-400',
error: '[&>svg]:text-destructive [&>span]:text-destructive',
},
},
Expand All @@ -100,7 +117,7 @@ const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<
className={formMessageVariants({ variant: error ? 'error' : 'default', className })}
{...props}
>
<RiInformationFill className="size-4" />
{error ? <RiErrorWarningFill className="size-4" /> : <RiInformationFill className="size-4" />}
<span className="mt-[1px] text-xs leading-3">{body}</span>
</p>
);
Expand Down
19 changes: 11 additions & 8 deletions apps/dashboard/src/components/primitives/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,31 @@ import { cn } from '@/utils/ui';
import { cva, VariantProps } from 'class-variance-authority';

const inputFieldVariants = cva(
'text-foreground-950 flex w-full flex-nowrap items-center gap-1.5 rounded-md border bg-transparent shadow-sm transition-colors focus-within:outline-none focus-visible:outline-none hover:bg-neutral-50 has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 has-[input[value=""]]:text-foreground-400 has-[input:disabled]:bg-neutral-100 has-[input:disabled]:text-foreground-300',
'text-foreground-950 flex w-full flex-nowrap items-center gap-1.5 rounded-md border bg-transparent shadow-sm transition-colors focus-within:outline-none focus-visible:outline-none hover:bg-neutral-50 has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 has-[input[value=""]]:text-foreground-400 has-[input:disabled]:bg-neutral-alpha-100 has-[input:disabled]:text-foreground-300',
{
variants: {
variant: {
default: 'h-10 px-3 [&>input]:py-2.5',
sm: 'h-9 px-2.5 [&>input]:py-2',
xs: 'h-8 px-2 [&>input]:py-1.5',
size: {
default: 'h-8 px-2 [&>input]:py-1.5',
},
state: {
default: 'border-neutral-200 focus-within:border-neutral-950 focus-visible:border-neutral-950',
default:
'border-neutral-alpha-200 focus-within:border-neutral-alpha-950 focus-visible:border-neutral-alpha-950',
error: 'border-destructive',
},
},
defaultVariants: {
size: 'default',
state: 'default',
},
}
);

export type InputFieldProps = { children: React.ReactNode; className?: string } & VariantProps<
typeof inputFieldVariants
>;

const InputField = ({ children, className, variant, state }: InputFieldProps) => {
return <div className={inputFieldVariants({ variant, state, className })}>{children}</div>;
const InputField = ({ children, className, size, state }: InputFieldProps) => {
return <div className={inputFieldVariants({ size, state, className })}>{children}</div>;
};

InputField.displayName = 'InputField';
Expand Down
24 changes: 24 additions & 0 deletions apps/dashboard/src/components/primitives/separator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';

import { cn } from '@/utils/ui';

const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-neutral-alpha-200 shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;

export { Separator };
Loading

0 comments on commit 677565a

Please sign in to comment.