Skip to content

Commit

Permalink
feat(dashboard): header navigation (#6672)
Browse files Browse the repository at this point in the history
  • Loading branch information
LetItRock authored Oct 11, 2024
1 parent 4a3da93 commit 67d05da
Show file tree
Hide file tree
Showing 28 changed files with 436 additions and 36 deletions.
1 change: 1 addition & 0 deletions apps/dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ VITE_HUBSPOT_EMBED=
VITE_API_HOSTNAME=http://localhost:3000
VITE_CLERK_PUBLISHABLE_KEY=
VITE_NOVU_APP_ID=
VITE_INTERCOM_APP_ID=
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.1",
"@hookform/resolvers": "^2.9.11",
"@novu/react": "^2.3.0",
"@novu/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.2",
Expand All @@ -47,6 +47,7 @@
"react-hook-form": "7.43.9",
"react-icons": "^5.3.0",
"react-router-dom": "6.26.2",
"react-use-intercom": "^2.0.0",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
Expand Down
14 changes: 14 additions & 0 deletions apps/dashboard/src/api/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { BridgeStatus } from '@/utils/types';
import { get, post } from './api.client';

export const getBridgeHealthCheck = async () => {
const { data } = await get<{ data: BridgeStatus }>('/bridge/status');

return data;
};

export const validateBridgeUrl = async (payload: { bridgeUrl: string }) => {
const { data } = await post<{ data: { isValid: boolean } }>('/bridge/validate', payload);

return data;
};
6 changes: 5 additions & 1 deletion apps/dashboard/src/api/environments.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { IEnvironment } from '@novu/shared';
import { get } from './api.client';
import { get, put } from './api.client';

export async function getEnvironments() {
const { data } = await get<{ data: IEnvironment[] }>('/environments');

return data;
}

export async function updateBridgeUrl(payload: { url: string | undefined }, environmentId: string) {
return put(`/environments/${environmentId}`, { bridge: payload });
}
41 changes: 20 additions & 21 deletions apps/dashboard/src/components/dashboard-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
import { ReactNode } from 'react';
import { UserProfile } from '@/components/user-profile';
import { InboxButton } from '@/components/inbox-button';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { IntercomProvider } from 'react-use-intercom';
import { SideNavigation } from './side-navigation';
import { HeaderNavigation } from './header-navigation';
import { INTERCOM_APP_ID } from '@/config';

export const DashboardLayout = ({ children }: { children: ReactNode }) => {
export const DashboardLayout = ({
children,
headerStartItems,
}: {
children: ReactNode;
headerStartItems?: ReactNode;
}) => {
return (
<div className="relative flex w-full">
<SideNavigation />
<div className="flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<div className="bg-background flex h-16 w-full items-center justify-between border-b p-4">
<a
href="/legacy/integrations"
target="_self"
className="text-blue-600 visited:text-purple-600 hover:border-b hover:border-current"
>
Integrations
</a>
<div className="flex gap-4">
<InboxButton />
<UserProfile />
</div>
</div>
<IntercomProvider appId={INTERCOM_APP_ID}>
<div className="relative flex w-full">
<SideNavigation />
<div className="flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<HeaderNavigation startItems={headerStartItems} />

<div className="flex min-h-[calc(100dvh-4rem)] flex-col overflow-y-auto overflow-x-hidden">{children}</div>
<div className="flex min-h-[calc(100dvh-4rem)] flex-col overflow-y-auto overflow-x-hidden">{children}</div>
</div>
</div>
</div>
</IntercomProvider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { RiCustomerService2Line } from 'react-icons/ri';
import { useBootIntercom } from '@/hooks';

export const CustomerSupportButton = () => {
useBootIntercom();

return (
<button id="intercom-launcher">
<RiCustomerService2Line className="size-5 cursor-pointer" />
</button>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 { Button } from '../primitives/button';
import { Input, InputField } from '../primitives/input';
import { useBridgeHealthCheck, useUpdateBridgeUrl, useValidateBridgeUrl } from '@/hooks';
import { ConnectionStatus } from '@/utils/types';
import { useEnvironment } from '@/context/environment/hooks';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../primitives/form';

const formSchema = z.object({ bridgeUrl: z.string().url() });

export const EditBridgeUrlButton = () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({ mode: 'onSubmit', resolver: zodResolver(formSchema) });
const {
control,
handleSubmit,
reset,
setError,
formState: { isDirty, errors },
} = form;
const { currentEnvironment, setBridgeUrl } = useEnvironment();
const { status, bridgeURL: envBridgeUrl } = useBridgeHealthCheck();
const { validateBridgeUrl, isPending: isValidatingBridgeUrl } = useValidateBridgeUrl();
const { updateBridgeUrl, isPending: isUpdatingBridgeUrl } = useUpdateBridgeUrl();

useLayoutEffect(() => {
reset({ bridgeUrl: envBridgeUrl });
}, [reset, envBridgeUrl]);

const onSubmit = async ({ bridgeUrl }: z.infer<typeof formSchema>) => {
const { isValid } = await validateBridgeUrl(bridgeUrl);
if (isValid) {
await updateBridgeUrl({ url: bridgeUrl, environmentId: currentEnvironment?._id ?? '' });
setBridgeUrl(bridgeUrl);
} else {
setError('bridgeUrl', { message: 'The provided URL is not the Novu Endpoint URL' });
}
};

return (
<Popover
open={isPopoverOpen}
onOpenChange={(newIsOpen) => {
setIsPopoverOpen(newIsOpen);
if (!newIsOpen && isDirty) {
reset({ bridgeUrl: envBridgeUrl });
}
}}
>
<PopoverTrigger asChild>
<button className="text-foreground-600 flex h-6 items-center gap-2 rounded-md border border-neutral-200 text-xs leading-4 hover:bg-neutral-50 focus:bg-neutral-50">
<div className="flex items-center gap-2 px-1.5 py-1">
<span
className={cn(
'relative size-1.5 animate-[pulse-shadow_1s_ease-in-out_infinite] rounded-full',
status === ConnectionStatus.DISCONNECTED || status === ConnectionStatus.LOADING
? 'bg-destructive [--pulse-color:var(--destructive)]'
: 'bg-success [--pulse-color:var(--destructive)]'
)}
/>
<span>Local Studio</span>
</div>
<span className="border-l border-neutral-200 p-1.5">
<RiPencilFill className="size-[12px]" />
</span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="w-[362px] p-0" side="bottom" align="end">
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-1 p-5">
<FormField
control={control}
name="bridgeUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Bridge Endpoint URL</FormLabel>
<FormControl>
<InputField variant="xs" state={errors.bridgeUrl?.message ? 'error' : 'default'}>
<RiLinkM className="size-5 min-w-5" />
<Input id="bridgeUrl" {...field} />
</InputField>
</FormControl>
<FormMessage>Full path URL (e.g., https://your.api.com/api/novu)</FormMessage>
</FormItem>
)}
/>
</div>
<div className="flex items-center justify-between border-t border-neutral-200 px-5 py-3">
<a
href="https://docs.novu.co/concepts/endpoint#bridge-endpoint"
target="_blank"
rel="noopener noreferrer"
className="text-xs"
>
How it works?
</a>
<Button
type="submit"
variant="primary"
size="xs"
disabled={!isDirty || isValidatingBridgeUrl || isUpdatingBridgeUrl}
>
Update endpoint
</Button>
</div>
</form>
</Form>
</PopoverContent>
</PopoverPortal>
</Popover>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ReactNode } from 'react';
import { UserProfile } from '@/components/user-profile';
import { InboxButton } from '@/components/inbox-button';
import { CustomerSupportButton } from './customer-support-button';
import { EditBridgeUrlButton } from './edit-bridge-url-button';

export const HeaderNavigation = ({ startItems }: { startItems?: ReactNode }) => {
return (
<div className="bg-background flex w-full items-center justify-between border-b px-6 py-3">
{startItems}
<div className="text-foreground-600 ml-auto flex items-center gap-3">
<EditBridgeUrlButton />
<CustomerSupportButton />
<InboxButton />
<UserProfile />
</div>
</div>
);
};
1 change: 1 addition & 0 deletions apps/dashboard/src/components/header-navigation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './header-navigation';
1 change: 1 addition & 0 deletions apps/dashboard/src/components/primitives/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const buttonVariants = cva(
},
size: {
default: 'h-9 p-2.5',
xs: 'h-6 px-1.5 rounded-md text-xs',
sm: 'h-8 px-3 rounded-md text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'size-8',
Expand Down
22 changes: 17 additions & 5 deletions apps/dashboard/src/components/primitives/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useF

import { cn } from '@/utils/ui';
import { Label } from '@/components/primitives/label';
import { cva } from 'class-variance-authority';
import { RiInformationFill } from 'react-icons/ri';

const Form = FormProvider;

Expand Down Expand Up @@ -65,7 +67,7 @@ const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivEl

return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
<div ref={ref} className={cn('space-y-1', className)} {...props} />
</FormItemContext.Provider>
);
}
Expand All @@ -76,9 +78,9 @@ const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
const { formItemId } = useFormField();

return <Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />;
return <Label ref={ref} className={className} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = 'FormLabel';

Expand Down Expand Up @@ -110,6 +112,15 @@ const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttribu
);
FormDescription.displayName = 'FormDescription';

const formMessageVariants = cva('flex items-center gap-1', {
variants: {
variant: {
default: '[&>svg]:text-neutral-400 [&>span]:text-foreground-600',
error: '[&>svg]:text-destructive [&>span]:text-destructive',
},
},
});

const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
Expand All @@ -123,10 +134,11 @@ const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<
<p
ref={ref}
id={formMessageId}
className={cn('text-destructive text-[0.8rem] font-medium', className)}
className={formMessageVariants({ variant: error ? 'error' : 'default', className })}
{...props}
>
{body}
<RiInformationFill className="size-4" />
<span className="mt-[1px] text-xs leading-3">{body}</span>
</p>
);
}
Expand Down
50 changes: 50 additions & 0 deletions apps/dashboard/src/components/primitives/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';

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',
{
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',
},
state: {
default: 'border-neutral-200 focus-within:border-neutral-950 focus-visible:border-neutral-950',
error: 'border-destructive',
},
},
}
);

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>;
};

InputField.displayName = 'InputField';

export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;

const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'file:text-foreground placeholder:text-foreground-400 flex h-full w-full bg-transparent text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed',
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';

export { InputField, Input };
4 changes: 3 additions & 1 deletion apps/dashboard/src/components/primitives/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { cva, type VariantProps } from 'class-variance-authority';

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

const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70');
const labelVariants = cva(
'text-foreground-600 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);

const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ if (!CLERK_PUBLISHABLE_KEY) {

export const API_HOSTNAME = import.meta.env.VITE_API_HOSTNAME;

export const INTERCOM_APP_ID = import.meta.env.VITE_INTERCOM_APP_ID;

export const SEGMENT_KEY = import.meta.env.VITE_SEGMENT_KEY;

export const MIXPANEL_KEY = import.meta.env.VITE_MIXPANEL_KEY;
Loading

0 comments on commit 67d05da

Please sign in to comment.