Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-05-03 18:19:20 -07:00
parent 625343dada
commit 14e338b4c4
70 changed files with 3070 additions and 1228 deletions

View File

@@ -26,7 +26,7 @@ posthog.init(process.env.POSTHOG_KEY!, {
});
const OrganizationSelectionPage = lazy(
() => import("./pages/OrganizationSelectionPage")
() => import("./pages/OrganizationSelectionPage"),
);
function App() {

View File

@@ -5,7 +5,7 @@ import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton";
import { Sidebar } from "./ui/sidebar";
export function AppSidebarSkeleton(
props: React.ComponentProps<typeof Sidebar>
props: React.ComponentProps<typeof Sidebar>,
) {
return (
<AppSidebarShell

View File

@@ -44,7 +44,7 @@ export function EditableField({
}
className={cn(
"w-full resize-none",
required && !value && "border-red-500"
required && !value && "border-red-500",
)}
placeholder={`Enter ${label.toLowerCase()}`}
rows={4}

View File

@@ -53,7 +53,7 @@ export function NavMain() {
if (item.items?.length) {
return item.items.some((subItem) =>
location.pathname.startsWith(subItem.url)
location.pathname.startsWith(subItem.url),
);
}
@@ -93,7 +93,7 @@ export function NavMain() {
<SidebarMenuSub>
{item.items?.map((subItem) => {
const subItemActive = location.pathname.startsWith(
subItem.url
subItem.url,
);
return (

View File

@@ -23,7 +23,7 @@ const navItems = [
];
export function NavSecondary(
props: React.ComponentPropsWithoutRef<typeof SidebarGroup>
props: React.ComponentPropsWithoutRef<typeof SidebarGroup>,
) {
return (
<SidebarGroup {...props}>

View File

@@ -82,7 +82,7 @@ export function OrganizationSwitcher({
useEffect(() => {
if (hasOrganizations) {
const org = data.organizations.edges.find(
(edge) => edge.node.id === organizationId
(edge) => edge.node.id === organizationId,
);
if (org) {
setCurrentOrganization(org.node);
@@ -104,7 +104,7 @@ export function OrganizationSwitcher({
variant="ghost"
className={cn(
"gap-2.5",
!currentOrganization && "border border-dashed"
!currentOrganization && "border border-dashed",
)}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-highlight-bg">

View File

@@ -66,7 +66,7 @@ export default function PeopleSelector({
<SelectItem key={edge.node.id} value={edge.node.id}>
{edge.node.fullName} ({edge.node.primaryEmailAddress})
</SelectItem>
)
),
)}
</SelectContent>
</Select>

View File

@@ -11,7 +11,7 @@ const Avatar = React.forwardRef<
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
className,
)}
{...props}
/>
@@ -38,7 +38,7 @@ const AvatarFallback = React.forwardRef<
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-subtle-bg",
className
className,
)}
{...props}
/>

View File

@@ -20,7 +20,7 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
},
);
export interface BadgeProps

View File

@@ -20,7 +20,7 @@ const BreadcrumbList = React.forwardRef<
ref={ref}
className={cn(
"flex flex-wrap items-center gap-3 break-words text-sm text-secondary sm:gap-2.5",
className
className,
)}
{...props}
/>

View File

@@ -31,7 +31,7 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
},
);
export interface ButtonProps
@@ -50,7 +50,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
);
}
},
);
Button.displayName = "Button";

View File

@@ -12,7 +12,7 @@ const Checkbox = React.forwardRef<
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-level-0 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-invert",
className
className,
)}
{...props}
>

View File

@@ -19,7 +19,7 @@ const Command = React.forwardRef<
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-level-0 text-primary",
className
className,
)}
{...props}
/>
@@ -48,7 +48,7 @@ const CommandInput = React.forwardRef<
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-tertiary disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
/>
@@ -91,7 +91,7 @@ const CommandGroup = React.forwardRef<
ref={ref}
className={cn(
"overflow-hidden p-1 text-primary [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-tertiary",
className
className,
)}
{...props}
/>
@@ -119,7 +119,7 @@ const CommandItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent-bg aria-selected:text-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-secondary-bg transition-colors",
className
className,
)}
{...props}
/>

View File

@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
className,
)}
{...props}
/>
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-mid-b bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
className,
)}
{...props}
>
@@ -57,7 +57,7 @@ const DialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
className,
)}
{...props}
/>
@@ -71,7 +71,7 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>
@@ -86,7 +86,7 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
className,
)}
{...props}
/>

View File

@@ -29,7 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent-bg data-[state=open]:bg-accent-bg [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
className,
)}
{...props}
>
@@ -48,7 +48,7 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-level-1 p-1 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
@@ -67,7 +67,7 @@ const DropdownMenuContent = React.forwardRef<
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-level-1 p-1 shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
@@ -86,7 +86,7 @@ const DropdownMenuItem = React.forwardRef<
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-active-bg focus:text-active data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
className,
)}
{...props}
/>
@@ -101,7 +101,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent-bg focus:text-accent data-disabled:pointer-events-none data-disabled:opacity-50",
className
className,
)}
checked={checked}
{...props}
@@ -125,7 +125,7 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent-bg focus:text-accent data-disabled:pointer-events-none data-disabled:opacity-50",
className
className,
)}
{...props}
>
@@ -150,7 +150,7 @@ const DropdownMenuLabel = React.forwardRef<
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
className,
)}
{...props}
/>

View File

@@ -9,13 +9,13 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-solid-b bg-invert-bg px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-primary placeholder:text-tertiary focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-active-b disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
className,
)}
ref={ref}
{...props}
/>
);
}
},
);
Input.displayName = "Input";

View File

@@ -18,7 +18,7 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-level-0 p-4 text-primary shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>

View File

@@ -27,7 +27,7 @@ const RadioGroupItem = React.forwardRef<
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-level-0 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
>

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-solid-b bg-invert-bg px-3 py-2 text-sm ring-offset-level-0 placeholder:text-tertiary focus:outline-hidden focus:ring focus:ring-active-b disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
>
@@ -39,7 +39,7 @@ const SelectContent = React.forwardRef<
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-invert-bg text-primary shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
className,
)}
position={position}
{...props}
@@ -48,7 +48,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@@ -66,7 +66,7 @@ const SelectItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-active-bg focus:text-active data-disabled:pointer-events-none data-disabled:opacity-50",
className
className,
)}
{...props}
>

View File

@@ -9,7 +9,7 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
@@ -18,11 +18,11 @@ const Separator = React.forwardRef<
className={cn(
"shrink-0 bg-solid-b",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
className,
)}
{...props}
/>
)
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;

View File

@@ -22,7 +22,7 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
className,
)}
{...props}
ref={ref}
@@ -46,7 +46,7 @@ const sheetVariants = cva(
defaultVariants: {
side: "right",
},
}
},
);
interface SheetContentProps
@@ -81,7 +81,7 @@ const SheetHeader = ({
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
className,
)}
{...props}
/>
@@ -95,7 +95,7 @@ const SheetFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>

View File

@@ -63,7 +63,7 @@ const SidebarProvider = React.forwardRef<
children,
...props
},
ref
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
@@ -84,7 +84,7 @@ const SidebarProvider = React.forwardRef<
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
[setOpenProp, open],
);
// Helper to toggle the sidebar.
@@ -124,7 +124,15 @@ const SidebarProvider = React.forwardRef<
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
);
return (
@@ -140,7 +148,7 @@ const SidebarProvider = React.forwardRef<
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-level-0",
className
className,
)}
ref={ref}
{...props}
@@ -150,7 +158,7 @@ const SidebarProvider = React.forwardRef<
</TooltipProvider>
</SidebarContext.Provider>
);
}
},
);
SidebarProvider.displayName = "SidebarProvider";
@@ -171,7 +179,7 @@ const Sidebar = React.forwardRef<
children,
...props
},
ref
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
@@ -180,7 +188,7 @@ const Sidebar = React.forwardRef<
<div
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-level-0 text-secondary",
className
className,
)}
ref={ref}
{...props}
@@ -227,7 +235,7 @@ const Sidebar = React.forwardRef<
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
@@ -240,7 +248,7 @@ const Sidebar = React.forwardRef<
variant === "floating" || variant === "inset"
? "p-4 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
className,
)}
{...props}
>
@@ -253,7 +261,7 @@ const Sidebar = React.forwardRef<
</div>
</div>
);
}
},
);
Sidebar.displayName = "Sidebar";
@@ -304,7 +312,7 @@ const SidebarRail = React.forwardRef<
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-level-0",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
className,
)}
{...props}
/>
@@ -322,7 +330,7 @@ const SidebarInset = React.forwardRef<
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-level-0",
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className
className,
)}
{...props}
/>
@@ -340,7 +348,7 @@ const SidebarInput = React.forwardRef<
data-sidebar="input"
className={cn(
"h-8 w-full bg-level-0 shadow-none focus-visible:ring-2 focus-visible:ring-active-b",
className
className,
)}
{...props}
/>
@@ -403,7 +411,7 @@ const SidebarContent = React.forwardRef<
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
className,
)}
{...props}
/>
@@ -439,7 +447,7 @@ const SidebarGroupLabel = React.forwardRef<
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-secondary/70 outline-hidden ring-active-b transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
className,
)}
{...props}
/>
@@ -462,7 +470,7 @@ const SidebarGroupAction = React.forwardRef<
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
@@ -531,7 +539,7 @@ const sidebarMenuButtonVariants = cva(
variant: "default",
size: "default",
},
}
},
);
const SidebarMenuButton = React.forwardRef<
@@ -552,7 +560,7 @@ const SidebarMenuButton = React.forwardRef<
className,
...props
},
ref
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
@@ -589,7 +597,7 @@ const SidebarMenuButton = React.forwardRef<
/>
</Tooltip>
);
}
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
@@ -616,7 +624,7 @@ const SidebarMenuAction = React.forwardRef<
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-accent md:opacity-0",
className
className,
)}
{...props}
/>
@@ -638,7 +646,7 @@ const SidebarMenuBadge = React.forwardRef<
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
@@ -693,7 +701,7 @@ const SidebarMenuSub = React.forwardRef<
className={cn(
"ml-5 flex min-w-0 flex-col gap-1 border-l border-solid-b px-2.5 py-1",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
@@ -728,7 +736,7 @@ const SidebarMenuSubButton = React.forwardRef<
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Switch({
className,
@@ -12,18 +12,18 @@ function Switch({
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
)
);
}
export { Switch }
export { Switch };

View File

@@ -56,7 +56,7 @@ const TableRow = React.forwardRef<
ref={ref}
className={cn(
"border-b transition-colors hover:bg-h-subtle-bg data-[state=selected]:bg-subtle-bg",
className
className,
)}
{...props}
/>
@@ -71,7 +71,7 @@ const TableHead = React.forwardRef<
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-tertiary [&:has([role=checkbox])]:pr-0",
className
className,
)}
{...props}
/>

View File

@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
ref={ref}
className={cn(
"inline-flex h-10 items-center rounded-md p-1 text-tertiary",
className
className,
)}
{...props}
/>
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
ref={ref}
className={cn(
"border-b-2 border-transparent cursor-pointer inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-level-0 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-active data-[state=active]:border-primary",
className
className,
)}
{...props}
/>
@@ -42,7 +42,7 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
"mt-2 ring-offset-level-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2",
className
className,
)}
{...props}
/>

View File

@@ -12,7 +12,7 @@ const Textarea = React.forwardRef<
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-solid-b bg-invert-bg px-3 py-2 text-sm ring-offset-level-0 placeholder:text-tertiary focus-visible:outline-hidden focus-visible:ring focus-visible:ring-active-b disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
ref={ref}
{...props}

View File

@@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<
ref={ref}
className={cn(
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
className,
)}
{...props}
/>
@@ -35,7 +35,7 @@ const toastVariants = cva(
defaultVariants: {
variant: "default",
},
}
},
);
const Toast = React.forwardRef<
@@ -61,7 +61,7 @@ const ToastAction = React.forwardRef<
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-invert focus:outline-hidden focus:ring-1 focus:ring-active-b disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-solid-b/40 hover:group-[.destructive]:border-danger-b/30 hover:group-[.destructive]:bg-danger-bg hover:group-[.destructive]:text-danger focus:group-[.destructive]:ring-danger-b",
className
className,
)}
{...props}
/>
@@ -76,7 +76,7 @@ const ToastClose = React.forwardRef<
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-primary/50 opacity-0 transition-opacity hover:text-primary focus:opacity-100 focus:outline-hidden focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
className
className,
)}
toast-close=""
{...props}

View File

@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-invert animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>

View File

@@ -33,11 +33,11 @@ export default function OrganizationSelectionPage() {
const navigate = useNavigate();
const data = useLazyLoadQuery<OrganizationSelectionPageQuery>(
organizationSelectionQuery,
{}
{},
);
const organizations = data.viewer.organizations.edges.map(
(edge) => edge.node
(edge) => edge.node,
);
useEffect(() => {

View File

@@ -3,7 +3,14 @@ import { useSearchParams } from "react-router";
import { Helmet } from "react-helmet-async";
import { buildEndpoint } from "../utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -24,12 +31,12 @@ type SigningResponse = {
export default function SigningRequestsPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [signingData, setSigningData] = useState<SigningResponse | null>(null);
const [currentDocIndex, setCurrentDocIndex] = useState(0);
// Fetch documents to sign using the token
useEffect(() => {
if (!token) {
@@ -40,13 +47,16 @@ export default function SigningRequestsPage() {
async function fetchDocuments() {
try {
const response = await fetch(buildEndpoint("/api/console/v1/policies/signing-requests"), {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
const response = await fetch(
buildEndpoint("/api/console/v1/policies/signing-requests"),
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
});
);
if (!response.ok) {
throw new Error("Failed to fetch signing documents");
@@ -54,18 +64,20 @@ export default function SigningRequestsPage() {
const documents: Document[] = await response.json();
// Transform the API response to match our internal structure
const enhancedDocuments = documents.map(doc => ({
const enhancedDocuments = documents.map((doc) => ({
...doc,
signed: false
signed: false,
}));
setSigningData({
documents: enhancedDocuments,
requesterName: "Requester", // Default values as they're not in the API response
requesterOrganization: "Organization"
requesterOrganization: "Organization",
});
} catch (err) {
setError(err instanceof Error ? err.message : "An unknown error occurred");
setError(
err instanceof Error ? err.message : "An unknown error occurred",
);
} finally {
setLoading(false);
}
@@ -77,17 +89,22 @@ export default function SigningRequestsPage() {
// Handle document signing
const handleSignDocument = async () => {
if (!signingData || !token) return;
const docToSign = signingData.documents[currentDocIndex];
try {
const response = await fetch(buildEndpoint(`/api/console/v1/policies/signing-requests/${docToSign.policy_version_id}/sign`), {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
const response = await fetch(
buildEndpoint(
`/api/console/v1/policies/signing-requests/${docToSign.policy_version_id}/sign`,
),
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
});
);
if (!response.ok) {
throw new Error("Failed to sign document");
@@ -124,7 +141,7 @@ export default function SigningRequestsPage() {
// Calculate progress
const getSignedCount = () => {
if (!signingData) return 0;
return signingData.documents.filter(doc => doc.signed).length;
return signingData.documents.filter((doc) => doc.signed).length;
};
const getProgressPercentage = () => {
@@ -139,7 +156,9 @@ export default function SigningRequestsPage() {
<Card className="w-full max-w-3xl">
<CardHeader>
<CardTitle>Loading Signing Requests</CardTitle>
<CardDescription>Please wait while we fetch your documents...</CardDescription>
<CardDescription>
Please wait while we fetch your documents...
</CardDescription>
</CardHeader>
</Card>
</div>
@@ -170,7 +189,9 @@ export default function SigningRequestsPage() {
<Card className="w-full max-w-3xl">
<CardHeader>
<CardTitle>No Documents to Sign</CardTitle>
<CardDescription>There are no documents requiring your signature at this time.</CardDescription>
<CardDescription>
There are no documents requiring your signature at this time.
</CardDescription>
</CardHeader>
</Card>
</div>
@@ -187,24 +208,28 @@ export default function SigningRequestsPage() {
<Helmet>
<title>Document Signing - Probo</title>
</Helmet>
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Document Signing Request</h1>
<p className="text-muted-foreground">
From {signingData.requesterName} at {signingData.requesterOrganization}
From {signingData.requesterName} at{" "}
{signingData.requesterOrganization}
</p>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">
{getSignedCount()} of {signingData.documents.length} documents signed
{getSignedCount()} of {signingData.documents.length} documents
signed
</span>
<span className="text-sm font-medium">
{Math.round(getProgressPercentage())}%
</span>
<span className="text-sm font-medium">{Math.round(getProgressPercentage())}%</span>
</div>
<Progress value={getProgressPercentage()} className="h-2" />
</div>
</div>
<Card className="w-full">
<CardHeader>
<CardTitle>{currentDoc.title}</CardTitle>
@@ -212,31 +237,29 @@ export default function SigningRequestsPage() {
Document {currentDocIndex + 1} of {signingData.documents.length}
</CardDescription>
</CardHeader>
<CardContent>
<div className="border rounded-md p-4 min-h-[400px] bg-muted/20">
<div className="prose prose-olive max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDoc.content}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentDoc.content}
</ReactMarkdown>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
{currentDoc.signed ? (
<div className="flex items-center gap-2">
<div className="text-sm text-green-600 font-medium">✓ Signed</div>
{!isLastDocument && (
<Button onClick={handleNextDocument}>
Next Document
</Button>
<Button onClick={handleNextDocument}>Next Document</Button>
)}
</div>
) : (
<Button onClick={handleSignDocument}>
Sign Document
</Button>
<Button onClick={handleSignDocument}>Sign Document</Button>
)}
{allSigned && (
<div className="text-green-600 font-medium">
All documents have been signed
@@ -246,4 +269,4 @@ export default function SigningRequestsPage() {
</Card>
</div>
);
}
}

View File

@@ -88,7 +88,7 @@ export default function ConfirmEmailPage() {
setError(
error instanceof Error
? error.message
: "Failed to confirm email. Please try again."
: "Failed to confirm email. Please try again.",
);
setIsLoading(false);
}

View File

@@ -79,7 +79,7 @@ export default function ConfirmInvitationPage() {
token: token.trim(),
password: password,
}),
}
},
);
const data = await response.json();
@@ -97,7 +97,7 @@ export default function ConfirmInvitationPage() {
setError(
error instanceof Error
? error.message
: "Failed to confirm invitation. Please try again."
: "Failed to confirm invitation. Please try again.",
);
} finally {
setIsLoading(false);

View File

@@ -34,7 +34,7 @@ export default function ForgotPasswordPage() {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
}
},
);
if (!response.ok) {

View File

@@ -37,7 +37,7 @@ export default function LoginPage() {
},
credentials: "include",
body: JSON.stringify({ email, password }),
}
},
);
if (!response.ok) {

View File

@@ -39,7 +39,7 @@ export default function RegisterPage() {
},
credentials: "include",
body: JSON.stringify({ email, password, fullName }),
}
},
);
if (!response.ok) {

View File

@@ -78,7 +78,7 @@ export default function ResetPasswordPage() {
token: token.trim(),
password: password,
}),
}
},
);
const data = await response.json();
@@ -96,7 +96,7 @@ export default function ResetPasswordPage() {
setError(
error instanceof Error
? error.message
: "Failed to reset password. Please try again."
: "Failed to reset password. Please try again.",
);
} finally {
setIsLoading(false);

View File

@@ -86,11 +86,11 @@ export default function CreateOrganizationView() {
const { toast } = useToast();
const data = useLazyLoadQuery<CreateOrganizationViewViewerQuery>(
viewerQuery,
{}
{},
);
const [createOrganization] =
useMutation<CreateOrganizationViewCreateOrganizationMutation>(
createOrganizationMutation
createOrganizationMutation,
);
const [formData, setFormData] = useState({
name: "",
@@ -114,7 +114,7 @@ export default function CreateOrganizationView() {
connections: [
ConnectionHandler.getConnectionID(
data.viewer.id,
"OrganizationSwitcher_organizations"
"OrganizationSwitcher_organizations",
),
],
},

View File

@@ -31,7 +31,7 @@ const BreadCrumbNavLink = ({
className={cn(
"text-quaternary",
isActive && "text-primary",
className
className,
)}
>
{children}

View File

@@ -60,7 +60,7 @@ const BreadcrumbNavLink = ({
className={cn(
"text-quaternary",
isActive && "text-primary",
className
className,
)}
>
{children}
@@ -98,7 +98,7 @@ function BreadCrumbOrganization() {
}
`,
{ organizationId: organizationId! },
{ fetchPolicy: "store-or-network" }
{ fetchPolicy: "store-or-network" },
);
return (
@@ -146,7 +146,7 @@ function BreadcrumbFrameworkOverview() {
}
}
`,
{ frameworkId: frameworkId! }
{ frameworkId: frameworkId! },
);
return (
@@ -196,7 +196,7 @@ function BreadcrumbVendorOverview() {
}
`,
{ vendorId: vendorId! },
{ fetchPolicy: "store-or-network" }
{ fetchPolicy: "store-or-network" },
);
return (
@@ -246,7 +246,7 @@ function BreadcrumbPeopleOverview() {
}
`,
{ peopleId: peopleId! },
{ fetchPolicy: "store-or-network" }
{ fetchPolicy: "store-or-network" },
);
return (
@@ -296,7 +296,7 @@ function BreadcrumbPolicyOverview() {
}
`,
{ policyId: policyId! },
{ fetchPolicy: "store-or-network" }
{ fetchPolicy: "store-or-network" },
);
return (
@@ -335,7 +335,9 @@ function BreadcrumbMeasureView() {
const data =
useLazyLoadQuery<OrganizationBreadcrumbBreadcrumbMeasureViewQuery>(
graphql`
query OrganizationBreadcrumbBreadcrumbMeasureViewQuery($measureId: ID!) {
query OrganizationBreadcrumbBreadcrumbMeasureViewQuery(
$measureId: ID!
) {
measure: node(id: $measureId) {
id
... on Measure {
@@ -345,7 +347,7 @@ function BreadcrumbMeasureView() {
}
}
`,
{ measureId: measureId! }
{ measureId: measureId! },
);
return (
@@ -385,7 +387,7 @@ function BreadcrumbControl() {
}
`,
{ controlId: controlId! },
{ fetchPolicy: "store-or-network" }
{ fetchPolicy: "store-or-network" },
);
return (
@@ -431,7 +433,7 @@ function BreadcrumbRiskShow() {
}
`,
{ riskId: riskId! },
{ fetchPolicy: "store-or-network" }
{ fetchPolicy: "store-or-network" },
);
return (

View File

@@ -9,10 +9,10 @@ import ErrorBoundary from "@/components/ErrorBoundary";
export default function OrganizationLayout() {
const controlMatch = useMatch(
"organizations/:organizationId/frameworks/:frameworkId/controls/*"
"organizations/:organizationId/frameworks/:frameworkId/controls/*",
);
const frameworkMatch = useMatch(
"organizations/:organizationId/frameworks/:frameworkId"
"organizations/:organizationId/frameworks/:frameworkId",
);
return (
@@ -28,7 +28,7 @@ export default function OrganizationLayout() {
"p-8 h-screen overflow-auto",
controlMatch || frameworkMatch
? "w-full"
: "mx-auto w-2xl md:w-3xl lg:w-5xl xl:w-6xl 2xl:w-7xl"
: "mx-auto w-2xl md:w-3xl lg:w-5xl xl:w-6xl 2xl:w-7xl",
)}
>
<header className="flex shrink-0 items-center gap-2 mb-7">

View File

@@ -54,7 +54,10 @@ export function OrganizationsRoutes() {
<Route path="vendors/:vendorId" element={<VendorPage />} />
<Route path="policies" element={<PolicyListPage />} />
<Route path="policies/:policyId" element={<ShowPolicyPage />} />
<Route path="policies/:policyId/versions/:versionId/edit" element={<EditPolicyPage />} />
<Route
path="policies/:policyId/versions/:versionId/edit"
element={<EditPolicyPage />}
/>
<Route path="risks" element={<ListRiskPage />} />
<Route path="risks/new" element={<NewRiskPage />} />
<Route path="risks/:riskId" element={<ShowRiskView />} />

View File

@@ -42,7 +42,6 @@ import type { SettingsViewRemoveUserMutation as SettingsViewRemoveUserMutationTy
import { PageTemplate } from "@/components/PageTemplate";
import { SettingsViewSkeleton } from "./SettingsPage";
interface AvailableConnector {
id: string;
name: string;
@@ -120,7 +119,8 @@ function SettingsViewContent({
const data = usePreloadedQuery(settingsViewQuery, queryRef);
const organization = data.organization;
const users = organization.users?.edges.map((edge) => edge.node) || [];
const connectors = organization.connectors?.edges.map((edge) => edge.node) || [];
const connectors =
organization.connectors?.edges.map((edge) => edge.node) || [];
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -130,26 +130,36 @@ function SettingsViewContent({
const [inviteFullName, setInviteFullName] = useState("");
const [isInviting, setIsInviting] = useState(false);
const [organizationName, setOrganizationName] = useState(
organization.name || ""
organization.name || "",
);
const [isUploading, setIsUploading] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
// Available connectors
// Available connectors
const availableConnectors: AvailableConnector[] = [
{ id: "github", name: "GitHub", type: "oauth2", description: "Connect to GitHub repositories and issues" },
{ id: "slack", name: "Slack", type: "oauth2", description: "Connect to Slack workspace and channels" },
{
id: "github",
name: "GitHub",
type: "oauth2",
description: "Connect to GitHub repositories and issues",
},
{
id: "slack",
name: "Slack",
type: "oauth2",
description: "Connect to Slack workspace and channels",
},
];
// Filter out connectors that are already connected
const connectedConnectorIds = connectors.map((connector) => connector.name);
const notConnectedConnectors = availableConnectors.filter(
(connector) => !connectedConnectorIds.includes(connector.id)
(connector) => !connectedConnectorIds.includes(connector.id),
);
const [updateOrganization] =
useMutation<SettingsViewUpdateOrganizationMutationType>(
updateOrganizationMutation
updateOrganizationMutation,
);
const [inviteUser] =
@@ -161,7 +171,7 @@ function SettingsViewContent({
const { organizationId } = useParams();
const [, loadQuery] =
useQueryLoader<SettingsViewQueryType>(settingsViewQuery);
const handleUpdateName = () => {
updateOrganization({
variables: {
@@ -490,7 +500,9 @@ function SettingsViewContent({
<div className="space-y-6">
{connectors.length > 0 && (
<div>
<h3 className="mb-4 text-sm font-medium">Connected services</h3>
<h3 className="mb-4 text-sm font-medium">
Connected services
</h3>
<div className="space-y-4">
{connectors.map((connector) => (
<div
@@ -506,7 +518,10 @@ function SettingsViewContent({
{connector.name}
</span>
<span className="text-sm text-tertiary">
{connector.type} · Connected on {new Date(connector.createdAt).toLocaleDateString()}
{connector.type} · Connected on{" "}
{new Date(
connector.createdAt,
).toLocaleDateString()}
</span>
</div>
</div>
@@ -518,7 +533,9 @@ function SettingsViewContent({
{notConnectedConnectors.length > 0 && (
<div>
<h3 className="mb-4 text-sm font-medium">Available services</h3>
<h3 className="mb-4 text-sm font-medium">
Available services
</h3>
<div className="space-y-4">
{notConnectedConnectors.map((connector) => (
<div
@@ -541,11 +558,25 @@ function SettingsViewContent({
<div className="flex items-center gap-2">
<a
href={(() => {
const baseUrl = process.env.API_SERVER_HOST || window.location.origin;
const url = new URL('/api/console/v1/connectors/initiate', baseUrl);
url.searchParams.append('organization_id', organization.id);
url.searchParams.append('connector_id', connector.id);
url.searchParams.append('continue', window.location.href);
const baseUrl =
process.env.API_SERVER_HOST ||
window.location.origin;
const url = new URL(
"/api/console/v1/connectors/initiate",
baseUrl,
);
url.searchParams.append(
"organization_id",
organization.id,
);
url.searchParams.append(
"connector_id",
connector.id,
);
url.searchParams.append(
"continue",
window.location.href,
);
return url.toString();
})()}
className="inline-flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground"
@@ -559,17 +590,21 @@ function SettingsViewContent({
</div>
)}
{connectors.length === 0 && notConnectedConnectors.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="rounded-full bg-subtle-bg p-3">
<Building2 className="h-6 w-6 text-tertiary" />
{connectors.length === 0 &&
notConnectedConnectors.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="rounded-full bg-subtle-bg p-3">
<Building2 className="h-6 w-6 text-tertiary" />
</div>
<h3 className="mt-4 text-lg font-medium">
No integrations available
</h3>
<p className="mt-2 text-sm text-tertiary">
There are currently no integrations available for your
workspace.
</p>
</div>
<h3 className="mt-4 text-lg font-medium">No integrations available</h3>
<p className="mt-2 text-sm text-tertiary">
There are currently no integrations available for your workspace.
</p>
</div>
)}
)}
</div>
</CardContent>
</Card>

View File

@@ -86,7 +86,7 @@ function EditableField({
}
className={cn(
"w-full resize-none",
required && !value && "border-red-500"
required && !value && "border-red-500",
)}
placeholder={`Enter ${label.toLowerCase()}`}
rows={4}
@@ -133,7 +133,7 @@ function EditFrameworkViewContent({
const [commit, isInFlight] =
useMutation<EditFrameworkViewUpdateFrameworkMutation>(
updateFrameworkMutation
updateFrameworkMutation,
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {

View File

@@ -37,7 +37,7 @@ export function FrameworkLayoutViewSkeleton() {
export function FrameworkLayout() {
const match = useMatch(
"organizations/:organizationId/frameworks/:frameworkId/*"
"organizations/:organizationId/frameworks/:frameworkId/*",
);
return (

View File

@@ -75,7 +75,7 @@ function FrameworkLayoutViewContent({
// Setup delete mutation
const [commitDeleteMutation] = useMutation<FrameworkLayoutViewDeleteMutation>(
DeleteFrameworkMutation
DeleteFrameworkMutation,
);
const handleDeleteFramework = useCallback(() => {
@@ -83,7 +83,7 @@ function FrameworkLayoutViewContent({
const connectionId = ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListView_frameworks"
"FrameworkListView_frameworks",
);
commitDeleteMutation({
@@ -189,7 +189,7 @@ function FrameworkLayoutViewContent({
export default function FrameworkLayoutView() {
const { frameworkId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<FrameworkLayoutViewQueryType>(
FrameworkLayoutViewQuery
FrameworkLayoutViewQuery,
);
useEffect(() => {

View File

@@ -34,7 +34,7 @@ export function ControlList(props: ControlListProps) {
const { className, fragmentKey } = props;
const { controls } = useFragment<ControlList_List$key>(
controlListFragment,
fragmentKey
fragmentKey,
);
if (controls.edges.length === 0) {
@@ -45,7 +45,7 @@ export function ControlList(props: ControlListProps) {
<aside
className={cn(
"shrink-0 w-70 border-r h-full overflow-x-hidden overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-level-0 [&::-webkit-scrollbar-thumb]:bg-highlight-bg [&::-webkit-scrollbar-thumb]:rounded-xs",
className
className,
)}
>
{controls.edges.map(({ node: control }, i) => (
@@ -55,7 +55,7 @@ export function ControlList(props: ControlListProps) {
className={({ isActive }) =>
cn(
"block pl-8 pr-4 py-4 border-b hover:bg-h-subtle-bg",
(isActive || (i === 0 && !controlId)) && "bg-subtle-bg"
(isActive || (i === 0 && !controlId)) && "bg-subtle-bg",
)
}
>
@@ -65,7 +65,7 @@ export function ControlList(props: ControlListProps) {
<div
className={cn(
"inline-block font-mono text-sm px-1 py-0.25 rounded-sm bg-highlight-bg border font-semibold",
isActive && "font-bold bg-active-bg border-mid-b"
isActive && "font-bold bg-active-bg border-mid-b",
)}
>
{control.referenceId}
@@ -73,7 +73,7 @@ export function ControlList(props: ControlListProps) {
<div
className={cn(
"text-sm leading-none break-words mt-2",
isActive && "font-medium"
isActive && "font-medium",
)}
>
{control.name.length > maxControlNameLength

View File

@@ -89,7 +89,7 @@ function FrameworkListViewContent({
export default function FrameworkListView() {
const { organizationId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<FrameworkListViewQueryType>(
FrameworkListViewQuery
FrameworkListViewQuery,
);
if (!queryRef) {

View File

@@ -39,7 +39,7 @@ function FrameworkViewContent({
}) {
const data = usePreloadedQuery<FrameworkViewQuery>(
frameworkViewQuery,
queryRef
queryRef,
);
if (!data?.node) {

View File

@@ -40,7 +40,7 @@ export function FrameworkImportDropdown() {
const [loadingFramework, setLoadingFramework] = useState<string | null>(null);
const [commit, isInFlight] =
useMutation<ImportFrameworkDialogImportFrameworkMutation>(
importFrameworkMutation
importFrameworkMutation,
);
const handleImport = async (frameworkId: string) => {
@@ -56,12 +56,12 @@ export function FrameworkImportDropdown() {
`${frameworkId}.json`,
{
type: "application/json",
}
},
);
const connectionId = ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListView_frameworks"
"FrameworkListView_frameworks",
);
commit({

View File

@@ -70,7 +70,7 @@ function EditableField({
}
className={cn(
"w-full resize-none",
required && !value && "border-red-500"
required && !value && "border-red-500",
)}
placeholder={`Enter ${label.toLowerCase()}`}
rows={4}
@@ -102,7 +102,7 @@ function NewFrameworkViewContent() {
const [commit, isInFlight] =
useMutation<NewFrameworkViewCreateFrameworkMutation>(
createFrameworkMutation
createFrameworkMutation,
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
@@ -126,7 +126,7 @@ function NewFrameworkViewContent() {
const connectionId = ConnectionHandler.getConnectionID(
organizationId!,
"FrameworkListPage_frameworks"
"FrameworkListPage_frameworks",
);
commit({
@@ -154,7 +154,7 @@ function NewFrameworkViewContent() {
});
navigate(
`/organizations/${organizationId}/frameworks/${data.createFramework.frameworkEdge.node.id}`
`/organizations/${organizationId}/frameworks/${data.createFramework.frameworkEdge.node.id}`,
);
},
onError(error) {

View File

@@ -233,8 +233,12 @@ export function Control({
const [isUnlinkingPolicy, setIsUnlinkingPolicy] = useState(false);
// Create mutation hooks
const [commitCreateMeasureMapping] = useMutation(createMeasureMappingMutation);
const [commitDeleteMeasureMapping] = useMutation(deleteMeasureMappingMutation);
const [commitCreateMeasureMapping] = useMutation(
createMeasureMappingMutation,
);
const [commitDeleteMeasureMapping] = useMutation(
deleteMeasureMappingMutation,
);
const [commitCreatePolicyMapping] = useMutation(createPolicyMappingMutation);
const [commitDeletePolicyMapping] = useMutation(deletePolicyMappingMutation);
@@ -269,16 +273,20 @@ export function Control({
organizationMeasuresQuery,
{
organizationId,
}
},
).subscribe({
next: (data) => {
setOrganizationMeasuresData(data);
},
complete: () => {
// Fetch linked measures for this control
fetchQuery<ControlLinkedMeasuresQuery>(environment, linkedMeasuresQuery, {
controlId: control.id,
}).subscribe({
fetchQuery<ControlLinkedMeasuresQuery>(
environment,
linkedMeasuresQuery,
{
controlId: control.id,
},
).subscribe({
next: (data) => {
setLinkedMeasuresData(data);
setIsLoadingMeasures(false);
@@ -310,7 +318,7 @@ export function Control({
const getMeasures = useCallback(() => {
if (!organizationMeasuresData?.organization?.measures?.edges) return [];
return organizationMeasuresData.organization.measures.edges.map(
(edge) => edge.node
(edge) => edge.node,
);
}, [organizationMeasuresData]);
@@ -324,7 +332,7 @@ export function Control({
const linkedMeasures = getLinkedMeasures();
return linkedMeasures.some((measure) => measure.id === measureId);
},
[getLinkedMeasures]
[getLinkedMeasures],
);
const getMeasureCategories = useCallback(() => {
@@ -397,7 +405,7 @@ export function Control({
linkedMeasuresQuery,
{
controlId: control.id,
}
},
).subscribe({
next: (data) => {
setLinkedMeasuresData(data);
@@ -423,7 +431,7 @@ export function Control({
},
});
},
[commitCreateMeasureMapping, control.id, environment, toast]
[commitCreateMeasureMapping, control.id, environment, toast],
);
const handleUnlinkMeasure = useCallback(
@@ -458,7 +466,7 @@ export function Control({
linkedMeasuresQuery,
{
controlId: control.id,
}
},
).subscribe({
next: (data) => {
setLinkedMeasuresData(data);
@@ -484,7 +492,7 @@ export function Control({
},
});
},
[commitDeleteMeasureMapping, control.id, environment, toast]
[commitDeleteMeasureMapping, control.id, environment, toast],
);
const handleOpenMeasureMappingDialog = useCallback(() => {
@@ -523,7 +531,7 @@ export function Control({
organizationPoliciesQuery,
{
organizationId,
}
},
).subscribe({
next: (data) => {
setOrganizationPoliciesData(data);
@@ -535,7 +543,7 @@ export function Control({
linkedPoliciesQuery,
{
controlId: control.id,
}
},
).subscribe({
next: (data) => {
setLinkedPoliciesData(data);
@@ -568,7 +576,7 @@ export function Control({
const getPolicies = useCallback(() => {
if (!organizationPoliciesData?.organization?.policies?.edges) return [];
return organizationPoliciesData.organization.policies.edges.map(
(edge) => edge.node
(edge) => edge.node,
);
}, [organizationPoliciesData]);
@@ -582,7 +590,7 @@ export function Control({
const linkedPolicies = getLinkedPolicies();
return linkedPolicies.some((policy) => policy.id === policyId);
},
[getLinkedPolicies]
[getLinkedPolicies],
);
const filteredPolicies = useCallback(() => {
@@ -634,7 +642,7 @@ export function Control({
linkedPoliciesQuery,
{
controlId: control.id,
}
},
).subscribe({
next: (data) => {
setLinkedPoliciesData(data);
@@ -660,7 +668,7 @@ export function Control({
},
});
},
[commitCreatePolicyMapping, control.id, environment, toast]
[commitCreatePolicyMapping, control.id, environment, toast],
);
const handleUnlinkPolicy = useCallback(
@@ -695,7 +703,7 @@ export function Control({
linkedPoliciesQuery,
{
controlId: control.id,
}
},
).subscribe({
next: (data) => {
setLinkedPoliciesData(data);
@@ -721,7 +729,7 @@ export function Control({
},
});
},
[commitDeletePolicyMapping, control.id, environment, toast]
[commitDeletePolicyMapping, control.id, environment, toast],
);
const handleOpenPolicyMappingDialog = useCallback(() => {
@@ -871,7 +879,7 @@ export function Control({
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getStateColor(
measure.state
measure.state,
)} inline-block`}
>
{formatState(measure.state)}
@@ -984,7 +992,7 @@ export function Control({
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getStateColor(
measure.state
measure.state,
)} inline-block`}
>
{formatState(measure.state)}
@@ -1076,7 +1084,9 @@ export function Control({
<thead className="sticky top-0 bg-white">
<tr className="border-b text-left text-sm text-secondary bg-invert-bg">
<th className="py-3 px-4 font-medium">Name</th>
<th className="py-3 px-4 font-medium">Review Date</th>
<th className="py-3 px-4 font-medium">
Review Date
</th>
<th className="py-3 px-4 font-medium text-right">
Actions
</th>
@@ -1103,7 +1113,7 @@ export function Control({
<td className="py-3 px-4">
{policy.updatedAt
? new Date(
policy.updatedAt
policy.updatedAt,
).toLocaleDateString()
: "Not set"}
</td>

View File

@@ -26,7 +26,7 @@ function ControlViewContent({
}) {
const { node } = usePreloadedQuery<ControlViewQuery>(
controlViewQuery,
queryRef
queryRef,
);
return <Control controlKey={node} />;

View File

@@ -58,7 +58,7 @@ function EditMeasureViewContent({
const [formData, setFormData] = useState({
name: measure.name ?? "",
description: measure.description ?? "",
category: measure.category ?? ""
category: measure.category ?? "",
});
const [commit, isInFlight] =
@@ -89,7 +89,7 @@ function EditMeasureViewContent({
id: measureId!,
name: formData.name,
description: formData.description,
category: formData.category
category: formData.category,
},
},
onCompleted(data, errors) {
@@ -154,7 +154,9 @@ function EditMeasureViewContent({
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/measures/${measureId}`)
navigate(
`/organizations/${organizationId}/measures/${measureId}`,
)
}
>
Cancel

View File

@@ -121,7 +121,7 @@ function MeasureListContent({
}) {
const data = usePreloadedQuery<MeasureListViewQueryType>(
measureListViewQuery,
queryRef
queryRef,
) as unknown as OrganizationData;
const { organizationId } = useParams();
@@ -129,14 +129,15 @@ function MeasureListContent({
const [isImporting, setIsImporting] = useState(false);
const { toast } = useToast();
const [importMeasure] =
useMutation<MeasureListViewImportMeasureMutationType>(importMeasureMutation);
const [importMeasure] = useMutation<MeasureListViewImportMeasureMutationType>(
importMeasureMutation,
);
// Monitor URL hash for changes and update state accordingly
const [initialHashCategory] = useState(() =>
window.location.hash.substring(1)
? decodeURIComponent(window.location.hash.substring(1))
: ""
: "",
);
// Keep track of manually expanded categories
@@ -202,10 +203,10 @@ function MeasureListContent({
// Calculate global progress
const implementedCount = processedMeasures.filter(
(measure: Measure) => measure.status === "complete"
(measure: Measure) => measure.status === "complete",
).length;
const notApplicableCount = processedMeasures.filter(
(measure: Measure) => measure.status === "not-applicable"
(measure: Measure) => measure.status === "not-applicable",
).length;
const totalMeasures = processedMeasures.length;
@@ -223,7 +224,7 @@ function MeasureListContent({
}
return acc;
},
{} as Record<string, number>
{} as Record<string, number>,
);
// Group measures by category
@@ -236,7 +237,7 @@ function MeasureListContent({
acc[measure.category].push(measure);
return acc;
},
{} as Record<string, Measure[]>
{} as Record<string, Measure[]>,
);
// Function to toggle a category's expanded state
@@ -253,10 +254,10 @@ function MeasureListContent({
const categories: Category[] = Object.entries(measuresByCategory)
.map(([categoryName, categoryMeasures]: [string, Measure[]]) => {
const catImplementedCount = categoryMeasures.filter(
(measure) => measure.status === "complete"
(measure) => measure.status === "complete",
).length;
const catNotApplicableCount = categoryMeasures.filter(
(measure) => measure.status === "not-applicable"
(measure) => measure.status === "not-applicable",
).length;
// Consider both "complete" and "not-applicable" as done for category progress
const catDoneCount = catImplementedCount + catNotApplicableCount;
@@ -271,7 +272,7 @@ function MeasureListContent({
description: `Measures related to ${categoryName.toLowerCase()}`,
progress: progress,
measures: categoryMeasures.sort((a, b) =>
(a.name || "").localeCompare(b.name || "")
(a.name || "").localeCompare(b.name || ""),
),
doneCount: catDoneCount,
totalCount: catTotalCount,
@@ -308,7 +309,7 @@ function MeasureListContent({
connections: [
ConnectionHandler.getConnectionID(
data.organization.id,
"MeasureListView_measures"
"MeasureListView_measures",
),
],
input: {
@@ -591,28 +592,28 @@ function MeasureListContent({
</p>
<p>
{measure.description.startsWith(
"##"
"##",
)
? measure.description
.split("\n")
.find((line) =>
line.startsWith(
"## Why"
)
"## Why",
),
)
?.replace("## Why?", "")
?.replace("## Why", "")
?.trim() ||
measure.description.split(
"\n"
"\n",
)[1] ||
""
: measure.description.substring(
0,
180
180,
) +
(measure.description.length >
180
(measure.description
.length > 180
? "..."
: "")}
</p>

File diff suppressed because it is too large Load Diff

View File

@@ -36,11 +36,12 @@ export default function NewMeasureView() {
const [formData, setFormData] = useState({
name: "",
description: "",
category: ""
category: "",
});
const [commit, isInFlight] =
useMutation<NewMeasureViewCreateMeasureMutation>(createMeasureMutation);
const [commit, isInFlight] = useMutation<NewMeasureViewCreateMeasureMutation>(
createMeasureMutation,
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
@@ -63,7 +64,7 @@ export default function NewMeasureView() {
const connectionId = ConnectionHandler.getConnectionID(
organizationId!,
"MeasureListView_measures"
"MeasureListView_measures",
);
commit({
@@ -72,7 +73,7 @@ export default function NewMeasureView() {
organizationId: organizationId!,
name: formData.name,
description: formData.description,
category: formData.category
category: formData.category,
},
connections: [connectionId],
},
@@ -92,7 +93,7 @@ export default function NewMeasureView() {
});
navigate(
`/organizations/${organizationId}/measures/${data.createMeasure.measureEdge.node.id}`
`/organizations/${organizationId}/measures/${data.createMeasure.measureEdge.node.id}`,
);
},
onError(error) {

View File

@@ -100,7 +100,7 @@ function NewPeopleViewContent() {
direction: "ASC",
field: "FULL_NAME",
},
}
},
),
ConnectionHandler.getConnectionID(
organizationId!,
@@ -110,7 +110,7 @@ function NewPeopleViewContent() {
direction: "ASC",
field: "FULL_NAME",
},
}
},
),
ConnectionHandler.getConnectionID(
organizationId!,
@@ -120,7 +120,7 @@ function NewPeopleViewContent() {
direction: "ASC",
field: "FULL_NAME",
},
}
},
),
],
input: {
@@ -196,7 +196,7 @@ function NewPeopleViewContent() {
onClick={() => {
const newEmails =
formData.additionalEmailAddresses.filter(
(_, i) => i !== index
(_, i) => i !== index,
);
handleFieldChange("additionalEmailAddresses", newEmails);
}}
@@ -243,7 +243,7 @@ function NewPeopleViewContent() {
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "EMPLOYEE"
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg"
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg",
)}
>
Employee
@@ -255,7 +255,7 @@ function NewPeopleViewContent() {
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "CONTRACTOR"
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg"
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg",
)}
>
Contractor
@@ -269,7 +269,7 @@ function NewPeopleViewContent() {
"rounded-full px-4 py-1 text-sm transition-colors",
formData.kind === "SERVICE_ACCOUNT"
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg"
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg",
)}
>
Service Account

View File

@@ -148,7 +148,7 @@ function PeopleListContent({
}) {
const data = usePreloadedQuery<PeopleListViewQueryType>(
peopleListViewQuery,
queryRef
queryRef,
);
const [, setSearchParams] = useSearchParams();
const [, startTransition] = useTransition();
@@ -217,10 +217,10 @@ function PeopleListContent({
{person?.kind === "EMPLOYEE"
? "Employee"
: person?.kind === "CONTRACTOR"
? "Contractor"
: person?.kind === "SERVICE_ACCOUNT"
? "Service Account"
: "Vendor"}
? "Contractor"
: person?.kind === "SERVICE_ACCOUNT"
? "Service Account"
: "Vendor"}
</Badge>
<Button
variant="ghost"
@@ -230,7 +230,7 @@ function PeopleListContent({
e.preventDefault();
if (
window.confirm(
"Are you sure you want to delete this person?"
"Are you sure you want to delete this person?",
)
) {
deletePeople({

View File

@@ -202,7 +202,7 @@ function PeopleViewContent({
onClick={() => {
const newEmails =
formData.additionalEmailAddresses.filter(
(_, i) => i !== index
(_, i) => i !== index,
);
handleFieldChange("additionalEmailAddresses", newEmails);
}}

View File

@@ -19,7 +19,11 @@ import { PageTemplate } from "@/components/PageTemplate";
import { EditPolicyViewSkeleton } from "./EditPolicyPage";
const editPolicyViewQuery = graphql`
query EditPolicyViewQuery($policyId: ID!, $organizationId: ID!, $policyVersionId: ID!) {
query EditPolicyViewQuery(
$policyId: ID!
$organizationId: ID!
$policyVersionId: ID!
) {
policyVersion: node(id: $policyVersionId) {
id
... on PolicyVersion {
@@ -63,7 +67,7 @@ function EditPolicyViewContent({
const { organizationId, policyId, versionId } = useParams();
const data = usePreloadedQuery<EditPolicyViewQuery>(
editPolicyViewQuery,
queryRef
queryRef,
);
const [content, setContent] = useState(data.policyVersion.content || "");
@@ -140,7 +144,7 @@ function EditPolicyViewContent({
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/policies/${policyId}`
`/organizations/${organizationId}/policies/${policyId}`,
)
}
>
@@ -175,7 +179,7 @@ export default function EditPolicyView() {
return (
<Suspense fallback={<EditPolicyViewSkeleton />}>
<EditPolicyViewContent queryRef={queryRef}/>
<EditPolicyViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -26,7 +26,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/hooks/use-toast";
import { format } from "date-fns";
import type { PolicyListViewQuery, PolicyListViewQuery$data } from "./__generated__/PolicyListViewQuery.graphql";
import type {
PolicyListViewQuery,
PolicyListViewQuery$data,
} from "./__generated__/PolicyListViewQuery.graphql";
import type { PolicyListViewDeleteMutation } from "./__generated__/PolicyListViewDeleteMutation.graphql";
import type { PolicyListViewCreateMutation } from "./__generated__/PolicyListViewCreateMutation.graphql";
import type { PolicyListViewSendSigningNotificationsMutation } from "./__generated__/PolicyListViewSendSigningNotificationsMutation.graphql";
@@ -54,7 +57,8 @@ const policyListViewQuery = graphql`
organization: node(id: $organizationId) {
... on Organization {
...PeopleSelector_organization
policies(first: 50, orderBy: {field: TITLE, direction: ASC}) @connection(key: "PolicyListView_policies") {
policies(first: 50, orderBy: { field: TITLE, direction: ASC })
@connection(key: "PolicyListView_policies") {
edges {
node {
id
@@ -127,7 +131,9 @@ const createPolicyMutation = graphql`
`;
const sendSigningNotificationsMutation = graphql`
mutation PolicyListViewSendSigningNotificationsMutation($input: SendSigningNotificationsInput!) {
mutation PolicyListViewSendSigningNotificationsMutation(
$input: SendSigningNotificationsInput!
) {
sendSigningNotifications(input: $input) {
success
}
@@ -137,26 +143,33 @@ function PolicyTableRow({
policy,
organizationId,
}: {
policy: NonNullable<PolicyListViewQuery$data["organization"]["policies"]>["edges"][0]["node"];
policy: NonNullable<
PolicyListViewQuery$data["organization"]["policies"]
>["edges"][0]["node"];
organizationId: string;
}) {
const navigate = useNavigate();
const { toast } = useToast();
const [deletePolicy, isDeleting] = useMutation<PolicyListViewDeleteMutation>(DeletePolicyMutation);
const [deletePolicy, isDeleting] =
useMutation<PolicyListViewDeleteMutation>(DeletePolicyMutation);
const latestVersion = policy.versions?.edges[0]?.node;
const status = latestVersion?.status || "DRAFT";
// Get signature counts
const signatureEdges = latestVersion?.signatures?.edges || [];
const totalSignatures = signatureEdges.length;
const signedCount = signatureEdges.filter(
edge => edge?.node?.state === "SIGNED"
(edge) => edge?.node?.state === "SIGNED",
).length;
const handleDeletePolicy = (e: React.MouseEvent) => {
e.stopPropagation();
if (window.confirm("Are you sure you want to delete this policy? This action cannot be undone.")) {
if (
window.confirm(
"Are you sure you want to delete this policy? This action cannot be undone.",
)
) {
deletePolicy({
variables: {
input: {
@@ -165,7 +178,7 @@ function PolicyTableRow({
connections: [
ConnectionHandler.getConnectionID(
organizationId,
"PolicyListView_policies"
"PolicyListView_policies",
),
],
},
@@ -202,7 +215,7 @@ function PolicyTableRow({
};
return (
<tr
<tr
className="border-t border-solid-b hover:bg-subtle-bg cursor-pointer"
onClick={() => {
navigate(`/organizations/${organizationId}/policies/${policy.id}`);
@@ -228,7 +241,9 @@ function PolicyTableRow({
</td>
<td className="py-4 px-6">
{status === "PUBLISHED" ? (
<span className="text-sm text-primary">{signedCount} / {totalSignatures}</span>
<span className="text-sm text-primary">
{signedCount} / {totalSignatures}
</span>
) : (
<span className="text-sm text-tertiary">-</span>
)}
@@ -236,8 +251,8 @@ function PolicyTableRow({
<td className="py-4 px-6">
<Badge
className={`font-medium border-0 py-0 px-[6px] h-5 text-xs rounded-md ${
status === "PUBLISHED"
? "bg-success-bg text-success"
status === "PUBLISHED"
? "bg-success-bg text-success"
: "bg-secondary-bg text-tertiary"
}`}
>
@@ -292,7 +307,8 @@ function CreatePolicyModal({
const [content, setContent] = useState("");
const [ownerId, setOwnerId] = useState<string | null>(null);
const [createPolicy, isCreating] = useMutation<PolicyListViewCreateMutation>(createPolicyMutation);
const [createPolicy, isCreating] =
useMutation<PolicyListViewCreateMutation>(createPolicyMutation);
// Reset form fields
const resetForm = () => {
@@ -319,7 +335,7 @@ function CreatePolicyModal({
});
return;
}
const input = {
organizationId,
title,
@@ -334,7 +350,7 @@ function CreatePolicyModal({
ConnectionHandler.getConnectionID(
organizationId,
"PolicyListView_policies",
{orderBy: {field: "TITLE", direction: "ASC"}}
{ orderBy: { field: "TITLE", direction: "ASC" } },
),
],
},
@@ -379,7 +395,10 @@ function CreatePolicyModal({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[1080px] h-[700px] max-h-[700px] p-0 gap-0 flex flex-col">
<DialogTitle className="sr-only">Create new policy</DialogTitle>
<DialogDescription className="sr-only">Form to create a new policy with title, content, and owner information.</DialogDescription>
<DialogDescription className="sr-only">
Form to create a new policy with title, content, and owner
information.
</DialogDescription>
<div className="flex justify-between items-center py-2 px-4 border-b border-solid-b h-[40px]">
<div className="flex items-center gap-1 text-sm">
<span className="text-tertiary">Policies</span>
@@ -391,23 +410,23 @@ function CreatePolicyModal({
<div className="flex flex-1 overflow-hidden">
<div className="flex-1 p-6 overflow-y-auto">
<div className="mb-6">
<h1
className={`text-4xl leading-tight font-bold outline-none focus:outline-none ${!title ? 'text-gray-400' : 'text-black'}`}
<h1
className={`text-4xl leading-tight font-bold outline-none focus:outline-none ${!title ? "text-gray-400" : "text-black"}`}
contentEditable
suppressContentEditableWarning
onInput={(e) => {
const newText = e.currentTarget.textContent || "";
setTitle(newText);
}}
style={{ WebkitTapHighlightColor: 'transparent' }}
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={(e) => {
if (!title) {
e.currentTarget.textContent = '';
e.currentTarget.textContent = "";
}
}}
onFocus={(e) => {
if (e.currentTarget.textContent === "Enter policy title...") {
e.currentTarget.textContent = '';
e.currentTarget.textContent = "";
}
}}
onBlur={(e) => {
@@ -421,8 +440,7 @@ function CreatePolicyModal({
el.textContent = title || "Enter policy title...";
}
}}
>
</h1>
></h1>
</div>
<Textarea
placeholder="This Privacy Policy outlines how NovaSoft collects, uses, and protects personal information provided by users of its services. By accessing or using our platform, you agree to the collection and use of information in accordance with this policy..."
@@ -433,15 +451,17 @@ function CreatePolicyModal({
</div>
<div className="w-[420px] bg-[rgba(5,77,5,0.03)] p-6 flex flex-col gap-4">
<h3 className="font-medium text-base">Properties</h3>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center py-2 border-t border-[rgba(2,42,2,0.08)]">
<span className="text-sm font-medium text-tertiary">Status</span>
<span className="text-sm font-medium text-tertiary">
Status
</span>
<div className="bg-[rgba(0,39,0,0.05)] py-1.5 px-2 rounded-lg">
<span className="text-sm font-medium">Draft</span>
</div>
</div>
<div className="flex justify-between items-center py-2 border-t border-[rgba(2,42,2,0.08)]">
<span className="text-sm font-medium text-tertiary">Owner</span>
<div className="relative">
@@ -453,10 +473,16 @@ function CreatePolicyModal({
/>
</div>
</div>
<div className="flex justify-between items-center py-2 border-t border-[rgba(2,42,2,0.08)]">
<span className="text-sm font-medium text-tertiary">Review date</span>
<Button size="icon" variant="outline" className="h-8 w-8 rounded-full bg-[rgba(0,39,0,0.05)] border-none">
<span className="text-sm font-medium text-tertiary">
Review date
</span>
<Button
size="icon"
variant="outline"
className="h-8 w-8 rounded-full bg-[rgba(0,39,0,0.05)] border-none"
>
<Plus className="h-4 w-4" />
</Button>
</div>
@@ -492,16 +518,19 @@ function PolicyListViewContent({
}) {
const data = usePreloadedQuery<PolicyListViewQuery>(
policyListViewQuery,
queryRef
queryRef,
);
const { organizationId } = useParams();
const policies =
data.organization?.policies?.edges.map((edge) => edge?.node) ?? [];
const [createModalOpen, setCreateModalOpen] = useState(false);
const [confirmSigningModalOpen, setConfirmSigningModalOpen] = useState(false);
const [sendSigningNotifications, isSendingNotifications] = useMutation<PolicyListViewSendSigningNotificationsMutation>(sendSigningNotificationsMutation);
const [sendSigningNotifications, isSendingNotifications] =
useMutation<PolicyListViewSendSigningNotificationsMutation>(
sendSigningNotificationsMutation,
);
const { toast } = useToast();
const handleOpenModal = () => {
@@ -524,7 +553,8 @@ function PolicyListViewContent({
console.error("Error sending signing notifications:", errors);
toast({
title: "Error",
description: "Failed to send signing notifications. Please try again.",
description:
"Failed to send signing notifications. Please try again.",
variant: "destructive",
});
return;
@@ -540,7 +570,8 @@ function PolicyListViewContent({
console.error("Error sending signing notifications:", error);
toast({
title: "Error",
description: "Failed to send signing notifications. Please try again.",
description:
"Failed to send signing notifications. Please try again.",
variant: "destructive",
});
},
@@ -635,13 +666,17 @@ function PolicyListViewContent({
organizationId={organizationId!}
organizationRef={data.organization}
/>
{/* Confirmation Modal for Sending Signing Notifications */}
<Dialog open={confirmSigningModalOpen} onOpenChange={setConfirmSigningModalOpen}>
<Dialog
open={confirmSigningModalOpen}
onOpenChange={setConfirmSigningModalOpen}
>
<DialogContent className="sm:max-w-[425px]">
<DialogTitle>Send Signing Notifications</DialogTitle>
<DialogDescription>
This will send signing notifications to all users who have pending policies to sign. Are you sure you want to continue?
This will send signing notifications to all users who have pending
policies to sign. Are you sure you want to continue?
</DialogDescription>
<DialogFooter className="gap-2 mt-4">
<Button

View File

@@ -7,7 +7,15 @@ import {
useQueryLoader,
useMutation,
} from "react-relay";
import { Clock, Download, Edit, Trash2, MoreHorizontal, X, FileSignature } from "lucide-react";
import {
Clock,
Download,
Edit,
Trash2,
MoreHorizontal,
X,
FileSignature,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { PageTemplate } from "@/components/PageTemplate";
@@ -15,7 +23,7 @@ import type { ShowPolicyViewQuery } from "./__generated__/ShowPolicyViewQuery.gr
import { ShowPolicyViewPublishMutation } from "./__generated__/ShowPolicyViewPublishMutation.graphql";
import { ShowPolicyViewCreateDraftMutation } from "./__generated__/ShowPolicyViewCreateDraftMutation.graphql";
import ReactMarkdown from "react-markdown";
import remarkGfm from 'remark-gfm'
import remarkGfm from "remark-gfm";
import { ShowPolicyViewSkeleton } from "./ShowPolicyPage";
import { format } from "date-fns";
import {
@@ -96,7 +104,9 @@ const publishPolicyVersionMutation = graphql`
`;
const createDraftPolicyVersionMutation = graphql`
mutation ShowPolicyViewCreateDraftMutation($input: CreateDraftPolicyVersionInput!) {
mutation ShowPolicyViewCreateDraftMutation(
$input: CreateDraftPolicyVersionInput!
) {
createDraftPolicyVersion(input: $input) {
policyVersionEdge {
node {
@@ -114,29 +124,36 @@ function ShowPolicyContent({
}: {
queryRef: PreloadedQuery<ShowPolicyViewQuery>;
}) {
const data = usePreloadedQuery<ShowPolicyViewQuery>(policyViewQuery, queryRef);
const data = usePreloadedQuery<ShowPolicyViewQuery>(
policyViewQuery,
queryRef,
);
const policy = data.node;
const { organizationId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [queryRef2, loadQuery] = useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
const [queryRef2, loadQuery] =
useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
const [isSignaturesModalOpen, setIsSignaturesModalOpen] = useState(false);
const [publishDraft, isPublishInFlight] = useMutation<ShowPolicyViewPublishMutation>(publishPolicyVersionMutation);
const [createDraft, isCreateDraftInFlight] = useMutation<ShowPolicyViewCreateDraftMutation>(createDraftPolicyVersionMutation);
const [publishDraft, isPublishInFlight] =
useMutation<ShowPolicyViewPublishMutation>(publishPolicyVersionMutation);
const [createDraft, isCreateDraftInFlight] =
useMutation<ShowPolicyViewCreateDraftMutation>(
createDraftPolicyVersionMutation,
);
const latestVersionEdge = policy.latestVersion?.edges[0];
const latestVersionNode = latestVersionEdge?.node;
const isDraft = latestVersionNode?.status === "DRAFT";
useEffect(() => {
// No need to update selectedVersion state since we're using the VersionHistoryModal component
}, []);
// Handle delete policy
const handleDeletePolicy = useCallback(() => {
@@ -157,32 +174,32 @@ function ShowPolicyContent({
navigate(`/organizations/${organizationId}/policies`);
}, 1000);
}, [toast, navigate, organizationId]);
// Navigate to publish flow
const handlePublish = useCallback(() => {
if (!policy.id) return;
publishDraft({
variables: {
input: {
policyId: policy.id
}
policyId: policy.id,
},
},
onCompleted: (_, errors) => {
if (errors) {
toast({
title: "Error publishing policy",
description: errors[0]?.message || "An unknown error occurred",
variant: "destructive"
variant: "destructive",
});
return;
}
toast({
title: "Policy published",
description: `The policy has been published successfully`,
});
// Reload the query to refresh the data
loadQuery({ policyId: policy.id });
},
@@ -190,9 +207,9 @@ function ShowPolicyContent({
toast({
title: "Error publishing policy",
description: error.message || "An unknown error occurred",
variant: "destructive"
variant: "destructive",
});
}
},
});
}, [policy.id, publishDraft, toast, loadQuery]);
@@ -202,18 +219,21 @@ function ShowPolicyContent({
}, []);
// Restore version
const handleRestoreVersion = useCallback((versionNumber: number) => {
// Here you would implement the logic to restore a version
toast({
title: "Version restored",
description: `Version ${versionNumber} has been restored`,
});
setIsVersionHistoryOpen(false);
// Reload the query to refresh the data
if (policy.id) {
loadQuery({ policyId: policy.id });
}
}, [policy.id, loadQuery, toast]);
const handleRestoreVersion = useCallback(
(versionNumber: number) => {
// Here you would implement the logic to restore a version
toast({
title: "Version restored",
description: `Version ${versionNumber} has been restored`,
});
setIsVersionHistoryOpen(false);
// Reload the query to refresh the data
if (policy.id) {
loadQuery({ policyId: policy.id });
}
},
[policy.id, loadQuery, toast],
);
// Handle edit policy
const handleEditPolicy = useCallback(() => {
@@ -224,35 +244,47 @@ function ShowPolicyContent({
createDraft({
variables: {
input: {
policyID: policy.id
}
policyID: policy.id,
},
},
onCompleted: (response, errors) => {
if (errors) {
toast({
title: "Error creating draft",
description: errors[0]?.message || "An unknown error occurred",
variant: "destructive"
variant: "destructive",
});
return;
}
const newDraftId = response.createDraftPolicyVersion.policyVersionEdge.node.id;
navigate(`/organizations/${organizationId}/policies/${policy.id}/versions/${newDraftId}/edit`);
const newDraftId =
response.createDraftPolicyVersion.policyVersionEdge.node.id;
navigate(
`/organizations/${organizationId}/policies/${policy.id}/versions/${newDraftId}/edit`,
);
},
onError: (error) => {
toast({
title: "Error creating draft",
description: error.message || "An unknown error occurred",
variant: "destructive"
variant: "destructive",
});
}
},
});
} else {
// Navigate directly to edit if it's already a draft
navigate(`/organizations/${organizationId}/policies/${policy.id}/versions/${latestVersionNode?.id}/edit`);
navigate(
`/organizations/${organizationId}/policies/${policy.id}/versions/${latestVersionNode?.id}/edit`,
);
}
}, [policy.id, latestVersionNode, createDraft, navigate, organizationId, toast]);
}, [
policy.id,
latestVersionNode,
createDraft,
navigate,
organizationId,
toast,
]);
// Format date helper
const formatDate = (dateString?: string) => {
@@ -265,7 +297,7 @@ function ShowPolicyContent({
<PageTemplate
title={policy.title!}
actions={
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -285,7 +317,7 @@ function ShowPolicyContent({
<FileSignature className="h-4 w-4" />
<span className="font-medium">Signature history</span>
</Button>
{isDraft && (
<Button
variant="default"
@@ -297,7 +329,7 @@ function ShowPolicyContent({
<span className="font-medium">Publish version</span>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -312,10 +344,12 @@ function ShowPolicyContent({
<DropdownMenuItem asChild>
<div onClick={handleEditPolicy}>
<Edit className="mr-2 h-4 w-4" />
{latestVersionNode?.status === "PUBLISHED" ? "Create new draft" : "Edit draft policy"}
{latestVersionNode?.status === "PUBLISHED"
? "Create new draft"
: "Edit draft policy"}
</div>
</DropdownMenuItem>
<DropdownMenuItem
<DropdownMenuItem
onClick={handleDeletePolicy}
className="text-danger focus:text-danger focus:bg-danger-bg"
>
@@ -326,42 +360,44 @@ function ShowPolicyContent({
</DropdownMenu>
</div>
}
>
<div className="space-y-4">
{latestVersionNode ? (
<div className="bg-white rounded-lg border border-solid-b shadow-sm p-6">
{latestVersionNode.changelog && (
<div className="mb-6 p-4 bg-level-1 rounded-md border border-solid-b">
<div className="text-xs text-tertiary uppercase font-medium mb-1">Change summary</div>
<div className="text-sm text-secondary">
{latestVersionNode.changelog}
</div>
>
<div className="space-y-4">
{latestVersionNode ? (
<div className="bg-white rounded-lg border border-solid-b shadow-sm p-6">
{latestVersionNode.changelog && (
<div className="mb-6 p-4 bg-level-1 rounded-md border border-solid-b">
<div className="text-xs text-tertiary uppercase font-medium mb-1">
Change summary
</div>
)}
<div className="prose prose-olive max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{latestVersionNode.content || "No content available"}
</ReactMarkdown>
</div>
<div className="mt-8 pt-4 border-t border-solid-b text-xs text-tertiary flex justify-between items-center">
<div>
{latestVersionNode.status === "PUBLISHED"
? `Published on ${formatDate(latestVersionNode.publishedAt || "")}${latestVersionNode.publishedBy ? ` by ${latestVersionNode.publishedBy.fullName}` : ''}`
: `Last modified on ${formatDate(latestVersionNode.updatedAt)} by ${policy.owner?.fullName || 'Unknown'}`}
<div className="text-sm text-secondary">
{latestVersionNode.changelog}
</div>
</div>
)}
<div className="prose prose-olive max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{latestVersionNode.content || "No content available"}
</ReactMarkdown>
</div>
) : (
<div className="bg-white rounded-lg border border-solid-b shadow-sm p-6 text-center text-tertiary">
No content available
<div className="mt-8 pt-4 border-t border-solid-b text-xs text-tertiary flex justify-between items-center">
<div>
{latestVersionNode.status === "PUBLISHED"
? `Published on ${formatDate(latestVersionNode.publishedAt || "")}${latestVersionNode.publishedBy ? ` by ${latestVersionNode.publishedBy.fullName}` : ""}`
: `Last modified on ${formatDate(latestVersionNode.updatedAt)} by ${policy.owner?.fullName || "Unknown"}`}
</div>
</div>
)}
</div>
</div>
) : (
<div className="bg-white rounded-lg border border-solid-b shadow-sm p-6 text-center text-tertiary">
No content available
</div>
)}
</div>
{/* Signatures Modal */}
<SignaturesModal
<SignaturesModal
isOpen={isSignaturesModalOpen}
onClose={() => setIsSignaturesModalOpen(false)}
policyRef={policy}
@@ -382,7 +418,8 @@ function ShowPolicyContent({
<DialogHeader>
<DialogTitle>Delete Policy</DialogTitle>
<DialogDescription>
Are you sure you want to delete the policy "{policy.title}"? This action cannot be undone.
Are you sure you want to delete the policy "{policy.title}"? This
action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -408,7 +445,8 @@ function ShowPolicyContent({
}
export default function ShowPolicyView() {
const [queryRef, loadQuery] = useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
const [queryRef, loadQuery] =
useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
const { policyId } = useParams();
useEffect(() => {

View File

@@ -27,7 +27,10 @@ export function SignaturesList({ signatures }: SignaturesListProps) {
return (
<div className="space-y-4">
{signatures.map((signature) => (
<div key={signature.id} className="bg-white rounded-lg border border-solid-b shadow-sm p-6">
<div
key={signature.id}
className="bg-white rounded-lg border border-solid-b shadow-sm p-6"
>
<div className="flex items-center gap-3 mb-4">
<Avatar className="h-10 w-10">
<AvatarImage src="" alt={signature.signedBy.fullName} />
@@ -44,15 +47,17 @@ export function SignaturesList({ signatures }: SignaturesListProps) {
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">
<span className="text-muted-foreground">Status:</span>{" "}
<span className={`px-2 py-0.5 rounded-full ${
signature.state === "SIGNED"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}>
<span
className={`px-2 py-0.5 rounded-full ${
signature.state === "SIGNED"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{signature.state}
</span>
</div>
@@ -71,4 +76,4 @@ export function SignaturesList({ signatures }: SignaturesListProps) {
))}
</div>
);
}
}

View File

@@ -3,8 +3,18 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import { graphql, useFragment, useMutation, useQueryLoader, usePreloadedQuery, ConnectionHandler } from "react-relay";
import type { SignaturesModal_policyVersions$data, SignaturesModal_policyVersions$key } from "./__generated__/SignaturesModal_policyVersions.graphql";
import {
graphql,
useFragment,
useMutation,
useQueryLoader,
usePreloadedQuery,
ConnectionHandler,
} from "react-relay";
import type {
SignaturesModal_policyVersions$data,
SignaturesModal_policyVersions$key,
} from "./__generated__/SignaturesModal_policyVersions.graphql";
import type { SignaturesModalRequestSignatureMutation } from "./__generated__/SignaturesModalRequestSignatureMutation.graphql";
import type { SignaturesModalOrganizationQuery } from "./__generated__/SignaturesModalOrganizationQuery.graphql";
import { useToast } from "@/hooks/use-toast";
@@ -26,7 +36,8 @@ export const policyVersionsFragment = graphql`
publishedBy {
fullName
}
signatures(first: 100) @connection(key: "SignaturesModal_policyVersions_signatures") {
signatures(first: 100)
@connection(key: "SignaturesModal_policyVersions_signatures") {
edges {
node {
id
@@ -50,7 +61,10 @@ export const policyVersionsFragment = graphql`
`;
const requestSignatureMutation = graphql`
mutation SignaturesModalRequestSignatureMutation($input: RequestSignatureInput!, $connections: [ID!]!) {
mutation SignaturesModalRequestSignatureMutation(
$input: RequestSignatureInput!
$connections: [ID!]!
) {
requestSignature(input: $input) {
policyVersionSignatureEdge @prependEdge(connections: $connections) {
node {
@@ -100,54 +114,66 @@ interface SignaturesModalProps {
} | null;
}
export function SignaturesModal({
isOpen,
onClose,
export function SignaturesModal({
isOpen,
onClose,
policyRef,
owner,
}: SignaturesModalProps) {
const data = useFragment<SignaturesModal_policyVersions$key>(
policyVersionsFragment,
policyRef
);
const versionNodes = (data?.policyVersions?.edges?.map(edge => edge.node) || []);
const publishedVersions = versionNodes
.filter(v => v.status === "PUBLISHED")
.sort((a, b) => b.version - a.version);
const [selectedVersion, setSelectedVersion] = useState<number>(
publishedVersions[0]?.version || 0
policyRef,
);
const versionNodes =
data?.policyVersions?.edges?.map((edge) => edge.node) || [];
const publishedVersions = versionNodes
.filter((v) => v.status === "PUBLISHED")
.sort((a, b) => b.version - a.version);
const [selectedVersion, setSelectedVersion] = useState<number>(
publishedVersions[0]?.version || 0,
);
const selectedVersionData = versionNodes.find(
(v) => v.version === selectedVersion,
);
const selectedVersionData = versionNodes.find(v => v.version === selectedVersion);
const { organizationId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<SignaturesModalOrganizationQuery>(organizationQuery);
const [queryRef, loadQuery] =
useQueryLoader<SignaturesModalOrganizationQuery>(organizationQuery);
const { toast } = useToast();
const [isRequesting, setIsRequesting] = useState<string | null>(null);
const [commitRequestSignature] = useMutation<SignaturesModalRequestSignatureMutation>(requestSignatureMutation);
const [commitRequestSignature] =
useMutation<SignaturesModalRequestSignatureMutation>(
requestSignatureMutation,
);
useEffect(() => {
if (isOpen && organizationId && selectedVersionData?.status === "PUBLISHED") {
if (
isOpen &&
organizationId &&
selectedVersionData?.status === "PUBLISHED"
) {
loadQuery({ organizationId });
}
}, [isOpen, organizationId, loadQuery, selectedVersionData]);
const formatDateTime = (dateString?: string | null) => {
if (!dateString) return "N/A";
const date = new Date(dateString);
return format(date, "h:mm a • MMM d, yyyy");
};
const signatures = selectedVersionData?.signatures?.edges?.map(edge => edge.node) || [];
const signatures =
selectedVersionData?.signatures?.edges?.map((edge) => edge.node) || [];
const handleRequestSignature = (personId: string) => {
if (!selectedVersionData) return;
setIsRequesting(personId);
commitRequestSignature({
variables: {
input: {
@@ -157,7 +183,7 @@ export function SignaturesModal({
connections: [
ConnectionHandler.getConnectionID(
selectedVersionData.id,
"SignaturesModal_policyVersions_signatures"
"SignaturesModal_policyVersions_signatures",
),
],
},
@@ -188,27 +214,44 @@ export function SignaturesModal({
<h2 className="text-lg font-semibold p-6 pb-4">Version history</h2>
<div className="overflow-y-auto">
{versionNodes.length > 0 ? (
versionNodes.map(version => {
versionNodes.map((version) => {
const isPublished = version.status === "PUBLISHED";
const isSelected = selectedVersion === version.version;
return (
<div
<div
key={version.id}
className={`flex items-center gap-3 p-6 py-4 ${
isPublished ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
} ${isSelected ? 'bg-slate-50' : ''}`}
onClick={() => isPublished && setSelectedVersion(version.version)}
isPublished
? "cursor-pointer"
: "cursor-not-allowed opacity-50"
} ${isSelected ? "bg-slate-50" : ""}`}
onClick={() =>
isPublished && setSelectedVersion(version.version)
}
>
<Avatar className="h-10 w-10">
<AvatarImage src="" alt={version.publishedBy?.fullName || owner?.fullName || ""} />
<AvatarImage
src=""
alt={
version.publishedBy?.fullName ||
owner?.fullName ||
""
}
/>
<AvatarFallback>
{(version.publishedBy?.fullName || owner?.fullName || "").charAt(0)}
{(
version.publishedBy?.fullName ||
owner?.fullName ||
""
).charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">Version {version.version}</span>
<span className="font-medium">
Version {version.version}
</span>
{isPublished ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-800">
Published
@@ -220,7 +263,13 @@ export function SignaturesModal({
)}
</div>
<div className="text-sm text-tertiary">
{version.publishedBy?.fullName || owner?.fullName || "Unknown"} • {formatDateTime(version.publishedAt || version.updatedAt)}
{version.publishedBy?.fullName ||
owner?.fullName ||
"Unknown"}{" "}
•{" "}
{formatDateTime(
version.publishedAt || version.updatedAt,
)}
</div>
</div>
</div>
@@ -233,27 +282,30 @@ export function SignaturesModal({
)}
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Section header */}
<div className="p-6 border-b border-solid-b">
<h2 className="text-2xl font-semibold mb-2">
{selectedVersionData?.status === "PUBLISHED" ? "Signatures & Requests" : "Signatures"}
{selectedVersionData?.status === "PUBLISHED"
? "Signatures & Requests"
: "Signatures"}
</h2>
{selectedVersionData?.status === "PUBLISHED" && (
<p className="text-sm text-muted-foreground">
Click request to ask for a signature from people in your organization
Click request to ask for a signature from people in your
organization
</p>
)}
</div>
{/* Content area with unified people and signatures list */}
<div className="flex-1 overflow-y-auto p-6">
{selectedVersionData ? (
selectedVersionData.status === "PUBLISHED" && queryRef ? (
<PeopleAndSignaturesList
queryRef={queryRef}
<PeopleAndSignaturesList
queryRef={queryRef}
onRequestSignature={handleRequestSignature}
requestingId={isRequesting}
existingSignatures={signatures}
@@ -263,30 +315,40 @@ export function SignaturesModal({
<div className="space-y-4">
{signatures.length > 0 ? (
<div className="divide-y divide-border">
{signatures.map(signature => (
{signatures.map((signature) => (
<div key={signature.id} className="py-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback>
{signature.signedBy?.fullName?.charAt(0) || signature.requestedBy?.fullName?.charAt(0)}
{signature.signedBy?.fullName?.charAt(0) ||
signature.requestedBy?.fullName?.charAt(
0,
)}
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{signature.signedBy?.fullName || signature.requestedBy?.fullName}</div>
<div className="font-medium">
{signature.signedBy?.fullName ||
signature.requestedBy?.fullName}
</div>
<div className="text-sm text-tertiary">
{signature.state === "SIGNED"
? `Signed on ${formatDateTime(signature.signedAt)}`
{signature.state === "SIGNED"
? `Signed on ${formatDateTime(signature.signedAt)}`
: `Requested on ${formatDateTime(signature.requestedAt)}`}
</div>
</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${
signature.state === "SIGNED"
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{signature.state === "SIGNED" ? "Signed" : "Pending"}
<span
className={`text-xs px-2 py-0.5 rounded-full ${
signature.state === "SIGNED"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{signature.state === "SIGNED"
? "Signed"
: "Pending"}
</span>
</div>
</div>
@@ -294,9 +356,13 @@ export function SignaturesModal({
</div>
) : (
<div className="text-center py-8">
<p className="text-tertiary">No signatures available for this version</p>
<p className="text-tertiary">
No signatures available for this version
</p>
{selectedVersionData.status !== "PUBLISHED" && (
<p className="text-sm text-muted-foreground mt-2">Only published versions can have signatures</p>
<p className="text-sm text-muted-foreground mt-2">
Only published versions can have signatures
</p>
)}
</div>
)}
@@ -307,7 +373,7 @@ export function SignaturesModal({
No version selected
</div>
)}
{selectedVersionData?.status === "PUBLISHED" && !queryRef && (
<div className="py-8 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
@@ -317,13 +383,9 @@ export function SignaturesModal({
</div>
</div>
</div>
<div className="flex justify-end p-4 border-t border-solid-b mt-auto">
<Button
variant="outline"
onClick={onClose}
className="mr-2"
>
<Button variant="outline" onClick={onClose} className="mr-2">
Close
</Button>
</div>
@@ -340,27 +402,25 @@ function PeopleAndSignaturesList({
existingSignatures,
formatDateTime,
}: {
queryRef: PreloadedQuery<SignaturesModalOrganizationQuery>,
onRequestSignature: (personId: string) => void,
requestingId: string | null,
existingSignatures: Array<SignaturesModal_policyVersions$data["policyVersions"]["edges"][0]["node"]["signatures"]["edges"][0]["node"]>,
formatDateTime: (date?: string | null) => string,
queryRef: PreloadedQuery<SignaturesModalOrganizationQuery>;
onRequestSignature: (personId: string) => void;
requestingId: string | null;
existingSignatures: Array<
SignaturesModal_policyVersions$data["policyVersions"]["edges"][0]["node"]["signatures"]["edges"][0]["node"]
>;
formatDateTime: (date?: string | null) => string;
}) {
const data = usePreloadedQuery<SignaturesModalOrganizationQuery>(
organizationQuery,
queryRef
queryRef,
);
const people = data?.organization?.peoples?.edges || [];
if (!data?.organization) {
return (
<div className="text-center py-8">
Organization not found
</div>
);
return <div className="text-center py-8">Organization not found</div>;
}
if (people.length === 0) {
return (
<div className="text-center py-8">
@@ -368,30 +428,28 @@ function PeopleAndSignaturesList({
</div>
);
}
// Create lookup for signatures by person ID
const signaturesByPersonId = new Map();
existingSignatures.forEach(sig => {
existingSignatures.forEach((sig) => {
if (sig.signedBy?.id) {
signaturesByPersonId.set(sig.signedBy.id, sig);
}
});
return (
<div className="divide-y divide-border">
{people.map((edge: any) => {
const person = edge.node;
const signature = signaturesByPersonId.get(person.id);
const isRequesting = requestingId === person.id;
return (
<div key={person.id} className="py-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback>
{person.fullName.charAt(0)}
</AvatarFallback>
<AvatarFallback>{person.fullName.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{person.fullName}</div>
@@ -400,12 +458,16 @@ function PeopleAndSignaturesList({
signature.state === "SIGNED" ? (
<div className="flex items-center gap-1 text-green-700">
<CheckCircle2 className="h-3.5 w-3.5" />
<span>Signed on {formatDateTime(signature.signedAt)}</span>
<span>
Signed on {formatDateTime(signature.signedAt)}
</span>
</div>
) : (
<div className="flex items-center gap-1 text-amber-700">
<Clock className="h-3.5 w-3.5" />
<span>Requested on {formatDateTime(signature.requestedAt)}</span>
<span>
Requested on {formatDateTime(signature.requestedAt)}
</span>
</div>
)
) : (
@@ -414,13 +476,15 @@ function PeopleAndSignaturesList({
</div>
</div>
</div>
{signature ? (
<span className={`text-xs px-2 py-0.5 rounded-full ${
signature.state === "SIGNED"
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
signature.state === "SIGNED"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{signature.state === "SIGNED" ? "Signed" : "Pending"}
</span>
) : (
@@ -446,4 +510,4 @@ function PeopleAndSignaturesList({
})}
</div>
);
}
}

View File

@@ -40,26 +40,27 @@ interface VersionHistoryModalProps {
onRestoreVersion?: (versionNumber: number) => void;
}
export function VersionHistoryModal({
isOpen,
onClose,
export function VersionHistoryModal({
isOpen,
onClose,
policyRef,
onRestoreVersion
onRestoreVersion,
}: VersionHistoryModalProps) {
const data = useFragment<VersionHistoryModal_policyVersions$key>(
policyVersionsFragment,
policyRef
policyRef,
);
// Safely access and extract version nodes
const versionNodes = data?.versionHistory?.edges?.map(edge => edge.node) || [];
const versionNodes =
data?.versionHistory?.edges?.map((edge) => edge.node) || [];
// Sort versions by version number (descending)
const versions = [...versionNodes].sort((a, b) => b.version - a.version);
const latestVersion = versions.length > 0 ? versions[0].version : 0;
const [selectedVersion, setSelectedVersion] = useState<number>(latestVersion);
// Format time helper for version history
const formatDateTime = (dateString?: string | null) => {
if (!dateString) return "N/A";
@@ -76,20 +77,33 @@ export function VersionHistoryModal({
<h2 className="text-lg font-semibold p-6 pb-4">Version history</h2>
<div className="overflow-y-auto">
{versions.map((version) => (
<div
<div
key={version.id}
className={`flex items-center gap-3 p-6 py-4 cursor-pointer ${selectedVersion === version.version ? 'bg-slate-50' : ''}`}
className={`flex items-center gap-3 p-6 py-4 cursor-pointer ${selectedVersion === version.version ? "bg-slate-50" : ""}`}
onClick={() => setSelectedVersion(version.version)}
>
<Avatar className="h-10 w-10">
<AvatarImage src="" alt={version.publishedBy?.fullName || data.owner?.fullName || ""} />
<AvatarImage
src=""
alt={
version.publishedBy?.fullName ||
data.owner?.fullName ||
""
}
/>
<AvatarFallback>
{(version.publishedBy?.fullName || data.owner?.fullName || "").charAt(0)}
{(
version.publishedBy?.fullName ||
data.owner?.fullName ||
""
).charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">Version {version.version}</span>
<span className="font-medium">
Version {version.version}
</span>
{version.status === "PUBLISHED" ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-800">
Published
@@ -101,37 +115,38 @@ export function VersionHistoryModal({
)}
</div>
<div className="text-sm text-tertiary">
{version.publishedBy?.fullName || data.owner?.fullName || "Unknown"} • {formatDateTime(version.publishedAt || version.updatedAt)}
{version.publishedBy?.fullName ||
data.owner?.fullName ||
"Unknown"}{" "}
•{" "}
{formatDateTime(version.publishedAt || version.updatedAt)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Content Area */}
<div className="flex-1 p-6 relative overflow-y-auto">
<h2 className="text-2xl font-semibold mb-6">{data.title}</h2>
<div className="prose prose-olive max-w-none">
{selectedVersion && (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{versions.find(v => v.version === selectedVersion)?.content || "No content available"}
{versions.find((v) => v.version === selectedVersion)
?.content || "No content available"}
</ReactMarkdown>
)}
</div>
</div>
</div>
<div className="flex justify-end p-4 border-t border-solid-b mt-auto">
<Button
variant="outline"
onClick={onClose}
className="mr-2"
>
<Button variant="outline" onClick={onClose} className="mr-2">
Cancel
</Button>
{onRestoreVersion && (
<Button
variant="default"
@@ -145,4 +160,4 @@ export function VersionHistoryModal({
</DialogContent>
</Dialog>
);
}
}

View File

@@ -56,7 +56,7 @@ const RISK_CATEGORIES = [
"Reputational",
"Strategic",
"Technology",
"Other"
"Other",
] as const;
const listRiskViewQuery = graphql`
@@ -138,19 +138,21 @@ const deleteRiskMutation = graphql`
// Helper function to convert risk score to risk level
const calculateRiskLevel = (score: number): string => {
if (score >= 15) return "High";
if (score >= 8) return "Medium";
if (score >= 8) return "Medium";
return "Low";
};
// Helper function to get color for risk level that matches the matrix colors
const getRiskLevelColor = (level: string): {backgroundColor: string, color: string} => {
switch(level) {
const getRiskLevelColor = (
level: string,
): { backgroundColor: string; color: string } => {
switch (level) {
case "High":
return {backgroundColor: "#ef4444", color: "#ffffff"}; // red-500
return { backgroundColor: "#ef4444", color: "#ffffff" }; // red-500
case "Medium":
return {backgroundColor: "#fcd34d", color: "#000000"}; // yellow-300
return { backgroundColor: "#fcd34d", color: "#000000" }; // yellow-300
default:
return {backgroundColor: "#22c55e", color: "#ffffff"}; // green-500
return { backgroundColor: "#22c55e", color: "#ffffff" }; // green-500
}
};
@@ -234,7 +236,6 @@ const emptyRiskMatrixColors = {
high: "bg-red-50 text-black",
};
// Risk Matrix Component
function RiskMatrix({
risks,
@@ -278,10 +279,10 @@ function RiskMatrix({
const getCellContent = (impactValue: number, likelihoodValue: number) => {
return risks.filter((risk) => {
const likelihood = isResidual
? risk.residualLikelihood ?? risk.inherentLikelihood
? (risk.residualLikelihood ?? risk.inherentLikelihood)
: risk.inherentLikelihood;
const impact = isResidual
? risk.residualImpact ?? risk.inherentImpact
? (risk.residualImpact ?? risk.inherentImpact)
: risk.inherentImpact;
return likelihood === likelihoodValue && impact === impactValue;
@@ -298,13 +299,37 @@ function RiskMatrix({
// This follows the 5x5 grid with rows representing impact (5 to 1) and columns representing likelihood (1 to 5)
const colorMatrix = [
// Impact 5 (Catastrophic) - first row
[colorSet.low, colorSet.medium, colorSet.high, colorSet.high, colorSet.high],
[
colorSet.low,
colorSet.medium,
colorSet.high,
colorSet.high,
colorSet.high,
],
// Impact 4 (Significant) - second row
[colorSet.low, colorSet.medium, colorSet.medium, colorSet.high, colorSet.high],
[
colorSet.low,
colorSet.medium,
colorSet.medium,
colorSet.high,
colorSet.high,
],
// Impact 3 (Moderate) - third row
[colorSet.low, colorSet.low, colorSet.medium, colorSet.medium, colorSet.high],
[
colorSet.low,
colorSet.low,
colorSet.medium,
colorSet.medium,
colorSet.high,
],
// Impact 2 (Low) - fourth row
[colorSet.low, colorSet.low, colorSet.low, colorSet.medium, colorSet.medium],
[
colorSet.low,
colorSet.low,
colorSet.low,
colorSet.medium,
colorSet.medium,
],
// Impact 1 (Negligible) - fifth row
[colorSet.low, colorSet.low, colorSet.low, colorSet.low, colorSet.low],
];
@@ -373,11 +398,14 @@ function RiskMatrix({
<div className="text-sm pt-1 border-t border-gray-200 mt-1">
<span className="text-muted-foreground">Risk Level:</span>
<span className="font-bold ml-1">
{calculateRiskLevel(impactValue * likelihoodValue)} ({impactValue * likelihoodValue})
{calculateRiskLevel(impactValue * likelihoodValue)} (
{impactValue * likelihoodValue})
</span>
<span
className="ml-2 px-2 py-0.5 text-xs rounded-full font-medium inline-block"
style={getRiskLevelColor(calculateRiskLevel(impactValue * likelihoodValue))}
style={getRiskLevelColor(
calculateRiskLevel(impactValue * likelihoodValue),
)}
>
{calculateRiskLevel(impactValue * likelihoodValue)}
</span>
@@ -393,15 +421,15 @@ function RiskMatrix({
key={risk.id}
className="text-sm border-l-2 pl-2"
style={{
borderColor:
getRiskLevelColor(
calculateRiskLevel(
isResidual
? (risk.residualImpact || risk.inherentImpact) *
(risk.residualLikelihood || risk.inherentLikelihood)
: risk.inherentImpact * risk.inherentLikelihood
)
).backgroundColor
borderColor: getRiskLevelColor(
calculateRiskLevel(
isResidual
? (risk.residualImpact || risk.inherentImpact) *
(risk.residualLikelihood ||
risk.inherentLikelihood)
: risk.inherentImpact * risk.inherentLikelihood,
),
).backgroundColor,
}}
>
<Link
@@ -516,7 +544,9 @@ function ListRiskViewContent({
const [showResidualRisk, setShowResidualRisk] = useState(false);
// State for category filters
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(
new Set(),
);
// Setup delete mutation
const [commitDeleteMutation] =
@@ -540,16 +570,19 @@ function ListRiskViewContent({
const connectionId = risksConnection?.risks?.__id;
// Get unique categories from existing risks
const availableCategories = Array.from(new Set(risks.map(risk => risk.category))).sort();
const availableCategories = Array.from(
new Set(risks.map((risk) => risk.category)),
).sort();
// Filter risks based on selected categories
const filteredRisks = selectedCategories.size === 0
? risks
: risks.filter(risk => selectedCategories.has(risk.category));
const filteredRisks =
selectedCategories.size === 0
? risks
: risks.filter((risk) => selectedCategories.has(risk.category));
// Handle category toggle
const toggleCategory = (category: string) => {
setSelectedCategories(prev => {
setSelectedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
@@ -761,9 +794,16 @@ function ListRiskViewContent({
>
<span
className="px-2 py-0.5 text-xs rounded-full font-medium inline-block"
style={getRiskLevelColor(calculateRiskLevel(risk.inherentLikelihood * risk.inherentImpact))}
style={getRiskLevelColor(
calculateRiskLevel(
risk.inherentLikelihood * risk.inherentImpact,
),
)}
>
{calculateRiskLevel(risk.inherentLikelihood * risk.inherentImpact)} ({risk.inherentLikelihood * risk.inherentImpact})
{calculateRiskLevel(
risk.inherentLikelihood * risk.inherentImpact,
)}{" "}
({risk.inherentLikelihood * risk.inherentImpact})
</span>
</Link>
</td>
@@ -783,9 +823,18 @@ function ListRiskViewContent({
{risk.residualLikelihood && risk.residualImpact ? (
<span
className="px-2 py-0.5 text-xs rounded-full font-medium inline-block"
style={getRiskLevelColor(calculateRiskLevel(risk.residualLikelihood * risk.residualImpact))}
style={getRiskLevelColor(
calculateRiskLevel(
risk.residualLikelihood *
risk.residualImpact,
),
)}
>
{calculateRiskLevel(risk.residualLikelihood * risk.residualImpact)} ({risk.residualLikelihood * risk.residualImpact})
{calculateRiskLevel(
risk.residualLikelihood * risk.residualImpact,
)}{" "}
({risk.residualLikelihood * risk.residualImpact}
)
</span>
) : (
"Not set"

View File

@@ -107,7 +107,7 @@ function NewRiskForm({
const [createRisk, isInFlight] = useMutation(createRiskMutation);
const categories = useMemo(
() => Array.from(new Set(riskTemplates.map(t => t.category))),
() => Array.from(new Set(riskTemplates.map((t) => t.category))),
[riskTemplates],
);
@@ -115,13 +115,13 @@ function NewRiskForm({
() =>
riskTemplates
.map((t, idx) => ({ ...t, originalIndex: idx }))
.filter(t => !selectedCategory || t.category === selectedCategory),
.filter((t) => !selectedCategory || t.category === selectedCategory),
[riskTemplates, selectedCategory],
);
// Handle category selection
const selectCategory = (category: string) => {
setSelectedCategory(prev => {
setSelectedCategory((prev) => {
if (prev !== category) setSelectedTemplate("");
return category;
});
@@ -226,7 +226,7 @@ function NewRiskForm({
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"ListRiskView_risks"
"ListRiskView_risks",
),
],
},
@@ -289,7 +289,9 @@ function NewRiskForm({
categories.map((category) => (
<Button
key={category}
variant={selectedCategory === category ? "default" : "outline"}
variant={
selectedCategory === category ? "default" : "outline"
}
size="sm"
onClick={() => selectCategory(category)}
className="rounded-full"
@@ -309,8 +311,8 @@ function NewRiskForm({
<SelectContent className="max-h-[300px] overflow-y-auto">
<SelectItem value="none">Select a template</SelectItem>
{filteredRisks.map((template) => (
<SelectItem
key={template.originalIndex}
<SelectItem
key={template.originalIndex}
value={template.originalIndex.toString()}
>
{template.name}
@@ -319,7 +321,8 @@ function NewRiskForm({
</SelectContent>
</Select>
<p className="text-sm text-tertiary">
Select a risk template to prefill the form or create a custom risk.
Select a risk template to prefill the form or create a custom
risk.
</p>
</div>
</div>
@@ -360,7 +363,9 @@ function NewRiskForm({
required={true}
/>
{ownerError && (
<p className="text-sm text-destructive mt-1">Please select a risk owner</p>
<p className="text-sm text-destructive mt-1">
Please select a risk owner
</p>
)}
</div>

View File

@@ -211,19 +211,21 @@ const deleteRiskPolicyMappingMutation = graphql`
// Helper function to convert risk score to risk level
const calculateRiskLevel = (score: number): string => {
if (score >= 15) return "High";
if (score >= 8) return "Medium";
if (score >= 8) return "Medium";
return "Low";
};
// Helper function to get color for risk level that matches the matrix colors
const getRiskLevelColor = (level: string): {backgroundColor: string, color: string} => {
switch(level) {
const getRiskLevelColor = (
level: string,
): { backgroundColor: string; color: string } => {
switch (level) {
case "High":
return {backgroundColor: "#ef4444", color: "#ffffff"}; // red-500
return { backgroundColor: "#ef4444", color: "#ffffff" }; // red-500
case "Medium":
return {backgroundColor: "#fcd34d", color: "#000000"}; // yellow-300
return { backgroundColor: "#fcd34d", color: "#000000" }; // yellow-300
default:
return {backgroundColor: "#22c55e", color: "#ffffff"}; // green-500
return { backgroundColor: "#22c55e", color: "#ffffff" }; // green-500
}
};
@@ -245,26 +247,46 @@ function getLikelihoodLabel(likelihood: number): {
if (likelihood === 5)
return {
label: "Frequent (5)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
if (likelihood === 4)
return {
label: "Probable (4)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
if (likelihood === 3)
return {
label: "Occasional (3)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
if (likelihood === 2)
return {
label: "Remote (2)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
return {
label: "Improbable (1)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
}
@@ -275,26 +297,46 @@ function getImpactLabel(impact: number): {
if (impact === 5)
return {
label: "Catastrophic (5)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
if (impact === 4)
return {
label: "Significant (4)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
if (impact === 3)
return {
label: "Moderate (3)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
if (impact === 2)
return {
label: "Low (2)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
return {
label: "Negligible (1)",
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
style: {
backgroundColor: "#fff",
color: "black",
border: "1px solid #e2e8f0",
},
};
}
@@ -307,7 +349,7 @@ function ShowRiskViewContent({
}) {
const data = usePreloadedQuery<ShowRiskViewQuery>(
showRiskViewQuery,
queryRef
queryRef,
);
const { toast } = useToast();
const environment = useRelayEnvironment();
@@ -317,14 +359,14 @@ function ShowRiskViewContent({
const risk = data.node;
const severity = getRiskSeverity(
risk.inherentLikelihood!,
risk.inherentImpact!
risk.inherentImpact!,
);
// Fix typing for measures
const measures = risk.measures?.edges?.map((edge) => edge.node) || [];
const residualSeverity = getRiskSeverity(
risk.residualLikelihood!,
risk.residualImpact!
risk.residualImpact!,
);
// Fix typing for policies
@@ -340,9 +382,9 @@ function ShowRiskViewContent({
const [measureSearchQuery, setMeasureSearchQuery] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [isLoadingMeasures, setIsLoadingMeasures] = useState(false);
const [linkingMeasures, setLinkingMeasures] = useState<Record<string, boolean>>(
{}
);
const [linkingMeasures, setLinkingMeasures] = useState<
Record<string, boolean>
>({});
const [unlinkingMeasures, setUnlinkingMeasures] = useState<
Record<string, boolean>
>({});
@@ -363,21 +405,21 @@ function ShowRiskViewContent({
// Setup mutation hooks
const [createRiskMeasureMapping] =
useMutation<ShowRiskViewCreateRiskMeasureMappingMutation>(
createRiskMeasureMappingMutation
createRiskMeasureMappingMutation,
);
const [deleteRiskMeasureMapping] =
useMutation<ShowRiskViewDeleteRiskMeasureMappingMutation>(
deleteRiskMeasureMappingMutation
deleteRiskMeasureMappingMutation,
);
// Setup policy mutation hooks
const [createRiskPolicyMapping] =
useMutation<ShowRiskViewCreateRiskPolicyMappingMutation>(
createRiskPolicyMappingMutation
createRiskPolicyMappingMutation,
);
const [deleteRiskPolicyMapping] =
useMutation<ShowRiskViewDeleteRiskPolicyMappingMutation>(
deleteRiskPolicyMappingMutation
deleteRiskPolicyMappingMutation,
);
// Clear filters when dialog closes
@@ -411,7 +453,7 @@ function ShowRiskViewContent({
organizationMeasuresQuery,
{
organizationId,
}
},
).subscribe({
next: (data) => {
setOrganizationMeasuresData(data);
@@ -441,7 +483,7 @@ function ShowRiskViewContent({
organizationPoliciesQuery,
{
organizationId,
}
},
).subscribe({
next: (data) => {
setOrganizationPoliciesData(data);
@@ -463,7 +505,7 @@ function ShowRiskViewContent({
const getMeasures = useCallback(() => {
if (!organizationMeasuresData?.organization?.measures?.edges) return [];
return organizationMeasuresData.organization.measures.edges.map(
(edge) => edge.node
(edge) => edge.node,
);
}, [organizationMeasuresData]);
@@ -508,7 +550,7 @@ function ShowRiskViewContent({
const getPolicies = useCallback(() => {
if (!organizationPoliciesData?.organization?.policies?.edges) return [];
return organizationPoliciesData.organization.policies.edges.map(
(edge) => edge.node
(edge) => edge.node,
);
}, [organizationPoliciesData]);
@@ -531,7 +573,7 @@ function ShowRiskViewContent({
NonNullable<
ShowRiskViewOrganizationMeasuresQuery$data["organization"]
>["measures"]
>["edges"][0]["node"]
>["edges"][0]["node"],
) => {
if (!risk.id) return;
@@ -593,7 +635,7 @@ function ShowRiskViewContent({
},
});
},
[risk.id, createRiskMeasureMapping, toast, environment, loadQuery]
[risk.id, createRiskMeasureMapping, toast, environment, loadQuery],
);
// Handle unlinking a measure from this risk
@@ -603,7 +645,7 @@ function ShowRiskViewContent({
NonNullable<
ShowRiskViewOrganizationMeasuresQuery$data["organization"]
>["measures"]
>["edges"][0]["node"]
>["edges"][0]["node"],
) => {
if (!risk.id) return;
@@ -665,7 +707,7 @@ function ShowRiskViewContent({
},
});
},
[risk.id, deleteRiskMeasureMapping, toast, environment, loadQuery]
[risk.id, deleteRiskMeasureMapping, toast, environment, loadQuery],
);
// Handle linking a policy to this risk
@@ -675,7 +717,7 @@ function ShowRiskViewContent({
NonNullable<
ShowRiskViewOrganizationPoliciesQuery$data["organization"]
>["policies"]
>["edges"][0]["node"]
>["edges"][0]["node"],
) => {
if (!risk.id) return;
@@ -737,7 +779,7 @@ function ShowRiskViewContent({
},
});
},
[risk.id, createRiskPolicyMapping, toast, environment, loadQuery]
[risk.id, createRiskPolicyMapping, toast, environment, loadQuery],
);
// Handle unlinking a policy from this risk
@@ -747,7 +789,7 @@ function ShowRiskViewContent({
NonNullable<
ShowRiskViewOrganizationPoliciesQuery$data["organization"]
>["policies"]
>["edges"][0]["node"]
>["edges"][0]["node"],
) => {
if (!risk.id) return;
@@ -809,7 +851,7 @@ function ShowRiskViewContent({
},
});
},
[risk.id, deleteRiskPolicyMapping, toast, environment, loadQuery]
[risk.id, deleteRiskPolicyMapping, toast, environment, loadQuery],
);
return (
@@ -890,7 +932,7 @@ function ShowRiskViewContent({
<span className="w-3 h-3 rounded-full bg-orange-500 mr-2"></span>
Initial Assessment
</h4>
<div className="flex items-center">
<div
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
@@ -900,23 +942,24 @@ function ShowRiskViewContent({
>
{getLikelihoodLabel(risk.inherentLikelihood!).label}
</div>
<span className="mx-4 text-xl">×</span>
<div
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
style={getImpactLabel(risk.inherentImpact!).style}
>
{getImpactLabel(risk.inherentImpact!).label}
</div>
<span className="mx-4 text-xl">=</span>
<div
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
style={severity.style}
>
{severity.level} ({risk.inherentLikelihood! * risk.inherentImpact!})
{severity.level} (
{risk.inherentLikelihood! * risk.inherentImpact!})
</div>
</div>
</div>
@@ -927,7 +970,7 @@ function ShowRiskViewContent({
<span className="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
After Treatment
</h4>
<div className="flex items-center">
<div
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
@@ -937,23 +980,24 @@ function ShowRiskViewContent({
>
{getLikelihoodLabel(risk.residualLikelihood!).label}
</div>
<span className="mx-4 text-xl">×</span>
<div
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
style={getImpactLabel(risk.residualImpact!).style}
>
{getImpactLabel(risk.residualImpact!).label}
</div>
<span className="mx-4 text-xl">=</span>
<div
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
style={residualSeverity.style}
>
{residualSeverity.level} ({risk.residualLikelihood! * risk.residualImpact!})
{residualSeverity.level} (
{risk.residualLikelihood! * risk.residualImpact!})
</div>
</div>
</div>
@@ -964,7 +1008,16 @@ function ShowRiskViewContent({
risk.inherentImpact !== risk.residualImpact) && (
<div className="mt-6 text-center pt-4 border-t">
<p className="text-sm text-slate-600">
Risk severity reduced from <span className="font-semibold">{severity.level} ({risk.inherentLikelihood! * risk.inherentImpact!})</span> to <span className="font-semibold">{residualSeverity.level} ({risk.residualLikelihood! * risk.residualImpact!})</span>
Risk severity reduced from{" "}
<span className="font-semibold">
{severity.level} (
{risk.inherentLikelihood! * risk.inherentImpact!})
</span>{" "}
to{" "}
<span className="font-semibold">
{residualSeverity.level} (
{risk.residualLikelihood! * risk.residualImpact!})
</span>
{risk.treatment === "MITIGATED" &&
risk.measures?.edges &&
risk.measures.edges.length > 0 &&
@@ -987,9 +1040,7 @@ function ShowRiskViewContent({
<CardTitle>Note</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm max-w-none">
{risk.note}
</div>
<div className="prose prose-sm max-w-none">{risk.note}</div>
</CardContent>
</Card>
)}
@@ -1153,7 +1204,10 @@ function ShowRiskViewContent({
</Tabs>
{/* Dialog for linking measures */}
<Dialog open={isMeasureDialogOpen} onOpenChange={setIsMeasureDialogOpen}>
<Dialog
open={isMeasureDialogOpen}
onOpenChange={setIsMeasureDialogOpen}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<div className="flex justify-between items-center">
@@ -1209,10 +1263,11 @@ function ShowRiskViewContent({
{filteredMeasures().map((measure) => {
// For each render, recalculate linked status directly against the current risk data
const isLinked = risk.measures?.edges?.some(
(edge) => edge.node.id === measure.id
(edge) => edge.node.id === measure.id,
);
const isLinking = linkingMeasures[measure.id] || false;
const isUnlinking = unlinkingMeasures[measure.id] || false;
const isUnlinking =
unlinkingMeasures[measure.id] || false;
return (
<div
@@ -1310,7 +1365,7 @@ function ShowRiskViewContent({
{filteredPolicies().map((policy) => {
// For each render, recalculate linked status directly against the current risk data
const isLinked = policies.some(
(riskPolicy) => riskPolicy.id === policy.id
(riskPolicy) => riskPolicy.id === policy.id,
);
const isLinking = linkingPolicies[policy.id] || false;
const isUnlinking = unlinkingPolicies[policy.id] || false;

View File

@@ -75,7 +75,10 @@ const vendorListFragment = graphql`
websiteUrl
createdAt
updatedAt
riskAssessments(first: 1, orderBy: { direction: DESC, field: ASSESSED_AT }) {
riskAssessments(
first: 1
orderBy: { direction: DESC, field: ASSESSED_AT }
) {
edges {
node {
id
@@ -191,7 +194,7 @@ function ListVendorContent({
const { toast } = useToast();
const data = usePreloadedQuery<ListVendorViewQuery>(
listVendorViewQuery,
queryRef
queryRef,
);
const [, setSearchParams] = useSearchParams();
const [, startTransition] = useTransition();
@@ -263,18 +266,18 @@ function ListVendorContent({
// Helper to format date (similar to "Mon, 8 Mar. 2025" in the design)
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
return date.toLocaleDateString("en-US", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
});
};
// Get favicon URL from website URL
const getFaviconUrl = (websiteUrl: string | null | undefined) => {
if (!websiteUrl) return null;
try {
const url = new URL(websiteUrl);
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
@@ -304,14 +307,14 @@ function ListVendorContent({
// Get risk assessment status
const getRiskAssessmentStatus = (vendor: any) => {
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
if (!latestAssessment) {
return {
status: "NEEDED",
dataSensitivity: null,
businessImpact: null,
isExpired: false,
assessedAt: null
assessedAt: null,
};
}
@@ -324,7 +327,7 @@ function ListVendorContent({
dataSensitivity: latestAssessment.dataSensitivity,
businessImpact: latestAssessment.businessImpact,
isExpired,
assessedAt: latestAssessment.assessedAt
assessedAt: latestAssessment.assessedAt,
};
};
@@ -410,12 +413,12 @@ function ListVendorContent({
>
<div className="flex items-center gap-2">
{vendor.websiteUrl && (
<img
src={getFaviconUrl(vendor.websiteUrl) || ''}
alt=""
<img
src={getFaviconUrl(vendor.websiteUrl) || ""}
alt=""
className="w-4 h-4"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.style.display = "none";
}}
/>
)}
@@ -450,7 +453,8 @@ function ListVendorContent({
>
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span className="font-medium">Create new vendor:</span> {searchTerm}
<span className="font-medium">Create new vendor:</span>{" "}
{searchTerm}
</div>
</button>
</div>
@@ -538,7 +542,9 @@ function ListVendorContent({
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8 bg-white">
{(() => {
const faviconUrl = vendor.websiteUrl ? getFaviconUrl(vendor.websiteUrl) : null;
const faviconUrl = vendor.websiteUrl
? getFaviconUrl(vendor.websiteUrl)
: null;
return faviconUrl ? (
<AvatarImage src={faviconUrl} alt={vendor.name} />
) : (
@@ -564,7 +570,9 @@ function ListVendorContent({
Assessment expired
</Badge>
) : (
<Badge className={`${getRiskBadgeStyle(riskStatus.dataSensitivity)} font-medium text-xs px-2 py-1 rounded-md`}>
<Badge
className={`${getRiskBadgeStyle(riskStatus.dataSensitivity)} font-medium text-xs px-2 py-1 rounded-md`}
>
{riskStatus.dataSensitivity || "NONE"}
</Badge>
)}
@@ -579,13 +587,17 @@ function ListVendorContent({
Assessment expired
</Badge>
) : (
<Badge className={`${getRiskBadgeStyle(riskStatus.businessImpact)} font-medium text-xs px-2 py-1 rounded-md`}>
<Badge
className={`${getRiskBadgeStyle(riskStatus.businessImpact)} font-medium text-xs px-2 py-1 rounded-md`}
>
{riskStatus.businessImpact || "LOW"}
</Badge>
)}
</td>
<td className="py-4 px-4">
{riskStatus.assessedAt ? formatDate(riskStatus.assessedAt) : "N/A"}
{riskStatus.assessedAt
? formatDate(riskStatus.assessedAt)
: "N/A"}
</td>
<td className="py-4 px-4 text-right">
<DropdownMenu>
@@ -605,7 +617,7 @@ function ListVendorContent({
e.preventDefault();
if (
window.confirm(
"Are you sure you want to delete this vendor?"
"Are you sure you want to delete this vendor?",
)
) {
deleteVendor({

File diff suppressed because it is too large Load Diff