Skip to content

Commit

Permalink
feat(dashboard): workflow editor route and basic layout (#6681)
Browse files Browse the repository at this point in the history
  • Loading branch information
LetItRock authored Oct 16, 2024
1 parent d454d17 commit cc184b4
Show file tree
Hide file tree
Showing 19 changed files with 705 additions and 223 deletions.
35 changes: 21 additions & 14 deletions apps/dashboard/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';

export default tseslint.config(
{ ignores: ["dist"] },
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"@typescript-eslint/no-explicit-any": "warn",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
'@typescript-eslint/no-explicit-any': 'warn',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['off'],
'@typescript-eslint/naming-convention': [
'error',
{
filter: '_',
selector: 'variableLike',
leadingUnderscore: 'allow',
format: ['PascalCase', 'camelCase', 'UPPER_CASE'],
},
],
},
},
}
);
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@segment/analytics-next": "^1.73.0",
"@tanstack/react-query": "^5.59.6",
Expand Down
8 changes: 8 additions & 0 deletions apps/dashboard/src/api/workflows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { WorkflowResponseDto } from '@novu/shared';
import { getV2 } from './api.client';

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

return data;
};
26 changes: 26 additions & 0 deletions apps/dashboard/src/components/edit-workflow-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ReactNode } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { IntercomProvider } from 'react-use-intercom';
import { HeaderNavigation } from './header-navigation';
import { INTERCOM_APP_ID } from '@/config';

export const EditWorkflowLayout = ({
children,
headerStartItems,
}: {
children: ReactNode;
headerStartItems?: ReactNode;
}) => {
return (
<IntercomProvider appId={INTERCOM_APP_ID}>
<div className="relative flex w-full">
<div className="flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<HeaderNavigation startItems={headerStartItems} />

<div className="flex min-h-dvh flex-col overflow-y-auto overflow-x-hidden">{children}</div>
</div>
</div>
</IntercomProvider>
);
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { useLayoutEffect, useState } from 'react';
import { RiLinkM, RiPencilFill } from 'react-icons/ri';
import { PopoverPortal } from '@radix-ui/react-popover';
import { useForm } from 'react-hook-form';
// eslint-disable-next-line
// @ts-ignore
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

import { cn } from '@/utils/ui';
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover';
import { Popover, PopoverContent, PopoverTrigger, PopoverPortal } from '../primitives/popover';
import { Button } from '../primitives/button';
import { Input, InputField } from '../primitives/input';
import { useBridgeHealthCheck, useUpdateBridgeUrl, useValidateBridgeUrl } from '@/hooks';
Expand Down Expand Up @@ -92,7 +91,7 @@ export const EditBridgeUrlButton = () => {
<Input id="bridgeUrl" {...field} />
</InputField>
</FormControl>
<FormMessage>Full path URL (e.g., https://your.api.com/api/novu)</FormMessage>
<FormMessage>URL (e.g., https://your.api.com/api/novu)</FormMessage>
</FormItem>
)}
/>
Expand All @@ -104,7 +103,7 @@ export const EditBridgeUrlButton = () => {
rel="noopener noreferrer"
className="text-xs"
>
How it works?
Learn more
</a>
<Button
type="submit"
Expand Down
3 changes: 1 addition & 2 deletions apps/dashboard/src/components/inbox-button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover';
import { Popover, PopoverContent, PopoverTrigger, PopoverPortal } from '@/components/primitives/popover';
import { APP_ID } from '@/config';
import { useUser } from '@clerk/clerk-react';
import { Bell, Inbox, InboxContent } from '@novu/react';
import { PopoverPortal } from '@radix-ui/react-popover';

export const InboxButton = () => {
const { user } = useUser();
Expand Down
4 changes: 3 additions & 1 deletion apps/dashboard/src/components/primitives/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger;

const PopoverAnchor = PopoverPrimitive.Anchor;

const PopoverPortal = PopoverPrimitive.Portal;

const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
Expand All @@ -28,4 +30,4 @@ const PopoverContent = React.forwardRef<
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;

export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverPortal };
50 changes: 50 additions & 0 deletions apps/dashboard/src/components/primitives/tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';

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

const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'border-neutral-alpha-200 inline-flex w-full items-center justify-start gap-6 border-b border-t px-3.5',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;

const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"text-foreground-600 ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:after:border-primary data-[state=active]:text-foreground-950 relative inline-flex items-center justify-center whitespace-nowrap rounded-md py-3.5 text-sm font-medium transition-all duration-300 ease-out after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-full after:border-b-2 after:border-b-transparent after:content-[''] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;

const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn('mt-2 focus-visible:outline-none', className)} {...props} />
));
TabsContent.displayName = TabsPrimitive.Content.displayName;

const Tabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>(({ className, ...props }, ref) => <TabsPrimitive.Root ref={ref} className={cn('', className)} {...props} />);
Tabs.displayName = TabsPrimitive.Root.displayName;

export { Tabs, TabsList, TabsTrigger, TabsContent };
5 changes: 3 additions & 2 deletions apps/dashboard/src/components/truncated-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { useCallback, useEffect, useRef, useState } from 'react';
interface TruncatedTextProps {
text: string;
className?: string;
onClick?: React.MouseEventHandler<HTMLSpanElement>;
}

export default function TruncatedText({ text, className = '' }: TruncatedTextProps) {
export default function TruncatedText({ text, className = '', onClick }: TruncatedTextProps) {
const [isTruncated, setIsTruncated] = useState(false);
const textRef = useRef<HTMLDivElement>(null);

Expand All @@ -28,7 +29,7 @@ export default function TruncatedText({ text, className = '' }: TruncatedTextPro
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span ref={textRef} className={cn('block truncate', className)}>
<span ref={textRef} className={cn('block truncate', className)} onClick={onClick}>
{text}
</span>
</TooltipTrigger>
Expand Down
63 changes: 63 additions & 0 deletions apps/dashboard/src/components/workflow-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useForm } from 'react-hook-form';
// eslint-disable-next-line
// @ts-ignore
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useNavigate, useParams } from 'react-router-dom';
import { StepTypeEnum } from '@novu/shared';

import { useFetchWorkflow } from '@/hooks/use-fetch-workflow';
import { Form } from './primitives/form';
import { buildRoute, ROUTES } from '@/utils/routes';
import { useEnvironment } from '@/context/environment/hooks';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './primitives/tabs';

const formSchema = z.object({
name: z.string(),
identifier: z.string(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
active: z.boolean().optional(),
critical: z.boolean().optional(),
steps: z.array(
z.object({
name: z.string().optional(),
type: z.nativeEnum(StepTypeEnum),
})
),
});

export const WorkflowEditor = () => {
const { currentEnvironment } = useEnvironment();
const { workflowId } = useParams<{ workflowId?: string }>();
const navigate = useNavigate();
const form = useForm<z.infer<typeof formSchema>>({ mode: 'onSubmit', resolver: zodResolver(formSchema) });
const { handleSubmit, reset } = form;

const { workflow } = useFetchWorkflow({
workflowId,
onSuccess: (data) => {
reset(data);
},
onError: () => {
navigate(buildRoute(ROUTES.WORKFLOWS, { environmentId: currentEnvironment?._id ?? '' }));
},
});

const onSubmit = async (_data: z.infer<typeof formSchema>) => {
// TODO: Implement submit logic
};

return (
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Tabs defaultValue="workflow" className="-mt-[1px]">
<TabsList>
<TabsTrigger value="workflow">Workflow</TabsTrigger>
</TabsList>
<TabsContent value="workflow">{`Workflow Editor Canvas - ${workflow?.name ?? ''}`}</TabsContent>
</Tabs>
</form>
</Form>
);
};
17 changes: 15 additions & 2 deletions apps/dashboard/src/components/workflow-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { useEnvironment } from '@/context/environment/hooks';
import { ListWorkflowResponse, WorkflowOriginEnum, WorkflowStatusEnum } from '@novu/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { FaCode } from 'react-icons/fa6';
import { createSearchParams, Link, useLocation, useSearchParams } from 'react-router-dom';
import { createSearchParams, Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import {
RiRouteFill,
RiBookMarkedLine,
Expand All @@ -41,11 +41,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/primitives/dropdown-menu';
import { buildRoute, ROUTES } from '@/utils/routes';

export const WorkflowList = () => {
const { currentEnvironment } = useEnvironment();
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const hrefFromOffset = (offset: number) => {
return `${location.pathname}?${createSearchParams({
...searchParams,
Expand Down Expand Up @@ -158,7 +160,18 @@ export const WorkflowList = () => {
<FaCode className="size-3" />
</Badge>
)}
<TruncatedText text={workflow.name} />
<TruncatedText
className="cursor-pointer"
text={workflow.name}
onClick={() => {
navigate(
buildRoute(ROUTES.EDIT_WORKFLOW, {
environmentId: currentEnvironment?._id ?? '',
workflowId: workflow._id,
})
);
}}
/>
</div>
<TruncatedText className="text-foreground-400 font-code block text-xs" text={workflow._id} />
</TableCell>
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/hooks/use-bridge-health-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const useBridgeHealthCheck = () => {
const bridgeURL = currentEnvironment?.bridge?.url || '';

const { data, isLoading, error } = useQuery<BridgeStatus>({
queryKey: [QueryKeys.bridgeHealthCheck, bridgeURL],
queryKey: [QueryKeys.bridgeHealthCheck, currentEnvironment?._id, bridgeURL],
queryFn: getBridgeHealthCheck,
enabled: !!bridgeURL,
networkMode: 'always',
Expand Down
37 changes: 37 additions & 0 deletions apps/dashboard/src/hooks/use-fetch-workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import type { WorkflowResponseDto } from '@novu/shared';
import { QueryKeys } from '@/utils/query-keys';
import { fetchWorkflow } from '@/api/workflows';
import { useEnvironment } from '@/context/environment/hooks';

export const useFetchWorkflow = ({
workflowId,
onSuccess,
onError,
}: {
workflowId?: string;
onSuccess?: (data: WorkflowResponseDto) => void;
onError?: (error: unknown) => void;
}) => {
const { currentEnvironment } = useEnvironment();
const { data, isPending, error } = useQuery<WorkflowResponseDto>({
queryKey: [QueryKeys.fetchWorkflow, currentEnvironment?._id, workflowId],
queryFn: async () => {
try {
const result = await fetchWorkflow({ workflowId });
onSuccess?.(result);
return result;
} catch (error) {
onError?.(error);
throw error;
}
},
enabled: !!currentEnvironment?._id && !!workflowId,
});

return {
workflow: data,
isPending,
error,
};
};
5 changes: 5 additions & 0 deletions apps/dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RootRoute, AuthRoute, DashboardRoute, CatchAllRoute } from './routes';
import { WorkflowsPage, SignInPage, SignUpPage, OrganizationListPage } from '@/pages';
import './index.css';
import { ROUTES } from './utils/routes';
import { EditWorkflowPage } from './pages/edit-workflow';

const router = createBrowserRouter([
{
Expand Down Expand Up @@ -40,6 +41,10 @@ const router = createBrowserRouter([
path: ROUTES.WORKFLOWS,
element: <WorkflowsPage />,
},
{
path: ROUTES.EDIT_WORKFLOW,
element: <EditWorkflowPage />,
},
{
path: '*',
element: <CatchAllRoute />,
Expand Down
Loading

0 comments on commit cc184b4

Please sign in to comment.