Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-label": "2.1.0",
"@radix-ui/react-popover": "1.1.5",
"@radix-ui/react-scroll-area": "1.2.3",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.1.1",
Expand Down
71 changes: 71 additions & 0 deletions site/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion site/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const buttonVariants = cva(
default:
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold",
outline:
"border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
"border border-border-default text-content-primary bg-transparent bg-surface-primary hover:bg-surface-secondary",
subtle:
"border-none bg-transparent text-content-secondary hover:text-content-primary",
destructive:
Expand All @@ -31,6 +31,7 @@ export const buttonVariants = cva(
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg",
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
icon: "size-8 px-1.5 [&_svg]:size-icon-sm",
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg",
},
},
defaultVariants: {
Expand Down
46 changes: 46 additions & 0 deletions site/src/components/ScrollArea/ScrollArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
/**
* Copied from shadc/ui on 03/05/2025
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
*/
import * as React from "react";
import { cn } from "utils/cn";

export const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;

export const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"border-0 border-solid border-border flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react";
import { InboxButton } from "./InboxButton";

const meta: Meta<typeof InboxButton> = {
title: "modules/notifications/NotificationsInbox/InboxButton",
component: InboxButton,
};

export default meta;
type Story = StoryObj<typeof InboxButton>;

export const AllRead: Story = {};

export const Unread: Story = {
args: {
unreadCount: 3,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Button, type ButtonProps } from "components/Button/Button";
import { BellIcon } from "lucide-react";
import { type FC, forwardRef } from "react";
import { UnreadBadge } from "./UnreadBadge";

type InboxButtonProps = {
unreadCount: number;
} & ButtonProps;

export const InboxButton = forwardRef<HTMLButtonElement, InboxButtonProps>(
({ unreadCount, ...props }, ref) => {
return (
<Button
size="icon-lg"
variant="outline"
className="relative"
ref={ref}
{...props}
>
<BellIcon />
{unreadCount > 0 && (
<UnreadBadge
count={unreadCount}
className="absolute top-0 right-0 -translate-y-1/2 translate-x-1/2"
/>
)}
</Button>
);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import { MockNotification } from "testHelpers/entities";
import { InboxItem } from "./InboxItem";

const meta: Meta<typeof InboxItem> = {
title: "modules/notifications/NotificationsInbox/InboxItem",
component: InboxItem,
render: (args) => {
return (
<div className="max-w-[460px] border-solid border-border rounded">
<InboxItem {...args} />
</div>
);
},
};

export default meta;
type Story = StoryObj<typeof InboxItem>;

export const Read: Story = {
args: {
notification: MockNotification,
},
};

export const Unread: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
},
},
};

export const UnreadFocus: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const notification = canvas.getByRole("menuitem");
await userEvent.click(notification);
},
};

export const OnMarkNotificationAsRead: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
},
onMarkNotificationAsRead: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const notification = canvas.getByRole("menuitem");
await userEvent.click(notification);
const markButton = canvas.getByRole("button", { name: /mark as read/i });
await userEvent.click(markButton);
await expect(args.onMarkNotificationAsRead).toHaveBeenCalledTimes(1);
await expect(args.onMarkNotificationAsRead).toHaveBeenCalledWith(
args.notification.id,
);
},
parameters: {
chromatic: {
disableSnapshot: true,
},
},
};
68 changes: 68 additions & 0 deletions site/src/modules/notifications/NotificationsInbox/InboxItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import { SquareCheckBig } from "lucide-react";
import type { FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { relativeTime } from "utils/time";
import type { Notification } from "./types";

type InboxItemProps = {
notification: Notification;
onMarkNotificationAsRead: (notificationId: string) => void;
};

export const InboxItem: FC<InboxItemProps> = ({
notification,
onMarkNotificationAsRead,
}) => {
return (
<div
className="flex items-stretch gap-3 p-3 group"
role="menuitem"
tabIndex={-1}
>
<div className="flex-shrink-0">
<Avatar fallback="AR" />
</div>

<div className="flex flex-col gap-3">
<span className="text-content-secondary text-sm font-medium">
{notification.content}
</span>
<div className="flex items-center gap-1">
{notification.actions.map((action) => {
return (
<Button variant="outline" size="sm" key={action.label} asChild>
<RouterLink to={action.url}>{action.label}</RouterLink>
</Button>
);
})}
</div>
</div>

<div className="w-12 flex flex-col items-end flex-shrink-0">
{notification.read_status === "unread" && (
<>
<div className="group-focus:hidden group-hover:hidden size-2.5 rounded-full bg-highlight-sky">
<span className="sr-only">Unread</span>
</div>

<Button
onClick={() => onMarkNotificationAsRead(notification.id)}
className="hidden group-focus:flex group-hover:flex"
variant="outline"
size="sm"
>
<SquareCheckBig />
mark as read
</Button>
</>
)}

<span className="mt-auto text-content-secondary text-xs font-medium whitespace-nowrap">
{relativeTime(new Date(notification.created_at))}
</span>
</div>
</div>
);
};
Loading
Loading